mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #354 from Ebullioscopic/feature/lunar-support
Feature/lunar support
This commit is contained in:
commit
96c3ee8e5b
36 changed files with 3362 additions and 3318 deletions
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
"location" : "https://github.com/Ebullioscopic/AtollExtensionKit",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "ebe35277572a349fad26abeb6fc552cb5251d584"
|
||||
"revision" : "e2d30afedbbcc259bec7eeb1bea0757e52f4ca01"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
574
DynamicIsland/managers/LunarManager.swift
Normal file
574
DynamicIsland/managers/LunarManager.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
184
DynamicIsland/services/Extensions/ExtensionRPCMessages.swift
Normal file
184
DynamicIsland/services/Extensions/ExtensionRPCMessages.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
294
DynamicIsland/services/Extensions/ExtensionRPCServer.swift
Normal file
294
DynamicIsland/services/Extensions/ExtensionRPCServer.swift
Normal 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?
|
||||
}
|
||||
454
DynamicIsland/services/Extensions/ExtensionRPCService.swift
Normal file
454
DynamicIsland/services/Extensions/ExtensionRPCService.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue