Merge pull request #354 from Ebullioscopic/feature/lunar-support

Feature/lunar support
This commit is contained in:
Hariharan Mudaliar 2026-03-21 22:03:28 +05:30 • committed by GitHub
commit 96c3ee8e5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 3362 additions and 3318 deletions

View file

@ -433,7 +433,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N;
@ -464,7 +464,7 @@
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";
INFOPLIST_KEY_NSDesktopFolderUsageDescription = "Atoll needs access to save screenshots to your desktop.";
INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Atoll needs access to save and read AI assistant files.";
INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Atoll needs access to save and read shelf dragged files.";
INFOPLIST_KEY_NSDownloadsFolderUsageDescription = "Atoll needs access to read files in the downloads folder.";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocationUsageDescription = "Atoll uses your location to display accurate lock screen weather.";
@ -475,7 +475,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.0.1;
MARKETING_VERSION = 2.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll.dev;
PRODUCT_NAME = Atoll;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -505,7 +505,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N;
@ -536,7 +536,7 @@
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";
INFOPLIST_KEY_NSDesktopFolderUsageDescription = "Atoll needs access to save screenshots to your desktop.";
INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Atoll needs access to save and read AI assistant files.";
INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Atoll needs access to save and read shelf dragged files.";
INFOPLIST_KEY_NSDownloadsFolderUsageDescription = "Atoll needs access to read files in the downloads folder.";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocationUsageDescription = "Atoll uses your location to display accurate lock screen weather.";
@ -547,7 +547,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.0.1;
MARKETING_VERSION = 2.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll;
PRODUCT_NAME = Atoll;
PROVISIONING_PROFILE_SPECIFIER = "";

View file

