diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index ad93c14a0..000000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "image": "mcr.microsoft.com/devcontainers/universal:2", - "features": { - } -} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..1056a4c48 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,13 @@ +# Code owners for boring.notch +# Reviewers are automatically requested on PRs that touch owned paths. +# Add active maintainers here as the team grows. + +# Default owner for the whole repo +* @Alexander5015 + +# Area-specific expertise +/boringNotch/MediaControllers/ @Alexander5015 +/boringNotch/components/OSD/ @Alexander5015 +/BoringNotchXPCHelper/ @Alexander5015 +/Shared/ @Alexander5015 +/.github/workflows/ @Alexander5015 diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 4fc5911dc..000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,21 +0,0 @@ - ---- -name: Feature request -about: Suggest an idea for this project -title: "[FEATURE]" -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Additional context** -Add any other context or screenshots about the feature request here. - -**Checks** -- [x] I haven't found any duplicates with my issue. diff --git a/.github/ISSUE_TEMPLATE/old_bug_report.md b/.github/ISSUE_TEMPLATE/old_bug_report.md deleted file mode 100644 index 8f20d78c4..000000000 --- a/.github/ISSUE_TEMPLATE/old_bug_report.md +++ /dev/null @@ -1,31 +0,0 @@ - ---- -name: Bug report -about: Create a report to help us improve -title: "[BUG]" -labels: 'bug,unconfirmed' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots or recordings** -If applicable, add screenshots to help explain your problem. - -**Additional context** -Add any other context about the problem here. Mentioning what version are you using. - -**Checks** -- [x] I haven't found any duplicates with my issue. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0520627e9..17215b370 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,18 +5,20 @@ version: 2 updates: - - package-ecosystem: "github-actions" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: "github-actions" + directory: "/" schedule: interval: "weekly" + target-branch: "dev" - package-ecosystem: "pip" directory: "/Configuration/dmg" schedule: interval: "weekly" - - - package-ecosystem: "swift" - directory: "/" - schedule: - interval: "weekly" target-branch: "dev" + + # NOTE: no "swift" section. SPM packages live in the xcodeproj-embedded + # manifest (boringNotch.xcodeproj/.../swiftpm/Package.resolved), which + # Dependabot's swift ecosystem cannot read (it requires a root-level + # Package.swift). Swift dependencies are bumped manually; see Package.resolved. + diff --git a/.github/PULL_REQUEST.md b/.github/pull_request_template.md similarity index 100% rename from .github/PULL_REQUEST.md rename to .github/pull_request_template.md diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index bb398e7b1..4fdf46fb4 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -45,3 +45,18 @@ jobs: verbosity: xcpretty upload-logs: always configuration: release + + # Unit tests run hosted by the app (TEST_HOST), so they spawn a real + # menubar-app instance on the runner — acceptable on CI where nothing + # else reacts to it; they are skipped on matrix legs without hosted-test + # support if they ever become flaky there. + - name: Test + uses: mxcl/xcodebuild@d3ee9b419c1be9a988086c58fe0988f32d99cfc5 # v3.6.0 + with: + xcode: ${{ matrix.xcode }} + platform: macOS + scheme: boringNotch + action: test + verbosity: xcpretty + upload-logs: always + configuration: debug diff --git a/.gitignore b/.gitignore index 8ad298c04..759956ce3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,13 @@ # Xcode *.xcuserstate *.xcuserdata -*.xcscheme *.xcuserdatad +*.xcodeproj/xcuserdata +*.xcodeproj/project.xcuserdata +*.xcodeproj/xcshareddata/WorkspaceSettings.xcsettings *.pbxuser *.xccheckout -*.xcscheme *.xcplayground -*.xcuserdatad -*.xctest -*.xcuserdata -*.xcodeproj/xcshareddata/WorkspaceSettings.xcsettings - -# CocoaPods -Pods/ -podfile.lock - -# Carthage -Carthage/Build/ # Swift Package Manager .swiftpm/ @@ -42,14 +32,6 @@ build/ *.vscode/ *.idea/ -# User-specific files -*.xcuserdatad/ -*.xcscheme -*.xcodeproj/xcuserdata -*.xcodeproj/project.xcuserdata - # Build artifacts *.ipa *.xcarchive -*.dSYM - diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 000000000..6f39039f8 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,85 @@ +# SwiftLint configuration for boring.notch +# Run with: swiftlint --config .swiftlint.yml +# CI wiring is tracked as a follow-up (needs a lint-capable runner). + +# Paths to check +included: + - boringNotch + - BoringNotchXPCHelper + - Shared + +# Paths to ignore +excluded: + - boringNotch/Assets.xcassets + - mediaremote-adapter + - updater + - .build + +# Crash-vector rules found in the audit — hard errors in new code. +# The few audited, allow-listed legacy sites are excluded per rule below; +# do not add new exclusions. +force_unwrapping: + severity: error + excluded: + # CGEventType(rawValue: 14)! == kCGEventSystemDefined by construction + - boringNotch/observers/MediaKeyInterceptor.swift + # compile-time-known regexes + - boringNotch/helpers/OTPDetector.swift + # hard-bundled placeholder artwork/resource URLs + - boringNotch/managers/MusicManager.swift + - boringNotch/components/Music/LottieAnimationView.swift + - boringNotch/components/Settings/Views/MediaSettingsView.swift + +force_cast: + severity: error + excluded: + # XPC secure-coding bridging (NSSet -> Set is a guaranteed bridge) + - boringNotch/XPCHelperClient/XPCHelperClient.swift + - BoringNotchXPCHelper/main.swift + +force_try: + severity: error + +# Opt-in rules matching the house style established during remediation +opt_in_rules: + - empty_count + - empty_string + - first_where + - contains_over_first_is_nil + - flatmap_over_parameter_reduce + - implicitly_unwrapped_optional + - lower_acl_than_parent + - modifier_order + - preferred_final_class + - redundant_nil_coalescing + - unused_enumerated + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + +type_body_length: + warning: 400 + error: 800 + +file_length: + warning: 500 + error: 1000 + +function_body_length: + warning: 60 + error: 120 + +line_length: + warning: 160 + error: 220 + +identifier_name: + min_length: + warning: 2 + error: 1 + max_length: + warning: 40 + error: 60 + excluded: + - id + - vm + - s diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift index 2e13379e2..42b3739ea 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift @@ -78,6 +78,97 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { } } + // MARK: - Notification Center banners + + /// One watcher for the whole helper: `BoringNotchXPCHelper` is created per + /// connection, the AX observer must not be. + private static let watcher = NotificationWatcher() + + @objc func startNotificationWatching(with reply: @escaping (Bool) -> Void) { + // Capture the delegate for this connection before hopping queues — + // NSXPCConnection.current() is only valid inside the incoming call. + // + // Cast to BoringNotchXPCAppDelegate, not its parent protocol: the + // proxy's conformance is built from the exact interface the + // connection was configured with, so casting to the parent can + // return nil and silently swallow every callback. + let connection = NSXPCConnection.current() + let proxy = connection?.remoteObjectProxyWithErrorHandler { error in + NSLog("[boringNotch] notification callback failed: \(error.localizedDescription)") + } + let delegate = proxy as? BoringNotchXPCAppDelegate + + if delegate == nil { + NSLog("[boringNotch] could not obtain notification delegate proxy — banners will not reach the app") + } + + // The AX observer needs a live run loop; the helper's is on main. + DispatchQueue.main.async { + let watcher = Self.watcher + watcher.onBanner = { notification in + NSLog("[boringNotch] captured banner: app=\(notification.appName ?? "-") bundle=\(notification.bundleID ?? "-") title=\(notification.title ?? "-") delegate=\(delegate == nil ? "nil" : "ok")") + delegate?.notificationDidAppear([ + "token": notification.token, + "appName": notification.appName ?? "", + "bundleID": notification.bundleID ?? "", + "title": notification.title ?? "", + "subtitle": notification.subtitle ?? "", + "body": notification.body ?? "", + "actions": notification.actions.joined(separator: "\n") + ]) + } + watcher.onBannerGone = { delegate?.notificationDidDisappear($0) } + let started = watcher.start() + NSLog("[boringNotch] notification watcher start -> \(started), AX trusted: \(AXIsProcessTrusted())") + reply(started) + } + } + + @objc func stopNotificationWatching() { + DispatchQueue.main.async { Self.watcher.stop() } + } + + @objc func replyToNotification(_ token: String, text: String, with reply: @escaping (Bool) -> Void) { + DispatchQueue.main.async { reply(Self.watcher.reply(token: token, text: text)) } + } + + /// Sends an iMessage directly through the Messages scripting + /// dictionary, bypassing the notification entirely. The app falls back + /// to this when the AX reply above fails because the banner has faded — + /// for Messages that recovers a real send instead of a clipboard + /// hand-off. No other supported app offers an equivalent. + @objc func sendIMessage(_ text: String, toChatNamed name: String, with reply: @escaping (Bool) -> Void) { + DispatchQueue.main.async { reply(MessagesSender.send(text, toChatNamed: name)) } + } + + @objc func performNotificationAction(_ token: String, name: String, with reply: @escaping (Bool) -> Void) { + DispatchQueue.main.async { reply(Self.watcher.performAction(token: token, name: name)) } + } + + @objc func openNotification(_ token: String, with reply: @escaping (Bool) -> Void) { + DispatchQueue.main.async { reply(Self.watcher.open(token: token)) } + } + + @objc func notificationDebugDump(with reply: @escaping (String) -> Void) { + DispatchQueue.main.async { reply(Self.watcher.debugDump()) } + } + + @objc func dismissNotification(_ token: String, with reply: @escaping (Bool) -> Void) { + DispatchQueue.main.async { reply(Self.watcher.dismiss(token: token)) } + } + + @objc func holdNotification(_ token: String) { + DispatchQueue.main.async { Self.watcher.hold(token: token) } + } + + @objc func releaseNotification(_ token: String) { + DispatchQueue.main.async { Self.watcher.release(token: token) } + } + + @objc func setNotchOpen(_ open: Bool) { + DispatchQueue.main.async { Self.watcher.notchOpen = open } + } + private class KeyboardBrightnessClient { private static let keyboardID: UInt64 = 1 private var clientInstance: NSObject? @@ -206,10 +297,17 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { reply(false) } - @objc func adjustScreenBrightness(by value: Float, with reply: @escaping (Bool) -> Void) { + @objc func adjustScreenBrightness(by value: Float, with reply: @escaping (NSNumber?) -> Void) { let displayID = brightnessDisplayID() if displayServicesSetBrightnessSmooth(displayID: displayID, value: value) { - reply(true) + // Read back inside the helper so the client pays for one RPC + // instead of two (adjust + currentScreenBrightness). + var b: Float = 0 + if displayServicesGetBrightness(displayID: displayID, out: &b) { + reply(NSNumber(value: b)) + return + } + reply(nil) return } if let io = ioServiceFor(displayID: displayID) { @@ -218,12 +316,12 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { let target = max(0, min(1, ioCurrent + value)) let ok = IODisplaySetFloatParameter(io, 0, kIODisplayBrightnessKey as CFString, target) == kIOReturnSuccess IOObjectRelease(io) - reply(ok) + reply(ok ? NSNumber(value: target) : nil) return } IOObjectRelease(io) } - reply(false) + reply(nil) } // MARK: - Lunar Events @@ -425,85 +523,5 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { private struct LunarBrightnessEvent: Decodable { let brightness: Double let display: Int - - init(from decoder: NSCoder) { - display = decoder.decodeInteger(forKey: "display") - brightness = decoder.decodeDouble(forKey: "brightness") - } } -private actor JSONLinesPipeHandler { - nonisolated let pipe: Pipe - private let fileHandle: FileHandle - private var buffer = "" - private let decoder: JSONDecoder - - init(decoder: JSONDecoder = JSONDecoder()) { - let pipe = Pipe() - self.pipe = pipe - self.fileHandle = pipe.fileHandleForReading - self.decoder = decoder - } - - nonisolated func getPipe() -> Pipe { - return pipe - } - - func readJSONLines(as type: T.Type, onLine: @escaping (T) -> Void) async { - do { - try await processLines(as: type) { decodedObject in - onLine(decodedObject) - } - } catch { - // Ignore stream errors to keep the helper lightweight. - } - } - - private func processLines(as type: T.Type, onLine: @escaping (T) -> Void) async throws { - while true { - let data = try await readData() - guard !data.isEmpty else { break } - - if let chunk = String(data: data, encoding: .utf8) { - buffer.append(chunk) - - while let range = buffer.range(of: "\n") { - let line = String(buffer[..(_ line: String, as type: T.Type, onLine: @escaping (T) -> Void) { - guard let data = line.data(using: .utf8) else { return } - if let decodedObject = try? decoder.decode(T.self, from: data) { - onLine(decodedObject) - } - } - - private func readData() async throws -> Data { - return try await withCheckedThrowingContinuation { continuation in - fileHandle.readabilityHandler = { handle in - let data = handle.availableData - handle.readabilityHandler = nil - continuation.resume(returning: data) - } - } - } - - func close() async { - do { - fileHandle.readabilityHandler = nil - - try fileHandle.close() - try pipe.fileHandleForWriting.close() - } catch { - // Ignore close errors. - } - } -} diff --git a/BoringNotchXPCHelper/Info.plist b/BoringNotchXPCHelper/Info.plist index c123a5d13..d703d6cb1 100644 --- a/BoringNotchXPCHelper/Info.plist +++ b/BoringNotchXPCHelper/Info.plist @@ -2,6 +2,12 @@ + + NSAppleEventsUsageDescription + boring.notch sends your replies to iMessage conversations from the notch. XPCService ServiceType diff --git a/BoringNotchXPCHelper/MessagesSender.swift b/BoringNotchXPCHelper/MessagesSender.swift new file mode 100644 index 000000000..31c223d87 --- /dev/null +++ b/BoringNotchXPCHelper/MessagesSender.swift @@ -0,0 +1,85 @@ +// +// MessagesSender.swift +// BoringNotchXPCHelper +// +// Sends iMessage replies through the Messages scripting dictionary. +// +// Why this exists: replying to a notification normally means typing into +// the system banner's own AX reply field, which stops working the moment +// that banner fades (~5s) — the element is destroyed, measured, not +// assumed. Messages is the one supported app with a real scripting +// dictionary (`send to `), so an iMessage reply +// can be delivered properly at any time, with no dependency on the +// notification still existing. +// +// There is no equivalent for WhatsApp/Telegram/Discord — none ship a +// scripting dictionary, and driving their UI with simulated clicks is far +// too brittle to put behind a send button. Those keep the clipboard +// hand-off. +// +// Lives in the helper because the app is sandboxed; sending Apple events +// to another app needs the unsandboxed context the helper already has. +// + +import Foundation +import AppKit + +enum MessagesSender { + /// Matches the notification's sender against a Messages chat (then a + /// participant) and sends. Returns false if the chat can't be resolved + /// or automation permission was denied, so the caller can fall back to + /// the clipboard hand-off. + static func send(_ text: String, toChatNamed name: String) -> Bool { + // Participants only — deliberately not chats. Verified against a + // real Messages library: `name of chat` returns `missing value` for + // every chat, so matching on it can never succeed. Participants do + // carry the display name the notification shows ("Harsh Vardhan + // Goswami"), which is the only thing a notification gives us. + // + // The first match wins. Duplicate participant entries for the same + // person are normal (one per handle/service — e:me@…, +9198…), and + // they all reach the same human, so picking the first is fine. + let script = """ + tell application "Messages" + repeat with p in participants + try + if (name of p as string) is equal to "\(escape(name))" then + send "\(escape(text))" to p + return "ok" + end if + end try + end repeat + end tell + return "notfound" + """ + + guard let scriptObject = NSAppleScript(source: script) else { return false } + + var error: NSDictionary? + let output = scriptObject.executeAndReturnError(&error) + + if let error { + // -1743 is "not authorized to send Apple events" — the user + // declined the Automation prompt, which is a legitimate choice, + // not a bug. Everything else is worth seeing in the log. + NSLog("[boringNotch] Messages send failed: \(error)") + return false + } + + let ok = output.stringValue == "ok" + if !ok { + NSLog("[boringNotch] Messages: no chat or participant named \(name.debugDescription)") + } + return ok + } + + /// Message text is arbitrary user input going into an AppleScript + /// string literal, so backslashes and quotes have to be neutralised — + /// otherwise a quote in a reply breaks the script (or worse, changes + /// what it does). Backslash first, or it would re-escape the escapes. + private static func escape(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } +} diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift new file mode 100644 index 000000000..e722d8344 --- /dev/null +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -0,0 +1,605 @@ +// +// NotificationWatcher.swift +// BoringNotchXPCHelper +// +// Observes Notification Center banners through the Accessibility API and +// streams them to the app. This lives in the helper because the app itself is +// sandboxed, and a sandboxed process cannot drive AX on another application. +// +// Verified hierarchy (macOS 26): +// AXWindow "Notification Center" (subrole AXSystemDialog) +// └ … groups … > AXScrollArea (identifier AXNotificationListItems) +// └ banner (subrole AXNotificationCenterBanner) +// · AXIdentifier = per-notification UUID +// · AXAttributedDescription = "App, Title, Subtitle, Body" +// └ AXStaticText identifier "title" / "subtitle" / "body" (AXValue) +// +// The window only exists while banners are on screen, so a banner is only +// actionable while visible. +// + +import Foundation +import ApplicationServices +import AppKit + +private let notificationCenterBundleID = "com.apple.notificationcenterui" +private let bannerSubroles: Set = ["AXNotificationCenterBanner", "AXNotificationCenterAlert"] + +struct CapturedNotification { + let token: String // banner AXIdentifier (UUID), stable while on screen + let appName: String? + let bundleID: String? + let title: String? + let subtitle: String? + let body: String? + let actions: [String] // AX action names, e.g. AXPress / AXReply / button titles +} + +final class NotificationWatcher { + var onBanner: ((CapturedNotification) -> Void)? + var onBannerGone: ((String) -> Void)? + + private var appElement: AXUIElement? + private var pollTimer: DispatchSourceTimer? + private var live: [String: AXUIElement] = [:] + + /// Banners being deliberately kept alive so their reply field stays + /// usable, and of those, the ones moved off-screen. + private var held: Set = [] + private var heldOffScreen: Set = [] + private var lastRefresh = Date.distantPast + + /// Effective notch-open state, pushed by the app over XPC. Gates every + /// focus-affecting part of the keep-alive: re-performing the details + /// toggle expands a banner, and an expanded banner's reply field seizes + /// keyboard focus even with the window parked off-screen (macOS does + /// not unfocus off-screen windows) — so the toggle may only run while + /// the notch is open. Parking in hold(token:) is focus-neutral and + /// stays unconditional. Defaults to false: never steal focus before + /// being told. + var notchOpen: Bool = false { + didSet { + if notchOpen, !oldValue { + // Re-arm the skip log for the next closed episode. + skipLogged.removeAll() + } else if oldValue, !notchOpen { + collapseHeldBanners() + } + } + } + + /// Tokens whose closed-notch keep-alive skip has already been logged, + /// so the log fires once per banner per closed episode rather than on + /// every 2.5s tick. + private var skipLogged: Set = [] + + /// Comfortably inside the ~5s dismissal window the toggle resets, so a + /// missed tick can't let a held banner slip away. + private let refreshInterval: TimeInterval = 2.5 + + /// Banners live ~5s, so 0.35s catches every one with room to spare. + /// Idle drops to 0.5s as a compromise: first-detection cadence stays + /// perceptibly snappy while the AX walk rate halves versus flat 0.35s + /// (memory-debugging shows the scan path is the helper's + /// allocation-heavy path, so idle trimming also bounds its pressure). + private let activePollInterval: TimeInterval = 0.35 + private let idlePollInterval: TimeInterval = 0.5 + private var currentPollInterval: TimeInterval = 0 + + var isRunning: Bool { appElement != nil } + + // MARK: - Lifecycle + + @discardableResult + func start() -> Bool { + guard AXIsProcessTrusted() else { return false } + guard !isRunning else { return true } + guard let notificationCenter = NSRunningApplication.runningApplications( + withBundleIdentifier: notificationCenterBundleID + ).first else { return false } + + let app = AXUIElementCreateApplication(notificationCenter.processIdentifier) + appElement = app + + // Polling only, deliberately — no AXObserver and no Timer. + // + // This runs inside an XPC service, whose main thread is driven by + // dispatch_main(). That services DispatchQueue.main blocks but does + // NOT run a CFRunLoop, so anything depending on one is dead code + // here: Timer/RunLoop.add never fires, and an AXObserver's + // CFRunLoopSource never delivers. An earlier version used both and + // silently captured nothing after the single scan() below — the + // watcher reported "started" and then went quiet forever. + // + // A DispatchSourceTimer needs no run loop, so it works. All watcher + // state stays on the main queue, which is also where the helper + // dispatches reply/action calls, so there's no locking to get wrong. + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + activePollInterval, repeating: activePollInterval) + timer.setEventHandler { [weak self] in self?.scan() } + timer.resume() + pollTimer = timer + currentPollInterval = activePollInterval + + scan() + return true + } + + func stop() { + pollTimer?.cancel() + pollTimer = nil + appElement = nil + live.removeAll() + // Correctness hygiene rather than the fix for a live leak: once + // pollTimer stops, refreshHeldBanners never runs again either, so + // anything still in `held` stops being artificially kept alive and + // dies on its own within a couple of seconds regardless. But + // leaving stale tokens around after a stop/restart cycle is still + // wrong, so clear them explicitly. + held.removeAll() + heldOffScreen.removeAll() + skipLogged.removeAll() + } + + // MARK: - Scanning + + private func scan() { + // Every AX-bridged object copied in a walk lands in the run queue's + // autorelease pool; wrap the walk explicitly so pool lifetime never + // depends on the dispatch-main scheduler's drain behavior. + autoreleasepool { scanImpl() } + } + + private func scanImpl() { + guard let appElement else { return } + var seen: Set = [] + + for window in (appElement[kAXWindowsAttribute] as? [AXUIElement]) ?? [] { + guard window[kAXSubroleAttribute] as? String == "AXSystemDialog" else { continue } + for banner in banners(in: window) { + guard let token = banner[kAXIdentifierAttribute] as? String else { continue } + seen.insert(token) + guard live[token] == nil else { continue } + live[token] = banner + onBanner?(capture(banner, token: token)) + } + } + + for token in live.keys where !seen.contains(token) { + live[token] = nil + heldOffScreen.remove(token) + skipLogged.remove(token) + onBannerGone?(token) + } + + refreshHeldBanners() + updatePollCadence() + } + + /// Drops the poll rate once nothing is on screen anymore; walking at + /// half rate while idle halves the allocation pressure of the scan path. + private func updatePollCadence() { + let wanted = (live.isEmpty && held.isEmpty) ? idlePollInterval : activePollInterval + guard wanted != currentPollInterval, let pollTimer else { return } + currentPollInterval = wanted + pollTimer.schedule(deadline: .now() + wanted, repeating: wanted) + } + + /// Keeps held banners from timing out. + /// + /// Measured: an untouched banner dies in ~1.25s and its AX element is + /// destroyed with it, which is what made replying impossible after the + /// fact. Performing the details toggle resets that dismissal timer — + /// re-performing it on an interval held a banner alive for a full 30s + /// test with its reply field intact. That's what lets the notch offer a + /// working reply box for as long as the notification is showing. + private func refreshHeldBanners() { + guard !held.isEmpty else { return } + let now = Date() + guard now.timeIntervalSince(lastRefresh) >= refreshInterval else { return } + lastRefresh = now + + // The toggle expands the banner, and the expanded banner's reply + // field grabs keyboard focus even parked off-screen. Focus effects + // are only acceptable while the notch is open; with it closed the + // banner is left to dismiss naturally and flows through the usual + // onBannerGone/expiry path (an intended tradeoff). + guard notchOpen else { + for token in held where live[token] != nil && !skipLogged.contains(token) { + skipLogged.insert(token) + NSLog("[boringNotch] notch closed — skipping keep-alive toggle for held banner \(token), leaving it to dismiss naturally") + } + return + } + + for token in held { + guard let banner = live[token] else { continue } + if let toggle = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("details") }) { + AXUIElementPerformAction(banner, toggle as CFString) + } + } + } + + /// Best-effort collapse of held banners left expanded, run once when + /// the notch closes: an expanded banner keeps its reply field focused, + /// and it must not stay that way after close. Looks for a "Hide …" + /// action ("Hide Details"); a banner without one is left as-is, so + /// non-English locales degrade to the pre-gate behavior. + private func collapseHeldBanners() { + let expanded = held.filter { live[$0] != nil } + guard !expanded.isEmpty else { return } + NSLog("[boringNotch] notch closed — collapsing \(expanded.count) held banner(s)") + for token in expanded { + guard let banner = live[token] else { continue } + if let hide = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("hide") }) { + AXUIElementPerformAction(banner, hide as CFString) + } + } + } + + /// Holds a banner open so its reply field stays usable, moving it + /// off-screen first. + /// + /// The move is not optional. Holding a banner works by re-performing + /// its details toggle, which leaves it expanded — showing its own reply + /// field, on top of everything, taking focus. Two live text fields + /// competing for the same keystrokes is worse than no keep-alive at + /// all, so the banner is parked off-screen for the whole hold and the + /// notch is the only visible surface. + /// + /// Safe to leave behind: with no banners showing, notificationcenterui + /// has zero windows — the window is created per session and destroyed + /// after — so a moved window can't permanently hide notifications. A + /// fresh one always spawns at its normal position. + func hold(token: String) { + guard let banner = live[token] else { return } + held.insert(token) + + guard !heldOffScreen.contains(token) else { return } + heldOffScreen.insert(token) + guard let windowValue = banner[kAXWindowAttribute], + CFGetTypeID(windowValue as CFTypeRef) == AXUIElementGetTypeID() else { return } + let window = windowValue as! AXUIElement + var target = CGPoint(x: -5000, y: -5000) + if let position = AXValueCreate(.cgPoint, &target) { + AXUIElementSetAttributeValue(window, kAXPositionAttribute as CFString, position) + } + } + + /// Stops holding a banner and lets it dismiss naturally. + func release(token: String) { + held.remove(token) + heldOffScreen.remove(token) + skipLogged.remove(token) + guard let banner = live[token] else { return } + if let close = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("close") }) { + AXUIElementPerformAction(banner, close as CFString) + } + } + + /// Measured, not assumed: once a banner leaves the screen its + /// AXUIElement is destroyed outright — reading AXRole from a retained + /// reference returns nil and AXUIElementCopyActionNames returns empty. + /// Holding onto elements past their banner therefore buys nothing, so + /// this is just `live`. Replying is only possible while the banner is + /// on screen; there is no API to send on an app's behalf afterwards. + private func element(for token: String) -> AXUIElement? { + live[token] + } + + /// Match on subrole rather than a fixed containment path — the wrapping + /// groups change between macOS releases, the subrole has not. + private func banners(in element: AXUIElement, depth: Int = 0) -> [AXUIElement] { + guard depth < 14 else { return [] } + if let subrole = element[kAXSubroleAttribute] as? String, bannerSubroles.contains(subrole) { + return [element] + } + return ((element[kAXChildrenAttribute] as? [AXUIElement]) ?? []) + .flatMap { banners(in: $0, depth: depth + 1) } + } + + // MARK: - Reading a banner + + private func capture(_ banner: AXUIElement, token: String) -> CapturedNotification { + var parts: [String: String] = [:] + collectLabelledText(in: banner, into: &parts) + + // AXAttributedDescription reads "App, Title, Subtitle, Body"; only its + // first field (the app name) isn't available as a labelled child. + let appName = (banner["AXAttributedDescription"] as? NSAttributedString)?.string + .components(separatedBy: ",").first? + .trimmingCharacters(in: .whitespaces) + .trimmingCharacters(in: CharacterSet(charactersIn: "\u{200E}\u{2068}\u{2069}")) + + return CapturedNotification( + token: token, + appName: appName, + bundleID: appName.flatMap(bundleID(forAppNamed:)), + title: parts["title"], + subtitle: parts["subtitle"], + body: parts["body"], + actions: availableActions(on: banner) + ) + } + + private func collectLabelledText(in element: AXUIElement, into parts: inout [String: String], depth: Int = 0) { + guard depth < 10 else { return } + if let identifier = element[kAXIdentifierAttribute] as? String, + ["title", "subtitle", "body"].contains(identifier), + let value = element[kAXValueAttribute] as? String { + parts[identifier] = value.trimmingCharacters( + in: CharacterSet(charactersIn: "\u{2068}\u{2069}\u{200E}").union(.whitespacesAndNewlines) + ) + } + for child in (element[kAXChildrenAttribute] as? [AXUIElement]) ?? [] { + collectLabelledText(in: child, into: &parts, depth: depth + 1) + } + } + + /// Reply/Accept/Decline are exposed either as AX actions on the banner or + /// as descendant buttons, and which one varies by app and notification + /// type — so report both rather than guessing. + private func availableActions(on banner: AXUIElement) -> [String] { + // AXPress is the "open the notification" default, not a user-facing + // choice — the notch exposes that as tapping the notification itself. + var labels = actionNames(of: banner) + .map(actionLabel) + .filter { $0 != kAXPressAction } + for button in descendants(of: banner, matching: [kAXButtonRole, kAXMenuButtonRole]) { + if let title = button[kAXTitleAttribute] as? String, !title.isEmpty { + labels.append(title) + } + } + return labels + } + + /// Maps a user-facing label back to the raw action string AX expects. + private func rawAction(on element: AXUIElement, matching predicate: (String) -> Bool) -> String? { + actionNames(of: element).first { predicate(actionLabel($0)) } + } + + /// Raw action names as AX reports them. Notification Center returns records + /// rather than plain names for its custom actions: + /// "Name:Reply\nTarget:0x0\nSelector:(null)" + /// The raw string is what `AXUIElementPerformAction` needs, so keep it and + /// use `actionLabel` for anything user-facing. + private func actionNames(of element: AXUIElement) -> [String] { + var names: CFArray? + guard AXUIElementCopyActionNames(element, &names) == .success else { return [] } + return (names as? [String]) ?? [] + } + + private func actionLabel(_ raw: String) -> String { + guard raw.hasPrefix("Name:") else { return raw } + return String(raw.dropFirst("Name:".count).prefix { !$0.isNewline }) + } + + private func descendants(of element: AXUIElement, matching roles: [String], depth: Int = 0) -> [AXUIElement] { + guard depth < 10 else { return [] } + var found: [AXUIElement] = [] + if depth > 0, let role = element[kAXRoleAttribute] as? String, roles.contains(role) { + found.append(element) + } + for child in (element[kAXChildrenAttribute] as? [AXUIElement]) ?? [] { + found += descendants(of: child, matching: roles, depth: depth + 1) + } + return found + } + + /// Resolution results, hits and misses both, keyed by normalized name, so + /// a repeat banner from the same app never rescans the disk — the helper + /// captures every banner, and a directory walk must be once per unique + /// name. + private var bundleIDCache: [String: String] = [:] + private var bundleIDMisses: Set = [] + + /// Matches on a normalized name because both sides can carry invisible + /// bidi marks: WhatsApp's `localizedName` is literally "\u{200E}WhatsApp" + /// (LEFT-TO-RIGHT MARK), and the banner's own description is wrapped in + /// isolates. Comparing raw strings silently failed to resolve WhatsApp's + /// bundle ID, which dropped every one of its notifications at the + /// allow-list check. + /// + /// The running-apps match stays first, but it misses when the app was + /// renamed, a helper process owns the notification, the app quit between + /// posting and capture, or the helper's view of running apps is + /// restricted. Two on-disk fallbacks follow before giving up: a direct + /// probe of the usual install locations, then one bounded scan of + /// /Applications. + private func bundleID(forAppNamed name: String) -> String? { + let target = Self.normalizedAppName(name) + guard !target.isEmpty else { return nil } + + if let cached = bundleIDCache[target] { return cached } + if bundleIDMisses.contains(target) { return nil } + + let resolved = NSWorkspace.shared.runningApplications.first { + guard let localizedName = $0.localizedName else { return false } + return Self.normalizedAppName(localizedName) == target + }?.bundleIdentifier + ?? bundleIDFromKnownLocations(named: name) + ?? bundleIDFromApplicationsScan(matching: target) + + if let resolved { + bundleIDCache[target] = resolved + } else { + bundleIDMisses.insert(target) + } + return resolved + } + + /// Fallback 1: probe the usual install locations directly — cheap when + /// the banner name matches the bundle's directory name, which is the + /// common case. Tries the name as-is plus a no-spaces variant + /// ("Google Chrome" → "GoogleChrome"). + private func bundleIDFromKnownLocations(named name: String) -> String? { + // Case-preserving sibling of normalizedAppName: paths need the real + // capitalization, just without the invisible marks. + let displayName = name.filter { !$0.unicodeScalars.allSatisfy(Self.bidiControlCharacters.contains) } + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !displayName.isEmpty else { return nil } + + let candidates = [displayName, displayName.replacingOccurrences(of: " ", with: "")] + let directories = ["/Applications", NSHomeDirectory() + "/Applications", "/System/Applications"] + for directory in directories { + for candidate in candidates where !candidate.isEmpty { + let path = directory + "/" + candidate + ".app" + if FileManager.default.fileExists(atPath: path), + let bundleID = Bundle(url: URL(fileURLWithPath: path))?.bundleIdentifier { + return bundleID + } + } + } + return nil + } + + /// Fallback 2: a single bounded pass over the top level of /Applications + /// (and ~/Applications when present), matching normalized directory names + /// minus the extension. No recursion, and the memoization above bounds it + /// to once per unique app name. + private func bundleIDFromApplicationsScan(matching target: String) -> String? { + let directories = ["/Applications", NSHomeDirectory() + "/Applications"] + for directory in directories { + let entries = (try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? [] + for entry in entries where entry.hasSuffix(".app") { + guard Self.normalizedAppName(String(entry.dropLast(".app".count))) == target else { continue } + if let bundleID = Bundle(url: URL(fileURLWithPath: directory + "/" + entry))?.bundleIdentifier { + return bundleID + } + } + } + return nil + } + + private static func normalizedAppName(_ name: String) -> String { + name.filter { !$0.unicodeScalars.allSatisfy(bidiControlCharacters.contains) } + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } + + /// Directional formatting characters Notification Center and app names + /// both sprinkle in: LRM/RLM, the isolate family, and the embedding / + /// override set. + private static let bidiControlCharacters: CharacterSet = { + var set = CharacterSet() + set.insert(charactersIn: "\u{200E}\u{200F}") // LRM, RLM + set.insert(charactersIn: "\u{2066}"..."\u{2069}") // isolates + PDI + set.insert(charactersIn: "\u{202A}"..."\u{202E}") // embeddings/overrides + return set + }() + + // MARK: - Acting on a banner + + /// Types into the banner's reply field and submits it. Only works while + /// the banner is on screen and the source app offers a reply field; the + /// caller falls back to opening the app otherwise. + /// + /// Verified against a live WhatsApp banner (2026-08-12): a collapsed + /// banner's actions are ["AXPress", "Show Details", "Reply", "Close"] — + /// a real "Reply" action does exist (an earlier check only ever caught + /// the banner post-expansion, where it's already gone, which led to a + /// wrong assumption here). "Reply" is preferred since it's the more + /// direct, purpose-built action; "Show Details" is the fallback for + /// banners/apps that don't expose it. Either way the field is submitted + /// via the "Send" action on the banner — not kAXConfirmAction on the + /// field, which is untested and unreliable across text-area + /// implementations. + func reply(token: String, text: String) -> Bool { + guard let banner = element(for: token) else { return false } + + if replyField(in: banner) == nil { + if let action = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("reply") }) + ?? rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("details") }) { + AXUIElementPerformAction(banner, action as CFString) + } else if let button = descendants(of: banner, matching: [kAXButtonRole]).first(where: { + ($0[kAXTitleAttribute] as? String)?.lowercased().contains("reply") == true + }) { + AXUIElementPerformAction(button, kAXPressAction as CFString) + } + // Thread.sleep, not RunLoop.run: there's no CFRunLoop to advance + // in an XPC service, so RunLoop.run(until:) returns immediately + // and the reply field wouldn't have appeared yet. + Thread.sleep(forTimeInterval: 0.4) + } + + guard let field = replyField(in: banner) else { return false } + AXUIElementSetAttributeValue(field, kAXFocusedAttribute as CFString, kCFBooleanTrue) + guard AXUIElementSetAttributeValue(field, kAXValueAttribute as CFString, text as CFString) == .success + else { return false } + + if let send = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("send") }) { + return AXUIElementPerformAction(banner, send as CFString) == .success + } + return AXUIElementPerformAction(field, kAXConfirmAction as CFString) == .success + } + + private func replyField(in element: AXUIElement, depth: Int = 0) -> AXUIElement? { + descendants(of: element, matching: [kAXTextFieldRole, kAXTextAreaRole]).first + } + + /// Performs a named AX action on the banner, or presses the button with + /// that title (Accept / Decline on call notifications). + func performAction(token: String, name: String) -> Bool { + guard let banner = element(for: token) else { return false } + if let raw = rawAction(on: banner, matching: { $0 == name }) { + return AXUIElementPerformAction(banner, raw as CFString) == .success + } + guard let button = descendants(of: banner, matching: [kAXButtonRole, kAXMenuButtonRole]).first(where: { + $0[kAXTitleAttribute] as? String == name + }) else { return false } + return AXUIElementPerformAction(button, kAXPressAction as CFString) == .success + } + + /// Opens the notification in its source app (the banner's default action). + func open(token: String) -> Bool { + guard let banner = element(for: token) else { return false } + return AXUIElementPerformAction(banner, kAXPressAction as CFString) == .success + } + + /// Clears the banner from screen. Notification Center exposes this as a + /// "Close" action rather than the AXRemove one might expect. + func dismiss(token: String) -> Bool { + guard let banner = element(for: token), + let raw = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("close") }) + else { return false } + return AXUIElementPerformAction(banner, raw as CFString) == .success + } + + // MARK: - Debug + + /// Full attribute dump of the banner window, for the debug window. + func debugDump() -> String { + guard let appElement else { return "watcher not running" } + var out = "" + for window in (appElement[kAXWindowsAttribute] as? [AXUIElement]) ?? [] { + guard window[kAXSubroleAttribute] as? String == "AXSystemDialog" else { continue } + describe(window, depth: 0, into: &out) + } + return out.isEmpty ? "no banners on screen" : out + } + + private func describe(_ element: AXUIElement, depth: Int, into out: inout String) { + guard depth < 14 else { return } + let pad = String(repeating: " ", count: depth) + out += "\(pad)\(element[kAXRoleAttribute] as? String ?? "?") \(element[kAXSubroleAttribute] as? String ?? "")" + out += " actions=\(actionNames(of: element))\n" + var names: CFArray? + if AXUIElementCopyAttributeNames(element, &names) == .success, let names = names as? [String] { + for name in names where name != kAXChildrenAttribute && name != kAXParentAttribute { + guard let value = element[name] else { continue } + out += "\(pad) · \(name) = \(String(describing: value).prefix(200))\n" + } + } + for child in (element[kAXChildrenAttribute] as? [AXUIElement]) ?? [] { + describe(child, depth: depth + 1, into: &out) + } + } +} + +private extension AXUIElement { + subscript(attribute: String) -> Any? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(self, attribute as CFString, &value) == .success else { return nil } + return value + } +} diff --git a/BoringNotchXPCHelper/main.swift b/BoringNotchXPCHelper/main.swift index f8afea721..10aa34bcb 100644 --- a/BoringNotchXPCHelper/main.swift +++ b/BoringNotchXPCHelper/main.swift @@ -17,7 +17,7 @@ class ServiceDelegate: NSObject, NSXPCListenerDelegate { newConnection.exportedInterface = NSXPCInterface(with: (any BoringNotchXPCHelperProtocol).self) // Configure the interface for callbacks from the helper to the app. - let listenerInterface = NSXPCInterface(with: (any BoringNotchXPCHelperLunarListener).self) + let listenerInterface = NSXPCInterface(with: (any BoringNotchXPCAppDelegate).self) listenerInterface.setClasses( NSSet(array: [BNLunarBrightnessEvent.self]) as! Set, for: #selector(BoringNotchXPCHelperLunarListener.lunarEventDidUpdate(_:)), diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..f5bc66e2f --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,85 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at our [Discord server](https://discord.gg/c8JXA7qrPm) or via [GitHub](https://github.com/TheBoredTeam/boring.notch/issues) private message to the maintainers. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f02fac50b..872d8a536 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ Please submit all translations to [Crowdin](https://crowdin.com/project/boring-n ``` All code contributions must be based on the `dev` branch, not `main`. Documentation changes should be based on `main` instead. -5. **Create a new feature branch**: +4. **Create a new feature branch**: ```bash git checkout -b feature/{your-feature-name} ``` @@ -92,13 +92,18 @@ Please submit all translations to [Crowdin](https://crowdin.com/project/boring-n 4. **Be patient**: Reviews take time. Maintainers will get to your PR as soon as they can. - +- Write clear, self-documenting code with meaningful variable and function names. Type names are UpperCamelCase, functions/variables lowerCamelCase — file names match the primary type they contain. +- Add comments for complex logic or non-obvious implementations; explain *why*, not just *what*. +- No force unwrapping (`!`), force casts (`as!`), or `try!` in new code — these fail CI. The argument for accepting a force unwrap (compile-time constants, guaranteed bridge) belongs in a comment plus a SwiftLint exclusion entry, not in silent code. +- Log with `os.Logger` via `helpers/Log.swift` (feature categories), never `print()` in production code. +- Managers publish state/events (e.g. via `NotchUIEventBus`); only the coordinator/presenter layer decides what the UI shows. Don't call `BoringViewCoordinator.shared` from hardware/OS-facing managers. +- New source files carry the header comment of their neighbors and must not introduce new directories with spaces in their names. +- Ensure your code builds cleanly before committing: `xcodebuild -scheme boringNotch -configuration Debug build`. +- Remove any debugging code, console logs, or commented-out code before submitting +- License header: headers of existing files keep their format; new third-party-derived files must state the license origin (SPDX identifier where practical, e.g. `// SPDX-License-Identifier: GPL-3.0-only`). ## Reporting Bugs diff --git a/README.md b/README.md index cdcc94625..a0c56264a 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Say hello to **Boring Notch**, the coolest way to make your MacBook’s notch th Once downloaded, open the `.dmg` and move **Boring Notch** to your `/Applications` folder. > [!IMPORTANT] -> We don't have an Apple Developer account (yet 👀), so macOS will warn you that Boring Notch is from an unidentified developer on first launch. This is expected behavior. +> Release builds are not notarized with Apple (macOS will warn you that Boring Notch is from an unidentified developer on first launch). This is expected behavior. > > You'll need to bypass this before the app will open. You only need to do this once. Use one of the methods below. @@ -180,7 +180,7 @@ We would like to express our gratitude to the authors and maintainers of the ope - **[MediaRemoteAdapter](https://github.com/ungive/mediaremote-adapter)** – An open-source project that allowed us to use the Now Playing source in macOS 15.4+ - **[NotchDrop](https://github.com/Lakr233/NotchDrop)** – An open-source project that has been instrumental in developing the first version of the "Shelf" feature in Boring Notch. -For a full list of licenses and attributions, please see the [Third-Party Licenses](./THIRD_PARTY_LICENSES.md) file. +For a full list of licenses and attributions, please see the [Third-Party Licenses](./THIRD_PARTY_LICENSES) file. ### Icon credits: [@maxtron95](https://github.com/maxtron95) ### Website credits: [@himanshhhhuv](https://github.com/himanshhhhuv) diff --git a/SECURITY.md b/SECURITY.md index 04c3621d3..1f8ac4f14 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,15 @@ # Security Policy +## Supported Versions + +Only the latest release (and the `main` branch) receives security fixes. +Beta builds on the `dev` branch are development snapshots. + +| Version | Supported | +| ------- | --------- | +| latest release | ✅ | +| older releases | ❌ | + ## Reporting a Vulnerability The Bored Team and community take security bugs in Boring Notch seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. @@ -9,3 +19,37 @@ To report a security issue, please use the GitHub Security Advisory ["Report a V The Bored Team will send a response indicating the next steps in handling your report. After the initial reply to your report, we will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. Report security bugs in third-party dependencies to the person or team maintaining the package or dependency. + +## Security Notes for Users and Contributors + +### Private / undocumented APIs + +Boring Notch uses private macOS APIs and frameworks to deliver features not +possible with the public SDK: the notch window lives in a private SkyLight +space (`boringNotch/private/`), media metadata comes from the private +`MediaRemote.framework` (via the vendored +[MediaRemoteAdapter](mediaremote-adapter/README.md)), and OSD display control +uses private DisplayServices/brightness symbols. These interfaces are +undocumented, may change with any macOS update, and the app may lose features +without warning when they do. This usage is also why the app is distributed +outside the App Store. + +### XPC helper privilege model + +The app is sandboxed (see `boringNotch/boringNotch.entitlements`), but its +bundled XPC service `BoringNotchXPCHelper` +(`BoringNotchXPCHelper/BoringNotchXPCHelper.entitlements`) is **not** — +sandboxed processes cannot drive the Accessibility API on other apps or load +private frameworks the app needs for notification/brightness features. The +helper is intentionally minimal: it exposes a narrow typed protocol +(`Shared/BoringNotchXPCHelperProtocol.swift`) and accepts connections only +from the bundled app. When auditing, treat the helper as the highest-trust +component in the repo: its attack surface is the XPC protocol plus the +Accessibility API. + +### MediaRemote adapter binaries + +`mediaremote-adapter/` contains vendored binaries built from +[ungive/mediaremote-adapter](https://github.com/ungive/mediaremote-adapter); +see its [README](mediaremote-adapter/README.md) for the pinned version, +rebuild instructions, and verification notes. diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift b/Shared/BoringNotchXPCHelperProtocol.swift similarity index 56% rename from BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift rename to Shared/BoringNotchXPCHelperProtocol.swift index 1fc0cb244..5a8a84da2 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift +++ b/Shared/BoringNotchXPCHelperProtocol.swift @@ -52,31 +52,44 @@ final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { func displayIDForBrightness(with reply: @escaping (NSNumber?) -> Void) func currentScreenBrightness(with reply: @escaping (NSNumber?) -> Void) func setScreenBrightness(_ value: Float, with reply: @escaping (Bool) -> Void) - func adjustScreenBrightness(by value: Float, with reply: @escaping (Bool) -> Void) + /// Replies the resulting brightness in 0...1, or nil on failure. + /// Returning the value collapses the old adjust→read two-RPC dance into one call. + func adjustScreenBrightness(by value: Float, with reply: @escaping (NSNumber?) -> Void) // Lunar brightness events (performed by the helper) func isLunarAvailable(with reply: @escaping (Bool) -> Void) func startLunarEventStream(with reply: @escaping (Bool) -> Void) func stopLunarEventStream() /// Write Lunar's hideOSD preference (disable/enable Lunar's OSD when we replace it). func setLunarOSDHidden(_ hide: Bool, with reply: @escaping (Bool) -> Void) + // Notification Center banner observation (performed by the helper) + func startNotificationWatching(with reply: @escaping (Bool) -> Void) + func stopNotificationWatching() + func replyToNotification(_ token: String, text: String, with reply: @escaping (Bool) -> Void) + func sendIMessage(_ text: String, toChatNamed name: String, with reply: @escaping (Bool) -> Void) + func performNotificationAction(_ token: String, name: String, with reply: @escaping (Bool) -> Void) + func openNotification(_ token: String, with reply: @escaping (Bool) -> Void) + func dismissNotification(_ token: String, with reply: @escaping (Bool) -> Void) + func holdNotification(_ token: String) + func releaseNotification(_ token: String) + /// Effective notch-open state (any screen), ref-counted by the client. + /// Gates the banner keep-alive's focus-affecting expansion; the helper + /// defaults to closed so it never steals focus before being told. + func setNotchOpen(_ open: Bool) + func notificationDebugDump(with reply: @escaping (String) -> Void) } -/* - To use the service from an application or other process, use NSXPCConnection to establish a connection to the service by doing something like this: - - connectionToService = NSXPCConnection(serviceName: "theboringteam.boringnotch.BoringNotchXPCHelper") - connectionToService.remoteObjectInterface = NSXPCInterface(with: (any BoringNotchXPCHelperProtocol).self) - connectionToService.resume() - - Once you have a connection to the service, you can use it like this: - - if let proxy = connectionToService.remoteObjectProxy as? BoringNotchXPCHelperProtocol { - proxy.performCalculation(firstNumber: 23, secondNumber: 19) { result in - NSLog("Result of calculation is: \(result)") - } - } +/// Pushed from the helper back to the app. The app sets an object conforming to +/// this as its connection's `exportedObject`. +@objc protocol BoringNotchXPCHelperDelegate { + /// Keys: token, appName, bundleID, title, subtitle, body, actions + /// (`actions` is newline-joined). A plain string dictionary keeps the XPC + /// interface free of custom coded types. + func notificationDidAppear(_ payload: [String: String]) + func notificationDidDisappear(_ token: String) +} - And, when you are finished with the service, clean up the connection like this: +/// A connection has exactly one exported object, and the helper calls back for +/// both Lunar events and notification banners — so the app vends a single +/// object conforming to both. +@objc protocol BoringNotchXPCAppDelegate: BoringNotchXPCHelperLunarListener, BoringNotchXPCHelperDelegate {} - connectionToService.invalidate() -*/ diff --git a/Shared/JSONLinesPipeHandler.swift b/Shared/JSONLinesPipeHandler.swift new file mode 100644 index 000000000..085592570 --- /dev/null +++ b/Shared/JSONLinesPipeHandler.swift @@ -0,0 +1,90 @@ +// +// JSONLinesPipeHandler.swift +// boringNotch +// +// SPDX-License-Identifier: GPL-3.0-only +// +// Shared source compiled into BOTH targets (via the Shared synchronized +// group). There is intentionally one copy — edit once, both sides build it. +// + +import Foundation + +/// Streams newline-delimited JSON from a pipe, decoding each line. +/// Used by the app (mediaremote-adapter now-playing stream) and the XPC +/// helper (Lunar daemon event stream). +actor JSONLinesPipeHandler { + nonisolated let pipe: Pipe + private let fileHandle: FileHandle + private var buffer = "" + private let decoder: JSONDecoder + + init(decoder: JSONDecoder = JSONDecoder()) { + let pipe = Pipe() + self.pipe = pipe + self.fileHandle = pipe.fileHandleForReading + self.decoder = decoder + } + + nonisolated func getPipe() -> Pipe { + pipe + } + + func readJSONLines(as type: T.Type, onLine: @escaping (T) async -> Void) async { + do { + try await processLines(as: type, onLine: onLine) + } catch { + NSLog("JSONLinesPipeHandler stream error: \(error.localizedDescription)") + } + } + + private func processLines(as type: T.Type, onLine: @escaping (T) async -> Void) async throws { + while true { + let data = try await readData() + guard !data.isEmpty else { break } + + if let chunk = String(data: data, encoding: .utf8) { + buffer.append(chunk) + + while let range = buffer.range(of: "\n") { + let line = String(buffer[..(_ line: String, as type: T.Type, onLine: @escaping (T) async -> Void) async { + guard let data = line.data(using: .utf8) else { return } + do { + let decodedObject = try decoder.decode(T.self, from: data) + await onLine(decodedObject) + } catch { + // Ignore lines that can't be decoded. + } + } + + private func readData() async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + fileHandle.readabilityHandler = { handle in + let data = handle.availableData + handle.readabilityHandler = nil + continuation.resume(returning: data) + } + } + } + + func close() async { + do { + fileHandle.readabilityHandler = nil + try fileHandle.close() + try pipe.fileHandleForWriting.close() + } catch { + // Ignore close errors. + } + } +} diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES index 464c84406..337403569 100644 --- a/THIRD_PARTY_LICENSES +++ b/THIRD_PARTY_LICENSES @@ -1,6 +1,7 @@ ----------------------------------------------------------------------------- BSD 3-Clause License applies to: + - AsyncXPCConnection, Copyright (c) 2023, Chime - MediaRemoteAdapter, Copyright (c) 2025, Jonas van den Berg and contributors ----------------------------------------------------------------------------- @@ -33,8 +34,20 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. The MIT License (MIT) applies to: - Calendr, Copyright (c) 2021 Carlos César Neves Enumo + - Defaults, Copyright (c) Sindre Sorhus - DynamicNotchKit, Copyright (c) 2025 Kai Azim + - KeyboardShortcuts, Copyright (c) Sindre Sorhus + - LaunchAtLogin-Modern, Copyright (c) Sindre Sorhus - NotchDrop Copyright (c) 2024 Lakr Aream + - Pow, Copyright (c) 2023 Emerge Tools, Inc. + - SkyLightWindow, Copyright (c) 2025 Lakr Aream + - Sparkle, Copyright (c) 2006-2017 Andy Matuschak, Elgato Systems GmbH, + Kornel Lesiński, Mayur Pawashe, C.W. Betts, Petroules Corporation, + Big Nerd Ranch, and the Sparkle Project contributors + (Sparkle additionally bundles bsdiff, sais-lite, ed25519 and + SUSignatureVerifier under BSD/MIT/zlib-style terms requiring the + same notice reproduction) + - SwiftUI-Introspect, Copyright 2019 Timber Software ----------------------------------------------------------------------------- Permission is hereby granted, free of charge, to any person obtaining a copy @@ -431,3 +444,191 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. + +----------------------------------------------------------------------------- + Apache License + Version 2.0, January 2004 + applies to: + - lottie-spm, Copyright 2018 Airbnb, Inc. + - swift-collections, Copyright (c) 2020 Apple Inc. and the Swift + project authors + - swift-syntax, Copyright (c) 2014 Apple Inc. and the Swift + project authors +----------------------------------------------------------------------------- + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power to, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but not + limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object + form, made available under the License, as indicated by a copyright + notice that is included in or attached to the work (an example is + provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including the + original version of the Work and any modifications or additions to + that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright + owner or by an individual or Legal Entity authorized to submit on + behalf of the copyright owner. For the purposes of this definition, + "submitted" means any form of electronic, verbal, or written + communication sent to the Licensor or its representatives, including + but not limited to communication on electronic mailing lists, source + code control systems, and issue tracking systems that are managed by, + on behalf of, the Licensor for the purpose of discussing the Work and + for excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received and subsequently + incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim or a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct or + contributory patent infringement, then any patent licenses granted + to You under this License for that Work shall terminate as of the + date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or for + any such Derivative Works as a whole, provided Your use, + reproduction, or distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work by + You to the Licensor, other than as expressly provided in this License, + without any additional terms and conditions. Notwithstanding the + above, nothing herein shall supersede or modify the terms of any + separate license agreement that You may have executed with Licensor + regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of your use or redistributing the Work and assume any + risks associated with your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable for any indirect, special, incidental, or consequential damages + of any character arising as a result of this License or out of the + use or inability to use the License, even if such Contributor has + been advised of the possibility of such damages, including without + limitation damages for loss of goodwill, work stoppage, computer + failure or malfunction, or any and all other commercial damages or + losses. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, and + charge a fee for, acceptance of warranty, support, indemnity or + liability obligations and/or rights consistent with this License. + However, in accepting such obligations, You may act only on Your own + behalf and on Your sole responsibility, not on behalf of any other + contributor, and only if You agree to indemnify, defend, and hold + each Contributor harmless for any liability incurred by, or claims + asserted against, such Contributor by reason of your accepting such + warranty or additional liability or indemnity. + +END OF TERMS AND CONDITIONS diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index 500c7cfe6..65a375b8b 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -13,7 +13,6 @@ 1100292A2E8691B400035A57 /* FileShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110029292E8691B400035A57 /* FileShareView.swift */; }; 1100292E2E86940F00035A57 /* QuickShareService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1100292D2E86940F00035A57 /* QuickShareService.swift */; }; 1113ABC52E80E27000EC13B2 /* ShelfItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1113ABC32E80E27000EC13B2 /* ShelfItemView.swift */; }; - A1F000012F00000100000001 /* ShelfItemInteractionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F000022F00000100000001 /* ShelfItemInteractionView.swift */; }; 1113ABC62E80E27000EC13B2 /* ShelfPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1113ABBE2E80E27000EC13B2 /* ShelfPersistenceService.swift */; }; 1113ABC82E80E27000EC13B2 /* ShelfItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1113ABB72E80E27000EC13B2 /* ShelfItem.swift */; }; 1113ABCA2E80E27000EC13B2 /* ShelfSelectionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1113ABC02E80E27000EC13B2 /* ShelfSelectionModel.swift */; }; @@ -88,18 +87,15 @@ 11EFCD702E8E92D600D0B974 /* ShelfItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11EFCD6F2E8E92D600D0B974 /* ShelfItemViewModel.swift */; }; 11F747CE2EC75CEA00F841DB /* DragPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F747CD2EC75CEA00F841DB /* DragPreviewView.swift */; }; 11F7485B2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 11F7484F2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 11F748682EC9AC9600F841DB /* BoringNotchXPCHelperProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F748652EC9AC9600F841DB /* BoringNotchXPCHelperProtocol.swift */; }; 11F748692EC9AC9600F841DB /* XPCHelperClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F748662EC9AC9600F841DB /* XPCHelperClient.swift */; }; 11F748732EC9DA9300F841DB /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 11F748722EC9DA9300F841DB /* Lottie */; }; 11F748822ECB07A400F841DB /* MusicControlButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F748812ECB07A400F841DB /* MusicControlButton.swift */; }; 11F748842ECB27DC00F841DB /* MusicSlotConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F748832ECB27DC00F841DB /* MusicSlotConfigurationView.swift */; }; 14288DDC2C6E015000B9F80C /* AudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14288DD62C6E015000B9F80C /* AudioPlayer.swift */; }; - 14288DE82C6E01C800B9F80C /* ProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14288DE72C6E01C800B9F80C /* ProgressIndicator.swift */; }; 14288E0C2C6F8EC000B9F80C /* AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14288E0B2C6F8EC000B9F80C /* AppIcons.swift */; }; 1443E7F32C609DCE0027C1FC /* matters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1443E7F22C609DCE0027C1FC /* matters.swift */; }; 147163982C5D35B70068B555 /* MusicVisualizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147163972C5D35B70068B555 /* MusicVisualizer.swift */; }; 1471639A2C5D35FF0068B555 /* MusicManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147163992C5D35FF0068B555 /* MusicManager.swift */; }; - 1471A8592C6281BD0058408D /* BoringNotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1471A8582C6281BD0058408D /* BoringNotchWindow.swift */; }; 149E0B972C737D00006418B1 /* WebcamManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149E0B962C737D00006418B1 /* WebcamManager.swift */; }; 149E0B9A2C737D40006418B1 /* WebcamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149E0B992C737D40006418B1 /* WebcamView.swift */; }; 14A7E5882C64A89C008C1BE9 /* HelloAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14A7E5872C64A89C008C1BE9 /* HelloAnimation.swift */; }; @@ -118,27 +114,42 @@ 14D570C02C5EA5870011E668 /* AnimatedFace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570BF2C5EA5870011E668 /* AnimatedFace.swift */; }; 14D570C62C5F38210011E668 /* BoringHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570C52C5F38210011E668 /* BoringHeader.swift */; }; 14D570C92C5F38890011E668 /* BoringViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570C82C5F38890011E668 /* BoringViewModel.swift */; }; - C0D300012F60000100000001 /* DropInteractionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D300022F60000100000001 /* DropInteractionState.swift */; }; 14D570CB2C5F4B2C0011E668 /* BatteryStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570CA2C5F4B2C0011E668 /* BatteryStatusViewModel.swift */; }; 14D570CD2C5F4BB70011E668 /* BoringBattery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570CC2C5F4BB70011E668 /* BoringBattery.swift */; }; 14D570D22C5F6C6A0011E668 /* BoringExtrasMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570D12C5F6C6A0011E668 /* BoringExtrasMenu.swift */; }; - 14E9FEAA2C70BF610062E83F /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14E9FEA92C70BF610062E83F /* DownloadView.swift */; }; 14E9FEAE2C7325770062E83F /* Button+Bouncing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14E9FEAD2C7325770062E83F /* Button+Bouncing.swift */; }; 14FC6E502C7DED5600C7BEA5 /* DataTypes+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14FC6E4F2C7DED5600C7BEA5 /* DataTypes+Extensions.swift */; }; + 1A8B65187F974EE9B7664F5C /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0268933F488E47DFB58E48CF /* VisualEffectView.swift */; }; 201C3E071A104384B8629328 /* AudioOutputRouteResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFAD1670A870402D88BFFE47 /* AudioOutputRouteResolver.swift */; }; 3C1B922F0813671B2886CF43 /* MeetingLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA22021D89A9E4FF88A618D /* MeetingLink.swift */; }; 507266DB2C908E2E00A2D00D /* HoverButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507266DA2C908E2E00A2D00D /* HoverButton.swift */; }; 5917FD112E57891600E87F1C /* MediaKeyInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5917FD102E57891600E87F1C /* MediaKeyInterceptor.swift */; }; 5955950D2E900ED800C66711 /* ApplicationRelauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5955950C2E900ED800C66711 /* ApplicationRelauncher.swift */; }; 59D8C23C2E589FAA00147B33 /* VolumeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59D8C23B2E589FAA00147B33 /* VolumeManager.swift */; }; + 62A83B43C1AD411097E93D17 /* MediaAppBundleID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F20E44C65E4DA1945BFF5C /* MediaAppBundleID.swift */; }; 64FA50FB2F4D6F9E00008A28 /* WebcamSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64FA50FA2F4D6F9E00008A28 /* WebcamSettingsView.swift */; }; + 7EF93DF9889B42E9BCD4634F /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033B8885979E4EA1A78E8ADC /* Log.swift */; }; + 80EA0C01BBCF4069AC5D133D /* AppleScriptControllerSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C26F24108F414781F79D4F /* AppleScriptControllerSupport.swift */; }; + 90ED6E81035940418671DCEA /* NotchWindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB1C3E8B149445A9E57C6AF /* NotchWindowManager.swift */; }; 9A0887322C7A693000C160EA /* TabButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A0887312C7A693000C160EA /* TabButton.swift */; }; 9A0887352C7AFF8E00C160EA /* TabSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A0887342C7AFF8E00C160EA /* TabSelectionView.swift */; }; 9A987A0D2C73CA66005CA465 /* ShelfView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A987A032C73CA66005CA465 /* ShelfView.swift */; }; 9A987A102C73CA8D005CA465 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 9A987A0F2C73CA8D005CA465 /* Collections */; }; 9AB0C6BD2C73C9CB00F7CD30 /* NotchHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AB0C6BB2C73C9CB00F7CD30 /* NotchHomeView.swift */; }; + A1F000012F00000100000001 /* ShelfItemInteractionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F000022F00000100000001 /* ShelfItemInteractionView.swift */; }; + A7F4A06476BC4B029B6BECEA /* NotchUIEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E354C7D712441284085CEA /* NotchUIEvent.swift */; }; + AA01NDW12E7A0001 /* NotificationDebugWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01NDW22E7A0001 /* NotificationDebugWindow.swift */; }; + AA01NLA12E7A0001 /* NotificationLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01NLA22E7A0001 /* NotificationLiveActivity.swift */; }; + AA01SNM12E7A0001 /* SystemNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01SNM22E7A0001 /* SystemNotificationManager.swift */; }; + AA02CAM12E7A0001 /* ContactAvatarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA02CAM22E7A0001 /* ContactAvatarManager.swift */; }; + AA02NSV12E7A0001 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA02NSV22E7A0001 /* NotificationSettingsView.swift */; }; + AA03OTP12E7A0001 /* OTPDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA03OTP22E7A0001 /* OTPDetector.swift */; }; + AA04LAS12E7A0001 /* LiveActivityStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA04LAS22E7A0001 /* LiveActivityStack.swift */; }; + AA05SRM12E7A0001 /* SmartReplyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA05SRM22E7A0001 /* SmartReplyManager.swift */; }; + AA06CHV12E7A0001 /* CompactHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA06CHV22E7A0001 /* CompactHomeView.swift */; }; + AA07ARM12E7A0001 /* AudioRouteManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA07ARM22E7A0001 /* AudioRouteManager.swift */; }; + AFA82F21304B406590337862 /* ShelfContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E49B58CAC648403F864E6D78 /* ShelfContextMenu.swift */; }; B10348D92C74E56000475897 /* ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10348D82C74E56000475897 /* ConditionalModifier.swift */; }; - B10F84A32C6C9596009F3026 /* TestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10F84A22C6C9596009F3026 /* TestView.swift */; }; B141C2412CA5F53F00AC8CC8 /* SparkleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B141C2402CA5F53E00AC8CC8 /* SparkleView.swift */; }; B1628B922CC260C0003D8DF3 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = B1628B912CC260C0003D8DF3 /* SwiftUIIntrospect */; }; B17266DF2C64DFA00031BA0D /* BundleInfos.swift in Sources */ = {isa = PBXBuildFile; fileRef = B17266DE2C64DFA00031BA0D /* BundleInfos.swift */; }; @@ -149,7 +160,6 @@ B19016222CC15B3D00E3F12E /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = B19016212CC15B3D00E3F12E /* Defaults */; }; B19016242CC15B5000E3F12E /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = B19016232CC15B4D00E3F12E /* Constants.swift */; }; B1A78C822C8BA08100BD51B0 /* FullscreenMediaDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A78C812C8BA08100BD51B0 /* FullscreenMediaDetection.swift */; }; - B1B112912C6A572100093D8F /* EditPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1B112902C6A572100093D8F /* EditPanelView.swift */; }; B1B112932C6A577E00093D8F /* MouseTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1B112922C6A577E00093D8F /* MouseTracker.swift */; }; B1C448962C9712C4001F0858 /* ActionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C448952C9712C4001F0858 /* ActionBar.swift */; }; B1C448982C972CC4001F0858 /* ListItemPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C448972C972CC4001F0858 /* ListItemPopover.swift */; }; @@ -160,8 +170,10 @@ B1D6FD432C6603730015F173 /* SoftwareUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D6FD422C6603730015F173 /* SoftwareUpdater.swift */; }; B1F0A0022E60000100000001 /* BrightnessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0A0012E60000100000001 /* BrightnessManager.swift */; }; B1FEB4992C7686630066EBBC /* PanGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1FEB4982C7686630066EBBC /* PanGesture.swift */; }; + C0D300012F60000100000001 /* DropInteractionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D300022F60000100000001 /* DropInteractionState.swift */; }; F1F2A0A100000000000000F1 /* AudioCaptureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1F2A0A200000000000000F2 /* AudioCaptureManager.swift */; }; F38DE6482D8243E7008B5C6D /* BatteryActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */; }; + F80A422BE2974CF6808C84CA /* MediaEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D24CBD49614ECE88F062B5 /* MediaEnvironment.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -207,6 +219,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0268933F488E47DFB58E48CF /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; + 033B8885979E4EA1A78E8ADC /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; 1100290B2E847E2800035A57 /* NSItemProvider+LoadHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSItemProvider+LoadHelpers.swift"; sourceTree = ""; }; 110029262E84FD4C00035A57 /* TemporaryFileStorageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryFileStorageService.swift; sourceTree = ""; }; 110029292E8691B400035A57 /* FileShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileShareView.swift; sourceTree = ""; }; @@ -218,7 +232,6 @@ 1113ABBE2E80E27000EC13B2 /* ShelfPersistenceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfPersistenceService.swift; sourceTree = ""; }; 1113ABC02E80E27000EC13B2 /* ShelfSelectionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfSelectionModel.swift; sourceTree = ""; }; 1113ABC32E80E27000EC13B2 /* ShelfItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfItemView.swift; sourceTree = ""; }; - A1F000022F00000100000001 /* ShelfItemInteractionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfItemInteractionView.swift; sourceTree = ""; }; 1113ABCF2E80E6BB00EC13B2 /* ThumbnailService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailService.swift; sourceTree = ""; }; 111BE9942ECF2DEF0079DD4E /* DragDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DragDetector.swift; sourceTree = ""; }; 111BEA602ED09B1B0079DD4E /* NSScreen+UUID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScreen+UUID.swift"; sourceTree = ""; }; @@ -281,18 +294,15 @@ 11EFCD6F2E8E92D600D0B974 /* ShelfItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfItemViewModel.swift; sourceTree = ""; }; 11F747CD2EC75CEA00F841DB /* DragPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DragPreviewView.swift; sourceTree = ""; }; 11F7484F2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = BoringNotchXPCHelper.xpc; sourceTree = BUILT_PRODUCTS_DIR; }; - 11F748652EC9AC9600F841DB /* BoringNotchXPCHelperProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringNotchXPCHelperProtocol.swift; sourceTree = ""; }; 11F748662EC9AC9600F841DB /* XPCHelperClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XPCHelperClient.swift; sourceTree = ""; }; 11F748812ECB07A400F841DB /* MusicControlButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicControlButton.swift; sourceTree = ""; }; 11F748832ECB27DC00F841DB /* MusicSlotConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicSlotConfigurationView.swift; sourceTree = ""; }; 14288DD62C6E015000B9F80C /* AudioPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = ""; }; - 14288DE72C6E01C800B9F80C /* ProgressIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = ""; }; 14288E0B2C6F8EC000B9F80C /* AppIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcons.swift; sourceTree = ""; }; 1443E7F22C609DCE0027C1FC /* matters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = matters.swift; sourceTree = ""; }; 1443E7F42C609E650027C1FC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 147163972C5D35B70068B555 /* MusicVisualizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicVisualizer.swift; sourceTree = ""; }; 147163992C5D35FF0068B555 /* MusicManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicManager.swift; sourceTree = ""; }; - 1471A8582C6281BD0058408D /* BoringNotchWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringNotchWindow.swift; sourceTree = ""; }; 149E0B962C737D00006418B1 /* WebcamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebcamManager.swift; sourceTree = ""; }; 149E0B992C737D40006418B1 /* WebcamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebcamView.swift; sourceTree = ""; }; 14A7E5872C64A89C008C1BE9 /* HelloAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelloAnimation.swift; sourceTree = ""; }; @@ -313,27 +323,39 @@ 14D570BF2C5EA5870011E668 /* AnimatedFace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedFace.swift; sourceTree = ""; }; 14D570C52C5F38210011E668 /* BoringHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringHeader.swift; sourceTree = ""; }; 14D570C82C5F38890011E668 /* BoringViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringViewModel.swift; sourceTree = ""; }; - C0D300022F60000100000001 /* DropInteractionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropInteractionState.swift; sourceTree = ""; }; 14D570CA2C5F4B2C0011E668 /* BatteryStatusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryStatusViewModel.swift; sourceTree = ""; }; 14D570CC2C5F4BB70011E668 /* BoringBattery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringBattery.swift; sourceTree = ""; }; 14D570D12C5F6C6A0011E668 /* BoringExtrasMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringExtrasMenu.swift; sourceTree = ""; }; - 14E9FEA92C70BF610062E83F /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; }; 14E9FEAD2C7325770062E83F /* Button+Bouncing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Button+Bouncing.swift"; sourceTree = ""; }; 14FC6E4F2C7DED5600C7BEA5 /* DataTypes+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataTypes+Extensions.swift"; sourceTree = ""; }; 3CA22021D89A9E4FF88A618D /* MeetingLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MeetingLink.swift; sourceTree = ""; }; 507266DA2C908E2E00A2D00D /* HoverButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverButton.swift; sourceTree = ""; }; + 54D24CBD49614ECE88F062B5 /* MediaEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaEnvironment.swift; sourceTree = ""; }; 5917FD102E57891600E87F1C /* MediaKeyInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaKeyInterceptor.swift; sourceTree = ""; }; 5955950C2E900ED800C66711 /* ApplicationRelauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationRelauncher.swift; sourceTree = ""; }; 59D8C23B2E589FAA00147B33 /* VolumeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeManager.swift; sourceTree = ""; }; + 61F20E44C65E4DA1945BFF5C /* MediaAppBundleID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaAppBundleID.swift; sourceTree = ""; }; 64FA50FA2F4D6F9E00008A28 /* WebcamSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebcamSettingsView.swift; sourceTree = ""; }; + 80E354C7D712441284085CEA /* NotchUIEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchUIEvent.swift; sourceTree = ""; }; 9A0887312C7A693000C160EA /* TabButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabButton.swift; sourceTree = ""; }; 9A0887342C7AFF8E00C160EA /* TabSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabSelectionView.swift; sourceTree = ""; }; 9A987A032C73CA66005CA465 /* ShelfView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShelfView.swift; sourceTree = ""; }; 9AB0C6BB2C73C9CB00F7CD30 /* NotchHomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotchHomeView.swift; sourceTree = ""; }; + A1F000022F00000100000001 /* ShelfItemInteractionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfItemInteractionView.swift; sourceTree = ""; }; A5167212301F85B40018095A /* boringNotchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = boringNotchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + AA01NDW22E7A0001 /* NotificationDebugWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDebugWindow.swift; sourceTree = ""; }; + AA01NLA22E7A0001 /* NotificationLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationLiveActivity.swift; sourceTree = ""; }; + AA01SNM22E7A0001 /* SystemNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemNotificationManager.swift; sourceTree = ""; }; + AA02CAM22E7A0001 /* ContactAvatarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactAvatarManager.swift; sourceTree = ""; }; + AA02NSV22E7A0001 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; + AA03OTP22E7A0001 /* OTPDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPDetector.swift; sourceTree = ""; }; + AA04LAS22E7A0001 /* LiveActivityStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityStack.swift; sourceTree = ""; }; + AA05SRM22E7A0001 /* SmartReplyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartReplyManager.swift; sourceTree = ""; }; + AA06CHV22E7A0001 /* CompactHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactHomeView.swift; sourceTree = ""; }; + AA07ARM22E7A0001 /* AudioRouteManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRouteManager.swift; sourceTree = ""; }; AFAD1670A870402D88BFFE47 /* AudioOutputRouteResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioOutputRouteResolver.swift; sourceTree = ""; }; + B0C26F24108F414781F79D4F /* AppleScriptControllerSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptControllerSupport.swift; sourceTree = ""; }; B10348D82C74E56000475897 /* ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalModifier.swift; sourceTree = ""; }; - B10F84A22C6C9596009F3026 /* TestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestView.swift; sourceTree = ""; }; B141C2402CA5F53E00AC8CC8 /* SparkleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleView.swift; sourceTree = ""; }; B17266DE2C64DFA00031BA0D /* BundleInfos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleInfos.swift; sourceTree = ""; }; B17266E02C6532560031BA0D /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; @@ -341,7 +363,6 @@ B186543B2C6F49AE000B926A /* ShortcutConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutConstants.swift; sourceTree = ""; }; B19016232CC15B4D00E3F12E /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; B1A78C812C8BA08100BD51B0 /* FullscreenMediaDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenMediaDetection.swift; sourceTree = ""; }; - B1B112902C6A572100093D8F /* EditPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPanelView.swift; sourceTree = ""; }; B1B112922C6A577E00093D8F /* MouseTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MouseTracker.swift; sourceTree = ""; }; B1C448952C9712C4001F0858 /* ActionBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionBar.swift; sourceTree = ""; }; B1C448972C972CC4001F0858 /* ListItemPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListItemPopover.swift; sourceTree = ""; }; @@ -353,6 +374,9 @@ B1F0A0012E60000100000001 /* BrightnessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessManager.swift; sourceTree = ""; }; B1FEB4982C7686630066EBBC /* PanGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanGesture.swift; sourceTree = ""; }; B9B2A5E5F4D28F1DD9CD0B09 /* MeetingLinkDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MeetingLinkDetector.swift; sourceTree = ""; }; + C0D300022F60000100000001 /* DropInteractionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropInteractionState.swift; sourceTree = ""; }; + E49B58CAC648403F864E6D78 /* ShelfContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfContextMenu.swift; sourceTree = ""; }; + EAB1C3E8B149445A9E57C6AF /* NotchWindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchWindowManager.swift; sourceTree = ""; }; F1F2A0A200000000000000F2 /* AudioCaptureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioCaptureManager.swift; sourceTree = ""; }; F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryActivityManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -370,6 +394,7 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 112FB72F2CCF12CC0015238C /* private */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = private; sourceTree = ""; }; 11F748502EC9AABA00F841DB /* BoringNotchXPCHelper */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (11F7485C2EC9AABA00F841DB /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = BoringNotchXPCHelper; sourceTree = ""; }; + 8137A8BA990F4D9CBA56CC7A /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; }; A5167213301F85B40018095A /* boringNotchTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = boringNotchTests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -453,6 +478,7 @@ 110029292E8691B400035A57 /* FileShareView.swift */, 1113ABC32E80E27000EC13B2 /* ShelfItemView.swift */, A1F000022F00000100000001 /* ShelfItemInteractionView.swift */, + E49B58CAC648403F864E6D78 /* ShelfContextMenu.swift */, 9A987A032C73CA66005CA465 /* ShelfView.swift */, ); path = Views; @@ -467,7 +493,7 @@ path = "mediaremote-adapter"; sourceTree = ""; }; - 1132E5232E78D6DA0068732D /* YouTube Music Controller */ = { + 1132E5232E78D6DA0068732D /* YouTubeMusicController */ = { isa = PBXGroup; children = ( 1132E5152E777C140068732D /* YouTubeMusicAuthentication.swift */, @@ -475,15 +501,17 @@ 1132E5102E777B6E0068732D /* YouTubeMusicModels.swift */, 1153BDA62D99B22200979FB0 /* YouTubeMusicController.swift */, ); - path = "YouTube Music Controller"; + path = YouTubeMusicController; sourceTree = ""; }; 1153BD8E2D986B1F00979FB0 /* MediaControllers */ = { isa = PBXGroup; children = ( - 1132E5232E78D6DA0068732D /* YouTube Music Controller */, + 1132E5232E78D6DA0068732D /* YouTubeMusicController */, 1153BD8D2D986B1F00979FB0 /* MediaControllerProtocol.swift */, 1153BD922D986E4300979FB0 /* AppleMusicController.swift */, + 61F20E44C65E4DA1945BFF5C /* MediaAppBundleID.swift */, + B0C26F24108F414781F79D4F /* AppleScriptControllerSupport.swift */, 1153BD992D98824300979FB0 /* SpotifyController.swift */, 1153BD9B2D98853B00979FB0 /* NowPlayingController.swift */, ); @@ -556,6 +584,7 @@ 11DB26692EDD0CDF001EA0CF /* AppearanceSettingsView.swift */, 11DB266A2EDD0CDF001EA0CF /* BatterySettingsView.swift */, 11DB266B2EDD0CDF001EA0CF /* CalendarSettingsView.swift */, + AA02NSV22E7A0001 /* NotificationSettingsView.swift */, 11DB266C2EDD0CDF001EA0CF /* GeneralSettingsView.swift */, 11DB266D2EDD0CDF001EA0CF /* OSDSettingsView.swift */, 11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */, @@ -570,7 +599,6 @@ 11F748672EC9AC9600F841DB /* XPCHelperClient */ = { isa = PBXGroup; children = ( - 11F748652EC9AC9600F841DB /* BoringNotchXPCHelperProtocol.swift */, 11F748662EC9AC9600F841DB /* XPCHelperClient.swift */, ); path = XPCHelperClient; @@ -581,9 +609,12 @@ children = ( 118EBE242E92DCCB00D54B5A /* AssociatedObject.swift */, 11A45C782E34E63100CEB175 /* MediaChecker.swift */, + 033B8885979E4EA1A78E8ADC /* Log.swift */, + 54D24CBD49614ECE88F062B5 /* MediaEnvironment.swift */, 1153BD972D9881F900979FB0 /* AppleScriptHelper.swift */, 14288DD62C6E015000B9F80C /* AudioPlayer.swift */, 5955950C2E900ED800C66711 /* ApplicationRelauncher.swift */, + AA03OTP22E7A0001 /* OTPDetector.swift */, 14288E0B2C6F8EC000B9F80C /* AppIcons.swift */, AFAD1670A870402D88BFFE47 /* AudioOutputRouteResolver.swift */, ); @@ -612,19 +643,19 @@ isa = PBXGroup; children = ( 11985BDF2F37A3C800F81585 /* OSD */, + AA01NDW22E7A0001 /* NotificationDebugWindow.swift */, B141C23B2CA5F50900AC8CC8 /* Onboarding */, 14C08BB72C8DE49E000F8AA0 /* Calendar */, 9A987A042C73CA66005CA465 /* Shelf */, 149E0B982C737D26006418B1 /* Webcam */, - B18654312C6F45AE000B926A /* Live activities */, + B18654312C6F45AE000B926A /* LiveActivities */, B18654302C6F4590000B926A /* Settings */, B186542F2C6F455E000B926A /* Notch */, 9A0887332C7AFF7E00C160EA /* Tabs */, B186542E2C6F453B000B926A /* Music */, 14D570BF2C5EA5870011E668 /* AnimatedFace.swift */, - 14288DE72C6E01C800B9F80C /* ProgressIndicator.swift */, - B10F84A22C6C9596009F3026 /* TestView.swift */, 507266DA2C908E2E00A2D00D /* HoverButton.swift */, + 0268933F488E47DFB58E48CF /* VisualEffectView.swift */, ); path = components; sourceTree = ""; @@ -636,7 +667,12 @@ 11D58EA12E760AE100FA8377 /* ImageService.swift */, F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */, 112FB7342CCF16F70015238C /* NotchSpaceManager.swift */, + EAB1C3E8B149445A9E57C6AF /* NotchWindowManager.swift */, + AA01SNM22E7A0001 /* SystemNotificationManager.swift */, + AA02CAM22E7A0001 /* ContactAvatarManager.swift */, 147163992C5D35FF0068B555 /* MusicManager.swift */, + AA07ARM22E7A0001 /* AudioRouteManager.swift */, + AA05SRM22E7A0001 /* SmartReplyManager.swift */, F1F2A0A200000000000000F2 /* AudioCaptureManager.swift */, 149E0B962C737D00006418B1 /* WebcamManager.swift */, 14C08BB52C8DE42D000F8AA0 /* CalendarManager.swift */, @@ -669,6 +705,7 @@ A5167213301F85B40018095A /* boringNotchTests */, 14CEF4132C5CAED300855D72 /* Products */, 14D031EC2C689DB70096E6A1 /* Frameworks */, + 600E881A3042DEAD00B17BFC /* Recovered References */, ); sourceTree = ""; }; @@ -758,12 +795,21 @@ 14D570C82C5F38890011E668 /* BoringViewModel.swift */, C0D300022F60000100000001 /* DropInteractionState.swift */, 14D570CA2C5F4B2C0011E668 /* BatteryStatusViewModel.swift */, + 80E354C7D712441284085CEA /* NotchUIEvent.swift */, 1153BD902D986DB300979FB0 /* PlaybackState.swift */, 3CA22021D89A9E4FF88A618D /* MeetingLink.swift */, ); path = models; sourceTree = ""; }; + 600E881A3042DEAD00B17BFC /* Recovered References */ = { + isa = PBXGroup; + children = ( + 8137A8BA990F4D9CBA56CC7A /* Shared */, + ); + name = "Recovered References"; + sourceTree = ""; + }; 9A0887332C7AFF7E00C160EA /* Tabs */ = { isa = PBXGroup; children = ( @@ -829,12 +875,14 @@ B186542F2C6F455E000B926A /* Notch */ = { isa = PBXGroup; children = ( + AA01NLA22E7A0001 /* NotificationLiveActivity.swift */, + AA06CHV22E7A0001 /* CompactHomeView.swift */, + AA04LAS22E7A0001 /* LiveActivityStack.swift */, 1194E8862EA6DDA7009C82D6 /* BoringNotchSkyLightWindow.swift */, 1160F8D72DD98230006FBB94 /* NotchShape.swift */, 9AB0C6BB2C73C9CB00F7CD30 /* NotchHomeView.swift */, 14D570C52C5F38210011E668 /* BoringHeader.swift */, 14D570D12C5F6C6A0011E668 /* BoringExtrasMenu.swift */, - 1471A8582C6281BD0058408D /* BoringNotchWindow.swift */, ); path = Notch; sourceTree = ""; @@ -847,21 +895,19 @@ 11C5E3152DFE88510065821E /* SettingsView.swift */, 11C5E3112DFE85970065821E /* SettingsWindowController.swift */, B1D6FD422C6603730015F173 /* SoftwareUpdater.swift */, - B1B112902C6A572100093D8F /* EditPanelView.swift */, B1C448972C972CC4001F0858 /* ListItemPopover.swift */, ); path = Settings; sourceTree = ""; }; - B18654312C6F45AE000B926A /* Live activities */ = { + B18654312C6F45AE000B926A /* LiveActivities */ = { isa = PBXGroup; children = ( 14D570CC2C5F4BB70011E668 /* BoringBattery.swift */, B1C974332C642B6D0000E707 /* MarqueeTextView.swift */, B1D365CD2C6A979C0047BDBC /* LiveActivityModifier.swift */, - 14E9FEA92C70BF610062E83F /* DownloadView.swift */, ); - path = "Live activities"; + path = LiveActivities; sourceTree = ""; }; B186543A2C6F49A4000B926A /* Shortcuts */ = { @@ -889,6 +935,7 @@ ); fileSystemSynchronizedGroups = ( 11F748502EC9AABA00F841DB /* BoringNotchXPCHelper */, + 8137A8BA990F4D9CBA56CC7A /* Shared */, ); name = BoringNotchXPCHelper; productName = BoringNotchXPCHelper; @@ -912,6 +959,7 @@ ); fileSystemSynchronizedGroups = ( 112FB72F2CCF12CC0015238C /* private */, + 8137A8BA990F4D9CBA56CC7A /* Shared */, ); name = boringNotch; packageProductDependencies = ( @@ -1061,6 +1109,8 @@ 11CC44A22CEE614100C7244B /* BoringViewCoordinator.swift in Sources */, B186543C2C6F49AE000B926A /* ShortcutConstants.swift in Sources */, 11A45C792E34E63100CEB175 /* MediaChecker.swift in Sources */, + 7EF93DF9889B42E9BCD4634F /* Log.swift in Sources */, + F80A422BE2974CF6808C84CA /* MediaEnvironment.swift in Sources */, B1D365CE2C6A979C0047BDBC /* LiveActivityModifier.swift in Sources */, 1113ABD02E80E6BB00EC13B2 /* ThumbnailService.swift in Sources */, 11CFC65B2E097E9D00748C80 /* WelcomeView.swift in Sources */, @@ -1071,6 +1121,7 @@ 1194E87C2EA19E09009C82D6 /* ImageProcessingService.swift in Sources */, 1194E8872EA6DDA7009C82D6 /* BoringNotchSkyLightWindow.swift in Sources */, 14D570CB2C5F4B2C0011E668 /* BatteryStatusViewModel.swift in Sources */, + A7F4A06476BC4B029B6BECEA /* NotchUIEvent.swift in Sources */, 9A0887322C7A693000C160EA /* TabButton.swift in Sources */, 1153BD9C2D98853B00979FB0 /* NowPlayingController.swift in Sources */, 11985BEF2F37E48900F81585 /* OSDIconView.swift in Sources */, @@ -1086,9 +1137,9 @@ 1153BD9A2D98824300979FB0 /* SpotifyController.swift in Sources */, 118EBE292E946B3F00D54B5A /* ShareServiceFinder.swift in Sources */, B1C974342C642B6D0000E707 /* MarqueeTextView.swift in Sources */, - 14288DE82C6E01C800B9F80C /* ProgressIndicator.swift in Sources */, 1113ABC52E80E27000EC13B2 /* ShelfItemView.swift in Sources */, A1F000012F00000100000001 /* ShelfItemInteractionView.swift in Sources */, + AFA82F21304B406590337862 /* ShelfContextMenu.swift in Sources */, 1113ABC62E80E27000EC13B2 /* ShelfPersistenceService.swift in Sources */, 11DB26662EDD0BE1001EA0CF /* LyricsService.swift in Sources */, 1113ABC82E80E27000EC13B2 /* ShelfItem.swift in Sources */, @@ -1101,7 +1152,16 @@ 5917FD112E57891600E87F1C /* MediaKeyInterceptor.swift in Sources */, 14C08BC12C8E03AD000F8AA0 /* NSImage+Extensions.swift in Sources */, 149E0B9A2C737D40006418B1 /* WebcamView.swift in Sources */, + AA01SNM12E7A0001 /* SystemNotificationManager.swift in Sources */, + AA01NDW12E7A0001 /* NotificationDebugWindow.swift in Sources */, + AA01NLA12E7A0001 /* NotificationLiveActivity.swift in Sources */, + AA06CHV12E7A0001 /* CompactHomeView.swift in Sources */, + AA04LAS12E7A0001 /* LiveActivityStack.swift in Sources */, + AA02CAM12E7A0001 /* ContactAvatarManager.swift in Sources */, + AA03OTP12E7A0001 /* OTPDetector.swift in Sources */, 1471639A2C5D35FF0068B555 /* MusicManager.swift in Sources */, + AA07ARM12E7A0001 /* AudioRouteManager.swift in Sources */, + AA05SRM12E7A0001 /* SmartReplyManager.swift in Sources */, F1F2A0A100000000000000F1 /* AudioCaptureManager.swift in Sources */, B1B112932C6A577E00093D8F /* MouseTracker.swift in Sources */, B1C448962C9712C4001F0858 /* ActionBar.swift in Sources */, @@ -1113,8 +1173,6 @@ 11985BF42F38520A00F81585 /* DraggableProgressBar.swift in Sources */, 9AB0C6BD2C73C9CB00F7CD30 /* NotchHomeView.swift in Sources */, B172AAC02C95DA0B001623F1 /* InlineOSD.swift in Sources */, - 14E9FEAA2C70BF610062E83F /* DownloadView.swift in Sources */, - B1B112912C6A572100093D8F /* EditPanelView.swift in Sources */, 1153BD912D986DB300979FB0 /* PlaybackState.swift in Sources */, B1A78C822C8BA08100BD51B0 /* FullscreenMediaDetection.swift in Sources */, 14E9FEAE2C7325770062E83F /* Button+Bouncing.swift in Sources */, @@ -1124,6 +1182,7 @@ 118EBE272E92DE8400D54B5A /* NSMenu+AssociatedObject.swift in Sources */, B1CE8CFE2C6F659400DD9871 /* KeyboardShortcutsHelper.swift in Sources */, 11DB26732EDD0CDF001EA0CF /* ShortcutsSettingsView.swift in Sources */, + AA02NSV12E7A0001 /* NotificationSettingsView.swift in Sources */, 11DB26742EDD0CDF001EA0CF /* GeneralSettingsView.swift in Sources */, 11DB26752EDD0CDF001EA0CF /* ShelfSettingsView.swift in Sources */, 11DB26762EDD0CDF001EA0CF /* AppearanceSettingsView.swift in Sources */, @@ -1137,16 +1196,17 @@ 14D570C62C5F38210011E668 /* BoringHeader.swift in Sources */, 14C08BB62C8DE42D000F8AA0 /* CalendarManager.swift in Sources */, 14D570CD2C5F4BB70011E668 /* BoringBattery.swift in Sources */, - B10F84A32C6C9596009F3026 /* TestView.swift in Sources */, 1443E7F32C609DCE0027C1FC /* matters.swift in Sources */, 11C5E3162DFE88510065821E /* SettingsView.swift in Sources */, 1153BD932D986E4300979FB0 /* AppleMusicController.swift in Sources */, + 62A83B43C1AD411097E93D17 /* MediaAppBundleID.swift in Sources */, + 80EA0C01BBCF4069AC5D133D /* AppleScriptControllerSupport.swift in Sources */, 11C5E3132DFE85970065821E /* SettingsWindowController.swift in Sources */, 110029272E84FD4C00035A57 /* TemporaryFileStorageService.swift in Sources */, 11CFC6652E09C7B300748C80 /* OnboardingFinishView.swift in Sources */, 64FA50FB2F4D6F9E00008A28 /* WebcamSettingsView.swift in Sources */, 507266DB2C908E2E00A2D00D /* HoverButton.swift in Sources */, - 1471A8592C6281BD0058408D /* BoringNotchWindow.swift in Sources */, + 1A8B65187F974EE9B7664F5C /* VisualEffectView.swift in Sources */, 14CEF4182C5CAED300855D72 /* ContentView.swift in Sources */, 9A987A0D2C73CA66005CA465 /* ShelfView.swift in Sources */, 1132E5142E777B920068732D /* YouTubeMusicNetworking.swift in Sources */, @@ -1161,7 +1221,6 @@ 118D1FD12E98FF5F00A2FF63 /* SharingStateManager.swift in Sources */, 11985BE62F37A3FC00F81585 /* BetterDisplayNotificationModels.swift in Sources */, 118EBE252E92DCCB00D54B5A /* AssociatedObject.swift in Sources */, - 11F748682EC9AC9600F841DB /* BoringNotchXPCHelperProtocol.swift in Sources */, 11F748692EC9AC9600F841DB /* XPCHelperClient.swift in Sources */, 118EBE2D2E97165600D54B5A /* Bookmark.swift in Sources */, 11985BE02F37A3C800F81585 /* BetterDisplayManager.swift in Sources */, @@ -1170,6 +1229,7 @@ 11F748842ECB27DC00F841DB /* MusicSlotConfigurationView.swift in Sources */, 9A0887352C7AFF8E00C160EA /* TabSelectionView.swift in Sources */, 112FB7352CCF16F70015238C /* NotchSpaceManager.swift in Sources */, + 90ED6E81035940418671DCEA /* NotchWindowManager.swift in Sources */, 14FC6E502C7DED5600C7BEA5 /* DataTypes+Extensions.swift in Sources */, 1153BD982D9881F900979FB0 /* AppleScriptHelper.swift in Sources */, 11CFC6612E097F6800748C80 /* PermissionsRequestView.swift in Sources */, @@ -1223,10 +1283,11 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 272; DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BoringNotchXPCHelper/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BoringNotchXPCHelper; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024-2026 The Bored Team. Licensed under GPL-3.0."; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = "2.8-beta.0"; PRODUCT_BUNDLE_IDENTIFIER = theboringteam.boringnotch.BoringNotchXPCHelper; @@ -1248,10 +1309,11 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 272; DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BoringNotchXPCHelper/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BoringNotchXPCHelper; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024-2026 The Bored Team. Licensed under GPL-3.0."; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = "2.8-beta.0"; PRODUCT_BUNDLE_IDENTIFIER = theboringteam.boringnotch.BoringNotchXPCHelper; @@ -1322,6 +1384,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = complete; @@ -1381,6 +1444,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; @@ -1410,7 +1474,7 @@ ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; ENABLE_RESOURCE_ACCESS_CAMERA = NO; - ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = YES; ENABLE_RESOURCE_ACCESS_LOCATION = NO; ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1427,7 +1491,8 @@ INFOPLIST_KEY_NSAppleEventsUsageDescription = "This app uses AppleEvents to control music"; INFOPLIST_KEY_NSCalendarsUsageDescription = "This app uses the calendar to display your calendar events"; INFOPLIST_KEY_NSCameraUsageDescription = "This app uses the camera to display a live camera view"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSContactsUsageDescription = "This app matches notification senders to your contacts to show their photo"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024-2026 The Bored Team. Licensed under GPL-3.0."; INFOPLIST_KEY_NSRemindersUsageDescription = "This app uses Reminders to display your scheduled reminder in the calendar"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -1477,7 +1542,7 @@ ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; ENABLE_RESOURCE_ACCESS_CAMERA = NO; - ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = YES; ENABLE_RESOURCE_ACCESS_LOCATION = NO; ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -1493,7 +1558,8 @@ INFOPLIST_KEY_NSAppleEventsUsageDescription = "This app uses AppleEvents to control music"; INFOPLIST_KEY_NSCalendarsUsageDescription = "This app uses the calendar to display your calendar events"; INFOPLIST_KEY_NSCameraUsageDescription = "This app uses the camera to display a live camera view"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSContactsUsageDescription = "This app matches notification senders to your contacts to show their photo"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024-2026 The Bored Team. Licensed under GPL-3.0."; INFOPLIST_KEY_NSRemindersUsageDescription = "This app uses Reminders to display your scheduled reminder in the calendar"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/boringNotch.xcodeproj/xcshareddata/xcschemes/boringNotch.xcscheme b/boringNotch.xcodeproj/xcshareddata/xcschemes/boringNotch.xcscheme new file mode 100644 index 000000000..f5400b8fc --- /dev/null +++ b/boringNotch.xcodeproj/xcshareddata/xcschemes/boringNotch.xcscheme @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/boringNotch/BoringViewCoordinator.swift b/boringNotch/BoringViewCoordinator.swift index 654c34936..95431b6cf 100644 --- a/boringNotch/BoringViewCoordinator.swift +++ b/boringNotch/BoringViewCoordinator.swift @@ -20,7 +20,7 @@ enum SneakContentType { case download } -struct sneakPeek { +struct SneakPeekState { var show: Bool = false var type: SneakContentType = .music var value: CGFloat = 0 @@ -29,13 +29,6 @@ struct sneakPeek { var targetScreenUUID: String? = nil } -struct SharedSneakPeek: Codable { - var show: Bool - var type: String - var value: String - var icon: String -} - enum BrowserType { case chromium case safari @@ -49,13 +42,11 @@ struct ExpandedItem { } @MainActor -class BoringViewCoordinator: ObservableObject { +final class BoringViewCoordinator: ObservableObject { static let shared = BoringViewCoordinator() @Published var currentView: NotchViews = .home @Published var helloAnimationRunning: Bool = false - private var sneakPeekDispatch: DispatchWorkItem? - private var expandingViewDispatch: DispatchWorkItem? private var osdEnableTask: Task? @AppStorage("firstLaunch") var firstLaunch: Bool = true @@ -101,6 +92,8 @@ class BoringViewCoordinator: ObservableObject { private var osdReplacementCancellable: AnyCancellable? private var boringShelfCancellable: AnyCancellable? private var osdSourceCancellables: [AnyCancellable] = [] + private var notificationLiveActivityCancellable: AnyCancellable? + private var uiEventCancellable: AnyCancellable? private init() { // Perform migration from name-based to UUID-based storage @@ -138,6 +131,24 @@ class BoringViewCoordinator: ObservableObject { XPCHelperClient.shared.startMonitoringAccessibilityAuthorization() + // Managers publish presentation events through the bus instead of + // calling into the coordinator directly; the coordinator is the + // single presenter (and keeps all show/hide policy in one place). + uiEventCancellable = NotchUIEventBus.events + .sink { [weak self] event in + Task { @MainActor in + guard let self else { return } + switch event { + case .sneakPeek(let type, let value, let icon, let accent, let uuid, let duration): + self.toggleSneakPeek( + status: true, type: type, duration: duration, value: value, + icon: icon, accent: accent, targetScreenUUID: uuid) + case .expandingView(let type): + self.toggleExpandingView(status: true, type: type) + } + } + } + // Observe changes to osdReplacement osdReplacementCancellable = Defaults.publisher(.osdReplacement) .sink { [weak self] change in @@ -173,56 +184,43 @@ class BoringViewCoordinator: ObservableObject { } } + // Observe changes to the notification live activity toggle; it owns + // the notification watcher lifecycle. + notificationLiveActivityCancellable = Defaults.publisher(.notificationLiveActivity) + .sink { change in + Task { @MainActor in + if change.newValue { + await SystemNotificationManager.shared.start() + if !SystemNotificationManager.shared.isWatching { + Defaults[.notificationLiveActivity] = false + } + } else { + SystemNotificationManager.shared.stop() + } + } + } + Task { @MainActor in helloAnimationRunning = firstLaunch if Defaults[.osdReplacement] { await MediaKeyInterceptor.shared.start(promptIfNeeded: false) } + + if Defaults[.notificationLiveActivity] { + await SystemNotificationManager.shared.start() + } self.applyOSDSources() } } - @objc func sneakPeekEvent(_ notification: Notification) { - let decoder = JSONDecoder() - if let decodedData = try? decoder.decode( - SharedSneakPeek.self, from: notification.userInfo?.first?.value as! Data) - { - let contentType = - decodedData.type == "brightness" - ? SneakContentType.brightness - : decodedData.type == "volume" - ? SneakContentType.volume - : decodedData.type == "backlight" - ? SneakContentType.backlight - : decodedData.type == "mic" - ? SneakContentType.mic : SneakContentType.brightness - - let formatter = NumberFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.numberStyle = .decimal - let value = CGFloat((formatter.number(from: decodedData.value) ?? 0.0).floatValue) - let icon = decodedData.icon - - print("Decoded: \(decodedData), Parsed value: \(value)") - - toggleSneakPeek(status: decodedData.show, type: contentType, value: value, icon: icon) - - } else { - print("Failed to decode JSON data") - } - } - // MARK: - Per-Screen Sneak Peek Management // Dictionary to hold sneak peek state for each screen UUID - @Published var sneakPeekStates: [String: sneakPeek] = [:] - + @Published var sneakPeekStates: [String: SneakPeekState] = [:] + // Dictionary to hold hide tasks for each screen UUID private var sneakPeekTasks: [String: Task] = [:] - - // Default duration - private var defaultSneakPeekDuration: TimeInterval = 1.5 func toggleSneakPeek( status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, @@ -240,7 +238,7 @@ class BoringViewCoordinator: ObservableObject { @MainActor func updateState(for uuid: String) { // If we don't have a state for this screen yet, initialize it - var state = self.sneakPeekStates[uuid] ?? sneakPeek(targetScreenUUID: uuid) + var state = self.sneakPeekStates[uuid] ?? SneakPeekState(targetScreenUUID: uuid) withAnimation(.smooth) { state.show = status @@ -335,17 +333,17 @@ class BoringViewCoordinator: ObservableObject { } // Helper to get state safely for binding/reading - func sneakPeekState(for screenUUID: String?) -> sneakPeek { - guard let uuid = screenUUID else { return sneakPeek() } - return sneakPeekStates[uuid] ?? sneakPeek(targetScreenUUID: uuid) + func sneakPeekState(for screenUUID: String?) -> SneakPeekState { + guard let uuid = screenUUID else { return SneakPeekState() } + return sneakPeekStates[uuid] ?? SneakPeekState(targetScreenUUID: uuid) } // Helper to get binding for SwiftUI views - func binding(for screenUUID: String?) -> Binding { + func binding(for screenUUID: String?) -> Binding { Binding( get: { [weak self] in - guard let self = self, let uuid = screenUUID else { return sneakPeek() } - return self.sneakPeekStates[uuid] ?? sneakPeek(targetScreenUUID: uuid) + guard let self = self, let uuid = screenUUID else { return SneakPeekState() } + return self.sneakPeekStates[uuid] ?? SneakPeekState(targetScreenUUID: uuid) }, set: { [weak self] newValue in guard let self = self, let uuid = screenUUID else { return } diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index 07896651c..e63240f86 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -23,6 +23,9 @@ struct ContentView: View { @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var brightnessManager = BrightnessManager.shared @ObservedObject var volumeManager = VolumeManager.shared + @ObservedObject var notificationManager = SystemNotificationManager.shared + /// Which entry of the closed-notch activity stack is on top. + @State private var activityIndex: Int = 0 @State private var hoverTask: Task? @State private var isHovering: Bool = false @State private var anyDropDebounceTask: Task? @@ -52,10 +55,16 @@ struct ContentView: View { return effectiveHeight / 38.0 } + /// Compact mode gets a rounder opened shape (35 vs 19) — at its smaller + /// size the standard radius reads square rather than pill-like. + private var openedInsets: (top: CGFloat, bottom: CGFloat) { + Defaults[.compactMode] ? compactCornerRadiusInsets.opened : cornerRadiusInsets.opened + } + private var topCornerRadius: CGFloat { // If the notch is open, return the opened radius. if vm.notchState == .open { - return cornerRadiusInsets.opened.top + return openedInsets.top } // For the closed notch, scale if enabled @@ -72,7 +81,7 @@ struct ContentView: View { let bottomCorner: CGFloat if vm.notchState == .open { - bottomCorner = cornerRadiusInsets.opened.bottom + bottomCorner = openedInsets.bottom } else if let scaleFactor = cornerRadiusScaleFactor { bottomCorner = max(0, baseClosedBottom * scaleFactor) } else { @@ -85,6 +94,61 @@ struct ContentView: View { ) } + /// Closed-notch activities, newest first. A notification sits in front of + /// music, so an incoming message takes over the display; when it expires + /// it drops out of this list on its own and music comes back — no + /// explicit "restore previous activity" bookkeeping needed. + private var liveActivities: [LiveActivityItem] { + var items: [LiveActivityItem] = [] + + if let notification = notificationManager.activeNotification { + items.append(.notification(notification)) + } + + let musicIsShowing = (!coordinator.expandingView.show || coordinator.expandingView.type == .music) + && (musicManager.isPlaying || !musicManager.isPlayerIdle) + && coordinator.musicLiveActivityEnabled + if musicIsShowing { + items.append(.music) + } + + return items + } + + /// A notification is a glance, not a workspace — it doesn't need the full + /// height the home/shelf tabs are sized for, and stretching to fill it + /// just surrounds two lines of text with empty black. + /// nil means "size to content". + /// + /// Compact mode must use nil: this frame bounds hit-testing as well as + /// layout, so any value shorter than the content leaves the transport + /// row outside the hover region — moving toward the buttons registered + /// as a hover-exit and closed the notch. The compact panel's height is + /// controlled by its own internal padding instead, which is the honest + /// lever anyway. + private var openNotchHeight: CGFloat? { + if notificationManager.activeNotification != nil { return 132 } + return Defaults[.compactMode] ? nil : vm.notchSize.height + } + + /// Compact mode drops the tab bar along with the tabs it switches + /// between — there's only the player to show, so a switcher would have + /// nothing to switch to. Also what keeps the panel narrow, since the + /// header spans the full notch width. + private var showsHeader: Bool { + vm.notchState == .open + && notificationManager.activeNotification == nil + && !Defaults[.compactMode] + } + + /// The activity currently on top of the stack — what the chin has to be + /// sized for. + private var selectedActivity: LiveActivityItem? { + let items = liveActivities + guard !items.isEmpty else { return nil } + return items[min(max(activityIndex, 0), items.count - 1)] + } + private var computedChinWidth: CGFloat { var chinWidth: CGFloat = vm.closedNotchSize.width @@ -92,11 +156,29 @@ struct ContentView: View { && vm.notchState == .closed && Defaults[.showPowerStatusNotifications] { chinWidth = 640 - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) - && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) - && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed - { - chinWidth += (2 * max(0, displayClosedNotchHeight - 12) + 20 + 2 * liveActivityEdgeMargin + 2) + } else if vm.notchState == .closed, !vm.hideOnClosed, let activity = selectedActivity { + // Sized for whichever activity is actually on top, not for + // whichever happens to exist — otherwise swiping to music while a + // notification is still in the stack leaves the chin at the + // notification's width. + switch activity { + case .notification(let notification) where notification.detectedCode != nil: + // The code + copy button live on the wing right of the + // physical notch; a flat 420 assumes a narrow cutout, and on + // standard-notch displays the wing fell short so the code + // clipped under the hardware bezel. + chinWidth = max(420, vm.closedNotchSize.width + 2 * 112) + case .notification: + chinWidth += (2 * max(0, vm.effectiveClosedNotchHeight - 12) + 20) + case .music: + chinWidth += (2 * max(0, displayClosedNotchHeight - 12) + 20 + 2 * liveActivityEdgeMargin + 2) + // The inline song-change peek widens the pill itself, so the + // chin has to grow with it — otherwise the hover region is + // narrower than what's on screen. + if showingInlineMusicPeek { + chinWidth += 2 * inlineMusicPeekLabelWidth + } + } } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed @@ -129,7 +211,7 @@ struct ContentView: View { .frame(alignment: .top) .padding( .horizontal, - vm.notchState == .open ? cornerRadiusInsets.opened.top : cornerRadiusInsets.closed.bottom + vm.notchState == .open ? openedInsets.top : cornerRadiusInsets.closed.bottom ) .padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0) .background(.black) @@ -149,7 +231,14 @@ struct ContentView: View { .opacity((isNotchHeightZero && vm.notchState == .closed) ? 0.01 : 1) mainLayout - .frame(height: vm.notchState == .open ? vm.notchSize.height : nil) + // alignment: .top matters here — without it this frame + // defaults to centering, and shrinking the height for a + // notification (openNotchHeight < vm.notchSize.height) + // then pulls the visible top edge down by half the + // difference instead of staying flush with the window's + // top-anchored origin. That's what read as "the notch + // sits a bit off the top of the screen." + .frame(height: vm.notchState == .open ? openNotchHeight : nil, alignment: .top) .conditionalModifier(true) { view in return view .animation(vm.notchState == .open ? StandardAnimations.open : StandardAnimations.close, value: vm.notchState) @@ -205,6 +294,31 @@ struct ContentView: View { isHovering = false } } + // Keep the helper's banner keep-alive gate in sync + // with the effective open state (refcounted client-side). + if newState == .open { + XPCHelperClient.shared.notchOpened() + } else { + XPCHelperClient.shared.notchClosed() + } + // Temporary trace for the empty-open-panel report. + if newState == .open { + Log.notifications.debug( + "notch opened; activeNotification=\(SystemNotificationManager.shared.activeNotification?.id ?? "nil"), compactMode=\(Defaults[.compactMode])" + ) + } + } + // A new notification always takes the front of the stack, + // even if the user had swiped away to music. + .onChange(of: notificationManager.activeNotification?.id) { _, newID in + if newID != nil { activityIndex = 0 } + } + // Activities disappear on their own (a notification + // expires, music stops). Keep the selection in range so + // the stack falls back to whatever is left instead of + // pointing past the end. + .onChange(of: liveActivities.count) { _, count in + if activityIndex >= count { activityIndex = max(count - 1, 0) } } .onChange(of: vm.isBatteryPopoverActive) { if !vm.isBatteryPopoverActive && !isHovering && vm.notchState == .open && !SharingStateManager.shared.preventNotchClose { @@ -328,8 +442,8 @@ struct ContentView: View { .frame(width: 76, alignment: .trailing) } .frame(height: displayClosedNotchHeight, alignment: .center) - } else if coordinator.shouldShowSneakPeek(on: vm.screenUUID) && Defaults[.inlineOSD] && (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .battery) && vm.notchState == .closed { - InlineOSD( + } else if coordinator.shouldShowSneakPeek(on: vm.screenUUID) && Defaults[.inlineOSD] && (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .battery) && vm.notchState == .closed { + InlineOSD( type: coordinator.binding(for: vm.screenUUID).type, value: coordinator.binding(for: vm.screenUUID).value, icon: coordinator.binding(for: vm.screenUUID).icon, @@ -338,12 +452,24 @@ struct ContentView: View { gestureProgress: $gestureProgress ) .transition(.opacity) - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed { - MusicLiveActivity() - .frame(alignment: .center) + } else if !liveActivities.isEmpty && vm.notchState == .closed && !vm.hideOnClosed { + LiveActivityStack(items: liveActivities, index: $activityIndex) { item in + switch item { + case .notification(let notification): + NotificationLiveActivity(notification: notification) + case .music: + MusicLiveActivity() + .frame(alignment: .center) + } + } } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { BoringFaceAnimation() - } else if vm.notchState == .open { + } else if showsHeader { + // No tab bar over a notification: it's a glance, + // not a place to switch between home and shelf — + // and the header spans the full notch width, + // which is what was stretching the whole panel + // out around a short message. BoringHeader() .frame(height: max(24, displayClosedNotchHeight)) .opacity(gestureProgress != 0 ? 1.0 - min(abs(gestureProgress) * 0.1, 0.3) : 1.0) @@ -355,8 +481,8 @@ struct ContentView: View { Rectangle().fill(.clear).frame(width: vm.closedNotchSize.width - 20, height: displayClosedNotchHeight) } - if coordinator.shouldShowSneakPeek(on: vm.screenUUID) { - if (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .battery) && !Defaults[.inlineOSD] && vm.notchState == .closed { + if coordinator.shouldShowSneakPeek(on: vm.screenUUID) { + if (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .battery) && !Defaults[.inlineOSD] && vm.notchState == .closed { SystemEventIndicatorModifier( eventType: coordinator.binding(for: vm.screenUUID).type, value: coordinator.binding(for: vm.screenUUID).value, @@ -377,41 +503,56 @@ struct ContentView: View { .padding(.leading, 4) .padding(.trailing, 8) } - // Old sneak peek music - else if coordinator.sneakPeekState(for: vm.screenUUID).type == .music { - if vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard { - HStack(alignment: .center) { - Image(systemName: "music.note") - GeometryReader { geo in - MarqueeText(musicManager.songTitle + " - " + musicManager.artistName, color: Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray, delayDuration: 1.0, frameWidth: geo.size.width) - } - } - .foregroundStyle(.gray) - .padding(.bottom, 10) - } - } + // Old sneak peek music + else if coordinator.sneakPeekState(for: vm.screenUUID).type == .music { + if vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard { + HStack(alignment: .center) { + Image(systemName: "music.note") + GeometryReader { geo in + MarqueeText(musicManager.songTitle + " - " + musicManager.artistName, color: Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray, delayDuration: 1.0, frameWidth: geo.size.width) + } + } + .foregroundStyle(.gray) + .padding(.bottom, 10) + } + } + } + } } - } - } - .conditionalModifier((coordinator.shouldShowSneakPeek(on: vm.screenUUID) && (coordinator.sneakPeekState(for: vm.screenUUID).type == .music) && vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.shouldShowSneakPeek(on: vm.screenUUID) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (vm.notchState == .closed))) { view in - view - .fixedSize() - } - .zIndex(1) + .conditionalModifier((coordinator.shouldShowSneakPeek(on: vm.screenUUID) && (coordinator.sneakPeekState(for: vm.screenUUID).type == .music) && vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.shouldShowSneakPeek(on: vm.screenUUID) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (vm.notchState == .closed))) { view in + view + .fixedSize() + } + .zIndex(1) if vm.notchState == .open { VStack { - switch coordinator.currentView { - case .home: - NotchHomeView( - albumArtNamespace: albumArtNamespace, - horizontalMediaGestureFeedback: horizontalMediaGestureFeedback, - isHoveringMusicArea: $isHoveringMusicArea - ) - case .shelf: - ShelfView( - dropInteraction: vm.dropInteraction, - animation: vm.animation - ) + // An open notch with a live notification is showing the + // reply UI — the usual tabs can wait until it's dismissed. + if let notification = notificationManager.activeNotification { + NotificationExpandedView(notification: notification) + } else if Defaults[.compactMode] { + // Player only — no tab switching, so currentView is + // ignored here rather than offering a shelf the + // compact layout has no room (or tab bar) for. + // 336 = Atoll's 420 base less 20%, which also lands + // within a few points of their Dynamic Island width + // (340) — the tighter of their two compact sizes. + CompactHomeView(albumArtNamespace: albumArtNamespace) + .frame(width: 336) + } else { + switch coordinator.currentView { + case .home: + NotchHomeView( + albumArtNamespace: albumArtNamespace, + horizontalMediaGestureFeedback: horizontalMediaGestureFeedback, + isHoveringMusicArea: $isHoveringMusicArea + ) + case .shelf: + ShelfView( + dropInteraction: vm.dropInteraction, + animation: vm.animation + ) + } } } .transition( @@ -434,13 +575,39 @@ struct ContentView: View { .fill(.black) .frame(width: vm.closedNotchSize.width + 20) let faceScale = min(1.0, displayClosedNotchHeight / 30.0) - MinimalFaceFeatures(height: 24.0 * faceScale, width: 30.0 * faceScale) + AnimatedFace(height: 24.0 * faceScale, width: 30.0 * faceScale) }.frame( height: displayClosedNotchHeight, alignment: .center ) } + /// True while the song-change peek is expanding the closed pill inline. + private var showingInlineMusicPeek: Bool { + coordinator.expandingView.show + && coordinator.expandingView.type == .music + && Defaults[.sneakPeekStyles] == .inline + } + + /// Width of the black centre section of the closed music pill. + /// + /// Derived from the real notch width rather than the previous hard-coded + /// 380. That constant assumed a particular notch size: the title sits + /// left of the cutout and the artist right of it, separated by a spacer + /// as wide as the notch itself, so on a wider notch there was no room + /// left for the artist and the labels collided. Sizing from + /// closedNotchSize keeps a fixed label budget either side whatever the + /// hardware is, and keeps liveActivityEdgeMargin in play so content + /// clears the bezel — the inline path had dropped it entirely. + private var musicActivityCenterWidth: CGFloat { + let margin = vm.closedNotchSize.width - 4 + (2 * liveActivityEdgeMargin) + guard showingInlineMusicPeek else { return margin } + return margin + (2 * inlineMusicPeekLabelWidth) + } + + /// Space reserved for the title (left of the cutout) and artist (right). + private let inlineMusicPeekLabelWidth: CGFloat = 110 + @ViewBuilder func MusicLiveActivity() -> some View { HStack(spacing: 0) { @@ -477,7 +644,10 @@ struct ContentView: View { Rectangle() .fill(.black) .overlay( - HStack(alignment: .top) { + // .center, not .top: the album art beside this is + // vertically centered, so top-aligned labels sat visibly + // high against it. + HStack(alignment: .center) { if coordinator.expandingView.show && coordinator.expandingView.type == .music { @@ -486,7 +656,7 @@ struct ContentView: View { color: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray, delayDuration: 0.4, - frameWidth: 100 + frameWidth: inlineMusicPeekLabelWidth ) .opacity( (coordinator.expandingView.show @@ -498,6 +668,7 @@ struct ContentView: View { Text(musicManager.artistName) .lineLimit(1) .truncationMode(.tail) + .frame(width: inlineMusicPeekLabelWidth, alignment: .trailing) .foregroundStyle( Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) @@ -511,17 +682,12 @@ struct ContentView: View { ) } } + .padding(.horizontal, 8) ) - .frame( - width: (coordinator.expandingView.show - && coordinator.expandingView.type == .music - && Defaults[.sneakPeekStyles] == .inline) - ? 380 - : vm.closedNotchSize.width - 4 + (2 * liveActivityEdgeMargin) - ) + .frame(width: musicActivityCenterWidth) HStack { - AudioSpectrumView( + MusicVisualizer( isPlaying: musicManager.isPlaying, tintColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.5) @@ -585,7 +751,16 @@ struct ContentView: View { withAnimation(animationSpring) { isHovering = true } - + + // Freeze the dismiss countdown the moment the pointer arrives, + // not when the notch finishes opening. Opening waits out + // minimumHoverDuration plus an animation, and a notification + // near the end of its life would expire during that — so it + // vanished exactly as the notch opened around it. + if notificationManager.activeNotification != nil { + notificationManager.holdActive() + } + if vm.notchState == .closed && Defaults[.enableHaptics] { haptics.toggle() } @@ -615,7 +790,10 @@ struct ContentView: View { withAnimation(animationSpring) { self.isHovering = false } - + + // Pointer left — let the notification age out again. + self.notificationManager.resumeDismiss() + if self.vm.notchState == .open && !self.vm.isBatteryPopoverActive && !SharingStateManager.shared.preventNotchClose { self.vm.close() } diff --git a/boringNotch/Info.plist b/boringNotch/Info.plist index fe01cd831..b4a1e0019 100644 --- a/boringNotch/Info.plist +++ b/boringNotch/Info.plist @@ -4,11 +4,6 @@ CFBundleAllowMixedLocalizations - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - SUBundleName Boring Notch SUEnableAutomaticChecks diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index 21c0e7f6e..1cbe63a0d 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -145,9 +145,6 @@ } } }, - "%@x" : { - "comment" : "Animation speed multiplier." - }, "%@" : { "localizations" : { "en" : { @@ -164,6 +161,9 @@ } } }, + "%@x" : { + "comment" : "Animation speed multiplier." + }, "%lld" : { "localizations" : { "en" : { @@ -179,6 +179,26 @@ } } } + }, + "%lld%%" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + } + } + }, + "+%lld" : { + }, "About" : { "localizations" : { @@ -603,6 +623,9 @@ } } } + }, + "actions: %@" : { + }, "Add" : { "extractionState" : "stale", @@ -1849,6 +1872,9 @@ } } } + }, + "Apps" : { + }, "Auto-scroll to next event" : { "localizations" : { @@ -2392,6 +2418,9 @@ } } } + }, + "Background Removal Failed" : { + }, "Backlight" : { "localizations" : { @@ -5057,6 +5086,9 @@ } } } + }, + "Clear" : { + }, "Clear slot" : { "localizations" : { @@ -5171,6 +5203,7 @@ } }, "Close" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -5860,6 +5893,9 @@ } } } + }, + "Compact mode" : { + }, "Continue" : { "localizations" : { @@ -6037,7 +6073,10 @@ } } }, - "Copy Meeting Link" : { + "Copied" : { + + }, + "Copy" : { }, "Copy items on drag" : { @@ -6145,6 +6184,9 @@ } } } + }, + "Copy Meeting Link" : { + }, "Currently selected: %@" : { "localizations" : { @@ -7191,6 +7233,7 @@ } }, "Download" : { + "extractionState" : "stale", "localizations" : { "cs" : { "stringUnit" : { @@ -7821,8 +7864,12 @@ } } } + }, + "Dump AX tree" : { + }, "Edit layout" : { + "extractionState" : "stale", "localizations" : { "cs" : { "stringUnit" : { @@ -9036,6 +9083,9 @@ } } } + }, + "expired" : { + }, "Extend hover area" : { "localizations" : { @@ -9497,6 +9547,9 @@ }, "Frame shape" : { "comment" : "A label for the shape of the mirror frame." + }, + "From all apps" : { + }, "Full charge" : { "localizations" : { @@ -10498,6 +10551,9 @@ } } } + }, + "Helper Service Unavailable" : { + }, "Hide all-day events" : { "localizations" : { @@ -11539,8 +11595,12 @@ } } } + }, + "Image Conversion Failed" : { + }, "In progress" : { + "extractionState" : "stale", "localizations" : { "cs" : { "stringUnit" : { @@ -12433,6 +12493,9 @@ } } } + }, + "Looking for devices…" : { + }, "Low" : { "localizations" : { @@ -13993,6 +14056,9 @@ } } } + }, + "Message couldn't be sent. Your draft is still here — try again or open Messages." : { + }, "Mic" : { "localizations" : { @@ -15555,6 +15621,9 @@ } } } + }, + "Not watching" : { + }, "Notch animation" : { "localizations" : { @@ -16085,6 +16154,12 @@ } } } + }, + "Notification Debug" : { + + }, + "Notifications" : { + }, "Now Playing" : { "localizations" : { @@ -16161,6 +16236,12 @@ } } } + }, + "Only these apps show a live activity in the notch. Turn on \"From all apps\" to mirror everything instead." : { + + }, + "Open" : { + }, "Open Calendar Settings" : { "localizations" : { @@ -16279,6 +16360,12 @@ } } } + }, + "Open in %@" : { + + }, + "Open in Calendar" : { + }, "Open Notch" : { "extractionState" : "stale", @@ -16374,9 +16461,6 @@ } } } - }, - "Open in Calendar" : { - }, "Open notch on hover" : { "localizations" : { @@ -17328,6 +17412,12 @@ } } } + }, + "Output" : { + + }, + "PDF Creation Failed" : { + }, "Pick a Color" : { "localizations" : { @@ -18959,6 +19049,12 @@ } } } + }, + "Reply" : { + + }, + "reply…" : { + }, "Request Accessibility" : { "extractionState" : "stale", @@ -19072,6 +19168,9 @@ } } } + }, + "Requires Accessibility access. Only banners are mirrored — notifications delivered silently to Notification Center aren't visible to the app." : { + }, "Requires macOS 14.2 or later. Update macOS to enable real-time audio waveform." : { "localizations" : { @@ -20091,6 +20190,9 @@ } } } + }, + "Send" : { + }, "Settings" : { "localizations" : { @@ -21689,6 +21791,9 @@ } } } + }, + "Show charging wattage" : { + }, "Show cool face animation while inactive" : { "localizations" : { @@ -22237,6 +22342,9 @@ } } } + }, + "Show notifications in the notch" : { + }, "Show on all displays" : { "localizations" : { @@ -23178,6 +23286,9 @@ } } } + }, + "Shows a smaller opened notch with just the music player — no tabs, calendar or mirror." : { + }, "Slider color" : { "localizations" : { @@ -24224,6 +24335,12 @@ } } } + }, + "Start" : { + + }, + "Stop" : { + }, "Stopped" : { "extractionState" : "stale", @@ -24343,6 +24460,9 @@ } } } + }, + "Suggest replies with Apple Intelligence" : { + }, "System" : { "localizations" : { @@ -24634,7 +24754,7 @@ } }, "System Setting" : { - "comment" : "Week starts on: follow the macOS system setting (shown with the resolved day in parentheses)", + "comment" : "Week starts on: follow the macOS system setting", "localizations" : { "de" : { "stringUnit" : { @@ -24673,6 +24793,12 @@ } } } + }, + "Tap to see the next of %lld waiting" : { + + }, + "The background helper crashed or was closed by macOS. It restarts automatically on the next OSD event." : { + }, "Time to Full Charge: %@" : { "localizations" : { @@ -26798,6 +26924,9 @@ } } } + }, + "Watching" : { + }, "Week starts on" : { "comment" : "Calendar setting: which weekday the week strip starts on", diff --git a/boringNotch/MediaControllers/AppleMusicController.swift b/boringNotch/MediaControllers/AppleMusicController.swift index bd5903354..3084e5b85 100644 --- a/boringNotch/MediaControllers/AppleMusicController.swift +++ b/boringNotch/MediaControllers/AppleMusicController.swift @@ -9,10 +9,10 @@ import Foundation import Combine import SwiftUI -class AppleMusicController: MediaControllerProtocol { +final class AppleMusicController: MediaControllerProtocol { // MARK: - Properties @Published private var playbackState: PlaybackState = PlaybackState( - bundleIdentifier: "com.apple.Music", + bundleIdentifier: MediaAppBundleID.appleMusic, playbackRate: 1 ) @@ -42,11 +42,7 @@ class AppleMusicController: MediaControllerProtocol { private func setupPlaybackStateChangeObserver() { notificationTask = Task { @Sendable [weak self] in - let notifications = DistributedNotificationCenter.default().notifications( - named: NSNotification.Name("com.apple.Music.playerInfo") - ) - - for await _ in notifications { + for await _ in AppleScriptControllerSupport.playerInfoNotifications(named: "com.apple.Music.playerInfo") { await self?.updatePlaybackInfo() } } @@ -112,7 +108,7 @@ class AppleMusicController: MediaControllerProtocol { func isActive() -> Bool { let runningApps = NSWorkspace.shared.runningApplications - return runningApps.contains { $0.bundleIdentifier == "com.apple.Music" } + return runningApps.contains { $0.bundleIdentifier == MediaAppBundleID.appleMusic } } func setFavorite(_ favorite: Bool) async { @@ -154,8 +150,7 @@ class AppleMusicController: MediaControllerProtocol { // MARK: - Private Methods private func executeCommand(_ command: String) async { - let script = "tell application \"Music\" to \(command)" - try? await AppleScriptHelper.executeVoid(script) + await AppleScriptControllerSupport.executeCommand(command, appName: "Music") } private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? { diff --git a/boringNotch/MediaControllers/AppleScriptControllerSupport.swift b/boringNotch/MediaControllers/AppleScriptControllerSupport.swift new file mode 100644 index 000000000..fcac9279d --- /dev/null +++ b/boringNotch/MediaControllers/AppleScriptControllerSupport.swift @@ -0,0 +1,36 @@ +// +// AppleScriptControllerSupport.swift +// boringNotch +// +// SPDX-License-Identifier: GPL-3.0-only +// +// Shared scaffolding for AppleScript-driven media controllers. +// + +import Foundation + +/// The "tell application X to command" shape and distributed player-info +/// observation that AppleMusicController and SpotifyController previously +/// copy-pasted between each other. +enum AppleScriptControllerSupport { + static func executeCommand(_ command: String, appName: String) async { + let script = "tell application \"\(appName)\" to \(command)" + try? await AppleScriptHelper.executeVoid(script) + } + + /// Streams player-info notifications from DistributedNotificationCenter + /// and cancels the underlying observer when the stream terminates. + static func playerInfoNotifications(named name: String) -> AsyncStream { + AsyncStream { continuation in + let observerTask = Task { + let notifications = DistributedNotificationCenter.default().notifications( + named: NSNotification.Name(name) + ) + for await _ in notifications { + continuation.yield() + } + } + continuation.onTermination = { _ in observerTask.cancel() } + } + } +} diff --git a/boringNotch/MediaControllers/MediaAppBundleID.swift b/boringNotch/MediaControllers/MediaAppBundleID.swift new file mode 100644 index 000000000..a64c43468 --- /dev/null +++ b/boringNotch/MediaControllers/MediaAppBundleID.swift @@ -0,0 +1,17 @@ +// +// MediaAppBundleID.swift +// boringNotch +// +// SPDX-License-Identifier: GPL-3.0-only +// +// Bundle identifiers for supported music apps — single source so typos +// can't creep into the (previously ~20) scattered literals. +// + +import Foundation + +enum MediaAppBundleID { + static let appleMusic = "com.apple.Music" + static let spotify = "com.spotify.client" + static let youTubeMusic = "com.github.th-ch.youtube-music" +} diff --git a/boringNotch/MediaControllers/NowPlayingController.swift b/boringNotch/MediaControllers/NowPlayingController.swift index 739899078..e2652c928 100644 --- a/boringNotch/MediaControllers/NowPlayingController.swift +++ b/boringNotch/MediaControllers/NowPlayingController.swift @@ -16,7 +16,7 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { // MARK: - Properties @Published private(set) var playbackState: PlaybackState = .init( - bundleIdentifier: "com.apple.Music" + bundleIdentifier: MediaAppBundleID.appleMusic ) var playbackStatePublisher: AnyPublisher { @@ -25,19 +25,19 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { var supportsVolumeControl: Bool { let bundleID = playbackState.bundleIdentifier - return bundleID == "com.apple.Music" || bundleID == "com.spotify.client" + return bundleID == MediaAppBundleID.appleMusic || bundleID == MediaAppBundleID.spotify } var supportsFavorite: Bool { let bundleID = playbackState.bundleIdentifier - return bundleID == "com.apple.Music" + return bundleID == MediaAppBundleID.appleMusic } func setFavorite(_ favorite: Bool) async { let bundleID = playbackState.bundleIdentifier - if bundleID == "com.apple.Music" { - let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Music") + if bundleID == MediaAppBundleID.appleMusic { + let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: MediaAppBundleID.appleMusic) if !runningApps.isEmpty { let script = """ tell application "Music" @@ -168,14 +168,14 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { let bundleID = playbackState.bundleIdentifier if !bundleID.isEmpty { - if bundleID == "com.apple.Music" { - let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Music") + if bundleID == MediaAppBundleID.appleMusic { + let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: MediaAppBundleID.appleMusic) if !runningApps.isEmpty { let script = "tell application \"Music\" to set sound volume to \(volumePercentage)" try? await AppleScriptHelper.executeVoid(script) } - } else if bundleID == "com.spotify.client" { - let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.spotify.client") + } else if bundleID == MediaAppBundleID.spotify { + let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: MediaAppBundleID.spotify) if !runningApps.isEmpty { let script = "tell application \"Spotify\" to set sound volume to \(volumePercentage)" try? await AppleScriptHelper.executeVoid(script) @@ -319,8 +319,8 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { private func fetchFavoriteStateIfSupported() async { let bundleID = playbackState.bundleIdentifier - if bundleID == "com.apple.Music" { - let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Music") + if bundleID == MediaAppBundleID.appleMusic { + let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: MediaAppBundleID.appleMusic) guard !runningApps.isEmpty else { return } let script = """ @@ -374,80 +374,3 @@ struct NowPlayingPayload: Codable { let volume: Double? } -actor JSONLinesPipeHandler { - private let pipe: Pipe - private let fileHandle: FileHandle - private var buffer = "" - - init() { - self.pipe = Pipe() - self.fileHandle = pipe.fileHandleForReading - } - - func getPipe() -> Pipe { - return pipe - } - - func readJSONLines(as type: T.Type, onLine: @escaping (T) async -> Void) async { - do { - try await self.processLines(as: type) { decodedObject in - await onLine(decodedObject) - } - } catch { - print("Error processing JSON stream: \(error)") - } - } - - private func processLines(as type: T.Type, onLine: @escaping (T) async -> Void) async throws { - while true { - let data = try await readData() - guard !data.isEmpty else { break } - - if let chunk = String(data: data, encoding: .utf8) { - buffer.append(chunk) - - while let range = buffer.range(of: "\n") { - let line = String(buffer[..(_ line: String, as type: T.Type, onLine: @escaping (T) async -> Void) async { - guard let data = line.data(using: .utf8) else { - return - } - do { - let decodedObject = try JSONDecoder().decode(T.self, from: data) - await onLine(decodedObject) - } catch { - // Ignore lines that can't be decoded - } - } - - private func readData() async throws -> Data { - return try await withCheckedThrowingContinuation { continuation in - - fileHandle.readabilityHandler = { handle in - let data = handle.availableData - handle.readabilityHandler = nil - continuation.resume(returning: data) - } - } - } - - func close() async { - do { - fileHandle.readabilityHandler = nil - try fileHandle.close() - try pipe.fileHandleForWriting.close() - } catch { - print("Error closing pipe handler: \(error)") - } - } -} diff --git a/boringNotch/MediaControllers/SpotifyController.swift b/boringNotch/MediaControllers/SpotifyController.swift index f1a2867c5..72a1a2d84 100644 --- a/boringNotch/MediaControllers/SpotifyController.swift +++ b/boringNotch/MediaControllers/SpotifyController.swift @@ -9,14 +9,14 @@ import Foundation import Combine import SwiftUI -class SpotifyController: MediaControllerProtocol { +final class SpotifyController: MediaControllerProtocol { func setFavorite(_ favorite: Bool) async { //Placeholder } // MARK: - Properties @Published private var playbackState: PlaybackState = PlaybackState( - bundleIdentifier: "com.spotify.client" + bundleIdentifier: MediaAppBundleID.spotify ) var playbackStatePublisher: AnyPublisher { @@ -48,11 +48,7 @@ class SpotifyController: MediaControllerProtocol { private func setupPlaybackStateChangeObserver() { notificationTask = Task { @Sendable [weak self] in - let notifications = DistributedNotificationCenter.default().notifications( - named: NSNotification.Name("com.spotify.client.PlaybackStateChanged") - ) - - for await _ in notifications { + for await _ in AppleScriptControllerSupport.playerInfoNotifications(named: "com.spotify.client.PlaybackStateChanged") { await self?.updatePlaybackInfo() } } @@ -112,7 +108,7 @@ class SpotifyController: MediaControllerProtocol { let artworkURL = descriptor.atIndex(10)?.stringValue ?? "" var state = PlaybackState( - bundleIdentifier: "com.spotify.client", + bundleIdentifier: MediaAppBundleID.spotify, isPlaying: isPlaying, title: currentTrack, artist: currentTrackArtist, @@ -163,8 +159,7 @@ class SpotifyController: MediaControllerProtocol { // MARK: - Private Methods private func executeCommand(_ command: String) async { - let script = "tell application \"Spotify\" to \(command)" - try? await AppleScriptHelper.executeVoid(script) + await AppleScriptControllerSupport.executeCommand(command, appName: "Spotify") } private func executeAndRefresh(_ command: String) async { diff --git a/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicAuthentication.swift b/boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicAuthentication.swift similarity index 100% rename from boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicAuthentication.swift rename to boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicAuthentication.swift diff --git a/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift b/boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicController.swift similarity index 95% rename from boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift rename to boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicController.swift index c5a3b87bd..0db6a0739 100644 --- a/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift +++ b/boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicController.swift @@ -39,7 +39,7 @@ final class YouTubeMusicController: MediaControllerProtocol { try? await Task.sleep(for: .milliseconds(150)) await updatePlaybackInfo() } catch { - print("[YouTubeMusicController] Failed to set favorite: \(error)") + Log.music.error("[YouTubeMusicController] Failed to set favorite: \(error)") } } @@ -146,7 +146,7 @@ final class YouTubeMusicController: MediaControllerProtocol { } catch YouTubeMusicError.authenticationRequired { await authManager.invalidateToken() } catch { - print("[YouTubeMusicController] Failed to update playback info: \(error)") + Log.music.error("[YouTubeMusicController] Failed to update playback info: \(error)") } } @@ -222,14 +222,14 @@ final class YouTubeMusicController: MediaControllerProtocol { await startPeriodicUpdates() await updatePlaybackInfo() } catch { - print("[YouTubeMusicController] Failed to initialize: \(error)") + Log.music.error("[YouTubeMusicController] Failed to initialize: \(error)") scheduleReconnect() } } private func setupWebSocketIfPossible(token: String) async { guard let wsURL = WebSocketURLBuilder.buildURL(from: configuration.baseURL) else { - print("[YouTubeMusicController] Failed to build WebSocket URL") + Log.music.error("[YouTubeMusicController] Failed to build WebSocket URL") return } @@ -246,7 +246,7 @@ final class YouTubeMusicController: MediaControllerProtocol { try await client.connect(to: wsURL, with: token) activateWebSocket(client) } catch { - print("[YouTubeMusicController] WebSocket connection failed: \(error)") + Log.music.error("[YouTubeMusicController] WebSocket connection failed: \(error)") scheduleReconnect() } } @@ -276,10 +276,14 @@ final class YouTubeMusicController: MediaControllerProtocol { } guard let newPosition = position else { return } + // Threshold position updates: the websocket pushes ~1/s (often + // more), and an always-new lastUpdated defeated the Equatable + // check so every tick republished the whole playback state. + guard abs(newPosition - playbackState.currentTime) > 0.25 else { return } var copied = playbackState copied.currentTime = newPosition copied.lastUpdated = Date() - if copied != playbackState { playbackState = copied } + playbackState = copied case .repeatChanged: guard let data = message.extractData() else { return } @@ -446,7 +450,7 @@ final class YouTubeMusicController: MediaControllerProtocol { } catch YouTubeMusicError.authenticationRequired { await authManager.invalidateToken() } catch { - print("[YouTubeMusicController] Command failed: \(error)") + Log.music.error("[YouTubeMusicController] Command failed: \(error)") } } diff --git a/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicModels.swift b/boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicModels.swift similarity index 98% rename from boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicModels.swift rename to boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicModels.swift index 7b387dc0a..b652d8d6b 100644 --- a/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicModels.swift +++ b/boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicModels.swift @@ -16,7 +16,7 @@ struct YouTubeMusicConfiguration: Sendable { static let `default` = YouTubeMusicConfiguration( baseURL: "http://localhost:26538", - bundleIdentifier: "com.github.th-ch.youtube-music", + bundleIdentifier: MediaAppBundleID.youTubeMusic, reconnectDelay: 1...60, updateInterval: 2.0 ) diff --git a/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicNetworking.swift b/boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicNetworking.swift similarity index 100% rename from boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicNetworking.swift rename to boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicNetworking.swift diff --git a/boringNotch/Providers/CalendarServiceProviding.swift b/boringNotch/Providers/CalendarServiceProviding.swift index 5bf86329c..58b43ee51 100644 --- a/boringNotch/Providers/CalendarServiceProviding.swift +++ b/boringNotch/Providers/CalendarServiceProviding.swift @@ -111,7 +111,7 @@ class CalendarService: CalendarServiceProviding { do { try store.save(reminder, commit: true) } catch { - print("Failed to update reminder completion: \(error)") + Log.calendar.error("Failed to update reminder completion: \(error)") } } } diff --git a/boringNotch/Shortcuts/ShortcutConstants.swift b/boringNotch/Shortcuts/ShortcutConstants.swift index 4e476a265..b574c026c 100644 --- a/boringNotch/Shortcuts/ShortcutConstants.swift +++ b/boringNotch/Shortcuts/ShortcutConstants.swift @@ -1,5 +1,5 @@ // -// Constants.swift +// ShortcutConstants.swift // boringNotch // // Created by Richard Kunkli on 16/08/2024. @@ -9,10 +9,6 @@ import KeyboardShortcuts import SwiftUI extension KeyboardShortcuts.Name { - static let clipboardHistoryPanel = Self("clipboardHistoryPanel", default: .init(.c, modifiers: [.shift, .command])) - static let toggleMicrophone = Self("toggleMicrophone", default: .init(.f5, modifiers: [.function])) - static let decreaseBacklight = Self("decreaseBacklight", default: .init(.f1, modifiers: [.command])) - static let increaseBacklight = Self("increaseBacklight", default: .init(.f2, modifiers: [.command])) static let toggleSneakPeek = Self("toggleSneakPeek", default: .init(.h, modifiers: [.command, .shift])) static let toggleNotchOpen = Self("toggleNotchOpen", default: .init(.i, modifiers: [.command, .shift])) } diff --git a/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift b/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift deleted file mode 100644 index 2b1f7ce69..000000000 --- a/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// BoringNotchXPCHelperProtocol.swift -// BoringNotchXPCHelper -// -// Created by Alexander on 2025-11-16. -// - -import Foundation - -/// The protocol that this service will vend as its API. This protocol will also need to be visible to the process hosting the service. -@objc protocol BoringNotchXPCHelperLunarListener { - func lunarEventDidUpdate(_ event: BNLunarBrightnessEvent) - func lunarStreamDidStop(_ reason: String?) -} - -@objc(BNLunarBrightnessEvent) -final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { - static var supportsSecureCoding: Bool { true } - - let brightness: Double - let display: Int - - init(brightness: Double, display: Int) { - self.brightness = brightness - self.display = display - super.init() - } - - required init?(coder: NSCoder) { - brightness = coder.decodeDouble(forKey: "brightness") - display = coder.decodeInteger(forKey: "display") - super.init() - } - - func encode(with coder: NSCoder) { - coder.encode(brightness, forKey: "brightness") - coder.encode(display, forKey: "display") - } -} - -@objc protocol BoringNotchXPCHelperProtocol { - func isAccessibilityAuthorized(with reply: @escaping (Bool) -> Void) - func requestAccessibilityAuthorization() - func ensureAccessibilityAuthorization(_ promptIfNeeded: Bool, with reply: @escaping (Bool) -> Void) - // Keyboard backlight / CoreBrightness access (performed by the helper) - func isKeyboardBrightnessAvailable(with reply: @escaping (Bool) -> Void) - func currentKeyboardBrightness(with reply: @escaping (NSNumber?) -> Void) - func setKeyboardBrightness(_ value: Float, with reply: @escaping (Bool) -> Void) - // Screen brightness access (performed by the helper) - func isScreenBrightnessAvailable(with reply: @escaping (Bool) -> Void) - // returns the displayID that will be used for built-in brightness operations (main or internal fallback) - func displayIDForBrightness(with reply: @escaping (NSNumber?) -> Void) - func currentScreenBrightness(with reply: @escaping (NSNumber?) -> Void) - func setScreenBrightness(_ value: Float, with reply: @escaping (Bool) -> Void) - func adjustScreenBrightness(by value: Float, with reply: @escaping (Bool) -> Void) - // Lunar brightness events (performed by the helper) - func isLunarAvailable(with reply: @escaping (Bool) -> Void) - func startLunarEventStream(with reply: @escaping (Bool) -> Void) - func stopLunarEventStream() - /// Write Lunar's hideOSD preference (disable/enable Lunar's OSD when we replace it). - func setLunarOSDHidden(_ hide: Bool, with reply: @escaping (Bool) -> Void) -} diff --git a/boringNotch/XPCHelperClient/XPCHelperClient.swift b/boringNotch/XPCHelperClient/XPCHelperClient.swift index e3ad4b0fc..2c42b5ab7 100644 --- a/boringNotch/XPCHelperClient/XPCHelperClient.swift +++ b/boringNotch/XPCHelperClient/XPCHelperClient.swift @@ -2,17 +2,42 @@ import Foundation import Cocoa import AsyncXPCConnection -final class XPCHelperClient: NSObject { +/// Why a helper call failed. Most methods still degrade to false/nil for +/// backward compatibility, but the cause is recorded in `lastError` and +/// rolled into `helperAvailable` for Settings to surface. +enum XPCHelperError: Error { + /// The XPC service could not be reached (crashed or restarting). + case unavailable + /// The helper refused the request (e.g. accessibility not granted). + case declined + /// Connection dropped mid-call. + case transport(underlying: Error) +} + +final class XPCHelperClient: NSObject, ObservableObject { nonisolated static let shared = XPCHelperClient() - + private let serviceName = "theboringteam.boringnotch.BoringNotchXPCHelper" + + /// Coarse, UI-friendly view of helper connectivity. Flips to false from + /// the connection's interruption/invalidation handlers so a crashed + /// helper is visible in Settings instead of features silently degrading; + /// flips back to true when a live connection is (re)established. + @MainActor @Published private(set) var helperAvailable = true + @MainActor private(set) var lastError: XPCHelperError? private var remoteService: RemoteXPCService? private var connection: NSXPCConnection? private var lastKnownAuthorization: Bool? - private var monitoringTask: Task? + private let notificationDelegate = NotificationXPCDelegate() + @MainActor private var activationObserver: (any NSObjectProtocol)? private var lunarListener: BoringNotchXPCHelperLunarListener? private var hasLunarListener: Bool = false + + /// Open-notch refcount: one ContentView per screen can hold the notch + /// open, but the helper only wants the effective state, so + /// `setNotchOpen` is sent on the 0→1 and 1→0 transitions only. + @MainActor private var notchOpenCount = 0 deinit { connection?.invalidate() @@ -23,45 +48,53 @@ final class XPCHelperClient: NSObject { @MainActor private func ensureRemoteService(needsListener: Bool = false) -> RemoteXPCService { - if let existing = remoteService, (!needsListener || hasLunarListener) { + // Always reuse a live connection — never tear one down to attach a + // listener. The exported object below serves *both* callback + // protocols from the moment the connection is created, so there's + // nothing to re-negotiate. + // + // This previously invalidated and rebuilt the connection whenever + // Lunar/OSD asked for a listener. The helper captures its callback + // proxy once, when notification watching starts; invalidating that + // connection left it holding a dead proxy, so banners kept being + // captured in the helper and silently never arrived in the app. + if let existing = remoteService { + notificationDelegate.lunarListener = lunarListener + hasLunarListener = hasLunarListener || (needsListener && lunarListener != nil) + helperAvailable = true return existing } - if let connection { - connection.invalidate() - self.connection = nil - self.remoteService = nil - } - let conn = NSXPCConnection(serviceName: serviceName) - if needsListener, let lunarListener { - let listenerInterface = makeLunarListenerInterface() - conn.exportedInterface = listenerInterface - conn.exportedObject = lunarListener - hasLunarListener = true - } else { - hasLunarListener = false - } - + // One exported object serves both callback protocols. + notificationDelegate.lunarListener = lunarListener + conn.exportedInterface = makeAppDelegateInterface() + conn.exportedObject = notificationDelegate + hasLunarListener = needsListener && lunarListener != nil + conn.interruptionHandler = { [weak self] in Task { @MainActor in self?.connection = nil self?.remoteService = nil self?.hasLunarListener = false + self?.helperAvailable = false + self?.lastError = .unavailable } } - + conn.invalidationHandler = { [weak self] in Task { @MainActor in self?.connection = nil self?.remoteService = nil self?.hasLunarListener = false + self?.helperAvailable = false + self?.lastError = .unavailable } } conn.resume() - + let service = RemoteXPCService( connection: conn, remoteInterface: BoringNotchXPCHelperProtocol.self @@ -69,6 +102,13 @@ final class XPCHelperClient: NSObject { connection = conn remoteService = service + helperAvailable = true + lastError = nil + // A helper restart forgets our state — re-announce an open notch so + // its banner keep-alive gate doesn't run while the notch is open. + if notchOpenCount > 0 { + Task { try? await service.withService { $0.setNotchOpen(true) } } + } return service } @@ -77,8 +117,8 @@ final class XPCHelperClient: NSObject { remoteService } - private func makeLunarListenerInterface() -> NSXPCInterface { - let interface = NSXPCInterface(with: (any BoringNotchXPCHelperLunarListener).self) + private func makeAppDelegateInterface() -> NSXPCInterface { + let interface = NSXPCInterface(with: (any BoringNotchXPCAppDelegate).self) interface.setClasses( NSSet(array: [BNLunarBrightnessEvent.self]) as! Set, for: #selector(BoringNotchXPCHelperLunarListener.lunarEventDidUpdate(_:)), @@ -100,29 +140,37 @@ final class XPCHelperClient: NSObject { } // MARK: - Monitoring - nonisolated func startMonitoringAccessibilityAuthorization(every interval: TimeInterval = 3.0) { - // Ensure only one monitor exists + + /// AX trust has no public change notification. Polling the helper every + /// few seconds costs ~29k XPC round-trips per day for a boolean that + /// changes maybe twice a year, so instead we check once at start and + /// then on every app activation — the natural moment a user comes back + /// from System Settings after toggling the switch. Every AX-needing + /// call (isAccessibilityAuthorized/ensureAccessibilityAuthorization) + /// also re-posts changes itself via notifyAuthorizationChange. + nonisolated func startMonitoringAccessibilityAuthorization() { stopMonitoringAccessibilityAuthorization() - monitoringTask = Task.detached { [weak self] in - guard let self = self else { return } - while !Task.isCancelled { - // Call the helper method periodically which will notify on change - _ = await self.isAccessibilityAuthorized() - do { - try await Task.sleep(for: .seconds(interval)) - } catch { break } + Task { @MainActor [weak self] in + guard let self else { return } + activationObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { _ = await self?.isAccessibilityAuthorized() } } } + // Initial probe so observers get the current state without waiting + // for the first activation. + Task { _ = await isAccessibilityAuthorized() } } nonisolated func stopMonitoringAccessibilityAuthorization() { - monitoringTask?.cancel() - monitoringTask = nil - } - - // Expose whether the client is actively monitoring (useful for tests/debug) - var isMonitoring: Bool { - return monitoringTask != nil + Task { @MainActor [weak self] in + guard let self, let activationObserver else { return } + NotificationCenter.default.removeObserver(activationObserver) + self.activationObserver = nil + } } // MARK: - Accessibility @@ -288,18 +336,19 @@ final class XPCHelperClient: NSObject { return false } } - nonisolated func adjustScreenBrightness(by value: Float) async -> Bool { + /// Returns the resulting brightness, or nil on failure. + nonisolated func adjustScreenBrightness(by value: Float) async -> Float? { do { let service = await MainActor.run { ensureRemoteService() } return try await service.withContinuation { service, continuation in - service.adjustScreenBrightness(by: value) { success in - continuation.resume(returning: success) + service.adjustScreenBrightness(by: value) { result in + continuation.resume(returning: result?.floatValue) } } } catch { - return false + return nil } } @@ -323,6 +372,10 @@ final class XPCHelperClient: NSObject { nonisolated func startLunarEventStream(listener: BoringNotchXPCHelperLunarListener) async -> Bool { await MainActor.run { lunarListener = listener + // Register on the shared exported object too: the connection may + // already exist (it isn't rebuilt for listeners any more), in + // which case this is the only path that hooks Lunar events up. + notificationDelegate.lunarListener = listener } do { let service = await MainActor.run { @@ -367,3 +420,183 @@ final class XPCHelperClient: NSObject { } } +// MARK: - Notification Center banners + +/// The app's single exported XPC object. Banner pushes are republished as local +/// notifications; Lunar events are forwarded to whichever listener the OSD code +/// registered, since both callbacks share one connection. +final class NotificationXPCDelegate: NSObject, BoringNotchXPCAppDelegate { + var lunarListener: BoringNotchXPCHelperLunarListener? + + func lunarEventDidUpdate(_ event: BNLunarBrightnessEvent) { + lunarListener?.lunarEventDidUpdate(event) + } + + func lunarStreamDidStop(_ reason: String?) { + lunarListener?.lunarStreamDidStop(reason) + } + + func notificationDidAppear(_ payload: [String: String]) { + NSLog("[boringNotch] app received banner: \(payload["appName"] ?? "-") / \(payload["title"] ?? "-")") + NotificationCenter.default.post( + name: .systemNotificationDidAppear, object: nil, userInfo: payload + ) + } + + func notificationDidDisappear(_ token: String) { + NotificationCenter.default.post( + name: .systemNotificationDidDisappear, object: nil, userInfo: ["token": token] + ) + } +} + +extension XPCHelperClient { + nonisolated func startNotificationWatching() async -> Bool { + do { + let service = await MainActor.run { ensureRemoteService() } + return try await service.withContinuation { service, continuation in + service.startNotificationWatching { started in + continuation.resume(returning: started) + } + } + } catch { + return false + } + } + + nonisolated func stopNotificationWatching() { + Task { + let service = await MainActor.run { ensureRemoteService() } + try? await service.withService { $0.stopNotificationWatching() } + } + } + + nonisolated func replyToNotification(token: String, text: String) async -> Bool { + do { + let service = await MainActor.run { ensureRemoteService() } + return try await service.withContinuation { service, continuation in + service.replyToNotification(token, text: text) { sent in + continuation.resume(returning: sent) + } + } + } catch { + return false + } + } + + nonisolated func performNotificationAction(token: String, name: String) async -> Bool { + do { + let service = await MainActor.run { ensureRemoteService() } + return try await service.withContinuation { service, continuation in + service.performNotificationAction(token, name: name) { done in + continuation.resume(returning: done) + } + } + } catch { + return false + } + } + + nonisolated func openNotification(token: String) async -> Bool { + do { + let service = await MainActor.run { ensureRemoteService() } + return try await service.withContinuation { service, continuation in + service.openNotification(token) { opened in + continuation.resume(returning: opened) + } + } + } catch { + return false + } + } + + /// Keeps a banner alive so its reply field stays usable, optionally + /// moving it off-screen so the user never sees it. + nonisolated func holdNotification(token: String) { + Task { + let service = await MainActor.run { ensureRemoteService() } + try? await service.withService { $0.holdNotification(token) } + } + } + + nonisolated func releaseNotification(token: String) { + Task { + let service = await MainActor.run { ensureRemoteService() } + try? await service.withService { $0.releaseNotification(token) } + } + } + + /// Feeds the helper the notch's effective open state so it can gate its + /// focus-stealing banner keep-alive expand to open-notch-only. + /// Refcounted across screens: only 0→1 sends true, only 1→0 sends false. + nonisolated func notchOpened() { + Task { @MainActor in + notchOpenCount += 1 + if notchOpenCount == 1 { sendNotchOpen(true) } + } + } + + nonisolated func notchClosed() { + Task { @MainActor in + guard notchOpenCount > 0 else { + NSLog("[boringNotch] XPCHelperClient: unmatched notchClosed ignored") + return + } + notchOpenCount -= 1 + if notchOpenCount == 0 { sendNotchOpen(false) } + } + } + + @MainActor private func sendNotchOpen(_ open: Bool) { + let service = ensureRemoteService() + Task { + try? await service.withService { $0.setNotchOpen(open) } + } + } + + nonisolated func sendIMessage(_ text: String, toChatNamed name: String) async -> Bool { + do { + let service = await MainActor.run { ensureRemoteService() } + return try await service.withContinuation { service, continuation in + service.sendIMessage(text, toChatNamed: name) { sent in + continuation.resume(returning: sent) + } + } + } catch { + return false + } + } + + nonisolated func dismissNotification(token: String) async -> Bool { + do { + let service = await MainActor.run { ensureRemoteService() } + return try await service.withContinuation { service, continuation in + service.dismissNotification(token) { dismissed in + continuation.resume(returning: dismissed) + } + } + } catch { + return false + } + } + + nonisolated func notificationDebugDump() async -> String { + do { + let service = await MainActor.run { ensureRemoteService() } + return try await service.withContinuation { service, continuation in + service.notificationDebugDump { dump in + continuation.resume(returning: dump) + } + } + } catch { + return "xpc error: \(error)" + } + } +} + +extension Notification.Name { + static let systemNotificationDidAppear = Notification.Name("systemNotificationDidAppear") + static let systemNotificationDidDisappear = Notification.Name("systemNotificationDidDisappear") +} + + diff --git a/boringNotch/animations/drop.swift b/boringNotch/animations/drop.swift index 7fc240ea7..db23a7ac8 100644 --- a/boringNotch/animations/drop.swift +++ b/boringNotch/animations/drop.swift @@ -44,7 +44,7 @@ enum StandardAnimations { static let timingCurve = Animation.timingCurve(0.16, 1, 0.3, 1, duration: 0.7) } -public class BoringAnimations { +final class BoringAnimations { @Published var notchStyle: Style = .notch var animation: Animation { diff --git a/boringNotch/boringNotch.entitlements b/boringNotch/boringNotch.entitlements index c903362a9..a8c08ace0 100644 --- a/boringNotch/boringNotch.entitlements +++ b/boringNotch/boringNotch.entitlements @@ -20,6 +20,8 @@ com.apple.security.network.server + com.apple.security.personal-information.addressbook + com.apple.security.personal-information.calendars com.apple.security.temporary-exception.apple-events diff --git a/boringNotch/boringNotchApp.swift b/boringNotch/boringNotchApp.swift index c3b78a6b6..d79d98b0b 100644 --- a/boringNotch/boringNotchApp.swift +++ b/boringNotch/boringNotchApp.swift @@ -22,6 +22,9 @@ struct DynamicNotchApp: App { let updaterController: SPUStandardUpdaterController init() { + #if DEBUG + OTPDetector.runSelfCheck() + #endif let sparkleUpdaterDelegate = BoringSparkleUpdaterDelegate() self.sparkleUpdaterDelegate = sparkleUpdaterDelegate updaterController = SPUStandardUpdaterController( @@ -41,6 +44,10 @@ struct DynamicNotchApp: App { } .keyboardShortcut(KeyEquivalent(","), modifiers: .command) CheckForUpdatesView(updater: updaterController.updater) + Button("Notification Debug") { + openWindow(id: "notification-debug") + NSApp.activate(ignoringOtherApps: true) + } Divider() Button("Restart Boring Notch") { ApplicationRelauncher.restart() @@ -50,6 +57,10 @@ struct DynamicNotchApp: App { } .keyboardShortcut(KeyEquivalent("Q"), modifiers: .command) } + + Window("Notification Debug", id: "notification-debug") { + NotificationDebugView() + } } } @@ -65,26 +76,26 @@ final class BoringSparkleUpdaterDelegate: NSObject, SPUUpdaterDelegate { } } -class AppDelegate: NSObject, NSApplicationDelegate { +/// App-lifecycle glue: shortcuts, onboarding, termination, observer wiring. +/// All notch-window / per-screen view-model / drag-detector lifecycle lives +/// in `NotchWindowManager` (see managers/NotchWindowManager.swift). +final class AppDelegate: NSObject, NSApplicationDelegate { var statusItem: NSStatusItem? - var windows: [String: NSWindow] = [:] // UUID -> NSWindow - var viewModels: [String: BoringViewModel] = [:] // UUID -> BoringViewModel - var window: NSWindow? - let vm: BoringViewModel = .init() @ObservedObject var coordinator = BoringViewCoordinator.shared var quickShareService = QuickShareService.shared - var whatsNewWindow: NSWindow? - var timer: Timer? var closeNotchTask: Task? - private var previousScreens: [NSScreen]? + private let windowManager = NotchWindowManager.shared private var onboardingWindowController: NSWindowController? private var screenLockedObserver: Any? private var screenUnlockedObserver: Any? - private var isScreenLocked: Bool = false - private var windowScreenDidChangeObserver: Any? - private var dragDetectors: [String: DragDetector] = [:] // UUID -> DragDetector private var observers: [Any] = [] + /// Kept for existing internal readers; the state itself moved to the manager. + var windows: [String: NSWindow] { windowManager.windows } + var viewModels: [String: BoringViewModel] { windowManager.viewModels } + var window: NSWindow? { windowManager.window } + var vm: BoringViewModel { windowManager.primaryViewModel } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return false } @@ -102,217 +113,35 @@ class AppDelegate: NSObject, NSApplicationDelegate { DistributedNotificationCenter.default().removeObserver(observer) screenUnlockedObserver = nil } - MusicManager.shared.destroy() - cleanupDragDetectors() - cleanupWindows() + MainActor.assumeIsolated { + MusicManager.shared.destroy() + windowManager.cleanup() + } BetterDisplayManager.shared.stopObserving() LunarManager.shared.stopListening() LunarManager.shared.configureLunarOSD(hide: false) XPCHelperClient.shared.stopMonitoringAccessibilityAuthorization() - + observers.forEach { NotificationCenter.default.removeObserver($0) } observers.removeAll() } @MainActor func onScreenLocked(_ notification: Notification) { - isScreenLocked = true - if !Defaults[.showOnLockScreen] { - cleanupWindows() - } else { - enableSkyLightOnAllWindows() - } + windowManager.screenLocked() } @MainActor func onScreenUnlocked(_ notification: Notification) { - isScreenLocked = false - if !Defaults[.showOnLockScreen] { - adjustWindowPosition(changeAlpha: true) - } else { - disableSkyLightOnAllWindows() - } - } - - @MainActor - private func enableSkyLightOnAllWindows() { - if Defaults[.showOnAllDisplays] { - windows.values.forEach { window in - if let skyWindow = window as? BoringNotchSkyLightWindow { - skyWindow.enableSkyLight() - } - } - } else { - if let skyWindow = window as? BoringNotchSkyLightWindow { - skyWindow.enableSkyLight() - } - } - } - - @MainActor - private func disableSkyLightOnAllWindows() { - // Delay disabling SkyLight to avoid flicker during unlock transition - Task { - try? await Task.sleep(for: .milliseconds(150)) - await MainActor.run { - if Defaults[.showOnAllDisplays] { - self.windows.values.forEach { window in - if let skyWindow = window as? BoringNotchSkyLightWindow { - skyWindow.disableSkyLight() - } - } - } else { - if let skyWindow = self.window as? BoringNotchSkyLightWindow { - skyWindow.disableSkyLight() - } - } - } - } - } - - private func cleanupWindows(shouldInvert: Bool = false) { - let shouldCleanupMulti = shouldInvert ? !Defaults[.showOnAllDisplays] : Defaults[.showOnAllDisplays] - - if shouldCleanupMulti { - windows.values.forEach { window in - window.close() - NotchSpaceManager.shared.notchSpace.windows.remove(window) - } - windows.removeAll() - viewModels.removeAll() - } else if let window = window { - window.close() - NotchSpaceManager.shared.notchSpace.windows.remove(window) - if let obs = windowScreenDidChangeObserver { - NotificationCenter.default.removeObserver(obs) - windowScreenDidChangeObserver = nil - } - self.window = nil - } - - // ensure OSD integration reflects the current window state - coordinator.applyOSDSources() - } - - private func cleanupDragDetectors() { - dragDetectors.values.forEach { detector in - detector.stopMonitoring() - } - dragDetectors.removeAll() - } - - private func setupDragDetectors() { - cleanupDragDetectors() - - guard Defaults[.expandedDragDetection] else { return } - - if Defaults[.showOnAllDisplays] { - for screen in NSScreen.screens { - setupDragDetectorForScreen(screen) - } - } else { - let preferredScreen: NSScreen? = window?.screen - ?? NSScreen.screen(withUUID: coordinator.selectedScreenUUID) - ?? NSScreen.main - - if let screen = preferredScreen { - setupDragDetectorForScreen(screen) - } - } - } - - private func setupDragDetectorForScreen(_ screen: NSScreen) { - guard let uuid = screen.displayUUID else { return } - - let screenFrame = screen.frame - let notchHeight = openNotchSize.height - let notchWidth = openNotchSize.width - - // Create notch region at the top-center of the screen where an open notch would occupy - let notchRegion = CGRect( - x: screenFrame.midX - notchWidth / 2, - y: screenFrame.maxY - notchHeight, - width: notchWidth, - height: notchHeight - ) - - let detector = DragDetector(notchRegion: notchRegion) - - detector.onDragEntersNotchRegion = { [weak self] in - Task { @MainActor in - self?.handleDragEntersNotchRegion(onScreen: screen) - } - } - - dragDetectors[uuid] = detector - detector.startMonitoring() - } - - private func handleDragEntersNotchRegion(onScreen screen: NSScreen) { - guard Defaults[.boringShelf] else { return } - guard let uuid = screen.displayUUID else { return } - - if Defaults[.showOnAllDisplays], let viewModel = viewModels[uuid] { - if viewModel.open() { - coordinator.currentView = .shelf - } - } else if !Defaults[.showOnAllDisplays], let windowScreen = window?.screen, screen == windowScreen { - if vm.open() { - coordinator.currentView = .shelf - } - } - } - - private func createBoringNotchWindow(for screen: NSScreen, with viewModel: BoringViewModel) -> NSWindow { - let rect = NSRect(x: 0, y: 0, width: windowSize.width, height: windowSize.height) - let styleMask: NSWindow.StyleMask = [.borderless, .nonactivatingPanel, .utilityWindow, .hudWindow] - - let window = BoringNotchSkyLightWindow(contentRect: rect, styleMask: styleMask, backing: .buffered, defer: false) - - // Enable SkyLight only when screen is locked - if isScreenLocked { - window.enableSkyLight() - } else { - window.disableSkyLight() - } - - window.contentView = NSHostingView( - rootView: ContentView() - .environmentObject(viewModel) - ) - - window.orderFrontRegardless() - NotchSpaceManager.shared.notchSpace.windows.insert(window) - - // Observe when the window's screen changes so we can update drag detectors - windowScreenDidChangeObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didChangeScreenNotification, - object: window, - queue: .main) { [weak self] _ in - Task { @MainActor in - self?.setupDragDetectors() - } - } - return window - } - - @MainActor - private func positionWindow(_ window: NSWindow, on screen: NSScreen, changeAlpha: Bool = false) { - if changeAlpha { - window.alphaValue = 0 - } - - let screenFrame = screen.frame - window.setFrameOrigin( - NSPoint( - x: screenFrame.origin.x + (screenFrame.width / 2) - window.frame.width / 2, - y: screenFrame.origin.y + screenFrame.height - window.frame.height - )) - window.alphaValue = 1 + windowManager.screenUnlocked() } func applicationDidFinishLaunching(_ notification: Notification) { + // Kick the environment probe eagerly; its persisted result feeds + // Defaults key defaults and MediaEnvironment consumers. + MediaEnvironment.shared.resolve() + NotificationCenter.default.addObserver( self, selector: #selector(screenConfigurationDidChange), @@ -324,8 +153,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { forName: Notification.Name.selectedScreenChanged, object: nil, queue: nil ) { [weak self] _ in Task { @MainActor in - self?.adjustWindowPosition(changeAlpha: true) - self?.setupDragDetectors() + self?.windowManager.adjustWindowPosition(changeAlpha: true) + self?.windowManager.setupDragDetectors() } }) @@ -333,8 +162,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { forName: Notification.Name.notchHeightChanged, object: nil, queue: nil ) { [weak self] _ in Task { @MainActor in - self?.adjustWindowPosition() - self?.setupDragDetectors() + self?.windowManager.adjustWindowPosition() + self?.windowManager.setupDragDetectors() } }) @@ -352,9 +181,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) { [weak self] _ in Task { @MainActor in guard let self = self else { return } - self.cleanupWindows(shouldInvert: true) - self.adjustWindowPosition(changeAlpha: true) - self.setupDragDetectors() + self.windowManager.cleanupWindows(shouldInvert: true) + self.windowManager.adjustWindowPosition(changeAlpha: true) + self.windowManager.setupDragDetectors() } }) @@ -362,7 +191,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { forName: Notification.Name.expandedDragDetectionChanged, object: nil, queue: nil ) { [weak self] _ in Task { @MainActor in - self?.setupDragDetectors() + self?.windowManager.setupDragDetectors() } }) @@ -452,25 +281,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Sync notch height with real value on app launch if mode is matchRealNotchSize syncNotchHeightIfNeeded() - - if !Defaults[.showOnAllDisplays] { - let viewModel = self.vm - let window = createBoringNotchWindow( - for: NSScreen.main ?? NSScreen.screens.first!, with: viewModel) - self.window = window - adjustWindowPosition(changeAlpha: true) - } else { - adjustWindowPosition(changeAlpha: true) - } - setupDragDetectors() + windowManager.prepareInitialWindows() if coordinator.firstLaunch { DispatchQueue.main.async { self.showOnboardingWindow() } playWelcomeSound() - } else if MusicManager.shared.isNowPlayingDeprecated + } else if MediaEnvironment.shared.isNowPlayingDeprecated && Defaults[.mediaController] == .nowPlaying { DispatchQueue.main.async { @@ -478,8 +297,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - previousScreens = NSScreen.screens - // make sure OSD subsystems are in the right state now that initial // notch windows have been created/cleaned up coordinator.applyOSDSources() @@ -490,110 +307,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { audioPlayer.play(fileName: "boring", fileExtension: "m4a") } - func deviceHasNotch() -> Bool { - if #available(macOS 12.0, *) { - for screen in NSScreen.screens { - if screen.safeAreaInsets.top > 0 { - return true - } - } - } - return false - } - @objc func screenConfigurationDidChange() { - let currentScreens = NSScreen.screens - - let screensChanged = - currentScreens.count != previousScreens?.count - || Set(currentScreens.compactMap { $0.displayUUID }) - != Set(previousScreens?.compactMap { $0.displayUUID } ?? []) - || Set(currentScreens.map { $0.frame }) != Set(previousScreens?.map { $0.frame } ?? []) - - previousScreens = currentScreens - - if screensChanged { - DispatchQueue.main.async { [weak self] in - // Sync notch height with real value if mode is matchRealNotchSize - syncNotchHeightIfNeeded() - - self?.cleanupWindows() - self?.adjustWindowPosition() - self?.setupDragDetectors() - } - } - } - - @objc func adjustWindowPosition(changeAlpha: Bool = false) { - if Defaults[.showOnAllDisplays] { - let currentScreenUUIDs = Set(NSScreen.screens.compactMap { $0.displayUUID }) - - // Remove windows for screens that no longer exist - for uuid in windows.keys where !currentScreenUUIDs.contains(uuid) { - if let window = windows[uuid] { - window.close() - NotchSpaceManager.shared.notchSpace.windows.remove(window) - windows.removeValue(forKey: uuid) - viewModels.removeValue(forKey: uuid) - } - } - - // Create or update windows for all screens - for screen in NSScreen.screens { - guard let uuid = screen.displayUUID else { continue } - - if windows[uuid] == nil { - let viewModel = BoringViewModel(screenUUID: uuid) - let window = createBoringNotchWindow(for: screen, with: viewModel) - - windows[uuid] = window - viewModels[uuid] = viewModel - } - - if let window = windows[uuid], let viewModel = viewModels[uuid] { - positionWindow(window, on: screen, changeAlpha: changeAlpha) - - if viewModel.notchState == .closed { - viewModel.close() - } - } - } - } else { - let selectedScreen: NSScreen - - if let preferredScreen = NSScreen.screen(withUUID: coordinator.preferredScreenUUID ?? "") { - coordinator.selectedScreenUUID = coordinator.preferredScreenUUID ?? "" - selectedScreen = preferredScreen - } else if Defaults[.automaticallySwitchDisplay], let mainScreen = NSScreen.main, - let mainUUID = mainScreen.displayUUID { - coordinator.selectedScreenUUID = mainUUID - selectedScreen = mainScreen - } else { - if let window = window { - window.alphaValue = 0 - } - return - } - - vm.screenUUID = selectedScreen.displayUUID - vm.notchSize = getClosedNotchSize(screenUUID: selectedScreen.displayUUID) - - if window == nil { - window = createBoringNotchWindow(for: selectedScreen, with: vm) - } - - if let window = window { - positionWindow(window, on: selectedScreen, changeAlpha: changeAlpha) - - if vm.notchState == .closed { - vm.close() - } - } - } - - // windows might have been added/removed during the earlier logic – - // update the OSD subsystems accordingly. - coordinator.applyOSDSources() + windowManager.screenConfigurationDidChange() } @objc func togglePopover(_ sender: Any?) { diff --git a/boringNotch/components/AnimatedFace.swift b/boringNotch/components/AnimatedFace.swift index 4deaaac49..5d0ec97c2 100644 --- a/boringNotch/components/AnimatedFace.swift +++ b/boringNotch/components/AnimatedFace.swift @@ -6,7 +6,7 @@ import SwiftUI -struct MinimalFaceFeatures: View { +struct AnimatedFace: View { @State private var isBlinking = false @State private var blinkTask: Task? = nil var height: CGFloat = 24 @@ -93,7 +93,7 @@ struct MinimalFaceFeatures_Previews: PreviewProvider { static var previews: some View { ZStack { Color.black - MinimalFaceFeatures(height: 24, width: 30) + AnimatedFace(height: 24, width: 30) } .previewLayout(.fixed(width: 60, height: 60)) // Adjusted preview size for better visibility } diff --git a/boringNotch/components/Live activities/DownloadView.swift b/boringNotch/components/Live activities/DownloadView.swift deleted file mode 100644 index bc2ecbc60..000000000 --- a/boringNotch/components/Live activities/DownloadView.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// DownloadView.swift -// boringNotch -// -// Created by Harsh Vardhan Goswami on 17/08/24. -// - -import Foundation -import SwiftUI - -enum Browser { - case safari - case chrome -} - -struct DownloadFile { - var name: String - var size: Int - var formattedSize: String - var browser: Browser -} - -class DownloadWatcher: ObservableObject { - @Published var downloadFiles: [DownloadFile] = [] -} - -struct DownloadArea: View { - @EnvironmentObject var watcher: DownloadWatcher - - var body: some View { - HStack(alignment: .center) { - HStack { - if watcher.downloadFiles.first!.browser == .safari { - AppIcon(for: "com.apple.safari") - } else { - AppIcon(for: "com.google.Chrome") - } - VStack(alignment: .leading) { - Text("Download") - Text("In progress").font(.system(.footnote)).foregroundStyle(.gray) - } - } - Spacer() - HStack(spacing: 12) { - VStack(alignment: .trailing) { - Text(watcher.downloadFiles.first!.formattedSize) - Text(watcher.downloadFiles.first!.name).font(.caption2).foregroundStyle(.gray) - } - } - } - } -} diff --git a/boringNotch/components/Live activities/BoringBattery.swift b/boringNotch/components/LiveActivities/BoringBattery.swift similarity index 89% rename from boringNotch/components/Live activities/BoringBattery.swift rename to boringNotch/components/LiveActivities/BoringBattery.swift index e86ba33b4..55ac01927 100644 --- a/boringNotch/components/Live activities/BoringBattery.swift +++ b/boringNotch/components/LiveActivities/BoringBattery.swift @@ -39,6 +39,25 @@ struct BatteryView: View { } } + /// The outline is an SF Symbol scaled by width, so everything drawn + /// inside it has to scale by width too. + /// + /// These were previously absolute: the fill height was + /// `(batteryWidth - 2.75) - 18`, which only lands correctly at the + /// default 30pt — at compact mode's 24pt it collapses to ~3pt, a sliver + /// floating inside the outline. Expressing them relative to a reference + /// width keeps the 30pt case numerically identical to before while + /// making every other size correct. + private static let referenceWidth: CGFloat = 30 + private var sizeScale: CGFloat { batteryWidth / Self.referenceWidth } + + /// 9.25pt at the 30pt reference — the interior cavity height of the + /// battery symbol. + private var fillHeight: CGFloat { 9.25 * sizeScale } + /// Combined width of the outline stroke and terminal nub. + private var fillInset: CGFloat { 6 * sizeScale } + private var fillLeadingInset: CGFloat { 2 * sizeScale } + var body: some View { ZStack(alignment: .leading) { @@ -51,13 +70,13 @@ struct BatteryView: View { width: batteryWidth + 1 ) - RoundedRectangle(cornerRadius: 2.5) + RoundedRectangle(cornerRadius: 2.5 * sizeScale) .fill(batteryColor) .frame( - width: CGFloat(((CGFloat(CFloat(levelBattery)) / 100) * (batteryWidth - 6))), - height: (batteryWidth - 2.75) - 18 + width: (CGFloat(levelBattery) / 100) * (batteryWidth - fillInset), + height: fillHeight ) - .padding(.leading, 2) + .padding(.leading, fillLeadingInset) if iconStatus != "" && (isForNotification || Defaults[.showPowerStatusIcons]) { ZStack { @@ -66,8 +85,8 @@ struct BatteryView: View { .aspectRatio(contentMode: .fit) .foregroundColor(.white) .frame( - width: 17, - height: 17 + width: 17 * sizeScale, + height: 17 * sizeScale ) } .frame(width: batteryWidth, height: batteryWidth) diff --git a/boringNotch/components/Live activities/LiveActivityModifier.swift b/boringNotch/components/LiveActivities/LiveActivityModifier.swift similarity index 100% rename from boringNotch/components/Live activities/LiveActivityModifier.swift rename to boringNotch/components/LiveActivities/LiveActivityModifier.swift diff --git a/boringNotch/components/Live activities/MarqueeTextView.swift b/boringNotch/components/LiveActivities/MarqueeTextView.swift similarity index 100% rename from boringNotch/components/Live activities/MarqueeTextView.swift rename to boringNotch/components/LiveActivities/MarqueeTextView.swift diff --git a/boringNotch/components/Music/MusicVisualizer.swift b/boringNotch/components/Music/MusicVisualizer.swift index 245286c0e..eca2f059e 100644 --- a/boringNotch/components/Music/MusicVisualizer.swift +++ b/boringNotch/components/Music/MusicVisualizer.swift @@ -9,7 +9,7 @@ import Cocoa import Defaults import SwiftUI -class AudioSpectrum: NSView, AudioCaptureLevelsConsumer { +class MusicVisualizerModel: NSView, AudioCaptureLevelsConsumer { private var barLayers: [CAGradientLayer] = [] private var isPlaying = false private var useRealtime = false @@ -220,14 +220,14 @@ class AudioSpectrum: NSView, AudioCaptureLevelsConsumer { } } -struct AudioSpectrumView: NSViewRepresentable { +struct MusicVisualizer: NSViewRepresentable { let isPlaying: Bool let tintColor: Color @Default(.realtimeAudioWaveform) var realtimeEnabled: Bool @ObservedObject private var audioCapture = AudioCaptureManager.shared - func makeNSView(context: Context) -> AudioSpectrum { - let spectrum = AudioSpectrum() + func makeNSView(context: Context) -> MusicVisualizerModel { + let spectrum = MusicVisualizerModel() spectrum.setTintColor(NSColor(tintColor)) spectrum.setUseRealtime(realtimeEnabled && audioCapture.isCapturing) spectrum.setPlaying(isPlaying) @@ -236,7 +236,7 @@ struct AudioSpectrumView: NSViewRepresentable { return spectrum } - func updateNSView(_ nsView: AudioSpectrum, context: Context) { + func updateNSView(_ nsView: MusicVisualizerModel, context: Context) { nsView.setTintColor(NSColor(tintColor)) nsView.setUseRealtime(realtimeEnabled && audioCapture.isCapturing) nsView.setPlaying(isPlaying) @@ -247,7 +247,7 @@ struct AudioSpectrumView: NSViewRepresentable { #Preview { ZStack { Color.black - AudioSpectrumView(isPlaying: true, tintColor: .green) + MusicVisualizer(isPlaying: true, tintColor: .green) .frame(width: 18, height: 14) } .padding() diff --git a/boringNotch/components/Notch/BoringHeader.swift b/boringNotch/components/Notch/BoringHeader.swift index 18ea71ab9..c86da4e16 100644 --- a/boringNotch/components/Notch/BoringHeader.swift +++ b/boringNotch/components/Notch/BoringHeader.swift @@ -12,11 +12,11 @@ struct BoringHeader: View { @EnvironmentObject var vm: BoringViewModel @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var coordinator = BoringViewCoordinator.shared - @StateObject var tvm = ShelfStateViewModel.shared + @StateObject var shelfState = ShelfStateViewModel.shared var body: some View { HStack(spacing: 0) { HStack { - if (!tvm.isEmpty || coordinator.alwaysShowTabs) && Defaults[.boringShelf] { + if (!shelfState.isEmpty || coordinator.alwaysShowTabs) && Defaults[.boringShelf] { TabSelectionView() } else if vm.notchState == .open { EmptyView() diff --git a/boringNotch/components/Notch/BoringNotchSkyLightWindow.swift b/boringNotch/components/Notch/BoringNotchSkyLightWindow.swift index e7a96cdb1..a380967e4 100644 --- a/boringNotch/components/Notch/BoringNotchSkyLightWindow.swift +++ b/boringNotch/components/Notch/BoringNotchSkyLightWindow.swift @@ -146,6 +146,25 @@ class BoringNotchSkyLightWindow: NSPanel { } } - override var canBecomeKey: Bool { false } + /// False by default so a click on the notch never activates the app or + /// steals focus from whatever is frontmost — load-bearing for every + /// normal interaction (hover-to-open, music controls, OSD). A text field + /// needs this flipped on for the moment it's actually being typed into, + /// and back off the instant it isn't — never left permanently true. + /// + /// This is the window class actually instantiated for the notch + /// (createBoringNotchWindow uses BoringNotchSkyLightWindow, not the + /// separate, unused BoringNotchWindow class) — an earlier fix targeted + /// that unused class and silently did nothing. + var wantsKeyForTextInput = false { + didSet { + guard wantsKeyForTextInput != oldValue else { return } + if wantsKeyForTextInput { + makeKey() + } + } + } + + override var canBecomeKey: Bool { wantsKeyForTextInput } override var canBecomeMain: Bool { false } } diff --git a/boringNotch/components/Notch/BoringNotchWindow.swift b/boringNotch/components/Notch/BoringNotchWindow.swift deleted file mode 100644 index 7f4ac42ce..000000000 --- a/boringNotch/components/Notch/BoringNotchWindow.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// BoringNotchWindow.swift -// boringNotch -// -// Created by Harsh Vardhan Goswami on 06/08/24. -// - -import Cocoa - -class BoringNotchWindow: NSPanel { - override init( - contentRect: NSRect, - styleMask: NSWindow.StyleMask, - backing: NSWindow.BackingStoreType, - defer flag: Bool - ) { - super.init( - contentRect: contentRect, - styleMask: styleMask, - backing: backing, - defer: flag - ) - - isFloatingPanel = true - isOpaque = false - titleVisibility = .hidden - titlebarAppearsTransparent = true - backgroundColor = .clear - isMovable = false - - collectionBehavior = [ - .fullScreenAuxiliary, - .stationary, - .canJoinAllSpaces, - .ignoresCycle, - ] - - isReleasedWhenClosed = false - level = .mainMenu + 3 - hasShadow = false - } - - override var canBecomeKey: Bool { - false - } - - override var canBecomeMain: Bool { - false - } -} diff --git a/boringNotch/components/Notch/CompactHomeView.swift b/boringNotch/components/Notch/CompactHomeView.swift new file mode 100644 index 000000000..21f83d75c --- /dev/null +++ b/boringNotch/components/Notch/CompactHomeView.swift @@ -0,0 +1,411 @@ +// +// CompactHomeView.swift +// boringNotch +// +// A smaller open-notch layout: just the now-playing essentials — art, +// title, scrubber, transport — with no tab bar, calendar or mirror. +// +// Layout and proportions follow Atoll's MinimalisticMusicPlayerView +// (https://github.com/Ebullioscopic/Atoll, GPL-3.0, itself a boring.notch +// fork): 50pt album art, 12/10pt title and artist, a fixed-width +// visualizer block on the right sized to match the trailing time label so +// the bars centre over it, an inline progress row with a counting-down +// remaining time, and a 10pt-spaced transport row. +// +// Built on this project's own MusicSliderView / HoverButton / +// MarqueeText rather than porting Atoll's ~1500-line view wholesale, so +// seeking behaves identically in both layouts instead of drifting apart. +// The transport row is a fixed five here rather than the musicControlSlots +// preference — that preference exists to configure the full layout, and +// its default would leave compact mode without shuffle or media output. +// + +import Defaults +import SwiftUI + +struct CompactHomeView: View { + @EnvironmentObject var vm: BoringViewModel + @ObservedObject var musicManager = MusicManager.shared + @ObservedObject var batteryModel = BatteryStatusViewModel.shared + let albumArtNamespace: Namespace.ID + + @State private var sliderValue: Double = 0 + @State private var dragging: Bool = false + @State private var lastDragged: Date = .distantPast + @State private var showingOutputPicker = false + @ObservedObject private var routeManager = AudioRouteManager.shared + + @Default(.coloredSpectrogram) private var coloredSpectrogram + @Default(.playerColorTinting) private var playerColorTinting + + private let albumArtWidth: CGFloat = 45 + private let headerSpacing: CGFloat = 10 + /// Matches the trailing time label's width in the row below, so the + /// visualizer's bars sit centred over "-0:00" rather than drifting. + private let vizBlockWidth: CGFloat = 42 + private let vizBarWidth: CGFloat = 24 + + // No idle branch, deliberately. The standard layout has none either — + // it renders whatever MusicManager last cached, so a paused or stopped + // track keeps its art, title and scrub position. A "Nothing Playing" + // placeholder here made compact mode lose state the full layout keeps. + var body: some View { + VStack(spacing: 0) { + header + .frame(height: albumArtWidth) + + progressRow + .padding(.top, 4) + + transport + .padding(.top, 1) + } + .padding(.horizontal, 12) + // Atoll's 15/3 formula assumes the player is the whole panel; here + // a notch-clearance spacer sits above it, so these are trimmed to + // land the panel at the intended overall height. + .padding(.top, 4) + .padding(.bottom, 1) + .frame(maxWidth: .infinity) + .buttonStyle(PlainButtonStyle()) + } + + // MARK: - Header + + private var header: some View { + GeometryReader { geo in + let textWidth = max( + 0, + geo.size.width - albumArtWidth - headerSpacing - (vizBlockWidth + headerSpacing) + ) + + HStack(alignment: .center, spacing: headerSpacing) { + compactAlbumArt + + VStack(alignment: .leading, spacing: 1) { + MarqueeText( + musicManager.songTitle, + font: .system(size: 12, weight: .semibold), + color: .white, + frameWidth: textWidth + ) + + Text(musicManager.artistName) + .font(.system(size: 10)) + .foregroundStyle( + playerColorTinting + ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) + : .gray + ) + .lineLimit(1) + } + .frame(width: textWidth, alignment: .leading) + + ZStack { + MusicVisualizer( + isPlaying: musicManager.isPlaying, + tintColor: coloredSpectrogram + ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) + : .gray + ) + .frame(width: vizBarWidth, height: 16) + } + .frame(width: vizBlockWidth) + } + } + .overlay(alignment: .topTrailing) { + // Compact mode hides BoringHeader (it spans the full notch + // width), which took the battery with it. Overlaid rather than + // placed in the HStack so it doesn't steal width from the title. + if Defaults[.showBatteryIndicator] { + BoringBatteryView( + batteryWidth: 24, + isCharging: batteryModel.isCharging, + isInLowPowerMode: batteryModel.isInLowPowerMode, + isPluggedIn: batteryModel.isPluggedIn, + levelBattery: batteryModel.levelBattery, + maxCapacity: batteryModel.maxCapacity, + timeToFullCharge: batteryModel.timeToFullCharge, + timeToDischarge: batteryModel.timeToDischarge, + maxAdapterWatts: batteryModel.maxAdapterWatts, + isForNotification: false + ) + .offset(y: -14) + } + } + } + + // MARK: - Progress + + private var progressRow: some View { + // See NotchHomeView.musicSlider — 0.5s ticks are imperceptible here. + TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.5 : nil)) { timeline in + MusicSliderView( + sliderValue: $sliderValue, + duration: $musicManager.songDuration, + lastDragged: $lastDragged, + color: musicManager.avgColor, + dragging: $dragging, + currentDate: timeline.date, + timestampDate: musicManager.timestampDate, + elapsedTime: musicManager.elapsedTime, + playbackRate: musicManager.playbackRate, + isPlaying: musicManager.isPlaying, + onValueChange: { MusicManager.shared.seek(to: $0) }, + labelLayout: .inline, + trailingLabel: .remaining, + restingTrackHeight: 7, + draggingTrackHeight: 11 + ) + } + .onAppear { sliderValue = musicManager.elapsedTime } + } + + // MARK: - Transport + + private var transport: some View { + HStack(spacing: 10) { + ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in + slotView(for: slot) + } + } + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: playPauseSize) + } + + /// Atoll's control dimensions: 36pt secondary buttons with 18pt glyphs, + /// a 54pt play/pause with a 26pt glyph. HoverButton's 30/40 is what made + /// this row read undersized against the rest of the panel. + private let controlSize: CGFloat = 32 + private let playPauseSize: CGFloat = 48 + + private func compactControl( + icon: String, + size: CGFloat, + glyph: CGFloat, + tint: Color = .white, + action: @escaping () -> Void + ) -> some View { + CompactControlButton( + icon: icon, + frameSize: size, + glyphSize: glyph, + tint: tint, + action: action + ) + } + + /// Fixed five rather than the musicControlSlots preference, so compact + /// mode always shows the full transport regardless of how the standard + /// layout is configured. + private var displayedSlots: [MusicControlButton] { + [.shuffle, .previous, .playPause, .next, .mediaOutput] + } + + @ViewBuilder + private func slotView(for slot: MusicControlButton) -> some View { + switch slot { + case .shuffle: + compactControl( + icon: "shuffle", + size: controlSize, + glyph: 16, + tint: musicManager.isShuffled ? .red : .white + ) { MusicManager.shared.toggleShuffle() } + case .previous: + compactControl(icon: "backward.fill", size: controlSize, glyph: 16) { + MusicManager.shared.previousTrack() + } + case .playPause: + compactControl( + icon: musicManager.isPlaying ? "pause.fill" : "play.fill", + size: playPauseSize, + glyph: 23 + ) { MusicManager.shared.togglePlay() } + case .next: + compactControl(icon: "forward.fill", size: controlSize, glyph: 16) { + MusicManager.shared.nextTrack() + } + case .repeatMode: + compactControl(icon: repeatIcon, size: controlSize, glyph: 16, tint: repeatIconColor) { + MusicManager.shared.toggleRepeat() + } + case .mediaOutput: + mediaOutputButton + case .none: + EmptyView() + default: + // Slots that only make sense in the full layout are skipped + // rather than rendered half-working in a player-only view. + EmptyView() + } + } + + /// Shows where audio is going and switches it, via a popover device + /// picker. + private var mediaOutputButton: some View { + compactControl(icon: routeSymbol, size: controlSize, glyph: 16) { + // Enumerate on open rather than polling: devices come and go + // (AirPods connecting, a display waking) and a list built at + // launch would be stale by the time anyone opened it. + routeManager.refreshDevices() + showingOutputPicker.toggle() + } + .popover(isPresented: $showingOutputPicker, arrowEdge: .bottom) { + AudioOutputPicker( + routeManager: routeManager, + onSelect: { showingOutputPicker = false } + ) + } + } + + private var compactAlbumArt: some View { + ZStack(alignment: .bottomTrailing) { + Image(nsImage: musicManager.albumArt) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: albumArtWidth, height: albumArtWidth) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + // Badge scaled to this art. AlbumArtView's is a fixed 30pt with + // a +10/+10 offset, sized for the 120pt art in the full layout — + // on 50pt art it spills outside the corner. + if !musicManager.usingAppIconForArtwork { + appIcon(for: musicManager.bundleIdentifier ?? MediaAppBundleID.appleMusic) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18, height: 18) + .offset(x: 5, y: 5) + } + } + .frame(width: albumArtWidth, height: albumArtWidth) + } + + /// Prefer the live device's own icon; fall back to the resolver's + /// classification before the first enumeration has run. + private var routeSymbol: String { + routeManager.activeDevice?.iconName ?? AudioOutputRouteResolver.shared.outputRouteSymbol() + } + + private var repeatIcon: String { + musicManager.repeatMode == .one ? "repeat.1" : "repeat" + } + + private var repeatIconColor: Color { + musicManager.repeatMode == .off ? .primary : .red + } + +} + +/// Output device list for the compact player's media-output button. +struct AudioOutputPicker: View { + @ObservedObject var routeManager: AudioRouteManager + let onSelect: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Output") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 6) + + if routeManager.devices.isEmpty { + // Enumeration is async, so an empty list on first open is + // normal rather than an error worth alarming anyone about. + Text("Looking for devices…") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.bottom, 10) + } else { + ForEach(routeManager.devices) { device in + Button { + routeManager.select(device) + onSelect() + } label: { + HStack(spacing: 8) { + Image(systemName: device.iconName) + .frame(width: 18) + Text(device.name) + .font(.system(size: 12)) + .lineLimit(1) + Spacer(minLength: 12) + if device.id == routeManager.activeDeviceID { + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .bold)) + } + } + .contentShape(Rectangle()) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + } + .padding(.bottom, 6) + } + } + .frame(minWidth: 220) + } +} + +/// Transport button matching Atoll's MinimalisticSquircircleButton: a +/// squircle that fills faintly on hover, sized independently of its glyph +/// so a 54pt play/pause and a 36pt skip share the same visual language. +/// +/// Not HoverButton — that's fixed at 30/40pt with a capsule fill, which +/// reads undersized against this layout's 50pt artwork. +private struct CompactControlButton: View { + let icon: String + let frameSize: CGFloat + let glyphSize: CGFloat + let tint: Color + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + RoundedRectangle(cornerRadius: frameSize * 0.4, style: .continuous) + .fill(isHovering ? Color.white.opacity(0.12) : .clear) + .frame(width: frameSize, height: frameSize) + .overlay { + Image(systemName: icon) + .font(.system(size: glyphSize, weight: .medium)) + .foregroundStyle(tint) + .contentTransition(.symbolEffect(.replace)) + } + .contentShape(RoundedRectangle(cornerRadius: frameSize * 0.4, style: .continuous)) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.18)) { isHovering = hovering } + } + } +} + +/// Audio-output slot for the standard layout's control row. +/// +/// Separate from CompactHomeView's inline version only because that one +/// uses the compact button sizing; the picker and behaviour are shared. +struct MediaOutputSlotButton: View { + @ObservedObject private var routeManager = AudioRouteManager.shared + @State private var showingPicker = false + + var body: some View { + HoverButton(icon: routeSymbol, scale: .medium) { + routeManager.refreshDevices() + showingPicker.toggle() + } + .popover(isPresented: $showingPicker, arrowEdge: .bottom) { + AudioOutputPicker(routeManager: routeManager) { + showingPicker = false + } + } + } + + private var routeSymbol: String { + routeManager.activeDevice?.iconName ?? AudioOutputRouteResolver.shared.outputRouteSymbol() + } +} diff --git a/boringNotch/components/Notch/LiveActivityStack.swift b/boringNotch/components/Notch/LiveActivityStack.swift new file mode 100644 index 000000000..6b07599bb --- /dev/null +++ b/boringNotch/components/Notch/LiveActivityStack.swift @@ -0,0 +1,109 @@ +// +// LiveActivityStack.swift +// boringNotch +// +// A browsable stack of closed-notch live activities, in the spirit of the +// Dynamic Island / Lock Screen activity stack: the newest activity takes +// the front, older ones sit behind it, and the user can swipe between them. +// +// Transient activities (a notification) auto-expire and reveal whatever was +// underneath (music), so an incoming message interrupts the now-playing +// display for a beat and then hands it back on its own. +// + +import Defaults +import SwiftUI + +/// One entry in the closed-notch stack. +/// +/// Deliberately excludes the momentary HUDs — volume/brightness OSD and the +/// battery pill. Those are interrupts, not activities: they take over for +/// ~1.5s and aren't something you'd want to swipe back to, which is also how +/// iOS separates them from live activities. +enum LiveActivityItem: Identifiable, Equatable { + case notification(SystemNotification) + case music + + var id: String { + switch self { + case .notification(let notification): "notification-\(notification.id)" + case .music: "music" + } + } +} + +/// Renders the selected activity with the others hinted behind it, and +/// handles swiping between them. +/// +/// Takes its content via a closure so callers keep ownership of how each +/// activity draws — `MusicLiveActivity` depends on ContentView's namespace +/// and gesture state, and dragging it out here would be a much larger, +/// riskier change than this feature needs. +struct LiveActivityStack: View { + let items: [LiveActivityItem] + @Binding var index: Int + @ViewBuilder let content: (LiveActivityItem) -> Content + + @State private var dragOffset: CGFloat = 0 + @State private var haptics: Bool = false + + private var clampedIndex: Int { min(max(index, 0), max(items.count - 1, 0)) } + + var body: some View { + ZStack { + // No "card behind the card" depth cue here. A full-width shape + // drawn behind the content is bisected by the black rectangle + // that masks the physical notch cutout, so it renders as two + // disembodied lines flanking the notch rather than as a stack. + // The closed pill has no room for a page-dot row either, so the + // stack stays discoverable by swiping rather than by ornament. + if let item = items[safe: clampedIndex] { + content(item) + .id(item.id) + .offset(x: dragOffset) + .transition(.asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .opacity + )) + } + } + .animation(.smooth(duration: 0.3), value: clampedIndex) + .animation(.smooth(duration: 0.3), value: items.count) + .contentShape(Rectangle()) + // minimumDistance keeps taps (open the notch) and the notch's own + // vertical pan gestures working — this only claims deliberate + // horizontal drags. + .gesture( + DragGesture(minimumDistance: 14) + .onChanged { value in + guard items.count > 1, abs(value.translation.width) > abs(value.translation.height) else { return } + dragOffset = value.translation.width * 0.3 + } + .onEnded { value in + dragOffset = 0 + guard items.count > 1, + abs(value.translation.width) > abs(value.translation.height), + abs(value.translation.width) > 24 + else { return } + move(by: value.translation.width < 0 ? 1 : -1) + } + ) + .sensoryFeedback(.alignment, trigger: haptics) + } + + private func move(by delta: Int) { + let next = clampedIndex + delta + guard items.indices.contains(next) else { return } + withAnimation(.smooth(duration: 0.3)) { index = next } + if Defaults[.enableHaptics] { haptics.toggle() } + } +} + +private extension Array { + /// The stack's contents change out from under the selection (a + /// notification expires, music stops), so every read is bounds-checked + /// rather than trusting an index that was valid a frame ago. + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/boringNotch/components/Notch/NotchHomeView.swift b/boringNotch/components/Notch/NotchHomeView.swift index 425dff4d7..ad3ebbf4e 100644 --- a/boringNotch/components/Notch/NotchHomeView.swift +++ b/boringNotch/components/Notch/NotchHomeView.swift @@ -22,7 +22,6 @@ struct MusicPlayerView: View { HStack { AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace).frame(width: 120).padding(.all, 5 * (vm.notchSize.height / 190)) MusicControlsView(horizontalMediaGestureFeedback: horizontalMediaGestureFeedback) - .drawingGroup() .compositingGroup() } .contentShape(Rectangle()) @@ -104,7 +103,7 @@ struct AlbumArtView: View { @ViewBuilder private var appIconOverlay: some View { if vm.notchState == .open && !musicManager.usingAppIconForArtwork { - AppIcon(for: musicManager.bundleIdentifier ?? "com.apple.Music") + appIcon(for: musicManager.bundleIdentifier ?? MediaAppBundleID.appleMusic) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 30, height: 30) @@ -202,7 +201,10 @@ struct MusicControlsView: View { } private var musicSlider: some View { - TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in + // 0.5s ticks are imperceptible on a minutes-long track (~1px steps) + // and the time labels only display whole seconds; the old 10Hz + // cadence re-rendered the slider 10x more than needed. + TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.5 : nil)) { timeline in MusicSliderView( sliderValue: $sliderValue, duration: $musicManager.songDuration, @@ -276,6 +278,8 @@ struct MusicControlsView: View { HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) { MusicManager.shared.toggleRepeat() } + case .mediaOutput: + MediaOutputSlotButton() case .volume: VolumeControlView() case .favorite: @@ -456,7 +460,7 @@ struct NotchHomeView: View { } if shouldShowCamera { - CameraPreviewView(webcamManager: webcamManager) + WebcamPreview(webcamManager: webcamManager) .scaledToFit() .opacity(vm.notchState == .closed ? 0 : 1) .blur(radius: vm.notchState == .closed ? 20 : 0) @@ -481,39 +485,113 @@ struct MusicSliderView: View { let isPlaying: Bool var onValueChange: (Double) -> Void + // Layout options, ported from Atoll (GPL-3.0, itself a boring.notch + // fork) so the compact layout can put the times either side of the + // track. Defaults reproduce the previous stacked/duration look exactly, + // so the standard layout is untouched. + var labelLayout: TimeLabelLayout = .stacked + var trailingLabel: TrailingLabel = .duration + var restingTrackHeight: CGFloat = 5 + var draggingTrackHeight: CGFloat = 9 + + enum TimeLabelLayout { + /// Times on a row beneath the track. + case stacked + /// Times flanking the track on the same row. + case inline + } + + enum TrailingLabel { + case duration + /// Counts down: "-2:56". + case remaining + } var body: some View { + Group { + switch labelLayout { + case .stacked: stackedContent + case .inline: inlineContent + } + } + .onChange(of: currentDate) { + guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return } + sliderValue = MusicManager.shared.estimatedPlaybackPosition(at: currentDate) + } + } + + private var stackedContent: some View { VStack { - CustomSlider( - value: $sliderValue, - range: 0...duration, - color: Defaults[.sliderColor] == SliderColorEnum.albumArt - ? Color(nsColor: color).ensureMinimumBrightness(factor: 0.8) - : Defaults[.sliderColor] == SliderColorEnum.accent ? .effectiveAccent : .white, - dragging: $dragging, - lastDragged: $lastDragged, - onValueChange: onValueChange - ) - .frame(height: 10, alignment: .center) + sliderCore + .frame(height: sliderFrameHeight, alignment: .center) HStack { Text(timeString(from: sliderValue)) Spacer() - Text(timeString(from: duration)) + Text(trailingTimeText) } .fontWeight(.medium) - .foregroundColor( - Defaults[.playerColorTinting] - ? Color(nsColor: color).ensureMinimumBrightness(factor: 0.6) : .gray - ) + .foregroundColor(timeLabelColor) .font(.caption) } - .onChange(of: currentDate) { - guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return } - sliderValue = MusicManager.shared.estimatedPlaybackPosition(at: currentDate) + } + + private var inlineContent: some View { + HStack(spacing: 6) { + Text(timeString(from: sliderValue)) + .font(inlineLabelFont) + .foregroundColor(timeLabelColor) + .frame(width: 36, alignment: .leading) + + sliderCore + .frame(height: sliderFrameHeight) + .frame(maxWidth: .infinity) + + Text(trailingTimeText) + .font(inlineLabelFont) + .foregroundColor(timeLabelColor) + .frame(width: 42, alignment: .trailing) + } + } + + private var sliderCore: some View { + CustomSlider( + value: $sliderValue, + range: 0...duration, + color: Defaults[.sliderColor] == SliderColorEnum.albumArt + ? Color(nsColor: color).ensureMinimumBrightness(factor: 0.8) + : Defaults[.sliderColor] == SliderColorEnum.accent ? .effectiveAccent : .white, + dragging: $dragging, + lastDragged: $lastDragged, + onValueChange: onValueChange, + restingTrackHeight: restingTrackHeight, + draggingTrackHeight: draggingTrackHeight + ) + } + + private var timeLabelColor: Color { + Defaults[.playerColorTinting] + ? Color(nsColor: color).ensureMinimumBrightness(factor: 0.6) : .gray + } + + private var trailingTimeText: String { + switch trailingLabel { + case .duration: + return timeString(from: duration) + case .remaining: + return "-" + timeString(from: max(duration - sliderValue, 0)) } } + /// Monospaced digits so the label doesn't jitter as the numbers tick. + private var inlineLabelFont: Font { + .system(size: 11, weight: .medium).monospacedDigit() + } + + private var sliderFrameHeight: CGFloat { + max(restingTrackHeight, draggingTrackHeight) + 1 + } + func timeString(from seconds: Double) -> String { guard seconds.isFinite else { return "--:--" } let totalMinutes = Int(seconds) / 60 @@ -537,11 +615,15 @@ struct CustomSlider: View { @Binding var lastDragged: Date var onValueChange: ((Double) -> Void)? var onDragChange: ((Double) -> Void)? + /// Defaults match the previous hard-coded 5/9 so the standard layout is + /// unchanged; the compact layout passes a chunkier track. + var restingTrackHeight: CGFloat = 5 + var draggingTrackHeight: CGFloat = 9 var body: some View { GeometryReader { geometry in let width = geometry.size.width - let height = CGFloat(dragging ? 9 : 5) + let height = CGFloat(dragging ? draggingTrackHeight : restingTrackHeight) let rangeSpan = range.upperBound - range.lowerBound let progress = rangeSpan == .zero ? 0 : (value - range.lowerBound) / rangeSpan @@ -557,7 +639,7 @@ struct CustomSlider: View { .frame(width: filledTrackWidth, height: height) } .cornerRadius(height / 2) - .frame(height: 10) + .frame(height: max(restingTrackHeight, draggingTrackHeight) + 1) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift new file mode 100644 index 000000000..c53156124 --- /dev/null +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -0,0 +1,767 @@ +// +// NotificationLiveActivity.swift +// boringNotch +// +// Closed-state and expanded presentations for an incoming system +// notification mirrored from Notification Center. +// +// Design intent: mirror the restraint of a native macOS/iOS notification +// banner — clear hierarchy (who → what → action), one obvious primary +// action, generous but not wasteful spacing, and motion that settles +// rather than bounces. Reuses the app's existing visual language (accent +// color, AppIcon, MarqueeText, HoverButton) rather than inventing new +// primitives. +// + +import Defaults +import SwiftUI + +/// Closed notch: app icon on the left, a status dot on the right that +/// briefly pulses on arrival — the same "something just happened" language +/// as an unread badge, without needing to read anything at a glance. +struct NotificationLiveActivity: View { + @EnvironmentObject var vm: BoringViewModel + let notification: SystemNotification + + // The ring is always in the tree; its scale/opacity are what animate. + // Gating the view itself with `if` would give SwiftUI no starting frame + // to interpolate from, so the "pulse" would just appear already faded. + @State private var ringScale: CGFloat = 1 + @State private var ringOpacity: Double = 0 + + private var itemSize: CGFloat { max(0, vm.effectiveClosedNotchHeight - 12) } + + var body: some View { + Group { + if let code = notification.detectedCode { + codePill(code) + } else { + statusPill + } + } + .frame(height: vm.effectiveClosedNotchHeight, alignment: .center) + .onAppear { pulse() } + .onChange(of: notification.id) { _, _ in pulse() } + } + + /// The default closed treatment: icon left, status dot right. + private var statusPill: some View { + HStack { + NotificationAppIcon(bundleID: notification.bundleID, size: itemSize) + + Rectangle() + .fill(.black) + .frame(width: vm.closedNotchSize.width - cornerRadiusInsets.closed.top) + + ZStack { + Circle() + .stroke(Color.effectiveAccent, lineWidth: 1.5) + .scaleEffect(ringScale) + .opacity(ringOpacity) + Circle() + .fill(notification.isLive ? Color.effectiveAccent : Color.secondary) + .frame(width: 7, height: 7) + } + .frame(width: itemSize, height: itemSize) + } + } + + /// A verification code doesn't need opening the notch to be useful — the + /// closed pill widens just enough to show the code and a one-tap copy, + /// same instinct as iOS surfacing OTPs directly on the lock screen. + private func codePill(_ code: String) -> some View { + HStack { + NotificationAppIcon(bundleID: notification.bundleID, size: itemSize) + + Rectangle() + .fill(.black) + .frame(width: vm.closedNotchSize.width - cornerRadiusInsets.closed.top) + + HStack(spacing: 8) { + Text(code) + .font(.system(size: 15, weight: .semibold, design: .monospaced)) + .kerning(1.5) + .foregroundStyle(.white) + .lineLimit(1) + + CodeCopyButton(code: code, diameter: itemSize) + } + } + } + + private func pulse() { + ringScale = 1 + ringOpacity = 0.8 + withAnimation(.easeOut(duration: 0.6)) { + ringScale = 1.8 + ringOpacity = 0 + } + } +} + +/// Open notch: sender, message, and one clear primary action — a reply +/// composer, call controls, or a hand-off to the source app, depending on +/// what the notification actually supports. +struct NotificationExpandedView: View { + @EnvironmentObject var vm: BoringViewModel + @ObservedObject private var manager = SystemNotificationManager.shared + + let notification: SystemNotification + + @State private var replyText = "" + @State private var isSending = false + @State private var didSend = false + @State private var didHandOff = false + @FocusState private var replyFocused: Bool + @State private var hostWindow: BoringNotchSkyLightWindow? + @State private var suggestions: [String] = [] + @State private var isComposing = false + /// User-visible delivery failure — the draft is kept, the notch stays + /// open, and the user can retry or handle the message themselves. + @State private var sendError: String? + + private var kind: NotificationKind { .init(notification) } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + headerAvatar + + // maxWidth (not .infinity) lets this shrink to whatever the + // message actually needs while still wrapping long ones, so the + // notch hugs the content instead of always spanning its full + // width. + VStack(alignment: .leading, spacing: 5) { + header + textBlock + actionArea + } + .frame(maxWidth: 300, alignment: .leading) + } + .padding(.horizontal, 4) + // Pinned to the card's actual top-right corner rather than sitting + // inline at the avatar's vertical center, matching where a close + // control belongs on a notification. + .overlay(alignment: .topTrailing) { + dismissButton + .padding(.top, -2) + } + // Rebuild cleanly when one notification replaces another, instead of + // reusing the previous one's view state. + .id(notification.id) + .background(WindowAccessor { self.hostWindow = $0 as? BoringNotchSkyLightWindow }) + // This view exists only while the notch is open, so appear/disappear + // is the open/close signal: pause the countdown while the user is + // looking at it, resume when they close. holdActive caps itself at + // maxLifetime, so an abandoned open notch still lets the + // notification go rather than pinning it forever. + .onAppear { + manager.holdActive() + // Cycling the stack rebuilds this view for a different + // notification (.id below), so a half-typed reply only survives + // if it's restored from the manager rather than kept in @State. + replyText = manager.draft(for: notification.id) + // Deliberately no auto-focus: mounting this view (hover, arrival, + // cycle) used to grab key-window status and pre-focus the field, + // which read as focus hijacking. Focus is pull-only — tapping the + // field or a suggestion chip sets replyFocused explicitly. + } + // The single teardown point for both the key-window grant and the + // compose hold. Focus changes are too noisy to release on (see + // onChange above), so everything is unwound here — and it must be + // unconditional: a stuck grant would let the notch steal focus on + // some later unrelated click, and a leaked compose hold would pin + // the notch open permanently, since every close path checks it. + .onDisappear { + manager.resumeDismiss() + hostWindow?.wantsKeyForTextInput = false + manager.isComposingReply = false + endComposing() + } + // Apply a pending key request once the window resolves — + // WindowAccessor reports asynchronously, so onAppear's auto-focus + // can run while hostWindow is still nil. + .onChange(of: hostWindow) { _, window in + if replyFocused { window?.wantsKeyForTextInput = true } + } + .onChange(of: replyFocused) { _, focused in + guard focused else { + // Deliberately does NOT release key status or the compose + // hold. Focus flips constantly for reasons that have + // nothing to do with the user being done: the suggestion + // chips arriving restructure the view above the field and + // drop focus, and clicking Send blurs on mouse-down. + // Releasing on each of those resigned key status + // mid-typing and closed the notch out from under clicks. + // Both are released in onDisappear instead, which is the + // only unambiguous "done" signal. + manager.holdActive() + manager.isComposingReply = false + return + } + // The window can only accept keystrokes while it's key — see + // BoringNotchSkyLightWindow.wantsKeyForTextInput. + hostWindow?.wantsKeyForTextInput = true + manager.holdWhileTyping() + // Newer notifications queue instead of replacing this one while + // the field has focus. + manager.isComposingReply = true + // Clicking into the field changes window key status, which + // rebuilds tracking areas and fires a spurious hover-exit — + // that's what closed the notch the instant you tapped the + // input. Every close path already honours preventNotchClose, + // so hold it for the whole compose session. + beginComposing() + } + .onChange(of: replyText) { _, text in + manager.setDraft(text, for: notification.id) + // Stopping the timer's clock is exactly what typing should do — + // a keystroke is the clearest possible "still here" signal, so + // it gets an uncapped hold rather than the notch-open cap. + if replyFocused { manager.holdWhileTyping() } + } + } + + // MARK: - Avatar + + /// A contact photo (or monogram fallback) when the notification has a + /// human sender, badged with the source app's icon bottom-trailing — + /// mirrors iMessage's own Communication Notifications treatment, but + /// works for any app since it's a plain name lookup rather than an + /// intent donation. + /// + /// Only attempted for messaging/calling apps. Other apps' "title" field + /// is often not a person at all — Claude's is a session name, for + /// instance — and treating it as one would both look wrong and fire a + /// pointless Contacts search. + private static let personAvatarBundleIDs: Set = [ + "com.apple.MobileSMS", "com.apple.FaceTime", "com.apple.mail", "com.microsoft.Outlook", + "net.whatsapp.WhatsApp", "ru.keepcoder.Telegram", "com.tdesktop.Telegram", + "com.hnc.Discord" + ] + + @ViewBuilder + private var headerAvatar: some View { + if let sender = notification.sender, + let bundleID = notification.bundleID, + Self.personAvatarBundleIDs.contains(bundleID) { + ZStack(alignment: .bottomTrailing) { + PersonAvatarView(name: sender, size: 46) + appIcon(for: bundleID) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(.black, lineWidth: 1.5)) + .offset(x: 3, y: 3) + } + } else { + NotificationAppIcon(bundleID: notification.bundleID, size: 46) + } + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(notification.sender ?? notification.appName ?? "Notification") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + // No Spacer here — it would expand to fill and drag the notch + // out to full width regardless of how short the message is. + Text(notification.receivedAt, style: .relative) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + .fixedSize() + + queuedBadge + } + // Clears the close button, which overlays the card rather than + // taking a layout slot — reserved here, on the one row it can + // actually collide with, instead of on the whole card (which pushed + // the reply row's right edge in by 20pt of dead space for no + // reason, since nothing else in the card runs that wide). + .padding(.trailing, 22) + } + + /// Deliberately not HoverButton's default 30pt sizing — that reads as a + /// full toolbar control; a notification's close button wants to be + /// closer to iOS's compact circular dismiss. + /// What's waiting behind this notification. Shows the app's icon when + /// everything queued is from one app, so a burst from one conversation + /// reads as "2 more from WhatsApp" rather than an anonymous count. + @ViewBuilder + private var queuedBadge: some View { + let queued = manager.queued + if !queued.isEmpty { + Button { + manager.cycleToNextQueued() + } label: { + HStack(spacing: 3) { + if let bundleID = singleQueuedAppBundleID { + appIcon(for: bundleID) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 11, height: 11) + .clipShape(RoundedRectangle(cornerRadius: 3)) + } + Text("+\(queued.count)") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white.opacity(0.9)) + } + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(.white.opacity(0.14), in: Capsule()) + .contentShape(Capsule()) + } + .buttonStyle(ScaleDownButtonStyle()) + .transition(.scale.combined(with: .opacity)) + .animation(.smooth(duration: 0.2), value: queued.count) + .help("Tap to see the next of \(queued.count) waiting") + } + } + + /// The shared bundle ID when every queued notification came from the + /// same app, else nil. + private var singleQueuedAppBundleID: String? { + let ids = Set(manager.queued.compactMap(\.bundleID)) + return ids.count == 1 ? ids.first : nil + } + + private var dismissButton: some View { + Button { + manager.dismissActive(token: notification.id) + } label: { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 18, height: 18) + .background(.white.opacity(0.1), in: Circle()) + } + .buttonStyle(ScaleDownButtonStyle()) + } + + // MARK: - Body text + + @ViewBuilder + private var textBlock: some View { + if let subtitle = notification.subtitle, subtitle != notification.sender { + Text(subtitle) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if let body = notification.body { + Text(body) + .font(.system(size: 13)) + .foregroundStyle(.secondary.opacity(0.9)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .lineSpacing(1) + } + } + + // MARK: - Primary action, chosen by what the notification supports + + @ViewBuilder + private var actionArea: some View { + switch kind { + case .code(let value): + codeRow(value) + case .call: + callActionRow + case .reply: + replyRow + case .openOnly: + openRow + } + } + + // MARK: - Verification code + + private func codeRow(_ code: String) -> some View { + HStack(spacing: 10) { + Text(code) + .font(.system(size: 20, weight: .semibold, design: .monospaced)) + .kerning(2) + .foregroundStyle(.white) + .lineLimit(1) + + CodeCopyButton(code: code, diameter: 26, showsLabel: true) + } + .padding(.top, 4) + } + + // MARK: - Reply + + private var replyRow: some View { + VStack(alignment: .leading, spacing: 6) { + if !suggestions.isEmpty { + suggestionChips + } + replyField + if let sendError { + Text(sendError) + .font(.caption2) + .foregroundStyle(.red) + .lineLimit(2) + .transition(.opacity) + } + } + .task(id: notification.id) { + guard Defaults[.smartRepliesEnabled], let body = notification.body else { return } + suggestions = await SmartReplyManager.suggestReplies(sender: notification.sender, body: body) + } + } + + /// Tapping a chip fills the field rather than sending immediately — an + /// AI-drafted reply should get a glance before it goes out under your + /// name, not fire on a single tap. + private var suggestionChips: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + // Keyed by position, not by the string itself: two identical + // suggestions would collide as ForEach ids and render + // undefined. SmartReplyManager already dedupes, but the + // view shouldn't depend on model output being distinct. + ForEach(Array(suggestions.enumerated()), id: \.offset) { _, suggestion in + Button { + replyText = suggestion + replyFocused = true + } label: { + Text(suggestion) + .font(.system(size: 12)) + .foregroundStyle(.primary) + .lineLimit(1) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(.white.opacity(0.1), in: Capsule()) + } + .buttonStyle(ScaleDownButtonStyle()) + } + } + } + } + + private var replyField: some View { + HStack(spacing: 8) { + HStack(spacing: 6) { + TextField("Reply", text: $replyText, axis: .horizontal) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .focused($replyFocused) + .onSubmit(send) + .disabled(isSending || didSend || didHandOff) + // Safe to let this fill available width now: the + // containing column is already capped at 300pt, so this + // only fills up to that cap rather than stretching the + // notch itself the way an unbounded TextField would. + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(.white.opacity(0.08), in: Capsule()) + .overlay(Capsule().strokeBorder(.white.opacity(replyFocused ? 0.18 : 0))) + .animation(.easeOut(duration: 0.15), value: replyFocused) + .frame(maxWidth: .infinity) + + sendButton + } + .padding(.top, 2) + } + + @ViewBuilder + /// A real Button, not a shape with .onTapGesture. In a non-activating + /// panel a bare tap gesture needs a clean mouse-down/up pair in a window + /// whose key status isn't changing — but clicking here blurs the text + /// field, which flips key status mid-click and ate the tap. Enter + /// (onSubmit) worked the whole time, and the suggestion chips (already + /// Buttons) worked, which is what pointed at the gesture rather than at + /// send() itself. Buttons track the press properly across that change. + private var sendButton: some View { + Button(action: send) { + ZStack { + Circle() + .fill(fillStyle) + .frame(width: 26, height: 26) + + if isSending { + ProgressView() + .controlSize(.small) + .tint(.white) + } else if didSend { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + } else if didHandOff { + // Clipboard, not a checkmark: the message wasn't + // delivered, it was copied for the user to paste. + Image(systemName: "doc.on.clipboard") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white) + } else { + Image(systemName: "arrow.up") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(canSend ? .white : .secondary) + } + } + .contentShape(Circle()) + } + .buttonStyle(ScaleDownButtonStyle()) + .disabled(!canSend) + .animation(.smooth(duration: 0.25), value: isSending) + .animation(.smooth(duration: 0.25), value: didSend) + .animation(.smooth(duration: 0.25), value: didHandOff) + .sensoryFeedback(.success, trigger: didSend) + } + + private var fillStyle: Color { + if didSend { return .green } + if didHandOff { return .orange } + return canSend ? .effectiveAccent : .white.opacity(0.1) + } + + private var canSend: Bool { + !replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !isSending && !didSend && !didHandOff + } + + /// SharingStateManager refcounts its sessions, so these must stay + /// balanced — a leaked begin pins the notch open for good, since every + /// close path honours preventNotchClose. The local flag guarantees at + /// most one outstanding session per view regardless of how many times + /// focus flips. + private func beginComposing() { + guard !isComposing else { return } + isComposing = true + SharingStateManager.shared.beginInteraction() + } + + private func endComposing() { + guard isComposing else { return } + isComposing = false + SharingStateManager.shared.endInteraction() + } + + private func send() { + guard canSend else { return } + let text = replyText + isSending = true + sendError = nil + Task { + let outcome = await manager.reply(to: notification, text: text) + isSending = false + + if outcome == .failed { + // Don't pretend. Keep the draft, keep the notch open, and + // tell the user plainly what happened. + withAnimation(.smooth) { + sendError = String(localized: "Message couldn't be sent. Your draft is still here — try again or open Messages.") + } + return + } + + replyText = "" + // A hand-off is not a delivery — showing the same checkmark for + // both would tell the user their message went out when it's + // actually sitting on the clipboard. + didSend = outcome == .sent + // A drafted reply isn't sent either — same honest treatment as + // the clipboard hand-off, since the user still has to press + // send in WhatsApp. + didHandOff = outcome == .handedOffToApp || outcome == .draftedInApp + manager.clearDraft(for: notification.id) + try? await Task.sleep(for: .milliseconds(1200)) + manager.dismissActive(token: notification.id) + } + } + + // MARK: - Calls + + /// Real phone/FaceTime affordance — green to accept, red to decline — + /// rather than generic rectangular buttons. + private var callActionRow: some View { + HStack(spacing: 14) { + if let decline = notification.actions.first(where: { + $0.localizedCaseInsensitiveContains("decline") + }) { + callButton(symbol: "phone.down.fill", tint: .red) { + Task { + await manager.perform(decline, on: notification) + manager.dismissActive(token: notification.id) + } + } + } + if let accept = notification.actions.first(where: { action in + ["accept", "answer", "join"].contains { action.localizedCaseInsensitiveContains($0) } + }) { + callButton(symbol: "phone.fill", tint: .green) { + Task { + await manager.perform(accept, on: notification) + manager.dismissActive(token: notification.id) + } + } + } + } + .padding(.top, 2) + } + + private func callButton(symbol: String, tint: Color, action: @escaping () -> Void) -> some View { + Button(action: action) { + Circle() + .fill(tint) + .frame(width: 30, height: 30) + .overlay { + Image(systemName: symbol) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + } + } + .buttonStyle(ScaleDownButtonStyle()) + } + + // MARK: - Fallback: no reply field available + + private var openRow: some View { + Button { + Task { await manager.open(notification) } + } label: { + HStack(spacing: 6) { + Image(systemName: "arrow.up.forward.app.fill") + .font(.system(size: 11)) + Text("Open in \(notification.appName ?? "app")") + .font(.system(size: 12, weight: .medium)) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(.white.opacity(0.06), in: Capsule()) + } + .buttonStyle(ScaleDownButtonStyle()) + .padding(.top, 2) + } +} + +/// What a notification's actions actually let the user do — decides which +/// action row renders. Computed once per render rather than scattering the +/// same string matching across the view. +private enum NotificationKind: Equatable { + case code(String), call, reply, openOnly + + init(_ notification: SystemNotification) { + // A verification code wins over everything else — copying it is + // almost certainly what the user opened the notch for. + if let code = notification.detectedCode { + self = .code(code) + return + } + let hasCallActions = notification.actions.contains { action in + ["accept", "decline", "answer", "join"].contains { action.localizedCaseInsensitiveContains($0) } + } + if hasCallActions { + self = .call + } else if notification.canReply { + self = .reply + } else { + self = .openOnly + } + } +} + +private struct NotificationAppIcon: View { + let bundleID: String? + let size: CGFloat + + var body: some View { + Group { + if let bundleID { + appIcon(for: bundleID) + .resizable() + } else { + Image(systemName: "bell.fill") + .resizable() + .scaledToFit() + .padding(size * 0.22) + .foregroundStyle(.secondary) + } + } + .aspectRatio(contentMode: .fit) + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: size * 0.22)) + } +} + +/// A restrained press state for the plain icon-style buttons above — +/// matches the subtle scale feedback used on the album art button rather +/// than a full opacity/highlight change. +private struct ScaleDownButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.92 : 1) + .animation(.smooth(duration: 0.15), value: configuration.isPressed) + } +} + +/// Copies a code to the pasteboard and morphs into a checkmark for a beat — +/// same confirmation language as the reply send button, so the two feel like +/// one design rather than two different affordances for "did that work?" +private struct CodeCopyButton: View { + let code: String + var diameter: CGFloat + var showsLabel: Bool = false + + @State private var didCopy = false + + var body: some View { + Button(action: copy) { + HStack(spacing: 4) { + Image(systemName: didCopy ? "checkmark" : "doc.on.doc") + .font(.system(size: diameter * 0.42, weight: .semibold)) + if showsLabel { + Text(didCopy ? "Copied" : "Copy") + .font(.system(size: 12, weight: .medium)) + } + } + .foregroundStyle(.white) + .frame(height: diameter) + .padding(.horizontal, showsLabel ? 10 : 0) + .frame(width: showsLabel ? nil : diameter) + .background( + (didCopy ? Color.green : Color.effectiveAccent), + in: Capsule() + ) + } + .buttonStyle(ScaleDownButtonStyle()) + .animation(.smooth(duration: 0.25), value: didCopy) + .sensoryFeedback(.success, trigger: didCopy) + } + + private func copy() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(code, forType: .string) + didCopy = true + Task { + try? await Task.sleep(for: .seconds(1.5)) + didCopy = false + } + } +} + +/// Reads the NSWindow hosting this SwiftUI view. Needed because +/// BoringNotchSkyLightWindow can't become key by default (a click on the notch must +/// never steal focus from the frontmost app) — the reply field has to reach +/// through to that window to ask for key status only for the moment it's +/// actually being typed into. +private struct WindowAccessor: NSViewRepresentable { + let onResolve: (NSWindow?) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { onResolve(view.window) } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) {} +} diff --git a/boringNotch/components/NotificationDebugWindow.swift b/boringNotch/components/NotificationDebugWindow.swift new file mode 100644 index 000000000..b1d6e50c5 --- /dev/null +++ b/boringNotch/components/NotificationDebugWindow.swift @@ -0,0 +1,100 @@ +// +// NotificationDebugWindow.swift +// boringNotch +// +// Developer window for inspecting captured Notification Center banners before +// they are wired into the notch UI. +// + +import SwiftUI + +struct NotificationDebugView: View { + @StateObject private var manager = SystemNotificationManager.shared + @State private var replyText = "" + @State private var axDump = "" + @State private var lastResult = "" + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Circle() + .fill(manager.isWatching ? .green : .red) + .frame(width: 8, height: 8) + Text(manager.isWatching ? "Watching" : "Not watching") + Spacer() + Button("Start") { Task { await manager.start() } } + Button("Stop") { manager.stop() } + Button("Clear") { manager.clear() } + Button("Dump AX tree") { Task { axDump = await manager.debugDump() } } + } + + if !lastResult.isEmpty { + Text(lastResult).font(.caption).foregroundStyle(.secondary) + } + + List(manager.notifications) { notification in + VStack(alignment: .leading, spacing: 4) { + HStack { + if let icon = notification.icon { + Image(nsImage: icon).resizable().frame(width: 20, height: 20) + } + Text(notification.appName ?? "unknown app").bold() + Text(notification.bundleID ?? "no bundle id") + .font(.caption).foregroundStyle(.secondary) + Spacer() + if !notification.isLive { + Text("expired").font(.caption).foregroundStyle(.orange) + } + } + Text(notification.title ?? "—") + if let subtitle = notification.subtitle { + Text(subtitle).font(.caption) + } + Text(notification.body ?? "—").font(.caption).foregroundStyle(.secondary) + Text("actions: \(notification.actions.joined(separator: ", "))") + .font(.caption2).foregroundStyle(.tertiary) + + HStack { + TextField("reply…", text: $replyText) + .onSubmit { send(to: notification) } + Button("Send") { send(to: notification) } + .disabled(replyText.isEmpty) + Button("Open") { + Task { await manager.open(notification) } + } + } + .disabled(!notification.isLive) + } + .padding(.vertical, 4) + } + + if !axDump.isEmpty { + ScrollView { + Text(axDump).font(.system(.caption, design: .monospaced)).textSelection(.enabled) + } + .frame(height: 200) + } + } + .padding() + .frame(minWidth: 520, minHeight: 480) + .task { await manager.start() } + } + + private func send(to notification: SystemNotification) { + let text = replyText + replyText = "" + Task { + let outcome = await manager.reply(to: notification, text: text) + switch outcome { + case .sent: + lastResult = "replied via the live banner" + case .handedOffToApp: + lastResult = "banner gone — copied to clipboard and opened the app" + case .draftedInApp: + lastResult = "banner gone — opened the conversation with the text pre-filled" + case .failed: + lastResult = "delivery failed (timeout/blocked) — draft preserved" + } + } + } +} diff --git a/boringNotch/components/OSD/Managers/BetterDisplayManager.swift b/boringNotch/components/OSD/Managers/BetterDisplayManager.swift index f313d1ca5..f6c688409 100644 --- a/boringNotch/components/OSD/Managers/BetterDisplayManager.swift +++ b/boringNotch/components/OSD/Managers/BetterDisplayManager.swift @@ -150,23 +150,22 @@ final class BetterDisplayManager { await MainActor.run { brightnessValue = Float(rawValue) lastChangeAt = Date() - BoringViewCoordinator.shared.toggleSneakPeek( - status: true, + NotchUIEventBus.events.send(.sneakPeek( type: .brightness, value: CGFloat(rawValue / maxVal), targetScreenUUID: targetScreenUUID - ) + )) } case .volume: let normalized = maxVal > 0 ? Float(rawValue / maxVal) : Float(rawValue) await MainActor.run { - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .volume, value: CGFloat(normalized)) + NotchUIEventBus.events.send(.sneakPeek(type: .volume, value: CGFloat(normalized))) } case .mute: await MainActor.run { - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .volume, value: CGFloat(rawValue)) + NotchUIEventBus.events.send(.sneakPeek(type: .volume, value: CGFloat(rawValue))) } case .other: @@ -218,7 +217,7 @@ final class BetterDisplayManager { ) } } catch { - print("Failed to encode integration request: \(error)") + Log.osd.error("Failed to encode integration request: \(error)") } } diff --git a/boringNotch/components/OSD/Managers/LunarManager.swift b/boringNotch/components/OSD/Managers/LunarManager.swift index c28ffc3b9..e5952339b 100644 --- a/boringNotch/components/OSD/Managers/LunarManager.swift +++ b/boringNotch/components/OSD/Managers/LunarManager.swift @@ -106,14 +106,13 @@ final class LunarManager { } Task { @MainActor in - BoringViewCoordinator.shared.toggleSneakPeek( - status: true, + NotchUIEventBus.events.send(.sneakPeek( type: .brightness, value: CGFloat(normalizedBrightness), icon: iconString, accent: accentColor, targetScreenUUID: targetScreenUUID - ) + )) } } diff --git a/boringNotch/components/OSD/Managers/XPC/BrightnessManager.swift b/boringNotch/components/OSD/Managers/XPC/BrightnessManager.swift index f386ae975..0c71e3d1e 100644 --- a/boringNotch/components/OSD/Managers/XPC/BrightnessManager.swift +++ b/boringNotch/components/OSD/Managers/XPC/BrightnessManager.swift @@ -15,19 +15,41 @@ final class BrightnessManager: ObservableObject { private let visibleDuration: TimeInterval = 1.2 private let client = XPCHelperClient.shared - private init() { refresh() } + /// Key repeats arriving while an XPC call is in flight accumulate here so + /// no press is lost — each press used to trigger its own 3-RPC sequence + /// (adjust + read + display lookup), and they would queue behind each other. + private var pendingDelta: Float = 0 + private var flushTask: Task? + + /// The brightness target display only changes with the display set. + private var cachedTargetUUID: String? + private var screenParametersObserver: (any NSObjectProtocol)? + + private init() { + refresh() + screenParametersObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.cachedTargetUUID = nil + } + } /// Determine which screen UUID should be used for brightness OSDs /// when the built‑in source is selected. This mirrors the logic in the /// XPC helper, which chooses the menu-bar display if it supports brightness and /// otherwise falls back to an internal panel. + /// Cached; invalidated when the display configuration changes. func brightnessTargetUUID() async -> String? { + if let cachedTargetUUID { return cachedTargetUUID } + var resolved: String? if let displayID = await client.displayIDForBrightness() { - if let screen = NSScreen.screens.first(where: { $0.cgDisplayID == displayID }) { - return screen.displayUUID - } + resolved = NSScreen.screens.first(where: { $0.cgDisplayID == displayID })?.displayUUID } - return NSScreen.main?.displayUUID + resolved = resolved ?? NSScreen.main?.displayUUID + cachedTargetUUID = resolved + return resolved } var shouldShowOverlay: Bool { Date().timeIntervalSince(lastChangeAt) < visibleDuration } @@ -41,16 +63,22 @@ final class BrightnessManager: ObservableObject { } @MainActor func setRelative(delta: Float) { - Task { @MainActor in - let ok = await client.adjustScreenBrightness(by: delta) - if ok { - let current = await client.currentScreenBrightness() ?? rawBrightness + pendingDelta += delta + guard flushTask == nil else { return } + flushTask = Task { @MainActor in + defer { flushTask = nil } + while pendingDelta != 0 { + let delta = pendingDelta + pendingDelta = 0 + // One RPC delivers both the adjustment and the resulting value. + guard let current = await client.adjustScreenBrightness(by: delta) else { + refresh() + return + } publish(brightness: current, touchDate: true) - let targetUUID = await brightnessTargetUUID() - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .brightness, value: CGFloat(current), targetScreenUUID: targetUUID) - } else { - refresh() + let uuid = await brightnessTargetUUID() + NotchUIEventBus.events.send(.sneakPeek(type: .brightness, value: CGFloat(current), targetScreenUUID: uuid)) } } } @@ -63,7 +91,7 @@ final class BrightnessManager: ObservableObject { publish(brightness: clamped, touchDate: true) // optionally show peek when user uses slider/controls let targetUUID = await brightnessTargetUUID() - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .brightness, value: CGFloat(clamped), targetScreenUUID: targetUUID) + NotchUIEventBus.events.send(.sneakPeek(type: .brightness, value: CGFloat(clamped), targetScreenUUID: targetUUID)) } else { refresh() } @@ -93,6 +121,11 @@ final class KeyboardBacklightManager: ObservableObject { private let visibleDuration: TimeInterval = 1.2 private let client = XPCHelperClient.shared + /// Deltas accumulate while a set call is in flight so key repeats are + /// never lost; each flush costs exactly one XPC call. + private var pendingDelta: Float = 0 + private var flushTask: Task? + private init() { refresh() } var shouldShowOverlay: Bool { Date().timeIntervalSince(lastChangeAt) < visibleDuration } @@ -106,20 +139,25 @@ final class KeyboardBacklightManager: ObservableObject { } @MainActor func setRelative(delta: Float) { - Task { @MainActor in - let starting = await client.currentKeyboardBrightness() ?? rawBrightness - let target = max(0, min(1, starting + delta)) - let ok = await client.setKeyboardBrightness(target) - if ok { - publish(brightness: target, touchDate: true) - } else { - refresh() + pendingDelta += delta + guard flushTask == nil else { return } + flushTask = Task { @MainActor in + defer { flushTask = nil } + while pendingDelta != 0 { + let delta = pendingDelta + pendingDelta = 0 + // Compute from the cached value — the read-back RPC the old + // code did before every set doubles the cost per key press. + let target = max(0, min(1, rawBrightness + delta)) + let ok = await client.setKeyboardBrightness(target) + if ok { + publish(brightness: target, touchDate: true) + } else { + refresh() + return + } + NotchUIEventBus.events.send(.sneakPeek(type: .backlight, value: CGFloat(target))) } - BoringViewCoordinator.shared.toggleSneakPeek( - status: true, - type: .backlight, - value: CGFloat(target) - ) } } diff --git a/boringNotch/components/OSD/Managers/XPC/VolumeManager.swift b/boringNotch/components/OSD/Managers/XPC/VolumeManager.swift index f8bdcc0cf..59aa793a0 100644 --- a/boringNotch/components/OSD/Managers/XPC/VolumeManager.swift +++ b/boringNotch/components/OSD/Managers/XPC/VolumeManager.swift @@ -19,360 +19,360 @@ final class VolumeManager: NSObject, ObservableObject { let visibleDuration: TimeInterval = 1.2 - private var didInitialFetch = false private let step: Float32 = 1.0 / 16.0 // Fallback software if hardware mute is not supported private var previousVolumeBeforeMute: Float32 = 0.2 private var softwareMuted: Bool = false + private var didInitialFetch = false + /// Main-side mirror of snapshot.supportsMute for mute-path decisions. + private var deviceSupportsMute = false + + /// All CoreAudio IPC runs on this serial queue. Every property + /// read/write is a synchronous round-trip to coreaudiod, and slider + /// drags can fire change callbacks at 60–120 Hz — none of it belongs + /// on the main thread (the old implementation ran ~40 IPC calls on + /// main *per volume event*). + private let audioQueue = DispatchQueue(label: "com.boringnotch.osd.volume", qos: .userInitiated) + + /// Cached output-device snapshot, rebuilt only when the default output + /// device changes. AudioObjectPropertyAddress values don't mutate + /// between calls, so probing Has/GetSize once per device replaces the + /// ~30 probe calls the old code made on every volume event. + private struct DeviceSnapshot { + var deviceID: AudioObjectID = kAudioObjectUnknown + var volumeElements: [UInt32] = [] + var supportsMute = false + } + /// Only touched on audioQueue. + private var snapshot = DeviceSnapshot() + + /// Writes are coalesced to 15 Hz: a drag gesture produces far more + /// callbacks than hardware (or the user) benefits from. audioQueue-only. + private var pendingWriteTarget: Float32? + private var writeFlushScheduled = false + private let writeFlushInterval: TimeInterval = 1.0 / 15.0 + + /// Volume/mute listeners must be re-registered whenever the output + /// device changes (the old code registered once at init — after a + /// device switch, live updates silently stopped). + private struct ListenerRegistration { + var deviceID: AudioObjectID + var address: AudioObjectPropertyAddress + var block: AudioObjectPropertyListenerBlock + } + private var listenerRegistrations: [ListenerRegistration] = [] private override init() { super.init() - setupAudioListener() - fetchCurrentVolume() + installDeviceChangeListener() + audioQueue.async { [self] in + rebuildSnapshotLocked() + syncFromDeviceLocked() + } } var shouldShowOverlay: Bool { Date().timeIntervalSince(lastChangeAt) < visibleDuration } // MARK: - Public Control API + @MainActor func increase(stepDivisor: Float = 1.0) { - let divisor = max(stepDivisor, 0.25) - let delta = step / Float32(divisor) - let current = readVolumeInternal() ?? rawVolume - let target = max(0, min(1, current + delta)) - setAbsolute(target) - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .volume, value: CGFloat(target)) + adjustInSteps(1, stepDivisor: stepDivisor) } @MainActor func decrease(stepDivisor: Float = 1.0) { - let divisor = max(stepDivisor, 0.25) - let delta = step / Float32(divisor) - let current = readVolumeInternal() ?? rawVolume - let target = max(0, min(1, current - delta)) - setAbsolute(target) - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .volume, value: CGFloat(target)) + adjustInSteps(-1, stepDivisor: stepDivisor) } - @MainActor func toggleMuteAction() { - // Determine expected resulting state immediately and show OSD with that value - let deviceID = systemOutputDeviceID() - var willBeMuted = false - var resultingVolume: Float32 = rawVolume + @MainActor private func adjustInSteps(_ direction: Float32, stepDivisor: Float) { + let delta = step / Float32(max(stepDivisor, 0.25)) * direction + commit(target: max(0, min(1, rawVolume + delta))) + } - if deviceID == kAudioObjectUnknown { - willBeMuted = !softwareMuted - resultingVolume = willBeMuted ? 0 : previousVolumeBeforeMute + @MainActor func toggleMuteAction() { + let willBeMuted = !isMuted + let resultingVolume: Float32 = rawVolume > 0.001 ? rawVolume : previousVolumeBeforeMute + + if willBeMuted { + if deviceSupportsMute { + enqueueHardwareMute(true) + } else { + if rawVolume > 0.001 { previousVolumeBeforeMute = rawVolume } + softwareMuted = true + requestVolumeWrite(0) + } + // Hardware mute preserves the underlying volume level. + publish(volume: deviceSupportsMute ? rawVolume : 0, muted: true, touchDate: true) + NotchUIEventBus.events.send(.sneakPeek(type: .volume, value: 0)) } else { - let currentMuted = isMutedInternal() - willBeMuted = !currentMuted - resultingVolume = willBeMuted ? 0 : (readVolumeInternal() ?? rawVolume) + if deviceSupportsMute { + enqueueHardwareMute(false) + } else { + softwareMuted = false + requestVolumeWrite(previousVolumeBeforeMute) + } + publish(volume: deviceSupportsMute ? rawVolume : resultingVolume, muted: false, touchDate: true) + NotchUIEventBus.events.send(.sneakPeek(type: .volume, value: CGFloat(resultingVolume))) } + } - toggleMuteInternal() - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .volume, value: CGFloat(willBeMuted ? 0 : resultingVolume)) + @MainActor func setAbsolute(_ value: Float32) { + commit(target: max(0, min(1, value))) } - - func refresh() { fetchCurrentVolume() } - - func adjustRelative(delta: Float32) { - if isMutedInternal() { toggleMuteInternal() } - guard let current = readVolumeInternal() else { - fetchCurrentVolume() - return + + /// Shared by keys and slider: optimistically publishes the intent (the + /// OSD bar animates instantly) and defers hardware I/O to the coalesced + /// writer — listeners confirm the ground truth afterwards. + @MainActor private func commit(target: Float32) { + if isMuted && target > 0 { + // Unmute intent: hardware unmute for mute-capable devices, and + // the volume write below restores sound on the software path. + softwareMuted = false + enqueueHardwareMute(false) + } + publish(volume: target, muted: target > 0 ? false : isMuted, touchDate: true) + if target == 0 && !isMuted { + // Historical behavior: driving volume to zero engages mute. + if deviceSupportsMute { + enqueueHardwareMute(true) + } else { + if rawVolume > 0.001 { previousVolumeBeforeMute = rawVolume } + softwareMuted = true + } + publish(volume: target, muted: true, touchDate: true) } - let target = max(0, min(1, current + delta)) - writeVolumeInternal(target) - publish(volume: target, muted: isMutedInternal(), touchDate: true) + requestVolumeWrite(target) + NotchUIEventBus.events.send(.sneakPeek(type: .volume, value: CGFloat(target))) } - @MainActor func setAbsolute(_ value: Float32) { - let clamped = max(0, min(1, value)) - let currentlyMuted = isMutedInternal() - if currentlyMuted && clamped > 0 { - toggleMuteInternal() + // MARK: - Hardware I/O (audioQueue) + + /// Coalesced writer: runs at most every writeFlushInterval and always + /// flushes the *latest* requested target. + private func requestVolumeWrite(_ value: Float32) { + audioQueue.async { [self] in + pendingWriteTarget = value + guard !writeFlushScheduled else { return } + writeFlushScheduled = true + audioQueue.asyncAfter(deadline: .now() + writeFlushInterval) { [self] in + writeFlushScheduled = false + guard let target = pendingWriteTarget else { return } + pendingWriteTarget = nil + writeVolumeLocked(target) + syncFromDeviceLocked() + } } + } - writeVolumeInternal(clamped) - - if clamped == 0 && !currentlyMuted { - toggleMuteInternal() + private func enqueueHardwareMute(_ muted: Bool) { + audioQueue.async { [self] in + guard snapshot.supportsMute else { return } + var value: UInt32 = muted ? 1 : 0 + var addr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyMute, + mScope: kAudioDevicePropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain + ) + AudioObjectSetPropertyData( + snapshot.deviceID, &addr, 0, nil, UInt32(MemoryLayout.size), &value) + syncFromDeviceLocked() } - - publish(volume: clamped, muted: isMutedInternal(), touchDate: true) } - // MARK: - CoreAudio Helpers - private func systemOutputDeviceID() -> AudioObjectID { - var defaultDeviceID = kAudioObjectUnknown - var propertyAddress = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDefaultOutputDevice, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain - ) - var dataSize = UInt32(MemoryLayout.size) - let status = AudioObjectGetPropertyData( - AudioObjectID(kAudioObjectSystemObject), - &propertyAddress, - 0, - nil, - &dataSize, - &defaultDeviceID - ) - if status != noErr { return kAudioObjectUnknown } - return defaultDeviceID - } + // MARK: - Snapshot & Listeners (audioQueue) + + /// Removes listeners from the old device, probes the new one once, and + /// attaches volume/mute listeners to it. CoreAudio delivers every + /// subsequent change event-driven, so steady state costs zero polling. + private func rebuildSnapshotLocked() { + for registration in listenerRegistrations { + var address = registration.address + AudioObjectRemovePropertyListenerBlock( + registration.deviceID, &address, audioQueue, registration.block) + } + listenerRegistrations.removeAll() - private func fetchCurrentVolume() { let deviceID = systemOutputDeviceID() - guard deviceID != kAudioObjectUnknown else { return } - var volumes: [Float32] = [] - let candidateElements: [UInt32] = [kAudioObjectPropertyElementMain, 1, 2, 3, 4] - for element in candidateElements { - if let v = readValidatedScalar(deviceID: deviceID, element: element) { - volumes.append(v) + var snap = DeviceSnapshot(deviceID: deviceID) + if deviceID != kAudioObjectUnknown { + snap.volumeElements = [kAudioObjectPropertyElementMain, 1, 2, 3, 4].filter { + probeScalar(deviceID: deviceID, element: $0) } + snap.supportsMute = probeMute(deviceID: deviceID) } - if !volumes.isEmpty { - let avg = max(0, min(1, volumes.reduce(0, +) / Float32(volumes.count))) - DispatchQueue.main.async { - if self.rawVolume != avg { - if self.didInitialFetch { - self.lastChangeAt = Date() - } - } - self.rawVolume = avg - self.didInitialFetch = true + snapshot = snap - } + let supports = snap.supportsMute + DispatchQueue.main.async { [self] in + deviceSupportsMute = supports } - var muteAddr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyMute, + guard deviceID != kAudioObjectUnknown else { return } + // Devices without a master volume only expose per-channel scalars; + // listen on every element the snapshot validated. + for element in snap.volumeElements { + attachListenerLocked( + deviceID: deviceID, selector: kAudioDevicePropertyVolumeScalar, element: element) + } + if snap.supportsMute { + attachListenerLocked( + deviceID: deviceID, selector: kAudioDevicePropertyMute, + element: kAudioObjectPropertyElementMain) + } + } + + private func attachListenerLocked( + deviceID: AudioObjectID, selector: AudioObjectPropertySelector, element: UInt32 + ) { + var address = AudioObjectPropertyAddress( + mSelector: selector, mScope: kAudioDevicePropertyScopeOutput, - mElement: kAudioObjectPropertyElementMain + mElement: element ) - if AudioObjectHasProperty(deviceID, &muteAddr) { - var sizeNeeded: UInt32 = 0 - if AudioObjectGetPropertyDataSize(deviceID, &muteAddr, 0, nil, &sizeNeeded) == noErr, - sizeNeeded == UInt32(MemoryLayout.size) - { - var muted: UInt32 = 0 - var mSize = sizeNeeded - if AudioObjectGetPropertyData(deviceID, &muteAddr, 0, nil, &mSize, &muted) == noErr - { - let newMuted = muted != 0 - DispatchQueue.main.async { - if self.isMuted != newMuted { self.lastChangeAt = Date() } - self.isMuted = newMuted - } - } - } + let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in + // Callbacks are delivered on audioQueue already. + self?.syncFromDeviceLocked() } + guard AudioObjectAddPropertyListenerBlock(deviceID, &address, audioQueue, block) == noErr + else { return } + listenerRegistrations.append( + ListenerRegistration(deviceID: deviceID, address: address, block: block)) } - private func setupAudioListener() { - let deviceID = systemOutputDeviceID() - guard deviceID != kAudioObjectUnknown else { return } - - var defaultDevAddr = AudioObjectPropertyAddress( + /// The system-object device-change listener is permanent (registered + /// once) and is delivered on audioQueue like every other callback. + private func installDeviceChangeListener() { + var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultOutputDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) AudioObjectAddPropertyListenerBlock( - AudioObjectID(kAudioObjectSystemObject), &defaultDevAddr, nil - ) { _, _ in - self.fetchCurrentVolume() - } - - var masterAddr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyVolumeScalar, - mScope: kAudioDevicePropertyScopeOutput, - mElement: kAudioObjectPropertyElementMain - ) - if AudioObjectHasProperty(deviceID, &masterAddr) { - AudioObjectAddPropertyListenerBlock(deviceID, &masterAddr, nil) { _, _ in - self.fetchCurrentVolume() - } - } else { - for ch in [UInt32(1), UInt32(2)] { - var chAddr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyVolumeScalar, - mScope: kAudioDevicePropertyScopeOutput, - mElement: ch - ) - if AudioObjectHasProperty(deviceID, &chAddr) { - AudioObjectAddPropertyListenerBlock(deviceID, &chAddr, nil) { _, _ in - self.fetchCurrentVolume() - } - } - } + AudioObjectID(kAudioObjectSystemObject), &address, audioQueue + ) { [weak self] _, _ in + self?.rebuildSnapshotLocked() + self?.syncFromDeviceLocked() } + } - // Mute - var muteAddr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyMute, - mScope: kAudioDevicePropertyScopeOutput, - mElement: kAudioObjectPropertyElementMain - ) - if AudioObjectHasProperty(deviceID, &muteAddr) { - AudioObjectAddPropertyListenerBlock(deviceID, &muteAddr, nil) { _, _ in - self.fetchCurrentVolume() - } + /// Reads ground truth using the cached snapshot (no Has/GetSize + /// probing, unlike the old fetch path) and mirrors it to the published + /// main-side state. + private func syncFromDeviceLocked() { + guard snapshot.deviceID != kAudioObjectUnknown else { return } + let volume = readVolumeLocked() + let muted = snapshot.supportsMute ? readMuteLocked() : nil + DispatchQueue.main.async { [self] in + applyFromDevice(volume: volume, hardwareMuted: muted) } } - private func readVolumeInternal() -> Float32? { - let deviceID = systemOutputDeviceID() - if deviceID == kAudioObjectUnknown { return nil } - var collected: [Float32] = [] - for el in [kAudioObjectPropertyElementMain, 1, 2, 3, 4] { - if let v = readValidatedScalar(deviceID: deviceID, element: el) { collected.append(v) } - } - guard !collected.isEmpty else { return nil } - return collected.reduce(0, +) / Float32(collected.count) + @MainActor private func applyFromDevice(volume: Float32?, hardwareMuted: Bool?) { + let effectiveMuted = hardwareMuted ?? softwareMuted + let changed = + (volume != nil && abs(volume! - rawVolume) > 0.0005) || effectiveMuted != isMuted + // The initial fetch arms change detection without touching the date; + // only later device-reported changes bring the OSD up. + if changed && didInitialFetch { lastChangeAt = Date() } + if let volume { rawVolume = volume } + isMuted = effectiveMuted + didInitialFetch = true } - private func writeVolumeInternal(_ value: Float32) { - let deviceID = systemOutputDeviceID() - if deviceID == kAudioObjectUnknown { return } - let newVal = max(0, min(1, value)) + // MARK: - CoreAudio Primitives (audioQueue, snapshot-backed) - var written = false - if writeValidatedScalar( - deviceID: deviceID, element: kAudioObjectPropertyElementMain, value: newVal) - { - written = true - } else { - var any = false - for el in [UInt32](1...4) { - if writeValidatedScalar(deviceID: deviceID, element: el, value: newVal) { - any = true - } - } - written = any - } - if !written { - // silent fail - } + private func systemOutputDeviceID() -> AudioObjectID { + var defaultDeviceID = kAudioObjectUnknown + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var dataSize = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + 0, + nil, + &dataSize, + &defaultDeviceID + ) + if status != noErr { return kAudioObjectUnknown } + return defaultDeviceID } - private func isMutedInternal() -> Bool { - let deviceID = systemOutputDeviceID() - if deviceID == kAudioObjectUnknown { return softwareMuted } - var muteAddr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyMute, + private func probeScalar(deviceID: AudioObjectID, element: UInt32) -> Bool { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyVolumeScalar, mScope: kAudioDevicePropertyScopeOutput, - mElement: kAudioObjectPropertyElementMain + mElement: element ) - guard AudioObjectHasProperty(deviceID, &muteAddr) else { return softwareMuted } + guard AudioObjectHasProperty(deviceID, &addr) else { return false } var sizeNeeded: UInt32 = 0 - guard AudioObjectGetPropertyDataSize(deviceID, &muteAddr, 0, nil, &sizeNeeded) == noErr, - sizeNeeded == UInt32(MemoryLayout.size) - else { return softwareMuted } - var muted: UInt32 = 0 - var size = sizeNeeded - if AudioObjectGetPropertyData(deviceID, &muteAddr, 0, nil, &size, &muted) == noErr { - return muted != 0 - } - return softwareMuted + return AudioObjectGetPropertyDataSize(deviceID, &addr, 0, nil, &sizeNeeded) == noErr + && sizeNeeded == UInt32(MemoryLayout.size) } - private func toggleMuteInternal() { - let deviceID = systemOutputDeviceID() - if deviceID == kAudioObjectUnknown { - performSoftwareMuteToggle(currentVolume: rawVolume) - return - } - var muteAddr = AudioObjectPropertyAddress( + private func probeMute(deviceID: AudioObjectID) -> Bool { + var addr = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyMute, mScope: kAudioDevicePropertyScopeOutput, mElement: kAudioObjectPropertyElementMain ) - if !AudioObjectHasProperty(deviceID, &muteAddr) { - let currentVol = readVolumeInternal() ?? rawVolume - performSoftwareMuteToggle(currentVolume: currentVol) - return - } + guard AudioObjectHasProperty(deviceID, &addr) else { return false } var sizeNeeded: UInt32 = 0 - guard AudioObjectGetPropertyDataSize(deviceID, &muteAddr, 0, nil, &sizeNeeded) == noErr, - sizeNeeded == UInt32(MemoryLayout.size) - else { - let currentVol = readVolumeInternal() ?? rawVolume - performSoftwareMuteToggle(currentVolume: currentVol) - return - } - var muted: UInt32 = 0 - var size = sizeNeeded - if AudioObjectGetPropertyData(deviceID, &muteAddr, 0, nil, &size, &muted) == noErr { - var newVal: UInt32 = muted == 0 ? 1 : 0 - AudioObjectSetPropertyData(deviceID, &muteAddr, 0, nil, size, &newVal) - let vol = readVolumeInternal() ?? rawVolume - publish(volume: vol, muted: newVal != 0, touchDate: true) - } else { - let currentVol = readVolumeInternal() ?? rawVolume - performSoftwareMuteToggle(currentVolume: currentVol) - } + return AudioObjectGetPropertyDataSize(deviceID, &addr, 0, nil, &sizeNeeded) == noErr + && sizeNeeded == UInt32(MemoryLayout.size) } - private func performSoftwareMuteToggle(currentVolume: Float32) { - if softwareMuted { - let restore = max(0, min(1, previousVolumeBeforeMute)) - writeVolumeInternal(restore) - softwareMuted = false - publish(volume: restore, muted: false, touchDate: true) - } else { - if currentVolume > 0.001 { previousVolumeBeforeMute = currentVolume } - writeVolumeInternal(0) - softwareMuted = true - publish(volume: 0, muted: true, touchDate: true) + private func readVolumeLocked() -> Float32? { + var collected: [Float32] = [] + for element in snapshot.volumeElements { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyVolumeScalar, + mScope: kAudioDevicePropertyScopeOutput, + mElement: element + ) + var vol = Float32(0) + var size = UInt32(MemoryLayout.size) + if AudioObjectGetPropertyData(snapshot.deviceID, &addr, 0, nil, &size, &vol) == noErr { + collected.append(vol) + } } + guard !collected.isEmpty else { return nil } + return max(0, min(1, collected.reduce(0, +) / Float32(collected.count))) } - private func readValidatedScalar(deviceID: AudioObjectID, element: UInt32) -> Float32? { - var addr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyVolumeScalar, - mScope: kAudioDevicePropertyScopeOutput, - mElement: element - ) - guard AudioObjectHasProperty(deviceID, &addr) else { return nil } - var sizeNeeded: UInt32 = 0 - guard AudioObjectGetPropertyDataSize(deviceID, &addr, 0, nil, &sizeNeeded) == noErr, - sizeNeeded == UInt32(MemoryLayout.size) - else { return nil } - var vol = Float32(0) - var size = sizeNeeded - let status = AudioObjectGetPropertyData(deviceID, &addr, 0, nil, &size, &vol) - return status == noErr ? vol : nil + private func writeVolumeLocked(_ value: Float32) { + guard snapshot.deviceID != kAudioObjectUnknown else { return } + let newVal = max(0, min(1, value)) + for element in snapshot.volumeElements { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyVolumeScalar, + mScope: kAudioDevicePropertyScopeOutput, + mElement: element + ) + var val = newVal + AudioObjectSetPropertyData( + snapshot.deviceID, &addr, 0, nil, UInt32(MemoryLayout.size), &val) + } } - private func writeValidatedScalar(deviceID: AudioObjectID, element: UInt32, value: Float32) - -> Bool - { + private func readMuteLocked() -> Bool? { var addr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyVolumeScalar, + mSelector: kAudioDevicePropertyMute, mScope: kAudioDevicePropertyScopeOutput, - mElement: element + mElement: kAudioObjectPropertyElementMain ) - guard AudioObjectHasProperty(deviceID, &addr) else { return false } - var sizeNeeded: UInt32 = 0 - guard AudioObjectGetPropertyDataSize(deviceID, &addr, 0, nil, &sizeNeeded) == noErr, - sizeNeeded == UInt32(MemoryLayout.size) - else { return false } - var val = value - return AudioObjectSetPropertyData(deviceID, &addr, 0, nil, sizeNeeded, &val) == noErr + var muted: UInt32 = 0 + var size = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData(snapshot.deviceID, &addr, 0, nil, &size, &muted) == noErr + else { return nil } + return muted != 0 } - private func publish(volume: Float32, muted: Bool, touchDate: Bool) { - DispatchQueue.main.async { - if touchDate { self.lastChangeAt = Date() } - self.rawVolume = volume - self.isMuted = muted - } + @MainActor private func publish(volume: Float32, muted: Bool, touchDate: Bool) { + if touchDate { lastChangeAt = Date() } + rawVolume = volume + isMuted = muted } } - -extension Array where Element == Float32 { - fileprivate var average: Float32? { isEmpty ? nil : reduce(0, +) / Float32(count) } -} - - diff --git a/boringNotch/components/OSD/Views/InlineOSD.swift b/boringNotch/components/OSD/Views/InlineOSD.swift index d38fa0971..c95b88158 100644 --- a/boringNotch/components/OSD/Views/InlineOSD.swift +++ b/boringNotch/components/OSD/Views/InlineOSD.swift @@ -21,7 +21,7 @@ struct InlineOSD: View { HStack(spacing: 5) { OSDIconView(eventType: type, icon: icon, value: value, accent: accent) - Text(Type2Name(type)) + Text(osdTypeName(type)) .font(.subheadline) .fontWeight(.medium) .lineLimit(1) @@ -79,7 +79,7 @@ struct InlineOSD: View { .frame(height: vm.closedNotchSize.height + (hoverAnimation ? 8 : 0), alignment: .center) } - func Type2Name(_ type: SneakContentType) -> String { + func osdTypeName(_ type: SneakContentType) -> String { switch(type) { case .volume: return NSLocalizedString("Volume", comment: "") diff --git a/boringNotch/components/OSD/Views/OSDIconView.swift b/boringNotch/components/OSD/Views/OSDIconView.swift index 30dac84dc..f070590a0 100644 --- a/boringNotch/components/OSD/Views/OSDIconView.swift +++ b/boringNotch/components/OSD/Views/OSDIconView.swift @@ -24,7 +24,7 @@ struct OSDIconView: View { .scaleEffect(value.isZero ? 0.85 : 1) .frame(width: 20, height: 15, alignment: .leading) case .brightness: - let symbol = icon.isEmpty ? BrightnessSymbolString(value) : icon + let symbol = icon.isEmpty ? brightnessSymbolName(value) : icon Image(systemName: symbol) .contentTransition(.interpolate) .frame(width: 20, height: 15) @@ -45,7 +45,7 @@ struct OSDIconView: View { } } -private func BrightnessSymbolString(_ value: CGFloat) -> String { +private func brightnessSymbolName(_ value: CGFloat) -> String { return value < 0.3 ? "sun.min.fill" : "sun.max.fill" } } diff --git a/boringNotch/components/OSD/Views/SystemEventIndicatorModifier.swift b/boringNotch/components/OSD/Views/SystemEventIndicatorModifier.swift index 5599ac273..83dd956a9 100644 --- a/boringNotch/components/OSD/Views/SystemEventIndicatorModifier.swift +++ b/boringNotch/components/OSD/Views/SystemEventIndicatorModifier.swift @@ -14,14 +14,15 @@ struct SystemEventIndicatorModifier: View { @Binding var value: CGFloat @Binding var icon: String @Binding var accent: Color? - let showSlider: Bool = false + /// Routed to hardware (volume/brightness) when the bar is dragged — + /// without this, the closed-notch OSD slider was display-only. var sendEventBack: (CGFloat) -> Void var body: some View { HStack(spacing: 14) { OSDIconView(eventType: eventType, icon: icon, value: value, accent: accent) - if (eventType != .mic) { - DraggableProgressBar(value: $value, accentColor: accent) + if eventType != .mic { + DraggableProgressBar(value: $value, onChange: sendEventBack, accentColor: accent) if Defaults[.showClosedNotchOSDPercentage] { Text(value, format: .percent.precision(.fractionLength(0))) .font(.system(size: 12, weight: .medium)) diff --git a/boringNotch/components/Onboarding/OnboardingView.swift b/boringNotch/components/Onboarding/OnboardingView.swift index ad36a9fee..fb5155bcc 100644 --- a/boringNotch/components/Onboarding/OnboardingView.swift +++ b/boringNotch/components/Onboarding/OnboardingView.swift @@ -42,7 +42,7 @@ struct OnboardingView: View { .transition(.opacity) case .cameraPermission: - PermissionRequestView( + PermissionsRequestView( icon: Image(systemName: "camera.fill"), title: "Enable Camera Access", description: "Boring Notch includes a mirror feature that lets you quickly check your appearance using your camera, right from the notch. Camera access is required only to show this live preview. You can turn the mirror feature on or off at any time in the app.", @@ -64,7 +64,7 @@ struct OnboardingView: View { .transition(.opacity) case .calendarPermission: - PermissionRequestView( + PermissionsRequestView( icon: Image(systemName: "calendar"), title: "Enable Calendar Access", description: "Boring Notch can show all your upcoming events in one place. Access to your calendar is needed to display your schedule.", @@ -86,7 +86,7 @@ struct OnboardingView: View { .transition(.opacity) case .remindersPermission: - PermissionRequestView( + PermissionsRequestView( icon: Image(systemName: "checklist"), title: "Enable Reminders Access", description: "Boring Notch can show your scheduled reminders alongside your calendar events. Access to Reminders is needed to display your reminders.", @@ -108,7 +108,7 @@ struct OnboardingView: View { .transition(.opacity) case .audioCapturePermission: - PermissionRequestView( + PermissionsRequestView( icon: Image(systemName: "waveform"), title: "Enable Real-Time Audio", description: "Boring Notch can analyze the audio playing from your music app to draw a live FFT waveform in the notch, with only a minimal impact on CPU usage.", @@ -133,7 +133,7 @@ struct OnboardingView: View { .transition(.opacity) case .accessibilityPermission: - PermissionRequestView( + PermissionsRequestView( icon: Image(systemName: "hand.raised.fill"), title: "Enable Accessibility Access", description: "Accessibility access is only needed when using built-in macOS control sources for OSD replacement. External sources like BetterDisplay or Lunar do not require Accessibility. You can enable it later in OSD settings if needed.", diff --git a/boringNotch/components/Onboarding/PermissionsRequestView.swift b/boringNotch/components/Onboarding/PermissionsRequestView.swift index 32501c526..e6718e804 100644 --- a/boringNotch/components/Onboarding/PermissionsRequestView.swift +++ b/boringNotch/components/Onboarding/PermissionsRequestView.swift @@ -7,7 +7,7 @@ import SwiftUI -struct PermissionRequestView: View { +struct PermissionsRequestView: View { let icon: Image let title: String let description: String diff --git a/boringNotch/components/ProgressIndicator.swift b/boringNotch/components/ProgressIndicator.swift deleted file mode 100644 index 00abd102c..000000000 --- a/boringNotch/components/ProgressIndicator.swift +++ /dev/null @@ -1,63 +0,0 @@ - // - // ProgressIndicator.swift - // boringNotch - // - // Created by Harsh Vardhan Goswami on 11/08/24. - // - -import Foundation -import SwiftUI - -struct CircularProgressView: View { - let progress: Double - let color: Color - - var body: some View { - ZStack { - Circle() - .stroke( - Color.white.opacity(0.2), - lineWidth: 6 - ) - Circle() - .trim(from: 0, to: progress) - .stroke( - color, - // 1 - style: StrokeStyle( - lineWidth: 6, - lineCap: .round - ) - ) - .rotationEffect(.degrees(-90)) - } - } -} - -enum ProgressIndicatorType { - case circle - case text -} - - - // based on type .circle or .text -struct ProgressIndicator: View { - var type: ProgressIndicatorType - var progress: Double - var color: Color - - var body: some View { - switch type { - case .circle: - CircularProgressView(progress: progress, color: color).frame( - width: 20, height: 20) - case .text: - Text(progress, format: .percent.precision(.fractionLength(0))) - } - } -} - -#Preview { - ProgressIndicator(type: .circle, progress: 0.8, color: Color.blue).padding() - .frame(width: 200, height: 200) -} diff --git a/boringNotch/components/Settings/EditPanelView.swift b/boringNotch/components/Settings/EditPanelView.swift deleted file mode 100644 index 0d3c0258c..000000000 --- a/boringNotch/components/Settings/EditPanelView.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// EditPanelView.swift -// boringNotch -// -// Created by Richard Kunkli on 12/08/2024. -// - -import SwiftUI - -struct EditPanelView: View { - @State var wallpaperPath: URL? - var body: some View { - VStack { - HStack { - Text("Edit layout") - .font(.system(.largeTitle, design: .rounded)) - .foregroundColor(.white.opacity(0.5)) - Spacer() - Button { - exit(0) - } label: { - Label("Close", systemImage: "xmark") - } - .controlSize(.extraLarge) - .buttonStyle(AccessoryBarButtonStyle()) - } - .padding() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } -} - -#Preview { - EditPanelView() -} - -struct VisualEffectView: NSViewRepresentable { - let material: NSVisualEffectView.Material - let blendingMode: NSVisualEffectView.BlendingMode - - func makeNSView(context _: Context) -> NSVisualEffectView { - let visualEffectView = NSVisualEffectView() - visualEffectView.material = material - visualEffectView.blendingMode = blendingMode - visualEffectView.state = NSVisualEffectView.State.active - visualEffectView.isEmphasized = true - return visualEffectView - } - - func updateNSView(_ visualEffectView: NSVisualEffectView, context _: Context) { - visualEffectView.material = material - visualEffectView.blendingMode = blendingMode - } -} diff --git a/boringNotch/components/Settings/SettingsView.swift b/boringNotch/components/Settings/SettingsView.swift index a3d19c78d..3f2e9542f 100644 --- a/boringNotch/components/Settings/SettingsView.swift +++ b/boringNotch/components/Settings/SettingsView.swift @@ -13,6 +13,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case general case appearance case media + case notifications case calendar case osd case battery @@ -29,6 +30,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .general: "General" case .appearance: "Appearance" case .media: "Media" + case .notifications: "Notifications" case .calendar: "Calendar" case .osd: "OSD" case .battery: "Battery" @@ -45,6 +47,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .general: "gear" case .appearance: "eye" case .media: "play.laptopcomputer" + case .notifications: "bell.badge" case .calendar: "calendar" case .osd: "dial.medium.fill" case .battery: "battery.100.bolt" @@ -85,29 +88,31 @@ struct SettingsView: View { case .general: GeneralSettings() case .appearance: - Appearance() + AppearanceSettingsView() case .media: - Media() + MediaSettingsView() + case .notifications: + NotificationSettingsView() case .calendar: CalendarSettings() case .osd: OSDSettings() case .battery: - Charge() + BatterySettingsView() case .shelf: - Shelf() + ShelfSettingsView() case .mirror: - MirrorSettings() + WebcamSettingsView() case .shortcuts: - Shortcuts() + ShortcutsSettingsView() case .advanced: - Advanced() + AdvancedSettingsView() case .about: if let controller = updaterController { - About(updaterController: controller) + AboutView(updaterController: controller) } else { // Fallback with a default controller - About( + AboutView( updaterController: SPUStandardUpdaterController( startingUpdater: false, updaterDelegate: nil, userDriverDelegate: nil)) diff --git a/boringNotch/components/Settings/Views/AboutView.swift b/boringNotch/components/Settings/Views/AboutView.swift index 14f39381f..852f18c61 100644 --- a/boringNotch/components/Settings/Views/AboutView.swift +++ b/boringNotch/components/Settings/Views/AboutView.swift @@ -9,7 +9,7 @@ import Defaults import Sparkle import SwiftUI -struct About: View { +struct AboutView: View { @State private var showBuildNumber: Bool = false let updaterController: SPUStandardUpdaterController @Environment(\.openWindow) var openWindow diff --git a/boringNotch/components/Settings/Views/AdvancedSettingsView.swift b/boringNotch/components/Settings/Views/AdvancedSettingsView.swift index 2bd918762..2328b91f1 100644 --- a/boringNotch/components/Settings/Views/AdvancedSettingsView.swift +++ b/boringNotch/components/Settings/Views/AdvancedSettingsView.swift @@ -8,7 +8,7 @@ import Defaults import SwiftUI -struct Advanced: View { +struct AdvancedSettingsView: View { @Default(.useCustomAccentColor) var useCustomAccentColor @Default(.customAccentColorData) var customAccentColorData @Default(.extendHoverArea) var extendHoverArea diff --git a/boringNotch/components/Settings/Views/AppearanceSettingsView.swift b/boringNotch/components/Settings/Views/AppearanceSettingsView.swift index d86b411d6..8d4c63c05 100644 --- a/boringNotch/components/Settings/Views/AppearanceSettingsView.swift +++ b/boringNotch/components/Settings/Views/AppearanceSettingsView.swift @@ -8,7 +8,7 @@ import Defaults import SwiftUI -struct Appearance: View { +struct AppearanceSettingsView: View { @ObservedObject var coordinator = BoringViewCoordinator.shared @Default(.sliderColor) var sliderColor diff --git a/boringNotch/components/Settings/Views/BatterySettingsView.swift b/boringNotch/components/Settings/Views/BatterySettingsView.swift index acf0aa52c..e5a64b5c5 100644 --- a/boringNotch/components/Settings/Views/BatterySettingsView.swift +++ b/boringNotch/components/Settings/Views/BatterySettingsView.swift @@ -8,7 +8,7 @@ import Defaults import SwiftUI -struct Charge: View { +struct BatterySettingsView: View { var body: some View { Form { Section { diff --git a/boringNotch/components/Settings/Views/GeneralSettingsView.swift b/boringNotch/components/Settings/Views/GeneralSettingsView.swift index 2fc9175d9..7048e88de 100644 --- a/boringNotch/components/Settings/Views/GeneralSettingsView.swift +++ b/boringNotch/components/Settings/Views/GeneralSettingsView.swift @@ -289,5 +289,15 @@ struct GeneralSettings: View { } header: { Text("Notch behavior") } + + Section { + Defaults.Toggle(key: .compactMode) { + Text("Compact mode") + } + } footer: { + Text("Shows a smaller opened notch with just the music player — no tabs, calendar or mirror.") + .font(.caption) + .foregroundStyle(.secondary) + } } } diff --git a/boringNotch/components/Settings/Views/MediaSettingsView.swift b/boringNotch/components/Settings/Views/MediaSettingsView.swift index 55410274d..744813905 100644 --- a/boringNotch/components/Settings/Views/MediaSettingsView.swift +++ b/boringNotch/components/Settings/Views/MediaSettingsView.swift @@ -8,7 +8,7 @@ import Defaults import SwiftUI -struct Media: View { +struct MediaSettingsView: View { @Default(.waitInterval) var waitInterval @Default(.mediaController) var mediaController @ObservedObject var coordinator = BoringViewCoordinator.shared diff --git a/boringNotch/components/Settings/Views/NotificationSettingsView.swift b/boringNotch/components/Settings/Views/NotificationSettingsView.swift new file mode 100644 index 000000000..d5488f842 --- /dev/null +++ b/boringNotch/components/Settings/Views/NotificationSettingsView.swift @@ -0,0 +1,119 @@ +// +// NotificationSettingsView.swift +// boringNotch +// +// Per-app controls for the notch's notification live activity: which apps +// are mirrored, and which of those should also have their system banner +// auto-dismissed once captured. +// + +import Defaults +import SwiftUI + +private struct KnownNotificationApp: Identifiable { + let bundleID: String + let name: String + var id: String { bundleID } +} + +private let knownNotificationApps: [KnownNotificationApp] = [ + .init(bundleID: "com.apple.MobileSMS", name: "Messages"), + .init(bundleID: "com.apple.FaceTime", name: "FaceTime"), + .init(bundleID: "com.apple.mail", name: "Mail"), + .init(bundleID: "com.microsoft.Outlook", name: "Outlook"), + .init(bundleID: "net.whatsapp.WhatsApp", name: "WhatsApp"), + .init(bundleID: "ru.keepcoder.Telegram", name: "Telegram"), + .init(bundleID: "com.tdesktop.Telegram", name: "Telegram Desktop"), + .init(bundleID: "com.hnc.Discord", name: "Discord"), + .init(bundleID: "com.anthropic.claudefordesktop", name: "Claude") +] + +struct NotificationSettingsView: View { + @Default(.notificationLiveActivity) var notificationLiveActivity + @Default(.notificationsFromAllApps) var notificationsFromAllApps + @Default(.notificationAllowedApps) var allowedApps + + var body: some View { + Form { + Section { + Defaults.Toggle(key: .notificationLiveActivity) { + Text("Show notifications in the notch") + } + } footer: { + Text("Requires Accessibility access. Only banners are mirrored — notifications delivered silently to Notification Center aren't visible to the app.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section { + Defaults.Toggle(key: .notificationsFromAllApps) { + Text("From all apps") + } + .disabled(!notificationLiveActivity) + + if !notificationsFromAllApps { + ForEach(knownNotificationApps) { app in + appRow(app) + } + } + } header: { + Text("Apps") + } footer: { + if !notificationsFromAllApps { + Text("Only these apps show a live activity in the notch. Turn on \"From all apps\" to mirror everything instead.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .disabled(!notificationLiveActivity) + + Section { + Defaults.Toggle(key: .smartRepliesEnabled) { + Text("Suggest replies with Apple Intelligence") + } + .disabled(!notificationLiveActivity || !smartRepliesAvailable) + } footer: { + Text(smartReplyFooter) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .navigationTitle("Notifications") + } + + private var smartRepliesAvailable: Bool { + if case .available = SmartReplyManager.availability { return true } + return false + } + + private var smartReplyFooter: String { + // Drafts run entirely on-device via Apple's on-device model — no + // network calls, nothing leaves the Mac. + switch SmartReplyManager.availability { + case .available: + return "Drafts a few short reply options for messages, entirely on-device. Nothing is sent over the network." + case .unavailable(let reason): + return reason + } + } + + @ViewBuilder + private func appRow(_ app: KnownNotificationApp) -> some View { + HStack { + appIcon(for: app.bundleID) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 5)) + + Toggle(app.name, isOn: Binding( + get: { allowedApps.contains(app.bundleID) }, + set: { on in + if on { allowedApps.insert(app.bundleID) } else { allowedApps.remove(app.bundleID) } + } + )) + } + .disabled(!notificationLiveActivity) + } +} diff --git a/boringNotch/components/Settings/Views/OSDSettingsView.swift b/boringNotch/components/Settings/Views/OSDSettingsView.swift index 7df5400c8..02c9f4c90 100644 --- a/boringNotch/components/Settings/Views/OSDSettingsView.swift +++ b/boringNotch/components/Settings/Views/OSDSettingsView.swift @@ -18,6 +18,7 @@ struct OSDSettings: View { @Default(.osdVolumeSource) private var osdVolumeSourceDefault @State private var isAccessibilityAuthorized = true @State private var menuBarBrightnessSupported = true + @ObservedObject private var xpcClient = XPCHelperClient.shared var body: some View { Form { @@ -75,6 +76,23 @@ struct OSDSettings: View { Text(OSDControlSource.builtin.localizedString) } HelpText("Keyboard brightness currently supports the built-in source only.") + if !xpcClient.helperAvailable { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.title) + .foregroundStyle(.yellow) + + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "Helper Service Unavailable")) + .font(.headline) + Text(String(localized: "The background helper crashed or was closed by macOS. It restarts automatically on the next OSD event.")) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 4) + } if !isAccessibilityAuthorized { HStack(alignment: .center, spacing: 12) { Image(systemName: "accessibility") diff --git a/boringNotch/components/Settings/Views/ShelfSettingsView.swift b/boringNotch/components/Settings/Views/ShelfSettingsView.swift index d997f1f2d..4410edf43 100644 --- a/boringNotch/components/Settings/Views/ShelfSettingsView.swift +++ b/boringNotch/components/Settings/Views/ShelfSettingsView.swift @@ -8,7 +8,7 @@ import Defaults import SwiftUI -struct Shelf: View { +struct ShelfSettingsView: View { @Default(.shelfTapToOpen) var shelfTapToOpen: Bool @Default(.quickShareProvider) var quickShareProvider diff --git a/boringNotch/components/Settings/Views/ShortcutsSettingsView.swift b/boringNotch/components/Settings/Views/ShortcutsSettingsView.swift index 7d39732b6..83ff7335b 100644 --- a/boringNotch/components/Settings/Views/ShortcutsSettingsView.swift +++ b/boringNotch/components/Settings/Views/ShortcutsSettingsView.swift @@ -8,7 +8,7 @@ import KeyboardShortcuts import SwiftUI -struct Shortcuts: View { +struct ShortcutsSettingsView: View { var body: some View { Form { Section { diff --git a/boringNotch/components/Settings/Views/WebcamSettingsView.swift b/boringNotch/components/Settings/Views/WebcamSettingsView.swift index 5aa9f2064..372686b10 100644 --- a/boringNotch/components/Settings/Views/WebcamSettingsView.swift +++ b/boringNotch/components/Settings/Views/WebcamSettingsView.swift @@ -9,7 +9,7 @@ import AVFoundation import SwiftUI import Defaults -struct MirrorSettings: View { +struct WebcamSettingsView: View { @Default(.showMirror) private var showMirror @Default(.isMirrored) private var isMirrored @Default(.mirrorShape) private var mirrorShape diff --git a/boringNotch/components/Shelf/Services/QuickShareService.swift b/boringNotch/components/Shelf/Services/QuickShareService.swift index e5fa0b055..8743f7a04 100644 --- a/boringNotch/components/Shelf/Services/QuickShareService.swift +++ b/boringNotch/components/Shelf/Services/QuickShareService.swift @@ -215,7 +215,7 @@ final class QuickShareService: ObservableObject { @MainActor func showFilePicker(for provider: QuickShareProvider, from view: NSView?) async { guard !isPickerOpen else { - print("⚠️ QuickShareService: File picker already open") + Log.shelf.error("⚠️ QuickShareService: File picker already open") return } @@ -330,7 +330,7 @@ private class SharingServiceDelegate: NSObject {} } } } - print("❌ Failed to resolve bookmark for shelf item") + Log.shelf.error("❌ Failed to resolve bookmark for shelf item") return nil } } diff --git a/boringNotch/components/Shelf/Services/ShareServiceFinder.swift b/boringNotch/components/Shelf/Services/ShareServiceFinder.swift index 745d79812..009d84cd4 100644 --- a/boringNotch/components/Shelf/Services/ShareServiceFinder.swift +++ b/boringNotch/components/Shelf/Services/ShareServiceFinder.swift @@ -42,7 +42,7 @@ class ShareServiceFinder: NSObject, NSSharingServicePickerDelegate { guard !didResume else { return } didResume = true picker.close() // Ensure picker is closed even on timeout - print("Warning: timed out waiting for sharing services") + Log.shelf.debug("Warning: timed out waiting for sharing services") continuation.resume(returning: []) } } diff --git a/boringNotch/components/Shelf/Services/ShelfPersistenceService.swift b/boringNotch/components/Shelf/Services/ShelfPersistenceService.swift index 6478de4a4..0c61620d7 100644 --- a/boringNotch/components/Shelf/Services/ShelfPersistenceService.swift +++ b/boringNotch/components/Shelf/Services/ShelfPersistenceService.swift @@ -41,7 +41,7 @@ final class ShelfPersistenceService { do { // Parse as JSON array to get individual item data guard let jsonArray = try JSONSerialization.jsonObject(with: data) as? [Any] else { - print("⚠️ Shelf persistence file is not a valid JSON array") + Log.shelf.error("⚠️ Shelf persistence file is not a valid JSON array") return [] } @@ -55,17 +55,17 @@ final class ShelfPersistenceService { validItems.append(item) } catch { failedCount += 1 - print("⚠️ Failed to decode shelf item at index \(index): \(error.localizedDescription)") + Log.shelf.error("⚠️ Failed to decode shelf item at index \(index): \(error.localizedDescription)") } } if failedCount > 0 { - print("📦 Successfully loaded \(validItems.count) shelf items, discarded \(failedCount) corrupted items") + Log.shelf.error("📦 Successfully loaded \(validItems.count) shelf items, discarded \(failedCount) corrupted items") } return validItems } catch { - print("❌ Failed to parse shelf persistence file: \(error.localizedDescription)") + Log.shelf.error("❌ Failed to parse shelf persistence file: \(error.localizedDescription)") return [] } } @@ -75,7 +75,7 @@ final class ShelfPersistenceService { let data = try encoder.encode(items) try data.write(to: fileURL, options: Data.WritingOptions.atomic) } catch { - print("Failed to save shelf items: \(error.localizedDescription)") + Log.shelf.error("Failed to save shelf items: \(error.localizedDescription)") } } @@ -85,7 +85,7 @@ final class ShelfPersistenceService { let data = try encoder.encode(items) try data.write(to: fileURL, options: Data.WritingOptions.atomic) } catch { - print("Failed to save shelf items: \(error.localizedDescription)") + Log.shelf.error("Failed to save shelf items: \(error.localizedDescription)") } }.value } diff --git a/boringNotch/components/Shelf/Services/TemporaryFileStorageService.swift b/boringNotch/components/Shelf/Services/TemporaryFileStorageService.swift index e06638ac4..10630d9da 100644 --- a/boringNotch/components/Shelf/Services/TemporaryFileStorageService.swift +++ b/boringNotch/components/Shelf/Services/TemporaryFileStorageService.swift @@ -15,7 +15,7 @@ enum TempFileType { case url(URL) } -class TemporaryFileStorageService { +final class TemporaryFileStorageService { static let shared = TemporaryFileStorageService() // MARK: - Public Interface @@ -32,7 +32,7 @@ class TemporaryFileStorageService { let tempDirectory = URL(fileURLWithPath: NSTemporaryDirectory()) guard url.path.hasPrefix(tempDirectory.path) else { - print("Attempted to remove temporary file outside temp directory: \(url.path)") + Log.shelf.debug("Attempted to remove temporary file outside temp directory: \(url.path)") return } @@ -40,18 +40,18 @@ class TemporaryFileStorageService { do { try FileManager.default.removeItem(at: url) - print("Deleted file: \(url.path)") + Log.shelf.debug("Deleted file: \(url.path)") let contents = try FileManager.default.contentsOfDirectory(atPath: folderURL.path) if contents.isEmpty { try FileManager.default.removeItem(at: folderURL) - print("Folder was empty, deleted folder: \(folderURL.path)") + Log.shelf.debug("Folder was empty, deleted folder: \(folderURL.path)") } else { - print("Folder not deleted — it still contains \(contents.count) item(s).") + Log.shelf.debug("Folder not deleted — it still contains \(contents.count) item(s).") } } catch { - print("Error: \(error.localizedDescription)") + Log.shelf.error("Error: \(error.localizedDescription)") } } @@ -72,7 +72,7 @@ class TemporaryFileStorageService { try data.write(to: fileURL) return fileURL } catch { - print("Error: \(error)") + Log.shelf.error("Error: \(error)") return nil } @@ -82,7 +82,7 @@ class TemporaryFileStorageService { let fileURL = dirURL.appendingPathComponent(filename) guard let data = string.data(using: .utf8) else { - print("❌ Failed to convert text to data") + Log.shelf.error("❌ Failed to convert text to data") return nil } @@ -91,7 +91,7 @@ class TemporaryFileStorageService { try data.write(to: fileURL) return fileURL } catch { - print("Error: \(error)") + Log.shelf.error("Error: \(error)") return nil } @@ -102,7 +102,7 @@ class TemporaryFileStorageService { let weblocContent = createWeblocContent(for: url) guard let data = weblocContent.data(using: String.Encoding.utf8) else { - print("❌ Failed to create webloc data") + Log.shelf.error("❌ Failed to create webloc data") return nil } @@ -111,7 +111,7 @@ class TemporaryFileStorageService { try data.write(to: fileURL) return fileURL } catch { - print("Error: \(error)") + Log.shelf.error("Error: \(error)") return nil } } @@ -122,7 +122,7 @@ class TemporaryFileStorageService { try data.write(to: url) return url } catch { - print("❌ Failed to create temp file at \(url.path): \(error)") + Log.shelf.error("❌ Failed to create temp file at \(url.path): \(error)") return nil } } @@ -134,7 +134,7 @@ class TemporaryFileStorageService { do { try FileManager.default.createDirectory(at: workingDir, withIntermediateDirectories: true) } catch { - print("❌ Failed to create zip working directory: \(error)") + Log.shelf.error("❌ Failed to create zip working directory: \(error)") return nil } @@ -149,7 +149,7 @@ class TemporaryFileStorageService { proc.waitUntilExit() return proc.terminationStatus == 0 } catch { - print("❌ Failed to run zip: \(error)") + Log.shelf.error("❌ Failed to run zip: \(error)") return false } } @@ -200,7 +200,7 @@ class TemporaryFileStorageService { try FileManager.default.copyItem(at: src, to: dest) } } catch { - print("⚠️ Failed to copy \(src.path) to working dir: \(error)") + Log.shelf.error("⚠️ Failed to copy \(src.path) to working dir: \(error)") } } @@ -218,7 +218,7 @@ class TemporaryFileStorageService { } } } catch { - print("⚠️ Failed to cleanup working directory after zip: \(error)") + Log.shelf.error("⚠️ Failed to cleanup working directory after zip: \(error)") } return archiveURL } else { diff --git a/boringNotch/components/Shelf/ViewModels/ShelfItemViewModel.swift b/boringNotch/components/Shelf/ViewModels/ShelfItemViewModel.swift index 69b2e2053..318caa329 100644 --- a/boringNotch/components/Shelf/ViewModels/ShelfItemViewModel.swift +++ b/boringNotch/components/Shelf/ViewModels/ShelfItemViewModel.swift @@ -16,40 +16,6 @@ import ObjectiveC final class ShelfItemViewModel: ObservableObject { @Published private(set) var item: ShelfItem - // MARK: - Localization helpers - private struct Strings { - static let open = NSLocalizedString("Shelf.ContextMenu.Open", comment: "Context menu item: Open") - static let openWith = NSLocalizedString("Shelf.ContextMenu.OpenWith", comment: "Context menu item: Open With") - static let noCompatibleApps = NSLocalizedString("Shelf.ContextMenu.NoCompatibleAppsFound", comment: "Context menu item: No Compatible Apps Found") - static let other = NSLocalizedString("Shelf.ContextMenu.Other", comment: "Context menu item: Other…") - static let showInFinder = NSLocalizedString("Shelf.ContextMenu.ShowInFinder", comment: "Context menu item: Show in Finder") - static let quickLook = NSLocalizedString("Shelf.ContextMenu.QuickLook", comment: "Context menu item: Quick Look") - static let share = NSLocalizedString("Shelf.ContextMenu.Share", comment: "Context menu item: Share…") - static let imageActions = NSLocalizedString("Shelf.ContextMenu.ImageActions", comment: "Context menu item: Image Actions") - static let removeBackground = NSLocalizedString("Shelf.ContextMenu.RemoveBackground", comment: "Context menu item: Remove Background") - static let convertImage = NSLocalizedString("Shelf.ContextMenu.ConvertImage", comment: "Context menu item: Convert Image…") - static let createPDF = NSLocalizedString("Shelf.ContextMenu.CreatePDF", comment: "Context menu item: Create PDF") - static let compress = NSLocalizedString("Shelf.ContextMenu.Compress", comment: "Context menu item: Compress") - static let rename = NSLocalizedString("Shelf.ContextMenu.Rename", comment: "Context menu item: Rename") - static let copy = NSLocalizedString("Shelf.ContextMenu.Copy", comment: "Context menu item: Copy") - static let copyPath = NSLocalizedString("Shelf.ContextMenu.CopyPath", comment: "Context menu item: Copy Path") - static let remove = NSLocalizedString("Shelf.ContextMenu.Remove", comment: "Context menu item: Remove") - } - - private enum ContextMenuAction: String { - case quickLook - case open - case share - case rename - case showInFinder - case copyPath - case copy - case remove - case removeBackground - case convertImage - case createPDF - case compress - } @Published var thumbnail: NSImage? @Published var isDropTargeted: Bool = false @Published var isRenaming: Bool = false @@ -57,7 +23,6 @@ final class ShelfItemViewModel: ObservableObject { private var sharingLifecycle: SharingLifecycleDelegate? private var quickShareLifecycle: SharingLifecycleDelegate? private var sharingAccessingURLs: [URL] = [] - private static var copiedURLs: [URL] = [] private let selection = ShelfSelectionModel.shared @@ -145,7 +110,11 @@ final class ShelfItemViewModel: ObservableObject { func handleRightClick(event: NSEvent, view: NSView) { if !selection.isSelected(item.id) { selection.selectSingle(item) } - presentContextMenu(event: event, in: view) + ShelfContextMenuBuilder.present( + item: item, event: event, in: view, + onShare: { [weak self] v in self?.shareItem(from: v) }, + onQuickLook: { [weak self] urls in self?.onQuickLookRequest?(urls) } + ) } func handleDoubleClick() { @@ -207,934 +176,4 @@ final class ShelfItemViewModel: ObservableObject { /// Call this closure to request a QuickLook preview for the given URLs. var onQuickLookRequest: (([URL]) -> Void)? - - // MARK: - Context Menu helpers (extracted from view) - func loadOpenWithApps() -> [URL] { - // Support both files and link items. For link items we ask NSWorkspace for apps that can open the URL (browsers). - if let fileURL = item.fileURL { - var results: [URL] = NSWorkspace.shared.urlsForApplications(toOpen: fileURL) - if results.isEmpty { - if let uti = try? fileURL.resourceValues(forKeys: [.contentTypeKey]).contentType { - results = NSWorkspace.shared.urlsForApplications(toOpen: uti) - } - } - let unique = Array(Set(results)) - let sorted = unique.sorted { appDisplayName(for: $0) < appDisplayName(for: $1) } - return sorted - } else if case .link(let url) = item.kind { - var results: [URL] = NSWorkspace.shared.urlsForApplications(toOpen: url) - if results.isEmpty { - if let uti = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType { - results = NSWorkspace.shared.urlsForApplications(toOpen: uti) - } - } - let unique = Array(Set(results)) - let sorted = unique.sorted { appDisplayName(for: $0) < appDisplayName(for: $1) } - return sorted - } - return [] - } - - private func ensureContextMenuSelection() { - if !selection.isSelected(item.id) { selection.selectSingle(item) } - } - - func presentContextMenu(event: NSEvent, in view: NSView) { - ensureContextMenuSelection() - let menu = NSMenu() - - func addMenuItem(title: String) { - let mi = NSMenuItem(title: title, action: nil, keyEquivalent: "") - menu.addItem(mi) - } - - let selectedItems = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - let selectedFileURLs = selectedItems.compactMap { $0.fileURL } - let selectedLinkURLs: [URL] = selectedItems.compactMap { itm in - if case .link(let url) = itm.kind { return url } - return nil - } - let selectedFolderURLs = selectedFileURLs.filter { isDirectory($0) } - // URLs valid for Open/Open With (exclude folders) - let selectedOpenableURLs = selectedItems.compactMap { itm -> URL? in - if let u = itm.fileURL { return isDirectory(u) ? nil : u } - if case .link(let url) = itm.kind { return url } - return nil - } - - if !selectedOpenableURLs.isEmpty { - addMenuItem(title: "Open") - } - - if !selectedOpenableURLs.isEmpty { - let openWith = NSMenuItem(title: "Open With", action: nil, keyEquivalent: "") - let submenu = NSMenu() - - // Choose a representative URL to compute apps (prefer current item if not a folder) - let baseURLForApps: URL? = { - if let u = item.fileURL, !isDirectory(u) { return u } - if case .link(let u) = item.kind { return u } - return selectedOpenableURLs.first - }() - - let openWithApps: [URL] = { - guard let u = baseURLForApps else { return [] } - if u.isFileURL { - var results = NSWorkspace.shared.urlsForApplications(toOpen: u) - if results.isEmpty, let uti = try? u.resourceValues(forKeys: [.contentTypeKey]).contentType { - results = NSWorkspace.shared.urlsForApplications(toOpen: uti) - } - return Array(Set(results)) - } else { - return Array(Set(NSWorkspace.shared.urlsForApplications(toOpen: u))) - } - }() - let defaultApp = defaultAppURL() - - if openWithApps.isEmpty { - let noApps = NSMenuItem(title: "No Compatible Apps Found", action: nil, keyEquivalent: "") - noApps.isEnabled = false - submenu.addItem(noApps) - } else { - if let defaultApp = defaultApp { - let appName = appDisplayName(for: defaultApp) - let def = NSMenuItem(title: appName, action: nil, keyEquivalent: "") - def.representedObject = defaultApp - def.image = nsAppIcon(for: defaultApp, size: 16) - - let title = NSMutableAttributedString(string: appName, attributes: [ - .font: NSFont.menuFont(ofSize: 0), - .foregroundColor: NSColor.labelColor - ]) - let defaultPart = NSAttributedString(string: " (default)", attributes: [ - .font: NSFont.menuFont(ofSize: 0), - .foregroundColor: NSColor.secondaryLabelColor - ]) - title.append(defaultPart) - def.attributedTitle = title - submenu.addItem(def) - - if openWithApps.count > 1 || !openWithApps.contains(defaultApp) { - submenu.addItem(NSMenuItem.separator()) - } - } - for appURL in openWithApps where appURL != defaultApp { - let mi = NSMenuItem(title: appDisplayName(for: appURL), action: nil, keyEquivalent: "") - mi.representedObject = appURL - mi.image = nsAppIcon(for: appURL, size: 16) - submenu.addItem(mi) - } - } - - submenu.addItem(NSMenuItem.separator()) - let other = NSMenuItem(title: "Other…", action: nil, keyEquivalent: "") - other.representedObject = "__OTHER__" - submenu.addItem(other) - - openWith.submenu = submenu - menu.addItem(openWith) - } - - if !selectedFileURLs.isEmpty { addMenuItem(title: "Show in Finder") } - // Allow Quick Look for files and link URLs - if !selectedFileURLs.isEmpty || !selectedLinkURLs.isEmpty { - // Add Quick Look menu item - let quickLookItem = NSMenuItem(title: "Quick Look", action: nil, keyEquivalent: "") - menu.addItem(quickLookItem) - - // Add Slideshow as alternate menu item (shown when Option key is held) - let slideshowItem = NSMenuItem(title: "Quick Look", action: nil, keyEquivalent: "") - slideshowItem.isAlternate = true - slideshowItem.keyEquivalentModifierMask = [.option] - menu.addItem(slideshowItem) - } - - menu.addItem(NSMenuItem.separator()) - addMenuItem(title: "Share…") - - // Add image processing options for image files grouped under "Image Actions" - let imageURLs = selectedFileURLs.filter { ImageProcessingService.shared.isImageFile($0) } - if !imageURLs.isEmpty { - menu.addItem(NSMenuItem.separator()) - - let imageActions = NSMenuItem(title: "Image Actions", action: nil, keyEquivalent: "") - let imageSubmenu = NSMenu() - - // Remove Background - only for single images - if imageURLs.count == 1 { - let removeBg = NSMenuItem(title: "Remove Background", action: nil, keyEquivalent: "") - imageSubmenu.addItem(removeBg) - } - - // Convert Image - only for single images - if imageURLs.count == 1 { - let convertItem = NSMenuItem(title: "Convert Image…", action: nil, keyEquivalent: "") - imageSubmenu.addItem(convertItem) - } - - // Create PDF - for one or more images - let createPDF = NSMenuItem(title: "Create PDF", action: nil, keyEquivalent: "") - imageSubmenu.addItem(createPDF) - - imageActions.submenu = imageSubmenu - menu.addItem(imageActions) - menu.addItem(NSMenuItem.separator()) - } - - // Add compression option for files/folders (single or multiple) - if !selectedFileURLs.isEmpty { - let compressItem = NSMenuItem(title: "Compress", action: nil, keyEquivalent: "") - menu.addItem(compressItem) - } - - if selectedItems.count == 1, case .file(_) = item.kind { addMenuItem(title: "Rename") } - - // Always show "Copy" for all item types - addMenuItem(title: "Copy") - // If there are file URLs, add "Copy Path" as an alternate menu item (Option key) - if !selectedFileURLs.isEmpty { - let copyPathItem = NSMenuItem(title: "Copy Path", action: nil, keyEquivalent: "") - copyPathItem.isAlternate = true - copyPathItem.keyEquivalentModifierMask = [.option] - menu.addItem(copyPathItem) - } - - menu.addItem(NSMenuItem.separator()) - addMenuItem(title: "Remove") - - let actionTarget = MenuActionTarget(item: item, view: view, viewModel: self) - - for menuItem in menu.items { - if menuItem.isSeparatorItem { continue } - menuItem.target = actionTarget - menuItem.action = #selector(MenuActionTarget.handle(_:)) - - if let submenu = menuItem.submenu { - for subItem in submenu.items { - if !subItem.isSeparatorItem { - subItem.target = actionTarget - subItem.action = #selector(MenuActionTarget.handle(_:)) - } - } - } - } - - menu.retainActionTarget(actionTarget) - - NSMenu.popUpContextMenu(menu, with: event, for: view) - } - - private func isDirectory(_ url: URL) -> Bool { - return url.accessSecurityScopedResource { scoped in - (try? scoped.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false - } - } - - private final class MenuActionTarget: NSObject { - let item: ShelfItem - weak var view: NSView? - weak var viewModel: ShelfItemViewModel? - - // Keep associated objects (like accessory view handlers) without magic keys - private static var sliderHandlerAssoc = AssociatedObject() - - init(item: ShelfItem, view: NSView, viewModel: ShelfItemViewModel) { - self.item = item - self.view = view - self.viewModel = viewModel - } - - @MainActor @objc func handle(_ sender: NSMenuItem) { - let title = sender.title - - if let marker = sender.representedObject as? String, marker == "__OTHER__" { - openWithPanel() - return - } - - if let appURL = sender.representedObject as? URL { - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - - Task { - var allSelectedURLs: [URL] = [] - - for itm in selected { - if let fileURL = itm.fileURL { - allSelectedURLs.append(fileURL) - } else if case .link(let url) = itm.kind { - allSelectedURLs.append(url) - } - } - - guard !allSelectedURLs.isEmpty else { return } - - let config = NSWorkspace.OpenConfiguration() - - let fileURLs = allSelectedURLs.filter { $0.isFileURL } - do { - if !fileURLs.isEmpty { - _ = try await fileURLs.accessSecurityScopedResources { _ in - try await NSWorkspace.shared.open(allSelectedURLs, withApplicationAt: appURL, configuration: config) - } - } else { - try await NSWorkspace.shared.open(allSelectedURLs, withApplicationAt: appURL, configuration: config) - } - } catch { - print("❌ Failed to open with application: \(error.localizedDescription)") - } - } - return - } - - switch title { - case "Quick Look": - // Handle all selected items for Quick Look, not just the clicked item - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - let urls: [URL] = selected.compactMap { item in - if let fileURL = item.fileURL { - return fileURL - } - if case .link(let url) = item.kind { - return url - } - return nil - } - if !urls.isEmpty { - viewModel?.onQuickLookRequest?(urls) - } - - case "Open": - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - for it in selected { ShelfActionService.open(it) } - - case "Share…": - viewModel?.shareItem(from: view) - - case "Rename": - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - if selected.count == 1, let single = selected.first { showRenameDialog(for: single) } - - case "Show in Finder": - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - Task { - let urls = await selected.asyncCompactMap { item -> URL? in - if case .file = item.kind { - // Use immediate update for user-initiated menu action - return ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) - } - return nil - } - if !urls.isEmpty { - await urls.accessSecurityScopedResources { accessibleURLs in - NSWorkspace.shared.activateFileViewerSelecting(accessibleURLs) - } - } - } - - case "Copy Path": - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - let paths = selected.compactMap { $0.fileURL?.path } - if !paths.isEmpty { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(paths.joined(separator: "\n"), forType: .string) - } - - case "Copy": - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - let pb = NSPasteboard.general - - // Stop accessing previously copied URLs - for url in ShelfItemViewModel.copiedURLs { - url.stopAccessingSecurityScopedResource() - } - ShelfItemViewModel.copiedURLs.removeAll() - - pb.clearContents() - Task { - let fileURLs = await selected.asyncCompactMap { item -> URL? in - if case .file = item.kind { - return ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) - } - return nil - } - if !fileURLs.isEmpty { - // Start security-scoped access for all URLs and keep them active - ShelfItemViewModel.copiedURLs = fileURLs.filter { $0.startAccessingSecurityScopedResource() } - NSLog("🔐 Started security-scoped access for \(ShelfItemViewModel.copiedURLs.count) copied files") - - // Write to pasteboard - pb.writeObjects(fileURLs as [NSURL]) - } else { - let strings = selected.map { $0.displayName } - if !strings.isEmpty { - pb.setString(strings.joined(separator: "\n"), forType: .string) - } - } - } - - case "Remove": - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - for it in selected { ShelfActionService.remove(it) } - - case "Remove Background": - handleRemoveBackground() - - case "Convert Image…": - showConvertImageDialog() - - case "Create PDF": - handleCreatePDF() - - case "Compress": - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - let fileURLs = selected.compactMap { $0.fileURL } - guard !fileURLs.isEmpty else { break } - - Task { - // Create ZIP in a temporary location while holding access to selected resources - if let zipTempURL = await fileURLs.accessSecurityScopedResources(accessor: { urls in - await TemporaryFileStorageService.shared.createZip(from: urls) - }) { - if let bookmark = try? Bookmark(url: zipTempURL) { - let newItem = ShelfItem(kind: .file(bookmark: bookmark.data), isTemporary: true) - ShelfStateViewModel.shared.add([newItem]) - } else { - // Fallback: reveal the temporary file in Finder - NSWorkspace.shared.activateFileViewerSelecting([zipTempURL]) - } - } - } - - default: - break - } - } - - @MainActor - private func openWithPanel() { - // Support both file items and link items - let targetURL: URL? - let needsSecurityScope: Bool - - if let fileURL = item.fileURL { - targetURL = fileURL - needsSecurityScope = true - } else if case .link(let url) = item.kind { - targetURL = url - needsSecurityScope = false - } else { - targetURL = nil - needsSecurityScope = false - } - guard let fileURL = targetURL else { return } - - let panel = NSOpenPanel() - panel.title = "Choose Application" - panel.message = "Choose an application to open the document \"\(item.displayName)\"." - panel.prompt = "Open" - panel.allowsMultipleSelection = false - panel.canChooseFiles = true - panel.canChooseDirectories = false - panel.resolvesAliases = true - if #available(macOS 12.0, *) { - panel.allowedContentTypes = [.application] - } - panel.directoryURL = URL(fileURLWithPath: "/Applications") - - // Compute recommended applications for the selected target - let recommendedApps: Set = { - let apps: [URL] - if let uti = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType { - apps = NSWorkspace.shared.urlsForApplications(toOpen: uti) - } else { - apps = NSWorkspace.shared.urlsForApplications(toOpen: fileURL) - } - return Set(apps.map { $0.standardizedFileURL }) - }() - - // Delegate to filter entries when in "Recommended Applications" mode - final class AppChooserDelegate: NSObject, NSOpenSavePanelDelegate { - enum Mode { case recommended, all } - var mode: Mode = .recommended - let recommended: Set - init(recommended: Set) { self.recommended = recommended } - - func panel(_ sender: Any, shouldEnable url: URL) -> Bool { - let ext = url.pathExtension.lowercased() - if ext == "app" { - switch mode { - case .all: - return true - case .recommended: - // Standardize URLs for reliable comparison - let std = url.standardizedFileURL - return recommended.contains(std) - } - } - - var isDirectory: ObjCBool = false - if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue { - return true - } - - return false - } - } - - let chooserDelegate = AppChooserDelegate(recommended: recommendedApps) - panel.delegate = chooserDelegate - - let enableLabel = NSTextField(labelWithString: "Enable:") - enableLabel.font = .systemFont(ofSize: NSFont.systemFontSize) - enableLabel.alignment = .natural - enableLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) - - let popup = NSPopUpButton(frame: .zero, pullsDown: false) - popup.addItems(withTitles: ["Recommended Applications", "All Applications"]) - popup.font = .systemFont(ofSize: NSFont.systemFontSize) - popup.selectItem(at: 0) - - popup.setContentHuggingPriority(.defaultLow, for: .horizontal) - popup.widthAnchor.constraint(greaterThanOrEqualToConstant: 200).isActive = true - - let alwaysCheckbox = NSButton(checkboxWithTitle: "Always Open With", target: nil, action: nil) - alwaysCheckbox.font = .systemFont(ofSize: NSFont.systemFontSize) - alwaysCheckbox.setContentHuggingPriority(.defaultLow, for: .horizontal) - - let row = NSStackView(views: [enableLabel, popup]) - row.orientation = .horizontal - row.spacing = 8 - row.alignment = .centerY - row.distribution = .fill - - let column = NSStackView(views: [row, alwaysCheckbox]) - column.orientation = .vertical - column.spacing = 12 - column.alignment = .centerX - column.distribution = .fill - column.edgeInsets = NSEdgeInsets(top: 16, left: 20, bottom: 16, right: 20) - - panel.accessoryView = column - panel.isAccessoryViewDisclosed = true - - // Wire up popup to switch filter mode - class PopupBinder: NSObject { - weak var popup: NSPopUpButton? - weak var chooserDelegate: AppChooserDelegate? - weak var panel: NSOpenPanel? - init(popup: NSPopUpButton, chooserDelegate: AppChooserDelegate, panel: NSOpenPanel) { - self.popup = popup - self.chooserDelegate = chooserDelegate - self.panel = panel - } - @MainActor @objc func changed(_ sender: Any?) { - if popup?.indexOfSelectedItem == 1 { - chooserDelegate?.mode = .all - } else { - chooserDelegate?.mode = .recommended - } - if let panel = panel { - panel.validateVisibleColumns() - let currentDir = panel.directoryURL - panel.directoryURL = currentDir - } - } - } - let binder = PopupBinder(popup: popup, chooserDelegate: chooserDelegate, panel: panel) - popup.target = binder - popup.action = #selector(PopupBinder.changed(_:)) - - panel.begin { response in - if response == .OK, let appURL = panel.url { - Task { - do { - let config = NSWorkspace.OpenConfiguration() - if alwaysCheckbox.state == .on, let bundleID = Bundle(url: appURL)?.bundleIdentifier { - if let contentType = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType { - let status = LSSetDefaultRoleHandlerForContentType(contentType.identifier as CFString, LSRolesMask.all, bundleID as CFString) - if status != noErr { print("⚠️ Failed to set default handler for \(contentType.identifier): \(status)") } - } else if let scheme = fileURL.scheme { - let status = LSSetDefaultHandlerForURLScheme(scheme as CFString, bundleID as CFString) - if status != noErr { print("⚠️ Failed to set default handler for scheme \(scheme): \(status)") } - } - } - - if needsSecurityScope { - _ = try await fileURL.accessSecurityScopedResource { accessibleURL in - try await NSWorkspace.shared.open([accessibleURL], withApplicationAt: appURL, configuration: config) - } - } else { - try await NSWorkspace.shared.open([fileURL], withApplicationAt: appURL, configuration: config) - } - } catch { - print("❌ Failed to open with application: \(error.localizedDescription)") - } - } - } - // Keep binder/delegate alive until panel finishes - _ = binder - _ = chooserDelegate - } - } - - @MainActor - private func showRenameDialog(for item: ShelfItem) { - guard case let .file(bookmarkData) = item.kind else { return } - Task { - let bookmark = Bookmark(data: bookmarkData) - if let fileURL = bookmark.resolvedURL { - // Start security-scoped access and keep it active until rename completes. - let didStart = fileURL.startAccessingSecurityScopedResource() - - let savePanel = NSSavePanel() - savePanel.title = "Rename File" - savePanel.prompt = "Rename" - savePanel.nameFieldStringValue = fileURL.lastPathComponent - savePanel.directoryURL = fileURL.deletingLastPathComponent() - savePanel.begin { response in - if response == .OK, let newURL = savePanel.url { - Task { - do { - NSLog("🔐 Rename: moving from \(fileURL.path) to \(newURL.path) (securityScope=\(didStart))") - - try FileManager.default.moveItem(at: fileURL, to: newURL) - - if let newBookmark = try? Bookmark(url: newURL) { - ShelfStateViewModel.shared.updateBookmark(for: item, bookmark: newBookmark.data) - } - } catch { - print("❌ Failed to rename file: \(error.localizedDescription)") - } - if didStart { fileURL.stopAccessingSecurityScopedResource() } - } - } else { - if didStart { fileURL.stopAccessingSecurityScopedResource() } - } - } - } - } - } - - @MainActor - private func handleRemoveBackground() { - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } - - guard let imageURL = imageURLs.first else { return } - - Task { - do { - let resultURL = try await imageURL.accessSecurityScopedResource { url in - try await ImageProcessingService.shared.removeBackground(from: url) - } - - if let resultURL = resultURL { - // Create bookmark and add to shelf as temporary item - if let bookmark = try? Bookmark(url: resultURL) { - let newItem = ShelfItem( - kind: .file(bookmark: bookmark.data), - isTemporary: true - ) - ShelfStateViewModel.shared.add([newItem]) - } - } - } catch { - print("❌ Failed to remove background: \(error.localizedDescription)") - showErrorAlert(title: "Background Removal Failed", message: error.localizedDescription) - } - } - } - - @MainActor - private func handleCreatePDF() { - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } - - guard !imageURLs.isEmpty else { return } - - Task { - do { - let resultURL = try await imageURLs.accessSecurityScopedResources { urls in - try await ImageProcessingService.shared.createPDF(from: urls) - } - - if let resultURL = resultURL { - // Create bookmark and add to shelf as temporary item - if let bookmark = try? Bookmark(url: resultURL) { - let newItem = ShelfItem( - kind: .file(bookmark: bookmark.data), - isTemporary: true - ) - ShelfStateViewModel.shared.add([newItem]) - } - } - } catch { - print("❌ Failed to create PDF: \(error.localizedDescription)") - showErrorAlert(title: "PDF Creation Failed", message: error.localizedDescription) - } - } - } - - @MainActor - private func showConvertImageDialog() { - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } - - guard let imageURL = imageURLs.first else { return } - - // Create and show conversion options dialog with better layout - let alert = NSAlert() - alert.messageText = "Convert Image" - alert.alertStyle = .informational - alert.addButton(withTitle: "Convert") - alert.addButton(withTitle: "Cancel") - - // Create accessory view with better spacing and organization - let accessoryView = NSView(frame: NSRect(x: 0, y: 0, width: 380, height: 180)) - accessoryView.wantsLayer = true - - // MARK: Format Row - let formatLabel = NSTextField(labelWithString: "Format:") - formatLabel.frame = NSRect(x: 0, y: 145, width: 100, height: 20) - formatLabel.font = .systemFont(ofSize: 12, weight: .medium) - accessoryView.addSubview(formatLabel) - - let formatPopup = NSPopUpButton(frame: NSRect(x: 120, y: 140, width: 250, height: 28)) - formatPopup.addItems(withTitles: ["PNG", "JPEG", "HEIC", "TIFF", "BMP"]) - formatPopup.selectItem(at: 0) - formatPopup.font = .systemFont(ofSize: 12) - accessoryView.addSubview(formatPopup) - - // MARK: Image Size Row - let imageSizeLabel = NSTextField(labelWithString: "Image Size:") - imageSizeLabel.frame = NSRect(x: 0, y: 105, width: 100, height: 20) - imageSizeLabel.font = .systemFont(ofSize: 12, weight: .medium) - accessoryView.addSubview(imageSizeLabel) - - let imageSizePopup = NSPopUpButton(frame: NSRect(x: 120, y: 100, width: 160, height: 28)) - imageSizePopup.addItems(withTitles: ["Actual Size", "Large", "Medium", "Small", "Custom..."]) - imageSizePopup.selectItem(at: 0) - imageSizePopup.font = .systemFont(ofSize: 12) - accessoryView.addSubview(imageSizePopup) - - // Custom size field (initially hidden) - let customSizeField = NSTextField(frame: NSRect(x: 285, y: 103, width: 85, height: 22)) - customSizeField.placeholderString = "e.g., 1920" - customSizeField.font = .systemFont(ofSize: 12) - customSizeField.isHidden = true - accessoryView.addSubview(customSizeField) - - // MARK: Preserve Metadata Checkbox - let metadataCheckbox = NSButton(checkboxWithTitle: "Preserve Metadata", target: nil, action: nil) - metadataCheckbox.frame = NSRect(x: 120, y: 65, width: 200, height: 20) - metadataCheckbox.font = .systemFont(ofSize: 12) - metadataCheckbox.state = .on - accessoryView.addSubview(metadataCheckbox) - - // MARK: Separator line - let separatorLine = NSView(frame: NSRect(x: 0, y: 50, width: 380, height: 1)) - separatorLine.wantsLayer = true - separatorLine.layer?.backgroundColor = NSColor.separatorColor.cgColor - accessoryView.addSubview(separatorLine) - - // MARK: Format-specific options (shown/hidden based on format selection) - let qualityRow = NSView(frame: NSRect(x: 0, y: 15, width: 380, height: 30)) - qualityRow.wantsLayer = true - - let qualityLabel = NSTextField(labelWithString: "Compression:") - qualityLabel.frame = NSRect(x: 0, y: 7, width: 100, height: 20) - qualityLabel.font = .systemFont(ofSize: 12, weight: .medium) - qualityRow.addSubview(qualityLabel) - - let qualitySlider = NSSlider(frame: NSRect(x: 120, y: 12, width: 200, height: 20)) - qualitySlider.minValue = 0.0 - qualitySlider.maxValue = 1.0 - qualitySlider.doubleValue = 0.85 - accessoryView.addSubview(qualitySlider) - - let qualityValueLabel = NSTextField(labelWithString: "85%") - qualityValueLabel.frame = NSRect(x: 325, y: 7, width: 55, height: 20) - qualityValueLabel.font = .systemFont(ofSize: 12) - qualityValueLabel.alignment = .left - accessoryView.addSubview(qualityValueLabel) - - // Update quality label and hide/show compression row based on format - let updateQualityLabel = { - let value = Int(qualitySlider.doubleValue * 100) - qualityValueLabel.stringValue = "\(value)%" - } - - let updateCompressionVisibility = { - let formatIndex = formatPopup.indexOfSelectedItem - let showCompression = formatIndex == 1 || formatIndex == 2 // JPEG or HEIC - qualitySlider.isHidden = !showCompression - qualityValueLabel.isHidden = !showCompression - qualityLabel.isHidden = !showCompression - } - - let updateCustomSizeVisibility = { - let sizeIndex = imageSizePopup.indexOfSelectedItem - customSizeField.isHidden = sizeIndex != 4 // Show only for "Custom..." - } - - // Create a target object to handle slider value changes - class SliderHandler: NSObject { - let updateLabel: () -> Void - let updateVisibility: () -> Void - let updateCustomSize: () -> Void - init(updateLabel: @escaping () -> Void, updateVisibility: @escaping () -> Void, updateCustomSize: @escaping () -> Void) { - self.updateLabel = updateLabel - self.updateVisibility = updateVisibility - self.updateCustomSize = updateCustomSize - } - @objc func sliderChanged(_ sender: NSSlider) { - updateLabel() - } - @objc func formatChanged(_ sender: NSPopUpButton) { - updateVisibility() - } - @objc func sizeChanged(_ sender: NSPopUpButton) { - updateCustomSize() - } - } - - let handler = SliderHandler(updateLabel: updateQualityLabel, updateVisibility: updateCompressionVisibility, updateCustomSize: updateCustomSizeVisibility) - qualitySlider.target = handler - qualitySlider.action = #selector(SliderHandler.sliderChanged(_:)) - qualitySlider.isContinuous = true - - formatPopup.target = handler - formatPopup.action = #selector(SliderHandler.formatChanged(_:)) - - imageSizePopup.target = handler - imageSizePopup.action = #selector(SliderHandler.sizeChanged(_:)) - - updateCompressionVisibility() - updateQualityLabel() - updateCustomSizeVisibility() - - // Keep the handler alive using the `AssociatedObject` helper instead of a magic string key - MenuActionTarget.sliderHandlerAssoc[accessoryView] = handler - - alert.accessoryView = accessoryView - - let response = alert.runModal() - - if response == .alertFirstButtonReturn { - // Get selected options - let formatIndex = formatPopup.indexOfSelectedItem - let format: ImageConversionOptions.ImageFormat - switch formatIndex { - case 0: format = .png - case 1: format = .jpeg - case 2: format = .heic - case 3: format = .tiff - case 4: format = .bmp - default: format = .png - } - - let quality = qualitySlider.doubleValue - - // Get max dimension based on image size selection - let maxDimension: CGFloat? = { - let sizeIndex = imageSizePopup.indexOfSelectedItem - switch sizeIndex { - case 0: return nil // Actual Size - case 1: return 1280 // Large - case 2: return 640 // Medium - case 3: return 320 // Small - case 4: // Custom (user-specified) - let text = customSizeField.stringValue.trimmingCharacters(in: .whitespaces) - guard !text.isEmpty, let value = Double(text), value > 0 else { return nil } - return CGFloat(value) - default: return nil - } - }() - - let removeMetadata = metadataCheckbox.state == .off // Note: we invert this - - let options = ImageConversionOptions( - format: format, - compressionQuality: quality, - maxDimension: maxDimension, - removeMetadata: removeMetadata - ) - - Task { - do { - let resultURL = try await imageURL.accessSecurityScopedResource { url in - try await ImageProcessingService.shared.convertImage(from: url, options: options) - } - - if let resultURL = resultURL { - // Create bookmark and add to shelf as temporary item - if let bookmark = try? Bookmark(url: resultURL) { - let newItem = ShelfItem( - kind: .file(bookmark: bookmark.data), - isTemporary: true - ) - ShelfStateViewModel.shared.add([newItem]) - } - } - } catch { - print("❌ Failed to convert image: \(error.localizedDescription)") - showErrorAlert(title: "Image Conversion Failed", message: error.localizedDescription) - } - } - } - } - - @MainActor - private func showErrorAlert(title: String, message: String) { - let alert = NSAlert() - alert.messageText = title - alert.informativeText = message - alert.alertStyle = .warning - alert.addButton(withTitle: "OK") - alert.runModal() - } - } - - // MARK: - Private helpers - private func appDisplayName(for appURL: URL) -> String { - (try? appURL.resourceValues(forKeys: [.localizedNameKey]).localizedName) ?? appURL.lastPathComponent - } - - private func nsAppIcon(for appURL: URL, size: CGFloat) -> NSImage? { - let baseIcon = NSWorkspace.shared.icon(forFile: appURL.path) - baseIcon.isTemplate = false - - let targetSize = NSSize(width: size, height: size) - let rendered = NSImage(size: targetSize, flipped: false) { rect in - NSGraphicsContext.current?.imageInterpolation = .high - baseIcon.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1.0, respectFlipped: true, hints: [ - .interpolation: NSImageInterpolation.high.rawValue - ]) - return true - } - - rendered.size = targetSize - return rendered - } - - private func defaultAppURL() -> URL? { - if let fileURL = item.fileURL { - return NSWorkspace.shared.urlForApplication(toOpen: fileURL) - } else if case .link(let url) = item.kind { - return NSWorkspace.shared.urlForApplication(toOpen: url) - } - return nil - } -} - -fileprivate extension Sequence { - func asyncCompactMap(_ transform: (Element) async -> T?) async -> [T] { - var result: [T] = [] - for element in self { - if let transformed = await transform(element) { - result.append(transformed) - } - } - return result - } } diff --git a/boringNotch/components/Shelf/Views/ShelfContextMenu.swift b/boringNotch/components/Shelf/Views/ShelfContextMenu.swift new file mode 100644 index 000000000..a706e703a --- /dev/null +++ b/boringNotch/components/Shelf/Views/ShelfContextMenu.swift @@ -0,0 +1,975 @@ +// +// ShelfContextMenu.swift +// boringNotch +// +// SPDX-License-Identifier: GPL-3.0-only +// +// AppKit context-menu construction and action dispatch for shelf items. +// Extracted from ShelfItemViewModel: the VM keeps item state and routes +// clicks here. Behavior preserved verbatim. +// + +import Foundation +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import CoreServices +import ObjectiveC + +// MARK: - Localization helpers +struct Strings { + static let open = NSLocalizedString("Shelf.ContextMenu.Open", comment: "Context menu item: Open") + static let openWith = NSLocalizedString("Shelf.ContextMenu.OpenWith", comment: "Context menu item: Open With") + static let noCompatibleApps = NSLocalizedString("Shelf.ContextMenu.NoCompatibleAppsFound", comment: "Context menu item: No Compatible Apps Found") + static let other = NSLocalizedString("Shelf.ContextMenu.Other", comment: "Context menu item: Other…") + static let showInFinder = NSLocalizedString("Shelf.ContextMenu.ShowInFinder", comment: "Context menu item: Show in Finder") + static let quickLook = NSLocalizedString("Shelf.ContextMenu.QuickLook", comment: "Context menu item: Quick Look") + static let share = NSLocalizedString("Shelf.ContextMenu.Share", comment: "Context menu item: Share…") + static let imageActions = NSLocalizedString("Shelf.ContextMenu.ImageActions", comment: "Context menu item: Image Actions") + static let removeBackground = NSLocalizedString("Shelf.ContextMenu.RemoveBackground", comment: "Context menu item: Remove Background") + static let convertImage = NSLocalizedString("Shelf.ContextMenu.ConvertImage", comment: "Context menu item: Convert Image…") + static let createPDF = NSLocalizedString("Shelf.ContextMenu.CreatePDF", comment: "Context menu item: Create PDF") + static let compress = NSLocalizedString("Shelf.ContextMenu.Compress", comment: "Context menu item: Compress") + static let rename = NSLocalizedString("Shelf.ContextMenu.Rename", comment: "Context menu item: Rename") + static let copy = NSLocalizedString("Shelf.ContextMenu.Copy", comment: "Context menu item: Copy") + static let copyPath = NSLocalizedString("Shelf.ContextMenu.CopyPath", comment: "Context menu item: Copy Path") + static let remove = NSLocalizedString("Shelf.ContextMenu.Remove", comment: "Context menu item: Remove") +} + +enum ContextMenuAction: String { + case quickLook + case open + case share + case rename + case showInFinder + case copyPath + case copy + case remove + case removeBackground + case convertImage + case createPDF + case compress +} + +@MainActor +enum ShelfContextMenuBuilder { +static func present( + item: ShelfItem, event: NSEvent, in view: NSView, + onShare: @escaping (NSView?) -> Void, onQuickLook: @escaping ([URL]) -> Void +) { + let selection = ShelfSelectionModel.shared + if !selection.isSelected(item.id) { selection.selectSingle(item) } + let menu = NSMenu() + + func addMenuItem(title: String, contextAction: ContextMenuAction? = nil) { + let mi = NSMenuItem(title: title, action: nil, keyEquivalent: "") + if let contextAction { + mi.representedObject = contextAction.rawValue + } + menu.addItem(mi) + } + + let selectedItems = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let selectedFileURLs = selectedItems.compactMap { $0.fileURL } + let selectedLinkURLs: [URL] = selectedItems.compactMap { itm in + if case .link(let url) = itm.kind { return url } + return nil + } + let selectedFolderURLs = selectedFileURLs.filter { isDirectory($0) } + // URLs valid for Open/Open With (exclude folders) + let selectedOpenableURLs = selectedItems.compactMap { itm -> URL? in + if let u = itm.fileURL { return isDirectory(u) ? nil : u } + if case .link(let url) = itm.kind { return url } + return nil + } + + if !selectedOpenableURLs.isEmpty { + addMenuItem(title: Strings.open, contextAction: .open) + } + + if !selectedOpenableURLs.isEmpty { + let openWith = NSMenuItem(title: Strings.openWith, action: nil, keyEquivalent: "") + let submenu = NSMenu() + + // Choose a representative URL to compute apps (prefer current item if not a folder) + let baseURLForApps: URL? = { + if let u = item.fileURL, !isDirectory(u) { return u } + if case .link(let u) = item.kind { return u } + return selectedOpenableURLs.first + }() + + let openWithApps: [URL] = { + guard let u = baseURLForApps else { return [] } + if u.isFileURL { + var results = NSWorkspace.shared.urlsForApplications(toOpen: u) + if results.isEmpty, let uti = try? u.resourceValues(forKeys: [.contentTypeKey]).contentType { + results = NSWorkspace.shared.urlsForApplications(toOpen: uti) + } + return Array(Set(results)) + } else { + return Array(Set(NSWorkspace.shared.urlsForApplications(toOpen: u))) + } + }() + let defaultApp = defaultAppURL(for: item) + + if openWithApps.isEmpty { + let noApps = NSMenuItem(title: Strings.noCompatibleApps, action: nil, keyEquivalent: "") + noApps.isEnabled = false + submenu.addItem(noApps) + } else { + if let defaultApp = defaultApp { + let appName = appDisplayName(for: defaultApp) + let def = NSMenuItem(title: appName, action: nil, keyEquivalent: "") + def.representedObject = defaultApp + def.image = nsAppIcon(for: defaultApp, size: 16) + + let title = NSMutableAttributedString(string: appName, attributes: [ + .font: NSFont.menuFont(ofSize: 0), + .foregroundColor: NSColor.labelColor + ]) + let defaultPart = NSAttributedString(string: " (default)", attributes: [ + .font: NSFont.menuFont(ofSize: 0), + .foregroundColor: NSColor.secondaryLabelColor + ]) + title.append(defaultPart) + def.attributedTitle = title + submenu.addItem(def) + + if openWithApps.count > 1 || !openWithApps.contains(defaultApp) { + submenu.addItem(NSMenuItem.separator()) + } + } + for appURL in openWithApps where appURL != defaultApp { + let mi = NSMenuItem(title: appDisplayName(for: appURL), action: nil, keyEquivalent: "") + mi.representedObject = appURL + mi.image = nsAppIcon(for: appURL, size: 16) + submenu.addItem(mi) + } + } + + submenu.addItem(NSMenuItem.separator()) + let other = NSMenuItem(title: Strings.other, action: nil, keyEquivalent: "") + other.representedObject = "__OTHER__" + submenu.addItem(other) + + openWith.submenu = submenu + menu.addItem(openWith) + } + + if !selectedFileURLs.isEmpty { addMenuItem(title: Strings.showInFinder, contextAction: .showInFinder) } + // Allow Quick Look for files and link URLs + if !selectedFileURLs.isEmpty || !selectedLinkURLs.isEmpty { + // Add Quick Look menu item + let quickLookItem = NSMenuItem(title: Strings.quickLook, action: nil, keyEquivalent: "") + quickLookItem.representedObject = ContextMenuAction.quickLook.rawValue + menu.addItem(quickLookItem) + + // Add Slideshow as alternate menu item (shown when Option key is held) + let slideshowItem = NSMenuItem(title: Strings.quickLook, action: nil, keyEquivalent: "") + slideshowItem.representedObject = ContextMenuAction.quickLook.rawValue + slideshowItem.isAlternate = true + slideshowItem.keyEquivalentModifierMask = [.option] + menu.addItem(slideshowItem) + } + + menu.addItem(NSMenuItem.separator()) + addMenuItem(title: Strings.share, contextAction: .share) + + // Add image processing options for image files grouped under "Image Actions" + let imageURLs = selectedFileURLs.filter { ImageProcessingService.shared.isImageFile($0) } + if !imageURLs.isEmpty { + menu.addItem(NSMenuItem.separator()) + + let imageActions = NSMenuItem(title: Strings.imageActions, action: nil, keyEquivalent: "") + let imageSubmenu = NSMenu() + + // Remove Background - only for single images + if imageURLs.count == 1 { + let removeBg = NSMenuItem(title: Strings.removeBackground, action: nil, keyEquivalent: "") + removeBg.representedObject = ContextMenuAction.removeBackground.rawValue + imageSubmenu.addItem(removeBg) + } + + // Convert Image - only for single images + if imageURLs.count == 1 { + let convertItem = NSMenuItem(title: Strings.convertImage, action: nil, keyEquivalent: "") + convertItem.representedObject = ContextMenuAction.convertImage.rawValue + imageSubmenu.addItem(convertItem) + } + + // Create PDF - for one or more images + let createPDF = NSMenuItem(title: Strings.createPDF, action: nil, keyEquivalent: "") + createPDF.representedObject = ContextMenuAction.createPDF.rawValue + imageSubmenu.addItem(createPDF) + + imageActions.submenu = imageSubmenu + menu.addItem(imageActions) + menu.addItem(NSMenuItem.separator()) + } + + // Add compression option for files/folders (single or multiple) + if !selectedFileURLs.isEmpty { + let compressItem = NSMenuItem(title: Strings.compress, action: nil, keyEquivalent: "") + compressItem.representedObject = ContextMenuAction.compress.rawValue + menu.addItem(compressItem) + } + + if selectedItems.count == 1, case .file(_) = item.kind { addMenuItem(title: Strings.rename, contextAction: .rename) } + + // Always show "Copy" for all item types + addMenuItem(title: Strings.copy, contextAction: .copy) + // If there are file URLs, add "Copy Path" as an alternate menu item (Option key) + if !selectedFileURLs.isEmpty { + let copyPathItem = NSMenuItem(title: Strings.copyPath, action: nil, keyEquivalent: "") + copyPathItem.representedObject = ContextMenuAction.copyPath.rawValue + copyPathItem.isAlternate = true + copyPathItem.keyEquivalentModifierMask = [.option] + menu.addItem(copyPathItem) + } + + menu.addItem(NSMenuItem.separator()) + addMenuItem(title: Strings.remove, contextAction: .remove) + + let actionTarget = MenuActionTarget(item: item, view: view, onShare: onShare, onQuickLook: onQuickLook) + + for menuItem in menu.items { + if menuItem.isSeparatorItem { continue } + menuItem.target = actionTarget + menuItem.action = #selector(MenuActionTarget.handle(_:)) + + if let submenu = menuItem.submenu { + for subItem in submenu.items { + if !subItem.isSeparatorItem { + subItem.target = actionTarget + subItem.action = #selector(MenuActionTarget.handle(_:)) + } + } + } + } + + menu.retainActionTarget(actionTarget) + + NSMenu.popUpContextMenu(menu, with: event, for: view) + } +} + +private func isDirectory(_ url: URL) -> Bool { + url.accessSecurityScopedResource { scoped in + (try? scoped.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + } +} + +func appDisplayName(for appURL: URL) -> String { + (try? appURL.resourceValues(forKeys: [.localizedNameKey]).localizedName) ?? appURL.lastPathComponent +} + +func nsAppIcon(for appURL: URL, size: CGFloat) -> NSImage? { + let baseIcon = NSWorkspace.shared.icon(forFile: appURL.path) + baseIcon.isTemplate = false + + let targetSize = NSSize(width: size, height: size) + let rendered = NSImage(size: targetSize, flipped: false) { rect in + NSGraphicsContext.current?.imageInterpolation = .high + baseIcon.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1.0, respectFlipped: true, hints: [ + .interpolation: NSImageInterpolation.high.rawValue + ]) + return true + } + + rendered.size = targetSize + return rendered +} + +@MainActor +func defaultAppURL(for item: ShelfItem) -> URL? { + if let fileURL = item.fileURL { + return NSWorkspace.shared.urlForApplication(toOpen: fileURL) + } else if case .link(let url) = item.kind { + return NSWorkspace.shared.urlForApplication(toOpen: url) + } + return nil +} + + +private final class MenuActionTarget: NSObject { + private static var copiedURLs: [URL] = [] + let item: ShelfItem + weak var view: NSView? + let onShare: (NSView?) -> Void + let onQuickLook: ([URL]) -> Void + + // Keep associated objects (like accessory view handlers) without magic keys + private static var sliderHandlerAssoc = AssociatedObject() + + init(item: ShelfItem, view: NSView, onShare: @escaping (NSView?) -> Void, onQuickLook: @escaping ([URL]) -> Void) { + self.item = item + self.view = view + self.onShare = onShare + self.onQuickLook = onQuickLook + } + + @MainActor @objc func handle(_ sender: NSMenuItem) { + if let marker = sender.representedObject as? String, marker == "__OTHER__" { + openWithPanel() + return + } + + // Dispatch on the action tag, never the (localized) title. + let actionRaw = sender.representedObject as? String + let action = actionRaw.flatMap { ContextMenuAction(rawValue: $0) } + + if let appURL = sender.representedObject as? URL { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + + Task { + var allSelectedURLs: [URL] = [] + + for itm in selected { + if let fileURL = itm.fileURL { + allSelectedURLs.append(fileURL) + } else if case .link(let url) = itm.kind { + allSelectedURLs.append(url) + } + } + + guard !allSelectedURLs.isEmpty else { return } + + let config = NSWorkspace.OpenConfiguration() + + let fileURLs = allSelectedURLs.filter { $0.isFileURL } + do { + if !fileURLs.isEmpty { + _ = try await fileURLs.accessSecurityScopedResources { _ in + try await NSWorkspace.shared.open(allSelectedURLs, withApplicationAt: appURL, configuration: config) + } + } else { + try await NSWorkspace.shared.open(allSelectedURLs, withApplicationAt: appURL, configuration: config) + } + } catch { + Log.shelf.error("❌ Failed to open with application: \(error.localizedDescription)") + } + } + return + } + + switch action { + case .quickLook?: + // Handle all selected items for Quick Look, not just the clicked item + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let urls: [URL] = selected.compactMap { item in + if let fileURL = item.fileURL { + return fileURL + } + if case .link(let url) = item.kind { + return url + } + return nil + } + if !urls.isEmpty { + onQuickLook(urls) + } + + case .open?: + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + for it in selected { ShelfActionService.open(it) } + + case .share?: + onShare(view) + + case .rename?: + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + if selected.count == 1, let single = selected.first { showRenameDialog(for: single) } + + case .showInFinder?: + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + Task { + let urls = await selected.asyncCompactMap { item -> URL? in + if case .file = item.kind { + // Use immediate update for user-initiated menu action + return ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) + } + return nil + } + if !urls.isEmpty { + await urls.accessSecurityScopedResources { accessibleURLs in + NSWorkspace.shared.activateFileViewerSelecting(accessibleURLs) + } + } + } + + case .copyPath?: + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let paths = selected.compactMap { $0.fileURL?.path } + if !paths.isEmpty { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(paths.joined(separator: "\n"), forType: .string) + } + + case .copy?: + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let pb = NSPasteboard.general + + // Stop accessing previously copied URLs + for url in MenuActionTarget.copiedURLs { + url.stopAccessingSecurityScopedResource() + } + MenuActionTarget.copiedURLs.removeAll() + + pb.clearContents() + Task { + let fileURLs = await selected.asyncCompactMap { item -> URL? in + if case .file = item.kind { + return ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) + } + return nil + } + if !fileURLs.isEmpty { + // Start security-scoped access for all URLs and keep them active + MenuActionTarget.copiedURLs = fileURLs.filter { $0.startAccessingSecurityScopedResource() } + NSLog("🔐 Started security-scoped access for \(MenuActionTarget.copiedURLs.count) copied files") + + // Write to pasteboard + pb.writeObjects(fileURLs as [NSURL]) + } else { + let strings = selected.map { $0.displayName } + if !strings.isEmpty { + pb.setString(strings.joined(separator: "\n"), forType: .string) + } + } + } + + case .remove?: + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + for it in selected { ShelfActionService.remove(it) } + + case .removeBackground?: + handleRemoveBackground() + + case .convertImage?: + showConvertImageDialog() + + case .createPDF?: + handleCreatePDF() + + case .compress?: + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let fileURLs = selected.compactMap { $0.fileURL } + guard !fileURLs.isEmpty else { break } + + Task { + // Create ZIP in a temporary location while holding access to selected resources + if let zipTempURL = await fileURLs.accessSecurityScopedResources(accessor: { urls in + await TemporaryFileStorageService.shared.createZip(from: urls) + }) { + if let bookmark = try? Bookmark(url: zipTempURL) { + let newItem = ShelfItem(kind: .file(bookmark: bookmark.data), isTemporary: true) + ShelfStateViewModel.shared.add([newItem]) + } else { + // Fallback: reveal the temporary file in Finder + NSWorkspace.shared.activateFileViewerSelecting([zipTempURL]) + } + } + } + + case .none: + break + } + } + + @MainActor + private func openWithPanel() { + // Support both file items and link items + let targetURL: URL? + let needsSecurityScope: Bool + + if let fileURL = item.fileURL { + targetURL = fileURL + needsSecurityScope = true + } else if case .link(let url) = item.kind { + targetURL = url + needsSecurityScope = false + } else { + targetURL = nil + needsSecurityScope = false + } + guard let fileURL = targetURL else { return } + + let panel = NSOpenPanel() + panel.title = "Choose Application" + panel.message = "Choose an application to open the document \"\(item.displayName)\"." + panel.prompt = "Open" + panel.allowsMultipleSelection = false + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.resolvesAliases = true + if #available(macOS 12.0, *) { + panel.allowedContentTypes = [.application] + } + panel.directoryURL = URL(fileURLWithPath: "/Applications") + + // Compute recommended applications for the selected target + let recommendedApps: Set = { + let apps: [URL] + if let uti = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType { + apps = NSWorkspace.shared.urlsForApplications(toOpen: uti) + } else { + apps = NSWorkspace.shared.urlsForApplications(toOpen: fileURL) + } + return Set(apps.map { $0.standardizedFileURL }) + }() + + // Delegate to filter entries when in "Recommended Applications" mode + final class AppChooserDelegate: NSObject, NSOpenSavePanelDelegate { + enum Mode { case recommended, all } + var mode: Mode = .recommended + let recommended: Set + init(recommended: Set) { self.recommended = recommended } + + func panel(_ sender: Any, shouldEnable url: URL) -> Bool { + let ext = url.pathExtension.lowercased() + if ext == "app" { + switch mode { + case .all: + return true + case .recommended: + // Standardize URLs for reliable comparison + let std = url.standardizedFileURL + return recommended.contains(std) + } + } + + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue { + return true + } + + return false + } + } + + let chooserDelegate = AppChooserDelegate(recommended: recommendedApps) + panel.delegate = chooserDelegate + + let enableLabel = NSTextField(labelWithString: "Enable:") + enableLabel.font = .systemFont(ofSize: NSFont.systemFontSize) + enableLabel.alignment = .natural + enableLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + let popup = NSPopUpButton(frame: .zero, pullsDown: false) + popup.addItems(withTitles: ["Recommended Applications", "All Applications"]) + popup.font = .systemFont(ofSize: NSFont.systemFontSize) + popup.selectItem(at: 0) + + popup.setContentHuggingPriority(.defaultLow, for: .horizontal) + popup.widthAnchor.constraint(greaterThanOrEqualToConstant: 200).isActive = true + + let alwaysCheckbox = NSButton(checkboxWithTitle: "Always Open With", target: nil, action: nil) + alwaysCheckbox.font = .systemFont(ofSize: NSFont.systemFontSize) + alwaysCheckbox.setContentHuggingPriority(.defaultLow, for: .horizontal) + + let row = NSStackView(views: [enableLabel, popup]) + row.orientation = .horizontal + row.spacing = 8 + row.alignment = .centerY + row.distribution = .fill + + let column = NSStackView(views: [row, alwaysCheckbox]) + column.orientation = .vertical + column.spacing = 12 + column.alignment = .centerX + column.distribution = .fill + column.edgeInsets = NSEdgeInsets(top: 16, left: 20, bottom: 16, right: 20) + + panel.accessoryView = column + panel.isAccessoryViewDisclosed = true + + // Wire up popup to switch filter mode + class PopupBinder: NSObject { + weak var popup: NSPopUpButton? + weak var chooserDelegate: AppChooserDelegate? + weak var panel: NSOpenPanel? + init(popup: NSPopUpButton, chooserDelegate: AppChooserDelegate, panel: NSOpenPanel) { + self.popup = popup + self.chooserDelegate = chooserDelegate + self.panel = panel + } + @MainActor @objc func changed(_ sender: Any?) { + if popup?.indexOfSelectedItem == 1 { + chooserDelegate?.mode = .all + } else { + chooserDelegate?.mode = .recommended + } + if let panel = panel { + panel.validateVisibleColumns() + let currentDir = panel.directoryURL + panel.directoryURL = currentDir + } + } + } + let binder = PopupBinder(popup: popup, chooserDelegate: chooserDelegate, panel: panel) + popup.target = binder + popup.action = #selector(PopupBinder.changed(_:)) + + panel.begin { response in + if response == .OK, let appURL = panel.url { + Task { + do { + let config = NSWorkspace.OpenConfiguration() + if alwaysCheckbox.state == .on, let bundleID = Bundle(url: appURL)?.bundleIdentifier { + if let contentType = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType { + let status = LSSetDefaultRoleHandlerForContentType(contentType.identifier as CFString, LSRolesMask.all, bundleID as CFString) + if status != noErr { Log.shelf.error("Failed to set default handler for \(contentType.identifier): \(status)") } + } else if let scheme = fileURL.scheme { + let status = LSSetDefaultHandlerForURLScheme(scheme as CFString, bundleID as CFString) + if status != noErr { Log.shelf.error("Failed to set default handler for scheme \(scheme): \(status)") } + } + } + + if needsSecurityScope { + _ = try await fileURL.accessSecurityScopedResource { accessibleURL in + try await NSWorkspace.shared.open([accessibleURL], withApplicationAt: appURL, configuration: config) + } + } else { + try await NSWorkspace.shared.open([fileURL], withApplicationAt: appURL, configuration: config) + } + } catch { + Log.shelf.error("❌ Failed to open with application: \(error.localizedDescription)") + } + } + } + // Keep binder/delegate alive until panel finishes + _ = binder + _ = chooserDelegate + } + } + + @MainActor + private func showRenameDialog(for item: ShelfItem) { + guard case let .file(bookmarkData) = item.kind else { return } + Task { + let bookmark = Bookmark(data: bookmarkData) + if let fileURL = bookmark.resolvedURL { + // Start security-scoped access and keep it active until rename completes. + let didStart = fileURL.startAccessingSecurityScopedResource() + + let savePanel = NSSavePanel() + savePanel.title = "Rename File" + savePanel.prompt = "Rename" + savePanel.nameFieldStringValue = fileURL.lastPathComponent + savePanel.directoryURL = fileURL.deletingLastPathComponent() + savePanel.begin { response in + if response == .OK, let newURL = savePanel.url { + Task { + do { + NSLog("🔐 Rename: moving from \(fileURL.path) to \(newURL.path) (securityScope=\(didStart))") + + try FileManager.default.moveItem(at: fileURL, to: newURL) + + if let newBookmark = try? Bookmark(url: newURL) { + ShelfStateViewModel.shared.updateBookmark(for: item, bookmark: newBookmark.data) + } + } catch { + Log.shelf.error("❌ Failed to rename file: \(error.localizedDescription)") + } + if didStart { fileURL.stopAccessingSecurityScopedResource() } + } + } else { + if didStart { fileURL.stopAccessingSecurityScopedResource() } + } + } + } + } + } + + @MainActor + private func handleRemoveBackground() { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } + + guard let imageURL = imageURLs.first else { return } + + Task { + do { + let resultURL = try await imageURL.accessSecurityScopedResource { url in + try await ImageProcessingService.shared.removeBackground(from: url) + } + + if let resultURL = resultURL { + // Create bookmark and add to shelf as temporary item + if let bookmark = try? Bookmark(url: resultURL) { + let newItem = ShelfItem( + kind: .file(bookmark: bookmark.data), + isTemporary: true + ) + ShelfStateViewModel.shared.add([newItem]) + } + } + } catch { + Log.shelf.error("❌ Failed to remove background: \(error.localizedDescription)") + showErrorAlert(title: String(localized: "Background Removal Failed"), message: error.localizedDescription) + } + } + } + + @MainActor + private func handleCreatePDF() { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } + + guard !imageURLs.isEmpty else { return } + + Task { + do { + let resultURL = try await imageURLs.accessSecurityScopedResources { urls in + try await ImageProcessingService.shared.createPDF(from: urls) + } + + if let resultURL = resultURL { + // Create bookmark and add to shelf as temporary item + if let bookmark = try? Bookmark(url: resultURL) { + let newItem = ShelfItem( + kind: .file(bookmark: bookmark.data), + isTemporary: true + ) + ShelfStateViewModel.shared.add([newItem]) + } + } + } catch { + Log.shelf.error("❌ Failed to create PDF: \(error.localizedDescription)") + showErrorAlert(title: String(localized: "PDF Creation Failed"), message: error.localizedDescription) + } + } + } + + @MainActor + private func showConvertImageDialog() { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } + + guard let imageURL = imageURLs.first else { return } + + // Create and show conversion options dialog with better layout + let alert = NSAlert() + alert.messageText = "Convert Image" + alert.alertStyle = .informational + alert.addButton(withTitle: "Convert") + alert.addButton(withTitle: "Cancel") + + // Create accessory view with better spacing and organization + let accessoryView = NSView(frame: NSRect(x: 0, y: 0, width: 380, height: 180)) + accessoryView.wantsLayer = true + + // MARK: Format Row + let formatLabel = NSTextField(labelWithString: "Format:") + formatLabel.frame = NSRect(x: 0, y: 145, width: 100, height: 20) + formatLabel.font = .systemFont(ofSize: 12, weight: .medium) + accessoryView.addSubview(formatLabel) + + let formatPopup = NSPopUpButton(frame: NSRect(x: 120, y: 140, width: 250, height: 28)) + formatPopup.addItems(withTitles: ["PNG", "JPEG", "HEIC", "TIFF", "BMP"]) + formatPopup.selectItem(at: 0) + formatPopup.font = .systemFont(ofSize: 12) + accessoryView.addSubview(formatPopup) + + // MARK: Image Size Row + let imageSizeLabel = NSTextField(labelWithString: "Image Size:") + imageSizeLabel.frame = NSRect(x: 0, y: 105, width: 100, height: 20) + imageSizeLabel.font = .systemFont(ofSize: 12, weight: .medium) + accessoryView.addSubview(imageSizeLabel) + + let imageSizePopup = NSPopUpButton(frame: NSRect(x: 120, y: 100, width: 160, height: 28)) + imageSizePopup.addItems(withTitles: ["Actual Size", "Large", "Medium", "Small", "Custom..."]) + imageSizePopup.selectItem(at: 0) + imageSizePopup.font = .systemFont(ofSize: 12) + accessoryView.addSubview(imageSizePopup) + + // Custom size field (initially hidden) + let customSizeField = NSTextField(frame: NSRect(x: 285, y: 103, width: 85, height: 22)) + customSizeField.placeholderString = "e.g., 1920" + customSizeField.font = .systemFont(ofSize: 12) + customSizeField.isHidden = true + accessoryView.addSubview(customSizeField) + + // MARK: Preserve Metadata Checkbox + let metadataCheckbox = NSButton(checkboxWithTitle: "Preserve Metadata", target: nil, action: nil) + metadataCheckbox.frame = NSRect(x: 120, y: 65, width: 200, height: 20) + metadataCheckbox.font = .systemFont(ofSize: 12) + metadataCheckbox.state = .on + accessoryView.addSubview(metadataCheckbox) + + // MARK: Separator line + let separatorLine = NSView(frame: NSRect(x: 0, y: 50, width: 380, height: 1)) + separatorLine.wantsLayer = true + separatorLine.layer?.backgroundColor = NSColor.separatorColor.cgColor + accessoryView.addSubview(separatorLine) + + // MARK: Format-specific options (shown/hidden based on format selection) + let qualityRow = NSView(frame: NSRect(x: 0, y: 15, width: 380, height: 30)) + qualityRow.wantsLayer = true + + let qualityLabel = NSTextField(labelWithString: "Compression:") + qualityLabel.frame = NSRect(x: 0, y: 7, width: 100, height: 20) + qualityLabel.font = .systemFont(ofSize: 12, weight: .medium) + qualityRow.addSubview(qualityLabel) + + let qualitySlider = NSSlider(frame: NSRect(x: 120, y: 12, width: 200, height: 20)) + qualitySlider.minValue = 0.0 + qualitySlider.maxValue = 1.0 + qualitySlider.doubleValue = 0.85 + accessoryView.addSubview(qualitySlider) + + let qualityValueLabel = NSTextField(labelWithString: "85%") + qualityValueLabel.frame = NSRect(x: 325, y: 7, width: 55, height: 20) + qualityValueLabel.font = .systemFont(ofSize: 12) + qualityValueLabel.alignment = .left + accessoryView.addSubview(qualityValueLabel) + + // Update quality label and hide/show compression row based on format + let updateQualityLabel = { + let value = Int(qualitySlider.doubleValue * 100) + qualityValueLabel.stringValue = "\(value)%" + } + + let updateCompressionVisibility = { + let formatIndex = formatPopup.indexOfSelectedItem + let showCompression = formatIndex == 1 || formatIndex == 2 // JPEG or HEIC + qualitySlider.isHidden = !showCompression + qualityValueLabel.isHidden = !showCompression + qualityLabel.isHidden = !showCompression + } + + let updateCustomSizeVisibility = { + let sizeIndex = imageSizePopup.indexOfSelectedItem + customSizeField.isHidden = sizeIndex != 4 // Show only for "Custom..." + } + + // Create a target object to handle slider value changes + class SliderHandler: NSObject { + let updateLabel: () -> Void + let updateVisibility: () -> Void + let updateCustomSize: () -> Void + init(updateLabel: @escaping () -> Void, updateVisibility: @escaping () -> Void, updateCustomSize: @escaping () -> Void) { + self.updateLabel = updateLabel + self.updateVisibility = updateVisibility + self.updateCustomSize = updateCustomSize + } + @objc func sliderChanged(_ sender: NSSlider) { + updateLabel() + } + @objc func formatChanged(_ sender: NSPopUpButton) { + updateVisibility() + } + @objc func sizeChanged(_ sender: NSPopUpButton) { + updateCustomSize() + } + } + + let handler = SliderHandler(updateLabel: updateQualityLabel, updateVisibility: updateCompressionVisibility, updateCustomSize: updateCustomSizeVisibility) + qualitySlider.target = handler + qualitySlider.action = #selector(SliderHandler.sliderChanged(_:)) + qualitySlider.isContinuous = true + + formatPopup.target = handler + formatPopup.action = #selector(SliderHandler.formatChanged(_:)) + + imageSizePopup.target = handler + imageSizePopup.action = #selector(SliderHandler.sizeChanged(_:)) + + updateCompressionVisibility() + updateQualityLabel() + updateCustomSizeVisibility() + + // Keep the handler alive using the `AssociatedObject` helper instead of a magic string key + MenuActionTarget.sliderHandlerAssoc[accessoryView] = handler + + alert.accessoryView = accessoryView + + let response = alert.runModal() + + if response == .alertFirstButtonReturn { + // Get selected options + let formatIndex = formatPopup.indexOfSelectedItem + let format: ImageConversionOptions.ImageFormat + switch formatIndex { + case 0: format = .png + case 1: format = .jpeg + case 2: format = .heic + case 3: format = .tiff + case 4: format = .bmp + default: format = .png + } + + let quality = qualitySlider.doubleValue + + // Get max dimension based on image size selection + let maxDimension: CGFloat? = { + let sizeIndex = imageSizePopup.indexOfSelectedItem + switch sizeIndex { + case 0: return nil // Actual Size + case 1: return 1280 // Large + case 2: return 640 // Medium + case 3: return 320 // Small + case 4: // Custom (user-specified) + let text = customSizeField.stringValue.trimmingCharacters(in: .whitespaces) + guard !text.isEmpty, let value = Double(text), value > 0 else { return nil } + return CGFloat(value) + default: return nil + } + }() + + let removeMetadata = metadataCheckbox.state == .off // Note: we invert this + + let options = ImageConversionOptions( + format: format, + compressionQuality: quality, + maxDimension: maxDimension, + removeMetadata: removeMetadata + ) + + Task { + do { + let resultURL = try await imageURL.accessSecurityScopedResource { url in + try await ImageProcessingService.shared.convertImage(from: url, options: options) + } + + if let resultURL = resultURL { + // Create bookmark and add to shelf as temporary item + if let bookmark = try? Bookmark(url: resultURL) { + let newItem = ShelfItem( + kind: .file(bookmark: bookmark.data), + isTemporary: true + ) + ShelfStateViewModel.shared.add([newItem]) + } + } + } catch { + Log.shelf.error("❌ Failed to convert image: \(error.localizedDescription)") + showErrorAlert(title: String(localized: "Image Conversion Failed"), message: error.localizedDescription) + } + } + } + } + + @MainActor + private func showErrorAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + alert.runModal() + } +} + + +fileprivate extension Sequence { + func asyncCompactMap(_ transform: (Element) async -> T?) async -> [T] { + var result: [T] = [] + for element in self { + if let transformed = await transform(element) { + result.append(transformed) + } + } + return result + } +} diff --git a/boringNotch/components/Shelf/Views/ShelfView.swift b/boringNotch/components/Shelf/Views/ShelfView.swift index b4d3ea0ad..bacc2b57b 100644 --- a/boringNotch/components/Shelf/Views/ShelfView.swift +++ b/boringNotch/components/Shelf/Views/ShelfView.swift @@ -12,12 +12,12 @@ import Defaults struct ShelfView: View { let dropInteraction: DropInteractionState let animation: Animation? - @StateObject var tvm = ShelfStateViewModel.shared + @StateObject var shelfState = ShelfStateViewModel.shared private let spacing: CGFloat = 8 private var displayedItems: [ShelfItem] { - Defaults[.reverseShelfOrdering] ? Array(tvm.items.reversed()) : tvm.items + Defaults[.reverseShelfOrdering] ? Array(shelfState.items.reversed()) : shelfState.items } var body: some View { @@ -38,7 +38,7 @@ struct ShelfView: View { private func handleDrop(providers: [NSItemProvider]) -> Bool { guard !ShelfSelectionModel.shared.isDragging else { return false } dropInteraction.dropEvent = true - tvm.load(providers) + shelfState.load(providers) return true } @@ -66,7 +66,7 @@ struct ShelfView: View { @Bindable var interaction = dropInteraction return Group { - if tvm.isEmpty { + if shelfState.isEmpty { VStack(spacing: 10) { Image(systemName: "tray.and.arrow.down") .symbolVariant(.fill) @@ -99,7 +99,7 @@ struct ShelfView: View { } } .onAppear { - tvm.cleanupInvalidItems() + shelfState.cleanupInvalidItems() } } } diff --git a/boringNotch/components/Tabs/TabButton.swift b/boringNotch/components/Tabs/TabButton.swift index e6eb6b45a..f6de1fa65 100644 --- a/boringNotch/components/Tabs/TabButton.swift +++ b/boringNotch/components/Tabs/TabButton.swift @@ -25,6 +25,6 @@ struct TabButton: View { #Preview { TabButton(label: "Home", icon: "tray.fill", selected: true) { - print("Tapped") + Log.general.debug("Tapped") } } diff --git a/boringNotch/components/TestView.swift b/boringNotch/components/TestView.swift deleted file mode 100644 index fbee74984..000000000 --- a/boringNotch/components/TestView.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// TestView.swift -// boringNotch -// -// Created by Richard Kunkli on 14/08/2024. -// - -import SwiftUI - -struct FluidSlider: View { - private let color: Color = Color.white - @State private var offset: CGFloat = 0 - var rectSize = CGSize(width: 300, height: 50) - var rectSize2 = CGSize(width: 200, height: 18) - var circleSize: CGFloat = 35 - @GestureState var isDragging: Bool = false - @State var previousOffset: CGFloat = 0 - @State private var isBeating: Bool = false - - var body: some View { - HStack { - slider - .frame(width: rectSize2.width, height: circleSize) - } - .padding() - .background(.black) - } - - private var slider: some View { - ZStack { - Canvas { context, size in - context.addFilter(.alphaThreshold(min: 0.5, max: 1, color: color)) - context.addFilter(.blur(radius: 10)) - - context.drawLayer { ctx in - if let rectangle = ctx.resolveSymbol(id: "Capsule") { - ctx.draw(rectangle, at: CGPoint(x: size.width/2, y: size.height/2)) - } - if let circle = ctx.resolveSymbol(id: "Circle") { - ctx.draw(circle, at: CGPoint(x: size.width/2 - rectSize2.width/2 + circleSize/2, y: size.height/2)) - } - } - } symbols: { - Capsule() - .frame(width: rectSize2.width, height: rectSize2.height, alignment: .center) - .tag("Capsule") - - Circle() - .frame(width: circleSize, height: circleSize, alignment: .center) - .offset(x: offset) - .animation(.spring(), value: isDragging) - .tag("Circle") - } - .simultaneousGesture( - DragGesture(minimumDistance: 0) - .updating($isDragging, body: { _, state, _ in - state = true - }) - .onChanged({ value in - self.offset = min(max(self.previousOffset + value.translation.width, 0), rectSize2.width - circleSize) - }) - .onEnded({ value in - self.previousOffset = self.offset - }) - ) - Circle() - .fill(Color.black) - .frame(width: circleSize * 0.6) - .overlay { - Image(systemName: "speaker.wave.2.fill") - .imageScale(.small) - } - .offset(x: (-rectSize2.width/2) + (circleSize/2)) - .offset(x: offset) - .animation(.spring(), value: isDragging) - .allowsHitTesting(false) - } - } - - - private var animation: Animation { - .spring(response: 0.5, dampingFraction: 0.6, blendDuration: 0.5) - } - - private var percentage: Int { - Int((offset) / (rectSize.width - circleSize) * 100) - } -} - -#Preview { - FluidSlider() -} diff --git a/boringNotch/components/VisualEffectView.swift b/boringNotch/components/VisualEffectView.swift new file mode 100644 index 000000000..66aefea3f --- /dev/null +++ b/boringNotch/components/VisualEffectView.swift @@ -0,0 +1,29 @@ +// +// VisualEffectView.swift +// boringNotch +// +// SPDX-License-Identifier: GPL-3.0-only +// +// Created by Richard Kunkli on 31/08/2024. +// + +import SwiftUI + +struct VisualEffectView: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context _: Context) -> NSVisualEffectView { + let visualEffectView = NSVisualEffectView() + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + visualEffectView.state = NSVisualEffectView.State.active + visualEffectView.isEmphasized = true + return visualEffectView + } + + func updateNSView(_ visualEffectView: NSVisualEffectView, context _: Context) { + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + } +} diff --git a/boringNotch/components/Webcam/WebcamView.swift b/boringNotch/components/Webcam/WebcamView.swift index e506b55fe..3edc261be 100644 --- a/boringNotch/components/Webcam/WebcamView.swift +++ b/boringNotch/components/Webcam/WebcamView.swift @@ -9,7 +9,7 @@ import AVFoundation import Defaults import SwiftUI -struct CameraPreviewView: View { +struct WebcamPreview: View { @EnvironmentObject var vm: BoringViewModel @ObservedObject var webcamManager: WebcamManager @@ -21,7 +21,7 @@ struct CameraPreviewView: View { GeometryReader { geometry in ZStack { if let previewLayer = webcamManager.previewLayer { - CameraPreviewLayerView(previewLayer: previewLayer) + WebcamPreviewLayer(previewLayer: previewLayer) .scaleEffect(x: isMirrored ? -1 : 1, y: 1) .clipShape(RoundedRectangle(cornerRadius: Defaults[.mirrorShape] == .rectangle ? MusicPlayerImageSizes.cornerRadiusInset.opened : 100)) .frame(width: geometry.size.width, height: geometry.size.width) @@ -94,7 +94,7 @@ struct CameraPreviewView: View { } } -struct CameraPreviewLayerView: NSViewRepresentable { +struct WebcamPreviewLayer: NSViewRepresentable { let previewLayer: AVCaptureVideoPreviewLayer func makeNSView(context: Context) -> NSView { @@ -115,5 +115,5 @@ struct CameraPreviewLayerView: NSViewRepresentable { } #Preview { - CameraPreviewView(webcamManager: .shared) + WebcamPreview(webcamManager: .shared) } diff --git a/boringNotch/enums/generic.swift b/boringNotch/enums/generic.swift index 810969dde..7ab23a07b 100644 --- a/boringNotch/enums/generic.swift +++ b/boringNotch/enums/generic.swift @@ -8,17 +8,17 @@ import Foundation import Defaults -public enum Style { +enum Style { case notch case floating } -public enum NotchState { +enum NotchState { case closed case open } -public enum NotchViews { +enum NotchViews { case home case shelf } diff --git a/boringNotch/extensions/Color+AccentColor.swift b/boringNotch/extensions/Color+AccentColor.swift index 8b4e26f16..a202e316d 100644 --- a/boringNotch/extensions/Color+AccentColor.swift +++ b/boringNotch/extensions/Color+AccentColor.swift @@ -8,21 +8,26 @@ import SwiftUI import Defaults +/// Decodes the user's custom accent color, if custom accents are enabled. +private var customAccentNSColor: NSColor? { + guard Defaults[.useCustomAccentColor], + let colorData = Defaults[.customAccentColorData], + let nsColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData) + else { return nil } + return nsColor +} + extension Color { static var effectiveAccent: Color { - if Defaults[.useCustomAccentColor], - let colorData = Defaults[.customAccentColorData], - let nsColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData) { + if let nsColor = customAccentNSColor { return Color(nsColor: nsColor) } return .accentColor } - + /// Returns a darker version of the accent color suitable for backgrounds static var effectiveAccentBackground: Color { - if Defaults[.useCustomAccentColor], - let colorData = Defaults[.customAccentColorData], - let nsColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData) { + if let nsColor = customAccentNSColor { return Color(nsColor: nsColor.withSystemEffect(.disabled)) } return Color.effectiveAccent.opacity(0.25) @@ -31,19 +36,12 @@ extension Color { extension NSColor { static var effectiveAccent: NSColor { - if Defaults[.useCustomAccentColor], - let colorData = Defaults[.customAccentColorData], - let nsColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData) { - return nsColor - } - return NSColor.controlAccentColor + customAccentNSColor ?? NSColor.controlAccentColor } - + /// Returns a darker version of the accent color as NSColor suitable for backgrounds static var effectiveAccentBackground: NSColor { - if Defaults[.useCustomAccentColor], - let colorData = Defaults[.customAccentColorData], - let nsColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData) { + if let nsColor = customAccentNSColor { return nsColor.withSystemEffect(.disabled) } return NSColor.controlAccentColor.withAlphaComponent(0.25) diff --git a/boringNotch/extensions/NSItemProvider+LoadHelpers.swift b/boringNotch/extensions/NSItemProvider+LoadHelpers.swift index 86b7cbe18..27501bb13 100644 --- a/boringNotch/extensions/NSItemProvider+LoadHelpers.swift +++ b/boringNotch/extensions/NSItemProvider+LoadHelpers.swift @@ -32,7 +32,7 @@ extension NSItemProvider { return await withCheckedContinuation { (cont: CheckedContinuation) in loadItem(forTypeIdentifier: UTType.data.identifier, options: nil) { item, error in if let error = error { - print("Error loading data for type \(UTType.data.identifier): \(error.localizedDescription)") + Log.general.error("Error loading data for type \(UTType.data.identifier): \(error.localizedDescription)") cont.resume(returning: nil) return } @@ -49,19 +49,19 @@ extension NSItemProvider { do { // Delete the file first try fileManager.removeItem(at: url) - print("Deleted file: \(url.path)") + Log.general.debug("Deleted file: \(url.path)") // Check folder contents let contents = try fileManager.contentsOfDirectory(atPath: folderURL.path) if contents.isEmpty { try fileManager.removeItem(at: folderURL) - print("Folder was empty, deleted folder: \(folderURL.path)") + Log.general.debug("Folder was empty, deleted folder: \(folderURL.path)") } else { - print("Folder not deleted — it still contains \(contents.count) item(s).") + Log.general.debug("Folder not deleted — it still contains \(contents.count) item(s).") } } catch { - print("Error: \(error.localizedDescription)") + Log.general.error("Error: \(error.localizedDescription)") } cont.resume(returning: data) @@ -104,7 +104,7 @@ extension NSItemProvider { await withCheckedContinuation { (cont: CheckedContinuation) in self.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in if let error = error { - print("❌ Error loading item for type \(typeIdentifier): \(error.localizedDescription)") + Log.general.error("❌ Error loading item for type \(typeIdentifier): \(error.localizedDescription)") cont.resume(returning: nil) return } diff --git a/boringNotch/helpers/AppIcons.swift b/boringNotch/helpers/AppIcons.swift index d9138e1ed..806e552fd 100644 --- a/boringNotch/helpers/AppIcons.swift +++ b/boringNotch/helpers/AppIcons.swift @@ -59,7 +59,7 @@ func normalizeBundleIdentifier(_ bundleID: String) -> String { return bundleID } -func AppIcon(for bundleID: String) -> Image { +func appIcon(for bundleID: String) -> Image { let workspace = NSWorkspace.shared let normalizedID = normalizeBundleIdentifier(bundleID) @@ -72,7 +72,7 @@ func AppIcon(for bundleID: String) -> Image { } -func AppIconAsNSImage(for bundleID: String) -> NSImage? { +func appIconAsNSImage(for bundleID: String) -> NSImage? { let workspace = NSWorkspace.shared let normalizedID = normalizeBundleIdentifier(bundleID) @@ -84,3 +84,121 @@ func AppIconAsNSImage(for bundleID: String) -> NSImage? { return nil } +/// Resolves an app's bundle ID from its display name when the XPC helper's +/// own one-shot match came back empty (the notification then arrives with a +/// nil bundleID and every icon surface falls back to the grey bell). +/// Fallback chain, cheapest first: +/// 1. running applications, matched on normalized `localizedName` +/// 2. direct probe of the standard /Applications locations +/// 3. one bounded listing of those directories (filename first, then +/// CFBundleDisplayName / CFBundleName) +/// Hits and misses are memoized, so a chatty app never re-scans the disk. +final class BundleIDResolver { + static let shared = BundleIDResolver() + + /// Thread-safety: the only production caller (`SystemNotificationManager.add`) + /// is @MainActor, but the lock costs nothing and keeps the cache sound if a + /// caller ever resolves off-queue. It is only held around dictionary access, + /// never during disk I/O — a duplicate concurrent lookup can do the scan + /// twice, in which case the last (identical) write wins. + private let lock = NSLock() + private var cache: [String: String?] = [:] + + /// Probe order: /Applications, then the user's own ~/Applications, then + /// /System/Applications. The app is sandboxed, so `homeDirectoryForCurrentUser` + /// would point at the container — build the real home from the login name + /// instead. Injectable so tests can point the resolver at a fixture dir. + static let defaultSearchDirectories: [URL] = [ + URL(fileURLWithPath: "/Applications"), + URL(fileURLWithPath: "/Users/\(NSUserName())/Applications"), + URL(fileURLWithPath: "/System/Applications"), + ] + + func bundleID(forAppNamed name: String, searchDirectories: [URL] = BundleIDResolver.defaultSearchDirectories) -> String? { + let target = Self.normalizedAppName(name) + guard !target.isEmpty else { return nil } + + lock.lock() + let cached = cache[target] + lock.unlock() + if let cached { return cached } + + let resolved = resolve(target: target, name: name, searchDirectories: searchDirectories) + + lock.lock() + cache[target] = resolved + lock.unlock() + return resolved + } + + private func resolve(target: String, name: String, searchDirectories: [URL]) -> String? { + // 1. Running apps — the same match the helper attempts, redone here in + // case the app (re)launched between posting and capture. + if let running = NSWorkspace.shared.runningApplications.first(where: { + guard let localizedName = $0.localizedName else { return false } + return Self.normalizedAppName(localizedName) == target + })?.bundleIdentifier { + return running + } + + // 2. Direct probes: the name as reported, plus a space-stripped + // variant ("Google Chrome" -> "GoogleChrome.app" style installs). + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + let withoutSpaces = trimmed.replacingOccurrences(of: " ", with: "") + let candidates = withoutSpaces == trimmed ? [trimmed] : [trimmed, withoutSpaces] + for directory in searchDirectories { + for candidate in candidates { + let url = directory.appendingPathComponent(candidate).appendingPathExtension("app") + if let bundleID = bundleIdentifier(at: url) { return bundleID } + } + } + + // 3. One bounded listing per directory — no recursion into subfolders. + for directory in searchDirectories { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsSubdirectoryDescendants, .skipsHiddenFiles] + ) else { continue } + for entry in entries where entry.pathExtension == "app" { + guard let bundle = Bundle(url: entry), let bundleID = bundle.bundleIdentifier else { continue } + if Self.normalizedAppName(entry.deletingPathExtension().lastPathComponent) == target { + return bundleID + } + let infoNames = [ + bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String, + bundle.object(forInfoDictionaryKey: "CFBundleName") as? String, + ] + if infoNames.contains(where: { $0.map { Self.normalizedAppName($0) == target } ?? false }) { + return bundleID + } + } + } + return nil + } + + /// Mirrors the helper's `normalizedAppName`: both sides can carry invisible + /// bidi marks — WhatsApp's `localizedName` is literally "\u{200E}WhatsApp" — + /// so strip them along with case and surrounding whitespace before comparing. + static func normalizedAppName(_ name: String) -> String { + name.filter { !$0.unicodeScalars.allSatisfy(bidiControlCharacters.contains) } + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } + + private func bundleIdentifier(at url: URL) -> String? { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + return Bundle(url: url)?.bundleIdentifier + } + + /// Directional formatting characters Notification Center and app names both + /// sprinkle in: LRM/RLM, the isolate family, and the embedding/override set. + private static let bidiControlCharacters: CharacterSet = { + var set = CharacterSet() + set.insert(charactersIn: "\u{200E}\u{200F}") // LRM, RLM + set.insert(charactersIn: "\u{2066}"..."\u{2069}") // isolates + PDI + set.insert(charactersIn: "\u{202A}"..."\u{202E}") // embeddings/overrides + return set + }() +} + diff --git a/boringNotch/helpers/AppleScriptHelper.swift b/boringNotch/helpers/AppleScriptHelper.swift index d57e32666..4fe3bac19 100644 --- a/boringNotch/helpers/AppleScriptHelper.swift +++ b/boringNotch/helpers/AppleScriptHelper.swift @@ -7,7 +7,7 @@ import Foundation -class AppleScriptHelper { +final class AppleScriptHelper { @discardableResult class func execute(_ scriptText: String) async throws -> NSAppleEventDescriptor? { try await withCheckedThrowingContinuation { continuation in diff --git a/boringNotch/helpers/AudioOutputRouteResolver.swift b/boringNotch/helpers/AudioOutputRouteResolver.swift index 9ac1a6ef3..c98f264d9 100644 --- a/boringNotch/helpers/AudioOutputRouteResolver.swift +++ b/boringNotch/helpers/AudioOutputRouteResolver.swift @@ -26,6 +26,22 @@ final class AudioOutputRouteResolver { private let stateQueue = DispatchQueue(label: "AudioOutputRouteResolver.state") private var cachedRouteKind: AudioOutputRouteKind = .unknown + /// Symbol for the current output route, independent of volume level — + /// for the media-output button, which shows *where* audio is going + /// rather than how loud it is. Built-in output reads as the machine + /// itself (a laptop), matching how macOS's own output picker shows it. + func outputRouteSymbol() -> String { + let routeKind = stateQueue.sync { cachedRouteKind } + switch routeKind { + case .airPods: return "airpods" + case .airPodsPro: return "airpodspro" + case .airPodsMax: return "airpodsmax" + case .wiredHeadphones, .bluetoothHeadphones: return "headphones" + case .externalSpeaker: return "hifispeaker" + case .builtInSpeaker, .unknown: return "laptopcomputer" + } + } + func volumeSymbol(for value: CGFloat) -> String { let clampedValue = max(0, min(1, value)) let routeKind = stateQueue.sync { cachedRouteKind } diff --git a/boringNotch/helpers/AudioPlayer.swift b/boringNotch/helpers/AudioPlayer.swift index c202873e9..029c9c7b2 100644 --- a/boringNotch/helpers/AudioPlayer.swift +++ b/boringNotch/helpers/AudioPlayer.swift @@ -8,8 +8,20 @@ import Foundation import AppKit -class AudioPlayer { +final class AudioPlayer: NSObject, NSSoundDelegate { + /// Playing sounds must be retained or playback is cut off when ARC + /// releases the instance at the end of the statement. + private var sound: NSSound? + func play(fileName: String, fileExtension: String) { - NSSound(contentsOf:Bundle.main.url(forResource: fileName, withExtension: fileExtension)!, byReference: false)?.play() + guard let url = Bundle.main.url(forResource: fileName, withExtension: fileExtension), + let sound = NSSound(contentsOf: url, byReference: false) else { return } + sound.delegate = self + self.sound = sound + sound.play() + } + + func sound(_ sound: NSSound, didFinishPlaying flag: Bool) { + self.sound = nil } } diff --git a/boringNotch/helpers/Log.swift b/boringNotch/helpers/Log.swift new file mode 100644 index 000000000..396cc5315 --- /dev/null +++ b/boringNotch/helpers/Log.swift @@ -0,0 +1,28 @@ +// +// Log.swift +// boringNotch +// +// SPDX-License-Identifier: GPL-3.0-only +// +// os.Logger backbone. One subsystem, per-feature categories — filterable +// in Console.app and persisted appropriately by level (debug is +// memory-only; notice+ hits the log store). +// + +import OSLog + +enum Log { + private static let subsystem = "theboringteam.boringnotch" + + static let general = Logger(subsystem: subsystem, category: "general") + static let app = Logger(subsystem: subsystem, category: "app") + static let music = Logger(subsystem: subsystem, category: "music") + static let osd = Logger(subsystem: subsystem, category: "osd") + static let xpc = Logger(subsystem: subsystem, category: "xpc") + static let shelf = Logger(subsystem: subsystem, category: "shelf") + static let notifications = Logger(subsystem: subsystem, category: "notifications") + static let battery = Logger(subsystem: subsystem, category: "battery") + static let webcam = Logger(subsystem: subsystem, category: "webcam") + static let calendar = Logger(subsystem: subsystem, category: "calendar") + static let window = Logger(subsystem: subsystem, category: "window") +} diff --git a/boringNotch/helpers/MediaEnvironment.swift b/boringNotch/helpers/MediaEnvironment.swift new file mode 100644 index 000000000..33413f421 --- /dev/null +++ b/boringNotch/helpers/MediaEnvironment.swift @@ -0,0 +1,48 @@ +// +// MediaEnvironment.swift +// boringNotch +// +// SPDX-License-Identifier: GPL-3.0-only +// +// Created as part of the architecture remediation. +// + +import Foundation + +/// Owns the launch-time MediaRemote (Now Playing) availability probe. +/// +/// Split out of MusicManager so nothing needs to instantiate the whole +/// music stack just to learn the flag: `Defaults.Keys.mediaController`'s +/// default previously read `MusicManager.shared.isNowPlayingDeprecated` +/// while MusicManager itself reads that key — reading the key from inside +/// MusicManager's lazy init could re-enter its own initialization and trap. +/// The result is persisted so static Defaults defaults are meaningful even +/// before this launch's probe finishes. +@MainActor +final class MediaEnvironment: ObservableObject { + static let shared = MediaEnvironment() + + @Published private(set) var isNowPlayingDeprecated: Bool + + private let checker = MediaChecker() + private var resolveTask: Task? + + static let persistenceKey = "MediaEnvironment.isNowPlayingDeprecated" + + private init() { + isNowPlayingDeprecated = UserDefaults.standard.bool(forKey: Self.persistenceKey) + } + + /// Probe once; concurrent calls coalesce behind the first. + func resolve() { + guard resolveTask == nil else { return } + resolveTask = Task { @MainActor in + defer { resolveTask = nil } + let resolved = (try? await checker.checkDeprecationStatus()) ?? false + if resolved != isNowPlayingDeprecated { + isNowPlayingDeprecated = resolved + } + UserDefaults.standard.set(resolved, forKey: Self.persistenceKey) + } + } +} diff --git a/boringNotch/helpers/OTPDetector.swift b/boringNotch/helpers/OTPDetector.swift new file mode 100644 index 000000000..b35c51fea --- /dev/null +++ b/boringNotch/helpers/OTPDetector.swift @@ -0,0 +1,167 @@ +// +// OTPDetector.swift +// boringNotch +// +// Finds a one-time verification code in notification text. Precision over +// recall: a phone number or price shown as a fake "code to copy" is worse +// than occasionally missing a real one — same trade-off iOS's own OTP +// autofill makes (it also requires contextual signal, not bare digits). +// +// Rules, in order: +// 1. A digit run of 4–8 digits (or two 3-digit groups joined by a dash/ +// space, e.g. "123-456") is a *candidate*. +// 2. A candidate is only accepted if a verification-related keyword +// ("code", "otp", "verification", "passcode", "pin", …) appears within +// `keywordWindow` characters of it — this is what keeps "call me at +// 9876543210" and "invoice #48293021" from matching, since neither has +// a keyword nearby, without needing hand-written phone/invoice rules. +// 3. Currency, percentage, and time-adjacent digits are rejected outright +// even if a keyword happens to be nearby (e.g. "OTP fee is $500"). +// 4. If no digit-only candidate is accepted, a secondary pass looks for a +// short uppercase alphanumeric token (Steam Guard-style: "R7K9P2") next +// to the same keyword set. +// + +import Foundation + +enum OTPDetector { + /// Longest a real OTP/2FA code gets in practice; wider misses ambiguity + /// with tracking numbers and account IDs. + private static let digitCountRange = 4...8 + + /// How close a keyword has to be to a candidate to count as context, + /// measured in UTF-16 code units. Covers "Your code is 482910" and + /// "482910 is your verification code" without also matching a keyword + /// three sentences away. + private static let keywordWindow = 40 + + private static let keywords = [ + "verification", "one-time", "one time", "passcode", "otp", + "security code", "confirmation code", "confirmation", "access code", + "auth code", "authentication code", "two-factor", "2fa", "pin", "code" + ] + + /// Used for the alphanumeric fallback only. Excludes bare "code"/"pin" — + /// those two are common in non-OTP contexts too ("promo code SAVE20", + /// "pin your location"), which is fine for the digit-only pass (a random + /// 6-digit number near "code" is almost always a real OTP) but would + /// wrongly accept a dictionary-word promo code like "SAVE20" here. + private static let strongKeywords = [ + "verification", "one-time", "one time", "passcode", "otp", + "security code", "confirmation code", "access code", "auth code", + "authentication code", "two-factor", "2fa" + ] + + private static let digitRunRegex = try! NSRegularExpression( + pattern: #"\d{3}[-\s]\d{3}\b|\b\d{4,8}\b"# + ) + private static let alphanumericRegex = try! NSRegularExpression( + pattern: #"\b(?=[A-Z0-9]*\d)(?=[A-Z0-9]*[A-Z])[A-Z0-9]{5,8}\b"# + ) + + /// Returns the code with any separators stripped, ready to paste into an + /// OTP field — or nil if nothing in `text` clears the bar above. + static func detect(in text: String) -> String? { + guard !text.isEmpty else { return nil } + let ns = text as NSString + + if let match = bestDigitCandidate(in: text, ns: ns) { + return match.replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: " ", with: "") + } + + return bestAlphanumericCandidate(in: text, ns: ns) + } + + private static func bestDigitCandidate(in text: String, ns: NSString) -> String? { + let matches = digitRunRegex.matches(in: text, range: NSRange(location: 0, length: ns.length)) + for match in matches { + guard !isExcluded(match.range, in: ns), hasNearbyKeyword(match.range, in: ns) else { continue } + return ns.substring(with: match.range) + } + return nil + } + + private static func bestAlphanumericCandidate(in text: String, ns: NSString) -> String? { + let matches = alphanumericRegex.matches(in: text, range: NSRange(location: 0, length: ns.length)) + for match in matches where hasNearbyKeyword(match.range, in: ns, from: strongKeywords) { + return ns.substring(with: match.range) + } + return nil + } + + /// Currency amounts, percentages, and clock times shouldn't match even + /// with a keyword nearby ("OTP delivery fee is $500", "meeting at 4:30"). + private static func isExcluded(_ range: NSRange, in ns: NSString) -> Bool { + let before = charBefore(range, in: ns) + let after = charAfter(range, in: ns) + + if let before, "$€£¥₹".contains(before) { return true } + if let after, after == "%" { return true } + if let after, after == ":" { return true } + if let before, before == ":" { return true } + if let after, after == "." { + // "500.00" — a decimal amount, not a code with a trailing period. + let next = ns.substring(with: NSRange(location: range.location + range.length, length: min(3, ns.length - range.location - range.length))) + if next.dropFirst().allSatisfy(\.isNumber) { return true } + } + return false + } + + private static func charBefore(_ range: NSRange, in ns: NSString) -> Character? { + guard range.location > 0 else { return nil } + return Character(UnicodeScalar(ns.character(at: range.location - 1))!) + } + + private static func charAfter(_ range: NSRange, in ns: NSString) -> Character? { + let end = range.location + range.length + guard end < ns.length else { return nil } + return Character(UnicodeScalar(ns.character(at: end))!) + } + + private static func hasNearbyKeyword(_ range: NSRange, in ns: NSString, from list: [String] = keywords) -> Bool { + let windowStart = max(0, range.location - keywordWindow) + let windowEnd = min(ns.length, range.location + range.length + keywordWindow) + let window = ns.substring(with: NSRange(location: windowStart, length: windowEnd - windowStart)).lowercased() + return list.contains { window.contains($0) } + } +} + +#if DEBUG +/// Non-exhaustive but covers the shapes that matter: separators, prefix vs. +/// suffix keyword position, currency/time/percentage traps, and an +/// alphanumeric fallback. Run via `OTPDetector.runSelfCheck()`. +extension OTPDetector { + static func runSelfCheck() { + let shouldDetect: [(String, String)] = [ + ("Your WhatsApp code: 123-456. Don't share this with anyone.", "123456"), + ("G-593821 is your Google verification code.", "593821"), + ("Your Instagram code is 482910. Learn more.", "482910"), + ("Use 7482 as your verification code. Expires in 10 minutes.", "7482"), + ("123456 is your Facebook confirmation code", "123456"), + ("<#> Your ABC App code is 384950 #hash", "384950"), + ("Your OTP for a transaction of INR 500.00 is 837201. Valid for 5 mins.", "837201"), + ("Your Amazon OTP is: 4821", "4821"), + ("Your Steam Guard verification code: R7K9P2", "R7K9P2") + ] + + let shouldMiss: [String] = [ + "Hey, call me at 9876543210 when you're free", + "Meeting at 3:30 today, don't forget code review at 4", + "Your invoice #48293021 total is due", + "Ref: 293847, please quote when calling about your 2023 order", + "Get 20% off with code SAVE20 at checkout", + "The OTP delivery fee is $5000 this month" + ] + + for (text, expected) in shouldDetect { + let got = detect(in: text) + assert(got == expected, "OTPDetector missed \"\(text)\" — expected \(expected), got \(got ?? "nil")") + } + for text in shouldMiss { + let got = detect(in: text) + assert(got == nil, "OTPDetector false-positived on \"\(text)\" — got \(got ?? "nil")") + } + } +} +#endif diff --git a/boringNotch/managers/AudioCaptureManager.swift b/boringNotch/managers/AudioCaptureManager.swift index 52331efad..ff42410fb 100644 --- a/boringNotch/managers/AudioCaptureManager.swift +++ b/boringNotch/managers/AudioCaptureManager.swift @@ -57,7 +57,9 @@ final class AudioCaptureManager: ObservableObject { private let levelsConsumers = NSHashTable.weakObjects() private var latestLevels: [Float]? - private let fft: vDSP.FFT + /// If setup fails (essentially impossible on real hardware) the FFT path + /// degrades to flat bars instead of taking down the whole app. + private let fft: vDSP.FFT? private let hannWindow: [Float] private let windowPowerScalar: Float private var samplesBuf: [Float] @@ -80,10 +82,7 @@ final class AudioCaptureManager: ObservableObject { ringBuffer = UnsafeMutablePointer.allocate(capacity: Self.ringCapacity) ringBuffer.initialize(repeating: 0, count: Self.ringCapacity) - guard let setup = vDSP.FFT(log2n: Self.log2n, radix: .radix2, ofType: DSPSplitComplex.self) else { - fatalError("Failed to create vDSP.FFT setup") - } - fft = setup + fft = vDSP.FFT(log2n: Self.log2n, radix: .radix2, ofType: DSPSplitComplex.self) let window = vDSP.window( ofType: Float.self, usingSequence: .hanningDenormalized, @@ -155,28 +154,32 @@ final class AudioCaptureManager: ObservableObject { // MARK: - State observation private func observeState() { - let music = MusicManager.shared - let enabledPublisher = Defaults.publisher(.realtimeAudioWaveform) - .map(\.newValue) - .prepend(Defaults[.realtimeAudioWaveform]) - .removeDuplicates() - - Publishers.CombineLatest4( - music.$isPlaying.removeDuplicates(), - music.$bundleIdentifier.removeDuplicates(), - music.$audioCaptureBundleIdentifiers.removeDuplicates(), - enabledPublisher - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] isPlaying, bundleID, captureBundleIDs, enabled in - self?.evaluate( - isPlaying: isPlaying, - displayBundleID: bundleID, - captureBundleIDs: captureBundleIDs, - enabled: enabled - ) - } - .store(in: &cancellables) + // MusicManager is @MainActor — wire the publishers on the main + // actor; delivery then continues via receive(on:) as before. + Task { @MainActor in + let music = MusicManager.shared + let enabledPublisher = Defaults.publisher(.realtimeAudioWaveform) + .map(\.newValue) + .prepend(Defaults[.realtimeAudioWaveform]) + .removeDuplicates() + + Publishers.CombineLatest4( + music.$isPlaying.removeDuplicates(), + music.$bundleIdentifier.removeDuplicates(), + music.$audioCaptureBundleIdentifiers.removeDuplicates(), + enabledPublisher + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isPlaying, bundleID, captureBundleIDs, enabled in + self?.evaluate( + isPlaying: isPlaying, + displayBundleID: bundleID, + captureBundleIDs: captureBundleIDs, + enabled: enabled + ) + } + .store(in: &cancellables) + } } private func evaluate( @@ -688,6 +691,7 @@ final class AudioCaptureManager: ObservableObject { } private func processFFT() { + guard let fft else { return } let n = Self.fftSize copyLatestSamples(count: n) diff --git a/boringNotch/managers/AudioRouteManager.swift b/boringNotch/managers/AudioRouteManager.swift new file mode 100644 index 000000000..9b94c5702 --- /dev/null +++ b/boringNotch/managers/AudioRouteManager.swift @@ -0,0 +1,207 @@ +// +// AudioRouteManager.swift +// boringNotch +// +// Lists the Mac's audio output devices and switches the system default +// between them, for the compact player's media-output button. +// +// Adapted from Atoll's AudioRouteManager +// (https://github.com/Ebullioscopic/Atoll, GPL-3.0, itself a boring.notch +// fork). +// +// Distinct from AudioOutputRouteResolver, which only classifies the +// *current* route into an icon for the OSD. This one enumerates every +// device and can change which is active. +// + +import Combine +import CoreAudio +import Foundation + +struct AudioOutputDevice: Identifiable, Equatable { + let id: AudioDeviceID + let name: String + let transportType: UInt32 + + /// Name first, transport second: a name match is more specific than the + /// transport ("AirPods Pro" over Bluetooth beats a generic headphones + /// glyph), and matches how macOS's own output menu labels things. + var iconName: String { + let normalized = name.lowercased() + + if normalized.contains("airpods max") { return "airpodsmax" } + if normalized.contains("airpods pro") { return "airpodspro" } + if normalized.contains("airpods") { return "airpods" } + if normalized.contains("macbook") { return "laptopcomputer" } + if normalized.contains("homepod") { return "homepod" } + if normalized.contains("headphone") || normalized.contains("headset") || normalized.contains("beats") { + return "headphones" + } + if normalized.contains("display") || normalized.contains("monitor") { return "display" } + + switch transportType { + case kAudioDeviceTransportTypeBluetooth, kAudioDeviceTransportTypeBluetoothLE: + return normalized.contains("speaker") ? "hifispeaker" : "headphones" + case kAudioDeviceTransportTypeAirPlay: + return "airplayaudio" + case kAudioDeviceTransportTypeDisplayPort, kAudioDeviceTransportTypeHDMI: + return "tv" + case kAudioDeviceTransportTypeUSB: + return "hifispeaker" + case kAudioDeviceTransportTypeBuiltIn: + return "laptopcomputer" + default: + return "speaker.wave.2" + } + } +} + +@MainActor +final class AudioRouteManager: ObservableObject { + static let shared = AudioRouteManager() + + @Published private(set) var devices: [AudioOutputDevice] = [] + @Published private(set) var activeDeviceID: AudioDeviceID = 0 + + var activeDevice: AudioOutputDevice? { + devices.first { $0.id == activeDeviceID } + } + + /// CoreAudio property reads block, so they stay off the main thread — + /// the picker opens from a click and shouldn't stutter the notch. + private let queue = DispatchQueue(label: "boringNotch.AudioRouteManager") + + private init() {} + + func refreshDevices() { + queue.async { [weak self] in + guard let self else { return } + let defaultID = Self.fetchDefaultOutputDevice() + let found = Self.fetchOutputDeviceIDs().compactMap(Self.makeDevice) + // Active device first, then alphabetical — the one you're using + // is the one you're most likely looking for. + let sorted = found.sorted { lhs, rhs in + if lhs.id == defaultID { return true } + if rhs.id == defaultID { return false } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + Task { @MainActor in + self.activeDeviceID = defaultID + self.devices = sorted + } + } + } + + func select(_ device: AudioOutputDevice) { + queue.async { [weak self] in + guard Self.setDefaultOutputDevice(device.id) else { return } + Task { @MainActor in + self?.activeDeviceID = device.id + self?.refreshDevices() + } + } + } + + // MARK: - CoreAudio + + private static func fetchDefaultOutputDevice() -> AudioDeviceID { + var deviceID = AudioDeviceID() + var size = UInt32(MemoryLayout.size) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID + ) + return status == noErr ? deviceID : 0 + } + + @discardableResult + private static func setDefaultOutputDevice(_ deviceID: AudioDeviceID) -> Bool { + var target = deviceID + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + return AudioObjectSetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &address, 0, nil, + UInt32(MemoryLayout.size), + &target + ) == noErr + } + + private static func fetchOutputDeviceIDs() -> [AudioDeviceID] { + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize( + AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size + ) == noErr else { return [] } + + var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout.size) + guard AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &ids + ) == noErr else { return [] } + + // Every device is returned, inputs included — keep only the ones + // that actually have output streams, or the picker would offer + // microphones as places to send audio. + return ids.filter(hasOutputStreams) + } + + private static func hasOutputStreams(_ deviceID: AudioDeviceID) -> Bool { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreams, + mScope: kAudioDevicePropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain + ) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &size) == noErr else { + return false + } + return size > 0 + } + + private static func makeDevice(_ deviceID: AudioDeviceID) -> AudioOutputDevice? { + guard let name = stringProperty(deviceID, kAudioObjectPropertyName), !name.isEmpty else { + return nil + } + return AudioOutputDevice(id: deviceID, name: name, transportType: transportType(deviceID)) + } + + private static func stringProperty(_ deviceID: AudioDeviceID, _ selector: AudioObjectPropertySelector) -> String? { + var address = AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var size = UInt32(MemoryLayout.size) + var value: CFString? + let status = withUnsafeMutablePointer(to: &value) { pointer in + AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, pointer) + } + guard status == noErr else { return nil } + return value as String? + } + + private static func transportType(_ deviceID: AudioDeviceID) -> UInt32 { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyTransportType, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var value: UInt32 = 0 + var size = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &value) == noErr else { + return 0 + } + return value + } +} diff --git a/boringNotch/managers/BatteryActivityManager.swift b/boringNotch/managers/BatteryActivityManager.swift index f9dab2a30..f005232dc 100644 --- a/boringNotch/managers/BatteryActivityManager.swift +++ b/boringNotch/managers/BatteryActivityManager.swift @@ -4,18 +4,10 @@ import IOKit.ps /// Manages and monitors battery status changes on the device /// - Note: This class uses the IOKit framework to monitor battery status -class BatteryActivityManager { +final class BatteryActivityManager { static let shared = BatteryActivityManager() - var onBatteryLevelChange: ((Float) -> Void)? - var onMaxCapacityChange: ((Float?) -> Void)? - var onPowerModeChange: ((Bool) -> Void)? - var onPowerSourceChange: ((Bool) -> Void)? - var onChargingChange: ((Bool) -> Void)? - var onTimeToFullChargeChange: ((Int) -> Void)? - var onTimeToDischargeChange: ((Int) -> Void)? - private var batterySource: CFRunLoopSource? private var observers: [(BatteryEvent) -> Void] = [] private var previousBatteryInfo: BatteryInfo? @@ -196,18 +188,6 @@ class BatteryActivityManager { // Update previous battery info previousBatteryInfo = batteryInfo - - // Trigger optional callbacks - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.onBatteryLevelChange?(batteryInfo.currentCapacity) - self.onPowerSourceChange?(batteryInfo.isPluggedIn) - self.onChargingChange?(batteryInfo.isCharging) - self.onPowerModeChange?(batteryInfo.isInLowPowerMode) - self.onTimeToFullChargeChange?(batteryInfo.timeToFullCharge) - self.onTimeToDischargeChange?(batteryInfo.timeToDischarge) - self.onMaxCapacityChange?(batteryInfo.maxCapacity) - } } /// Enqueues a notification to be processed using the concurrency-based queue actor. @@ -299,16 +279,16 @@ class BatteryActivityManager { return batteryInfo } catch BatteryError.powerSourceUnavailable { - print("⚠️ Error: Power source information unavailable") + Log.battery.error("⚠️ Error: Power source information unavailable") return defaultBatteryInfo } catch BatteryError.batteryInfoUnavailable(let reason) { - print("⚠️ Error: Battery information unavailable - \(reason)") + Log.battery.error("⚠️ Error: Battery information unavailable - \(reason)") return defaultBatteryInfo } catch BatteryError.batteryParameterMissing(let parameter) { - print("⚠️ Error: Battery parameter missing - \(parameter)") + Log.battery.error("⚠️ Error: Battery parameter missing - \(parameter)") return defaultBatteryInfo } catch { - print("⚠️ Error: Unexpected error getting battery info - \(error.localizedDescription)") + Log.battery.error("⚠️ Error: Unexpected error getting battery info - \(error.localizedDescription)") return defaultBatteryInfo } } diff --git a/boringNotch/managers/CalendarManager.swift b/boringNotch/managers/CalendarManager.swift index 0d7c49a91..a0609fb71 100644 --- a/boringNotch/managers/CalendarManager.swift +++ b/boringNotch/managers/CalendarManager.swift @@ -12,7 +12,7 @@ import SwiftUI // MARK: - CalendarManager @MainActor -class CalendarManager: ObservableObject { +final class CalendarManager: ObservableObject { static let shared = CalendarManager() @Published var currentWeekStartDate: Date @@ -27,6 +27,9 @@ class CalendarManager: ObservableObject { private let calendarService = CalendarService() private var eventStoreChangedObserver: NSObjectProtocol? + /// EventKit can fire EKEventStoreChanged in bursts during syncs; reloads + /// coalesce so the UI refreshes once per burst instead of per notification. + private var reloadTask: Task? private init() { self.currentWeekStartDate = CalendarManager.startOfDay(Date()) @@ -48,8 +51,12 @@ class CalendarManager: ObservableObject { object: nil, queue: .main ) { [weak self] _ in - Task { - await self?.reloadCalendarAndReminderLists() + guard let self, self.reloadTask == nil else { return } + self.reloadTask = Task { @MainActor in + defer { self.reloadTask = nil } + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled else { return } + await self.reloadCalendarAndReminderLists() } } } @@ -66,7 +73,7 @@ class CalendarManager: ObservableObject { func checkCalendarAuthorization() async { let status = EKEventStore.authorizationStatus(for: .event) DispatchQueue.main.async { - print("📅 Current calendar authorization status: \(status)") + Log.calendar.debug("📅 Current calendar authorization status: \(String(describing: status))") self.calendarAuthorizationStatus = status } @@ -96,14 +103,14 @@ class CalendarManager: ObservableObject { case .writeOnly: NSLog("Write only") @unknown default: - print("Unknown authorization status") + Log.calendar.debug("Unknown authorization status") } } func checkReminderAuthorization() async { let status = EKEventStore.authorizationStatus(for: .reminder) DispatchQueue.main.async { - print("📅 Current reminder authorization status: \(status)") + Log.calendar.debug("📅 Current reminder authorization status: \(String(describing: status))") self.reminderAuthorizationStatus = status } @@ -125,7 +132,7 @@ class CalendarManager: ObservableObject { case .writeOnly: NSLog("Write only") @unknown default: - print("Unknown authorization status") + Log.calendar.debug("Unknown authorization status") } } diff --git a/boringNotch/managers/ContactAvatarManager.swift b/boringNotch/managers/ContactAvatarManager.swift new file mode 100644 index 000000000..7aca2f600 --- /dev/null +++ b/boringNotch/managers/ContactAvatarManager.swift @@ -0,0 +1,166 @@ +// +// ContactAvatarManager.swift +// boringNotch +// +// Resolves a notification sender's name to a Contacts photo, for the +// "person, not app" avatar treatment iMessage's Communication Notifications +// use. Notification Center banners don't expose a photo over Accessibility +// at all (verified against live WhatsApp/Discord banners — title/subtitle/ +// body text and buttons only, no image element), so this is the only way +// to get one for third-party apps too. +// +// Many senders won't resolve — a WhatsApp/Telegram display name rarely +// matches a Contacts card exactly, and some notifications aren't from a +// person at all. Callers fall back to a monogram avatar in that case. +// + +import AppKit +import Contacts +import SwiftUI + +@MainActor +final class ContactAvatarManager: ObservableObject { + static let shared = ContactAvatarManager() + + private let store = CNContactStore() + private var isAuthorized = false + /// Exact-name lookups are cheap to repeat but the store fetch isn't; + /// misses are remembered too so a name that doesn't resolve isn't + /// retried forever. NSCache lets the system evict hits under pressure. + private let cache = NSCache() + private var knownMisses: Set = [] + + private init() { + isAuthorized = CNContactStore.authorizationStatus(for: .contacts) == .authorized + } + + private func ensureAuthorized() async -> Bool { + switch CNContactStore.authorizationStatus(for: .contacts) { + case .authorized: + isAuthorized = true + return true + case .notDetermined: + let granted = (try? await store.requestAccess(for: .contacts)) ?? false + isAuthorized = granted + return granted + default: + return false + } + } + + /// Looks up a contact photo by exact display-name match. Returns nil + /// immediately (no permission prompt) unless the caller has already + /// established access via `requestAccessIfNeeded`. + func photo(forSenderNamed name: String) -> NSImage? { + if let cached = cache.object(forKey: name as NSString) { return cached } + if knownMisses.contains(name) { return nil } + guard isAuthorized else { return nil } + + let keys = [CNContactImageDataKey, CNContactThumbnailImageDataKey] as [CNKeyDescriptor] + let predicate = CNContact.predicateForContacts(matchingName: name) + + guard let contacts = try? store.unifiedContacts(matching: predicate, keysToFetch: keys), + // Ambiguous matches (multiple people share a first name) are + // worse than no photo — a wrong face is worse than a monogram. + contacts.count == 1, + let data = contacts[0].thumbnailImageData ?? contacts[0].imageData, + let image = NSImage(data: data) + else { + NSLog("[boringNotch] avatar for \(name.debugDescription): no contact photo, using monogram") + knownMisses.insert(name) + return nil + } + + NSLog("[boringNotch] avatar for \(name.debugDescription): using contact photo") + cache.setObject(image, forKey: name as NSString) + return image + } + + /// Phone number for a contact, digits only and international, suitable + /// for a whatsapp:// deep link — or nil when it can't be resolved + /// *confidently*. + /// + /// The bar is deliberately high, because the cost of being wrong is + /// dropping someone's private reply into a stranger's chat: + /// + /// - exactly one matching contact, else the name is ambiguous + /// - the stored number must already carry a country code (leading "+"). + /// A local-format number can't be made international without guessing + /// the country, and a wrong guess is a wrong person. + /// + /// Group chats resolve to nothing here, which is correct — a group name + /// isn't a contact and has no single number to send to. + func phoneNumber(forContactNamed name: String) -> String? { + guard isAuthorized else { return nil } + + let keys = [CNContactPhoneNumbersKey as CNKeyDescriptor] + guard let contacts = try? store.unifiedContacts( + matching: CNContact.predicateForContacts(matchingName: name), + keysToFetch: keys + ), contacts.count == 1 else { return nil } + + // Prefer an explicitly mobile number; a landline won't reach + // WhatsApp at all. + let numbers = contacts[0].phoneNumbers + let preferred = numbers.first { + $0.label == CNLabelPhoneNumberMobile || $0.label == CNLabelPhoneNumberiPhone + } ?? numbers.first + + guard let raw = preferred?.value.stringValue else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("+") else { return nil } + + let digits = trimmed.filter(\.isNumber) + return digits.isEmpty ? nil : digits + } + + /// Call once, e.g. when notification live activity starts, so the first + /// banner isn't blocked on a permission prompt mid-render. + func requestAccessIfNeeded() async { + _ = await ensureAuthorized() + } +} + +/// Stable "person" avatar: a real contact photo when one resolves, otherwise +/// a colored monogram — the same fallback Contacts/Messages/Mail use for +/// people without a saved photo, so it never looks broken. +struct PersonAvatarView: View { + let name: String + let size: CGFloat + + @ObservedObject private var contacts = ContactAvatarManager.shared + + var body: some View { + Group { + if let photo = contacts.photo(forSenderNamed: name) { + Image(nsImage: photo) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + ZStack { + monogramColor + Text(initials) + .font(.system(size: size * 0.4, weight: .semibold)) + .foregroundStyle(.white) + } + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + } + + private var initials: String { + let parts = name.split(separator: " ").prefix(2) + let letters = parts.compactMap { $0.first }.map(String.init) + return letters.isEmpty ? "?" : letters.joined().uppercased() + } + + /// Hashes the name to a hue so the same person gets the same color across + /// notifications, without needing to persist anything. + private var monogramColor: Color { + var hasher = Hasher() + hasher.combine(name) + let hue = Double(abs(hasher.finalize()) % 360) / 360 + return Color(hue: hue, saturation: 0.55, brightness: 0.75) + } +} diff --git a/boringNotch/managers/ImageService.swift b/boringNotch/managers/ImageService.swift index 6e324af10..439e12452 100644 --- a/boringNotch/managers/ImageService.swift +++ b/boringNotch/managers/ImageService.swift @@ -8,12 +8,12 @@ import Foundation import Defaults -public protocol ImageServiceProtocol { +protocol ImageServiceProtocol { func fetchImageData(from url: URL) async throws -> Data } -public final class ImageService: ImageServiceProtocol { - public static let shared = ImageService() +final class ImageService: ImageServiceProtocol { + static let shared = ImageService() private let session: URLSession @@ -39,7 +39,7 @@ public final class ImageService: ImageServiceProtocol { } } - public func fetchImageData(from url: URL) async throws -> Data { + func fetchImageData(from url: URL) async throws -> Data { guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { throw URLError(.unsupportedURL) } diff --git a/boringNotch/managers/LyricsService.swift b/boringNotch/managers/LyricsService.swift index 3dda022e4..7d78bbbf5 100644 --- a/boringNotch/managers/LyricsService.swift +++ b/boringNotch/managers/LyricsService.swift @@ -10,15 +10,24 @@ import Foundation /// Service responsible for fetching and parsing lyrics for the currently playing track. @MainActor -class LyricsService: ObservableObject { +final class LyricsService: ObservableObject { static let shared = LyricsService() @Published var currentLyrics: String = "" @Published var isFetchingLyrics: Bool = false @Published var syncedLyrics: [(time: Double, text: String)] = [] - // Cache to avoid redundant fetches - private var lyricsCache: [String: (plain: String, synced: [(time: Double, text: String)])] = [:] + // Cache to avoid redundant fetches; NSCache evicts under memory pressure + // instead of growing for the whole session. + private final class LyricsEntry { + let plain: String + let synced: [(time: Double, text: String)] + init(plain: String, synced: [(time: Double, text: String)]) { + self.plain = plain + self.synced = synced + } + } + private let lyricsCache = NSCache() private var currentFetchTask: Task? private init() {} @@ -37,7 +46,7 @@ class LyricsService: ObservableObject { // Check cache first let cacheKey = cacheKey(title: title, artist: artist) - if let cached = lyricsCache[cacheKey] { + if let cached = lyricsCache.object(forKey: cacheKey as NSString) { currentLyrics = cached.plain syncedLyrics = cached.synced isFetchingLyrics = false @@ -52,14 +61,14 @@ class LyricsService: ObservableObject { guard let self = self else { return } // Try Apple Music first if applicable - if let bundleIdentifier = bundleIdentifier, bundleIdentifier.contains("com.apple.Music") { + if let bundleIdentifier = bundleIdentifier, bundleIdentifier.contains(MediaAppBundleID.appleMusic) { if let lyrics = await self.fetchAppleMusicLyrics() { guard !Task.isCancelled else { return } await MainActor.run { self.currentLyrics = lyrics self.syncedLyrics = [] self.isFetchingLyrics = false - self.lyricsCache[cacheKey] = (plain: lyrics, synced: []) + self.lyricsCache.setObject(LyricsEntry(plain: lyrics, synced: []), forKey: cacheKey as NSString) } return } @@ -75,7 +84,7 @@ class LyricsService: ObservableObject { self.syncedLyrics = webResult.synced self.isFetchingLyrics = false if !webResult.plain.isEmpty { - self.lyricsCache[cacheKey] = webResult + self.lyricsCache.setObject(LyricsEntry(plain: webResult.plain, synced: webResult.synced), forKey: cacheKey as NSString) } } } @@ -128,7 +137,7 @@ class LyricsService: ObservableObject { } private func fetchAppleMusicLyrics() async -> String? { - let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Music") + let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: MediaAppBundleID.appleMusic) guard !runningApps.isEmpty else { return nil } let script = """ diff --git a/boringNotch/managers/MusicManager.swift b/boringNotch/managers/MusicManager.swift index 275dbdcab..d4387b7fc 100644 --- a/boringNotch/managers/MusicManager.swift +++ b/boringNotch/managers/MusicManager.swift @@ -14,16 +14,17 @@ let defaultImage: NSImage = .init( accessibilityDescription: "Album Art" )! -class MusicManager: ObservableObject { +@MainActor +final class MusicManager: ObservableObject { // MARK: - Properties static let shared = MusicManager() private var cancellables = Set() private var controllerCancellables = Set() private var debounceIdleTask: Task? - // Helper to check if macOS has removed support for NowPlayingController - public private(set) var isNowPlayingDeprecated: Bool = false - private let mediaChecker = MediaChecker() + // Whether macOS has removed support for NowPlayingController. + // Mirrored from MediaEnvironment, which owns the probe. + private(set) var isNowPlayingDeprecated: Bool = false // Active controller private var activeController: (any MediaControllerProtocol)? @@ -47,7 +48,6 @@ class MusicManager: ObservableObject { @Published var repeatMode: RepeatMode = .off @Published var volume: Double = 0.5 @Published var volumeControlSupported: Bool = true - @ObservedObject var coordinator = BoringViewCoordinator.shared @Published var usingAppIconForArtwork: Bool = false @Published var canFavoriteTrack: Bool = false @@ -77,30 +77,31 @@ class MusicManager: ObservableObject { // Listen for changes to the default controller preference NotificationCenter.default.publisher(for: Notification.Name.mediaControllerChanged) .sink { [weak self] _ in - self?.setActiveControllerBasedOnPreference() + Task { @MainActor in + self?.setActiveControllerBasedOnPreference() + } } .store(in: &cancellables) - // Initialize deprecation check asynchronously - Task { @MainActor in - do { - self.isNowPlayingDeprecated = try await self.mediaChecker.checkDeprecationStatus() - print("Deprecation check completed: \(self.isNowPlayingDeprecated)") - } catch { - print("Failed to check deprecation status: \(error). Defaulting to false.") - self.isNowPlayingDeprecated = false + // The NowPlaying availability probe is owned by MediaEnvironment + // (resolved eagerly at launch); mirror it so existing consumers of + // MusicManager.shared.isNowPlayingDeprecated keep working. + isNowPlayingDeprecated = MediaEnvironment.shared.isNowPlayingDeprecated + MediaEnvironment.shared.$isNowPlayingDeprecated + .sink { [weak self] resolved in + Task { @MainActor in + self?.isNowPlayingDeprecated = resolved + self?.setActiveControllerBasedOnPreference() + } } - - // Initialize the active controller after deprecation check - self.setActiveControllerBasedOnPreference() - } - } + .store(in: &cancellables) - deinit { - destroy() + setActiveControllerBasedOnPreference() } - - public func destroy() { + + // Singleton: no deinit-based teardown. App teardown calls destroy() + // explicitly from applicationWillTerminate. + func destroy() { debounceIdleTask?.cancel() cancellables.removeAll() controllerCancellables.removeAll() @@ -144,7 +145,9 @@ class MusicManager: ObservableObject { .sink { [weak self] state in guard let self = self, self.activeController === controller else { return } - self.updateFromPlaybackState(state) + Task { @MainActor in + self.updateFromPlaybackState(state) + } } .store(in: &controllerCancellables) } @@ -154,7 +157,7 @@ class MusicManager: ObservableObject { private func setActiveControllerBasedOnPreference() { let preferredType = Defaults[.mediaController] - print("Preferred Media Controller: \(preferredType)") + Log.music.debug("Preferred Media Controller: \(String(describing: preferredType))") // If NowPlaying is deprecated but that's the preference, use Apple Music instead let controllerType = (self.isNowPlayingDeprecated && preferredType == .nowPlaying) @@ -186,7 +189,8 @@ class MusicManager: ObservableObject { @MainActor private func updateFromPlaybackState(_ state: PlaybackState) { // Check for playback state changes (playing/paused) - if state.isPlaying != self.isPlaying { + let playingStateChanged = state.isPlaying != self.isPlaying + if playingStateChanged { NSLog("Playback state changed: \(state.isPlaying ? "Playing" : "Paused")") withAnimation(.smooth) { self.isPlaying = state.isPlaying @@ -216,7 +220,7 @@ class MusicManager: ObservableObject { self.updateArtwork(artwork) } else if state.artwork == nil { // Try to use app icon if no artwork but track changed - if let appIconImage = AppIconAsNSImage(for: state.bundleIdentifier) { + if let appIconImage = appIconAsNSImage(for: state.bundleIdentifier) { self.usingAppIconForArtwork = true self.updateAlbumArt(newAlbumArt: appIconImage) } else { @@ -299,8 +303,15 @@ class MusicManager: ObservableObject { if volumeChanged { self.volume = state.volume } - - self.timestampDate = state.lastUpdated + + // The slider extrapolates from (elapsedTime, timestampDate); only + // republish when an extrapolation input actually changed — otherwise + // every no-op stream event invalidates the whole view tree. A pause/ + // resume must rebase it too, or the estimate overshoots by the pause + // duration. + if timeChanged || playbackRateChanged || playingStateChanged { + self.timestampDate = state.lastUpdated + } } func toggleFavoriteTrack() { @@ -311,7 +322,7 @@ class MusicManager: ObservableObject { @MainActor private func toggleAppleMusicFavorite() async { - let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Music") + let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: MediaAppBundleID.appleMusic) guard !runningApps.isEmpty else { return } let script = """ @@ -424,7 +435,7 @@ class MusicManager: ObservableObject { } // MARK: - Playback Position Estimation - public func estimatedPlaybackPosition(at date: Date = Date()) -> TimeInterval { + func estimatedPlaybackPosition(at date: Date = Date()) -> TimeInterval { guard isPlaying else { return min(elapsedTime, songDuration) } let timeDifference = date.timeIntervalSince(timestampDate) @@ -443,12 +454,11 @@ class MusicManager: ObservableObject { } private func updateSneakPeek() { - if isPlaying && Defaults[.enableSneakPeek] { - if Defaults[.sneakPeekStyles] == .standard { - coordinator.toggleSneakPeek(status: true, type: .music) - } else { - coordinator.toggleExpandingView(status: true, type: .music) - } + guard isPlaying && Defaults[.enableSneakPeek] else { return } + if Defaults[.sneakPeekStyles] == .standard { + NotchUIEventBus.events.send(.sneakPeek(type: .music, value: 0)) + } else { + NotchUIEventBus.events.send(.expandingView(type: .music)) } } @@ -520,7 +530,7 @@ class MusicManager: ObservableObject { } func openMusicApp() { guard let bundleID = bundleIdentifier else { - print("Error: appBundleIdentifier is nil") + Log.music.error("Error: appBundleIdentifier is nil") return } @@ -529,13 +539,13 @@ class MusicManager: ObservableObject { let configuration = NSWorkspace.OpenConfiguration() workspace.openApplication(at: appURL, configuration: configuration) { (app, error) in if let error = error { - print("Failed to launch app with bundle ID: \(bundleID), error: \(error)") + Log.music.error("Failed to launch app with bundle ID: \(bundleID), error: \(error)") } else { - print("Launched app with bundle ID: \(bundleID)") + Log.music.debug("Launched app with bundle ID: \(bundleID)") } } } else { - print("Failed to find app with bundle ID: \(bundleID)") + Log.music.error("Failed to find app with bundle ID: \(bundleID)") } } @@ -559,7 +569,7 @@ class MusicManager: ObservableObject { NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bundleID }) else { return } var script: String? - if bundleID == "com.apple.Music" { + if bundleID == MediaAppBundleID.appleMusic { script = """ tell application "Music" if it is running then @@ -569,7 +579,7 @@ class MusicManager: ObservableObject { end if end tell """ - } else if bundleID == "com.spotify.client" { + } else if bundleID == MediaAppBundleID.spotify { script = """ tell application "Spotify" if it is running then diff --git a/boringNotch/managers/NotchSpaceManager.swift b/boringNotch/managers/NotchSpaceManager.swift index faf37efbf..f55020259 100644 --- a/boringNotch/managers/NotchSpaceManager.swift +++ b/boringNotch/managers/NotchSpaceManager.swift @@ -7,12 +7,10 @@ import Foundation -class NotchSpaceManager { +final class NotchSpaceManager { static let shared = NotchSpaceManager() let notchSpace: CGSSpace - private var eventTap: CFMachPort? - private var runLoopSource: CFRunLoopSource? - + private init() { notchSpace = CGSSpace(level: 2147483647) // Max level } diff --git a/boringNotch/managers/NotchWindowManager.swift b/boringNotch/managers/NotchWindowManager.swift new file mode 100644 index 000000000..ae8f6784d --- /dev/null +++ b/boringNotch/managers/NotchWindowManager.swift @@ -0,0 +1,390 @@ +// +// NotchWindowManager.swift +// boringNotch +// +// SPDX-License-Identifier: GPL-3.0-only +// +// Extracted from AppDelegate: all notch-window, per-screen view-model and +// drag-detector lifecycle in one place. AppDelegate keeps app-lifecycle +// glue (shortcuts, onboarding, termination) and forwards to this manager. +// + +import Defaults +import SwiftUI + +@MainActor +final class NotchWindowManager { + static let shared = NotchWindowManager() + + /// All per-screen state in one value — replaces the parallel + /// windows/viewModels/dragDetectors dictionaries that previously had + /// to be mutated in lockstep (a missed mutation leaked observers). + struct ScreenContext { + let viewModel: BoringViewModel + var window: NSWindow? + var dragDetector: DragDetector? + } + + private(set) var contexts: [String: ScreenContext] = [:] // UUID -> ScreenContext + private(set) var primaryWindow: NSWindow? + let primaryViewModel = BoringViewModel() + + private(set) var isScreenLocked: Bool = false + private var windowScreenDidChangeObserver: Any? + private var previousScreens: [NSScreen]? + + // MARK: - Public lookups (preserve AppDelegate's old API shape) + + var windows: [String: NSWindow] { + contexts.compactMapValues { $0.window } + } + + var viewModels: [String: BoringViewModel] { + contexts.mapValues { $0.viewModel } + } + + var window: NSWindow? { primaryWindow } + + // MARK: - Screen lock / unlock + + func screenLocked() { + isScreenLocked = true + if !Defaults[.showOnLockScreen] { + cleanupWindows() + } else { + enableSkyLightOnAllWindows() + } + } + + func screenUnlocked() { + isScreenLocked = false + if !Defaults[.showOnLockScreen] { + adjustWindowPosition(changeAlpha: true) + } else { + disableSkyLightOnAllWindows() + } + } + + private func enableSkyLightOnAllWindows() { + if Defaults[.showOnAllDisplays] { + contexts.values.forEach { context in + (context.window as? BoringNotchSkyLightWindow)?.enableSkyLight() + } + } else { + (primaryWindow as? BoringNotchSkyLightWindow)?.enableSkyLight() + } + } + + private func disableSkyLightOnAllWindows() { + // Delay disabling SkyLight to avoid flicker during unlock transition + Task { + try? await Task.sleep(for: .milliseconds(150)) + await MainActor.run { + if Defaults[.showOnAllDisplays] { + contexts.values.forEach { context in + (context.window as? BoringNotchSkyLightWindow)?.disableSkyLight() + } + } else { + (primaryWindow as? BoringNotchSkyLightWindow)?.disableSkyLight() + } + } + } + } + + // MARK: - Window lifecycle + + func cleanupWindows(shouldInvert: Bool = false) { + let shouldCleanupMulti = shouldInvert ? !Defaults[.showOnAllDisplays] : Defaults[.showOnAllDisplays] + + if shouldCleanupMulti { + for (uuid, context) in contexts { + context.window?.close() + if let window = context.window { + NotchSpaceManager.shared.notchSpace.windows.remove(window) + } + context.dragDetector?.stopMonitoring() + contexts.removeValue(forKey: uuid) + } + } else if let window = primaryWindow { + window.close() + NotchSpaceManager.shared.notchSpace.windows.remove(window) + if let obs = windowScreenDidChangeObserver { + NotificationCenter.default.removeObserver(obs) + windowScreenDidChangeObserver = nil + } + primaryWindow = nil + } + + // ensure OSD integration reflects the current window state + BoringViewCoordinator.shared.applyOSDSources() + } + + private func createBoringNotchWindow(for screen: NSScreen, with viewModel: BoringViewModel) -> NSWindow { + let rect = NSRect(x: 0, y: 0, width: windowSize.width, height: windowSize.height) + let styleMask: NSWindow.StyleMask = [.borderless, .nonactivatingPanel, .utilityWindow, .hudWindow] + + let window = BoringNotchSkyLightWindow(contentRect: rect, styleMask: styleMask, backing: .buffered, defer: false) + + // Enable SkyLight only when screen is locked + if isScreenLocked { + window.enableSkyLight() + } else { + window.disableSkyLight() + } + + window.contentView = NSHostingView( + rootView: ContentView() + .environmentObject(viewModel) + ) + + window.orderFrontRegardless() + NotchSpaceManager.shared.notchSpace.windows.insert(window) + + // Observe when the window's screen changes so we can update drag detectors. + // Remove any previous observer first — recreating windows used to + // overwrite the token and leak the earlier observer each cycle. + if let obs = windowScreenDidChangeObserver { + NotificationCenter.default.removeObserver(obs) + } + windowScreenDidChangeObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didChangeScreenNotification, + object: window, + queue: .main) { [weak self] _ in + Task { @MainActor in + self?.setupDragDetectors() + } + } + return window + } + + private func positionWindow(_ window: NSWindow, on screen: NSScreen, changeAlpha: Bool = false) { + if changeAlpha { + window.alphaValue = 0 + } + + let screenFrame = screen.frame + window.setFrameOrigin( + NSPoint( + x: screenFrame.origin.x + (screenFrame.width / 2) - window.frame.width / 2, + y: screenFrame.origin.y + screenFrame.height - window.frame.height + )) + window.alphaValue = 1 + } + + func adjustWindowPosition(changeAlpha: Bool = false) { + let coordinator = BoringViewCoordinator.shared + if Defaults[.showOnAllDisplays] { + let currentScreenUUIDs = Set(NSScreen.screens.compactMap { $0.displayUUID }) + + // Remove windows for screens that no longer exist + for uuid in contexts.keys where !currentScreenUUIDs.contains(uuid) { + if let window = contexts[uuid]?.window { + window.close() + NotchSpaceManager.shared.notchSpace.windows.remove(window) + } + contexts[uuid]?.dragDetector?.stopMonitoring() + contexts.removeValue(forKey: uuid) + } + + // Create or update windows for all screens + for screen in NSScreen.screens { + guard let uuid = screen.displayUUID else { continue } + + if contexts[uuid] == nil { + contexts[uuid] = ScreenContext( + viewModel: BoringViewModel(screenUUID: uuid), + window: nil, + dragDetector: nil + ) + } + + if contexts[uuid]?.window == nil { + let viewModel = contexts[uuid]!.viewModel + let window = createBoringNotchWindow(for: screen, with: viewModel) + contexts[uuid]?.window = window + } + + if let window = contexts[uuid]?.window { + let viewModel = contexts[uuid]!.viewModel + positionWindow(window, on: screen, changeAlpha: changeAlpha) + + if viewModel.notchState == .closed { + viewModel.close() + } + } + } + } else { + let selectedScreen: NSScreen + + if let preferredScreen = NSScreen.screen(withUUID: coordinator.preferredScreenUUID ?? "") { + coordinator.selectedScreenUUID = coordinator.preferredScreenUUID ?? "" + selectedScreen = preferredScreen + } else if Defaults[.automaticallySwitchDisplay], let mainScreen = NSScreen.main, + let mainUUID = mainScreen.displayUUID { + coordinator.selectedScreenUUID = mainUUID + selectedScreen = mainScreen + } else { + if let window = primaryWindow { + window.alphaValue = 0 + } + return + } + + primaryViewModel.screenUUID = selectedScreen.displayUUID + primaryViewModel.notchSize = getClosedNotchSize(screenUUID: selectedScreen.displayUUID) + + if primaryWindow == nil { + primaryWindow = createBoringNotchWindow(for: selectedScreen, with: primaryViewModel) + } + + if let window = primaryWindow { + positionWindow(window, on: selectedScreen, changeAlpha: changeAlpha) + + if primaryViewModel.notchState == .closed { + primaryViewModel.close() + } + } + } + + // windows might have been added/removed during the earlier logic – + // update the OSD subsystems accordingly. + coordinator.applyOSDSources() + } + + func screenConfigurationDidChange() { + let currentScreens = NSScreen.screens + + let screensChanged = + currentScreens.count != previousScreens?.count + || Set(currentScreens.compactMap { $0.displayUUID }) + != Set(previousScreens?.compactMap { $0.displayUUID } ?? []) + || Set(currentScreens.map { $0.frame }) != Set(previousScreens?.map { $0.frame } ?? []) + + previousScreens = currentScreens + + if screensChanged { + DispatchQueue.main.async { [weak self] in + // Sync notch height with real value if mode is matchRealNotchSize + syncNotchHeightIfNeeded() + + self?.cleanupWindows() + self?.adjustWindowPosition() + self?.setupDragDetectors() + } + } + } + + func noteInitialScreens() { + previousScreens = NSScreen.screens + } + + // MARK: - Drag detection + + func cleanupDragDetectors() { + for (_, var context) in contexts { + context.dragDetector?.stopMonitoring() + context.dragDetector = nil + } + } + + func setupDragDetectors() { + cleanupDragDetectors() + + guard Defaults[.expandedDragDetection] else { return } + + if Defaults[.showOnAllDisplays] { + for screen in NSScreen.screens { + setupDragDetectorForScreen(screen) + } + } else { + let preferredScreen: NSScreen? = primaryWindow?.screen + ?? NSScreen.screen(withUUID: BoringViewCoordinator.shared.selectedScreenUUID) + ?? NSScreen.main + + if let screen = preferredScreen { + setupDragDetectorForScreen(screen) + } + } + } + + private func setupDragDetectorForScreen(_ screen: NSScreen) { + guard let uuid = screen.displayUUID else { return } + + let screenFrame = screen.frame + let notchHeight = openNotchSize.height + let notchWidth = openNotchSize.width + + // Create notch region at the top-center of the screen where an open notch would occupy + let notchRegion = CGRect( + x: screenFrame.midX - notchWidth / 2, + y: screenFrame.maxY - notchHeight, + width: notchWidth, + height: notchHeight + ) + + let detector = DragDetector(notchRegion: notchRegion) + + detector.onDragEntersNotchRegion = { [weak self] in + Task { @MainActor in + self?.handleDragEntersNotchRegion(onScreen: screen) + } + } + + // In single-screen mode there may be no context entry yet — only + // multi-display mode registers per-screen contexts. + if contexts[uuid] != nil { + contexts[uuid]?.dragDetector = detector + } else { + contexts[uuid] = ScreenContext(viewModel: primaryViewModel, window: nil, dragDetector: detector) + } + detector.startMonitoring() + } + + private func handleDragEntersNotchRegion(onScreen screen: NSScreen) { + guard Defaults[.boringShelf] else { return } + guard let uuid = screen.displayUUID else { return } + + let coordinator = BoringViewCoordinator.shared + if Defaults[.showOnAllDisplays], let viewModel = contexts[uuid]?.viewModel { + if viewModel.open() { + coordinator.currentView = .shelf + } + } else if !Defaults[.showOnAllDisplays], let windowScreen = primaryWindow?.screen, screen == windowScreen { + if primaryViewModel.open() { + coordinator.currentView = .shelf + } + } + } + + // MARK: - Initial setup + + /// Creates the windows for the current configuration (previously inlined + /// in applicationDidFinishLaunching). + func prepareInitialWindows() { + if !Defaults[.showOnAllDisplays] { + let viewModel = primaryViewModel + if let screen = NSScreen.main ?? NSScreen.screens.first { + primaryWindow = createBoringNotchWindow(for: screen, with: viewModel) + } + adjustWindowPosition(changeAlpha: true) + } else { + adjustWindowPosition(changeAlpha: true) + } + + setupDragDetectors() + noteInitialScreens() + } + + func togglePopover(_ sender: Any?) { + if primaryWindow?.isVisible == true { + primaryWindow?.orderOut(nil) + } else { + primaryWindow?.orderFrontRegardless() + } + } + + func cleanup() { + cleanupDragDetectors() + cleanupWindows() + } +} diff --git a/boringNotch/managers/SmartReplyManager.swift b/boringNotch/managers/SmartReplyManager.swift new file mode 100644 index 000000000..24d45fa7b --- /dev/null +++ b/boringNotch/managers/SmartReplyManager.swift @@ -0,0 +1,109 @@ +// +// SmartReplyManager.swift +// boringNotch +// +// Draft reply suggestions using Apple's on-device Foundation Models — +// entirely local, no network calls, same model that powers Apple +// Intelligence system-wide. +// +// Every touchpoint here is macOS 26+ and Apple-Intelligence-enabled only. +// This project's deployment target is macOS 14, so nothing outside an +// `@available`/`#available` guard may reference FoundationModels — Swift's +// autolinking weak-links the framework based on those annotations, which is +// what lets the app still launch on older macOS versions at all. Skipping a +// guard wouldn't just lose this feature, it would risk the whole app +// failing to launch below macOS 26. +// + +import Foundation + +#if canImport(FoundationModels) +import FoundationModels +#endif + +enum SmartReplyAvailability: Equatable { + case available + case unavailable(reason: String) +} + +enum SmartReplyManager { + static var availability: SmartReplyAvailability { + #if canImport(FoundationModels) + guard #available(macOS 26.0, *) else { + return .unavailable(reason: "Requires macOS 26 or later.") + } + switch SystemLanguageModel.default.availability { + case .available: + return .available + case .unavailable(let reason): + return .unavailable(reason: describeUnavailable(reason)) + } + #else + return .unavailable(reason: "Requires macOS 26 or later.") + #endif + } + + #if canImport(FoundationModels) + @available(macOS 26.0, *) + private static func describeUnavailable(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> String { + switch reason { + case .deviceNotEligible: + return "This Mac doesn't support Apple Intelligence." + case .appleIntelligenceNotEnabled: + return "Turn on Apple Intelligence in System Settings → Apple Intelligence & Siri." + case .modelNotReady: + return "The on-device model is still downloading." + @unknown default: + return "Apple Intelligence isn't available right now." + } + } + #endif + + /// Up to three short reply drafts for the given message, or an empty + /// array on any unavailability/failure — callers show nothing rather + /// than an error state for what's an optional nicety, not a feature the + /// UI depends on. + static func suggestReplies(sender: String?, body: String) async -> [String] { + #if canImport(FoundationModels) + guard #available(macOS 26.0, *), + case .available = SystemLanguageModel.default.availability, + !body.isEmpty + else { return [] } + + do { + let session = LanguageModelSession(instructions: """ + You draft extremely short, casual reply suggestions to an \ + incoming message, in the voice of the person replying, not \ + the sender. Match the tone and language of the message. \ + Never invent facts, plans, times, or commitments the message \ + doesn't mention. Each reply must be under 8 words. + """) + let prompt = "From: \(sender ?? "someone")\nMessage: \(body)\n\nSuggest 3 short possible replies." + let result = try await session.respond(to: prompt, generating: ReplySuggestionSet.self) + + // The model repeats itself sometimes ("Got it!" twice), which is + // both useless as a second chip and a duplicate SwiftUI ForEach + // id. Dedupe case-insensitively, keeping first-seen order. + var seen = Set() + return result.content.replies + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && seen.insert($0.lowercased()).inserted } + .prefix(3) + .map { $0 } + } catch { + return [] + } + #else + return [] + #endif + } +} + +#if canImport(FoundationModels) +@available(macOS 26.0, *) +@Generable +struct ReplySuggestionSet: Equatable { + @Guide(description: "2 to 3 short, casual reply suggestions, each under 8 words") + let replies: [String] +} +#endif diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift new file mode 100644 index 000000000..4f325116b --- /dev/null +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -0,0 +1,576 @@ +// +// SystemNotificationManager.swift +// boringNotch +// +// Collects Notification Center banners captured by the XPC helper and exposes +// them to the UI. The helper does the Accessibility work; this side only +// models what is currently on screen. +// + +import AppKit +import Combine +import Defaults +import SwiftUI + +struct SystemNotification: Identifiable, Equatable { + /// Notification Center's own identifier for the banner, valid while it is + /// on screen. Also the token the helper needs to act on it. + let id: String + let appName: String? + let bundleID: String? + let title: String? + let subtitle: String? + let body: String? + let actions: [String] + let receivedAt: Date + + /// True while the banner still exists, i.e. while replying can work. + var isLive: Bool = true + + /// Best-effort signal for showing the reply row. A collapsed WhatsApp + /// banner exposes "Reply" directly; once expanded that's replaced by + /// "Show Details"/"Send" (verified against live banners in both states). + /// Any of the three is a decent hint; `SystemNotificationManager.reply` + /// is the actual source of truth and falls back to opening the app if no + /// field materializes. + /// Whether to offer a reply box at all — based on the app supporting + /// replies, not on whether the banner is still alive. The compose box + /// stays for as long as the notification is in the notch; if the banner + /// has since died, `reply` degrades instead of the UI disappearing out + /// from under a half-typed message. + var canReply: Bool { + actions.contains { + $0.localizedCaseInsensitiveContains("reply") + || $0.localizedCaseInsensitiveContains("send") + || $0.localizedCaseInsensitiveContains("details") + } + } + + /// A verification code found in the notification text, if any. Computed + /// rather than stored — it's cheap regex work and only ever read a + /// handful of times per notification. + var detectedCode: String? { + OTPDetector.detect(in: [title, subtitle, body].compactMap { $0 }.joined(separator: " ")) + } + + var icon: NSImage? { + guard let bundleID, + let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) + else { return nil } + return NSWorkspace.shared.icon(forFile: url.path) + } + + /// Whoever the message is from — banners put the sender in the title. + var sender: String? { title } +} + +@MainActor +final class SystemNotificationManager: ObservableObject { + static let shared = SystemNotificationManager() + + /// Most recent first, live banners before expired ones. + @Published private(set) var notifications: [SystemNotification] = [] + @Published private(set) var isWatching = false + + /// The notification the notch is currently showing, if any. + @Published var activeNotification: SystemNotification? + + /// Notifications that arrived while the user was mid-reply, held back + /// rather than shown. Promoted one at a time once they're done. + @Published private(set) var queued: [SystemNotification] = [] + + /// Set by the reply UI while its field has focus. Newer notifications + /// queue instead of replacing the active one during this — yanking a + /// message out from under someone mid-sentence loses what they typed. + var isComposingReply = false { + didSet { + guard oldValue, !isComposingReply else { return } + // Finished composing and the notification is already gone (sent + // or dismissed) — nothing will promote the queue, so do it here. + if activeNotification == nil { promoteNextQueued() } + } + } + + /// Queued banners are all held alive off-screen, so this can't grow + /// without bound — past a handful, the oldest are dropped and released. + private let queueLimit = 5 + + /// How long the closed notch shows a notification before handing the + /// pill back to whatever was there before (usually music). Only applies + /// while the notch is closed and unhovered — hovering or opening it + /// holds the notification indefinitely. + private let activeDuration: TimeInterval = 8 + private var dismissTask: Task? + + /// Expired banners are kept around briefly so the notch can still show the + /// last message after the banner itself has faded. + private let historyLimit = 20 + + private var observers: [NSObjectProtocol] = [] + + private init() { + observers.append(NotificationCenter.default.addObserver( + forName: .systemNotificationDidAppear, object: nil, queue: .main + ) { [weak self] note in + guard let payload = note.userInfo as? [String: String] else { return } + MainActor.assumeIsolated { self?.add(payload) } + }) + + observers.append(NotificationCenter.default.addObserver( + forName: .systemNotificationDidDisappear, object: nil, queue: .main + ) { [weak self] note in + guard let token = note.userInfo?["token"] as? String else { return } + MainActor.assumeIsolated { self?.markExpired(token) } + }) + } + + deinit { + observers.forEach(NotificationCenter.default.removeObserver) + } + + // MARK: - Lifecycle + + func start() async { + guard await XPCHelperClient.shared.ensureAccessibilityAuthorization(promptIfNeeded: true) else { + isWatching = false + return + } + isWatching = await XPCHelperClient.shared.startNotificationWatching() + // Ask up front so the first banner isn't blocked on a permission + // prompt mid-render. Contacts access is optional — a denial just + // means every avatar falls back to a monogram. + await ContactAvatarManager.shared.requestAccessIfNeeded() + } + + func stop() { + XPCHelperClient.shared.stopNotificationWatching() + isWatching = false + releaseQueued() + } + + /// Frees every queued notification's held banner. Anything still queued + /// is holding a parked, off-screen window, so dropping the queue without + /// this would strand them. + private func releaseQueued() { + for notification in queued { + XPCHelperClient.shared.releaseNotification(token: notification.id) + clearDraft(for: notification.id) + } + queued.removeAll() + } + + // MARK: - Incoming banners + + private func add(_ payload: [String: String]) { + guard let token = payload["token"], !token.isEmpty else { return } + func value(_ key: String) -> String? { + let string = payload[key] ?? "" + return string.isEmpty ? nil : string + } + + // The helper's one-shot bundle-ID match can miss (renamed app, helper + // process owning the banner, an app that quit between posting and + // capture). Re-resolve from the app name here so every icon surface + // gets the real app instead of the grey bell fallback. A + // helper-provided bundleID always wins. + let appName = value("appName") + var bundleID = value("bundleID") + if bundleID == nil, let appName, + let resolved = BundleIDResolver.shared.bundleID(forAppNamed: appName) { + bundleID = resolved + Log.notifications.debug("[boringNotch] resolved bundleID app-side: \(appName) -> \(resolved)") + } + + let notification = SystemNotification( + id: token, + appName: appName, + bundleID: bundleID, + title: value("title"), + subtitle: value("subtitle"), + body: value("body"), + actions: (value("actions") ?? "").components(separatedBy: "\n").filter { !$0.isEmpty }, + receivedAt: Date() + ) + + notifications.removeAll { $0.id == token } + notifications.insert(notification, at: 0) + if notifications.count > historyLimit { + notifications.removeLast(notifications.count - historyLimit) + } + + guard isAllowed(notification) else { + Log.notifications.debug("[boringNotch] filtered out: \(notification.appName ?? "-") bundle=\(notification.bundleID ?? "nil")") + return + } + + // Hold the banner either way: a queued notification still needs its + // reply field alive for when it's promoted, and holding is what + // keeps that possible past the banner's few seconds on screen. + holdSystemBanner(notification) + + if isComposingReply, activeNotification != nil { + enqueue(notification) + return + } + + show(notification) + } + + private func enqueue(_ notification: SystemNotification) { + queued.removeAll { $0.id == notification.id } + queued.append(notification) + + // Oldest out first. Their held banners are released on the way, or + // they'd stay parked off-screen with nothing left to free them. + while queued.count > queueLimit { + let dropped = queued.removeFirst() + XPCHelperClient.shared.releaseNotification(token: dropped.id) + clearDraft(for: dropped.id) + } + } + + /// Rotates the stack: shows the next queued notification and sends the + /// current one to the back, so repeated taps cycle through everything + /// and always come back around rather than discarding as they go. + func cycleToNextQueued() { + guard let next = queued.first else { return } + queued.removeFirst() + if let current = activeNotification { + queued.append(current) + } + show(next, releasingPrevious: false) + } + + // MARK: - Reply drafts + + /// Half-typed replies, keyed by notification. Cycling the stack tears + /// the compose view down and rebuilds it for a different notification, + /// so without this a draft would vanish the moment you looked at + /// something else — the same lost-typing problem the queue exists to + /// prevent. + private var replyDrafts: [String: String] = [:] + + func draft(for id: String) -> String { replyDrafts[id] ?? "" } + + func setDraft(_ text: String, for id: String) { + if text.isEmpty { + replyDrafts.removeValue(forKey: id) + } else { + replyDrafts[id] = text + } + } + + func clearDraft(for id: String) { + replyDrafts.removeValue(forKey: id) + } + + /// Shows the oldest queued notification, if any. Oldest first so a burst + /// is read in the order it arrived. + private func promoteNextQueued() { + guard !queued.isEmpty else { return } + let next = queued.removeFirst() + show(next) + } + + /// Holds the system banner open for as long as the notch is showing the + /// notification, so its reply field stays usable — an untouched banner + /// dies in seconds, taking the only means of replying with it — and + /// parks it off-screen for the duration. + /// + /// Hiding isn't optional here: holding works by re-triggering the + /// banner's details toggle, which leaves it expanded with its own reply + /// field visible and focused. Two text fields fighting over the same + /// keystrokes is worse than no keep-alive, so the notch is the only + /// surface on screen while it's held. + private func holdSystemBanner(_ notification: SystemNotification) { + XPCHelperClient.shared.holdNotification(token: notification.id) + } + + /// The notch mirrors banners rather than replacing them, so an unfiltered + /// feed would duplicate every system notification. Default to the messaging + /// apps people actually reply to. + private func isAllowed(_ notification: SystemNotification) -> Bool { + if Defaults[.notificationsFromAllApps] { return true } + + if let bundleID = notification.bundleID { + return Defaults[.notificationAllowedApps].contains(bundleID) + } + + // Bundle ID resolution goes through the app's *running* name, which + // can miss (renamed app, helper process owning the notification, an + // app that quit between posting and capture). Rather than silently + // dropping the notification, fall back to matching the app name + // against the tail of an allowed bundle ID — "WhatsApp" against + // net.whatsapp.WhatsApp. + guard let appName = notification.appName?.lowercased(), !appName.isEmpty else { return false } + return Defaults[.notificationAllowedApps].contains { bundleID in + bundleID.split(separator: ".").last.map { appName == $0.lowercased() } ?? false + } + } + + /// `releasingPrevious: false` when rotating through the stack — the + /// outgoing notification goes back into the queue rather than away, and + /// it needs to keep its held banner or it won't be replyable when it + /// comes back around. + private func show(_ notification: SystemNotification, releasingPrevious: Bool = true) { + // A newer notification replaces the active one directly here rather + // than going through dismissActive, so its hold was never being + // released: dismissActive only releases whatever activeNotification + // happens to be *when it runs*, and by the time a superseded + // notification would be dismissed it's already been overwritten. + // The result was a parked, off-screen banner window that never came + // back — and since the same window can get reused by + // notificationcenterui for its own real Notification Center panel, + // that panel could end up opening off-screen too, which is why the + // system clock stopped visibly doing anything. + if releasingPrevious, let previous = activeNotification, previous.id != notification.id { + XPCHelperClient.shared.releaseNotification(token: previous.id) + } + holdSystemBanner(notification) + withAnimation(.smooth) { activeNotification = notification } + dismissTask?.cancel() + dismissTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(self?.activeDuration ?? 8)) + guard !Task.isCancelled else { return } + await MainActor.run { self?.dismissActive(token: notification.id) } + } + } + + /// Passing a token only dismisses if it is still the one on screen, so a + /// newer notification isn't cleared by an older one's timer. + func dismissActive(token: String? = nil) { + if let token, activeNotification?.id != token { return } + dismissTask?.cancel() + dismissTask = nil + // Stop holding the system banner open — otherwise the keep-alive + // would pin it (visibly, for non-hidden apps) long after the notch + // has moved on. + if let id = activeNotification?.id { + XPCHelperClient.shared.releaseNotification(token: id) + clearDraft(for: id) + } + withAnimation(.smooth) { activeNotification = nil } + + // Anything that queued up behind a reply gets its turn now. Skipped + // while still composing — the reply field can be focused for a beat + // after a send, and promoting there would replace the "sent" + // confirmation before it's been seen. + if !isComposingReply { promoteNextQueued() } + } + + /// Keeps the notification up for as long as the reply field is being + /// typed into. Same behaviour as `holdActive` now — kept as a separate + /// name because the call sites mean different things. + func holdWhileTyping() { + holdActive() + } + + /// Holds the notification indefinitely while the notch is open. No time + /// cap: it stays until the notch closes or a newer notification + /// replaces it. + /// + /// This previously capped at `maxLifetime` to stop an abandoned open + /// notch pinning a stale message. That cap is unnecessary now that + /// closing the notch clears the notification outright (see + /// `resumeDismiss`) — the notch closing is what bounds it, so a stale + /// one can't survive to be seen later regardless of how long it was + /// held. + func holdActive() { + dismissTask?.cancel() + dismissTask = nil + } + + /// Restarts the dismiss countdown once the user stops interacting — + /// Clears the notification shortly after the notch closes. This is what + /// bounds the otherwise-indefinite `holdActive`, so it must always + /// schedule — a bail-if-busy check here would let an indefinite hold + /// survive the notch closing and strand the notification. + /// + /// The short delay exists so briefly clipping the notch edge on the way + /// past doesn't destroy a notification the user was mid-way through + /// reading. + func resumeDismiss(after delay: TimeInterval = 3) { + guard let active = activeNotification else { return } + dismissTask?.cancel() + dismissTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled else { return } + await MainActor.run { self?.dismissActive(token: active.id) } + } + } + + private func markExpired(_ token: String) { + if let index = notifications.firstIndex(where: { $0.id == token }) { + notifications[index].isLive = false + } + if activeNotification?.id == token { + activeNotification?.isLive = false + } + } + + func clear() { + notifications.removeAll() + releaseQueued() + dismissActive() + } + + // MARK: - Acting + + enum ReplyOutcome { + /// Delivered through the live banner's reply field. + case sent + /// The banner was gone, so the draft went to the clipboard and the + /// app was opened for the user to paste. + case handedOffToApp + /// Opened the conversation with the text already in its compose + /// box. Better than the clipboard, but still not sent — the user + /// presses send themselves. + case draftedInApp + /// The reply couldn't be delivered in time. The draft is preserved + /// and surfaced as an error so the user can retry or handle it + /// themselves — never silently stuck on "sending". + case failed + } + + /// iMessage/AX delivery attempts can stall on an unresponsive Messages + /// process or a wedged XPC reply; race them so the UI always resolves. + /// Notifications-banner replies are nearly free; iMessage scripting is + /// expensive (Messages.app cold-launch can take seconds), so each stage + /// gets its own budget instead of one shared race. + private let bannerReplyTimeout: TimeInterval = 2.0 + private let imessageScriptTimeout: TimeInterval = 4.0 + + /// Sends an inline reply. + /// + /// Replying types into the notification's own reply field via + /// Accessibility, so it depends on that element still being reachable. + /// The helper retains elements after their banner fades (the + /// notification lives on in Notification Center), which is what makes + /// replying work beyond the ~5s banner. If it genuinely can't be + /// reached, hand off rather than dropping a typed message: the draft + /// goes to the clipboard and the app opens so it's one paste away. + @discardableResult + func reply(to notification: SystemNotification, text: String) async -> ReplyOutcome { + await performReply(to: notification, text: text) + } + + /// Races an operation against a timeout; whichever resolves first wins. + private func raceTimeout( + seconds: TimeInterval, fallback: T, + operation: @escaping @Sendable () async -> T + ) async -> T { + await withTaskGroup(of: T.self) { group in + group.addTask { await operation() } + group.addTask { + try? await Task.sleep(for: .seconds(seconds)) + return fallback + } + let result = await group.next() ?? fallback + group.cancelAll() + return result + } + } + + private func performReply(to notification: SystemNotification, text: String) async -> ReplyOutcome { + // Stage 1 — the banner's own reply field (native, cheap). nil means + // the attempt stalled: do NOT cross-deliver through another channel + // — the message may already have gone through. + let bannerDelivered: Bool? = await raceTimeout(seconds: bannerReplyTimeout, fallback: nil) { + await XPCHelperClient.shared.replyToNotification(token: notification.id, text: text) + } + if bannerDelivered == true { + NSLog("[boringNotch] reply sent via AX for \(notification.appName ?? "-")") + playSentSound() + dismissActive(token: notification.id) + return .sent + } + if bannerDelivered == nil { + NSLog("[boringNotch] reply attempt timed out at the banner for \(notification.appName ?? "-")") + return .failed + } + + // The banner is gone, so AX can't deliver. Messages is the one + // supported app that can still be sent to properly — it has a real + // scripting dictionary, so the reply goes out for real instead of + // becoming a clipboard hand-off. Nothing equivalent exists for + // WhatsApp/Telegram/Discord. + if notification.bundleID == "com.apple.MobileSMS", + let chatName = notification.sender, + await raceTimeout(seconds: imessageScriptTimeout, fallback: false, operation: { + await XPCHelperClient.shared.sendIMessage(text, toChatNamed: chatName) + }) { + NSLog("[boringNotch] reply sent via Messages scripting for \(chatName)") + playSentSound() + dismissActive(token: notification.id) + return .sent + } + + // iMessage delivery definitively failed — surface the error, keep + // the user's draft, and let them handle it directly in Messages + // (no silent clipboard gymnastics). + if notification.bundleID == "com.apple.MobileSMS" { + return .failed + } + + // WhatsApp exposes no scripting interface, but its URL scheme can + // open a specific conversation with text pre-filled — far better + // than the clipboard, which leaves the user to find the chat and + // paste. Needs a phone number, which only comes from Contacts and + // only when it resolves unambiguously (see phoneNumber(for:)). + if notification.bundleID == "net.whatsapp.WhatsApp", + let sender = notification.sender, + let phone = ContactAvatarManager.shared.phoneNumber(forContactNamed: sender), + let encoded = text.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + // whatsapp:// rather than wa.me — the scheme is registered to + // WhatsApp.app directly, so it opens the app instead of bouncing + // the message text through a browser. + let url = URL(string: "whatsapp://send?phone=\(phone)&text=\(encoded)") { + NSLog("[boringNotch] reply drafted in WhatsApp conversation for \(sender)") + NSWorkspace.shared.open(url) + playHandOffSound() + dismissActive(token: notification.id) + return .draftedInApp + } + + NSLog("[boringNotch] reply could not be delivered (banner gone) — handing off to \(notification.appName ?? "app")") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + playHandOffSound() + await open(notification) + dismissActive(token: notification.id) + return .handedOffToApp + } + + /// Only on a real send. A "sent" sound when nothing was sent is a lie + /// the user can't see through — they'd find out when the reply never + /// arrived. + private func playSentSound() { + NSSound(named: "Tink")?.play() + } + + /// Distinct from the sent sound on purpose: there's still feedback that + /// the click registered, but it must not read as a delivery. The orange + /// clipboard glyph on the button carries the actual meaning. + private func playHandOffSound() { + NSSound(named: "Pop")?.play() + } + + func perform(_ action: String, on notification: SystemNotification) async -> Bool { + await XPCHelperClient.shared.performNotificationAction(token: notification.id, name: action) + } + + /// Opens the notification in its source app; falls back to launching the + /// app once the banner is gone. + @discardableResult + func open(_ notification: SystemNotification) async -> Bool { + if await XPCHelperClient.shared.openNotification(token: notification.id) { return true } + guard let bundleID = notification.bundleID, + let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) + else { return false } + return (try? await NSWorkspace.shared.openApplication(at: url, configuration: .init())) != nil + } + + func debugDump() async -> String { + await XPCHelperClient.shared.notificationDebugDump() + } +} diff --git a/boringNotch/managers/WebcamManager.swift b/boringNotch/managers/WebcamManager.swift index ca422a1d8..d0a4e0052 100644 --- a/boringNotch/managers/WebcamManager.swift +++ b/boringNotch/managers/WebcamManager.swift @@ -8,45 +8,21 @@ import AVFoundation import Defaults import SwiftUI -class WebcamManager: NSObject, ObservableObject { +final class WebcamManager: NSObject, ObservableObject { static let shared = WebcamManager() - @Published var previewLayer: AVCaptureVideoPreviewLayer? { - didSet { - objectWillChange.send() - } - } + @Published var previewLayer: AVCaptureVideoPreviewLayer? private var captureSession: AVCaptureSession? - @Published var isSessionRunning: Bool = false { - didSet { - objectWillChange.send() - } - } + @Published var isSessionRunning: Bool = false - @Published var authorizationStatus: AVAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) { - didSet { - objectWillChange.send() - } - } + @Published var authorizationStatus: AVAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) - @Published var cameraAvailable: Bool = false { - didSet { - objectWillChange.send() - } - } + @Published var cameraAvailable: Bool = false - @Published var availableCameras: [AVCaptureDevice] = [] { - didSet { - objectWillChange.send() - } - } + @Published var availableCameras: [AVCaptureDevice] = [] - @Published var selectedCameraID: String? { - didSet { - objectWillChange.send() - } - } + @Published var selectedCameraID: String? private let sessionQueue = DispatchQueue(label: "BoringNotch.WebcamManager.SessionQueue", qos: .userInitiated) diff --git a/boringNotch/menu/StatusBarMenu.swift b/boringNotch/menu/StatusBarMenu.swift deleted file mode 100644 index 2bcbae2e0..000000000 --- a/boringNotch/menu/StatusBarMenu.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Cocoa - -class BoringStatusMenu: NSMenu { - - var statusItem: NSStatusItem! - - override init() { - super.init() - - // Initialize the status item - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - - if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "music.note", accessibilityDescription: "BoringNotch") - button.action = #selector(showMenu) - } - - // Set up the menu - let menu = NSMenu() - menu.addItem(NSMenuItem(title: "Quit", action: #selector(quitAction), keyEquivalent: "q")) - statusItem.menu = menu - } - -} diff --git a/boringNotch/models/BatteryStatusViewModel.swift b/boringNotch/models/BatteryStatusViewModel.swift index 4628bd55b..877f5b0e5 100644 --- a/boringNotch/models/BatteryStatusViewModel.swift +++ b/boringNotch/models/BatteryStatusViewModel.swift @@ -5,14 +5,12 @@ import IOKit.ps import SwiftUI /// A view model that manages and monitors the battery status of the device -class BatteryStatusViewModel: ObservableObject { +final class BatteryStatusViewModel: ObservableObject { private var wasCharging: Bool = false private var powerSourceChangedCallback: IOPowerSourceCallbackType? private var runLoopSource: Unmanaged? - @ObservedObject var coordinator = BoringViewCoordinator.shared - @Published private(set) var levelBattery: Float = 0.0 @Published private(set) var maxCapacity: Float? @Published private(set) var isPluggedIn: Bool = false @@ -79,7 +77,7 @@ class BatteryStatusViewModel: ObservableObject { private func handleBatteryEvent(_ event: BatteryActivityManager.BatteryEvent) { switch event { case .powerSourceChanged(let isPluggedIn): - print("🔌 Power source: \(isPluggedIn ? "Connected" : "Disconnected")") + Log.battery.debug("🔌 Power source: \(isPluggedIn ? "Connected" : "Disconnected")") withAnimation { self.isPluggedIn = isPluggedIn // remember the last battery-related message so the computed @@ -89,13 +87,13 @@ class BatteryStatusViewModel: ObservableObject { } case .batteryLevelChanged(let level): - print("🔋 Battery level: \(Int(level))%") + Log.battery.debug("🔋 Battery level: \(Int(level))%") withAnimation { self.levelBattery = level } case .lowPowerModeChanged(let isEnabled): - print("⚡ Low power mode: \(isEnabled ? "Enabled" : "Disabled")") + Log.battery.debug("⚡ Low power mode: \(isEnabled ? "Enabled" : "Disabled")") self.notifyImportanChangeStatus() withAnimation { self.isInLowPowerMode = isEnabled @@ -103,9 +101,9 @@ class BatteryStatusViewModel: ObservableObject { } case .isChargingChanged(let isCharging): - print("🔌 Charging: \(isCharging ? "Yes" : "No")") - print("maxCapacity: \(self.maxCapacity.map { "\($0)" } ?? "Unavailable")") - print("levelBattery: \(self.levelBattery)") + Log.battery.debug("🔌 Charging: \(isCharging ? "Yes" : "No")") + Log.battery.debug("maxCapacity: \(self.maxCapacity.map { "\($0)" } ?? "Unavailable")") + Log.battery.debug("levelBattery: \(self.levelBattery)") self.notifyImportanChangeStatus() withAnimation { self.isCharging = isCharging @@ -113,31 +111,31 @@ class BatteryStatusViewModel: ObservableObject { } case .timeToFullChargeChanged(let time): - print("🕒 Time to full charge: \(time) minutes") + Log.battery.debug("🕒 Time to full charge: \(time) minutes") withAnimation { self.timeToFullCharge = time } case .timeToDischargeChanged(let time): - print("🕒 Time until empty: \(time) minutes") + Log.battery.debug("🕒 Time until empty: \(time) minutes") withAnimation { self.timeToDischarge = time } case .maxCapacityChanged(let capacity): - print("🔋 Max capacity: \(capacity.map { "\($0)" } ?? "Unavailable")") + Log.battery.debug("🔋 Max capacity: \(capacity.map { "\($0)" } ?? "Unavailable")") withAnimation { self.maxCapacity = capacity } case .adapterWattageChanged(let watts): - print("🔌 Power adapter: \(watts)W") + Log.battery.debug("🔌 Power adapter: \(watts)W") withAnimation { self.maxAdapterWatts = watts } case .error(let description): - print("⚠️ Error: \(description)") + Log.battery.error("⚠️ Error: \(description)") } } @@ -160,14 +158,14 @@ class BatteryStatusViewModel: ObservableObject { /// Notifies important changes in the battery status with an optional delay /// - Parameter delay: The delay before notifying the change, default is 0.0 private func notifyImportanChangeStatus(delay: Double = 0.0) { - Task { + Task { [delay] in try? await Task.sleep(for: .seconds(delay)) - self.coordinator.toggleExpandingView(status: true, type: .battery) + NotchUIEventBus.events.send(.expandingView(type: .battery)) } } deinit { - print("🔌 Cleaning up battery monitoring...") + Log.battery.debug("🔌 Cleaning up battery monitoring...") if let managerBatteryId: Int = managerBatteryId { managerBattery.removeObserver(byId: managerBatteryId) } diff --git a/boringNotch/models/BoringViewModel.swift b/boringNotch/models/BoringViewModel.swift index 77d5366d8..115c68d84 100644 --- a/boringNotch/models/BoringViewModel.swift +++ b/boringNotch/models/BoringViewModel.swift @@ -9,7 +9,7 @@ import Combine import Defaults import SwiftUI -class BoringViewModel: NSObject, ObservableObject { +final class BoringViewModel: NSObject, ObservableObject { @ObservedObject var coordinator = BoringViewCoordinator.shared @ObservedObject var detector = FullscreenMediaDetector.shared diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index fc2847421..6cf32ebbc 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -9,14 +9,13 @@ import SwiftUI import Defaults // MARK: - File System Paths -private let availableDirectories = FileManager - .default - .urls(for: .documentDirectory, in: .userDomainMask) -let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! -let bundleIdentifier = Bundle.main.bundleIdentifier! +let documentsDirectory: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory()) +let bundleIdentifier: String = Bundle.main.bundleIdentifier ?? "theboringteam.boringnotch" let appVersion = "\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""))" -let temporaryDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! +let temporaryDirectory: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory()) let spacing: CGFloat = 16 enum CalendarSelectionState: Codable, Defaults.Serializable { @@ -293,6 +292,36 @@ extension Defaults.Keys { // MARK: OSD static let osdReplacement = Key("osdReplacement", default: false) static let inlineOSD = Key("inlineOSD", default: false) + + // MARK: Layout + /// Swaps the opened notch for a smaller, player-only layout: no tab + /// bar, calendar or mirror. Off by default so existing users keep the + /// layout they already have. + static let compactMode = Key("compactMode", default: false) + + // MARK: Notifications + /// Off by default: mirroring banners needs Accessibility access. + static let notificationLiveActivity = Key("notificationLiveActivity", default: false) + static let notificationsFromAllApps = Key("notificationsFromAllApps", default: false) + static let notificationAllowedApps = Key>( + "notificationAllowedApps", + default: [ + "com.apple.MobileSMS", // Messages + "com.apple.FaceTime", + "com.apple.mail", + "com.microsoft.Outlook", + "net.whatsapp.WhatsApp", + "ru.keepcoder.Telegram", // Telegram Desktop (App Store build) + "com.tdesktop.Telegram", + "com.hnc.Discord", + "com.anthropic.claudefordesktop" + ] + ) + /// Off by default: a new capability, even though it runs entirely + /// on-device with no network calls. Only takes effect on macOS 26+ with + /// Apple Intelligence enabled — see SmartReplyManager. + static let smartRepliesEnabled = Key("smartRepliesEnabled", default: false) + static let enableGradient = Key("enableGradient", default: false) static let systemEventIndicatorShadow = Key("systemEventIndicatorShadow", default: false) static let systemEventIndicatorUseAccent = Key("systemEventIndicatorUseAccent", default: false) @@ -339,9 +368,12 @@ extension Defaults.Keys { // Normalize scroll/gesture direction so when macOS "Natural scrolling" is disabled, it doesn't invert gestures static let normalizeGestureDirection = Key("normalizeGestureDirection", default: true) - // Helper to determine the default media controller based on NowPlaying deprecation status + // Helper to determine the default media controller based on NowPlaying deprecation status. + // Reads ONLY persisted probe data — never a singleton. Defaults key defaults are + // evaluated once, potentially before managers exist; the probe result is owned + // and refreshed by MediaEnvironment (see helpers/MediaEnvironment.swift). static var defaultMediaController: MediaControllerType { - if MusicManager.shared.isNowPlayingDeprecated { + if UserDefaults.standard.bool(forKey: MediaEnvironment.persistenceKey) { return .appleMusic } else { return .nowPlaying diff --git a/boringNotch/models/MusicControlButton.swift b/boringNotch/models/MusicControlButton.swift index 430ea6430..2547c1040 100644 --- a/boringNotch/models/MusicControlButton.swift +++ b/boringNotch/models/MusicControlButton.swift @@ -17,16 +17,20 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S case favorite case goBackward case goForward + case mediaOutput case none var id: String { rawValue } + /// Shuffle and media output round out the default row. The previous + /// default left two empty slots, so a fresh install showed only three + /// transport buttons with dead space either side. static let defaultLayout: [MusicControlButton] = [ - .none, + .shuffle, .previous, .playPause, .next, - .none + .mediaOutput ] static let minSlotCount: Int = 3 @@ -41,7 +45,8 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S .favorite, .volume, .goBackward, - .goForward + .goForward, + .mediaOutput ] var label: String { @@ -64,6 +69,8 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S return "Backward 15s" case .goForward: return "Forward 15s" + case .mediaOutput: + return "Audio output" case .none: return "Empty slot" } @@ -89,6 +96,10 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S return "gobackward.15" case .goForward: return "goforward.15" + case .mediaOutput: + // Placeholder for the settings picker; the live button swaps in + // the actual route's glyph (laptop / headphones / AirPods). + return "laptopcomputer" case .none: return "" } diff --git a/boringNotch/models/NotchUIEvent.swift b/boringNotch/models/NotchUIEvent.swift new file mode 100644 index 000000000..d63cf885f --- /dev/null +++ b/boringNotch/models/NotchUIEvent.swift @@ -0,0 +1,36 @@ +// +// NotchUIEvent.swift +// boringNotch +// +// SPDX-License-Identifier: GPL-3.0-only +// +// Created as part of the architecture remediation. +// + +import SwiftUI +import Combine + +/// UI-presentation events emitted by hardware/OS-facing managers. +/// +/// Inverts the old "manager calls `BoringViewCoordinator.shared`" direction: +/// the coordinator also *configures* those same managers (applyOSDSources), +/// so direct calls created a dependency cycle. Managers now publish events; +/// the coordinator is the single subscriber and decides what to present. +/// Presentation policy (e.g. `Defaults[.osdReplacement]`) lives on the +/// presenter side, and managers stay testable without the UI stack. +enum NotchUIEvent { + case sneakPeek( + type: SneakContentType, + value: CGFloat, + icon: String = "", + accent: Color? = nil, + targetScreenUUID: String? = nil, + duration: TimeInterval = 1.5 + ) + case expandingView(type: SneakContentType) +} + +/// The event pipe. `BoringViewCoordinator` is the intended subscriber. +enum NotchUIEventBus { + static let events = PassthroughSubject() +} diff --git a/boringNotch/observers/MediaKeyInterceptor.swift b/boringNotch/observers/MediaKeyInterceptor.swift index c75c7d7c1..940c669d7 100644 --- a/boringNotch/observers/MediaKeyInterceptor.swift +++ b/boringNotch/observers/MediaKeyInterceptor.swift @@ -105,7 +105,7 @@ final class MediaKeyInterceptor { } CGEvent.tapEnable(tap: eventTap, enable: true) } else { - print("⚠️ [MediaKeyInterceptor] Failed to create media-key event tap") + Log.osd.error("⚠️ [MediaKeyInterceptor] Failed to create media-key event tap") } } @@ -136,7 +136,7 @@ final class MediaKeyInterceptor { reason = "unknown reason" } - print("ℹ️ [MediaKeyInterceptor] Re-enabled media-key event tap after \(reason)") + Log.osd.debug("ℹ️ [MediaKeyInterceptor] Re-enabled media-key event tap after \(reason)") } // MARK: - Event Handling @@ -234,12 +234,12 @@ final class MediaKeyInterceptor { if FileManager.default.fileExists(atPath: defaultPath) { do { audioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: defaultPath)) - print("🔊 [MediaKeyInterceptor] Loaded default Bezel audio from: \(defaultPath)") + Log.osd.debug("🔊 [MediaKeyInterceptor] Loaded default Bezel audio from: \(defaultPath)") } catch { - print("⚠️ [MediaKeyInterceptor] Failed to init AVAudioPlayer with default path \(defaultPath): \(error.localizedDescription)") + Log.osd.error("⚠️ [MediaKeyInterceptor] Failed to init AVAudioPlayer with default path \(defaultPath): \(error.localizedDescription)") } } else { - print("⚠️ [MediaKeyInterceptor] Default bezel audio not found at: \(defaultPath)") + Log.osd.error("⚠️ [MediaKeyInterceptor] Default bezel audio not found at: \(defaultPath)") } if let player = audioPlayer { @@ -250,18 +250,23 @@ final class MediaKeyInterceptor { } private func playFeedbackSound() { - guard let feedback = UserDefaults.standard.persistentDomain(forName: "NSGlobalDomain")?["com.apple.sound.beep.feedback"] as? Int, - feedback == 1 else { return } + // Single-key lookup — persistentDomain(forName:) materialized the + // entire NSGlobalDomain on every volume key press. + let feedback = CFPreferencesCopyAppValue( + "com.apple.sound.beep.feedback" as CFString, + kCFPreferencesAnyApplication + ) as? Int + guard feedback == 1 else { return } prepareAudioPlayerIfNeeded() guard let player = audioPlayer else { - print("⚠️ [MediaKeyInterceptor] No audio player available to play feedback sound") + Log.osd.error("⚠️ [MediaKeyInterceptor] No audio player available to play feedback sound") return } if let url = player.url { - print("🔊 [MediaKeyInterceptor] Playing feedback sound from: \(url.path)") + Log.osd.debug("🔊 [MediaKeyInterceptor] Playing feedback sound from: \(url.path)") } else { - print("🔊 [MediaKeyInterceptor] Playing feedback sound (no url available for AVAudioPlayer)") + Log.osd.debug("🔊 [MediaKeyInterceptor] Playing feedback sound (no url available for AVAudioPlayer)") } if player.isPlaying { player.stop() @@ -319,10 +324,8 @@ final class MediaKeyInterceptor { BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .backlight, value: CGFloat(v)) } else { let v = BrightnessManager.shared.rawBrightness - Task { @MainActor in - let target = await BrightnessManager.shared.brightnessTargetUUID() - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .brightness, value: CGFloat(v), targetScreenUUID: target) - } + let target = await BrightnessManager.shared.brightnessTargetUUID() + BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .brightness, value: CGFloat(v), targetScreenUUID: target) } case .keyboardBrightnessUp, .keyboardBrightnessDown: let v = KeyboardBacklightManager.shared.rawBrightness diff --git a/boringNotch/sizing/matters.swift b/boringNotch/sizing/matters.swift index a94048c53..8e6aed2f8 100644 --- a/boringNotch/sizing/matters.swift +++ b/boringNotch/sizing/matters.swift @@ -1,5 +1,5 @@ // -// sizeMatters.swift +// matters.swift // boringNotch // // Created by Harsh Vardhan Goswami on 05/08/24. @@ -9,14 +9,18 @@ import Defaults import Foundation import SwiftUI -let downloadSneakSize: CGSize = .init(width: 65, height: 1) -let batterySneakSize: CGSize = .init(width: 160, height: 1) - let shadowPadding: CGFloat = 20 let openNotchSize: CGSize = .init(width: 640, height: 190) let windowSize: CGSize = .init(width: openNotchSize.width, height: openNotchSize.height + shadowPadding) let cornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 19, bottom: 24), closed: (top: 6, bottom: 14)) +/// Compact mode uses a much rounder opened shape than the standard layout +/// — matching Atoll's minimalisticCornerRadiusInsets (35/35 against the +/// standard 19/24). At compact's smaller size the standard radius reads +/// square; the rounder corners are what make it look like a pill rather +/// than a shrunken panel. +let compactCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 35, bottom: 35), closed: cornerRadiusInsets.closed) + // Horizontal gap between closed-state live-activity content (album art / waveform) // and the physical notch edge. Without this margin the hardware bezel clips the // adjacent content since the spacer rect used to be narrower than the physical notch. @@ -88,6 +92,15 @@ enum MusicPlayerImageSizes { @MainActor func syncNotchHeightIfNeeded() { var didChangeHeight = false + // "Match real notch height" isn't a valid choice for a non-notch display + // — there's no real notch to match — so it's not offered in that + // Picker. A value here can only be leftover from an older build that + // allowed it; fall back to the sensible default rather than leaving a + // persisted value with no matching Picker tag. + if Defaults[.nonNotchHeightMode] == .matchRealNotchSize { + Defaults[.nonNotchHeightMode] = .matchMenuBar + } + switch Defaults[.notchHeightMode] { case .matchRealNotchSize: let realHeight = getRealNotchHeight() diff --git a/boringNotch/utils/Logger.swift b/boringNotch/utils/Logger.swift deleted file mode 100644 index e95cdbadf..000000000 --- a/boringNotch/utils/Logger.swift +++ /dev/null @@ -1,77 +0,0 @@ -import Foundation -import SwiftUI - -enum LogCategory: String { - case lifecycle = "🔄" - case memory = "💾" - case performance = "⚡️" - case ui = "🎨" - case network = "🌐" - case error = "❌" - case warning = "⚠️" - case success = "✅" - case debug = "🔍" -} - -struct Logger { - static func log( - _ message: String, - category: LogCategory, - file: String = #file, - function: String = #function, - line: Int = #line - ) { - let fileName = (file as NSString).lastPathComponent - let timestamp = ISO8601DateFormatter().string(from: Date()) - print("\(category.rawValue) [\(timestamp)] [\(fileName):\(line)] \(function) - \(message)") - } - - static func trackMemory( - file: String = #file, - function: String = #function, - line: Int = #line - ) { - var info = mach_task_basic_info() - var count = mach_msg_type_number_t(MemoryLayout.size)/4 - - let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { - $0.withMemoryRebound(to: integer_t.self, capacity: 1) { - task_info(mach_task_self_, - task_flavor_t(MACH_TASK_BASIC_INFO), - $0, - &count) - } - } - - if kerr == KERN_SUCCESS { - let usedMB = Double(info.resident_size) / 1024.0 / 1024.0 - log(String(format: "Memory used: %.2f MB", usedMB), - category: .memory, - file: file, - function: function, - line: line) - } - } -} - -extension View { - func trackLifecycle(_ identifier: String) -> some View { - self.modifier(ViewLifecycleTracker(identifier: identifier)) - } -} - -struct ViewLifecycleTracker: ViewModifier { - let identifier: String - - func body(content: Content) -> some View { - content - .onAppear { - Logger.log("\(identifier) appeared", category: .lifecycle) - Logger.trackMemory() - } - .onDisappear { - Logger.log("\(identifier) disappeared", category: .lifecycle) - Logger.trackMemory() - } - } -} \ No newline at end of file diff --git a/boringNotchTests/BundleIDResolverTests.swift b/boringNotchTests/BundleIDResolverTests.swift new file mode 100644 index 000000000..cd804e8c3 --- /dev/null +++ b/boringNotchTests/BundleIDResolverTests.swift @@ -0,0 +1,138 @@ +// +// BundleIDResolverTests.swift +// boringNotch +// +// SPDX-License-Identifier: GPL-3.0-only +// +// Covers the app-side bundle-ID resolver: normalization parity with the +// helper, the direct-probe and directory-scan fallbacks against an injected +// fixture directory, and memoization of hits and misses. +// + +import XCTest +@testable import boringNotch + +final class BundleIDResolverTests: XCTestCase { + + private var resolver: BundleIDResolver! + private var fixtureRoot: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + resolver = BundleIDResolver() + fixtureRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("BundleIDResolverTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: fixtureRoot, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: fixtureRoot) + fixtureRoot = nil + resolver = nil + try super.tearDownWithError() + } + + /// Builds a minimal `.app` — just `Contents/Info.plist` with a bundle ID, + /// optionally a display name — inside the fixture root. + @discardableResult + private func makeFixtureApp(named name: String, bundleID: String, displayName: String? = nil) throws -> URL { + let appURL = fixtureRoot.appendingPathComponent(name).appendingPathExtension("app") + let contents = appURL.appendingPathComponent("Contents", isDirectory: true) + try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + var info: [String: Any] = ["CFBundleIdentifier": bundleID] + if let displayName { info["CFBundleDisplayName"] = displayName } + let data = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + try data.write(to: contents.appendingPathComponent("Info.plist")) + return appURL + } + + // MARK: - Normalization + + func testNormalizationStripsBidiMarksCaseAndWhitespace() { + XCTAssertEqual(BundleIDResolver.normalizedAppName("WhatsApp"), "whatsapp") + XCTAssertEqual(BundleIDResolver.normalizedAppName("whatsapp"), "whatsapp") + // WhatsApp's localizedName really is "\u{200E}WhatsApp" (LRM prefix). + XCTAssertEqual(BundleIDResolver.normalizedAppName("\u{200E}WhatsApp"), "whatsapp") + XCTAssertEqual(BundleIDResolver.normalizedAppName(" \u{202A}WhatsApp\u{202C}\u{2068} "), "whatsapp") + XCTAssertEqual( + BundleIDResolver.normalizedAppName("WhatsApp"), + BundleIDResolver.normalizedAppName("\u{200E}whatsapp") + ) + } + + // MARK: - Direct probe + + /// `/.app` exists: the probe must hit before any directory + /// listing is needed. + func testDirectProbeFindsFixture() throws { + let name = "ProbeFixture\(UUID().uuidString.prefix(8))" + let bundleID = "com.test.probe.\(name)" + try makeFixtureApp(named: name, bundleID: bundleID) + + XCTAssertEqual(resolver.bundleID(forAppNamed: name, searchDirectories: [fixtureRoot]), bundleID) + } + + // MARK: - Directory scan + + /// The .app's filename doesn't match the queried name, so only the scan's + /// CFBundleDisplayName comparison can find it. + func testDirectoryScanMatchesDisplayName() throws { + let tag = UUID().uuidString.prefix(8) + let displayName = "Scan Fixture \(tag)" + let bundleID = "com.test.scan.\(tag)" + try makeFixtureApp(named: "scanfixture-\(tag)", bundleID: bundleID, displayName: displayName) + + XCTAssertEqual(resolver.bundleID(forAppNamed: displayName, searchDirectories: [fixtureRoot]), bundleID) + } + + /// Filename match through normalization: lowercase query with a bidi mark + /// must still match the capitalized .app name. + func testDirectoryScanMatchesFilenameIgnoringCaseAndBidiMarks() throws { + let name = "BidiFixture\(UUID().uuidString.prefix(8))" + let bundleID = "com.test.bidi.\(name)" + try makeFixtureApp(named: name, bundleID: bundleID) + + XCTAssertEqual( + resolver.bundleID(forAppNamed: "\u{200E}\(name.lowercased())", searchDirectories: [fixtureRoot]), + bundleID + ) + } + + // MARK: - Misses + + func testNoMatchReturnsNil() { + let name = "DefinitelyNotARealApp-\(UUID().uuidString)" + XCTAssertNil(resolver.bundleID(forAppNamed: name, searchDirectories: [fixtureRoot])) + } + + func testEmptyAndWhitespaceNamesReturnNil() { + XCTAssertNil(resolver.bundleID(forAppNamed: "")) + XCTAssertNil(resolver.bundleID(forAppNamed: " ")) + XCTAssertNil(resolver.bundleID(forAppNamed: "\u{200E}")) + } + + // MARK: - Cache + + /// After the first (disk) resolution, deleting the fixture must not change + /// the answer — proof the second call never touches the directory again. + func testSecondResolutionComesFromCache() throws { + let name = "CacheFixture\(UUID().uuidString.prefix(8))" + let bundleID = "com.test.cache.\(name)" + let appURL = try makeFixtureApp(named: name, bundleID: bundleID) + + XCTAssertEqual(resolver.bundleID(forAppNamed: name, searchDirectories: [fixtureRoot]), bundleID) + + try FileManager.default.removeItem(at: appURL) + XCTAssertEqual(resolver.bundleID(forAppNamed: name, searchDirectories: [fixtureRoot]), bundleID) + } + + /// Negative results are cached too: a miss followed by the app appearing + /// on disk must still answer the cached miss rather than rescanning. + func testNegativeResultIsCached() throws { + let name = "LateFixture\(UUID().uuidString.prefix(8))" + XCTAssertNil(resolver.bundleID(forAppNamed: name, searchDirectories: [fixtureRoot])) + + try makeFixtureApp(named: name, bundleID: "com.test.late.\(name)") + XCTAssertNil(resolver.bundleID(forAppNamed: name, searchDirectories: [fixtureRoot])) + } +} diff --git a/boringNotchTests/NotchUIEventTests.swift b/boringNotchTests/NotchUIEventTests.swift new file mode 100644 index 000000000..6e38b8320 --- /dev/null +++ b/boringNotchTests/NotchUIEventTests.swift @@ -0,0 +1,147 @@ +// +// NotchUIEventTests.swift +// boringNotch +// +// SPDX-License-Identifier: GPL-3.0-only +// +// Smoke tests for the seams introduced during the architecture remediation. +// + +import XCTest +import Combine +import SwiftUI +@testable import boringNotch + +final class NotchUIEventTests: XCTestCase { + + private var cancellables: Set = [] + + override func tearDown() { + cancellables.removeAll() + super.tearDown() + } + + /// Managers publish; the presenter subscribes — the seam that replaced + /// direct manager->coordinator calls. This is the contract. + func testSneakPeekEventDeliversPayload() { + let expectation = expectation(description: "event delivered") + NotchUIEventBus.events + .sink { event in + guard case .sneakPeek(let type, let value, let icon, _, let uuid, _) = event else { + XCTFail("unexpected event") + return + } + XCTAssertEqual(type, .volume) + XCTAssertEqual(value, 0.5, accuracy: 0.0001) + XCTAssertEqual(icon, "speaker.wave.2") + XCTAssertNil(uuid) + expectation.fulfill() + } + .store(in: &cancellables) + + NotchUIEventBus.events.send(.sneakPeek(type: .volume, value: 0.5, icon: "speaker.wave.2")) + waitForExpectations(timeout: 1.0) + } + + func testExpandingViewEventDeliversType() { + let expectation = expectation(description: "expanding event delivered") + NotchUIEventBus.events + .sink { event in + guard case .expandingView(let type) = event else { + XCTFail("unexpected event") + return + } + XCTAssertEqual(type, .battery) + expectation.fulfill() + } + .store(in: &cancellables) + + NotchUIEventBus.events.send(.expandingView(type: .battery)) + waitForExpectations(timeout: 1.0) + } +} + +final class MediaAppBundleIDTests: XCTestCase { + func testBundleIDsAreDistinctAndWellFormed() { + let ids = [ + MediaAppBundleID.appleMusic, + MediaAppBundleID.spotify, + MediaAppBundleID.youTubeMusic, + ] + XCTAssertEqual(ids.count, Set(ids).count, "bundle IDs must be unique") + for id in ids { + XCTAssertFalse(id.isEmpty) + XCTAssertTrue(id.contains("."), "malformed bundle id: \(id)") + } + } +} + +final class PlaybackStateTests: XCTestCase { + func testEquatableIgnoresVolatileFields() { + var a = PlaybackState(bundleIdentifier: MediaAppBundleID.spotify, isPlaying: true) + a.title = "Track" + a.currentTime = 42 + + var b = a + // Volatile/monitor-side fields that must not defeat `==`: + // volume, playbackRate and lastUpdated are intentionally excluded. + b.volume = 0.9 + b.playbackRate = 0.75 + b.lastUpdated = Date() + + XCTAssertEqual(a, b) + } + + func testEquatableTracksUserVisibleFields() { + var a = PlaybackState(bundleIdentifier: MediaAppBundleID.spotify, isPlaying: true) + a.title = "Track" + + var b = a + b.isFavorite = true + XCTAssertNotEqual(a, b, "isFavorite is user-visible and must be compared") + + b = a + b.currentTime += 1 + XCTAssertNotEqual(a, b, "position changes must be visible to == to drive UI updates") + } +} + +@MainActor final class ExpandedViewPixelTests: XCTestCase { + + /// Renders NotificationExpandedView offscreen and counts non-black + /// pixels. Guards the reported "open notch is empty with a notification" + /// regression. + func testNotificationExpandedViewPaintsContent() throws { + let notification = SystemNotification( + id: "test-token", appName: "WhatsApp", bundleID: "net.whatsapp.WhatsApp", + title: "Sender", subtitle: nil, body: "hello", actions: [], + receivedAt: Date()) + + let view = NotificationExpandedView(notification: notification) + .environmentObject(BoringViewModel()) + .frame(width: 380, height: 132) + + let hostingView = NSHostingView(rootView: view) + hostingView.frame = CGRect(x: 0, y: 0, width: 380, height: 132) + hostingView.layoutSubtreeIfNeeded() + + guard let rep = hostingView.bitmapImageRepForCachingDisplay(in: hostingView.bounds) else { + XCTFail("could not create bitmap rep") + return + } + hostingView.cacheDisplay(in: hostingView.bounds, to: rep) + + guard let data = rep.bitmapData else { + XCTFail("no bitmap data") + return + } + var nonBlack = 0 + let bytes = rep.pixelsWide * rep.pixelsHigh * max(1, rep.samplesPerPixel) + for i in stride(from: 0, to: bytes, by: 4 * 40) { // sample every ~10th px + if data[i] > 30 || data[i + 1] > 30 || data[i + 2] > 30 { + nonBlack += 1 + } + } + XCTAssertGreaterThan(nonBlack, 20, "expanded view rendered ~empty") + } +} diff --git a/mediaremote-adapter/README.md b/mediaremote-adapter/README.md new file mode 100644 index 000000000..6223447f8 --- /dev/null +++ b/mediaremote-adapter/README.md @@ -0,0 +1,60 @@ +# MediaRemoteAdapter (vendored) + +This directory contains a **vendored copy** of +[ungive/mediaremote-adapter](https://github.com/ungive/mediaremote-adapter), +vendored under the terms of its BSD 3-Clause license +(see [../THIRD_PARTY_LICENSES](../THIRD_PARTY_LICENSES)). + +## Why it's here + +`MediaRemote.framework` is a private Apple framework. The sandboxed app +cannot link it directly, so `NowPlayingController` spawns +`mediaremote-adapter.pl` as a child process; the script dynamically loads +`MediaRemoteAdapter.framework`, which exposes now-playing data and commands +as a stream of JSON lines on stdout. + +| File | Used as | +|---|---| +| `mediaremote-adapter.pl` | Copied into the app bundle's Resources; spawned by `NowPlayingController` (`stream` command) | +| `MediaRemoteAdapter.framework` | Embedded in `Contents/Frameworks`; loaded by the script at runtime | +| `MediaRemoteAdapterTestClient` | Bundled diagnostic executable (`test` command) that verifies the adapter is functional/entitled on the host macOS | + +## Pinned version + +- **Upstream tag: `v0.7.2`** + (commit [`dc3ff17`](https://github.com/ungive/mediaremote-adapter/commit/dc3ff1740e2035a2490ec67d3b33322449af780a)) +- Vendored-in commit: `61487af` ("Update MediaRemoteAdapter", 2025-08-14) +- `mediaremote-adapter.pl` is byte-identical to the upstream `bin/mediaremote-adapter.pl` at that tag. +- The framework binary reports `CFBundleShortVersionString = 0.1` + (upstream does not sync this with release tags; the script match is the + authoritative pin). +- The binaries are ad-hoc signed; see "Signing caveat" below. + +## Updating to a new upstream release + +1. Check the upstream + [releases page](https://github.com/ungive/mediaremote-adapter/releases) + and pick a tag. +2. Build the framework and test client from source at that tag + (see the upstream `README.md` / `Makefile`), **or** download the + release artifacts published on the tag and verify their checksums + against the values published upstream. +3. Replace `MediaRemoteAdapter.framework`, `MediaRemoteAdapterTestClient` + and `mediaremote-adapter.pl` (upstream path: `bin/mediaremote-adapter.pl`) + in this directory. +4. Re-sign the binaries ad-hoc if you built from source + (`codesign -s - --force --deep MediaRemoteAdapter.framework`), matching + what the Xcode build phase expects. +5. Verify: run the app, confirm now-playing updates stream in, and run + `MediaRemoteAdapterTestClient` (or `mediaremote-adapter.pl … test`) + on the oldest supported macOS version. +6. **Update the pin in this README** (tag + commit hash) in the same commit. + +## Signing caveat + +The checked-in binaries were built on a maintainer's machine and are ad-hoc +signed (`codesign -dv` shows `Signature=adhoc`). They are **not** +independently verifiable bit-for-bit against the upstream source. Until the +build is wired to fetch pinned upstream release artifacts (with SHA-256 +verification) instead of committing binaries, treat this directory as +trusted-but-unverifiable and prefer rebuilding from source when updating.