@ -7,7 +7,7 @@
"location" : "https://github.com/Ebullioscopic/AtollExtensionKit",
"state" : {
"branch" : "main",
"revision" : "ebe35277572a349fad26abeb6fc552cb5251d584"
"revision" : "e2d30afedbbcc259bec7eeb1bea0757e52f4ca01"
}
},
{

View file

@ -150,6 +150,7 @@ struct ContentView: View {
@State private var hoverClickMonitor: Any?
@State private var hoverClickLocalMonitor: Any?
@State private var stickyTerminalClickMonitor: Any?
@State private var hiddenEdgeHoverPollingTask: Task<Void, Never>?
@State private var gestureProgress: CGFloat = .zero
@State private var skipGestureActiveDirection: MusicManager.SkipDirection?
@ -257,22 +258,40 @@ struct ContentView: View {
/// Whether the current screen should render as a Dynamic Island pill
/// rather than the standard notch shape. Always false on physical notch screens.
private var isDynamicIslandMode: Bool {
shouldUseDynamicIslandMode(for: vm.screen ?? coordinator.selectedScreen)
shouldUseDynamicIslandMode(for: currentScreenName)
}
private var currentScreenName: String {
vm.screen ?? coordinator.selectedScreen
}
/// Whether the current screen lacks a physical notch.
private var isNonNotchScreen: Bool {
let screenName = vm.screen ?? coordinator.selectedScreen
guard let screen = NSScreen.screens.first(where: { $0.localizedName == screenName }) else {
guard let screen = NSScreen.screens.first(where: { $0.localizedName == currentScreenName }) else {
return true
}
return screen.safeAreaInsets.top <= 0
}
/// Whether the global sneak peek is visible on this specific screen.
private var isSneakPeekVisibleOnCurrentScreen: Bool {
guard coordinator.sneakPeek.show else { return false }
guard Defaults[.showOnAllDisplays] else { return true }
guard let targetScreenName = coordinator.sneakPeek.targetScreenName else { return true }
return currentScreenName == targetScreenName
}
/// Whether the notch/island should hide off-screen when closed on a non-notch display.
/// Temporarily reveals the notch when a sneakPeek HUD (volume, brightness, music, etc.) is active.
private var shouldHideUntilHover: Bool {
hideNonNotchUntilHover && isNonNotchScreen && vm.notchState == .closed && !coordinator.sneakPeek.show
hideNonNotchUntilHover && isNonNotchScreen && vm.notchState == .closed && !isSneakPeekVisibleOnCurrentScreen
}
/// Whether the fallback top-edge hover detector should run.
/// This is only needed when the notch is fully hidden off-screen and
/// regular `.onHover` hit-testing may not trigger reliably.
private var shouldUseHiddenEdgeHoverPolling: Bool {
shouldHideUntilHover && !lockScreenManager.isLocked
}
/// Pill shape for Dynamic Island mode with animated corner radius transitions.
@ -524,6 +543,7 @@ struct ContentView: View {
clearMusicControlVisibilityDeadline()
}
enqueueMusicControlWindowSync(forceRefresh: true)
startHiddenEdgeHoverPolling()
}
.onChange(of: vm.notchState) { _, state in
if state == .open {
@ -615,6 +635,7 @@ struct ContentView: View {
hoverTask?.cancel()
stopHoverClickMonitor()
removeStickyTerminalClickMonitor()
stopHiddenEdgeHoverPolling()
cancelMusicControlWindowSync()
hideMusicControlWindow()
cancelMusicControlVisibilityTimer()
@ -696,7 +717,7 @@ struct ContentView: View {
.frame(width: 76, alignment: .trailing)
}
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
} else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .reminder) && !coordinator.sneakPeek.type.isExtensionPayload && ((coordinator.sneakPeek.type != .volume && coordinator.sneakPeek.type != .brightness && coordinator.sneakPeek.type != .backlight) || vm.notchState == .closed) {
} else if isSneakPeekVisibleOnCurrentScreen && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .reminder) && !coordinator.sneakPeek.type.isExtensionPayload && ((coordinator.sneakPeek.type != .volume && coordinator.sneakPeek.type != .brightness && coordinator.sneakPeek.type != .backlight) || vm.notchState == .closed) {
InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress)
.transition(
coordinator.sneakPeek.type == .capsLock
@ -743,7 +764,7 @@ struct ContentView: View {
Rectangle().fill(.clear).frame(width: vm.closedNotchSize.width - 20, height: vm.effectiveClosedNotchHeight)
}
if coordinator.sneakPeek.show {
if isSneakPeekVisibleOnCurrentScreen {
if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .reminder) && (coordinator.sneakPeek.type != .capsLock) && !coordinator.sneakPeek.type.isExtensionPayload && !Defaults[.inlineHUD] && ((coordinator.sneakPeek.type != .volume && coordinator.sneakPeek.type != .brightness && coordinator.sneakPeek.type != .backlight) || vm.notchState == .closed) {
SystemEventIndicatorModifier(eventType: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, sendEventBack: { _ in
//
@ -970,7 +991,7 @@ struct ContentView: View {
.background(
Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(contentMode: .fill)
.aspectRatio(contentMode: .fit)
)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: MusicPlayerImageSizes.cornerRadiusInset.closed))
@ -1552,6 +1573,48 @@ struct ContentView: View {
}
}
private func hiddenHoverActivationContainsMouse(_ location: NSPoint = NSEvent.mouseLocation) -> Bool {
guard let screen = NSScreen.screens.first(where: { $0.localizedName == currentScreenName }) else {
return false
}
let horizontalPadding: CGFloat = 8
let activationWidth = vm.closedNotchSize.width + horizontalPadding * 2
let activationHeight = max(vm.closedNotchSize.height + zeroHeightHoverPadding, 14)
let activationRect = CGRect(
x: screen.frame.midX - activationWidth / 2,
y: screen.frame.maxY - activationHeight,
width: activationWidth,
height: activationHeight
)
return activationRect.contains(location)
}
private func startHiddenEdgeHoverPolling() {
guard hiddenEdgeHoverPollingTask == nil else { return }
hiddenEdgeHoverPollingTask = Task { @MainActor in
while !Task.isCancelled {
if self.shouldUseHiddenEdgeHoverPolling {
let hovering = self.hiddenHoverActivationContainsMouse()
if hovering != self.isHovering {
self.handleHover(hovering)
}
}
try? await Task.sleep(for: .milliseconds(50))
}
self.hiddenEdgeHoverPollingTask = nil
}
}
private func stopHiddenEdgeHoverPolling() {
hiddenEdgeHoverPollingTask?.cancel()
hiddenEdgeHoverPollingTask = nil
}
private func startHoverClickMonitor() {
guard hoverClickMonitor == nil else { return }
@ -1637,7 +1700,7 @@ struct ContentView: View {
let shouldFocusTimerTab = enableTimerFeature && timerDisplayMode == .tab && timerManager.isTimerActive && !enableMinimalisticUI
guard vm.notchState == .closed,
!coordinator.sneakPeek.show,
!isSneakPeekVisibleOnCurrentScreen,
(Defaults[.openNotchOnHover] || shouldFocusTimerTab) else { return }
hoverTask = Task {
@ -1647,7 +1710,7 @@ struct ContentView: View {
await MainActor.run {
guard self.vm.notchState == .closed,
self.isHovering,
!self.coordinator.sneakPeek.show else { return }
!self.isSneakPeekVisibleOnCurrentScreen else { return }
if shouldFocusTimerTab {
withAnimation(.smooth) {
@ -2162,7 +2225,7 @@ struct ContentView: View {
#endif
private func shouldFixSizeForSneakPeek() -> Bool {
guard coordinator.sneakPeek.show else { return false }
guard isSneakPeekVisibleOnCurrentScreen else { return false }
let style = resolvedSneakPeekStyle()
// Check for extension sneak peek

View file

@ -2,21 +2,21 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.mach-lookup.global-name</key>
<array>
<string>com.ebullioscopic.Atoll.xpc</string>
</array>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.temporary-exception.apple-events</key>
<array>
<string>com.spotify.client</string>
<string>com.apple.Music</string>
</array>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
</array>
<key>com.apple.security.mach-services</key>
<array>
<string>com.ebullioscopic.Atoll.xpc</string>
</array>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.temporary-exception.apple-events</key>
<array>
<string>com.spotify.client</string>
<string>com.apple.Music</string>
</array>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
</array>
</dict>
</plist>

View file

@ -111,6 +111,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let mediaControlsStateCoordinator = MediaControlsStateCoordinator.shared
let systemTimerBridge = SystemTimerBridge.shared
let extensionXPCServiceHost = ExtensionXPCServiceHost.shared
let extensionRPCServer = ExtensionRPCServer.shared
var closeNotchWorkItem: DispatchWorkItem?
private var previousScreens: [NSScreen]?
private var onboardingWindowController: NSWindowController?
@ -155,10 +156,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
func applicationWillTerminate(_ notification: Notification) {
let userInfo: [String: Any] = [
AtollDistributedNotifications.UserInfoKey.sourcePID: NSNumber(value: ProcessInfo.processInfo.processIdentifier)
]
DistributedNotificationCenter.default().postNotificationName(
AtollDistributedNotifications.didBecomeIdle,
object: nil,
userInfo: userInfo,
deliverImmediately: true
)
// Cancel any pending window size updates
windowSizeUpdateWorkItem?.cancel()
NotificationCenter.default.removeObserver(self)
extensionXPCServiceHost.stop()
extensionRPCServer.stop()
// Restore Lunar's native OSD if integration was active
LunarManager.shared.appWillTerminate()
}
@objc func onScreenLocked(_: Notification) {
@ -388,15 +403,39 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
func applicationDidFinishLaunching(_ notification: Notification) {
let userInfo: [String: Any] = [
AtollDistributedNotifications.UserInfoKey.sourcePID: NSNumber(value: ProcessInfo.processInfo.processIdentifier)
]
DistributedNotificationCenter.default().postNotificationName(
AtollDistributedNotifications.didBecomeActive,
object: nil,
userInfo: userInfo,
deliverImmediately: true
)
LockScreenLiveActivityWindowManager.shared.configure(viewModel: vm)
LockScreenManager.shared.configure(viewModel: vm)
extensionXPCServiceHost.start()
extensionRPCServer.start()
// Migrate legacy progress bar settings
Defaults.Keys.migrateProgressBarStyle()
Defaults.Keys.migrateMusicAuxControls()
Defaults.Keys.migrateMusicControlSlots()
Defaults.Keys.migrateCapsLockTintMode()
Defaults.Keys.migrateThirdPartyDDCIntegration()
Defaults.publisher(.enableThirdPartyDDCIntegration, options: [])
.sink { _ in
Defaults.Keys.syncLegacyThirdPartyDDCKeys()
}
.store(in: &cancellables)
Defaults.publisher(.thirdPartyDDCProvider, options: [])
.sink { _ in
Defaults.Keys.syncLegacyThirdPartyDDCKeys()
}
.store(in: &cancellables)
// Initialize idle animations (load bundled + built-in face)
idleAnimationManager.initializeDefaultAnimations()
@ -415,6 +454,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Setup BetterDisplay integration
BetterDisplayManager.shared.configure(coordinator: coordinator)
// Setup Lunar integration
LunarManager.shared.configure(coordinator: coordinator)
// Setup ScreenRecording Manager
if Defaults[.enableScreenRecordingDetection] {

View file

@ -84,6 +84,7 @@ struct sneakPeek {
var subtitle: String = ""
var accentColor: Color?
var styleOverride: SneakPeekStyle? = nil
var targetScreenName: String? = nil
}
enum BrowserType {
@ -323,7 +324,8 @@ class DynamicIslandViewCoordinator: ObservableObject {
title: String = "",
subtitle: String = "",
accentColor: Color? = nil,
styleOverride: SneakPeekStyle? = nil
styleOverride: SneakPeekStyle? = nil,
onScreen targetScreen: NSScreen? = nil
) {
let resolvedDuration: TimeInterval
switch type {
@ -360,6 +362,7 @@ class DynamicIslandViewCoordinator: ObservableObject {
self.sneakPeek.subtitle = subtitle
self.sneakPeek.accentColor = accentColor
self.sneakPeek.styleOverride = styleOverride
self.sneakPeek.targetScreenName = targetScreen?.localizedName
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,98 +0,0 @@
/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* Originally from boring.notch project
* Modified and adapted for Atoll (DynamicIsland)
* See NOTICE for details.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import SwiftUI
struct MinimalFaceFeatures: View {
@State private var isBlinking = false
@State var height:CGFloat = 20;
@State var width:CGFloat = 30;
var body: some View {
VStack(spacing: 4) { // Adjusted spacing to fit within 30x30
// Eyes
HStack(spacing: 4) { // Adjusted spacing to fit within 30x30
Eye(isBlinking: $isBlinking)
Eye(isBlinking: $isBlinking)
}
// Nose and mouth combined
VStack(spacing: 2) { // Adjusted spacing to fit within 30x30
// Nose
RoundedRectangle(cornerRadius: 2)
.fill(Color.white)
.frame(width: 3, height: 4)
// Mouth (happy)
GeometryReader { geometry in
Path { path in
let width = geometry.size.width
let height = geometry.size.height
path.move(to: CGPoint(x: 0, y: height / 2))
path.addQuadCurve(to: CGPoint(x: width, y: height / 2), control: CGPoint(x: width / 2, y: height))
}
.stroke(Color.white, lineWidth: 2)
}
.frame(width: 14, height: 10)
}
}
.frame(width: self.width, height: self.height) // Maximum size of face
.onAppear {
startBlinking()
}
}
func startBlinking() {
Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in
withAnimation(.spring(duration: 0.2)) {
isBlinking = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
withAnimation(.spring(duration: 0.2)) {
isBlinking = false
}
}
}
}
}
struct Eye: View {
@Binding var isBlinking: Bool
var body: some View {
RoundedRectangle(cornerRadius: 10)
.fill(Color.white)
.frame(width: 4, height: isBlinking ? 1 : 4)
.frame(maxWidth: 15, maxHeight: 15) // Adjusted max size
.animation(.easeInOut(duration: 0.1), value: isBlinking)
}
}
struct MinimalFaceFeatures_Previews: PreviewProvider {
static var previews: some View {
ZStack {
Color.black
MinimalFaceFeatures()
}
.previewLayout(.fixed(width: 60, height: 60)) // Adjusted preview size for better visibility
}
}

View file

@ -261,7 +261,16 @@ struct StandaloneCalendarView: View {
@Default(.hideCompletedReminders) private var hideCompletedReminders
private let calendar = Calendar.current
private let weekdaySymbols = Calendar.current.shortWeekdaySymbols
private var weekdaySymbols: [String] {
let symbols = calendar.veryShortStandaloneWeekdaySymbols
guard !symbols.isEmpty else { return symbols }
let firstWeekdayIndex = max(0, min(symbols.count - 1, calendar.firstWeekday - 1))
var ordered = Array(symbols[firstWeekdayIndex...])
ordered.append(contentsOf: symbols[..<firstWeekdayIndex])
return ordered
}
private var monthTitle: String {
displayedMonth.formatted(.dateTime.month(.wide))
@ -397,7 +406,7 @@ struct StandaloneCalendarView: View {
ScrollViewReader { proxy in
VStack(spacing: 6) {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 14), spacing: 6), count: 7), spacing: 6) {
ForEach(weekdaySymbols, id: \.self) { symbol in
ForEach(Array(weekdaySymbols.enumerated()), id: \.offset) { _, symbol in
Text(symbol.prefix(1))
.font(.caption2)
.fontWeight(.semibold)

View file

@ -28,8 +28,10 @@ struct EmptyStateView: View {
var body: some View {
HStack {
MinimalFaceFeatures(
height: 70, width: 80)
Image(systemName: "music.note")
.font(.system(size: 24))
.foregroundColor(.gray)
.frame(width: 80, height: 70)
Text(message)
.font(.system(size:14))
.foregroundColor(.gray)

View file

@ -373,6 +373,9 @@ struct ExtensionWebContentView: NSViewRepresentable {
return true
}
if (scheme == "http" || scheme == "https") {
if allowsRemoteRequests() {
return true
}
guard descriptor.allowLocalhostRequests else {
return false
}
@ -381,6 +384,15 @@ struct ExtensionWebContentView: NSViewRepresentable {
}
return false
}
private func allowsRemoteRequests() -> Bool {
guard let data = try? JSONEncoder().encode(descriptor),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let value = json["allowRemoteRequests"] as? Bool else {
return false
}
return value
}
}
}

View file

@ -31,8 +31,8 @@ struct IdleAnimationView: View {
AnimationContentView(animation: animation)
.id("\(animation.id)-\(overrides[animation.id.uuidString]?.hashValue ?? 0)") // Force recreation when override changes
} else {
// Fallback to original face if nothing selected
MinimalFaceFeatures(height: 20, width: 30)
// No animation selected
EmptyView()
}
}
}
@ -85,20 +85,6 @@ private struct AnimationContentView: View {
.padding(.bottom, config.paddingBottom)
.frame(width: config.expandWithAnimation ? nil : 30, height: 20)
.clipped()
case .builtInFace:
// Apply transforms to built-in face too!
MinimalFaceFeatures(height: 20, width: 30)
.frame(
width: config.cropWidth * config.scale,
height: config.cropHeight * config.scale
)
.offset(x: config.offsetX, y: config.offsetY)
.rotationEffect(.degrees(config.rotation))
.opacity(config.opacity)
.padding(.bottom, config.paddingBottom)
.frame(width: config.expandWithAnimation ? nil : 30, height: 20)
.clipped()
}
}
}

View file

@ -824,7 +824,7 @@ struct LockScreenMusicPanel: View {
private func albumArtImage(size: CGFloat, cornerRadius: CGFloat) -> some View {
Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(contentMode: .fill)
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
}

View file

@ -92,7 +92,7 @@ struct MinimalisticMusicView: View {
.background(
Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(contentMode: .fill)
.aspectRatio(contentMode: .fit)
)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 18)) // Dramatically increased corner radius for minimalistic mode

View file

@ -1058,7 +1058,7 @@ struct MinimalisticAlbumArtView: View {
.background(
Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(contentMode: .fill)
.aspectRatio(contentMode: .fit)
)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 12))
@ -1077,7 +1077,7 @@ struct MinimalisticAlbumArtView: View {
.background(
Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(contentMode: .fill)
.aspectRatio(contentMode: .fit)
)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 12))

View file

@ -36,8 +36,6 @@ struct MusicPlayerView: View {
.padding(.all, 5)
MusicControlsView()
.frame(maxWidth: .infinity, alignment: .leading)
.drawingGroup()
.compositingGroup()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ -58,8 +56,13 @@ struct AlbumArtView: View {
}
private var albumArtBackground: some View {
Image(nsImage: musicManager.albumArt)
.resizable()
Color.clear
.aspectRatio(1, contentMode: .fit)
.background(
Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(contentMode: .fit)
)
.clipped()
.clipShape(
RoundedRectangle(
@ -67,7 +70,6 @@ struct AlbumArtView: View {
? MusicPlayerImageSizes.cornerRadiusInset.opened
: MusicPlayerImageSizes.cornerRadiusInset.closed)
)
.aspectRatio(1, contentMode: .fit)
.scaleEffect(x: 1.3, y: 1.4)
.rotationEffect(.degrees(92))
.blur(radius: 40)
@ -102,9 +104,13 @@ struct AlbumArtView: View {
}
private var albumArtImage: some View {
Image(nsImage: musicManager.albumArt)
.resizable()
Color.clear
.aspectRatio(1, contentMode: .fit)
.overlay {
Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(contentMode: .fit)
}
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)
.clipped()
.clipShape(
@ -529,11 +535,12 @@ struct NotchHomeView: View {
@ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
@ObservedObject private var musicManager = MusicManager.shared
@Default(.showStandardMediaControls) private var showStandardMediaControls
@Default(.autoHideInactiveNotchMediaPlayer) private var autoHideInactiveNotchMediaPlayer
let albumArtNamespace: Namespace.ID
/// Whether the music player should actively display (enabled AND has real content).
private var shouldShowMusicPlayer: Bool {
showStandardMediaControls && musicManager.hasActiveSession
showStandardMediaControls && (!autoHideInactiveNotchMediaPlayer || musicManager.hasActiveSession)
}
var body: some View {

View file

@ -170,16 +170,11 @@ struct AnimationEditorView: View {
.frame(width: 30 * previewScale, height: 20 * previewScale)
Group {
if let existing = existingAnimation, case .builtInFace = existing.source {
MinimalFaceFeatures(height: 20, width: 30)
.scaleEffect(previewScale)
} else {
LottieView(state: LUStateData(
type: .loadedFrom(sourceURL),
speed: speed,
loopMode: loopMode.lottieLoopMode
))
}
LottieView(state: LUStateData(
type: .loadedFrom(sourceURL),
speed: speed,
loopMode: loopMode.lottieLoopMode
))
}
.frame(width: cropWidth * scale * previewScale, height: cropHeight * scale * previewScale)
.offset(x: offsetX * previewScale, y: offsetY * previewScale)

View file

@ -87,12 +87,6 @@ struct IdleAnimationsSettingsSection: View {
print("🔧 [Edit] Lottie URL: \(url)")
editorSourceURL = url
editorIsRemote = true
case .builtInFace:
print("🔧 [Edit] Built-in face - using dummy URL")
// Use a dummy URL for built-in face (editor only needs URL for preview)
editorSourceURL = URL(string: "builtin://face")!
editorIsRemote = false
}
// Show editor with slight delay to ensure state is set
@ -118,7 +112,7 @@ struct IdleAnimationsSettingsSection: View {
}
.frame(height: 140)
} else {
Text("Enable \"Show cool face animation while inactivity\" to customize animations")
Text("Enable \"Idle Animation\" to customize animations")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.vertical, 8)
@ -388,10 +382,6 @@ struct AnimationPreview: View {
loopMode: .loop
))
.frame(width: 60, height: 40)
case .builtInFace:
MinimalFaceFeatures(height: 40, width: 60)
.scaleEffect(2.0) // Scale up the face for preview
}
}
}

View file

@ -422,8 +422,23 @@ struct SettingsView: View {
@ViewBuilder
private func sidebarIcon(for tab: SettingsTab) -> some View {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(tab.tint)
.fill(
LinearGradient(
colors: [
tab.tint.opacity(1),
tab.tint.opacity(0.7)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 26, height: 26)
.overlay {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Color.white.opacity(0.2), lineWidth: 0.7)
.blendMode(.plusLighter)
}
.shadow(color: tab.tint.opacity(0.35), radius: 2, x: 0, y: 1)
.overlay {
Image(systemName: tab.systemImage)
.font(.system(size: 13, weight: .semibold))
@ -739,13 +754,16 @@ struct SettingsView: View {
SettingsSearchEntry(tab: .hudAndOSD, title: "Keyboard Backlight OSD", keywords: ["keyboard", "backlight", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Keyboard Backlight OSD")),
SettingsSearchEntry(tab: .hudAndOSD, title: "Material", keywords: ["material", "frosted", "liquid", "glass", "solid", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Material")),
SettingsSearchEntry(tab: .hudAndOSD, title: "Icon & Progress Color", keywords: ["color", "icon", "white", "black", "gray", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Icon & Progress Color")),
SettingsSearchEntry(tab: .hudAndOSD, title: "BetterDisplay integration", keywords: ["betterdisplay", "external", "display", "brightness", "integration", "third party"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "BetterDisplay integration")),
SettingsSearchEntry(tab: .hudAndOSD, title: "Third-party DDC app integration", keywords: ["ddc", "third party", "external", "display", "betterdisplay", "lunar"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Third-party DDC app integration")),
SettingsSearchEntry(tab: .hudAndOSD, title: "Third-party DDC provider", keywords: ["provider", "betterdisplay", "lunar", "integration", "refresh detection"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Third-party DDC provider")),
SettingsSearchEntry(tab: .hudAndOSD, title: "Enable external volume control listener", keywords: ["external volume", "ddc volume", "betterdisplay volume", "lunar volume", "disable native volume"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Enable external volume control listener")),
// Media
SettingsSearchEntry(tab: .media, title: "Music Source", keywords: ["media source", "controller"], highlightID: SettingsTab.media.highlightID(for: "Music Source")),
SettingsSearchEntry(tab: .media, title: "Skip buttons", keywords: ["skip", "controls", "±10"], highlightID: SettingsTab.media.highlightID(for: "Skip buttons")),
SettingsSearchEntry(tab: .media, title: "Sneak Peek Style", keywords: ["sneak peek", "preview"], highlightID: SettingsTab.media.highlightID(for: "Sneak Peek Style")),
SettingsSearchEntry(tab: .media, title: "Enable lyrics", keywords: ["lyrics", "song text"], highlightID: SettingsTab.media.highlightID(for: "Enable lyrics")),
SettingsSearchEntry(tab: .media, title: "Auto-hide inactive notch media player", keywords: ["auto hide", "inactive", "placeholder", "notch media"], highlightID: SettingsTab.media.highlightID(for: "Auto-hide inactive notch media player")),
SettingsSearchEntry(tab: .media, title: "Show Change Media Output control", keywords: ["airplay", "route picker", "media output"], highlightID: SettingsTab.media.highlightID(for: "Show Change Media Output control")),
SettingsSearchEntry(tab: .media, title: "Enable album art parallax", keywords: ["parallax", "lock screen", "album art"], highlightID: SettingsTab.media.highlightID(for: "Enable album art parallax")),
SettingsSearchEntry(tab: .media, title: "Enable album art parallax effect", keywords: ["parallax", "parallax effect", "album art"], highlightID: SettingsTab.media.highlightID(for: "Enable album art parallax effect")),
@ -789,7 +807,7 @@ struct SettingsView: View {
SettingsSearchEntry(tab: .appearance, title: "Slider color", keywords: ["slider", "accent"], highlightID: SettingsTab.appearance.highlightID(for: "Slider color")),
SettingsSearchEntry(tab: .appearance, title: "Enable Dynamic mirror", keywords: ["mirror", "reflection"], highlightID: SettingsTab.appearance.highlightID(for: "Enable Dynamic mirror")),
SettingsSearchEntry(tab: .appearance, title: "Mirror shape", keywords: ["mirror shape", "circle", "rectangle"], highlightID: SettingsTab.appearance.highlightID(for: "Mirror shape")),
SettingsSearchEntry(tab: .appearance, title: "Show cool face animation while inactivity", keywords: ["face animation", "idle"], highlightID: SettingsTab.appearance.highlightID(for: "Show cool face animation while inactivity")),
SettingsSearchEntry(tab: .appearance, title: "Idle Animation", keywords: ["face animation", "idle", "cool face"], highlightID: SettingsTab.appearance.highlightID(for: "Idle Animation")),
SettingsSearchEntry(tab: .appearance, title: "App icon", keywords: ["app icon", "custom icon"], highlightID: SettingsTab.appearance.highlightID(for: "App icon")),
// Lock Screen
@ -1494,7 +1512,7 @@ private struct HUDAndOSDSettingsView: View {
@Default(.enableVolumeHUD) var enableVolumeHUD
@Default(.enableBrightnessHUD) var enableBrightnessHUD
@Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD
@Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration
@Default(.enableThirdPartyDDCIntegration) var enableThirdPartyDDCIntegration
@Default(.verticalHUDShowValue) var verticalHUDShowValue
@Default(.verticalHUDInteractive) var verticalHUDInteractive
@Default(.verticalHUDHeight) var verticalHUDHeight
@ -1730,7 +1748,7 @@ private struct HUDAndOSDSettingsView: View {
}
case .vertical:
Form {
if !accessibilityPermission.isAuthorized && !enableBetterDisplayIntegration {
if !accessibilityPermission.isAuthorized && !enableThirdPartyDDCIntegration {
Section {
SettingsPermissionCallout(
message: "Accessibility permission is needed to intercept system controls for the Vertical HUD.",
@ -1746,13 +1764,13 @@ private struct HUDAndOSDSettingsView: View {
}
}
if accessibilityPermission.isAuthorized || enableBetterDisplayIntegration {
if accessibilityPermission.isAuthorized || enableThirdPartyDDCIntegration {
Section {
Toggle("Volume HUD", isOn: $enableVolumeHUD)
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
.disabled(enableBetterDisplayIntegration)
.help(enableBetterDisplayIntegration ? "Disabled while BetterDisplay integration is active — BetterDisplay uses Cmd+Brightness keys for its own controls." : "")
.disabled(enableThirdPartyDDCIntegration)
.help(enableThirdPartyDDCIntegration ? "Disabled while external display integration is active — brightness keys are handled by the external app." : "")
} header: {
Text("Controls")
} footer: {
@ -1847,7 +1865,7 @@ private struct HUDAndOSDSettingsView: View {
case .circular:
Form {
if !accessibilityPermission.isAuthorized && !enableBetterDisplayIntegration {
if !accessibilityPermission.isAuthorized && !enableThirdPartyDDCIntegration {
Section {
SettingsPermissionCallout(
message: "Accessibility permission is needed to intercept system controls for the Circular HUD.",
@ -1863,13 +1881,13 @@ private struct HUDAndOSDSettingsView: View {
}
}
if accessibilityPermission.isAuthorized || enableBetterDisplayIntegration {
if accessibilityPermission.isAuthorized || enableThirdPartyDDCIntegration {
Section {
Toggle("Volume HUD", isOn: $enableVolumeHUD)
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
.disabled(enableBetterDisplayIntegration)
.help(enableBetterDisplayIntegration ? "Disabled while BetterDisplay integration is active — BetterDisplay uses Cmd+Brightness keys for its own controls." : "")
.disabled(enableThirdPartyDDCIntegration)
.help(enableThirdPartyDDCIntegration ? "Disabled while external display integration is active — brightness keys are handled by the external app." : "")
} header: {
Text("Controls")
} footer: {
@ -1909,8 +1927,8 @@ private struct HUDAndOSDSettingsView: View {
}
}
// BetterDisplay Integration (shared across all HUD variants)
BetterDisplayIntegrationSection()
// Third-party display integrations (shared across all HUD variants)
ExternalDisplayIntegrationsSection()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(paneBackgroundColor)
@ -1924,93 +1942,135 @@ private struct HUDAndOSDSettingsView: View {
}
}
// MARK: - BetterDisplay Integration Settings Section
// MARK: - External Display Integrations Settings Section
private struct BetterDisplayIntegrationSection: View {
@Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration
private struct ExternalDisplayIntegrationsSection: View {
@Default(.enableThirdPartyDDCIntegration) var enableThirdPartyDDCIntegration
@Default(.thirdPartyDDCProvider) var thirdPartyDDCProvider
@Default(.enableExternalVolumeControlListener) var enableExternalVolumeControlListener
@ObservedObject private var betterDisplayManager = BetterDisplayManager.shared
@ObservedObject private var lunarManager = LunarManager.shared
private func highlightID(_ title: String) -> String {
SettingsTab.hudAndOSD.highlightID(for: title)
}
private var statusText: String {
if betterDisplayManager.isRunning {
return "Running"
} else if betterDisplayManager.isDetected {
return "Not running"
} else {
private var providerStatusText: String {
switch thirdPartyDDCProvider {
case .betterDisplay:
if betterDisplayManager.isRunning { return "Running" }
if betterDisplayManager.isDetected { return "Not running" }
return "Not detected"
case .lunar:
if lunarManager.isConnected { return "Connected" }
if lunarManager.isRunning { return "Running" }
if lunarManager.isDetected { return "Not running" }
return "Not detected"
}
}
private var statusColor: Color {
if betterDisplayManager.isRunning {
return .green
} else if betterDisplayManager.isDetected {
return .orange
} else {
private var providerStatusColor: Color {
switch thirdPartyDDCProvider {
case .betterDisplay:
if betterDisplayManager.isRunning { return .green }
if betterDisplayManager.isDetected { return .orange }
return .secondary
case .lunar:
if lunarManager.isConnected { return .green }
if lunarManager.isRunning { return .orange }
if lunarManager.isDetected { return .orange }
return .secondary
}
}
private var providerStatusDescription: String {
switch thirdPartyDDCProvider {
case .betterDisplay:
if !betterDisplayManager.isDetected {
return "Install [BetterDisplay](https://betterdisplay.pro) to control external display brightness (and optional volume) through Atoll's HUD."
}
if !betterDisplayManager.isRunning {
return "BetterDisplay is installed but not currently running. Launch BetterDisplay to enable integration."
}
return "BetterDisplay OSD events will be routed through Atoll's active HUD style. Brightness is always routed; volume is routed when external volume control listener is enabled below. Make sure BetterDisplay's OSD integration is enabled in Settings › Application › Integration."
case .lunar:
if !lunarManager.isDetected {
return "Install [Lunar](https://lunar.fyi) to control external display brightness, contrast, and optional volume through Atoll's HUD via DDC."
}
if !lunarManager.isRunning {
return "Lunar is installed but not currently running. Launch Lunar to enable integration."
}
if lunarManager.isConnected {
return "Connected to Lunar's DDC socket. Brightness and contrast adjustments are shown through Atoll's HUD; volume follows when external volume control listener is enabled below."
}
return "Lunar is running but the socket connection is not yet established. It will connect automatically."
}
}
private func refreshDetectionStatus() {
switch thirdPartyDDCProvider {
case .betterDisplay:
betterDisplayManager.refreshDetectionStatus()
case .lunar:
lunarManager.refreshDetectionStatus()
}
}
var body: some View {
Form {
Section {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("BetterDisplay")
.font(.system(size: 13, weight: .medium))
Text(statusText)
Toggle("Enable third-party DDC app integration", isOn: $enableThirdPartyDDCIntegration)
.settingsHighlight(id: highlightID("Third-party DDC app integration"))
if enableThirdPartyDDCIntegration {
Picker("Provider", selection: $thirdPartyDDCProvider) {
ForEach(ThirdPartyDDCProvider.allCases) { provider in
Text(provider.displayName).tag(provider)
}
}
.settingsHighlight(id: highlightID("Third-party DDC provider"))
Toggle("Enable external volume control listener", isOn: $enableExternalVolumeControlListener)
.settingsHighlight(id: highlightID("Enable external volume control listener"))
Text(
enableExternalVolumeControlListener
? "Atoll's built-in volume key interception is disabled while external volume listening is on. Volume HUD/OSD will follow \(thirdPartyDDCProvider.displayName) payloads."
: "Atoll keeps native volume key interception. External provider volume payloads are ignored while this is off."
)
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Text("Status")
Spacer()
Text(providerStatusText)
.font(.caption)
.foregroundStyle(statusColor)
.foregroundStyle(providerStatusColor)
}
Spacer()
Text(providerStatusDescription)
.font(.caption)
.foregroundStyle(.secondary)
Toggle("", isOn: $enableBetterDisplayIntegration)
.toggleStyle(.switch)
.labelsHidden()
.disabled(!betterDisplayManager.isDetected)
}
.settingsHighlight(id: highlightID("BetterDisplay integration"))
if !betterDisplayManager.isDetected {
Text("Install [BetterDisplay](https://betterdisplay.pro) to control external display brightness and volume through Atoll's HUD.")
.font(.caption)
.foregroundStyle(.secondary)
} else if !betterDisplayManager.isRunning {
Text("BetterDisplay is installed but not currently running. Launch BetterDisplay to enable integration.")
.font(.caption)
.foregroundStyle(.secondary)
} else if enableBetterDisplayIntegration {
Text("BetterDisplay OSD events will be routed through Atoll's active HUD style. Make sure BetterDisplay's OSD integration is enabled in its Settings › Application › Integration.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("Enable to route BetterDisplay's brightness and volume changes through Atoll's HUD instead of the system OSD.")
.font(.caption)
.foregroundStyle(.secondary)
}
if betterDisplayManager.isDetected {
Button {
betterDisplayManager.refreshDetectionStatus()
refreshDetectionStatus()
} label: {
Label("Refresh detection", systemImage: "arrow.clockwise")
.font(.caption)
}
.buttonStyle(.link)
}
} header: {
HStack(spacing: 6) {
Image(systemName: "display.2")
Text("BetterDisplay Integration")
} else {
Text("Enable to route BetterDisplay or Lunar display adjustments through Atoll's active HUD style.")
.font(.caption)
.foregroundStyle(.secondary)
}
} footer: {
Text("When enabled, Atoll listens for OSD notifications from BetterDisplay and displays them using your selected HUD style above. This works alongside the existing media key interception — BetterDisplay handles external display controls while Atoll provides the visual feedback.")
.foregroundStyle(.secondary)
.font(.caption)
if enableThirdPartyDDCIntegration {
Text("Atoll always listens to selected-provider brightness events, and listens to provider volume events only when external volume listener is enabled.")
.foregroundStyle(.secondary)
.font(.caption)
}
}
}
}
@ -2157,7 +2217,7 @@ struct HUD: View {
@Default(.enableVolumeHUD) var enableVolumeHUD
@Default(.enableBrightnessHUD) var enableBrightnessHUD
@Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD
@Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration
@Default(.enableThirdPartyDDCIntegration) var enableThirdPartyDDCIntegration
@Default(.systemHUDSensitivity) var systemHUDSensitivity
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared
@ -2176,7 +2236,7 @@ struct HUD: View {
var body: some View {
Form {
if !hasAccessibilityPermission && !enableBetterDisplayIntegration {
if !hasAccessibilityPermission && !enableThirdPartyDDCIntegration {
Section {
SettingsPermissionCallout(
message: "Accessibility permission lets Dynamic Island replace the native volume, brightness, and keyboard HUDs.",
@ -2190,13 +2250,13 @@ struct HUD: View {
if enableSystemHUD && !Defaults[.enableCustomOSD] && (hasAccessibilityPermission || enableBetterDisplayIntegration) {
if enableSystemHUD && !Defaults[.enableCustomOSD] && (hasAccessibilityPermission || enableThirdPartyDDCIntegration) {
Section {
Toggle("Volume HUD", isOn: $enableVolumeHUD)
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
.disabled(enableBetterDisplayIntegration)
.help(enableBetterDisplayIntegration ? "Disabled while BetterDisplay integration is active \u{2014} BetterDisplay uses Cmd+Brightness keys for its own controls." : "")
.disabled(enableThirdPartyDDCIntegration)
.help(enableThirdPartyDDCIntegration ? "Disabled while external display integration is active \u{2014} brightness keys are handled by the external app." : "")
} header: {
Text("Controls")
} footer: {
@ -2312,6 +2372,7 @@ struct Media: View {
@Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenMusicAlbumParallaxEnabled
@Default(.showStandardMediaControls) private var showStandardMediaControls
@Default(.autoHideInactiveNotchMediaPlayer) private var autoHideInactiveNotchMediaPlayer
private func highlightID(_ title: String) -> String {
SettingsTab.media.highlightID(for: title)
@ -2359,6 +2420,10 @@ struct Media: View {
.disabled(enableMinimalisticUI)
.settingsHighlight(id: highlightID("Show media controls in Dynamic Island"))
Defaults.Toggle("Auto-hide inactive notch media player", key: .autoHideInactiveNotchMediaPlayer)
.disabled(enableMinimalisticUI || !showStandardMediaControls)
.settingsHighlight(id: highlightID("Auto-hide inactive notch media player"))
if enableMinimalisticUI {
Text("Disable Minimalistic UI to configure the standard notch media controls.")
.font(.caption)
@ -2367,6 +2432,10 @@ struct Media: View {
Text("Standard notch media controls are hidden. Re-enable the toggle above to restore them.")
.font(.caption)
.foregroundStyle(.secondary)
} else if !autoHideInactiveNotchMediaPlayer {
Text("When disabled, the notch music player stays visible with placeholder metadata even when playback is inactive.")
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("Dynamic Island Visibility")
@ -3129,6 +3198,15 @@ struct Shelf: View {
@Default(.autoRemoveShelfItems) var autoRemoveShelfItems
@StateObject private var quickShareService = QuickShareService.shared
@ObservedObject private var fullDiskAccessPermission = FullDiskAccessPermissionStore.shared
@ObservedObject private var shelfFolderAccessPermission = ShelfFolderAccessPermissionStore.shared
private var hasDocumentsAndDownloadsAccess: Bool {
shelfFolderAccessPermission.hasDocumentsAndDownloadsAccess
}
private var canEnableShelf: Bool {
fullDiskAccessPermission.isAuthorized || hasDocumentsAndDownloadsAccess
}
private var selectedProvider: QuickShareProvider? {
quickShareService.availableProviders.first(where: { $0.id == quickShareProvider })
@ -3144,18 +3222,33 @@ struct Shelf: View {
var body: some View {
Form {
if !fullDiskAccessPermission.isAuthorized {
if !canEnableShelf || !fullDiskAccessPermission.isAuthorized {
Section {
SettingsPermissionCallout(
title: "Full Disk Access required",
message: "Grant Full Disk Access so the Shelf can index and move files outside the app sandbox.",
icon: "externaldrive.fill",
iconColor: .purple,
requestButtonTitle: "Request Full Disk Access",
openSettingsButtonTitle: "Open Privacy & Security",
requestAction: { fullDiskAccessPermission.requestAccessPrompt() },
openSettingsAction: { fullDiskAccessPermission.openSystemSettings() }
)
if !canEnableShelf {
SettingsPermissionCallout(
title: "Additional folder access required",
message: "Enable Full Disk Access, or grant access to both Documents and Downloads folders to use Shelf.",
icon: "folder.badge.questionmark",
iconColor: .orange,
requestButtonTitle: "Request Folder Access",
openSettingsButtonTitle: "Open Privacy & Security",
requestAction: { shelfFolderAccessPermission.requestAccessPrompt() },
openSettingsAction: { shelfFolderAccessPermission.openSystemSettings() }
)
}
if !fullDiskAccessPermission.isAuthorized {
SettingsPermissionCallout(
title: "Full Disk Access for global mode",
message: "Without Full Disk Access, Shelf can only read files from Documents and Downloads. Grant Full Disk Access to make Shelf work globally.",
icon: "externaldrive.fill",
iconColor: .purple,
requestButtonTitle: "Request Full Disk Access",
openSettingsButtonTitle: "Open Privacy & Security",
requestAction: { fullDiskAccessPermission.requestAccessPrompt() },
openSettingsAction: { fullDiskAccessPermission.openSystemSettings() }
)
}
} header: {
Text("Permissions")
}
@ -3163,7 +3256,7 @@ struct Shelf: View {
Section {
Defaults.Toggle("Enable shelf", key: .dynamicShelf)
.disabled(!fullDiskAccessPermission.isAuthorized)
.disabled(!canEnableShelf)
.settingsHighlight(id: highlightID("Enable shelf"))
Defaults.Toggle("Open shelf tab by default if items added", key: .openShelfByDefault)
@ -3250,6 +3343,7 @@ struct Shelf: View {
.navigationTitle("Shelf")
.onAppear {
fullDiskAccessPermission.refreshStatus()
shelfFolderAccessPermission.refreshStatus()
}
}
}
@ -3857,8 +3951,8 @@ struct Appearance: View {
.tag(MirrorShapeEnum.rectangle)
}
.settingsHighlight(id: highlightID("Mirror shape"))
Defaults.Toggle("Show cool face animation while inactivity", key: .showNotHumanFace)
.settingsHighlight(id: highlightID("Show cool face animation while inactivity"))
Defaults.Toggle("Idle Animation", key: .showNotHumanFace)
.settingsHighlight(id: highlightID("Idle Animation"))
} header: {
HStack {
Text("Additional features")
@ -6908,7 +7002,7 @@ struct CustomOSDSettings: View {
@Default(.enableOSDVolume) var enableOSDVolume
@Default(.enableOSDBrightness) var enableOSDBrightness
@Default(.enableOSDKeyboardBacklight) var enableOSDKeyboardBacklight
@Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration
@Default(.enableThirdPartyDDCIntegration) var enableThirdPartyDDCIntegration
@Default(.osdMaterial) var osdMaterial
@Default(.osdLiquidGlassCustomizationMode) var osdLiquidGlassCustomizationMode
@Default(.osdLiquidGlassVariant) var osdLiquidGlassVariant
@ -6951,7 +7045,7 @@ struct CustomOSDSettings: View {
var body: some View {
Form {
if !hasAccessibilityPermission && !enableBetterDisplayIntegration {
if !hasAccessibilityPermission && !enableThirdPartyDDCIntegration {
Section {
SettingsPermissionCallout(
message: "Accessibility permission is needed to intercept system controls for the Custom OSD.",
@ -6963,7 +7057,7 @@ struct CustomOSDSettings: View {
}
}
if hasAccessibilityPermission || enableBetterDisplayIntegration {
if hasAccessibilityPermission || enableThirdPartyDDCIntegration {
Section {
Toggle("Volume OSD", isOn: $enableOSDVolume)
.settingsHighlight(id: highlightID("Volume OSD"))
@ -6971,8 +7065,8 @@ struct CustomOSDSettings: View {
.settingsHighlight(id: highlightID("Brightness OSD"))
Toggle("Keyboard Backlight OSD", isOn: $enableOSDKeyboardBacklight)
.settingsHighlight(id: highlightID("Keyboard Backlight OSD"))
.disabled(enableBetterDisplayIntegration)
.help(enableBetterDisplayIntegration ? "Disabled while BetterDisplay integration is active \u{2014} BetterDisplay uses Cmd+Brightness keys for its own controls." : "")
.disabled(enableThirdPartyDDCIntegration)
.help(enableThirdPartyDDCIntegration ? "Disabled while external display integration is active \u{2014} brightness keys are handled by the external app." : "")
} header: {
Text("Controls")
} footer: {
@ -7209,6 +7303,7 @@ struct TerminalSettings: View {
@ObservedObject var terminalManager = TerminalManager.shared
@Default(.enableTerminalFeature) var enableTerminalFeature
@Default(.terminalShellPath) var terminalShellPath
@Default(.terminalFontFamily) var terminalFontFamily
@Default(.terminalFontSize) var terminalFontSize
@Default(.terminalOpacity) var terminalOpacity
@Default(.terminalMaxHeightFraction) var terminalMaxHeightFraction
@ -7229,6 +7324,21 @@ struct TerminalSettings: View {
"\(Int(terminalMaxHeightFraction * 100))% of screen"
}
/// All monospaced font families available on the system.
private var monospacedFontFamilies: [String] {
NSFontManager.shared.availableFontFamilies.filter { family in
guard let font = NSFont(name: family, size: 12) else { return false }
return font.isFixedPitch
|| font.fontDescriptor.symbolicTraits.contains(.monoSpace)
}
.sorted()
}
/// Display name for the font picker — shows "System Monospaced" when no custom font is set.
private var fontDisplayName: String {
terminalFontFamily.isEmpty ? "System Monospaced" : terminalFontFamily
}
private var cursorStyleBinding: Binding<TerminalCursorStyleOption> {
Binding(
get: { TerminalCursorStyleOption(rawValue: terminalCursorStyle) ?? .blinkBlock },
@ -7279,6 +7389,20 @@ struct TerminalSettings: View {
// MARK: Appearance
Section {
Picker("Font family", selection: $terminalFontFamily) {
Text("System Monospaced").tag("")
Divider()
ForEach(monospacedFontFamilies, id: \.self) { family in
Text(family)
.font(.custom(family, size: 13))
.tag(family)
}
}
.onChange(of: terminalFontFamily) { _, newValue in
terminalManager.applyFontFamily(newValue)
}
.settingsHighlight(id: highlightID("Font family"))
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Font size")

View file

@ -53,6 +53,53 @@ enum FullDiskAccessAuthorization {
}
}
enum ShelfFolderAccessAuthorization {
private static var documentsDirectoryURL: URL? {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
}
private static var downloadsDirectoryURL: URL? {
FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
}
static func hasDocumentsAccess() -> Bool {
canReadDirectory(at: documentsDirectoryURL)
}
static func hasDownloadsAccess() -> Bool {
canReadDirectory(at: downloadsDirectoryURL)
}
static func hasDocumentsAndDownloadsAccess() -> Bool {
hasDocumentsAccess() && hasDownloadsAccess()
}
static func requestAccessProbe() {
if let documentsDirectoryURL {
_ = try? FileManager.default.contentsOfDirectory(at: documentsDirectoryURL, includingPropertiesForKeys: nil)
}
if let downloadsDirectoryURL {
_ = try? FileManager.default.contentsOfDirectory(at: downloadsDirectoryURL, includingPropertiesForKeys: nil)
}
}
private static func canReadDirectory(at url: URL?) -> Bool {
guard let url else { return false }
do {
_ = try FileManager.default.contentsOfDirectory(
at: url,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
)
return true
} catch {
return false
}
}
}
@MainActor
final class FullDiskAccessPermissionStore: ObservableObject {
static let shared = FullDiskAccessPermissionStore()
@ -128,3 +175,83 @@ final class FullDiskAccessPermissionStore: ObservableObject {
isAuthorized = newValue
}
}
@MainActor
final class ShelfFolderAccessPermissionStore: ObservableObject {
static let shared = ShelfFolderAccessPermissionStore()
@Published private(set) var hasDocumentsAccess: Bool = ShelfFolderAccessAuthorization.hasDocumentsAccess()
@Published private(set) var hasDownloadsAccess: Bool = ShelfFolderAccessAuthorization.hasDownloadsAccess()
var hasDocumentsAndDownloadsAccess: Bool {
hasDocumentsAccess && hasDownloadsAccess
}
private var pollingTask: Task<Void, Never>?
private init() {}
deinit {
pollingTask?.cancel()
}
func refreshStatus() {
updateStatus(
documents: ShelfFolderAccessAuthorization.hasDocumentsAccess(),
downloads: ShelfFolderAccessAuthorization.hasDownloadsAccess()
)
}
func requestAccessPrompt() {
ShelfFolderAccessAuthorization.requestAccessProbe()
beginPollingForStatusChanges()
}
func openSystemSettings() {
#if os(macOS)
let urls = [
"x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders",
"x-apple.systempreferences:com.apple.preference.security"
]
for candidate in urls {
guard let url = URL(string: candidate) else { continue }
if NSWorkspace.shared.open(url) {
return
}
}
#endif
}
private func beginPollingForStatusChanges() {
pollingTask?.cancel()
pollingTask = Task { [weak self] in
guard let self else { return }
for _ in 0..<40 {
try? await Task.sleep(nanoseconds: 500_000_000)
let docs = ShelfFolderAccessAuthorization.hasDocumentsAccess()
let downloads = ShelfFolderAccessAuthorization.hasDownloadsAccess()
await MainActor.run {
self.updateStatus(documents: docs, downloads: downloads)
}
if docs && downloads {
break
}
}
}
}
private func updateStatus(documents: Bool, downloads: Bool) {
if hasDocumentsAccess != documents {
hasDocumentsAccess = documents
}
if hasDownloadsAccess != downloads {
hasDownloadsAccess = downloads
}
}
}

View file

@ -115,6 +115,14 @@ final class BetterDisplayManager: ObservableObject {
private var cancellables = Set<AnyCancellable>()
private weak var coordinator: DynamicIslandViewCoordinator?
private var isBetterDisplayIntegrationEnabled: Bool {
Defaults[.enableThirdPartyDDCIntegration] && Defaults[.thirdPartyDDCProvider] == .betterDisplay
}
private var isExternalVolumeListenerEnabled: Bool {
isBetterDisplayIntegrationEnabled && Defaults[.enableExternalVolumeControlListener]
}
private init() {
isDetected = Self.checkInstallation()
isRunning = Self.checkRunning()
@ -128,11 +136,7 @@ final class BetterDisplayManager: ObservableObject {
/// Configure with the view coordinator for HUD dispatch.
func configure(coordinator: DynamicIslandViewCoordinator) {
self.coordinator = coordinator
// Start listening if integration is enabled and BetterDisplay is running
if Defaults[.enableBetterDisplayIntegration] && isRunning {
startListening()
}
refreshListeningState()
}
/// Refresh detection status (e.g. after app install/uninstall).
@ -141,11 +145,13 @@ final class BetterDisplayManager: ObservableObject {
let wasRunning = isRunning
isDetected = Self.checkInstallation()
isRunning = Self.checkRunning()
if wasRunning && !isRunning {
stopListening()
} else if !wasRunning && isRunning && Defaults[.enableBetterDisplayIntegration] {
startListening()
if wasDetected != isDetected {
NSLog("📺 BetterDisplay detection changed: detected=\(isDetected)")
}
if wasRunning != isRunning {
NSLog("📺 BetterDisplay running state changed: running=\(isRunning)")
}
refreshListeningState()
}
// MARK: - Detection
@ -199,7 +205,7 @@ final class BetterDisplayManager: ObservableObject {
// MARK: - OSD Handling
private func handleOSDNotification(_ notification: Notification) {
guard Defaults[.enableBetterDisplayIntegration], isRunning else { return }
guard isBetterDisplayIntegrationEnabled, isRunning else { return }
guard let notificationString = notification.object as? String else {
NSLog("⚠️ BetterDisplay OSD: unexpected notification format")
@ -213,6 +219,16 @@ final class BetterDisplayManager: ObservableObject {
BetterDisplayOSDNotification.self,
from: Data(notificationString.utf8)
)
let displayIDText = osd.displayID.map { String($0) } ?? "nil"
let targetText = osd.controlTarget ?? "nil"
let iconIDText = osd.systemIconID.map { String($0) } ?? "nil"
let valueText = osd.value.map { String($0) } ?? "nil"
let maxValueText = osd.maxValue.map { String($0) } ?? "nil"
let symbolText = osd.customSymbol ?? "nil"
let textValue = osd.text ?? "nil"
NSLog(
"📺 BetterDisplay decoded payload: displayID=\(displayIDText) target=\(targetText) iconID=\(iconIDText) value=\(valueText) maxValue=\(maxValueText) customSymbol=\(symbolText) text=\(textValue)"
)
routeOSDToHUD(osd)
} catch {
NSLog("⚠️ BetterDisplay OSD decode error: \(error.localizedDescription)")
@ -224,9 +240,20 @@ final class BetterDisplayManager: ObservableObject {
let category = classifyControlTarget(osd.controlTarget, systemIconID: osd.systemIconID)
let normalizedValue = normalizeValue(osd.value, maxValue: osd.maxValue)
let targetScreen = resolveScreen(for: osd.displayID)
let isExternalDisplay = isExternal(displayID: osd.displayID)
let isExternalDisplay = isExternal(displayID: osd.displayID, resolvedScreen: targetScreen)
let inferredMute = osd.controlTarget == "mute" || osd.systemIconID == 4
let hasVolumeData = category == .volume && osd.value != nil
let externalVolumeListenerEnabled = isExternalVolumeListenerEnabled
let targetText = osd.controlTarget ?? "nil"
let displayIDText = osd.displayID.map { String($0) } ?? "nil"
let resolvedScreenText = targetScreen?.localizedName ?? "nil"
let valueText = osd.value.map { String($0) } ?? "nil"
let maxValueText = osd.maxValue.map { String($0) } ?? "nil"
let normalizedText = String(format: "%.3f", normalizedValue)
NSLog("📺 BetterDisplay OSD: target=\(osd.controlTarget ?? "nil") displayID=\(osd.displayID.map(String.init) ?? "nil") resolvedScreen=\(targetScreen?.localizedName ?? "nil") isExternal=\(isExternalDisplay) value=\(osd.value ?? -1)")
NSLog(
"📺 BetterDisplay routed payload: category=\(categoryName(category)) target=\(targetText) displayID=\(displayIDText) resolvedScreen=\(resolvedScreenText) isExternal=\(isExternalDisplay) rawValue=\(valueText) maxValue=\(maxValueText) normalized=\(normalizedText) hasVolumeData=\(hasVolumeData) inferredMute=\(inferredMute) externalVolumeListener=\(externalVolumeListenerEnabled)"
)
switch category {
case .brightness:
@ -234,6 +261,10 @@ final class BetterDisplayManager: ObservableObject {
dispatchBrightnessHUD(value: normalizedValue, customSymbol: icon, onScreen: targetScreen)
case .volume:
guard externalVolumeListenerEnabled else {
NSLog("📺 BetterDisplay volume payload ignored because external volume listener is disabled")
return
}
let isMuted = osd.controlTarget == "mute" || osd.systemIconID == 4
dispatchVolumeHUD(value: normalizedValue, isMuted: isMuted, onScreen: targetScreen)
@ -266,7 +297,8 @@ final class BetterDisplayManager: ObservableObject {
status: true,
type: .volume,
value: value,
icon: ""
icon: "",
onScreen: targetScreen
)
}
}
@ -289,7 +321,8 @@ final class BetterDisplayManager: ObservableObject {
status: true,
type: .brightness,
value: value,
icon: icon
icon: icon,
onScreen: targetScreen
)
}
}
@ -302,28 +335,50 @@ final class BetterDisplayManager: ObservableObject {
NSLog("📺 BetterDisplay resolveScreen: displayID is nil, falling back to all screens")
return nil
}
if let directDisplayID = UInt32(exactly: displayID),
let matchedScreen = NSScreen.screens.first(where: { screenNumber(for: $0) == directDisplayID }) {
return matchedScreen
}
// Fallback for payloads that use 1-based display indexes (1, 2, ...)
if displayID > 0 {
let index = displayID - 1
if NSScreen.screens.indices.contains(index) {
let fallback = NSScreen.screens[index]
NSLog("📺 BetterDisplay resolveScreen: displayID=\(displayID) resolved via index fallback to '\(fallback.localizedName)'")
return fallback
}
}
let target = UInt32(displayID)
let availableScreens = NSScreen.screens.map { screen -> (String, UInt32) in
let num = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0
return (screen.localizedName, num)
}
NSLog("📺 BetterDisplay resolveScreen: looking for displayID=\(displayID) (UInt32=\(target)) among screens: \(availableScreens)")
let matched = NSScreen.screens.first { screen in
guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else {
return false
}
return screenNumber.uint32Value == target
}
if matched == nil {
NSLog("📺 BetterDisplay resolveScreen: no match found for displayID=\(displayID), HUD will show on all screens")
}
return matched
NSLog("📺 BetterDisplay resolveScreen: no match found for displayID=\(displayID), HUD will show on all screens")
return nil
}
/// Whether the given displayID refers to an external (non-built-in) display.
private func isExternal(displayID: Int?) -> Bool {
guard let displayID else { return false }
return CGDisplayIsBuiltin(UInt32(displayID)) == 0
private func isExternal(displayID: Int?, resolvedScreen: NSScreen?) -> Bool {
if let resolvedScreen,
let resolvedDisplayID = screenNumber(for: resolvedScreen) {
return CGDisplayIsBuiltin(resolvedDisplayID) == 0
}
guard let displayID,
let displayIDValue = UInt32(exactly: displayID) else {
return false
}
return CGDisplayIsBuiltin(displayIDValue) == 0
}
private func screenNumber(for screen: NSScreen) -> UInt32? {
guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else {
return nil
}
return screenNumber.uint32Value
}
private func classifyControlTarget(_ target: String?, systemIconID: Int?) -> BetterDisplayControlCategory {
@ -341,6 +396,17 @@ final class BetterDisplayManager: ObservableObject {
}
}
private func categoryName(_ category: BetterDisplayControlCategory) -> String {
switch category {
case .brightness:
return "brightness"
case .volume:
return "volume"
case .other:
return "other"
}
}
/// Normalize a BetterDisplay value (0...maxValue) to 0...1 range.
private func normalizeValue(_ value: Double?, maxValue: Double?) -> CGFloat {
guard let value else { return 0 }
@ -464,7 +530,7 @@ final class BetterDisplayManager: ObservableObject {
Task { @MainActor in
NSLog("🔴 BetterDisplay terminated (workspace notification)")
self?.isRunning = false
self?.stopListening()
self?.refreshListeningState()
}
}
}
@ -483,9 +549,7 @@ final class BetterDisplayManager: ObservableObject {
NSLog("🟢 BetterDisplay launched notification received")
self?.isDetected = true
self?.isRunning = true
if Defaults[.enableBetterDisplayIntegration] {
self?.startListening()
}
self?.refreshListeningState()
}
}
@ -497,23 +561,34 @@ final class BetterDisplayManager: ObservableObject {
Task { @MainActor in
NSLog("🔴 BetterDisplay terminated notification received")
self?.isRunning = false
self?.stopListening()
self?.refreshListeningState()
}
}
}
// MARK: - Settings Observer
private func refreshListeningState() {
if isBetterDisplayIntegrationEnabled && isRunning {
startListening()
} else {
stopListening()
}
}
private func setupSettingsObserver() {
Defaults.publisher(.enableBetterDisplayIntegration, options: [])
.sink { [weak self] change in
Defaults.publisher(.enableThirdPartyDDCIntegration, options: [])
.sink { [weak self] _ in
Task { @MainActor in
guard let self else { return }
if change.newValue && self.isRunning {
self.startListening()
} else {
self.stopListening()
}
self?.refreshListeningState()
}
}
.store(in: &cancellables)
Defaults.publisher(.thirdPartyDDCProvider, options: [])
.sink { [weak self] _ in
Task { @MainActor in
self?.refreshListeningState()
}
}
.store(in: &cancellables)

View file

@ -128,6 +128,7 @@ final class ExtensionLiveActivityManager: ObservableObject {
if previousCount != activeActivities.count {
Logger.log("Dismissed extension live activity \(activityID) from \(bundleIdentifier)", category: .extensions)
ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: activityID)
ExtensionRPCServer.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: activityID)
logDiagnostics("Removed live activity \(activityID) for \(bundleIdentifier); remaining: \(activeActivities.count)")
broadcastSnapshot()
}
@ -140,7 +141,10 @@ final class ExtensionLiveActivityManager: ObservableObject {
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
activeActivities.removeAll { $0.bundleIdentifier == bundleIdentifier }
}
ids.forEach { ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0) }
ids.forEach {
ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0)
ExtensionRPCServer.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0)
}
if !ids.isEmpty {
logDiagnostics("Removed all live activities for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))")
broadcastSnapshot()

View file

@ -118,6 +118,7 @@ final class ExtensionLockScreenWidgetManager: ObservableObject {
if previousCount != activeWidgets.count {
Logger.log("Dismissed extension widget \(widgetID) from \(bundleIdentifier)", category: .extensions)
ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: widgetID)
ExtensionRPCServer.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: widgetID)
logDiagnostics("Removed lock screen widget \(widgetID) for \(bundleIdentifier); remaining: \(activeWidgets.count)")
broadcastSnapshot()
}
@ -128,7 +129,10 @@ final class ExtensionLockScreenWidgetManager: ObservableObject {
.filter { $0.bundleIdentifier == bundleIdentifier }
.map { $0.descriptor.id }
activeWidgets.removeAll { $0.bundleIdentifier == bundleIdentifier }
ids.forEach { ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: $0) }
ids.forEach {
ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: $0)
ExtensionRPCServer.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: $0)
}
if !ids.isEmpty {
logDiagnostics("Removed all lock screen widgets for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))")
broadcastSnapshot()

View file

@ -137,6 +137,7 @@ final class ExtensionNotchExperienceManager: ObservableObject {
guard previousCount != activeExperiences.count else { return }
Logger.log("Dismissed notch experience \(experienceID) from \(bundleIdentifier)", category: .extensions)
ExtensionXPCServiceHost.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: experienceID)
ExtensionRPCServer.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: experienceID)
logDiagnostics("Removed notch experience \(experienceID) for \(bundleIdentifier); remaining: \(activeExperiences.count)")
broadcastSnapshot()
}
@ -148,7 +149,10 @@ final class ExtensionNotchExperienceManager: ObservableObject {
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
activeExperiences.removeAll { $0.bundleIdentifier == bundleIdentifier }
}
ids.forEach { ExtensionXPCServiceHost.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: $0) }
ids.forEach {
ExtensionXPCServiceHost.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: $0)
ExtensionRPCServer.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: $0)
}
if !ids.isEmpty {
logDiagnostics("Removed all notch experiences for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))")
broadcastSnapshot()

View file

@ -39,19 +39,10 @@ class IdleAnimationManager {
// MARK: - Initialization
/// Load bundled animations from the LottieAnimations folder and built-in face
/// Load bundled animations from the LottieAnimations folder
func initializeDefaultAnimations() {
var animations: [CustomIdleAnimation] = []
// Add built-in face as first option
let builtInFace = CustomIdleAnimation(
name: "Classic Face",
source: .builtInFace,
speed: 1.0,
isBuiltIn: true
)
animations.append(builtInFace)
// Load bundled Lottie files
if let bundledAnimations = loadBundledAnimations() {
animations.append(contentsOf: bundledAnimations)
@ -60,14 +51,21 @@ class IdleAnimationManager {
// Get existing animations
var existing = Defaults[.customIdleAnimations]
// Remove any legacy builtInFace entries that may be stored from older versions
existing.removeAll { animation in
if case .lottieFile(let url) = animation.source, url.absoluteString == "builtin://face" {
return true
}
return false
}
if existing.isEmpty {
// First launch - set everything
Defaults[.customIdleAnimations] = animations
Defaults[.selectedIdleAnimation] = builtInFace
Defaults[.selectedIdleAnimation] = animations.first
print("✅ [IdleAnimationManager] First launch: Initialized with \(animations.count) animations")
} else {
// Subsequent launch - ensure all bundled animations are present
let existingIDs = Set(existing.map { $0.id })
let existingNames = Set(existing.filter { $0.isBuiltIn }.map { $0.name })
// Add any missing bundled animations
@ -79,7 +77,23 @@ class IdleAnimationManager {
}
Defaults[.customIdleAnimations] = existing
print("✅ [IdleAnimationManager] Subsequent launch: \(existing.count) total animations (\(animations.count - 1) bundled + built-in face)")
// If current selection was the old built-in face (no longer valid), select first available
if let selected = Defaults[.selectedIdleAnimation] {
let isValid: Bool
switch selected.source {
case .lottieFile(let url):
isValid = url.absoluteString != "builtin://face"
case .lottieURL:
isValid = true
}
if !isValid {
Defaults[.selectedIdleAnimation] = existing.first
print("🔄 [IdleAnimationManager] Migrated selection from legacy face to: \(existing.first?.name ?? "nil")")
}
}
print("✅ [IdleAnimationManager] Subsequent launch: \(existing.count) total animations")
}
}

View file

@ -0,0 +1,574 @@
/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@preconcurrency import Foundation
import AppKit
import Combine
import Defaults
import Network
// MARK: - Lunar Display Data Model
/// Matches the JSON structure streamed by Lunar's socket listener.
struct LunarDisplayData: Codable {
var brightness: Float?
var contrast: Float?
var volume: Float?
var mute: Bool?
var nits: Float?
var display: Int
}
// MARK: - Lunar Control Category
enum LunarControlCategory {
case brightness
case contrast
case volume
}
// MARK: - Lunar Manager
/// Manages integration with the Lunar app (fyi.lunar.Lunar).
///
/// Responsibilities:
/// - Detect whether Lunar is installed and running
/// - Observe app launch / termination to connect / disconnect automatically
/// - Connect to Lunar's TCP socket on localhost:23803 and listen for DDC changes
/// - Route brightness / contrast / volume events to Atoll's HUD pipeline
@MainActor
final class LunarManager: ObservableObject {
static let shared = LunarManager()
/// Bundle identifier of the Lunar app.
nonisolated static let bundleID = "fyi.lunar.Lunar"
/// UserDefaults suite used by Lunar to store its API key.
private static let lunarDomain = "fyi.lunar.Lunar"
private static let apiKeyKey = "apiKey"
// MARK: Published state
/// Whether Lunar is currently detected (installed) on this machine.
@Published private(set) var isDetected: Bool = false
/// Whether Lunar is currently running.
@Published private(set) var isRunning: Bool = false
/// Whether the TCP socket connection to Lunar is active.
@Published private(set) var isConnected: Bool = false
// MARK: Private
private var connection: NWConnection?
private var workspaceLaunchObserver: NSObjectProtocol?
private var workspaceTermObserver: NSObjectProtocol?
private var cancellables = Set<AnyCancellable>()
private weak var coordinator: DynamicIslandViewCoordinator?
/// Reconnection back-off state.
private var reconnectTask: Task<Void, Never>?
private var reconnectAttempts: Int = 0
private static let maxReconnectAttempts = 5
private static let baseReconnectDelay: UInt64 = 2_000_000_000 // 2 seconds
private var isLunarIntegrationEnabled: Bool {
Defaults[.enableThirdPartyDDCIntegration] && Defaults[.thirdPartyDDCProvider] == .lunar
}
private var isExternalVolumeListenerEnabled: Bool {
isLunarIntegrationEnabled && Defaults[.enableExternalVolumeControlListener]
}
private init() {
isDetected = Self.checkInstallation()
isRunning = Self.checkRunning()
setupWorkspaceObservers()
setupSettingsObserver()
}
// MARK: - Public API
/// Configure with the view coordinator for HUD dispatch.
func configure(coordinator: DynamicIslandViewCoordinator) {
self.coordinator = coordinator
refreshConnectionState()
}
/// Called when Atoll is about to quit so Lunar's native OSD is restored.
func appWillTerminate() {
setLunarHideOSD(false)
}
/// Refresh detection status (e.g. after manual check from Settings).
func refreshDetectionStatus() {
let wasRunning = isRunning
isDetected = Self.checkInstallation()
isRunning = Self.checkRunning()
if wasRunning != isRunning {
NSLog("🌙 Lunar running state changed: running=\(isRunning)")
}
refreshConnectionState()
}
// MARK: - Detection
/// Check if Lunar is installed by looking for its bundle ID.
nonisolated static func checkInstallation() -> Bool {
if NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bundleID }) {
return true
}
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) {
return FileManager.default.fileExists(atPath: url.path)
}
return false
}
/// Check if Lunar is currently running.
nonisolated static func checkRunning() -> Bool {
NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bundleID })
}
// MARK: - Socket Connection
private func connectToLunar() {
guard connection == nil else { return }
guard let apiKey = Self.retrieveAPIKey() else {
NSLog("⚠️ Lunar: failed to retrieve API key from UserDefaults suite '\(Self.lunarDomain)'")
return
}
setLunarHideOSD(true)
let host = NWEndpoint.Host("localhost")
let port = NWEndpoint.Port(23803)
let conn = NWConnection(host: host, port: port, using: .tcp)
conn.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
self?.handleConnectionState(state, apiKey: apiKey)
}
}
connection = conn
conn.start(queue: DispatchQueue.global(qos: .utility))
NSLog("🌙 Lunar: connecting to socket on \(host):\(port)…")
}
private func disconnect() {
reconnectTask?.cancel()
reconnectTask = nil
reconnectAttempts = 0
if let conn = connection {
conn.stateUpdateHandler = nil
conn.cancel()
connection = nil
}
isConnected = false
setLunarHideOSD(false)
NSLog("⏹ Lunar: socket disconnected")
}
private func handleConnectionState(_ state: NWConnection.State, apiKey: String) {
switch state {
case .ready:
isConnected = true
reconnectAttempts = 0
NSLog("🌙 Lunar: connected to socket")
sendListenCommand(apiKey: apiKey)
receiveMessages()
case .failed(let error):
NSLog("🌙 Lunar: connection failed — \(error.localizedDescription)")
isConnected = false
connection?.cancel()
connection = nil
scheduleReconnect()
case .cancelled:
isConnected = false
case .waiting(let error):
NSLog("🌙 Lunar: connection waiting — \(error.localizedDescription)")
default:
break
}
}
/// Send Lunar's listen command with the API key.
private func sendListenCommand(apiKey: String) {
guard let conn = connection else { return }
let separator = String(UnicodeScalar(0x01))
let message = [apiKey, "listen", "--json", "--only-user-adjustments"]
.joined(separator: separator)
guard let data = message.data(using: .utf8) else {
NSLog("⚠️ Lunar: failed to encode listen command")
return
}
conn.send(content: data, completion: .contentProcessed { error in
if let error {
NSLog("⚠️ Lunar: failed to send listen command — \(error.localizedDescription)")
} else {
NSLog("🌙 Lunar: listen command sent")
}
})
}
/// Receive JSON messages from Lunar's socket in a loop.
private func receiveMessages() {
guard let conn = connection else { return }
conn.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in
if let data, !data.isEmpty {
Task { @MainActor in
self?.handleReceivedData(data)
}
}
if isComplete {
NSLog("🌙 Lunar: stream ended")
Task { @MainActor in
self?.isConnected = false
self?.connection?.cancel()
self?.connection = nil
self?.scheduleReconnect()
}
} else if let error {
NSLog("⚠️ Lunar: receive error — \(error.localizedDescription)")
Task { @MainActor in
self?.isConnected = false
self?.connection?.cancel()
self?.connection = nil
self?.scheduleReconnect()
}
} else {
// Continue reading
Task { @MainActor in
self?.receiveMessages()
}
}
}
}
private func handleReceivedData(_ data: Data) {
guard isLunarIntegrationEnabled, isRunning else { return }
do {
let displayData = try JSONDecoder().decode(LunarDisplayData.self, from: data)
logDecodedPayload(displayData, source: "socket")
routeToHUD(displayData)
} catch {
// Lunar may send multi-line JSON or partial data; try splitting by newlines
let rawString = String(data: data, encoding: .utf8) ?? ""
let lines = rawString.components(separatedBy: .newlines).filter { !$0.isEmpty }
for line in lines {
guard let lineData = line.data(using: .utf8) else { continue }
if let displayData = try? JSONDecoder().decode(LunarDisplayData.self, from: lineData) {
logDecodedPayload(displayData, source: "socket-line")
routeToHUD(displayData)
}
}
}
}
// MARK: - HUD Routing
private func routeToHUD(_ data: LunarDisplayData) {
let targetScreen = resolveScreen(for: data.display)
let isExternal = isExternalDisplay(data.display, resolvedScreen: targetScreen)
let hasVolumeData = data.volume != nil || data.mute != nil
let externalVolumeListenerEnabled = isExternalVolumeListenerEnabled
let resolvedScreenText = targetScreen?.localizedName ?? "nil"
let brightnessText = data.brightness.map { String($0) } ?? "nil"
let contrastText = data.contrast.map { String($0) } ?? "nil"
let volumeText = data.volume.map { String($0) } ?? "nil"
let muteText = data.mute.map { String($0) } ?? "nil"
NSLog(
"🌙 Lunar routed payload: display=\(data.display) resolvedScreen=\(resolvedScreenText) isExternal=\(isExternal) brightness=\(brightnessText) contrast=\(contrastText) volume=\(volumeText) mute=\(muteText) hasVolumeData=\(hasVolumeData) externalVolumeListener=\(externalVolumeListenerEnabled)"
)
// Nits-only events are informational (no user-facing HUD needed).
let hasActionableChange = data.brightness != nil || data.contrast != nil || data.volume != nil || data.mute != nil
guard hasActionableChange else { return }
// Brightness — values arrive in 0…1 range from Lunar
if let brightness = data.brightness {
let value = CGFloat(brightness)
let icon = isExternal ? "display" : ""
dispatchBrightnessHUD(value: value, icon: icon, onScreen: targetScreen)
}
// Contrast — show as brightness-style HUD with a contrast icon
if let contrast = data.contrast {
let value = CGFloat(contrast)
dispatchBrightnessHUD(value: value, icon: "circle.righthalf.filled", onScreen: targetScreen)
}
// Volume
if externalVolumeListenerEnabled {
if let volume = data.volume {
let value = CGFloat(volume)
let isMuted = data.mute ?? false
dispatchVolumeHUD(value: value, isMuted: isMuted, onScreen: targetScreen)
} else if let mute = data.mute {
// Mute toggle without a volume value
dispatchVolumeHUD(value: mute ? 0 : 1, isMuted: mute, onScreen: targetScreen)
}
} else if hasVolumeData {
NSLog("🌙 Lunar volume payload ignored because external volume listener is disabled")
}
}
// MARK: - HUD Dispatch (mirrors BetterDisplayManager / SystemChangesObserver logic)
private func dispatchVolumeHUD(value: CGFloat, isMuted: Bool, onScreen targetScreen: NSScreen? = nil) {
if HUDSuppressionCoordinator.shared.shouldSuppressVolumeHUD { return }
if Defaults[.enableCircularHUD] {
CircularHUDWindowManager.shared.show(type: .volume, value: value, onScreen: targetScreen)
return
}
if Defaults[.enableVerticalHUD] {
VerticalHUDWindowManager.shared.show(type: .volume, value: value, icon: "", onScreen: targetScreen)
return
}
if Defaults[.enableCustomOSD] && Defaults[.enableOSDVolume] {
CustomOSDWindowManager.shared.showVolume(value: value, onScreen: targetScreen)
}
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] {
coordinator?.toggleSneakPeek(
status: true,
type: .volume,
value: value,
icon: "",
onScreen: targetScreen
)
}
}
private func dispatchBrightnessHUD(value: CGFloat, icon: String = "", onScreen targetScreen: NSScreen? = nil) {
if Defaults[.enableCircularHUD] {
CircularHUDWindowManager.shared.show(type: .brightness, value: value, icon: icon, onScreen: targetScreen)
return
}
if Defaults[.enableVerticalHUD] {
VerticalHUDWindowManager.shared.show(type: .brightness, value: value, icon: icon, onScreen: targetScreen)
return
}
if Defaults[.enableCustomOSD] && Defaults[.enableOSDBrightness] {
CustomOSDWindowManager.shared.showBrightness(value: value, icon: icon, onScreen: targetScreen)
}
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] {
coordinator?.toggleSneakPeek(
status: true,
type: .brightness,
value: value,
icon: icon,
onScreen: targetScreen
)
}
}
// MARK: - Helpers
/// Retrieve the Lunar API key from its UserDefaults suite.
nonisolated private static func retrieveAPIKey() -> String? {
UserDefaults(suiteName: lunarDomain)?.string(forKey: apiKeyKey)
}
/// Resolve a Lunar display ID (CGDirectDisplayID) to the matching NSScreen.
private func resolveScreen(for displayID: Int) -> NSScreen? {
if let directDisplayID = UInt32(exactly: displayID),
let matchedScreen = NSScreen.screens.first(where: { screenNumber(for: $0) == directDisplayID }) {
return matchedScreen
}
// Fallback for integrations that send 1-based display indexes (e.g. 1, 2)
if displayID > 0 {
let index = displayID - 1
if NSScreen.screens.indices.contains(index) {
let fallback = NSScreen.screens[index]
NSLog("🌙 Lunar: display=\(displayID) resolved via index fallback to '\(fallback.localizedName)'")
return fallback
}
}
NSLog("🌙 Lunar: unable to resolve display=\(displayID), HUD will show on all screens")
return nil
}
/// Whether the given displayID refers to an external (non-built-in) display.
private func isExternalDisplay(_ displayID: Int, resolvedScreen: NSScreen?) -> Bool {
if let resolvedScreen,
let resolvedDisplayID = screenNumber(for: resolvedScreen) {
return CGDisplayIsBuiltin(resolvedDisplayID) == 0
}
if let displayIDValue = UInt32(exactly: displayID) {
return CGDisplayIsBuiltin(displayIDValue) == 0
}
return false
}
private func screenNumber(for screen: NSScreen) -> UInt32? {
guard let num = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else {
return nil
}
return num.uint32Value
}
private func logDecodedPayload(_ data: LunarDisplayData, source: String) {
let hasVolumeData = data.volume != nil || data.mute != nil
let brightnessText = data.brightness.map { String($0) } ?? "nil"
let contrastText = data.contrast.map { String($0) } ?? "nil"
let volumeText = data.volume.map { String($0) } ?? "nil"
let muteText = data.mute.map { String($0) } ?? "nil"
NSLog(
"🌙 Lunar decoded payload (\(source)): display=\(data.display) brightness=\(brightnessText) contrast=\(contrastText) volume=\(volumeText) mute=\(muteText) hasVolumeData=\(hasVolumeData)"
)
}
// MARK: - Reconnection
private func scheduleReconnect() {
guard isLunarIntegrationEnabled, isRunning else { return }
guard reconnectAttempts < Self.maxReconnectAttempts else {
NSLog("🌙 Lunar: max reconnect attempts reached, giving up")
return
}
reconnectTask?.cancel()
let attempt = reconnectAttempts
reconnectAttempts += 1
let delay = Self.baseReconnectDelay * UInt64(1 << min(attempt, 3)) // exponential back-off, max 16s
reconnectTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: delay)
guard !Task.isCancelled, self.isRunning, self.isLunarIntegrationEnabled else { return }
NSLog("🌙 Lunar: reconnect attempt \(attempt + 1)/\(Self.maxReconnectAttempts)")
self.connectToLunar()
}
}
// MARK: - Workspace Observers (detect Lunar launch / quit)
private func setupWorkspaceObservers() {
let lunarBundleID = Self.bundleID
workspaceLaunchObserver = NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didLaunchApplicationNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
app.bundleIdentifier == lunarBundleID
else { return }
Task { @MainActor in
NSLog("🟢 Lunar launched (pid: \(app.processIdentifier))")
self?.isDetected = true
self?.isRunning = true
self?.reconnectAttempts = 0
if self?.isLunarIntegrationEnabled == true {
// Give Lunar a moment to start its socket server
try? await Task.sleep(nanoseconds: 1_500_000_000)
self?.connectToLunar()
}
}
}
workspaceTermObserver = NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didTerminateApplicationNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
app.bundleIdentifier == lunarBundleID
else { return }
Task { @MainActor in
NSLog("🔴 Lunar terminated (pid: \(app.processIdentifier))")
self?.isRunning = false
self?.disconnect()
}
}
}
// MARK: - Settings Observer
private func refreshConnectionState() {
if isLunarIntegrationEnabled && isRunning {
connectToLunar()
} else {
disconnect()
}
}
private func setupSettingsObserver() {
Defaults.publisher(.enableThirdPartyDDCIntegration, options: [])
.sink { [weak self] _ in
Task { @MainActor in
self?.refreshConnectionState()
}
}
.store(in: &cancellables)
Defaults.publisher(.thirdPartyDDCProvider, options: [])
.sink { [weak self] _ in
Task { @MainActor in
self?.refreshConnectionState()
}
}
.store(in: &cancellables)
}
deinit {
// Restore Lunar's native OSD synchronously on teardown
LunarManager.writeLunarDefault(hideOSD: false)
connection?.cancel()
reconnectTask?.cancel()
if let workspaceLaunchObserver {
NSWorkspace.shared.notificationCenter.removeObserver(workspaceLaunchObserver)
}
if let workspaceTermObserver {
NSWorkspace.shared.notificationCenter.removeObserver(workspaceTermObserver)
}
cancellables.removeAll()
}
// MARK: - Lunar OSD Suppression
/// Tell Lunar to hide (or show) its own native OSD by writing to its UserDefaults.
private func setLunarHideOSD(_ hide: Bool) {
Self.writeLunarDefault(hideOSD: hide)
}
/// Synchronous, nonisolated helper so it can also be called from `deinit`.
nonisolated private static func writeLunarDefault(hideOSD hide: Bool) {
guard let defaults = UserDefaults(suiteName: lunarDomain) else {
NSLog("⚠️ Lunar: unable to open UserDefaults suite '\(lunarDomain)' to set hideOSD")
return
}
defaults.set(hide, forKey: "hideOSD")
NSLog("🌙 Lunar: hideOSD set to \(hide)")
}
}

View file

@ -630,15 +630,19 @@ class MusicManager: ObservableObject {
func togglePlay() {
guard let controller = activeController else { return }
let targetState = !isPlaying
Task {
await MainActor.run {
let newState = !isPlaying
pendingOptimisticPlayState = newState
applyPlayState(newState, animation: .smooth(duration: 0.18))
pendingOptimisticPlayState = targetState
applyPlayState(targetState, animation: .smooth(duration: 0.18))
}
await controller.togglePlay()
if targetState {
await controller.play()
} else {
await controller.pause()
}
}
}

View file

@ -662,12 +662,14 @@ class ScreenAssistantManager: NSObject, ObservableObject {
return
}
let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
var task: URLSessionDataTask?
task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
DispatchQueue.main.async {
guard let self = self else { return }
guard let currentTask = task else { return }
// Ensure this callback belongs to the current in-flight request
guard self.activeRequest === task else { return }
guard self.activeRequest === currentTask else { return }
self.isLoading = false
self.activeRequest = nil
@ -677,7 +679,7 @@ class ScreenAssistantManager: NSObject, ObservableObject {
}
activeRequest = task
task.resume()
task?.resume()
}
private func performOpenAIRequest(url: URL, requestBody: [String: Any], apiKey: String) {
@ -696,12 +698,14 @@ class ScreenAssistantManager: NSObject, ObservableObject {
return
}
let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
var task: URLSessionDataTask?
task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
DispatchQueue.main.async {
guard let self = self else { return }
guard let currentTask = task else { return }
// Ensure this callback belongs to the current in-flight request
guard self.activeRequest === task else { return }
guard self.activeRequest === currentTask else { return }
self.isLoading = false
self.activeRequest = nil
@ -711,7 +715,7 @@ class ScreenAssistantManager: NSObject, ObservableObject {
}
activeRequest = task
task.resume()
task?.resume()
}
private func performClaudeRequest(url: URL, requestBody: [String: Any], apiKey: String) {
@ -731,12 +735,14 @@ class ScreenAssistantManager: NSObject, ObservableObject {
return
}
let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
var task: URLSessionDataTask?
task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
DispatchQueue.main.async {
guard let self = self else { return }
guard let currentTask = task else { return }
// Ensure this callback belongs to the current in-flight request
guard self.activeRequest === task else { return }
guard self.activeRequest === currentTask else { return }
self.isLoading = false
self.activeRequest = nil
@ -746,7 +752,7 @@ class ScreenAssistantManager: NSObject, ObservableObject {
}
activeRequest = task
task.resume()
task?.resume()
}
// MARK: - Response Handlers

View file

@ -414,14 +414,20 @@ class StatsManager: ObservableObject {
private var delayedStopTimer: Timer?
private var delayedStartTimer: Timer?
private let maxHistoryPoints = 30
/// Cached host port to avoid leaking Mach send rights.
/// Every call to `mach_host_self()` acquires a new send right that must be
/// explicitly deallocated; caching it once prevents port exhaustion over time.
private let hostPort: mach_port_t = mach_host_self()
private let totalPhysicalMemory: UInt64 = {
var stats = host_basic_info()
var count = mach_msg_type_number_t(MemoryLayout<host_basic_info_data_t>.size / MemoryLayout<integer_t>.size)
let initHostPort = mach_host_self()
let result = withUnsafeMutablePointer(to: &stats) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_info(mach_host_self(), HOST_BASIC_INFO, $0, &count)
host_info(initHostPort, HOST_BASIC_INFO, $0, &count)
}
}
mach_port_deallocate(mach_task_self_, initHostPort)
if result == KERN_SUCCESS {
return UInt64(stats.max_mem)
}
@ -786,7 +792,7 @@ class StatsManager: ObservableObject {
let result = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, $0, &count)
host_statistics(hostPort, HOST_CPU_LOAD_INFO, $0, &count)
}
}
@ -858,7 +864,7 @@ class StatsManager: ObservableObject {
let vmResult = withUnsafeMutablePointer(to: &vmStatistics) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(size)) {
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &size)
host_statistics64(hostPort, HOST_VM_INFO64, $0, &size)
}
}
@ -978,7 +984,7 @@ class StatsManager: ObservableObject {
var cpuInfo: processor_info_array_t?
var numCpuInfo: mach_msg_type_number_t = 0
var numCPUsU: natural_t = 0
let result = host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO, &numCPUsU, &cpuInfo, &numCpuInfo)
let result = host_processor_info(hostPort, PROCESSOR_CPU_LOAD_INFO, &numCPUsU, &cpuInfo, &numCpuInfo)
guard result == KERN_SUCCESS, let cpuInfo else {
return cpuCoreUsage
}

View file

@ -181,8 +181,22 @@ class SystemHUDManager {
)
}.store(in: &cancellables)
// When BetterDisplay integration toggled, restart observer to update Cmd+Brightness key interception
Defaults.publisher(.enableBetterDisplayIntegration, options: []).sink { [weak self] _ in
// Restart observer when third-party DDC integration state changes.
Defaults.publisher(.enableThirdPartyDDCIntegration, options: []).sink { [weak self] _ in
guard let self = self, self.isSetupComplete else { return }
Task { @MainActor in
await self.startSystemObserver()
}
}.store(in: &cancellables)
Defaults.publisher(.thirdPartyDDCProvider, options: []).sink { [weak self] _ in
guard let self = self, self.isSetupComplete else { return }
Task { @MainActor in
await self.startSystemObserver()
}
}.store(in: &cancellables)
Defaults.publisher(.enableExternalVolumeControlListener, options: []).sink { [weak self] _ in
guard let self = self, self.isSetupComplete else { return }
Task { @MainActor in
await self.startSystemObserver()
@ -220,7 +234,7 @@ class SystemHUDManager {
}
}
/// Resolves the effective control flags, applying BetterDisplay overrides.
/// Resolves the effective control flags, applying third-party DDC overrides.
private func resolvedControlFlags() -> (volume: Bool, brightness: Bool, backlight: Bool) {
var volumeEnabled: Bool
var brightnessEnabled: Bool
@ -240,11 +254,16 @@ class SystemHUDManager {
keyboardBacklightEnabled = Defaults[.enableKeyboardBacklightHUD]
}
// When BetterDisplay integration is on, stop intercepting brightness and
// Cmd+Brightness keys so BetterDisplay receives them and sends OSD notifications.
if Defaults[.enableBetterDisplayIntegration] {
// When third-party DDC integration is on, stop intercepting brightness and
// Cmd+Brightness keys so the external app receives them and sends DDC/OSD events.
if Defaults[.enableThirdPartyDDCIntegration] {
brightnessEnabled = false
keyboardBacklightEnabled = false
// Optional: route volume exclusively from the selected external provider.
if Defaults[.enableExternalVolumeControlListener] {
volumeEnabled = false
}
}
return (volumeEnabled, brightnessEnabled, keyboardBacklightEnabled)
@ -273,7 +292,7 @@ class SystemHUDManager {
// Force disable system HUD to ensure no duplicates
SystemOSDManager.disableSystemHUD()
print("System observer started (HUD: \(Defaults[.enableSystemHUD]), OSD: \(Defaults[.enableCustomOSD]), Vertical: \(Defaults[.enableVerticalHUD]), BD: \(Defaults[.enableBetterDisplayIntegration]))")
print("System observer started (HUD: \(Defaults[.enableSystemHUD]), OSD: \(Defaults[.enableCustomOSD]), Vertical: \(Defaults[.enableVerticalHUD]), ThirdPartyDDC: \(Defaults[.enableThirdPartyDDCIntegration]), Provider: \(Defaults[.thirdPartyDDCProvider].displayName), ExternalVolumeListener: \(Defaults[.enableExternalVolumeControlListener]))")
isSystemOperationInProgress = false
}

View file

@ -181,15 +181,28 @@ class TerminalManager: ObservableObject {
terminalTitle = title
}
// MARK: - Font Resolution
/// Resolves the terminal font from the user's chosen family and size.
///
/// - If `family` is empty, returns the system monospaced font.
/// - Otherwise tries `NSFont(name:size:)` and falls back to system monospaced
/// when the name is invalid or the font is not installed.
private func resolveFont(family: String, size: CGFloat) -> NSFont {
if !family.isEmpty, let custom = NSFont(name: family, size: size) {
return custom
}
return NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
// MARK: - Settings Application
/// Applies all persisted settings to a terminal view at creation time.
private func applyAllSettings(to view: LocalProcessTerminalView) {
// Font
let fontSize = CGFloat(Defaults[.terminalFontSize])
if let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) as NSFont? {
view.font = font
}
let fontFamily = Defaults[.terminalFontFamily]
view.font = resolveFont(family: fontFamily, size: fontSize)
// Opacity
view.layer?.opacity = Float(Defaults[.terminalOpacity])
@ -220,9 +233,15 @@ class TerminalManager: ObservableObject {
/// Updates font size on the live terminal view.
func applyFontSize(_ size: Double) {
guard let view = terminalView else { return }
if let font = NSFont.monospacedSystemFont(ofSize: CGFloat(size), weight: .regular) as NSFont? {
view.font = font
}
let fontFamily = Defaults[.terminalFontFamily]
view.font = resolveFont(family: fontFamily, size: CGFloat(size))
}
/// Updates font family on the live terminal view.
func applyFontFamily(_ family: String) {
guard let view = terminalView else { return }
let fontSize = CGFloat(Defaults[.terminalFontSize])
view.font = resolveFont(family: family, size: fontSize)
}
/// Updates opacity on the live terminal view.

View file

@ -128,13 +128,11 @@ enum AnimationLoopMode: String, Codable, CaseIterable {
enum AnimationSource: Codable, Hashable, Equatable {
case lottieFile(URL) // Local file (in app support or bundle)
case lottieURL(URL) // Remote URL
case builtInFace // Original MinimalFaceFeatures
var displayType: String {
switch self {
case .lottieFile: return "Local"
case .lottieURL: return "Remote"
case .builtInFace: return "Built-in"
}
}
}
@ -316,6 +314,22 @@ enum ColorPickerDisplayMode: String, CaseIterable, Codable, Defaults.Serializabl
}
}
enum ThirdPartyDDCProvider: String, CaseIterable, Codable, Defaults.Serializable, Identifiable {
case betterDisplay
case lunar
var id: String { rawValue }
var displayName: String {
switch self {
case .betterDisplay:
return "BetterDisplay"
case .lunar:
return "Lunar"
}
}
}
enum HideNotchOption: String, Defaults.Serializable {
case always
case nowPlayingOnly
@ -774,6 +788,7 @@ extension Defaults.Keys {
static let musicSkipBehavior = Key<MusicSkipBehavior>("musicSkipBehavior", default: .track)
static let musicControlWindowEnabled = Key<Bool>("musicControlWindowEnabled", default: false)
static let showStandardMediaControls = Key<Bool>("showStandardMediaControls", default: true)
static let autoHideInactiveNotchMediaPlayer = Key<Bool>("autoHideInactiveNotchMediaPlayer", default: true)
static let cachedMusicLiveActivityPreference = Key<Bool?>("cachedMusicLiveActivityPreference", default: nil)
static let cachedLockScreenMediaWidgetPreference = Key<Bool?>("cachedLockScreenMediaWidgetPreference", default: nil)
static let cachedMusicControlWindowPreference = Key<Bool?>("cachedMusicControlWindowPreference", default: nil)
@ -915,6 +930,7 @@ extension Defaults.Keys {
// MARK: Terminal Feature
static let enableTerminalFeature = Key<Bool>("enableTerminalFeature", default: false)
static let terminalShellPath = Key<String>("terminalShellPath", default: "/bin/zsh")
static let terminalFontFamily = Key<String>("terminalFontFamily", default: "")
static let terminalFontSize = Key<Double>("terminalFontSize", default: 12.0)
static let terminalOpacity = Key<Double>("terminalOpacity", default: 1.0)
static let terminalMaxHeightFraction = Key<Double>("terminalMaxHeightFraction", default: 0.4)
@ -1027,8 +1043,15 @@ extension Defaults.Keys {
static let circularHUDStrokeWidth = Key<CGFloat>("circularHUDStrokeWidth", default: 4)
static let circularHUDUseAccentColor = Key<Bool>("circularHUDUseAccentColor", default: true)
// MARK: BetterDisplay Integration
// MARK: Third-Party DDC Integration
static let enableThirdPartyDDCIntegration = Key<Bool>("enableThirdPartyDDCIntegration", default: false)
static let thirdPartyDDCProvider = Key<ThirdPartyDDCProvider>("thirdPartyDDCProvider", default: .betterDisplay)
static let enableExternalVolumeControlListener = Key<Bool>("enableExternalVolumeControlListener", default: false)
static let didMigrateThirdPartyDDCIntegration = Key<Bool>("didMigrateThirdPartyDDCIntegration", default: false)
// Legacy keys retained for migration/backward compatibility
static let enableBetterDisplayIntegration = Key<Bool>("enableBetterDisplayIntegration", default: false)
static let enableLunarIntegration = Key<Bool>("enableLunarIntegration", default: false)
static let hasSeenOSDAlphaWarning = Key<Bool>("hasSeenOSDAlphaWarning", default: false)
static let enableOSDVolume = Key<Bool>("enableOSDVolume", default: true)
@ -1152,6 +1175,29 @@ extension Defaults.Keys {
Defaults[.didMigrateMusicControlSlots] = true
}
static func migrateThirdPartyDDCIntegration() {
if Defaults[.didMigrateThirdPartyDDCIntegration] == false {
let legacyBetterDisplayEnabled = Defaults[.enableBetterDisplayIntegration]
let legacyLunarEnabled = Defaults[.enableLunarIntegration]
if legacyBetterDisplayEnabled || legacyLunarEnabled {
Defaults[.enableThirdPartyDDCIntegration] = true
Defaults[.thirdPartyDDCProvider] = (legacyLunarEnabled && !legacyBetterDisplayEnabled) ? .lunar : .betterDisplay
}
Defaults[.didMigrateThirdPartyDDCIntegration] = true
}
syncLegacyThirdPartyDDCKeys()
}
static func syncLegacyThirdPartyDDCKeys() {
let isIntegrationEnabled = Defaults[.enableThirdPartyDDCIntegration]
let selectedProvider = Defaults[.thirdPartyDDCProvider]
Defaults[.enableBetterDisplayIntegration] = isIntegrationEnabled && selectedProvider == .betterDisplay
Defaults[.enableLunarIntegration] = isIntegrationEnabled && selectedProvider == .lunar
}
private static func normalizeMusicAuxControls() {
guard Defaults[.musicAuxLeftControl] == Defaults[.musicAuxRightControl] else { return }

View file

@ -0,0 +1,20 @@
/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
import Foundation
enum AtollDistributedNotifications {
static let didBecomeActive = Notification.Name("com.ebullioscopic.Atoll.lifecycle.didBecomeActive")
static let didBecomeIdle = Notification.Name("com.ebullioscopic.Atoll.lifecycle.didBecomeIdle")
enum UserInfoKey {
static let sourcePID = "sourcePID"
}
}

View file

@ -0,0 +1,184 @@
/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Foundation
// MARK: - JSON-RPC 2.0 Message Types
/// Incoming JSON-RPC 2.0 request from a client.
struct RPCRequest: Codable {
let jsonrpc: String
let method: String
let params: RPCParams?
let id: String
}
/// Outgoing JSON-RPC 2.0 success response.
struct RPCSuccessResponse: Codable {
let jsonrpc: String
let result: [String: RPCValue]
let id: String
init(result: [String: RPCValue], id: String) {
self.jsonrpc = "2.0"
self.result = result
self.id = id
}
}
/// Outgoing JSON-RPC 2.0 error response.
struct RPCErrorResponse: Codable {
let jsonrpc: String
let error: RPCErrorObject
let id: String?
init(error: RPCErrorObject, id: String?) {
self.jsonrpc = "2.0"
self.error = error
self.id = id
}
}
/// JSON-RPC error object.
struct RPCErrorObject: Codable {
let code: Int
let message: String
let data: String?
init(code: Int, message: String, data: String? = nil) {
self.code = code
self.message = message
self.data = data
}
}
/// Outgoing JSON-RPC 2.0 notification (server → client, no id).
struct RPCNotification: Codable {
let jsonrpc: String
let method: String
let params: [String: RPCValue]
init(method: String, params: [String: RPCValue]) {
self.jsonrpc = "2.0"
self.method = method
self.params = params
}
}
// MARK: - Standard Error Codes
enum RPCErrorCode {
static let parseError = -32700
static let invalidRequest = -32600
static let methodNotFound = -32601
static let invalidParams = -32602
static let internalError = -32603
// Application-specific codes
static let unauthorized = -32001
static let featureDisabled = -32002
static let capacityExceeded = -32003
static let descriptorInvalid = -32004
static let unsupported = -32005
}
// MARK: - Flexible JSON Value
/// Type-erased JSON value for RPC params and results.
enum RPCValue: Codable {
case string(String)
case int(Int)
case double(Double)
case bool(Bool)
case null
case object([String: RPCValue])
case array([RPCValue])
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let b = try? container.decode(Bool.self) {
self = .bool(b)
} else if let i = try? container.decode(Int.self) {
self = .int(i)
} else if let d = try? container.decode(Double.self) {
self = .double(d)
} else if let s = try? container.decode(String.self) {
self = .string(s)
} else if let obj = try? container.decode([String: RPCValue].self) {
self = .object(obj)
} else if let arr = try? container.decode([RPCValue].self) {
self = .array(arr)
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON type")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let s): try container.encode(s)
case .int(let i): try container.encode(i)
case .double(let d): try container.encode(d)
case .bool(let b): try container.encode(b)
case .null: try container.encodeNil()
case .object(let o): try container.encode(o)
case .array(let a): try container.encode(a)
}
}
var stringValue: String? {
if case .string(let s) = self { return s }
return nil
}
var boolValue: Bool? {
if case .bool(let b) = self { return b }
return nil
}
var objectValue: [String: RPCValue]? {
if case .object(let o) = self { return o }
return nil
}
}
// MARK: - Flexible Params
/// JSON-RPC params can be an object or individual values.
struct RPCParams: Codable {
let values: [String: RPCValue]
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
self.values = try container.decode([String: RPCValue].self)
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(values)
}
subscript(key: String) -> RPCValue? {
values[key]
}
/// Extract a JSON sub-object as raw Data for use with JSONDecoder.
func jsonData(for key: String) -> Data? {
guard let value = values[key] else { return nil }
return try? JSONEncoder().encode(value)
}
}

View file

@ -0,0 +1,294 @@
/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Foundation
import Network
import Defaults
/// WebSocket server for Atoll RPC.
/// Uses Apple's Network.framework (`NWListener`) — no external dependencies.
/// Listens on localhost:9020 for JSON-RPC 2.0 requests over WebSocket.
@MainActor
final class ExtensionRPCServer {
static let shared = ExtensionRPCServer()
private var listener: NWListener?
private var connections: [UUID: RPCClientConnection] = [:]
private let port: UInt16 = 9020
private let queue = DispatchQueue(label: "com.ebullioscopic.Atoll.rpc.server", qos: .userInitiated)
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private init() {}
// MARK: - Lifecycle
func start() {
guard listener == nil else {
logDiagnostics("RPC server already running")
return
}
let params = NWParameters(tls: nil)
let wsOptions = NWProtocolWebSocket.Options()
wsOptions.autoReplyPing = true
params.defaultProtocolStack.applicationProtocols.insert(wsOptions, at: 0)
do {
listener = try NWListener(using: params, on: NWEndpoint.Port(integerLiteral: port))
} catch {
Logger.log("Failed to create RPC listener: \(error.localizedDescription)", category: .extensions)
return
}
listener?.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
self?.handleListenerState(state)
}
}
listener?.newConnectionHandler = { [weak self] connection in
Task { @MainActor in
self?.handleNewConnection(connection)
}
}
listener?.start(queue: queue)
}
func stop() {
listener?.cancel()
listener = nil
for (_, conn) in connections {
conn.connection.cancel()
}
connections.removeAll()
logDiagnostics("RPC server stopped")
}
// MARK: - Client Notifications
func notifyActivityDismiss(bundleIdentifier: String, activityID: String) {
sendNotification(
to: bundleIdentifier,
method: "atoll.activityDidDismiss",
params: [
"bundleIdentifier": .string(bundleIdentifier),
"activityID": .string(activityID)
]
)
}
func notifyWidgetDismiss(bundleIdentifier: String, widgetID: String) {
sendNotification(
to: bundleIdentifier,
method: "atoll.widgetDidDismiss",
params: [
"bundleIdentifier": .string(bundleIdentifier),
"widgetID": .string(widgetID)
]
)
}
func notifyNotchExperienceDismiss(bundleIdentifier: String, experienceID: String) {
sendNotification(
to: bundleIdentifier,
method: "atoll.notchExperienceDidDismiss",
params: [
"bundleIdentifier": .string(bundleIdentifier),
"experienceID": .string(experienceID)
]
)
}
func notifyAuthorizationChange(bundleIdentifier: String, isAuthorized: Bool) {
sendNotification(
to: bundleIdentifier,
method: "atoll.authorizationDidChange",
params: [
"bundleIdentifier": .string(bundleIdentifier),
"isAuthorized": .bool(isAuthorized)
]
)
}
// MARK: - Connection Handling
private func handleListenerState(_ state: NWListener.State) {
switch state {
case .ready:
Logger.log("Started Atoll RPC WebSocket server on port \(port)", category: .extensions)
case .failed(let error):
Logger.log("RPC server failed: \(error.localizedDescription)", category: .extensions)
// Attempt restart after delay
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.listener = nil
self?.start()
}
case .cancelled:
logDiagnostics("RPC server listener cancelled")
default:
break
}
}
private func handleNewConnection(_ nwConnection: NWConnection) {
let connID = UUID()
let clientConn = RPCClientConnection(
id: connID,
connection: nwConnection,
bundleIdentifier: nil
)
connections[connID] = clientConn
nwConnection.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
self?.handleConnectionState(connID: connID, state: state)
}
}
nwConnection.start(queue: queue)
receiveMessage(connID: connID)
logDiagnostics("RPC client connected (id: \(connID.uuidString.prefix(8)))")
}
private func handleConnectionState(connID: UUID, state: NWConnection.State) {
switch state {
case .failed, .cancelled:
connections.removeValue(forKey: connID)
logDiagnostics("RPC client disconnected (id: \(connID.uuidString.prefix(8)))")
default:
break
}
}
private func receiveMessage(connID: UUID) {
guard let clientConn = connections[connID] else { return }
let connection = clientConn.connection
connection.receiveMessage { [weak self] content, context, isComplete, error in
guard let self else { return }
if let error {
Task { @MainActor in
self.logDiagnostics("RPC receive error for \(connID.uuidString.prefix(8)): \(error.localizedDescription)")
self.connections.removeValue(forKey: connID)
}
return
}
if let data = content, !data.isEmpty {
Task { @MainActor in
self.processMessage(data: data, connID: connID)
}
}
// Continue receiving
Task { @MainActor in
self.receiveMessage(connID: connID)
}
}
}
private func processMessage(data: Data, connID: UUID) {
guard var clientConn = connections[connID] else { return }
// Parse JSON-RPC request
guard let request = try? decoder.decode(RPCRequest.self, from: data) else {
let errorResponse = RPCErrorResponse(
error: RPCErrorObject(code: RPCErrorCode.parseError, message: "Invalid JSON-RPC request"),
id: nil
)
sendResponse(errorResponse, to: connID)
return
}
// Resolve bundle identifier from first authorization request
if clientConn.bundleIdentifier == nil,
let params = request.params,
let bi = params["bundleIdentifier"]?.stringValue {
clientConn.bundleIdentifier = bi
connections[connID] = clientConn
}
let service = ExtensionRPCService(
bundleIdentifier: clientConn.bundleIdentifier ?? "unknown",
server: self
)
let responseData = service.handleRequest(request)
sendRawData(responseData, to: connID)
}
// MARK: - Send Helpers
private func sendResponse(_ response: Codable, to connID: UUID) {
guard let data = try? encoder.encode(response) else { return }
sendRawData(data, to: connID)
}
private func sendRawData(_ data: Data, to connID: UUID) {
guard let clientConn = connections[connID] else { return }
let metadata = NWProtocolWebSocket.Metadata(opcode: .text)
let context = NWConnection.ContentContext(identifier: "rpc-response", metadata: [metadata])
clientConn.connection.send(
content: data,
contentContext: context,
isComplete: true,
completion: .contentProcessed { error in
if let error {
Task { @MainActor in
self.logDiagnostics("RPC send error: \(error.localizedDescription)")
}
}
}
)
}
private func sendNotification(to bundleIdentifier: String, method: String, params: [String: RPCValue]) {
let notification = RPCNotification(method: method, params: params)
guard let data = try? encoder.encode(notification) else { return }
for (_, clientConn) in connections where clientConn.bundleIdentifier == bundleIdentifier {
let metadata = NWProtocolWebSocket.Metadata(opcode: .text)
let context = NWConnection.ContentContext(identifier: "rpc-notification", metadata: [metadata])
clientConn.connection.send(
content: data,
contentContext: context,
isComplete: true,
completion: .contentProcessed { _ in }
)
}
}
private func logDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions)
}
}
// MARK: - Client Connection
struct RPCClientConnection {
let id: UUID
let connection: NWConnection
var bundleIdentifier: String?
}

View file

@ -0,0 +1,454 @@
/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Foundation
import Defaults
import AtollExtensionKit
/// Handles JSON-RPC method calls for a single WebSocket connection.
/// Mirrors the functionality of `ExtensionXPCService` but uses JSON-RPC transport.
@MainActor
final class ExtensionRPCService {
let bundleIdentifier: String
private weak var server: ExtensionRPCServer?
private let liveActivityManager = ExtensionLiveActivityManager.shared
private let widgetManager = ExtensionLockScreenWidgetManager.shared
private let notchManager = ExtensionNotchExperienceManager.shared
private let authorizationManager = ExtensionAuthorizationManager.shared
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
// Keys whose values represent Swift enums that use {"type":...} in client format.
// These need to be transformed to Swift Codable format: {"caseName": {params}}.
private static let enumKeys: Set<String> = [
"leadingIcon", "trailingContent", "progressIndicator",
"badgeIcon", "leadingContent", "icon", "tint"
]
// Enum-typed fields that can appear inside arrays (like content elements)
private static let contentElementTypeFields: Set<String> = [
"text", "icon", "progress", "graph", "gauge", "spacer", "divider", "webView"
]
init(bundleIdentifier: String, server: ExtensionRPCServer) {
self.bundleIdentifier = bundleIdentifier
self.server = server
}
// MARK: - Method Routing
func handleRequest(_ request: RPCRequest) -> Data {
let result: Codable
switch request.method {
case "atoll.getVersion":
result = handleGetVersion(id: request.id)
case "atoll.requestAuthorization":
result = handleRequestAuthorization(params: request.params, id: request.id)
case "atoll.checkAuthorization":
result = handleCheckAuthorization(params: request.params, id: request.id)
case "atoll.presentLiveActivity":
result = handlePresentLiveActivity(params: request.params, id: request.id)
case "atoll.updateLiveActivity":
result = handleUpdateLiveActivity(params: request.params, id: request.id)
case "atoll.dismissLiveActivity":
result = handleDismissLiveActivity(params: request.params, id: request.id)
case "atoll.presentLockScreenWidget":
result = handlePresentLockScreenWidget(params: request.params, id: request.id)
case "atoll.updateLockScreenWidget":
result = handleUpdateLockScreenWidget(params: request.params, id: request.id)
case "atoll.dismissLockScreenWidget":
result = handleDismissLockScreenWidget(params: request.params, id: request.id)
case "atoll.presentNotchExperience":
result = handlePresentNotchExperience(params: request.params, id: request.id)
case "atoll.updateNotchExperience":
result = handleUpdateNotchExperience(params: request.params, id: request.id)
case "atoll.dismissNotchExperience":
result = handleDismissNotchExperience(params: request.params, id: request.id)
default:
result = RPCErrorResponse(
error: RPCErrorObject(code: RPCErrorCode.methodNotFound, message: "Method not found: \(request.method)"),
id: request.id
)
}
return (try? encoder.encode(result)) ?? Data()
}
// MARK: - Version
private func handleGetVersion(id: String) -> RPCSuccessResponse {
RPCSuccessResponse(
result: ["version": .string(AtollExtensionKitVersion)],
id: id
)
}
// MARK: - Authorization
private func handleRequestAuthorization(params: RPCParams?, id: String) -> Codable {
guard Defaults[.enableThirdPartyExtensions] else {
return RPCErrorResponse(
error: RPCErrorObject(code: RPCErrorCode.featureDisabled, message: "Extensions are disabled"),
id: id
)
}
let bi = params?["bundleIdentifier"]?.stringValue ?? bundleIdentifier
let entry = authorizationManager.ensureEntryExists(bundleIdentifier: bi, appName: bi)
if entry.isAuthorized {
return RPCSuccessResponse(result: ["authorized": .bool(true)], id: id)
}
// Auto-authorize for now (user can revoke in settings)
authorizationManager.authorize(bundleIdentifier: bi, appName: bi)
logDiagnostics("Authorized RPC client \(bi)")
return RPCSuccessResponse(result: ["authorized": .bool(true)], id: id)
}
private func handleCheckAuthorization(params: RPCParams?, id: String) -> Codable {
let bi = params?["bundleIdentifier"]?.stringValue ?? bundleIdentifier
guard Defaults[.enableThirdPartyExtensions] else {
return RPCSuccessResponse(result: ["authorized": .bool(false)], id: id)
}
let entry = authorizationManager.authorizationEntry(for: bi)
let authorized = entry?.isAuthorized ?? false
return RPCSuccessResponse(result: ["authorized": .bool(authorized)], id: id)
}
// MARK: - Live Activities
private func handlePresentLiveActivity(params: RPCParams?, id: String) -> Codable {
guard let descriptorData = params?.jsonData(for: "descriptor") else {
return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id)
}
do {
let transformed = Self.transformClientJSON(descriptorData)
let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: transformed)
try ExtensionDescriptorValidator.validate(descriptor)
try liveActivityManager.present(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier)
logDiagnostics("RPC: Presented live activity \(descriptor.id) for \(descriptor.bundleIdentifier)")
return RPCSuccessResponse(result: ["success": .bool(true)], id: id)
} catch let error as ExtensionValidationError {
return errorResponse(from: error, id: id)
} catch {
logDiagnostics("RPC: Decode error for presentLiveActivity: \(error)")
logDiagnostics("RPC: Raw payload: \(String(data: descriptorData, encoding: .utf8) ?? "<binary>")")
return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id)
}
}
private func handleUpdateLiveActivity(params: RPCParams?, id: String) -> Codable {
guard let descriptorData = params?.jsonData(for: "descriptor") else {
return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id)
}
do {
let transformed = Self.transformClientJSON(descriptorData)
let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: transformed)
try liveActivityManager.update(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier)
logDiagnostics("RPC: Updated live activity \(descriptor.id) for \(descriptor.bundleIdentifier)")
return RPCSuccessResponse(result: ["success": .bool(true)], id: id)
} catch let error as ExtensionValidationError {
return errorResponse(from: error, id: id)
} catch {
logDiagnostics("RPC: Decode error for updateLiveActivity: \(error)")
return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id)
}
}
private func handleDismissLiveActivity(params: RPCParams?, id: String) -> Codable {
guard let activityID = params?["activityID"]?.stringValue else {
return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing activityID", id: id)
}
let bi = params?["bundleIdentifier"]?.stringValue ?? bundleIdentifier
liveActivityManager.dismiss(activityID: activityID, bundleIdentifier: bi)
logDiagnostics("RPC: Dismissed live activity \(activityID) for \(bi)")
return RPCSuccessResponse(result: ["success": .bool(true)], id: id)
}
// MARK: - Lock Screen Widgets
private func handlePresentLockScreenWidget(params: RPCParams?, id: String) -> Codable {
guard let descriptorData = params?.jsonData(for: "descriptor") else {
return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id)
}
do {
let transformed = Self.transformClientJSON(descriptorData)
let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: transformed)
try ExtensionDescriptorValidator.validate(descriptor)
try widgetManager.present(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier)
logDiagnostics("RPC: Presented widget \(descriptor.id) for \(descriptor.bundleIdentifier)")
return RPCSuccessResponse(result: ["success": .bool(true)], id: id)
} catch let error as ExtensionValidationError {
return errorResponse(from: error, id: id)
} catch {
logDiagnostics("RPC: Decode error for presentWidget: \(error)")
logDiagnostics("RPC: Raw payload: \(String(data: descriptorData, encoding: .utf8) ?? "<binary>")")
return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id)
}
}
private func handleUpdateLockScreenWidget(params: RPCParams?, id: String) -> Codable {
guard let descriptorData = params?.jsonData(for: "descriptor") else {
return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id)
}
do {
let transformed = Self.transformClientJSON(descriptorData)
let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: transformed)
try widgetManager.update(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier)
logDiagnostics("RPC: Updated widget \(descriptor.id) for \(descriptor.bundleIdentifier)")
return RPCSuccessResponse(result: ["success": .bool(true)], id: id)
} catch let error as ExtensionValidationError {
return errorResponse(from: error, id: id)
} catch {
logDiagnostics("RPC: Decode error for updateWidget: \(error)")
return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id)
}
}
private func handleDismissLockScreenWidget(params: RPCParams?, id: String) -> Codable {
guard let widgetID = params?["widgetID"]?.stringValue else {
return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing widgetID", id: id)
}
let bi = params?["bundleIdentifier"]?.stringValue ?? bundleIdentifier
widgetManager.dismiss(widgetID: widgetID, bundleIdentifier: bi)
logDiagnostics("RPC: Dismissed widget \(widgetID) for \(bi)")
return RPCSuccessResponse(result: ["success": .bool(true)], id: id)
}
// MARK: - Notch Experiences
private func handlePresentNotchExperience(params: RPCParams?, id: String) -> Codable {
guard let descriptorData = params?.jsonData(for: "descriptor") else {
return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id)
}
do {
let transformed = Self.transformClientJSON(descriptorData)
let descriptor = try decoder.decode(AtollNotchExperienceDescriptor.self, from: transformed)
try ExtensionDescriptorValidator.validate(descriptor)
try notchManager.present(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier)
logDiagnostics("RPC: Presented notch experience \(descriptor.id) for \(descriptor.bundleIdentifier)")
return RPCSuccessResponse(result: ["success": .bool(true)], id: id)
} catch let error as ExtensionValidationError {
return errorResponse(from: error, id: id)
} catch {
logDiagnostics("RPC: Decode error for presentNotchExperience: \(error)")
logDiagnostics("RPC: Raw payload: \(String(data: descriptorData, encoding: .utf8) ?? "<binary>")")
return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id)
}
}
private func handleUpdateNotchExperience(params: RPCParams?, id: String) -> Codable {
guard let descriptorData = params?.jsonData(for: "descriptor") else {
return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id)
}
do {
let transformed = Self.transformClientJSON(descriptorData)
let descriptor = try decoder.decode(AtollNotchExperienceDescriptor.self, from: transformed)
try notchManager.update(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier)
logDiagnostics("RPC: Updated notch experience \(descriptor.id) for \(descriptor.bundleIdentifier)")
return RPCSuccessResponse(result: ["success": .bool(true)], id: id)
} catch let error as ExtensionValidationError {
return errorResponse(from: error, id: id)
} catch {
logDiagnostics("RPC: Decode error for updateNotchExperience: \(error)")
return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id)
}
}
private func handleDismissNotchExperience(params: RPCParams?, id: String) -> Codable {
guard let experienceID = params?["experienceID"]?.stringValue else {
return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing experienceID", id: id)
}
let bi = params?["bundleIdentifier"]?.stringValue ?? bundleIdentifier
notchManager.dismiss(experienceID: experienceID, bundleIdentifier: bi)
logDiagnostics("RPC: Dismissed notch experience \(experienceID) for \(bi)")
return RPCSuccessResponse(result: ["success": .bool(true)], id: id)
}
// MARK: - Helpers
private func errorResponse(code: Int, message: String, id: String) -> RPCErrorResponse {
RPCErrorResponse(
error: RPCErrorObject(code: code, message: message),
id: id
)
}
private func errorResponse(from error: ExtensionValidationError, id: String) -> RPCErrorResponse {
let code: Int
switch error {
case .featureDisabled: code = RPCErrorCode.featureDisabled
case .unauthorized: code = RPCErrorCode.unauthorized
case .invalidDescriptor: code = RPCErrorCode.descriptorInvalid
case .exceedsCapacity: code = RPCErrorCode.capacityExceeded
case .unsupportedContent: code = RPCErrorCode.unsupported
case .rateLimited: code = RPCErrorCode.internalError
case .duplicateIdentifier: code = RPCErrorCode.descriptorInvalid
}
return RPCErrorResponse(
error: RPCErrorObject(code: code, message: error.localizedDescription ?? "Unknown error"),
id: id
)
}
private func logDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions)
}
// MARK: - Client JSON Transformation
/// Converts client wire format to Swift Codable format for enum types.
///
/// Client sends: `{ "type": "symbol", "name": "timer", "size": 16 }`
/// Swift expects: `{ "symbol": { "name": "timer", "size": 16 } }`
///
/// For enums with unnamed parameters (e.g., `case text(String, font:, color:)`):
/// Client sends: `{ "type": "text", "text": "LIVE", "font": {...} }`
/// Swift expects: `{ "text": { "_0": "LIVE", "font": {...} } }`
static func transformClientJSON(_ data: Data) -> Data {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return data
}
let transformed = transformObject(json)
return (try? JSONSerialization.data(withJSONObject: transformed)) ?? data
}
private static func transformObject(_ obj: [String: Any]) -> [String: Any] {
var result: [String: Any] = [:]
for (key, value) in obj {
if let dict = value as? [String: Any] {
if enumKeys.contains(key), let typeName = dict["type"] as? String {
// Transform: { "type": "symbol", ... } → { "symbol": { ... } }
result[key] = transformEnumValue(dict, typeName: typeName)
} else {
result[key] = transformObject(dict)
}
} else if let arr = value as? [[String: Any]] {
// Arrays like "content", "elements", "sections"
result[key] = arr.map { item in
if let typeName = item["type"] as? String,
(key == "content" || key == "elements") {
return transformEnumValue(item, typeName: typeName)
}
return transformObject(item)
}
} else {
result[key] = value
}
}
return result
}
/// Transforms `{ "type": "caseName", ...params }` → `{ "caseName": { ...params } }`.
/// Handles special cases where Swift enum cases have unnamed first parameters:
/// - `text` / `marquee`: `text` field → `_0`
/// - `icon`: `icon` field → `_0` (AtollIconDescriptor)
/// - `progress`: `indicator` field → `_0` (AtollProgressIndicator)
/// - `webView`: `content` field → `_0` (AtollWidgetWebContentDescriptor)
/// - `animation`: `data` field → `_0` (Data)
private static func transformEnumValue(_ dict: [String: Any], typeName: String) -> [String: Any] {
var inner: [String: Any] = [:]
for (k, v) in dict where k != "type" {
if let nestedDict = v as? [String: Any] {
// Check if this nested dict is an enum-typed field
if (enumKeys.contains(k) || k == "indicator"),
let nestedType = nestedDict["type"] as? String {
inner[k] = transformEnumValue(nestedDict, typeName: nestedType)
} else {
inner[k] = transformObject(nestedDict)
}
} else if let nestedArr = v as? [[String: Any]] {
inner[k] = nestedArr.map { item in
if let t = item["type"] as? String {
return transformEnumValue(item, typeName: t)
}
return transformObject(item)
}
} else {
inner[k] = v
}
}
// Handle unnamed first parameter for specific enum cases.
// Swift's auto-synthesized Codable encodes unnamed associated values as `_0`, `_1`, etc.
switch typeName {
case "text":
// AtollTrailingContent.text(String, ...) AND AtollWidgetContentElement.text(String, ...)
if let textValue = inner.removeValue(forKey: "text") {
inner["_0"] = textValue
}
case "marquee":
// AtollTrailingContent.marquee(String, ...)
if let textValue = inner.removeValue(forKey: "text") {
inner["_0"] = textValue
}
case "icon":
// AtollWidgetContentElement.icon(AtollIconDescriptor, tint:)
if let iconValue = inner.removeValue(forKey: "icon") {
inner["_0"] = iconValue
}
case "progress":
// AtollWidgetContentElement.progress(AtollProgressIndicator, value:, color:)
if let indicatorValue = inner.removeValue(forKey: "indicator") {
inner["_0"] = indicatorValue
}
case "webView":
// AtollWidgetContentElement.webView(AtollWidgetWebContentDescriptor)
if let contentValue = inner.removeValue(forKey: "content") {
inner["_0"] = contentValue
}
case "animation":
// AtollTrailingContent.animation(data:, size:) — data is named but let's be safe
break
default:
break
}
return [typeName: inner]
}
}