From ddda04369df3909b1eb46af5fb291a49ca98d64b Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 1 Mar 2026 02:22:02 +0530 Subject: [PATCH 01/26] Fix: Transition issues --- DynamicIsland/ContentView.swift | 1 - DynamicIsland/components/Tabs/TabSelectionView.swift | 9 ++++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 44ca54b..8c2bb5d 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -774,7 +774,6 @@ struct ContentView: View { .transition(tabSwitchTransition) } } - .clipped() .zIndex(1) .allowsHitTesting(vm.notchState == .open) .blur(radius: abs(gestureProgress) > 0.3 ? min(abs(gestureProgress), 8) : 0) diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index c75ab59..caf7efe 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -107,12 +107,10 @@ struct TabSelectionView: View { let isSelected = isSelected(tab) let activeAccent = tab.accentColor ?? .white TabButton(label: tab.label, icon: tab.icon, selected: isSelected) { - withAnimation(.smooth(duration: 0.3)) { - if tab.view == .extensionExperience { - coordinator.selectedExtensionExperienceID = tab.experienceID - } - coordinator.currentView = tab.view + if tab.view == .extensionExperience { + coordinator.selectedExtensionExperienceID = tab.experienceID } + coordinator.currentView = tab.view } .frame(height: 26) .foregroundStyle(isSelected ? activeAccent : .gray) @@ -131,6 +129,7 @@ struct TabSelectionView: View { } } } + .animation(.smooth(duration: 0.3), value: coordinator.currentView) .clipShape(Capsule()) .onAppear { ensureValidSelection(with: tabs) From b900804acbc35428d5e93a7cd5f968b4309a3bfe Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 2 Mar 2026 23:14:54 +0530 Subject: [PATCH 02/26] Update DynamicIsland Configuration --- DynamicIsland/ContentView.swift | 65 ++++++++++++- DynamicIsland/DynamicIslandApp.swift | 30 ++++-- DynamicIsland/Localizable.xcstrings | 97 ++++++++++++++++++- .../Notch/DynamicIslandPillShape.swift | 50 ++++++++++ .../components/Settings/SettingsView.swift | 22 +++++ DynamicIsland/enums/generic.swift | 30 ++++++ DynamicIsland/models/Constants.swift | 5 + DynamicIsland/sizing/matters.swift | 45 +++++++++ 8 files changed, 333 insertions(+), 11 deletions(-) create mode 100644 DynamicIsland/components/Notch/DynamicIslandPillShape.swift diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 8c2bb5d..8f3673d 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -80,6 +80,8 @@ struct ContentView: View { @Default(.enableCapsLockIndicator) var enableCapsLockIndicator @Default(.enableExtensionLiveActivities) var enableExtensionLiveActivities @Default(.showStandardMediaControls) var showStandardMediaControls + @Default(.externalDisplayStyle) var externalDisplayStyle + @Default(.hideNonNotchUntilHover) var hideNonNotchUntilHover // Dynamic sizing based on view type and graph count with smooth transitions var dynamicNotchSize: CGSize { @@ -231,8 +233,52 @@ struct ContentView: View { return NotchShape(topCornerRadius: topRadius, bottomCornerRadius: bottomRadius) } + /// 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) + } + + /// 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 { + return true + } + return screen.safeAreaInsets.top <= 0 + } + + /// Whether the notch/island should hide off-screen when closed on a non-notch display. + private var shouldHideUntilHover: Bool { + hideNonNotchUntilHover && isNonNotchScreen && vm.notchState == .closed + } + + /// Pill shape for Dynamic Island mode with animated corner radius transitions. + private var currentPillShape: DynamicIslandPillShape { + let radius: CGFloat + if vm.notchState == .open { + radius = enableMinimalisticUI + ? minimalisticCornerRadiusInsets.opened.top + : dynamicIslandPillCornerRadiusInsets.opened + } else { + // Use half the closed height for a true capsule shape + radius = max(vm.closedNotchSize.height / 2, dynamicIslandPillCornerRadiusInsets.closed.standard) + } + return DynamicIslandPillShape(cornerRadius: radius) + } + + /// Resolves the clip/content shape per-screen: pill on non-notch screens + /// when dynamic island mode is active, standard notch shape otherwise. + private var resolvedClipShape: AnyShape { + if isDynamicIslandMode { + return AnyShape(currentPillShape) + } + return AnyShape(currentNotchShape) + } + var body: some View { let interactionsEnabled = !lockScreenManager.isLocked + let isIslandMode = isDynamicIslandMode let notchHorizontalPadding: CGFloat = { guard vm.notchState == .open else { return activeCornerRadiusInsets.closed.bottom @@ -249,6 +295,8 @@ struct ContentView: View { return vm.effectiveClosedNotchHeight == 0 ? zeroHeightHoverPadding : 0 }() let notchBottomPadding = currentShadowPadding + hoverAreaPadding + // Extra top padding to detach pill from screen edge in Dynamic Island mode + let pillTopOffset: CGFloat = isIslandMode ? dynamicIslandTopOffset : 0 ZStack(alignment: .top) { let mainLayout = NotchLayout() @@ -256,7 +304,7 @@ struct ContentView: View { .padding(.horizontal, notchHorizontalPadding) .padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0) .background(.black) - .clipShape(currentNotchShape) + .clipShape(resolvedClipShape) .compositingGroup() .shadow( color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) @@ -264,7 +312,16 @@ struct ContentView: View { : .clear, radius: Defaults[.cornerRadiusScaling] ? 10 : 5 ) + // Extra horizontal inset for Dynamic Island mode so the shadow + // is not clipped by the outer frame constraint + .padding(.horizontal, isIslandMode ? dynamicIslandShadowInset : 0) + .padding(.top, pillTopOffset) .padding(.bottom, notchBottomPadding) + // Hide off-screen when non-notch hide-until-hover is active + .offset(y: shouldHideUntilHover && !isHovering + ? -(vm.closedNotchSize.height + pillTopOffset + currentShadowPadding + 10) + : 0 + ) mainLayout .conditionalModifier(!useModernCloseAnimation) { view in @@ -288,7 +345,7 @@ struct ContentView: View { } .conditionalModifier(interactionsEnabled) { view in view - .contentShape(currentNotchShape) + .contentShape(resolvedClipShape) .onHover { hovering in handleHover(hovering) } @@ -410,8 +467,8 @@ struct ContentView: View { } .frame( - maxWidth: dynamicNotchSize.width, - maxHeight: dynamicNotchSize.height + currentShadowPadding, + maxWidth: dynamicNotchSize.width + (isDynamicIslandMode ? dynamicIslandShadowInset * 2 : 0), + maxHeight: dynamicNotchSize.height + currentShadowPadding + (isDynamicIslandMode ? dynamicIslandTopOffset : 0), alignment: .top ) .frame(maxHeight: .infinity, alignment: .top) diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index ff5c2c4..bee93b2 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -224,7 +224,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { -> NSWindow { // Use the current required size instead of always using openNotchSize - let requiredSize = calculateRequiredNotchSize() + let baseSize = calculateRequiredNotchSize() + let requiredSize = adjustedSizeForScreen(baseSize, screen: screen) let window = DynamicIslandWindow( contentRect: NSRect( @@ -317,10 +318,25 @@ class AppDelegate: NSObject, NSApplicationDelegate { isStatsTabActive: coordinator.currentView == .stats, secondRowProgress: coordinator.statsSecondRowExpansion ) - return addShadowPadding( + var result = addShadowPadding( to: adjustedContentSize, isMinimalistic: Defaults[.enableMinimalisticUI] ) + + return result + } + + /// Adjusts a base notch size for a specific screen by adding Dynamic Island + /// shadow insets and top-offset only when the screen lacks a physical notch + /// and the user has chosen the Dynamic Island style. + private func adjustedSizeForScreen(_ baseSize: CGSize, screen: NSScreen) -> CGSize { + guard shouldUseDynamicIslandMode(for: screen.localizedName) else { + return baseSize + } + var adjusted = baseSize + adjusted.width += dynamicIslandShadowInset * 2 + adjusted.height += dynamicIslandTopOffset + return adjusted } func ensureWindowSize(_ size: CGSize, animated: Bool, force: Bool = false) { @@ -332,15 +348,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { if Defaults[.showOnAllDisplays] { for (screen, window) in windows { - if force || window.frame.size != size { - resizeWindow(window, on: screen, to: size, animated: animated) + let screenSize = adjustedSizeForScreen(size, screen: screen) + if force || window.frame.size != screenSize { + resizeWindow(window, on: screen, to: screenSize, animated: animated) } } } else if let window { let screen = window.screen ?? NSScreen.screens.first { $0.frame.intersects(window.frame) } ?? NSScreen.main ?? NSScreen.screens.first guard let screen else { return } - if force || window.frame.size != size { - resizeWindow(window, on: screen, to: size, animated: animated) + let screenSize = adjustedSizeForScreen(size, screen: screen) + if force || window.frame.size != screenSize { + resizeWindow(window, on: screen, to: screenSize, animated: animated) } } } diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index b3c3e64..734637b 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1591,6 +1591,10 @@ } } }, + "±10 Seconds" : { + "comment" : "Display name for the \"±10 Seconds\" music skip behavior.", + "isCommentAutoGenerated" : true + }, "0" : { "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", "isCommentAutoGenerated" : true, @@ -1907,6 +1911,10 @@ } } }, + "Accent" : { + "comment" : "Name of the Caps Lock Indicator Tint Mode option that uses the accent color.", + "isCommentAutoGenerated" : true + }, "Accent color" : { "localizations" : { "ar" : { @@ -2252,6 +2260,10 @@ } } }, + "Adaptive gradient" : { + "comment" : "Name of the timer icon color mode that uses an adaptive gradient.", + "isCommentAutoGenerated" : true + }, "Add" : { "localizations" : { "ar" : { @@ -3841,6 +3853,10 @@ } } }, + "Apple Music" : { + "comment" : "Names for the different media player types available in the app.", + "isCommentAutoGenerated" : true + }, "Applies the notch-style parallax effect to the lock screen media widget album art." : { "localizations" : { "pt-BR" : { @@ -4252,6 +4268,10 @@ } } }, + "Bar" : { + "comment" : "Localized name for the \"Bar\" timer progress style.", + "isCommentAutoGenerated" : true + }, "Battery" : { "localizations" : { "ar" : { @@ -6234,6 +6254,10 @@ } } }, + "Classic notch shape that blends into the top screen edge" : { + "comment" : "Description of a notch shape that blends into the top screen edge.", + "isCommentAutoGenerated" : true + }, "Clear" : { "extractionState" : "stale", "localizations" : { @@ -8491,6 +8515,10 @@ } } }, + "Digital" : { + "comment" : "The name of the \"Digital\" style of reminder presentation.", + "isCommentAutoGenerated" : true + }, "Dimensions" : { "localizations" : { "pt-BR" : { @@ -9256,6 +9284,10 @@ } } }, + "Dynamic Island" : { + "comment" : "Description of the dynamic island style.", + "isCommentAutoGenerated" : true + }, "Dynamic Island Progress Bars" : { "comment" : "A section header that lists the options for customizing the appearance of the progress bars displayed by Dynamic Island.", "isCommentAutoGenerated" : true, @@ -10814,6 +10846,10 @@ } } }, + "External display style" : { + "comment" : "A picker that lets the user choose the style of the external display.", + "isCommentAutoGenerated" : true + }, "Favorites" : { "comment" : "Localized name of the \"Favorites\" tab in the clipboard.", "isCommentAutoGenerated" : true @@ -11861,6 +11897,10 @@ } } }, + "Green" : { + "comment" : "Display name for the \"Green\" Caps Lock Indicator Tint Mode.", + "isCommentAutoGenerated" : true + }, "Height:" : { "extractionState" : "stale", "localizations" : { @@ -15527,7 +15567,6 @@ } }, "Minutes" : { - "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -17737,6 +17776,10 @@ } } }, + "Now Playing" : { + "comment" : "Localized name for each media controller type.", + "isCommentAutoGenerated" : true + }, "Off" : { "comment" : "Text displayed in a status label at the top of the screen indicating whether low power mode is on or off.", "isCommentAutoGenerated" : true @@ -18103,6 +18146,10 @@ } } }, + "Panel" : { + "comment" : "Display name for the \"Panel\" clipboard display mode.", + "isCommentAutoGenerated" : true + }, "Panel mode shows clipboard in a floating window near the notch." : { "comment" : "A description of the \"Panel\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -18232,6 +18279,10 @@ } } }, + "Pill-shaped island with rounded corners, similar to iPhone's Dynamic Island" : { + "comment" : "Description of the Dynamic Island style for external displays.", + "isCommentAutoGenerated" : true + }, "Pin items from history to save them here" : { "localizations" : { "pt-BR" : { @@ -19697,6 +19748,10 @@ } } }, + "Ring" : { + "comment" : "Localized name for the \"Ring\" timer progress style.", + "isCommentAutoGenerated" : true + }, "Rotation:" : { "extractionState" : "stale", "localizations" : { @@ -20517,6 +20572,10 @@ } } }, + "Separate Tab" : { + "comment" : "Name of the clipboard display mode option that opens the clipboard in a separate tab.", + "isCommentAutoGenerated" : true + }, "Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : { "comment" : "A description of the \"Separate Tab\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -21571,6 +21630,14 @@ } } }, + "Shows screen assistant as a dropdown attached to the AI button" : { + "comment" : "Description of the \"popover\" display mode for the screen assistant.", + "isCommentAutoGenerated" : true + }, + "Shows screen assistant in a floating panel near the notch" : { + "comment" : "Description of the \"Panel\" screen assistant display mode.", + "isCommentAutoGenerated" : true + }, "Shows the model's reasoning process before the final answer" : { "localizations" : { "fr" : { @@ -21633,6 +21700,10 @@ } } }, + "Skip forward or backward by ten seconds" : { + "comment" : "Description of the \"Skip forward or backward by ten seconds\" music skip behavior option.", + "isCommentAutoGenerated" : true + }, "Slider color" : { "localizations" : { "ar" : { @@ -22200,6 +22271,10 @@ } } }, + "Spotify" : { + "comment" : "Add more media player types here.", + "isCommentAutoGenerated" : true + }, "Square" : { "localizations" : { "ar" : { @@ -22302,12 +22377,20 @@ }, "Standard" : { + }, + "Standard Notch" : { + "comment" : "Text for the \"Standard Notch\" option in the external display style picker.", + "isCommentAutoGenerated" : true }, "Standard notch media controls are hidden while this toggle is off." : { }, "Standard notch media controls are hidden. Re-enable the toggle above to restore them." : { + }, + "Standard previous/next track controls" : { + "comment" : "Description of a music skip behavior option that uses standard previous/next track controls.", + "isCommentAutoGenerated" : true }, "Start" : { "localizations" : { @@ -23657,6 +23740,10 @@ }, "shouldTranslate" : false }, + "Track Skip" : { + "comment" : "Name of the music skip behavior option that skips to the next track.", + "isCommentAutoGenerated" : true + }, "Transform" : { "extractionState" : "stale", "localizations" : { @@ -25243,6 +25330,10 @@ } } }, + "When enabled, the notch slides up and hides on external (non-notch) displays until you hover over it." : { + "comment" : "A description of the feature that hides the notch on non-notch displays.", + "isCommentAutoGenerated" : true + }, "When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : { "localizations" : { "pt-BR" : { @@ -25567,6 +25658,10 @@ "comment" : "A footer note explaining the app's funding.", "isCommentAutoGenerated" : true }, + "Youtube Music" : { + "comment" : "Name for the \"Youtube Music\" media controller type in settings.", + "isCommentAutoGenerated" : true + }, "YouTube Music requires this third-party app to be installed: " : { "localizations" : { "ar" : { diff --git a/DynamicIsland/components/Notch/DynamicIslandPillShape.swift b/DynamicIsland/components/Notch/DynamicIslandPillShape.swift new file mode 100644 index 0000000..18d8899 --- /dev/null +++ b/DynamicIsland/components/Notch/DynamicIslandPillShape.swift @@ -0,0 +1,50 @@ +/* + * 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 . + */ + +import SwiftUI + +/// A pill-shaped (capsule-like) clip shape used for the Dynamic Island mode +/// on external and non-notched displays. Uses `.continuous` rounded corners +/// for an Apple-style squircle appearance, inspired by DynamicNotchKit's +/// floating style. +struct DynamicIslandPillShape: Shape { + var cornerRadius: CGFloat + + var animatableData: CGFloat { + get { cornerRadius } + set { cornerRadius = newValue } + } + + func path(in rect: CGRect) -> Path { + let resolvedRadius = min(cornerRadius, min(rect.width, rect.height) / 2) + return Path(roundedRect: rect, cornerRadius: resolvedRadius, style: .continuous) + } +} + +#Preview { + VStack(spacing: 20) { + DynamicIslandPillShape(cornerRadius: 16) + .fill(.black) + .frame(width: 185, height: 32) + + DynamicIslandPillShape(cornerRadius: 24) + .fill(.black) + .frame(width: 640, height: 200) + } + .padding(20) +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index cb80cb8..4e81f35 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -606,6 +606,8 @@ struct SettingsView: View { SettingsSearchEntry(tab: .general, title: "Extend hover area", keywords: ["hover", "cursor"], highlightID: SettingsTab.general.highlightID(for: "Extend hover area")), SettingsSearchEntry(tab: .general, title: "Enable haptics", keywords: ["haptic", "feedback"], highlightID: SettingsTab.general.highlightID(for: "Enable haptics")), SettingsSearchEntry(tab: .general, title: "Open notch on hover", keywords: ["hover to open", "auto open"], highlightID: SettingsTab.general.highlightID(for: "Open notch on hover")), + SettingsSearchEntry(tab: .general, title: "External display style", keywords: ["dynamic island", "pill", "external display", "non-notch", "floating", "capsule"], highlightID: SettingsTab.general.highlightID(for: "External display style")), + SettingsSearchEntry(tab: .general, title: "Hide until hovered", keywords: ["hide", "hover", "external", "non-notch", "auto hide", "slide"], highlightID: SettingsTab.general.highlightID(for: "Hide until hovered")), SettingsSearchEntry(tab: .general, title: "Notch display height", keywords: ["display height", "menu bar size"], highlightID: SettingsTab.general.highlightID(for: "Notch display height")), // Live Activities @@ -905,6 +907,8 @@ struct GeneralSettings: View { @Default(.musicGestureBehavior) var musicGestureBehavior @Default(.reverseSwipeGestures) var reverseSwipeGestures @Default(.reverseScrollGestures) var reverseScrollGestures + @Default(.externalDisplayStyle) var externalDisplayStyle + @Default(.hideNonNotchUntilHover) var hideNonNotchUntilHover private func highlightID(_ title: String) -> String { SettingsTab.general.highlightID(for: title) @@ -1118,6 +1122,24 @@ struct GeneralSettings: View { NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil) } } + Picker("External display style", selection: $externalDisplayStyle) { + ForEach(ExternalDisplayStyle.allCases) { style in + Text(style.localizedName) + .tag(style) + } + } + .onChange(of: externalDisplayStyle) { + NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil) + } + .settingsHighlight(id: highlightID("External display style")) + Text(externalDisplayStyle.description) + .font(.caption) + .foregroundStyle(.secondary) + Defaults.Toggle("Hide until hovered on non-notch displays", key: .hideNonNotchUntilHover) + .settingsHighlight(id: highlightID("Hide until hovered")) + Text("When enabled, the notch slides up and hides on external (non-notch) displays until you hover over it.") + .font(.caption) + .foregroundStyle(.secondary) } header: { Text("Notch behavior") } diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index f9a9b8d..7b7eb52 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -29,6 +29,36 @@ public enum Style { case floating } +/// Controls how Atoll renders on external and non-notched displays. +/// - `notch`: Standard notch shape (concave top corners blending into the screen edge). +/// - `dynamicIsland`: Pill-shaped island with continuously rounded corners, +/// inspired by DynamicNotchKit's floating style. Only applies to screens +/// that do NOT have a physical notch. +enum ExternalDisplayStyle: String, CaseIterable, Defaults.Serializable, Identifiable { + case notch = "Standard Notch" + case dynamicIsland = "Dynamic Island" + + var id: String { rawValue } + + var localizedName: String { + switch self { + case .notch: + return String(localized: "Standard Notch") + case .dynamicIsland: + return String(localized: "Dynamic Island") + } + } + + var description: String { + switch self { + case .notch: + return String(localized: "Classic notch shape that blends into the top screen edge") + case .dynamicIsland: + return String(localized: "Pill-shaped island with rounded corners, similar to iPhone's Dynamic Island") + } + } +} + public enum ContentType: Int, Codable, Hashable, Equatable { case normal case menu diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index b470a79..5e38106 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -699,6 +699,11 @@ extension Defaults.Keys { static let enableHaptics = Key("enableHaptics", default: true) static let openNotchOnHover = Key("openNotchOnHover", default: true) static let extendHoverArea = Key("extendHoverArea", default: false) + static let externalDisplayStyle = Key( + "externalDisplayStyle", + default: .notch + ) + static let hideNonNotchUntilHover = Key("hideNonNotchUntilHover", default: false) static let notchHeightMode = Key( "notchHeightMode", default: WindowHeightMode.matchRealNotchSize diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index 5987a3e..3757150 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -104,6 +104,51 @@ func addShadowPadding(to size: CGSize, isMinimalistic: Bool) -> CGSize { CGSize(width: size.width, height: size.height + notchShadowPaddingValue(isMinimalistic: isMinimalistic)) } +/// Determines whether a specific screen should render the Dynamic Island pill +/// shape instead of the standard notch shape. +/// +/// Returns `true` only when ALL of these conditions are met: +/// 1. The user has selected `.dynamicIsland` in `externalDisplayStyle` +/// 2. The screen does NOT have a physical notch (safeAreaInsets.top == 0) +/// +/// Screens with a physical notch always use the standard notch shape. +func shouldUseDynamicIslandMode(for screenName: String?) -> Bool { + guard Defaults[.externalDisplayStyle] == .dynamicIsland else { + return false + } + + var selectedScreen: NSScreen? = NSScreen.main + if let screenName { + selectedScreen = NSScreen.screens.first(where: { $0.localizedName == screenName }) + } + + guard let screen = selectedScreen else { + // No screen found — fallback to standard notch + return false + } + + // Physical notch screens always use standard notch shape + return screen.safeAreaInsets.top <= 0 +} + +/// Corner radius insets for the Dynamic Island pill shape. +/// - closed: half the closed notch height for a true capsule look +/// - opened: generous radius for smooth expanded pill +let dynamicIslandPillCornerRadiusInsets: (opened: CGFloat, closed: (standard: CGFloat, minimalistic: CGFloat)) = ( + opened: 24, + closed: (standard: 16, minimalistic: 16) +) + +/// Vertical offset from the top screen edge for the Dynamic Island pill. +/// Creates a visual gap so the pill floats below the menu bar, mimicking +/// the iPhone's Dynamic Island detachment from the physical screen edge. +let dynamicIslandTopOffset: CGFloat = 6 + +/// Extra horizontal padding applied OUTSIDE the pill clip shape in Dynamic +/// Island mode so the drop shadow has room to render without being clipped +/// by the outer frame constraint. +let dynamicIslandShadowInset: CGFloat = 14 + enum MusicPlayerImageSizes { static let cornerRadiusInset: (opened: CGFloat, closed: CGFloat) = (opened: 13.0, closed: 4.0) static let size = (opened: CGSize(width: 90, height: 90), closed: CGSize(width: 20, height: 20)) From dd726d6401278b765f75f8d66aa48d2f3278906f Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 2 Mar 2026 23:49:50 +0530 Subject: [PATCH 03/26] Update: Working BetterDisplay Integration --- DynamicIsland/DynamicIslandApp.swift | 3 + DynamicIsland/Localizable.xcstrings | 34 ++ .../components/Settings/SettingsView.swift | 72 +++ .../managers/BetterDisplayManager.swift | 435 ++++++++++++++++++ DynamicIsland/models/Constants.swift | 3 + 5 files changed, 547 insertions(+) create mode 100644 DynamicIsland/managers/BetterDisplayManager.swift diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index bee93b2..6bceecd 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -405,6 +405,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Setup SystemHUD Manager SystemHUDManager.shared.setup(coordinator: coordinator) + + // Setup BetterDisplay integration + BetterDisplayManager.shared.configure(coordinator: coordinator) // Setup ScreenRecording Manager if Defaults[.enableScreenRecordingDetection] { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 734637b..bb20861 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -4762,6 +4762,17 @@ "comment" : "A label indicating a beta version.", "isCommentAutoGenerated" : true, "shouldTranslate" : false + }, + "BetterDisplay" : { + "comment" : "The name of the BetterDisplay integration option.", + "isCommentAutoGenerated" : true + }, + "BetterDisplay Integration" : { + "comment" : "The header text for the \"BetterDisplay Integration\" section in the settings view.", + "isCommentAutoGenerated" : true + }, + "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." : { + }, "Blue dashed box shows actual size in Atoll" : { "extractionState" : "stale", @@ -8469,6 +8480,10 @@ "comment" : "Name of a user profile for a designer.", "isCommentAutoGenerated" : true }, + "Detected" : { + "comment" : "A label indicating that BetterDisplay is detected on the system.", + "isCommentAutoGenerated" : true + }, "Detection Status" : { "comment" : "The header for a section that displays whether the screen recording detection feature is currently active or inactive.", "isCommentAutoGenerated" : true, @@ -10205,6 +10220,10 @@ } } }, + "Enable to route BetterDisplay's brightness and volume changes through Atoll's HUD instead of the system OSD." : { + "comment" : "A description of the benefit of enabling BetterDisplay integration.", + "isCommentAutoGenerated" : true + }, "Enable when scaling beyond 30x20px so the Atoll opens gracefully." : { "localizations" : { "pt-BR" : { @@ -12990,6 +13009,10 @@ } } }, + "Install [BetterDisplay](https://betterdisplay.pro) to control external display brightness and volume through Atoll's HUD." : { + "comment" : "A description of the benefits of installing BetterDisplay.", + "isCommentAutoGenerated" : true + }, "Install updates automatically" : { "extractionState" : "stale", "localizations" : { @@ -17324,6 +17347,9 @@ "Not charging" : { "comment" : "Text displayed in the status text when the battery is not charging.", "isCommentAutoGenerated" : true + }, + "Not detected" : { + }, "Not Now" : { "localizations" : { @@ -19125,6 +19151,10 @@ } } }, + "Refresh detection" : { + "comment" : "A button label that refreshes the detection status of BetterDisplay.", + "isCommentAutoGenerated" : true + }, "Release name" : { "localizations" : { "ar" : { @@ -25308,6 +25338,10 @@ } } }, + "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." : { + "comment" : "A footer description for the \"BetterDisplay Integration\" section in the settings view.", + "isCommentAutoGenerated" : true + }, "When enabled, Focus appears briefly (on/off) and then collapses instead of staying visible." : { "comment" : "A help text for the setting that allows the focus indicator to collapse instead of staying visible.", "isCommentAutoGenerated" : true, diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 4e81f35..1ee0ec8 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -650,6 +650,7 @@ 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")), // Media SettingsSearchEntry(tab: .media, title: "Music Source", keywords: ["media source", "controller"], highlightID: SettingsTab.media.highlightID(for: "Music Source")), @@ -1794,6 +1795,9 @@ private struct HUDAndOSDSettingsView: View { } } } + + // BetterDisplay Integration (shared across all HUD variants) + BetterDisplayIntegrationSection() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(paneBackgroundColor) @@ -1807,6 +1811,74 @@ private struct HUDAndOSDSettingsView: View { } } +// MARK: - BetterDisplay Integration Settings Section + +private struct BetterDisplayIntegrationSection: View { + @Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration + @ObservedObject private var betterDisplayManager = BetterDisplayManager.shared + + private func highlightID(_ title: String) -> String { + SettingsTab.hudAndOSD.highlightID(for: title) + } + + var body: some View { + Form { + Section { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("BetterDisplay") + .font(.system(size: 13, weight: .medium)) + Text(betterDisplayManager.isDetected ? "Detected" : "Not detected") + .font(.caption) + .foregroundStyle(betterDisplayManager.isDetected ? .green : .secondary) + } + + Spacer() + + 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 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() + } label: { + Label("Refresh detection", systemImage: "arrow.clockwise") + .font(.caption) + } + .buttonStyle(.link) + } + } header: { + HStack(spacing: 6) { + Image(systemName: "display.2") + Text("BetterDisplay Integration") + } + } 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) + } + } + } +} + private struct HUDSelectionCard: View { let title: String let isSelected: Bool diff --git a/DynamicIsland/managers/BetterDisplayManager.swift b/DynamicIsland/managers/BetterDisplayManager.swift new file mode 100644 index 0000000..8f1fcc2 --- /dev/null +++ b/DynamicIsland/managers/BetterDisplayManager.swift @@ -0,0 +1,435 @@ +/* + * 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 . + */ + +@preconcurrency import Foundation +import AppKit +import Defaults +import Combine + +// MARK: - BetterDisplay OSD Notification Model + +/// Matches the JSON structure dispatched by BetterDisplay's OSD notification system. +/// See: https://github.com/waydabber/BetterDisplay/wiki/Integration-features,-CLI#osd-notification-dispatch-integration +struct BetterDisplayOSDNotification: Codable { + var displayID: Int? + var systemIconID: Int? + var customSymbol: String? + var text: String? + var lock: Bool? + var controlTarget: String? + var value: Double? + var maxValue: Double? + var symbolFadeAfter: Int? + var symbolSizeMultiplier: Double? + var textFadeAfter: Int? +} + +// MARK: - BetterDisplay Request / Response Models + +/// JSON structure for sending commands to BetterDisplay via distributed notifications. +struct BetterDisplayRequestData: Codable { + var uuid: String? + var commands: [String] = [] + var parameters: [String: String?] = [:] +} + +/// JSON structure for receiving responses from BetterDisplay. +struct BetterDisplayResponseData: Codable { + var uuid: String? + var result: Bool? + var payload: String? +} + +// MARK: - BetterDisplay Control Target Classification + +enum BetterDisplayControlCategory { + case brightness + case volume + case other +} + +private let brightnessControlTargets: Set = [ + "combinedBrightness", + "hardwareBrightness", + "softwareBrightness", +] + +private let volumeControlTargets: Set = [ + "volume", + "mute", +] + +// MARK: - BetterDisplay Manager + +/// Manages integration with the BetterDisplay app (waydabber.BetterDisplay). +/// +/// Responsibilities: +/// - Detect whether BetterDisplay is installed +/// - Observe OSD notifications from BetterDisplay and route them to Atoll's HUD pipeline +/// - Provide request/response primitives for controlling display properties +@MainActor +final class BetterDisplayManager: ObservableObject { + static let shared = BetterDisplayManager() + + /// The bundle identifier of BetterDisplay. + nonisolated static let bundleID = "pro.betterdisplay.BetterDisplay" + + // Notification names + private static let osdNotificationName = NSNotification.Name("com.betterdisplay.BetterDisplay.osd") + private static let requestNotificationName = NSNotification.Name("com.betterdisplay.BetterDisplay.request") + private static let responseNotificationName = NSNotification.Name("com.betterdisplay.BetterDisplay.response") + + // MARK: Published state + + /// Whether BetterDisplay is currently detected on this machine. + @Published private(set) var isDetected: Bool = false + + // MARK: Private + + private var osdObserver: NSObjectProtocol? + private var responseObserver: NSObjectProtocol? + private var workspaceObserver: NSObjectProtocol? + private var cancellables = Set() + private weak var coordinator: DynamicIslandViewCoordinator? + + private init() { + isDetected = Self.checkInstallation() + setupWorkspaceObserver() + setupSettingsObserver() + } + + // MARK: - Public API + + /// Configure with the view coordinator for HUD dispatch. + func configure(coordinator: DynamicIslandViewCoordinator) { + self.coordinator = coordinator + + // Start listening if integration is enabled and BetterDisplay is detected + if Defaults[.enableBetterDisplayIntegration] && isDetected { + startListening() + } + } + + /// Refresh detection status (e.g. after app install/uninstall). + func refreshDetectionStatus() { + let wasDetected = isDetected + isDetected = Self.checkInstallation() + if wasDetected && !isDetected { + stopListening() + } else if !wasDetected && isDetected && Defaults[.enableBetterDisplayIntegration] { + startListening() + } + } + + // MARK: - Detection + + /// Check if BetterDisplay is installed by looking for its bundle ID in running apps + /// and falling back to NSWorkspace URL lookup. + static func checkInstallation() -> Bool { + // Check running apps first (fast path) + if NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bundleID }) { + return true + } + + // Fallback: check if the app is installed via URL scheme or bundle lookup + if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { + return FileManager.default.fileExists(atPath: url.path) + } + + return false + } + + // MARK: - OSD Listening + + private func startListening() { + guard osdObserver == nil else { return } + + osdObserver = DistributedNotificationCenter.default().addObserver( + forName: Self.osdNotificationName, + object: nil, + queue: .main + ) { [weak self] notification in + Task { @MainActor in + self?.handleOSDNotification(notification) + } + } + + NSLog("✅ BetterDisplay OSD listener started") + } + + private func stopListening() { + if let observer = osdObserver { + DistributedNotificationCenter.default().removeObserver(observer) + osdObserver = nil + NSLog("⏹ BetterDisplay OSD listener stopped") + } + } + + // MARK: - OSD Handling + + private func handleOSDNotification(_ notification: Notification) { + guard Defaults[.enableBetterDisplayIntegration] else { return } + + guard let notificationString = notification.object as? String else { + NSLog("⚠️ BetterDisplay OSD: unexpected notification format") + return + } + + do { + let osd = try JSONDecoder().decode( + BetterDisplayOSDNotification.self, + from: Data(notificationString.utf8) + ) + routeOSDToHUD(osd) + } catch { + NSLog("⚠️ BetterDisplay OSD decode error: \(error.localizedDescription)") + } + } + + /// Route a decoded BetterDisplay OSD notification to the active Atoll HUD variant. + private func routeOSDToHUD(_ osd: BetterDisplayOSDNotification) { + let category = classifyControlTarget(osd.controlTarget, systemIconID: osd.systemIconID) + let normalizedValue = normalizeValue(osd.value, maxValue: osd.maxValue) + + switch category { + case .brightness: + guard Defaults[.enableBrightnessHUD] else { return } + dispatchBrightnessHUD(value: normalizedValue) + + case .volume: + guard Defaults[.enableVolumeHUD] else { return } + let isMuted = osd.controlTarget == "mute" || osd.systemIconID == 4 + dispatchVolumeHUD(value: normalizedValue, isMuted: isMuted) + + case .other: + // For unsupported control targets (contrast, gamma, temperature, etc.), + // show a generic brightness-style HUD if the user has brightness HUD enabled + guard Defaults[.enableBrightnessHUD] else { return } + dispatchBrightnessHUD(value: normalizedValue, customSymbol: osd.customSymbol) + } + } + + // MARK: - HUD Dispatch (mirrors SystemChangesObserver logic) + + private func dispatchVolumeHUD(value: CGFloat, isMuted: Bool) { + if HUDSuppressionCoordinator.shared.shouldSuppressVolumeHUD { return } + + if Defaults[.enableCircularHUD] { + CircularHUDWindowManager.shared.show(type: .volume, value: value) + return + } + if Defaults[.enableVerticalHUD] { + VerticalHUDWindowManager.shared.show(type: .volume, value: value, icon: "") + return + } + if Defaults[.enableCustomOSD] && Defaults[.enableOSDVolume] { + CustomOSDWindowManager.shared.showVolume(value: value) + } + if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] { + coordinator?.toggleSneakPeek( + status: true, + type: .volume, + value: value, + icon: "" + ) + } + } + + private func dispatchBrightnessHUD(value: CGFloat, customSymbol: String? = nil) { + if Defaults[.enableCircularHUD] { + CircularHUDWindowManager.shared.show(type: .brightness, value: value) + return + } + if Defaults[.enableVerticalHUD] { + VerticalHUDWindowManager.shared.show(type: .brightness, value: value) + return + } + if Defaults[.enableCustomOSD] && Defaults[.enableOSDBrightness] { + CustomOSDWindowManager.shared.showBrightness(value: value) + } + if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] { + coordinator?.toggleSneakPeek( + status: true, + type: .brightness, + value: value, + icon: "" + ) + } + } + + // MARK: - Helpers + + private func classifyControlTarget(_ target: String?, systemIconID: Int?) -> BetterDisplayControlCategory { + if let target { + if brightnessControlTargets.contains(target) { return .brightness } + if volumeControlTargets.contains(target) { return .volume } + } + + // Fallback to systemIconID + switch systemIconID { + case 1: return .brightness // brightness icon + case 3: return .volume // volume icon + case 4: return .volume // mute icon + default: 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 } + let maxVal = maxValue ?? 1.0 + guard maxVal > 0 else { return 0 } + return CGFloat(Swift.min(Swift.max(value / maxVal, 0), 1)) + } + + // MARK: - Request API + + /// Send a command to BetterDisplay and optionally receive a response. + /// - Parameters: + /// - commands: e.g. ["set"], ["get"] + /// - parameters: e.g. ["brightness": "0.8"] + /// - completion: Called with the response on the main queue, or nil on timeout. + func sendRequest( + commands: [String], + parameters: [String: String?], + completion: (@MainActor @Sendable (BetterDisplayResponseData?) -> Void)? = nil + ) { + let uuid = UUID().uuidString + let request = BetterDisplayRequestData(uuid: uuid, commands: commands, parameters: parameters) + + // If we need a response, set up a temporary observer + if let completion { + // Use a class wrapper so the observer closure can safely capture & cancel + final class ResponseState: @unchecked Sendable { + var observer: NSObjectProtocol? + var timeoutItem: DispatchWorkItem? + } + let state = ResponseState() + + let timeoutItem = DispatchWorkItem { + if let obs = state.observer { + DistributedNotificationCenter.default().removeObserver(obs) + state.observer = nil + } + Task { @MainActor in + completion(nil) + } + } + state.timeoutItem = timeoutItem + + state.observer = DistributedNotificationCenter.default().addObserver( + forName: Self.responseNotificationName, + object: nil, + queue: .main + ) { notification in + guard let responseString = notification.object as? String, + let response = try? JSONDecoder().decode( + BetterDisplayResponseData.self, + from: Data(responseString.utf8) + ), + response.uuid == uuid + else { return } + + state.timeoutItem?.cancel() + if let obs = state.observer { + DistributedNotificationCenter.default().removeObserver(obs) + state.observer = nil + } + Task { @MainActor in + completion(response) + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: timeoutItem) + } + + // Send the request + do { + let encoded = try JSONEncoder().encode(request) + if let encodedString = String(data: encoded, encoding: .utf8) { + DistributedNotificationCenter.default().postNotificationName( + Self.requestNotificationName, + object: encodedString, + userInfo: nil, + deliverImmediately: true + ) + } + } catch { + NSLog("⚠️ BetterDisplay request encode error: \(error.localizedDescription)") + completion?(nil) + } + } + + // MARK: - Workspace Observer (detect install/uninstall) + + private func setupWorkspaceObserver() { + let betterDisplayBundleID = Self.bundleID + + workspaceObserver = 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 == betterDisplayBundleID + else { return } + Task { @MainActor in + self?.refreshDetectionStatus() + } + } + + // Also observe app termination + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didTerminateApplicationNotification, + object: nil, + queue: .main + ) { notification in + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier == betterDisplayBundleID + else { return } + // Don't clear isDetected on termination — app is still installed, just not running. + } + } + + // MARK: - Settings Observer + + private func setupSettingsObserver() { + Defaults.publisher(.enableBetterDisplayIntegration, options: []) + .sink { [weak self] change in + Task { @MainActor in + guard let self else { return } + if change.newValue && self.isDetected { + self.startListening() + } else { + self.stopListening() + } + } + } + .store(in: &cancellables) + } + + deinit { + if let osdObserver { + DistributedNotificationCenter.default().removeObserver(osdObserver) + } + if let workspaceObserver { + NSWorkspace.shared.notificationCenter.removeObserver(workspaceObserver) + } + cancellables.removeAll() + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 5e38106..252fe03 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -1009,6 +1009,9 @@ extension Defaults.Keys { static let circularHUDSize = Key("circularHUDSize", default: 65) static let circularHUDStrokeWidth = Key("circularHUDStrokeWidth", default: 4) static let circularHUDUseAccentColor = Key("circularHUDUseAccentColor", default: true) + + // MARK: BetterDisplay Integration + static let enableBetterDisplayIntegration = Key("enableBetterDisplayIntegration", default: false) static let hasSeenOSDAlphaWarning = Key("hasSeenOSDAlphaWarning", default: false) static let enableOSDVolume = Key("enableOSDVolume", default: true) From cead5f9ce8daf733d3423e7a25f8e3a6a39d1a82 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 3 Mar 2026 00:19:51 +0530 Subject: [PATCH 04/26] Update HUDs to show up betterdisplay --- DynamicIsland/ContentView.swift | 3 +- .../Live activities/InlineHUD.swift | 2 +- .../managers/BetterDisplayManager.swift | 47 ++++++++++++++----- .../managers/CircularHUDWindowManager.swift | 6 +-- .../managers/CustomOSDWindowManager.swift | 14 +++--- DynamicIsland/managers/SystemHUDManager.swift | 16 ++++++- .../managers/VerticalHUDWindowManager.swift | 6 +-- 7 files changed, 66 insertions(+), 28 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 8f3673d..981af01 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -249,8 +249,9 @@ struct ContentView: View { } /// 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 + hideNonNotchUntilHover && isNonNotchScreen && vm.notchState == .closed && !coordinator.sneakPeek.show } /// Pill shape for Dynamic Island mode with animated corner radius transitions. diff --git a/DynamicIsland/components/Live activities/InlineHUD.swift b/DynamicIsland/components/Live activities/InlineHUD.swift index 079bb7e..975e88d 100644 --- a/DynamicIsland/components/Live activities/InlineHUD.swift +++ b/DynamicIsland/components/Live activities/InlineHUD.swift @@ -212,7 +212,7 @@ struct InlineHUD: View { .frame(width: 20, height: 15, alignment: .leading) } case .brightness: - Image(systemName: BrightnessSymbol(value)) + Image(systemName: !icon.isEmpty ? icon : BrightnessSymbol(value)) .contentTransition(.interpolate) .frame(width: 20, height: 15, alignment: .center) case .backlight: diff --git a/DynamicIsland/managers/BetterDisplayManager.swift b/DynamicIsland/managers/BetterDisplayManager.swift index 8f1fcc2..5d10948 100644 --- a/DynamicIsland/managers/BetterDisplayManager.swift +++ b/DynamicIsland/managers/BetterDisplayManager.swift @@ -205,40 +205,44 @@ final class BetterDisplayManager: ObservableObject { private func routeOSDToHUD(_ osd: BetterDisplayOSDNotification) { 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) switch category { case .brightness: guard Defaults[.enableBrightnessHUD] else { return } - dispatchBrightnessHUD(value: normalizedValue) + let icon = isExternalDisplay ? "display" : nil + dispatchBrightnessHUD(value: normalizedValue, customSymbol: icon, onScreen: targetScreen) case .volume: guard Defaults[.enableVolumeHUD] else { return } let isMuted = osd.controlTarget == "mute" || osd.systemIconID == 4 - dispatchVolumeHUD(value: normalizedValue, isMuted: isMuted) + dispatchVolumeHUD(value: normalizedValue, isMuted: isMuted, onScreen: targetScreen) case .other: // For unsupported control targets (contrast, gamma, temperature, etc.), // show a generic brightness-style HUD if the user has brightness HUD enabled guard Defaults[.enableBrightnessHUD] else { return } - dispatchBrightnessHUD(value: normalizedValue, customSymbol: osd.customSymbol) + let icon = isExternalDisplay ? "display" : osd.customSymbol + dispatchBrightnessHUD(value: normalizedValue, customSymbol: icon, onScreen: targetScreen) } } // MARK: - HUD Dispatch (mirrors SystemChangesObserver logic) - private func dispatchVolumeHUD(value: CGFloat, isMuted: Bool) { + 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) + CircularHUDWindowManager.shared.show(type: .volume, value: value, onScreen: targetScreen) return } if Defaults[.enableVerticalHUD] { - VerticalHUDWindowManager.shared.show(type: .volume, value: value, icon: "") + VerticalHUDWindowManager.shared.show(type: .volume, value: value, icon: "", onScreen: targetScreen) return } if Defaults[.enableCustomOSD] && Defaults[.enableOSDVolume] { - CustomOSDWindowManager.shared.showVolume(value: value) + CustomOSDWindowManager.shared.showVolume(value: value, onScreen: targetScreen) } if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] { coordinator?.toggleSneakPeek( @@ -250,30 +254,49 @@ final class BetterDisplayManager: ObservableObject { } } - private func dispatchBrightnessHUD(value: CGFloat, customSymbol: String? = nil) { + private func dispatchBrightnessHUD(value: CGFloat, customSymbol: String? = nil, onScreen targetScreen: NSScreen? = nil) { + let icon = customSymbol ?? "" if Defaults[.enableCircularHUD] { - CircularHUDWindowManager.shared.show(type: .brightness, value: value) + CircularHUDWindowManager.shared.show(type: .brightness, value: value, icon: icon, onScreen: targetScreen) return } if Defaults[.enableVerticalHUD] { - VerticalHUDWindowManager.shared.show(type: .brightness, value: value) + VerticalHUDWindowManager.shared.show(type: .brightness, value: value, icon: icon, onScreen: targetScreen) return } if Defaults[.enableCustomOSD] && Defaults[.enableOSDBrightness] { - CustomOSDWindowManager.shared.showBrightness(value: value) + 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: icon ) } } // MARK: - Helpers + /// Resolve a BetterDisplay `displayID` (CGDirectDisplayID) to the matching NSScreen, if any. + private func resolveScreen(for displayID: Int?) -> NSScreen? { + guard let displayID else { return nil } + let target = UInt32(displayID) + return NSScreen.screens.first { screen in + guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { + return false + } + return screenNumber.uint32Value == target + } + } + + /// 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 classifyControlTarget(_ target: String?, systemIconID: Int?) -> BetterDisplayControlCategory { if let target { if brightnessControlTargets.contains(target) { return .brightness } diff --git a/DynamicIsland/managers/CircularHUDWindowManager.swift b/DynamicIsland/managers/CircularHUDWindowManager.swift index 2ee90dd..a6847d4 100644 --- a/DynamicIsland/managers/CircularHUDWindowManager.swift +++ b/DynamicIsland/managers/CircularHUDWindowManager.swift @@ -66,13 +66,13 @@ final class CircularHUDWindowManager { } } - func show(type: SneakContentType, value: CGFloat, icon: String = "") { + func show(type: SneakContentType, value: CGFloat, icon: String = "", onScreen targetScreen: NSScreen? = nil) { guard Defaults[.enableCircularHUD] else { return } - let screens = NSScreen.screens + let screens = targetScreen.map { [$0] } ?? NSScreen.screens guard !screens.isEmpty else { return } - // Show on all screens + // Show on target screen(s) for screen in screens { let windowContext = ensureWindow(for: type, screen: screen) updateContent(window: windowContext, type: type, value: value, icon: icon) diff --git a/DynamicIsland/managers/CustomOSDWindowManager.swift b/DynamicIsland/managers/CustomOSDWindowManager.swift index 02d3120..ee808c8 100644 --- a/DynamicIsland/managers/CustomOSDWindowManager.swift +++ b/DynamicIsland/managers/CustomOSDWindowManager.swift @@ -46,14 +46,14 @@ final class CustomOSDWindowManager { // MARK: - Public API - func showVolume(value: CGFloat, isMuted: Bool = false, icon: String = "") { + func showVolume(value: CGFloat, isMuted: Bool = false, icon: String = "", onScreen targetScreen: NSScreen? = nil) { guard Defaults[.enableCustomOSD], isInitialized else { return } - show(type: .volume, value: value, icon: icon) + show(type: .volume, value: value, icon: icon, onScreen: targetScreen) } - func showBrightness(value: CGFloat) { + func showBrightness(value: CGFloat, icon: String = "", onScreen targetScreen: NSScreen? = nil) { guard Defaults[.enableCustomOSD], isInitialized else { return } - show(type: .brightness, value: value, icon: "") + show(type: .brightness, value: value, icon: icon, onScreen: targetScreen) } func showBacklight(value: CGFloat) { @@ -67,14 +67,14 @@ final class CustomOSDWindowManager { // MARK: - Private Implementation - private func show(type: SneakContentType, value: CGFloat, icon: String) { - let screens = NSScreen.screens + private func show(type: SneakContentType, value: CGFloat, icon: String, onScreen targetScreen: NSScreen? = nil) { + let screens = targetScreen.map { [$0] } ?? NSScreen.screens guard !screens.isEmpty else { return } // Close other windows first hideAllWindowsExcept(type: type) - // Show on all screens + // Show on target screen(s) for screen in screens { let window = ensureWindow(for: type, screen: screen) updateContent(window: window, type: type, value: value, icon: icon) diff --git a/DynamicIsland/managers/SystemHUDManager.swift b/DynamicIsland/managers/SystemHUDManager.swift index 3591425..77cdead 100644 --- a/DynamicIsland/managers/SystemHUDManager.swift +++ b/DynamicIsland/managers/SystemHUDManager.swift @@ -174,6 +174,14 @@ class SystemHUDManager { keyboardBacklightEnabled: change.newValue ) }.store(in: &cancellables) + + // When BetterDisplay integration toggled, restart observer to update Cmd+Brightness key interception + Defaults.publisher(.enableBetterDisplayIntegration, options: []).sink { [weak self] _ in + guard let self = self, self.isSetupComplete else { return } + Task { @MainActor in + await self.startSystemObserver() + } + }.store(in: &cancellables) } private var cancellables = Set() @@ -218,7 +226,7 @@ class SystemHUDManager { // Determine which controls to enable based on HUD or OSD mode let volumeEnabled: Bool let brightnessEnabled: Bool - let keyboardBacklightEnabled: Bool + var keyboardBacklightEnabled: Bool if Defaults[.enableCircularHUD] || Defaults[.enableVerticalHUD] { // Respect per-control toggles so the system HUD can handle disabled events @@ -234,6 +242,12 @@ class SystemHUDManager { brightnessEnabled = Defaults[.enableBrightnessHUD] keyboardBacklightEnabled = Defaults[.enableKeyboardBacklightHUD] } + + // When BetterDisplay integration is on, disable Cmd+Brightness key interception + // because BetterDisplay uses Cmd+F1/F2 for its own display controls. + if Defaults[.enableBetterDisplayIntegration] { + keyboardBacklightEnabled = false + } changesObserver?.startObserving( volumeEnabled: volumeEnabled, diff --git a/DynamicIsland/managers/VerticalHUDWindowManager.swift b/DynamicIsland/managers/VerticalHUDWindowManager.swift index 6f1dd9d..ec61c24 100644 --- a/DynamicIsland/managers/VerticalHUDWindowManager.swift +++ b/DynamicIsland/managers/VerticalHUDWindowManager.swift @@ -136,13 +136,13 @@ final class VerticalHUDWindowManager { windows.removeAll() } - func show(type: SneakContentType, value: CGFloat, icon: String = "") { + func show(type: SneakContentType, value: CGFloat, icon: String = "", onScreen targetScreen: NSScreen? = nil) { guard Defaults[.enableVerticalHUD] else { return } - let screens = NSScreen.screens + let screens = targetScreen.map { [$0] } ?? NSScreen.screens guard !screens.isEmpty else { return } - // Show on all screens + // Show on target screen(s) for screen in screens { let windowContext = ensureWindow(for: type, screen: screen) updateContent(window: windowContext, type: type, value: value, icon: icon) From 12f8bc28bab97ebb5b391d07c63570c64b058204 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 3 Mar 2026 00:41:49 +0530 Subject: [PATCH 05/26] Update per display windows for betterdisplay --- DynamicIsland/Localizable.xcstrings | 3 + .../components/Settings/SettingsView.swift | 29 +++-- .../managers/BetterDisplayManager.swift | 23 +++- DynamicIsland/managers/SystemHUDManager.swift | 103 ++++++++++-------- 4 files changed, 99 insertions(+), 59 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index bb20861..df19672 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -8747,6 +8747,9 @@ } } } + }, + "Disabled while BetterDisplay integration is active — BetterDisplay uses Cmd+Brightness keys for its own controls." : { + }, "Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%)." : { "comment" : "A description of how the color-coded progress bars work when not using smooth transitions.", diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 1ee0ec8..7484500 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1384,8 +1384,7 @@ private struct HUDAndOSDSettingsView: View { @Default(.enableVolumeHUD) var enableVolumeHUD @Default(.enableBrightnessHUD) var enableBrightnessHUD @Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD - - // Vertical HUD Props + @Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration @Default(.verticalHUDShowValue) var verticalHUDShowValue @Default(.verticalHUDInteractive) var verticalHUDInteractive @Default(.verticalHUDHeight) var verticalHUDHeight @@ -1621,7 +1620,7 @@ private struct HUDAndOSDSettingsView: View { } case .vertical: Form { - if !accessibilityPermission.isAuthorized { + if !accessibilityPermission.isAuthorized && !enableBetterDisplayIntegration { Section { SettingsPermissionCallout( message: "Accessibility permission is needed to intercept system controls for the Vertical HUD.", @@ -1637,11 +1636,13 @@ private struct HUDAndOSDSettingsView: View { } } - if accessibilityPermission.isAuthorized { + if accessibilityPermission.isAuthorized || enableBetterDisplayIntegration { 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." : "") } header: { Text("Controls") } footer: { @@ -1736,7 +1737,7 @@ private struct HUDAndOSDSettingsView: View { case .circular: Form { - if !accessibilityPermission.isAuthorized { + if !accessibilityPermission.isAuthorized && !enableBetterDisplayIntegration { Section { SettingsPermissionCallout( message: "Accessibility permission is needed to intercept system controls for the Circular HUD.", @@ -1752,11 +1753,13 @@ private struct HUDAndOSDSettingsView: View { } } - if accessibilityPermission.isAuthorized { + if accessibilityPermission.isAuthorized || enableBetterDisplayIntegration { 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." : "") } header: { Text("Controls") } footer: { @@ -2020,6 +2023,7 @@ struct HUD: View { @Default(.enableVolumeHUD) var enableVolumeHUD @Default(.enableBrightnessHUD) var enableBrightnessHUD @Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD + @Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration @Default(.systemHUDSensitivity) var systemHUDSensitivity @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared @@ -2038,7 +2042,7 @@ struct HUD: View { var body: some View { Form { - if !hasAccessibilityPermission { + if !hasAccessibilityPermission && !enableBetterDisplayIntegration { Section { SettingsPermissionCallout( message: "Accessibility permission lets Dynamic Island replace the native volume, brightness, and keyboard HUDs.", @@ -2052,11 +2056,13 @@ struct HUD: View { - if enableSystemHUD && !Defaults[.enableCustomOSD] && hasAccessibilityPermission { + if enableSystemHUD && !Defaults[.enableCustomOSD] && (hasAccessibilityPermission || enableBetterDisplayIntegration) { 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." : "") } header: { Text("Controls") } footer: { @@ -6702,6 +6708,7 @@ struct CustomOSDSettings: View { @Default(.enableOSDVolume) var enableOSDVolume @Default(.enableOSDBrightness) var enableOSDBrightness @Default(.enableOSDKeyboardBacklight) var enableOSDKeyboardBacklight + @Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration @Default(.osdMaterial) var osdMaterial @Default(.osdLiquidGlassCustomizationMode) var osdLiquidGlassCustomizationMode @Default(.osdLiquidGlassVariant) var osdLiquidGlassVariant @@ -6744,7 +6751,7 @@ struct CustomOSDSettings: View { var body: some View { Form { - if !hasAccessibilityPermission { + if !hasAccessibilityPermission && !enableBetterDisplayIntegration { Section { SettingsPermissionCallout( message: "Accessibility permission is needed to intercept system controls for the Custom OSD.", @@ -6756,7 +6763,7 @@ struct CustomOSDSettings: View { } } - if hasAccessibilityPermission { + if hasAccessibilityPermission || enableBetterDisplayIntegration { Section { Toggle("Volume OSD", isOn: $enableOSDVolume) .settingsHighlight(id: highlightID("Volume OSD")) @@ -6764,6 +6771,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." : "") } header: { Text("Controls") } footer: { diff --git a/DynamicIsland/managers/BetterDisplayManager.swift b/DynamicIsland/managers/BetterDisplayManager.swift index 5d10948..16a4b8a 100644 --- a/DynamicIsland/managers/BetterDisplayManager.swift +++ b/DynamicIsland/managers/BetterDisplayManager.swift @@ -190,6 +190,8 @@ final class BetterDisplayManager: ObservableObject { return } + NSLog("📺 BetterDisplay OSD raw payload: \(notificationString)") + do { let osd = try JSONDecoder().decode( BetterDisplayOSDNotification.self, @@ -208,21 +210,20 @@ final class BetterDisplayManager: ObservableObject { let targetScreen = resolveScreen(for: osd.displayID) let isExternalDisplay = isExternal(displayID: osd.displayID) + NSLog("📺 BetterDisplay OSD: target=\(osd.controlTarget ?? "nil") displayID=\(osd.displayID.map(String.init) ?? "nil") resolvedScreen=\(targetScreen?.localizedName ?? "nil") isExternal=\(isExternalDisplay) value=\(osd.value ?? -1)") + switch category { case .brightness: - guard Defaults[.enableBrightnessHUD] else { return } let icon = isExternalDisplay ? "display" : nil dispatchBrightnessHUD(value: normalizedValue, customSymbol: icon, onScreen: targetScreen) case .volume: - guard Defaults[.enableVolumeHUD] else { return } let isMuted = osd.controlTarget == "mute" || osd.systemIconID == 4 dispatchVolumeHUD(value: normalizedValue, isMuted: isMuted, onScreen: targetScreen) case .other: // For unsupported control targets (contrast, gamma, temperature, etc.), // show a generic brightness-style HUD if the user has brightness HUD enabled - guard Defaults[.enableBrightnessHUD] else { return } let icon = isExternalDisplay ? "display" : osd.customSymbol dispatchBrightnessHUD(value: normalizedValue, customSymbol: icon, onScreen: targetScreen) } @@ -281,14 +282,26 @@ final class BetterDisplayManager: ObservableObject { /// Resolve a BetterDisplay `displayID` (CGDirectDisplayID) to the matching NSScreen, if any. private func resolveScreen(for displayID: Int?) -> NSScreen? { - guard let displayID else { return nil } + guard let displayID else { + NSLog("📺 BetterDisplay resolveScreen: displayID is nil, falling back to all screens") + return nil + } let target = UInt32(displayID) - return NSScreen.screens.first { screen in + 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 } /// Whether the given displayID refers to an external (non-built-in) display. diff --git a/DynamicIsland/managers/SystemHUDManager.swift b/DynamicIsland/managers/SystemHUDManager.swift index 77cdead..efc7893 100644 --- a/DynamicIsland/managers/SystemHUDManager.swift +++ b/DynamicIsland/managers/SystemHUDManager.swift @@ -108,70 +108,76 @@ class SystemHUDManager { }.store(in: &cancellables) // Observe individual HUD toggles - Defaults.publisher(.enableVolumeHUD, options: []).sink { [weak self] change in + Defaults.publisher(.enableVolumeHUD, options: []).sink { [weak self] _ in guard let self = self, self.isSetupComplete, self.requiresSystemToggleHandling else { return } + let flags = self.resolvedControlFlags() self.changesObserver?.update( - volumeEnabled: change.newValue, - brightnessEnabled: Defaults[.enableBrightnessHUD], - keyboardBacklightEnabled: Defaults[.enableKeyboardBacklightHUD] + volumeEnabled: flags.volume, + brightnessEnabled: flags.brightness, + keyboardBacklightEnabled: flags.backlight ) }.store(in: &cancellables) - Defaults.publisher(.enableBrightnessHUD, options: []).sink { [weak self] change in + Defaults.publisher(.enableBrightnessHUD, options: []).sink { [weak self] _ in guard let self = self, self.isSetupComplete, self.requiresSystemToggleHandling else { return } + let flags = self.resolvedControlFlags() self.changesObserver?.update( - volumeEnabled: Defaults[.enableVolumeHUD], - brightnessEnabled: change.newValue, - keyboardBacklightEnabled: Defaults[.enableKeyboardBacklightHUD] + volumeEnabled: flags.volume, + brightnessEnabled: flags.brightness, + keyboardBacklightEnabled: flags.backlight ) }.store(in: &cancellables) - Defaults.publisher(.enableKeyboardBacklightHUD, options: []).sink { [weak self] change in + Defaults.publisher(.enableKeyboardBacklightHUD, options: []).sink { [weak self] _ in guard let self = self, self.isSetupComplete, self.requiresSystemToggleHandling else { return } + let flags = self.resolvedControlFlags() self.changesObserver?.update( - volumeEnabled: Defaults[.enableVolumeHUD], - brightnessEnabled: Defaults[.enableBrightnessHUD], - keyboardBacklightEnabled: change.newValue + volumeEnabled: flags.volume, + brightnessEnabled: flags.brightness, + keyboardBacklightEnabled: flags.backlight ) }.store(in: &cancellables) // Observe individual OSD toggles - Defaults.publisher(.enableOSDVolume, options: []).sink { [weak self] change in + Defaults.publisher(.enableOSDVolume, options: []).sink { [weak self] _ in guard let self = self, self.isSetupComplete, Defaults[.enableCustomOSD] else { return } + let flags = self.resolvedControlFlags() self.changesObserver?.update( - volumeEnabled: change.newValue, - brightnessEnabled: Defaults[.enableOSDBrightness], - keyboardBacklightEnabled: Defaults[.enableOSDKeyboardBacklight] + volumeEnabled: flags.volume, + brightnessEnabled: flags.brightness, + keyboardBacklightEnabled: flags.backlight ) }.store(in: &cancellables) - Defaults.publisher(.enableOSDBrightness, options: []).sink { [weak self] change in + Defaults.publisher(.enableOSDBrightness, options: []).sink { [weak self] _ in guard let self = self, self.isSetupComplete, Defaults[.enableCustomOSD] else { return } + let flags = self.resolvedControlFlags() self.changesObserver?.update( - volumeEnabled: Defaults[.enableOSDVolume], - brightnessEnabled: change.newValue, - keyboardBacklightEnabled: Defaults[.enableOSDKeyboardBacklight] + volumeEnabled: flags.volume, + brightnessEnabled: flags.brightness, + keyboardBacklightEnabled: flags.backlight ) }.store(in: &cancellables) - Defaults.publisher(.enableOSDKeyboardBacklight, options: []).sink { [weak self] change in + Defaults.publisher(.enableOSDKeyboardBacklight, options: []).sink { [weak self] _ in guard let self = self, self.isSetupComplete, Defaults[.enableCustomOSD] else { return } + let flags = self.resolvedControlFlags() self.changesObserver?.update( - volumeEnabled: Defaults[.enableOSDVolume], - brightnessEnabled: Defaults[.enableOSDBrightness], - keyboardBacklightEnabled: change.newValue + volumeEnabled: flags.volume, + brightnessEnabled: flags.brightness, + keyboardBacklightEnabled: flags.backlight ) }.store(in: &cancellables) @@ -214,22 +220,13 @@ class SystemHUDManager { } } - @MainActor - private func startSystemObserver() async { - guard let coordinator = coordinator, !isSystemOperationInProgress else { return } - - isSystemOperationInProgress = true - await stopSystemObserver() // Stop any existing observer - - changesObserver = SystemChangesObserver(coordinator: coordinator) - - // Determine which controls to enable based on HUD or OSD mode - let volumeEnabled: Bool - let brightnessEnabled: Bool + /// Resolves the effective control flags, applying BetterDisplay overrides. + private func resolvedControlFlags() -> (volume: Bool, brightness: Bool, backlight: Bool) { + var volumeEnabled: Bool + var brightnessEnabled: Bool var keyboardBacklightEnabled: Bool - + if Defaults[.enableCircularHUD] || Defaults[.enableVerticalHUD] { - // Respect per-control toggles so the system HUD can handle disabled events volumeEnabled = Defaults[.enableVolumeHUD] brightnessEnabled = Defaults[.enableBrightnessHUD] keyboardBacklightEnabled = Defaults[.enableKeyboardBacklightHUD] @@ -243,22 +240,40 @@ class SystemHUDManager { keyboardBacklightEnabled = Defaults[.enableKeyboardBacklightHUD] } - // When BetterDisplay integration is on, disable Cmd+Brightness key interception - // because BetterDisplay uses Cmd+F1/F2 for its own display controls. + // When BetterDisplay integration is on, stop intercepting brightness and + // Cmd+Brightness keys so BetterDisplay receives them and sends OSD notifications. if Defaults[.enableBetterDisplayIntegration] { + brightnessEnabled = false keyboardBacklightEnabled = false } + + return (volumeEnabled, brightnessEnabled, keyboardBacklightEnabled) + } + + @MainActor + private func startSystemObserver() async { + guard let coordinator = coordinator, !isSystemOperationInProgress else { return } + isSystemOperationInProgress = true + + // Stop any existing observer directly (cannot call stopSystemObserver() + // because isSystemOperationInProgress is already true). + changesObserver?.stopObserving() + changesObserver = nil + + changesObserver = SystemChangesObserver(coordinator: coordinator) + + let flags = resolvedControlFlags() changesObserver?.startObserving( - volumeEnabled: volumeEnabled, - brightnessEnabled: brightnessEnabled, - keyboardBacklightEnabled: keyboardBacklightEnabled + volumeEnabled: flags.volume, + brightnessEnabled: flags.brightness, + keyboardBacklightEnabled: flags.backlight ) // Force disable system HUD to ensure no duplicates SystemOSDManager.disableSystemHUD() - print("System observer started (HUD: \(Defaults[.enableSystemHUD]), OSD: \(Defaults[.enableCustomOSD]), Vertical: \(Defaults[.enableVerticalHUD]))") + print("System observer started (HUD: \(Defaults[.enableSystemHUD]), OSD: \(Defaults[.enableCustomOSD]), Vertical: \(Defaults[.enableVerticalHUD]), BD: \(Defaults[.enableBetterDisplayIntegration]))") isSystemOperationInProgress = false } From b46d88c79aad6c984988531383d3c459cf7854a4 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 3 Mar 2026 12:25:02 +0530 Subject: [PATCH 06/26] Nothing Playing --- DynamicIsland/Localizable.xcstrings | 4 + .../LockScreen/LockScreenMusicPanel.swift | 2 +- .../Notch/MinimalisticMusicPlayerView.swift | 142 +++++++++++------- .../components/Notch/NotchHomeView.swift | 10 +- DynamicIsland/managers/MusicManager.swift | 22 +++ 5 files changed, 119 insertions(+), 61 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index df19672..f84bc5f 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -17793,6 +17793,10 @@ } } }, + "Nothing Playing" : { + "comment" : "A label displayed when there is nothing playing in the music player.", + "isCommentAutoGenerated" : true + }, "Notify before" : { "comment" : "A label describing the slider that allows the user to set a reminder lead time.", "isCommentAutoGenerated" : true, diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index e7ffd82..fcc7afc 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -111,7 +111,7 @@ struct LockScreenMusicPanel: View { } var body: some View { - if isActive { + if isActive && musicManager.hasActiveSession { panelContent } else { Color.clear diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 6fa3009..b62b94b 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -45,72 +45,98 @@ struct MinimalisticMusicPlayerView: View { private let skipMagnitude: CGFloat = 8 var body: some View { - VStack(spacing: 0) { - // Header area with album art (matching DynamicIslandHeader height of 24pt) - GeometryReader { headerGeo in - let albumArtWidth: CGFloat = 50 - let spacing: CGFloat = 10 - let visualizerWidth: CGFloat = useMusicVisualizer ? 24 : 0 - let textWidth = max(0, headerGeo.size.width - albumArtWidth - spacing - (useMusicVisualizer ? (visualizerWidth + spacing) : 0)) - HStack(alignment: .center, spacing: spacing) { - MinimalisticAlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace) - .frame(width: albumArtWidth, height: albumArtWidth) + if !musicManager.hasActiveSession { + // Nothing playing state + VStack(spacing: 0) { + Spacer(minLength: 0) + + VStack(spacing: 8) { + Image(systemName: "music.note.slash") + .font(.system(size: 24, weight: .light)) + .foregroundColor(.gray) + Text("Nothing Playing") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.gray) + } + + Spacer(minLength: 0) + + timerCountdownSection + + reminderList + } + .padding(.horizontal, 12) + .frame(maxWidth: .infinity) + .frame(height: calculateDynamicHeight()) + .animation(.smooth(duration: 0.3), value: dynamicHeightSignature) + } else { + VStack(spacing: 0) { + // Header area with album art (matching DynamicIslandHeader height of 24pt) + GeometryReader { headerGeo in + let albumArtWidth: CGFloat = 50 + let spacing: CGFloat = 10 + let visualizerWidth: CGFloat = useMusicVisualizer ? 24 : 0 + let textWidth = max(0, headerGeo.size.width - albumArtWidth - spacing - (useMusicVisualizer ? (visualizerWidth + spacing) : 0)) + HStack(alignment: .center, spacing: spacing) { + MinimalisticAlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace) + .frame(width: albumArtWidth, height: albumArtWidth) + + VStack(alignment: .leading, spacing: 1) { + if !musicManager.songTitle.isEmpty { + MarqueeText( + $musicManager.songTitle, + font: .system(size: 12, weight: .semibold), + nsFont: .subheadline, + textColor: .white, + frameWidth: textWidth + ) + } + + Text(musicManager.artistName) + .font(.system(size: 10, weight: .regular)) + .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray) + .lineLimit(1) - VStack(alignment: .leading, spacing: 1) { - if !musicManager.songTitle.isEmpty { - MarqueeText( - $musicManager.songTitle, - font: .system(size: 12, weight: .semibold), - nsFont: .subheadline, - textColor: .white, - frameWidth: textWidth - ) } + .frame(width: textWidth, alignment: .leading) - Text(musicManager.artistName) - .font(.system(size: 10, weight: .regular)) - .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray) - .lineLimit(1) - - } - .frame(width: textWidth, alignment: .leading) - - if useMusicVisualizer { - visualizer - .frame(width: visualizerWidth) + if useMusicVisualizer { + visualizer + .frame(width: visualizerWidth) + } } } - } - .frame(height: 50) - - // Compact progress bar - progressBar - .padding(.top, 6) - - // Compact playback controls - if shouldShowControlHUDRow { - controlHUDRow - .padding(.top, 4) - } else { - playbackControls - .padding(.top, 4) - } + .frame(height: 50) + + // Compact progress bar + progressBar + .padding(.top, 6) + + // Compact playback controls + if shouldShowControlHUDRow { + controlHUDRow + .padding(.top, 4) + } else { + playbackControls + .padding(.top, 4) + } - if enableLyrics { - lyricsView - .padding(.top, 10) + if enableLyrics { + lyricsView + .padding(.top, 10) + } + + timerCountdownSection + + reminderList } - - timerCountdownSection - - reminderList + .padding(.horizontal, 12) + .padding(.top, -15) + .padding(.bottom, ReminderLiveActivityManager.baselineMinimalisticBottomPadding) + .frame(maxWidth: .infinity) + .frame(height: calculateDynamicHeight(), alignment: .top) + .animation(.smooth(duration: 0.3), value: dynamicHeightSignature) } - .padding(.horizontal, 12) - .padding(.top, -15) - .padding(.bottom, ReminderLiveActivityManager.baselineMinimalisticBottomPadding) - .frame(maxWidth: .infinity) - .frame(height: calculateDynamicHeight(), alignment: .top) - .animation(.smooth(duration: 0.3), value: dynamicHeightSignature) } // MARK: - TypingLyricView diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 1b3be04..1fe1925 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -521,8 +521,14 @@ struct NotchHomeView: View { @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared + @ObservedObject private var musicManager = MusicManager.shared @Default(.showStandardMediaControls) private var showStandardMediaControls let albumArtNamespace: Namespace.ID + + /// Whether the music player should actively display (enabled AND has real content). + private var shouldShowMusicPlayer: Bool { + showStandardMediaControls && musicManager.hasActiveSession + } var body: some View { Group { @@ -546,14 +552,14 @@ struct NotchHomeView: View { } } else { // Normal mode: Show full music player with optional calendar and webcam - if showStandardMediaControls { + if shouldShowMusicPlayer { MusicPlayerView(albumArtNamespace: albumArtNamespace) .frame(maxWidth: .infinity, alignment: .leading) } if Defaults[.showCalendar] { Group { - if showStandardMediaControls { + if shouldShowMusicPlayer { CalendarView() } else { StandaloneCalendarView() diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index 418e01b..188132f 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -78,6 +78,28 @@ class MusicManager: ObservableObject { @Published var isPlaying = false @Published var album: String = "Self Love" @Published var isPlayerIdle: Bool = true + + /// Whether there is an active music session with real metadata. + /// Returns `false` only when the metadata is still placeholder/fallback defaults + /// (i.e. nothing has been played since app launch, or the controller returned + /// unknown/not-playing placeholders). Paused music with real metadata is still + /// considered an active session. + private static let placeholderTitles: Set = [ + "i'm handsome", "unknown", "not playing" + ] + private static let placeholderArtists: Set = [ + "me", "unknown" + ] + + var hasActiveSession: Bool { + if isPlaying { return true } + let trimmedTitle = songTitle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let trimmedArtist = artistName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let hasRealTitle = !trimmedTitle.isEmpty && !Self.placeholderTitles.contains(trimmedTitle) + let hasRealArtist = !trimmedArtist.isEmpty && !Self.placeholderArtists.contains(trimmedArtist) + return hasRealTitle || hasRealArtist + } + @Published var animations: DynamicIslandAnimations = .init() @Published var avgColor: NSColor = .white @Published var bundleIdentifier: String? = nil From 6f4f1ae893393e2c404a059ba2be5169c676c4c1 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 3 Mar 2026 13:51:23 +0530 Subject: [PATCH 07/26] Fix: Sneak peek --- DynamicIsland/ContentView.swift | 23 ++++++++++++++++--- DynamicIsland/DynamicIslandApp.swift | 10 +++----- DynamicIsland/Localizable.xcstrings | 1 + .../components/Settings/SettingsView.swift | 14 +---------- 4 files changed, 25 insertions(+), 23 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 981af01..b97b446 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -87,6 +87,18 @@ struct ContentView: View { var dynamicNotchSize: CGSize { let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize + // When inline sneak peek is active in closed notch, use the wider inline width + // so the outer maxWidth frame doesn't clip the expanded content + let inlineSneakPeekActive = vm.notchState == .closed + && coordinator.expandingView.show + && (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer) + && Defaults[.enableSneakPeek] + && Defaults[.sneakPeekStyles] == .inline + if inlineSneakPeekActive { + let inlineWidth: CGFloat = 460 + return CGSize(width: max(baseSize.width, inlineWidth), height: baseSize.height) + } + if coordinator.currentView == .timer { return CGSize(width: baseSize.width, height: 250) // Extra height for timer presets } @@ -952,37 +964,42 @@ struct ContentView: View { .fill(.black) .frame(width: effectiveCenterWidth, height: notchContentHeight) .overlay( - HStack(alignment: .top){ + HStack(alignment: .top) { if(coordinator.expandingView.show && coordinator.expandingView.type == .music) { MarqueeText( .constant(musicManager.songTitle), textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray, minDuration: 0.4, - frameWidth: 100 + frameWidth: max(0, (effectiveCenterWidth - vm.closedNotchSize.width) / 2 - 12) ) + .padding(.leading, 8) .opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) Spacer(minLength: vm.closedNotchSize.width) Text(musicManager.artistName) .lineLimit(1) .truncationMode(.tail) .foregroundStyle(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray) + .padding(.trailing, 8) .opacity((coordinator.expandingView.show && coordinator.expandingView.type == .music && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) } else if(coordinator.expandingView.show && coordinator.expandingView.type == .timer) { MarqueeText( .constant(timerManager.timerName), textColor: timerManager.timerColor, minDuration: 0.4, - frameWidth: 100 + frameWidth: max(0, (effectiveCenterWidth - vm.closedNotchSize.width) / 2 - 12) ) + .padding(.leading, 8) .opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) Spacer(minLength: vm.closedNotchSize.width) Text(timerManager.formattedRemainingTime()) .lineLimit(1) .truncationMode(.tail) .foregroundStyle(timerManager.timerColor) + .padding(.trailing, 8) .opacity((coordinator.expandingView.show && coordinator.expandingView.type == .timer && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) } } + .clipped() ) musicRightWing(for: secondary, notchHeight: notchContentHeight, trailingWidth: rightWingWidth) diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 6bceecd..1143c43 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -297,8 +297,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { // If inline sneak peek is active, use a wider width to accommodate the expanded content if isInlineSneakPeekActive { // Calculate required width for inline sneak peek: - // Album art (~32) + Middle section (380) + Visualizer (~32) + padding = ~450 - let inlineSneakPeekWidth: CGFloat = 450 + // Album art (~32) + Middle section (380) + Visualizer (~32) + horizontal padding (28) + clip shape margin (12) + let inlineSneakPeekWidth: CGFloat = 460 return CGSize(width: inlineSneakPeekWidth, height: vm.effectiveClosedNotchHeight) } @@ -513,11 +513,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { }.store(in: &cancellables) // Observe minimalistic UI setting changes - trigger window resize - Defaults.publisher(.enableMinimalisticUI, options: []).sink { [weak self] change in - // Force sneak peek to standard mode when minimalistic UI is enabled - if change.newValue == true && Defaults[.sneakPeekStyles] != .standard { - Defaults[.sneakPeekStyles] = .standard - } + Defaults.publisher(.enableMinimalisticUI, options: []).sink { [weak self] _ in // Update window size IMMEDIATELY (no debouncing) to prevent position shift self?.updateWindowSizeIfNeeded() }.store(in: &cancellables) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index f84bc5f..9f538da 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -22077,6 +22077,7 @@ }, "Sneak peek style is locked to Standard in minimalistic mode" : { "comment" : "A message displayed when the sneak peek style is locked to standard in minimalistic UI mode.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "pt-BR" : { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 7484500..5c7a6ce 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -2308,21 +2308,9 @@ struct Media: View { Text(style.rawValue).tag(style) } } - .disabled(!enableSneakPeek || enableMinimalisticUI) - .onChange(of: enableMinimalisticUI) { _, isMinimalistic in - // Force standard sneak peek style when minimalistic UI is enabled - if isMinimalistic { - sneakPeekStyles = .standard - } - } + .disabled(!enableSneakPeek) .settingsHighlight(id: highlightID("Sneak Peek Style")) - if enableMinimalisticUI { - Text("Sneak peek style is locked to Standard in minimalistic mode") - .font(.caption) - .foregroundStyle(.secondary) - } - HStack { Stepper(value: $waitInterval, in: 0...10, step: 1) { HStack { From e59d35d89aa68a42759f381fae7dc26a6304d17a Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Wed, 4 Mar 2026 01:46:26 +0530 Subject: [PATCH 08/26] Terminal Work In Progress --- DynamicIsland.xcodeproj/project.pbxproj | 17 ++ .../xcshareddata/swiftpm/Package.resolved | 20 ++- DynamicIsland/ContentView.swift | 10 ++ DynamicIsland/DynamicIslandApp.swift | 19 +- .../DynamicIslandViewCoordinator.swift | 2 +- DynamicIsland/Localizable.xcstrings | 120 +++++-------- .../components/Notch/NotchTerminalView.swift | 165 ++++++++++++++++++ .../components/Settings/SettingsView.swift | 126 ++++++++++++- .../components/Tabs/TabSelectionView.swift | 3 + DynamicIsland/enums/generic.swift | 1 + DynamicIsland/managers/TerminalManager.swift | 157 +++++++++++++++++ DynamicIsland/models/Constants.swift | 7 + 12 files changed, 570 insertions(+), 77 deletions(-) create mode 100644 DynamicIsland/components/Notch/NotchTerminalView.swift create mode 100644 DynamicIsland/managers/TerminalManager.swift diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index f099972..46809fe 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 7ECFE0C62F4A2323000DD9BD /* MijickCalendarView in Frameworks */ = {isa = PBXBuildFile; productRef = 7ECFE0C52F4A2323000DD9BD /* MijickCalendarView */; }; 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; }; 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; }; + 7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 7EF19C162F577040005B9929 /* SwiftTerm */; }; 7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */; }; /* End PBXBuildFile section */ @@ -62,6 +63,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */, 7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */, 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */, 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */, @@ -174,6 +176,7 @@ 7E61181E2E24071E0050006C /* SkyLightWindow */, 7E288D112F1659EF0020C117 /* AtollExtensionKit */, 7ECFE0C52F4A2323000DD9BD /* MijickCalendarView */, + 7EF19C162F577040005B9929 /* SwiftTerm */, ); productName = DynamicIsland; productReference = F54406E72CD4987A00C7AFD9 /* Atoll.app */; @@ -220,6 +223,7 @@ 7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */, 7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */, 7ECFE0C42F4A2323000DD9BD /* XCRemoteSwiftPackageReference "CalendarView" */, + 7EF19C152F577040005B9929 /* XCRemoteSwiftPackageReference "SwiftTerm" */, ); preferredProjectObjectVersion = 77; productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */; @@ -601,6 +605,14 @@ minimumVersion = 1.1.1; }; }; + 7EF19C152F577040005B9929 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/migueldeicaza/SwiftTerm"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.5.0; + }; + }; 9A987A112C73CAA1005CA465 /* XCRemoteSwiftPackageReference "Pow" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/EmergeTools/Pow"; @@ -682,6 +694,11 @@ package = 7ECFE0C42F4A2323000DD9BD /* XCRemoteSwiftPackageReference "CalendarView" */; productName = MijickCalendarView; }; + 7EF19C162F577040005B9929 /* SwiftTerm */ = { + isa = XCSwiftPackageProductDependency; + package = 7EF19C152F577040005B9929 /* XCRemoteSwiftPackageReference "SwiftTerm" */; + productName = SwiftTerm; + }; 9A987A0F2C73CA8D005CA465 /* Collections */ = { isa = XCSwiftPackageProductDependency; package = F54407082CD4A23F00C7AFD9 /* XCRemoteSwiftPackageReference "swift-collections" */; diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7f72980..a7a1b40 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "802e15fea0dccd2d325dc082d8d36162007adcd2fa301070eb26448d0a2cf6c5", + "originHash" : "e56c82928835ebd09a259cfbf30486c2ebf4c785c9519f893675e244e709ec46", "pins" : [ { "identity" : "atollextensionkit", @@ -109,6 +109,15 @@ "version" : "2.7.0" } }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -127,6 +136,15 @@ "version" : "601.0.1" } }, + { + "identity" : "swiftterm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/migueldeicaza/SwiftTerm", + "state" : { + "revision" : "b1262db5b6bea699a8260a8c66999436c508ca56", + "version" : "1.11.2" + } + }, { "identity" : "swiftui-introspect", "kind" : "remoteSourceControl", diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index b97b446..2573d18 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -109,6 +109,14 @@ struct ContentView: View { return CGSize(width: baseSize.width, height: resolvedHeight) } + if coordinator.currentView == .terminal { + // Dynamic height: up to terminalMaxHeightFraction of screen, min 300pt + let screenHeight = NSScreen.main?.visibleFrame.height ?? 800 + let maxFraction = Defaults[.terminalMaxHeightFraction] + let terminalHeight = min(screenHeight * maxFraction, max(300, screenHeight * maxFraction)) + return CGSize(width: baseSize.width, height: terminalHeight) + } + if coordinator.currentView == .extensionExperience { if let preferredHeight = extensionTabPreferredHeight(baseSize: baseSize) { return CGSize(width: baseSize.width, height: preferredHeight) @@ -832,6 +840,8 @@ struct ContentView: View { NotchNotesView() case .clipboard: NotchNotesView() + case .terminal: + NotchTerminalView() case .extensionExperience: if let payload = currentExtensionTabPayload() { ExtensionNotchExperienceTabView(payload: payload) diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 1143c43..e23cd9c 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -311,6 +311,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { } else if coordinator.currentView == .notes || coordinator.currentView == .clipboard { let preferredHeight = coordinator.notesLayoutState.preferredHeight baseSize.height = max(baseSize.height, preferredHeight) + } else if coordinator.currentView == .terminal { + let screenHeight = NSScreen.main?.visibleFrame.height ?? 800 + let maxFraction = Defaults[.terminalMaxHeightFraction] + baseSize.height = min(screenHeight * maxFraction, max(300, screenHeight * maxFraction)) } let adjustedContentSize = statsAdjustedNotchSize( @@ -423,8 +427,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { PrivacyIndicatorManager.shared.startMonitoring() // Observe tab changes - use immediate resize to keep the notch pinned + // Deferred to next run loop tick because @Published fires on willSet, + // so coordinator.currentView still holds the OLD value at emission time. coordinator.$currentView.sink { [weak self] _ in - self?.updateWindowSizeForTabSwitch() + DispatchQueue.main.async { + self?.updateWindowSizeForTabSwitch() + } }.store(in: &cancellables) coordinator.$notesLayoutState @@ -463,6 +471,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { self?.debouncedUpdateWindowSize() }.store(in: &cancellables) + // Observe terminal settings changes + Defaults.publisher(.enableTerminalFeature, options: []).sink { [weak self] _ in + self?.debouncedUpdateWindowSize() + }.store(in: &cancellables) + + Defaults.publisher(.terminalMaxHeightFraction, options: []).sink { [weak self] _ in + self?.debouncedUpdateWindowSize() + }.store(in: &cancellables) + MemoryUsageMonitor.shared.startMonitoring() ReminderLiveActivityManager.shared.$activeWindowReminders diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index c29ce36..d578199 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -102,7 +102,7 @@ class DynamicIslandViewCoordinator: ObservableObject { static let shared = DynamicIslandViewCoordinator() private var cancellables = Set() - private static let tabOrder: [NotchViews] = [.home, .shelf, .timer, .stats, .colorPicker, .notes, .clipboard, .extensionExperience] + private static let tabOrder: [NotchViews] = [.home, .shelf, .timer, .stats, .colorPicker, .notes, .clipboard, .terminal, .extensionExperience] /// Direction of the most recent tab switch (true = forward/right, false = backward/left) @Published var tabSwitchForward: Bool = true diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 9f538da..459d121 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1054,6 +1054,10 @@ } } }, + "%lld pt" : { + "comment" : "A label displaying the current font size of the terminal. The value is shown in point size.", + "isCommentAutoGenerated" : true + }, "%lld px" : { "comment" : "A label displaying the current expanded notch width value. The value is shown in pixels.", "isCommentAutoGenerated" : true, @@ -1591,10 +1595,6 @@ } } }, - "±10 Seconds" : { - "comment" : "Display name for the \"±10 Seconds\" music skip behavior.", - "isCommentAutoGenerated" : true - }, "0" : { "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", "isCommentAutoGenerated" : true, @@ -1911,10 +1911,6 @@ } } }, - "Accent" : { - "comment" : "Name of the Caps Lock Indicator Tint Mode option that uses the accent color.", - "isCommentAutoGenerated" : true - }, "Accent color" : { "localizations" : { "ar" : { @@ -2260,10 +2256,6 @@ } } }, - "Adaptive gradient" : { - "comment" : "Name of the timer icon color mode that uses an adaptive gradient.", - "isCommentAutoGenerated" : true - }, "Add" : { "localizations" : { "ar" : { @@ -2756,6 +2748,10 @@ } } }, + "Adds a Guake-style dropdown terminal tab. The terminal runs a shell session that persists while the notch is open." : { + "comment" : "A description of the terminal settings feature.", + "isCommentAutoGenerated" : true + }, "Adds a notch HUD when Caps Lock is enabled, with optional label and tint controls." : { "localizations" : { "pt-BR" : { @@ -3853,10 +3849,6 @@ } } }, - "Apple Music" : { - "comment" : "Names for the different media player types available in the app.", - "isCommentAutoGenerated" : true - }, "Applies the notch-style parallax effect to the lock screen media widget album art." : { "localizations" : { "pt-BR" : { @@ -4268,10 +4260,6 @@ } } }, - "Bar" : { - "comment" : "Localized name for the \"Bar\" timer progress style.", - "isCommentAutoGenerated" : true - }, "Battery" : { "localizations" : { "ar" : { @@ -8530,10 +8518,6 @@ } } }, - "Digital" : { - "comment" : "The name of the \"Digital\" style of reminder presentation.", - "isCommentAutoGenerated" : true - }, "Dimensions" : { "localizations" : { "pt-BR" : { @@ -10016,6 +10000,9 @@ } } } + }, + "Enable it in Settings → Terminal" : { + }, "Enable media panel blur" : { "comment" : "A toggle switch that enables or disables the blur effect on the media panel.", @@ -11064,6 +11051,10 @@ } } }, + "Font size" : { + "comment" : "A label displayed above a slider that controls the font size of the terminal", + "isCommentAutoGenerated" : true + }, "Free · %@" : { "comment" : "A label displaying the amount of free storage on a disk device. The argument is the number of free bytes on the disk.", "isCommentAutoGenerated" : true, @@ -11919,10 +11910,6 @@ } } }, - "Green" : { - "comment" : "Display name for the \"Green\" Caps Lock Indicator Tint Mode.", - "isCommentAutoGenerated" : true - }, "Height:" : { "extractionState" : "stale", "localizations" : { @@ -14396,6 +14383,10 @@ } } }, + "Maximum height" : { + "comment" : "A label for the terminal's maximum height setting.", + "isCommentAutoGenerated" : true + }, "Media" : { "localizations" : { "ar" : { @@ -15593,6 +15584,7 @@ } }, "Minutes" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -17809,10 +17801,6 @@ } } }, - "Now Playing" : { - "comment" : "Localized name for each media controller type.", - "isCommentAutoGenerated" : true - }, "Off" : { "comment" : "Text displayed in a status label at the top of the screen indicating whether low power mode is on or off.", "isCommentAutoGenerated" : true @@ -18179,10 +18167,6 @@ } } }, - "Panel" : { - "comment" : "Display name for the \"Panel\" clipboard display mode.", - "isCommentAutoGenerated" : true - }, "Panel mode shows clipboard in a floating window near the notch." : { "comment" : "A description of the \"Panel\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -19707,6 +19691,18 @@ } } }, + "Restart shell" : { + "comment" : "A button label that says \"Restart shell\".", + "isCommentAutoGenerated" : true + }, + "Restart Shell" : { + "comment" : "A button that restarts the shell process. Any unsaved work in the terminal will be lost.", + "isCommentAutoGenerated" : true + }, + "Restarts the shell process. Any unsaved work in the terminal will be lost." : { + "comment" : "A footer description for the \"Restart Shell\" button in the Terminal settings view.", + "isCommentAutoGenerated" : true + }, "Restore" : { "comment" : "The text for a button that restores default settings.", "isCommentAutoGenerated" : true, @@ -19785,10 +19781,6 @@ } } }, - "Ring" : { - "comment" : "Localized name for the \"Ring\" timer progress style.", - "isCommentAutoGenerated" : true - }, "Rotation:" : { "extractionState" : "stale", "localizations" : { @@ -20609,10 +20601,6 @@ } } }, - "Separate Tab" : { - "comment" : "Name of the clipboard display mode option that opens the clipboard in a separate tab.", - "isCommentAutoGenerated" : true - }, "Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : { "comment" : "A description of the \"Separate Tab\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -20877,6 +20865,10 @@ } } }, + "Shell path" : { + "comment" : "A setting option for the path to the shell to use in the terminal", + "isCommentAutoGenerated" : true + }, "Shortcuts" : { "localizations" : { "ar" : { @@ -21667,14 +21659,6 @@ } } }, - "Shows screen assistant as a dropdown attached to the AI button" : { - "comment" : "Description of the \"popover\" display mode for the screen assistant.", - "isCommentAutoGenerated" : true - }, - "Shows screen assistant in a floating panel near the notch" : { - "comment" : "Description of the \"Panel\" screen assistant display mode.", - "isCommentAutoGenerated" : true - }, "Shows the model's reasoning process before the final answer" : { "localizations" : { "fr" : { @@ -21737,10 +21721,6 @@ } } }, - "Skip forward or backward by ten seconds" : { - "comment" : "Description of the \"Skip forward or backward by ten seconds\" music skip behavior option.", - "isCommentAutoGenerated" : true - }, "Slider color" : { "localizations" : { "ar" : { @@ -22309,10 +22289,6 @@ } } }, - "Spotify" : { - "comment" : "Add more media player types here.", - "isCommentAutoGenerated" : true - }, "Square" : { "localizations" : { "ar" : { @@ -22425,10 +22401,6 @@ }, "Standard notch media controls are hidden. Re-enable the toggle above to restore them." : { - }, - "Standard previous/next track controls" : { - "comment" : "Description of a music skip behavior option that uses standard previous/next track controls.", - "isCommentAutoGenerated" : true }, "Start" : { "localizations" : { @@ -23173,6 +23145,18 @@ } } }, + "Terminal" : { + "comment" : "The title of the settings view related to the terminal.", + "isCommentAutoGenerated" : true + }, + "Terminal is disabled" : { + "comment" : "A message displayed when the terminal feature is disabled.", + "isCommentAutoGenerated" : true + }, + "Terminal opacity" : { + "comment" : "A label displayed above a slider that controls the opacity of the terminal view", + "isCommentAutoGenerated" : true + }, "Text" : { "comment" : "Name of a clipboard item type that contains plain text.", "isCommentAutoGenerated" : true @@ -23778,10 +23762,6 @@ }, "shouldTranslate" : false }, - "Track Skip" : { - "comment" : "Name of the music skip behavior option that skips to the next track.", - "isCommentAutoGenerated" : true - }, "Transform" : { "extractionState" : "stale", "localizations" : { @@ -25700,10 +25680,6 @@ "comment" : "A footer note explaining the app's funding.", "isCommentAutoGenerated" : true }, - "Youtube Music" : { - "comment" : "Name for the \"Youtube Music\" media controller type in settings.", - "isCommentAutoGenerated" : true - }, "YouTube Music requires this third-party app to be installed: " : { "localizations" : { "ar" : { diff --git a/DynamicIsland/components/Notch/NotchTerminalView.swift b/DynamicIsland/components/Notch/NotchTerminalView.swift new file mode 100644 index 0000000..bc9068d --- /dev/null +++ b/DynamicIsland/components/Notch/NotchTerminalView.swift @@ -0,0 +1,165 @@ +/* + * 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 . + */ + +import SwiftUI +import SwiftTerm +import Defaults + +// MARK: - NSViewRepresentable wrapper for SwiftTerm + +/// Bridges SwiftTerm's `LocalProcessTerminalView` (NSView) into SwiftUI. +/// +/// Returns `TerminalManager.containerView` (a stable NSView) so that +/// SwiftUI's view lifecycle never tears down the actual terminal. The +/// real `LocalProcessTerminalView` lives as a subview of that container +/// and survives notch close/open and tab-switch cycles. +struct TerminalNSViewRepresentable: NSViewRepresentable { + @ObservedObject var terminalManager = TerminalManager.shared + + func makeNSView(context: Context) -> NSView { + context.coordinator.terminalManager = terminalManager + terminalManager.ensureTerminalView(delegate: context.coordinator) + if !terminalManager.isProcessRunning { + terminalManager.startShellProcess() + } + return terminalManager.containerView + } + + func updateNSView(_ nsView: NSView, context: Context) { + // Re-mount terminal if it was restarted (generation bumped). + terminalManager.ensureTerminalView(delegate: context.coordinator) + if !terminalManager.isProcessRunning { + terminalManager.startShellProcess() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator: NSObject, LocalProcessTerminalViewDelegate { + var terminalManager: TerminalManager? + + func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) { + // Terminal resized — no action needed; SwiftTerm handles reflow. + } + + func setTerminalTitle(source: LocalProcessTerminalView, title: String) { + Task { @MainActor in + terminalManager?.updateTitle(title) + } + } + + func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) { + // Could be used for breadcrumbs in the future. + } + + func processTerminated(source: TerminalView, exitCode: Int32?) { + Task { @MainActor in + terminalManager?.processDidTerminate(exitCode: exitCode) + } + } + } +} + +// MARK: - Notch Tab View + +/// Guake-style dropdown terminal tab for the notch. +/// Dynamically sizes up to half the screen height; content scrolls when the +/// terminal buffer exceeds the visible area (handled internally by SwiftTerm). +struct NotchTerminalView: View { + @ObservedObject var terminalManager = TerminalManager.shared + @EnvironmentObject var vm: DynamicIslandViewModel + @Default(.enableTerminalFeature) var enableTerminalFeature + @State private var suppressionToken = UUID() + @State private var isSuppressing = false + + var body: some View { + VStack(spacing: 0) { + if enableTerminalFeature { + // Terminal header bar + HStack { + Image(systemName: "apple.terminal") + .foregroundStyle(.secondary) + .font(.system(size: 11)) + + Text(terminalManager.terminalTitle) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer() + + // Restart button + Button { + terminalManager.restartShell() + } label: { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Restart shell") + } + .padding(.horizontal, 12) + .padding(.top, 6) + .padding(.bottom, 4) + + Divider() + .padding(.horizontal, 8) + + // Terminal content — .id(sessionGeneration) forces + // representable recreation only after an explicit restart. + TerminalNSViewRepresentable(terminalManager: terminalManager) + .id(terminalManager.sessionGeneration) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .padding(.horizontal, 8) + .padding(.bottom, 8) + .padding(.top, 4) + .onHover { hovering in + updateSuppression(for: hovering) + } + } else { + // Feature disabled placeholder + VStack(spacing: 8) { + Image(systemName: "apple.terminal") + .font(.system(size: 28)) + .foregroundStyle(.secondary) + + Text("Terminal is disabled") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + + Text("Enable it in Settings → Terminal") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .onDisappear { + updateSuppression(for: false) + } + } + + private func updateSuppression(for hovering: Bool) { + guard hovering != isSuppressing else { return } + isSuppressing = hovering + vm.setScrollGestureSuppression(hovering, token: suppressionToken) + } +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 5c7a6ce..f7c0efa 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -37,6 +37,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case shelf case shortcuts case notes + case terminal case about var id: String { rawValue } @@ -62,6 +63,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .shelf: return "Shelf" case .shortcuts: return "Shortcuts" case .notes: return "Notes" + case .terminal: return "Terminal" case .about: return "About" } } @@ -87,6 +89,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .shelf: return "books.vertical" case .shortcuts: return "keyboard" case .notes: return "note.text" + case .terminal: return "apple.terminal" case .about: return "info.circle" } } @@ -112,6 +115,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .shelf: return .brown case .shortcuts: return .orange case .notes: return Color(red: 0.979, green: 0.716, blue: 0.153, opacity: 1.000) + case .terminal: return Color(red: 0.2, green: 0.8, blue: 0.4) case .about: return .secondary } } @@ -421,6 +425,7 @@ struct SettingsView: View { .downloads, .shelf, .shortcuts, + .terminal, .extensions, .about ] @@ -781,13 +786,20 @@ struct SettingsView: View { SettingsSearchEntry(tab: .colorPicker, title: "Show Color Picker Icon", keywords: ["color icon", "toolbar"], highlightID: SettingsTab.colorPicker.highlightID(for: "Show Color Picker Icon")), SettingsSearchEntry(tab: .colorPicker, title: "Display Mode", keywords: ["color", "list"], highlightID: SettingsTab.colorPicker.highlightID(for: "Display Mode")), SettingsSearchEntry(tab: .colorPicker, title: "History Size", keywords: ["color history"], highlightID: SettingsTab.colorPicker.highlightID(for: "History Size")), - SettingsSearchEntry(tab: .colorPicker, title: "Show All Color Formats", keywords: ["hex", "hsl", "color formats"], highlightID: SettingsTab.colorPicker.highlightID(for: "Show All Color Formats")) + SettingsSearchEntry(tab: .colorPicker, title: "Show All Color Formats", keywords: ["hex", "hsl", "color formats"], highlightID: SettingsTab.colorPicker.highlightID(for: "Show All Color Formats")), + + // Terminal + SettingsSearchEntry(tab: .terminal, title: "Enable terminal", keywords: ["terminal", "guake", "shell"], highlightID: SettingsTab.terminal.highlightID(for: "Enable terminal")), + SettingsSearchEntry(tab: .terminal, title: "Shell path", keywords: ["shell", "zsh", "bash", "terminal"], highlightID: SettingsTab.terminal.highlightID(for: "Shell path")), + SettingsSearchEntry(tab: .terminal, title: "Font size", keywords: ["terminal", "font", "text size"], highlightID: SettingsTab.terminal.highlightID(for: "Font size")), + SettingsSearchEntry(tab: .terminal, title: "Terminal opacity", keywords: ["terminal", "opacity", "transparency"], highlightID: SettingsTab.terminal.highlightID(for: "Terminal opacity")), + SettingsSearchEntry(tab: .terminal, title: "Maximum height", keywords: ["terminal", "height", "size"], highlightID: SettingsTab.terminal.highlightID(for: "Maximum height")), ] } private func isTabVisible(_ tab: SettingsTab) -> Bool { switch tab { - case .timer, .stats, .clipboard, .screenAssistant, .colorPicker, .shelf, .notes: + case .timer, .stats, .clipboard, .screenAssistant, .colorPicker, .shelf, .notes, .terminal: return !enableMinimalisticUI default: return true @@ -873,6 +885,10 @@ struct SettingsView: View { SettingsForm(tab: .notes) { NotesSettingsView() } + case .terminal: + SettingsForm(tab: .terminal) { + TerminalSettings() + } case .about: if let controller = updaterController { SettingsForm(tab: .about) { @@ -6990,3 +7006,109 @@ struct NotesSettingsView: View { .navigationTitle("Notes") } } + +// MARK: - Terminal Settings + +struct TerminalSettings: View { + @ObservedObject var terminalManager = TerminalManager.shared + @Default(.enableTerminalFeature) var enableTerminalFeature + @Default(.terminalShellPath) var terminalShellPath + @Default(.terminalFontSize) var terminalFontSize + @Default(.terminalOpacity) var terminalOpacity + @Default(.terminalMaxHeightFraction) var terminalMaxHeightFraction + + private func highlightID(_ title: String) -> String { + SettingsTab.terminal.highlightID(for: title) + } + + private var formattedMaxHeight: String { + "\(Int(terminalMaxHeightFraction * 100))% of screen" + } + + var body: some View { + Form { + Section { + Defaults.Toggle("Enable terminal", key: .enableTerminalFeature) + .settingsHighlight(id: highlightID("Enable terminal")) + } header: { + Text("General") + } footer: { + Text("Adds a Guake-style dropdown terminal tab. The terminal runs a shell session that persists while the notch is open.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if enableTerminalFeature { + Section { + HStack { + Text("Shell path") + Spacer() + TextField("", text: $terminalShellPath) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + .multilineTextAlignment(.trailing) + } + .settingsHighlight(id: highlightID("Shell path")) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Font size") + Spacer() + Text("\(Int(terminalFontSize)) pt") + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider(value: $terminalFontSize, in: 8...24, step: 1) + .onChange(of: terminalFontSize) { _, newValue in + terminalManager.applyFontSize(newValue) + } + } + .settingsHighlight(id: highlightID("Font size")) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Terminal opacity") + Spacer() + Text("\(Int(terminalOpacity * 100))%") + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider(value: $terminalOpacity, in: 0.3...1.0, step: 0.05) + .onChange(of: terminalOpacity) { _, newValue in + terminalManager.applyOpacity(newValue) + } + } + .settingsHighlight(id: highlightID("Terminal opacity")) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Maximum height") + Spacer() + Text(formattedMaxHeight) + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider(value: $terminalMaxHeightFraction, in: 0.2...0.5, step: 0.05) + } + .settingsHighlight(id: highlightID("Maximum height")) + } header: { + Text("Appearance") + } + + Section { + Button("Restart Shell") { + terminalManager.restartShell() + } + .disabled(!terminalManager.isProcessRunning) + } header: { + Text("Actions") + } footer: { + Text("Restarts the shell process. Any unsaved work in the terminal will be lost.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Terminal") + } +} diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index caf7efe..03a0d09 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -83,6 +83,9 @@ struct TabSelectionView: View { let icon = Defaults[.enableNotes] ? "note.text" : "doc.on.clipboard" tabsArray.append(TabModel(label: label, icon: icon, view: .notes)) } + if Defaults[.enableTerminalFeature] { + tabsArray.append(TabModel(label: "Terminal", icon: "apple.terminal", view: .terminal)) + } if extensionTabsEnabled { for payload in extensionTabPayloads { guard let tab = payload.descriptor.tab else { continue } diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index 7b7eb52..c7d590a 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -78,6 +78,7 @@ public enum NotchViews { case colorPicker case notes case clipboard + case terminal case extensionExperience } diff --git a/DynamicIsland/managers/TerminalManager.swift b/DynamicIsland/managers/TerminalManager.swift new file mode 100644 index 0000000..b723fd9 --- /dev/null +++ b/DynamicIsland/managers/TerminalManager.swift @@ -0,0 +1,157 @@ +/* + * 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 . + */ + +import Foundation +import SwiftUI +import SwiftTerm +import Defaults + +/// Manages the Guake-style dropdown terminal session lifecycle. +/// The terminal is lazily created when the user first switches to the terminal tab, +/// and the process is kept alive across notch open/close cycles. +/// +/// Uses a stable `containerView` (NSView) as the host so that SwiftUI's +/// `NSViewRepresentable` lifecycle (make/update/dismantle) never tears down +/// the actual terminal. The `LocalProcessTerminalView` is added as a subview +/// of the container and survives notch close/open cycles. +@MainActor +class TerminalManager: ObservableObject { + static let shared = TerminalManager() + + /// Whether a shell process is currently running. + @Published var isProcessRunning: Bool = false + + /// The current terminal title reported by the shell. + @Published var terminalTitle: String = "Terminal" + + /// Bumped on restart so the `NSViewRepresentable` can recreate via `.id()`. + @Published var sessionGeneration: Int = 0 + + /// Stable container returned to SwiftUI — never deallocated. + let containerView: NSView = { + let v = NSView(frame: .zero) + v.autoresizingMask = [.width, .height] + v.wantsLayer = true + return v + }() + + /// The actual terminal view (child of `containerView`). + private(set) var terminalView: LocalProcessTerminalView? + + private init() {} + + // MARK: - Lifecycle + + /// Ensures the terminal view exists inside the container and returns the container. + /// Call this from the `NSViewRepresentable` wrapper. + func ensureTerminalView(delegate: LocalProcessTerminalViewDelegate) { + if let existing = terminalView, existing.superview === containerView { + // Already mounted — just re-wire the delegate in case the coordinator changed. + existing.processDelegate = delegate + return + } + + let view = LocalProcessTerminalView(frame: containerView.bounds) + view.autoresizingMask = [.width, .height] + + // Apply font size from settings + let fontSize = CGFloat(Defaults[.terminalFontSize]) + if let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) as NSFont? { + view.font = font + } + + // Apply opacity + view.layer?.opacity = Float(Defaults[.terminalOpacity]) + + // Configure the dark terminal appearance + view.nativeBackgroundColor = .black + view.nativeForegroundColor = .white + + view.processDelegate = delegate + + // Mount inside the stable container + containerView.subviews.forEach { $0.removeFromSuperview() } + containerView.addSubview(view) + terminalView = view + } + + /// Starts the shell process if not already running. + func startShellProcess() { + guard let view = terminalView, !isProcessRunning else { return } + + let shell = Defaults[.terminalShellPath] + let execName = "-" + (shell as NSString).lastPathComponent // login shell convention + + view.startProcess( + executable: shell, + args: [], + environment: buildEnvironment(), + execName: execName + ) + isProcessRunning = true + } + + /// Called when the shell process terminates. + func processDidTerminate(exitCode: Int32?) { + isProcessRunning = false + } + + /// Restarts the shell by tearing down the old terminal and creating a fresh one. + func restartShell() { + // Terminate the running process gracefully + terminalView?.terminate() + // Remove old terminal from container + terminalView?.removeFromSuperview() + terminalView = nil + isProcessRunning = false + terminalTitle = "Terminal" + // Bump generation so the SwiftUI representable re-mounts + sessionGeneration += 1 + } + + /// Updates the terminal title from the shell escape sequence. + func updateTitle(_ title: String) { + terminalTitle = title + } + + /// 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 + } + } + + /// Updates opacity on the live terminal view. + func applyOpacity(_ opacity: Double) { + guard let view = terminalView else { return } + view.layer?.opacity = Float(opacity) + } + + // MARK: - Environment + + /// Builds the environment for the child shell process. + private func buildEnvironment() -> [String] { + var env = ProcessInfo.processInfo.environment + env["TERM"] = "xterm-256color" + env["LANG"] = env["LANG"] ?? "en_US.UTF-8" + // Remove TERM_PROGRAM if set by a parent terminal + env.removeValue(forKey: "TERM_PROGRAM") + return env.map { "\($0.key)=\($0.value)" } + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 252fe03..86d60b1 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -911,6 +911,13 @@ extension Defaults.Keys { static let showNetworkGraph = Key("showNetworkGraph", default: false) static let showDiskGraph = Key("showDiskGraph", default: false) + // MARK: Terminal Feature + static let enableTerminalFeature = Key("enableTerminalFeature", default: false) + static let terminalShellPath = Key("terminalShellPath", default: "/bin/zsh") + static let terminalFontSize = Key("terminalFontSize", default: 12.0) + static let terminalOpacity = Key("terminalOpacity", default: 1.0) + static let terminalMaxHeightFraction = Key("terminalMaxHeightFraction", default: 0.4) + // MARK: Timer Feature static let enableTimerFeature = Key("enableTimerFeature", default: true) static let timerDisplayMode = Key("timerDisplayMode", default: .tab) From 1441f83388091111bb55d146445146054a129752 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Wed, 4 Mar 2026 14:06:51 +0530 Subject: [PATCH 09/26] Update stuff --- DynamicIsland/Localizable.xcstrings | 123 +++++++++- .../Live activities/InlineHUD.swift | 2 +- .../components/Notch/NotchTerminalView.swift | 5 +- .../components/Settings/SettingsView.swift | 134 ++++++++++- DynamicIsland/managers/TerminalManager.swift | 214 ++++++++++++++++-- DynamicIsland/models/Constants.swift | 8 + 6 files changed, 456 insertions(+), 30 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 459d121..412ac56 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1595,6 +1595,10 @@ } } }, + "±10 Seconds" : { + "comment" : "Display name for the \"±10 Seconds\" music skip behavior option.", + "isCommentAutoGenerated" : true + }, "0" : { "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", "isCommentAutoGenerated" : true, @@ -1911,6 +1915,10 @@ } } }, + "Accent" : { + "comment" : "Display name for the \"Accent\" caps lock indicator tint mode.", + "isCommentAutoGenerated" : true + }, "Accent color" : { "localizations" : { "ar" : { @@ -2256,6 +2264,10 @@ } } }, + "Adaptive gradient" : { + "comment" : "Name of the timer icon color mode option that uses an adaptive gradient.", + "isCommentAutoGenerated" : true + }, "Add" : { "localizations" : { "ar" : { @@ -2748,9 +2760,8 @@ } } }, - "Adds a Guake-style dropdown terminal tab. The terminal runs a shell session that persists while the notch is open." : { - "comment" : "A description of the terminal settings feature.", - "isCommentAutoGenerated" : true + "Adds a Guake-style dropdown terminal tab. The terminal session persists across notch open/close cycles." : { + }, "Adds a notch HUD when Caps Lock is enabled, with optional label and tint controls." : { "localizations" : { @@ -3352,6 +3363,9 @@ "Allow minimalistic overrides" : { "comment" : "Label for a toggle that allows minimalistic overrides in notch experiences.", "isCommentAutoGenerated" : true + }, + "Allow mouse reporting" : { + }, "Allowed Features" : { "comment" : "A label describing the allowed features for an app extension.", @@ -3849,6 +3863,10 @@ } } }, + "Apple Music" : { + "comment" : "Localized name for the \"Now Playing\" media controller type.", + "isCommentAutoGenerated" : true + }, "Applies the notch-style parallax effect to the lock screen media widget album art." : { "localizations" : { "pt-BR" : { @@ -4247,6 +4265,9 @@ } } } + }, + "Background" : { + }, "Backlight" : { "comment" : "A control for adjusting the keyboard backlight intensity.", @@ -4260,6 +4281,10 @@ } } }, + "Bar" : { + "comment" : "Localized name for the 'Bar' timer progress style.", + "isCommentAutoGenerated" : true + }, "Battery" : { "localizations" : { "ar" : { @@ -4825,6 +4850,9 @@ } } } + }, + "Bold text as bright colors" : { + }, "Boost your productivity with Clipboard Manager" : { "localizations" : { @@ -6877,6 +6905,9 @@ } } } + }, + "Colors" : { + }, "Coming soon" : { "localizations" : { @@ -7477,6 +7508,12 @@ } } } + }, + "Cursor" : { + + }, + "Cursor style" : { + }, "Custom" : { "extractionState" : "stale", @@ -8518,6 +8555,10 @@ } } }, + "Digital" : { + "comment" : "The display name for the \"Digital\" reminder presentation style.", + "isCommentAutoGenerated" : true + }, "Dimensions" : { "localizations" : { "pt-BR" : { @@ -11054,6 +11095,9 @@ "Font size" : { "comment" : "A label displayed above a slider that controls the font size of the terminal", "isCommentAutoGenerated" : true + }, + "Foreground" : { + }, "Free · %@" : { "comment" : "A label displaying the amount of free storage on a disk device. The argument is the number of free bytes on the disk.", @@ -11910,6 +11954,10 @@ } } }, + "Green" : { + "comment" : "Name of the \"Green\" Caps Lock Indicator Tint Mode.", + "isCommentAutoGenerated" : true + }, "Height:" : { "extractionState" : "stale", "localizations" : { @@ -12998,6 +13046,9 @@ } } } + }, + "Input" : { + }, "Install [BetterDisplay](https://betterdisplay.pro) to control external display brightness and volume through Atoll's HUD." : { "comment" : "A description of the benefits of installing BetterDisplay.", @@ -15584,7 +15635,6 @@ } }, "Minutes" : { - "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -17800,6 +17850,13 @@ } } } + }, + "Now Playing" : { + "comment" : "Localized name for each media controller type.", + "isCommentAutoGenerated" : true + }, + "Number of lines kept in the scrollback buffer. Higher values use more memory." : { + }, "Off" : { "comment" : "Text displayed in a status label at the top of the screen indicating whether low power mode is on or off.", @@ -18130,6 +18187,12 @@ } } } + }, + "Option as Meta key" : { + + }, + "Option as Meta sends Esc+key instead of macOS special characters. Mouse reporting forwards mouse events to terminal applications like vim or tmux." : { + }, "Output devices" : { "comment" : "Section title for the list of available audio output devices in the media output selector.", @@ -18167,6 +18230,10 @@ } } }, + "Panel" : { + "comment" : "Display name for the \"Panel\" clipboard display mode.", + "isCommentAutoGenerated" : true + }, "Panel mode shows clipboard in a floating window near the notch." : { "comment" : "A description of the \"Panel\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -19781,6 +19848,10 @@ } } }, + "Ring" : { + "comment" : "Localized name for the \"Ring\" timer progress style.", + "isCommentAutoGenerated" : true + }, "Rotation:" : { "extractionState" : "stale", "localizations" : { @@ -20093,6 +20164,12 @@ } } } + }, + "Scrollback" : { + + }, + "Scrollback lines" : { + }, "Search clipboard..." : { "localizations" : { @@ -20601,6 +20678,10 @@ } } }, + "Separate Tab" : { + "comment" : "Name of the clipboard display mode option that opens the clipboard in a separate tab.", + "isCommentAutoGenerated" : true + }, "Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : { "comment" : "A description of the \"Separate Tab\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -20864,6 +20945,9 @@ } } } + }, + "Shell" : { + }, "Shell path" : { "comment" : "A setting option for the path to the shell to use in the terminal", @@ -21659,6 +21743,14 @@ } } }, + "Shows screen assistant as a dropdown attached to the AI button" : { + "comment" : "Description of the \"Popover\" display mode for the screen assistant.", + "isCommentAutoGenerated" : true + }, + "Shows screen assistant in a floating panel near the notch" : { + "comment" : "Description of the \"panel\" display mode for the screen assistant.", + "isCommentAutoGenerated" : true + }, "Shows the model's reasoning process before the final answer" : { "localizations" : { "fr" : { @@ -21721,6 +21813,10 @@ } } }, + "Skip forward or backward by ten seconds" : { + "comment" : "Description of the \"Skip forward or backward by ten seconds\" music skip behavior.", + "isCommentAutoGenerated" : true + }, "Slider color" : { "localizations" : { "ar" : { @@ -22289,6 +22385,10 @@ } } }, + "Spotify" : { + "comment" : "Add more media controller types here.", + "isCommentAutoGenerated" : true + }, "Square" : { "localizations" : { "ar" : { @@ -22401,6 +22501,10 @@ }, "Standard notch media controls are hidden. Re-enable the toggle above to restore them." : { + }, + "Standard previous/next track controls" : { + "comment" : "Description of a music skip behavior option that uses standard previous/next track controls.", + "isCommentAutoGenerated" : true }, "Start" : { "localizations" : { @@ -23762,6 +23866,10 @@ }, "shouldTranslate" : false }, + "Track Skip" : { + "comment" : "Name of the music skip behavior option that skips to the next track.", + "isCommentAutoGenerated" : true + }, "Transform" : { "extractionState" : "stale", "localizations" : { @@ -25315,6 +25423,9 @@ } } } + }, + "When bold-as-bright is off, bold text uses a heavier font weight instead of bright ANSI colors." : { + }, "When disabled, all keyboard shortcuts will be inactive. You can still use the UI controls." : { "localizations" : { @@ -25680,6 +25791,10 @@ "comment" : "A footer note explaining the app's funding.", "isCommentAutoGenerated" : true }, + "Youtube Music" : { + "comment" : "Add more media player types here.", + "isCommentAutoGenerated" : true + }, "YouTube Music requires this third-party app to be installed: " : { "localizations" : { "ar" : { diff --git a/DynamicIsland/components/Live activities/InlineHUD.swift b/DynamicIsland/components/Live activities/InlineHUD.swift index 975e88d..a9391d1 100644 --- a/DynamicIsland/components/Live activities/InlineHUD.swift +++ b/DynamicIsland/components/Live activities/InlineHUD.swift @@ -237,7 +237,7 @@ struct InlineHUD: View { LoopingVideoIcon(url: url, size: CGSize(width: 20, height: 20)) .frame(width: 20, height: 20, alignment: .center) } else { - Image(systemName: icon.isEmpty ? "bluetooth" : icon) + Image(systemName: icon.isEmpty ? "dot.radiowaves.left.and.right" : icon) .symbolRenderingMode(.hierarchical) .contentTransition(.interpolate) .frame(width: 20, height: 15, alignment: .center) diff --git a/DynamicIsland/components/Notch/NotchTerminalView.swift b/DynamicIsland/components/Notch/NotchTerminalView.swift index bc9068d..a3f4336 100644 --- a/DynamicIsland/components/Notch/NotchTerminalView.swift +++ b/DynamicIsland/components/Notch/NotchTerminalView.swift @@ -123,10 +123,9 @@ struct NotchTerminalView: View { Divider() .padding(.horizontal, 8) - // Terminal content — .id(sessionGeneration) forces - // representable recreation only after an explicit restart. + // Terminal content — the containerView is stable across + // notch close/open cycles; updateNSView handles restart. TerminalNSViewRepresentable(terminalManager: terminalManager) - .id(terminalManager.sessionGeneration) .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) .padding(.horizontal, 8) .padding(.bottom, 8) diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index f7c0efa..551d297 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -794,6 +794,14 @@ struct SettingsView: View { SettingsSearchEntry(tab: .terminal, title: "Font size", keywords: ["terminal", "font", "text size"], highlightID: SettingsTab.terminal.highlightID(for: "Font size")), SettingsSearchEntry(tab: .terminal, title: "Terminal opacity", keywords: ["terminal", "opacity", "transparency"], highlightID: SettingsTab.terminal.highlightID(for: "Terminal opacity")), SettingsSearchEntry(tab: .terminal, title: "Maximum height", keywords: ["terminal", "height", "size"], highlightID: SettingsTab.terminal.highlightID(for: "Maximum height")), + SettingsSearchEntry(tab: .terminal, title: "Background color", keywords: ["terminal", "background", "color", "theme"], highlightID: SettingsTab.terminal.highlightID(for: "Background color")), + SettingsSearchEntry(tab: .terminal, title: "Foreground color", keywords: ["terminal", "foreground", "text color", "theme"], highlightID: SettingsTab.terminal.highlightID(for: "Foreground color")), + SettingsSearchEntry(tab: .terminal, title: "Cursor color", keywords: ["terminal", "cursor", "caret", "color"], highlightID: SettingsTab.terminal.highlightID(for: "Cursor color")), + SettingsSearchEntry(tab: .terminal, title: "Bold as bright", keywords: ["terminal", "bold", "bright", "colors"], highlightID: SettingsTab.terminal.highlightID(for: "Bold as bright")), + SettingsSearchEntry(tab: .terminal, title: "Cursor style", keywords: ["terminal", "cursor", "block", "underline", "bar", "blink"], highlightID: SettingsTab.terminal.highlightID(for: "Cursor style")), + SettingsSearchEntry(tab: .terminal, title: "Scrollback lines", keywords: ["terminal", "scrollback", "buffer", "history"], highlightID: SettingsTab.terminal.highlightID(for: "Scrollback lines")), + SettingsSearchEntry(tab: .terminal, title: "Option as Meta", keywords: ["terminal", "option", "meta", "alt", "key"], highlightID: SettingsTab.terminal.highlightID(for: "Option as Meta")), + SettingsSearchEntry(tab: .terminal, title: "Mouse reporting", keywords: ["terminal", "mouse", "reporting", "vim", "tmux"], highlightID: SettingsTab.terminal.highlightID(for: "Mouse reporting")), ] } @@ -7016,6 +7024,14 @@ struct TerminalSettings: View { @Default(.terminalFontSize) var terminalFontSize @Default(.terminalOpacity) var terminalOpacity @Default(.terminalMaxHeightFraction) var terminalMaxHeightFraction + @Default(.terminalCursorStyle) var terminalCursorStyle + @Default(.terminalScrollbackLines) var terminalScrollbackLines + @Default(.terminalOptionAsMeta) var terminalOptionAsMeta + @Default(.terminalMouseReporting) var terminalMouseReporting + @Default(.terminalBoldAsBright) var terminalBoldAsBright + @Default(.terminalBackgroundColor) var terminalBackgroundColor + @Default(.terminalForegroundColor) var terminalForegroundColor + @Default(.terminalCursorColor) var terminalCursorColor private func highlightID(_ title: String) -> String { SettingsTab.terminal.highlightID(for: title) @@ -7025,6 +7041,13 @@ struct TerminalSettings: View { "\(Int(terminalMaxHeightFraction * 100))% of screen" } + private var cursorStyleBinding: Binding { + Binding( + get: { TerminalCursorStyleOption(rawValue: terminalCursorStyle) ?? .blinkBlock }, + set: { terminalCursorStyle = $0.rawValue } + ) + } + var body: some View { Form { Section { @@ -7033,12 +7056,13 @@ struct TerminalSettings: View { } header: { Text("General") } footer: { - Text("Adds a Guake-style dropdown terminal tab. The terminal runs a shell session that persists while the notch is open.") + Text("Adds a Guake-style dropdown terminal tab. The terminal session persists across notch open/close cycles.") .font(.caption) .foregroundStyle(.secondary) } if enableTerminalFeature { + // MARK: Shell Section { HStack { Text("Shell path") @@ -7049,7 +7073,12 @@ struct TerminalSettings: View { .multilineTextAlignment(.trailing) } .settingsHighlight(id: highlightID("Shell path")) + } header: { + Text("Shell") + } + // MARK: Appearance + Section { VStack(alignment: .leading, spacing: 8) { HStack { Text("Font size") @@ -7095,6 +7124,109 @@ struct TerminalSettings: View { Text("Appearance") } + // MARK: Colors + Section { + ColorPicker("Background", selection: $terminalBackgroundColor, supportsOpacity: false) + .onChange(of: terminalBackgroundColor) { _, newValue in + terminalManager.applyBackgroundColor(newValue) + } + .settingsHighlight(id: highlightID("Background color")) + + ColorPicker("Foreground", selection: $terminalForegroundColor, supportsOpacity: false) + .onChange(of: terminalForegroundColor) { _, newValue in + terminalManager.applyForegroundColor(newValue) + } + .settingsHighlight(id: highlightID("Foreground color")) + + ColorPicker("Cursor", selection: $terminalCursorColor, supportsOpacity: false) + .onChange(of: terminalCursorColor) { _, newValue in + terminalManager.applyCursorColor(newValue) + } + .settingsHighlight(id: highlightID("Cursor color")) + + Toggle("Bold text as bright colors", isOn: $terminalBoldAsBright) + .onChange(of: terminalBoldAsBright) { _, newValue in + terminalManager.applyBoldAsBright(newValue) + } + .settingsHighlight(id: highlightID("Bold as bright")) + } header: { + Text("Colors") + } footer: { + Text("When bold-as-bright is off, bold text uses a heavier font weight instead of bright ANSI colors.") + .font(.caption) + .foregroundStyle(.secondary) + } + + // MARK: Cursor + Section { + Picker("Cursor style", selection: cursorStyleBinding) { + ForEach(TerminalCursorStyleOption.allCases, id: \.self) { style in + Text(style.displayName).tag(style) + } + } + .onChange(of: terminalCursorStyle) { _, newValue in + if let style = TerminalCursorStyleOption(rawValue: newValue) { + terminalManager.applyCursorStyle(style) + } + } + .settingsHighlight(id: highlightID("Cursor style")) + } header: { + Text("Cursor") + } + + // MARK: Scrollback + Section { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Scrollback lines") + Spacer() + Text("\(terminalScrollbackLines)") + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider( + value: Binding( + get: { Double(terminalScrollbackLines) }, + set: { terminalScrollbackLines = Int($0) } + ), + in: 100...10000, + step: 100 + ) + .onChange(of: terminalScrollbackLines) { _, newValue in + terminalManager.applyScrollback(newValue) + } + } + .settingsHighlight(id: highlightID("Scrollback lines")) + } header: { + Text("Scrollback") + } footer: { + Text("Number of lines kept in the scrollback buffer. Higher values use more memory.") + .font(.caption) + .foregroundStyle(.secondary) + } + + // MARK: Input + Section { + Toggle("Option as Meta key", isOn: $terminalOptionAsMeta) + .onChange(of: terminalOptionAsMeta) { _, newValue in + terminalManager.applyOptionAsMeta(newValue) + } + .settingsHighlight(id: highlightID("Option as Meta")) + + Toggle("Allow mouse reporting", isOn: $terminalMouseReporting) + .onChange(of: terminalMouseReporting) { _, newValue in + terminalManager.applyMouseReporting(newValue) + } + .settingsHighlight(id: highlightID("Mouse reporting")) + } header: { + Text("Input") + } footer: { + Text("Option as Meta sends Esc+key instead of macOS special characters. Mouse reporting forwards mouse events to terminal applications like vim or tmux.") + .font(.caption) + .foregroundStyle(.secondary) + } + + // MARK: Actions Section { Button("Restart Shell") { terminalManager.restartShell() diff --git a/DynamicIsland/managers/TerminalManager.swift b/DynamicIsland/managers/TerminalManager.swift index b723fd9..212c81b 100644 --- a/DynamicIsland/managers/TerminalManager.swift +++ b/DynamicIsland/managers/TerminalManager.swift @@ -21,11 +21,53 @@ import SwiftUI import SwiftTerm import Defaults +// MARK: - Stable Container + +/// Container NSView that shields its terminal child from zero-sized frames. +/// +/// When the notch closes, SwiftUI removes the `NSViewRepresentable` from the +/// hierarchy — but later re-adds the same `containerView` on reopen. During +/// that insertion, SwiftUI momentarily sets the frame to `.zero`. Without +/// protection, AppKit's autoresizing mask propagates the zero frame to the +/// terminal child, causing SwiftTerm's `processSizeChange` to resize the +/// emulator to 2 cols × 1 row (the enforced minimum), which destroys the +/// scrollback buffer. +/// +/// We bypass `super.resizeSubviews(withOldSize:)` entirely and manually set +/// children to fill the container's bounds. This avoids the autoresizing +/// calculation which produces corrupt geometry when `oldSize` was `.zero` +/// (recorded during the transient removal) but the child kept its previous +/// large frame. +final class StableTerminalContainerView: NSView { + override func resizeSubviews(withOldSize oldSize: NSSize) { + let size = bounds.size + // Block degenerate sizes — the terminal would collapse to 2×1 + guard size.width >= 10, size.height >= 10 else { return } + // Manually fill children to bounds — bypasses autoresizing math + // that breaks when oldSize was zero but child kept its old frame. + for child in subviews { + child.frame = bounds + } + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard window != nil else { return } + // When re-inserted into a window (notch reopened), force a full + // redraw so SwiftTerm re-renders the terminal buffer contents. + for child in subviews { + child.needsDisplay = true + } + } +} + +// MARK: - Terminal Manager + /// Manages the Guake-style dropdown terminal session lifecycle. /// The terminal is lazily created when the user first switches to the terminal tab, /// and the process is kept alive across notch open/close cycles. /// -/// Uses a stable `containerView` (NSView) as the host so that SwiftUI's +/// Uses a stable `StableTerminalContainerView` as the host so that SwiftUI's /// `NSViewRepresentable` lifecycle (make/update/dismantle) never tears down /// the actual terminal. The `LocalProcessTerminalView` is added as a subview /// of the container and survives notch close/open cycles. @@ -39,12 +81,11 @@ class TerminalManager: ObservableObject { /// The current terminal title reported by the shell. @Published var terminalTitle: String = "Terminal" - /// Bumped on restart so the `NSViewRepresentable` can recreate via `.id()`. - @Published var sessionGeneration: Int = 0 - /// Stable container returned to SwiftUI — never deallocated. - let containerView: NSView = { - let v = NSView(frame: .zero) + /// Uses `StableTerminalContainerView` to prevent zero-frame transients + /// from destroying the scrollback buffer. + let containerView: StableTerminalContainerView = { + let v = StableTerminalContainerView(frame: .zero) v.autoresizingMask = [.width, .height] v.wantsLayer = true return v @@ -63,24 +104,25 @@ class TerminalManager: ObservableObject { if let existing = terminalView, existing.superview === containerView { // Already mounted — just re-wire the delegate in case the coordinator changed. existing.processDelegate = delegate + // Force a redraw — the view may have been off-screen (notch closed) + // and needs to re-render the terminal buffer. + existing.needsDisplay = true return } - let view = LocalProcessTerminalView(frame: containerView.bounds) + // Use the container's current bounds if valid, otherwise a reasonable + // default. SwiftTerm's init calls setupOptions() which reads the + // frame — a zero frame creates a 2×1 terminal that is corrected + // once the container gets its proper layout size. + let initialFrame = containerView.bounds.size.width >= 10 + ? containerView.bounds + : CGRect(x: 0, y: 0, width: 400, height: 300) + + let view = LocalProcessTerminalView(frame: initialFrame) view.autoresizingMask = [.width, .height] - // Apply font size from settings - let fontSize = CGFloat(Defaults[.terminalFontSize]) - if let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) as NSFont? { - view.font = font - } - - // Apply opacity - view.layer?.opacity = Float(Defaults[.terminalOpacity]) - - // Configure the dark terminal appearance - view.nativeBackgroundColor = .black - view.nativeForegroundColor = .white + // Apply all settings from Defaults + applyAllSettings(to: view) view.processDelegate = delegate @@ -88,6 +130,12 @@ class TerminalManager: ObservableObject { containerView.subviews.forEach { $0.removeFromSuperview() } containerView.addSubview(view) terminalView = view + + // If the container already has a valid size, snap the child to it. + let containerSize = containerView.bounds.size + if containerSize.width >= 10, containerSize.height >= 10 { + view.frame = containerView.bounds + } } /// Starts the shell process if not already running. @@ -112,6 +160,12 @@ class TerminalManager: ObservableObject { } /// Restarts the shell by tearing down the old terminal and creating a fresh one. + /// + /// Instead of bumping a generation counter (which forced SwiftUI to destroy + /// and recreate the `NSViewRepresentable` — recycling the same `containerView` + /// across identities which broke layout), we simply nil out `terminalView` + /// and change `@Published` state. SwiftUI's `updateNSView` will fire, + /// see the nil terminal, and call `ensureTerminalView` to mount a new one. func restartShell() { // Terminate the running process gracefully terminalView?.terminate() @@ -120,8 +174,6 @@ class TerminalManager: ObservableObject { terminalView = nil isProcessRunning = false terminalTitle = "Terminal" - // Bump generation so the SwiftUI representable re-mounts - sessionGeneration += 1 } /// Updates the terminal title from the shell escape sequence. @@ -129,6 +181,42 @@ class TerminalManager: ObservableObject { terminalTitle = title } + // 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 + } + + // Opacity + view.layer?.opacity = Float(Defaults[.terminalOpacity]) + + // Colors + view.nativeBackgroundColor = NSColor(Defaults[.terminalBackgroundColor]) + view.nativeForegroundColor = NSColor(Defaults[.terminalForegroundColor]) + view.caretColor = NSColor(Defaults[.terminalCursorColor]) + + // Cursor style + let cursorStyle = TerminalCursorStyleOption(rawValue: Defaults[.terminalCursorStyle]) + ?? .blinkBlock + view.getTerminal().options.cursorStyle = cursorStyle.swiftTermStyle + + // Scrollback + let scrollback = Defaults[.terminalScrollbackLines] + view.getTerminal().buffer.changeHistorySize(scrollback) + view.getTerminal().options.scrollback = scrollback + + // Input behavior + view.optionAsMetaKey = Defaults[.terminalOptionAsMeta] + view.allowMouseReporting = Defaults[.terminalMouseReporting] + + // Rendering + view.useBrightColors = Defaults[.terminalBoldAsBright] + } + /// Updates font size on the live terminal view. func applyFontSize(_ size: Double) { guard let view = terminalView else { return } @@ -143,6 +231,56 @@ class TerminalManager: ObservableObject { view.layer?.opacity = Float(opacity) } + /// Updates cursor style on the live terminal view. + func applyCursorStyle(_ style: TerminalCursorStyleOption) { + guard let view = terminalView else { return } + view.getTerminal().options.cursorStyle = style.swiftTermStyle + view.setNeedsDisplay(view.bounds) + } + + /// Updates scrollback buffer size on the live terminal view. + func applyScrollback(_ lines: Int) { + guard let view = terminalView else { return } + view.getTerminal().buffer.changeHistorySize(lines) + view.getTerminal().options.scrollback = lines + } + + /// Updates option-as-meta on the live terminal view. + func applyOptionAsMeta(_ enabled: Bool) { + guard let view = terminalView else { return } + view.optionAsMetaKey = enabled + } + + /// Updates mouse reporting on the live terminal view. + func applyMouseReporting(_ enabled: Bool) { + guard let view = terminalView else { return } + view.allowMouseReporting = enabled + } + + /// Updates bold-as-bright on the live terminal view. + func applyBoldAsBright(_ enabled: Bool) { + guard let view = terminalView else { return } + view.useBrightColors = enabled + } + + /// Updates background color on the live terminal view. + func applyBackgroundColor(_ color: SwiftUI.Color) { + guard let view = terminalView else { return } + view.nativeBackgroundColor = NSColor(color) + } + + /// Updates foreground color on the live terminal view. + func applyForegroundColor(_ color: SwiftUI.Color) { + guard let view = terminalView else { return } + view.nativeForegroundColor = NSColor(color) + } + + /// Updates cursor color on the live terminal view. + func applyCursorColor(_ color: SwiftUI.Color) { + guard let view = terminalView else { return } + view.caretColor = NSColor(color) + } + // MARK: - Environment /// Builds the environment for the child shell process. @@ -155,3 +293,37 @@ class TerminalManager: ObservableObject { return env.map { "\($0.key)=\($0.value)" } } } + +// MARK: - Cursor Style Bridge + +/// Codable-friendly cursor style enum that bridges to SwiftTerm's `CursorStyle`. +enum TerminalCursorStyleOption: String, CaseIterable, Defaults.Serializable { + case blinkBlock = "blinkBlock" + case steadyBlock = "steadyBlock" + case blinkUnderline = "blinkUnderline" + case steadyUnderline = "steadyUnderline" + case blinkBar = "blinkBar" + case steadyBar = "steadyBar" + + var swiftTermStyle: CursorStyle { + switch self { + case .blinkBlock: return .blinkBlock + case .steadyBlock: return .steadyBlock + case .blinkUnderline: return .blinkUnderline + case .steadyUnderline: return .steadyUnderline + case .blinkBar: return .blinkBar + case .steadyBar: return .steadyBar + } + } + + var displayName: String { + switch self { + case .blinkBlock: return "Block (blinking)" + case .steadyBlock: return "Block (steady)" + case .blinkUnderline: return "Underline (blinking)" + case .steadyUnderline: return "Underline (steady)" + case .blinkBar: return "Bar (blinking)" + case .steadyBar: return "Bar (steady)" + } + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 86d60b1..c718ea5 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -917,6 +917,14 @@ extension Defaults.Keys { static let terminalFontSize = Key("terminalFontSize", default: 12.0) static let terminalOpacity = Key("terminalOpacity", default: 1.0) static let terminalMaxHeightFraction = Key("terminalMaxHeightFraction", default: 0.4) + static let terminalCursorStyle = Key("terminalCursorStyle", default: "blinkBlock") + static let terminalScrollbackLines = Key("terminalScrollbackLines", default: 1000) + static let terminalOptionAsMeta = Key("terminalOptionAsMeta", default: true) + static let terminalMouseReporting = Key("terminalMouseReporting", default: true) + static let terminalBoldAsBright = Key("terminalBoldAsBright", default: true) + static let terminalBackgroundColor = Key("terminalBackgroundColor", default: .black) + static let terminalForegroundColor = Key("terminalForegroundColor", default: .white) + static let terminalCursorColor = Key("terminalCursorColor", default: Color(.selectedControlColor)) // MARK: Timer Feature static let enableTimerFeature = Key("enableTimerFeature", default: true) From ccd07322947a87ea557d2e276f6282c2e7cd0af9 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Wed, 4 Mar 2026 20:04:38 +0530 Subject: [PATCH 10/26] Update Music Player --- .../LockScreen/LockScreenMusicPanel.swift | 2 +- .../Notch/MinimalisticMusicPlayerView.swift | 8 ++++++- .../components/Notch/NotchHomeView.swift | 23 ++++++++++++++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index fcc7afc..0c8f7f2 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -365,7 +365,7 @@ struct LockScreenMusicPanel: View { private var progressBar: some View { TimelineView( .animation( - minimumInterval: 0.1, + minimumInterval: 1.0, paused: isProgressTimelinePaused ) ) { timeline in diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index b62b94b..664dc09 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -654,10 +654,16 @@ private struct MinimalisticReminderDetailsView: View { @State private var dragging: Bool = false @State private var lastDragged: Date = .distantPast + /// Whether the progress timeline should be paused (no ticks). + private var isProgressTimelinePaused: Bool { + !musicManager.isPlaying || musicManager.isLiveStream || musicManager.playbackRate <= 0 + } + private var progressBar: some View { TimelineView( .animation( - minimumInterval: (!musicManager.isLiveStream && musicManager.playbackRate > 0) ? 0.1 : nil + minimumInterval: 1.0, + paused: isProgressTimelinePaused ) ) { timeline in MusicSliderView( diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 1fe1925..f8603a6 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -215,8 +215,13 @@ struct MusicControlsView: View { } } + /// Whether the progress timeline should be paused (no ticks). + private var isProgressTimelinePaused: Bool { + !musicManager.isPlaying || musicManager.isLiveStream || musicManager.playbackRate <= 0 + } + private var musicSlider: some View { - TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in + TimelineView(.animation(minimumInterval: 1.0, paused: isProgressTimelinePaused)) { timeline in MusicSliderView( sliderValue: $sliderValue, duration: $musicManager.songDuration, @@ -639,6 +644,13 @@ struct MusicSliderView: View { guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return } sliderValue = MusicManager.shared.estimatedPlaybackPosition(at: newDate) } + .onChange(of: isPlaying) { _, playing in + // Snap slider to the exact position when music pauses so + // the in-flight animation doesn't coast past the true value. + if !playing { + sliderValue = MusicManager.shared.estimatedPlaybackPosition() + } + } .onChange(of: isLiveStream) { isLive in if isLive { sliderValue = 0 @@ -711,6 +723,15 @@ struct MusicSliderView: View { restingTrackHeight: restingTrackHeight, draggingTrackHeight: draggingTrackHeight ) + // Smoothly interpolate the filled track between 1-second ticks using + // Core Animation — runs on the GPU with zero CPU polling cost. + // Disabled while dragging or paused so the bar responds instantly. + .animation( + !dragging && isPlaying && !isLiveStream + ? .linear(duration: 1.0) + : nil, + value: sliderValue + ) } private var sliderTint: Color { From 326e51cd3d85639203b583fa1bdedb41e9839bd3 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 5 Mar 2026 00:05:56 +0530 Subject: [PATCH 11/26] Update Settings --- .../components/Settings/SettingsView.swift | 152 ++++++++++++++---- 1 file changed, 118 insertions(+), 34 deletions(-) diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 551d297..c996f9a 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -17,6 +17,34 @@ import SwiftUI import SwiftUIIntrospect import UniformTypeIdentifiers +/// Groups for organizing settings tabs in the sidebar. +private enum SettingsTabGroup: String, CaseIterable, Identifiable { + case core + case mediaAndDisplay + case system + case productivity + case utilities + case developer + case integrations + case info + + var id: String { rawValue } + + /// Display title for the section header. `nil` means no visible header. + var title: String? { + switch self { + case .core: return nil + case .mediaAndDisplay: return "Media & Display" + case .system: return "System" + case .productivity: return "Productivity" + case .utilities: return "Utilities" + case .developer: return "Developer" + case .integrations: return "Integrations" + case .info: return nil + } + } +} + private enum SettingsTab: String, CaseIterable, Identifiable { case general case liveActivities @@ -42,6 +70,21 @@ private enum SettingsTab: String, CaseIterable, Identifiable { var id: String { rawValue } + /// Which sidebar group this tab belongs to. + var group: SettingsTabGroup { + switch self { + case .general, .appearance: return .core + case .media, .liveActivities, .lockScreen, .devices: return .mediaAndDisplay + case .hudAndOSD, .battery: return .system + case .timer, .calendar, .notes: return .productivity + case .clipboard, .screenAssistant, .colorPicker, .shelf, + .downloads, .shortcuts: return .utilities + case .stats, .terminal: return .developer + case .extensions: return .integrations + case .about: return .info + } + } + var title: String { switch self { case .general: return "General" @@ -275,36 +318,19 @@ struct SettingsView: View { Divider() .padding(.horizontal, 12) - List(filteredTabs, selection: selectionBinding) { tab in - NavigationLink(value: tab) { - HStack(spacing: 10) { - sidebarIcon(for: tab) - Text(tab.title) - if tab == .downloads || tab == .hudAndOSD { - Spacer() - Text("BETA") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - Capsule() - .fill(Color.blue) - ) - } else if tab == .extensions { - Spacer() - Text("ALPHA") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - Capsule() - .fill(Color.orange) - ) + List(selection: selectionBinding) { + ForEach(groupedFilteredTabs, id: \.group) { section in + Section { + ForEach(section.tabs) { tab in + NavigationLink(value: tab) { + sidebarRow(for: tab) + } + } + } header: { + if let title = section.group.title { + Text(title) } } - .padding(.vertical, 4) } } .listStyle(SidebarListStyle()) @@ -405,34 +431,92 @@ struct SettingsView: View { } } + @ViewBuilder + private func sidebarRow(for tab: SettingsTab) -> some View { + HStack(spacing: 10) { + sidebarIcon(for: tab) + Text(tab.title) + if tab == .downloads || tab == .hudAndOSD { + Spacer() + Text("BETA") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(Color.blue) + ) + } else if tab == .extensions { + Spacer() + Text("ALPHA") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(Color.orange) + ) + } + } + .padding(.vertical, 4) + } + private var availableTabs: [SettingsTab] { + // Ordered to match group layout: core → media & display → system → + // productivity → utilities → developer → integrations → info. let ordered: [SettingsTab] = [ + // Core .general, - .liveActivities, .appearance, - .lockScreen, + // Media & Display .media, + .liveActivities, + .lockScreen, .devices, - .timer, - .calendar, + // System .hudAndOSD, .battery, - .stats, + // Productivity + .timer, + .calendar, .notes, + // Utilities .clipboard, .screenAssistant, .colorPicker, - .downloads, .shelf, + .downloads, .shortcuts, + // Developer + .stats, .terminal, + // Integrations .extensions, + // Info .about ] return ordered.filter { isTabVisible($0) } } + /// Groups the filtered tabs into sidebar sections, preserving both + /// the group order and the per-group tab order from `availableTabs`. + private var groupedFilteredTabs: [(group: SettingsTabGroup, tabs: [SettingsTab])] { + let visible = filteredTabs + var result: [(group: SettingsTabGroup, tabs: [SettingsTab])] = [] + + for group in SettingsTabGroup.allCases { + let tabs = visible.filter { $0.group == group } + if !tabs.isEmpty { + result.append((group: group, tabs: tabs)) + } + } + + return result + } + private func tabsMatchingSearch(_ query: String) -> [SettingsTab] { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return availableTabs } From 3a99bad78d8ef6b0d7dc38544d5afa9d5a17d975 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 5 Mar 2026 11:18:25 +0530 Subject: [PATCH 12/26] Update minimum sizing --- .../DynamicIslandViewCoordinator.swift | 24 +++++++ DynamicIsland/Localizable.xcstrings | 12 ++++ .../components/Settings/SettingsView.swift | 28 ++++++-- DynamicIsland/sizing/matters.swift | 68 ++++++++++++++++++- 4 files changed, 123 insertions(+), 9 deletions(-) diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index d578199..7c55430 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -219,6 +219,30 @@ class DynamicIslandViewCoordinator: ObservableObject { .store(in: &cancellables) handleExtensionExperienceSnapshot(extensionNotchExperienceManager.activeExperiences) + + // Observe all tab-affecting settings to enforce minimum notch width + Publishers.MergeMany( + Defaults.publisher(.showStandardMediaControls).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.showCalendar).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.showMirror).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.dynamicShelf).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.enableTimerFeature).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.timerDisplayMode).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.enableStatsFeature).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.enableNotes).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.enableClipboardManager).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.clipboardDisplayMode).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.enableTerminalFeature).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.enableMinimalisticUI).map { _ in () }.eraseToAnyPublisher() + ) + .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) + .sink { _ in + enforceMinimumNotchWidth() + } + .store(in: &cancellables) + + // Enforce minimum width on launch for existing configurations + enforceMinimumNotchWidth() } private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 412ac56..0fd79b0 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1068,6 +1068,18 @@ "isCommentAutoGenerated" : true, "shouldTranslate" : false }, + "%lld tab%@ enabled · min %lld px" : { + "comment" : "A label displaying the number of enabled tabs, the recommended minimum notch width, and a button to reset the notch width to its recommended minimum value. The first argument is the count of enabled tabs. The second argument is the recommended minimum notch width as a formatted pixel count.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld tab%2$@ enabled · min %3$lld px" + } + } + } + }, "%lld%%" : { "localizations" : { "ar" : { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index c996f9a..eceac92 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -3468,8 +3468,12 @@ struct Appearance: View { @State private var url: String = "" @State private var speed: CGFloat = 1.0 - private let notchWidthRange: ClosedRange = 640...900 - private let defaultOpenNotchWidth: CGFloat = 640 + private var notchWidthRange: ClosedRange { + Double(currentRecommendedMinimumNotchWidth())...900 + } + private var defaultOpenNotchWidth: CGFloat { + currentRecommendedMinimumNotchWidth() + } private func highlightID(_ title: String) -> String { SettingsTab.appearance.highlightID(for: title) @@ -4019,10 +4023,14 @@ struct Appearance: View { @ViewBuilder private func notchWidthControls() -> some View { Section { + let recommendedMin = currentRecommendedMinimumNotchWidth() + let tabCount = enabledStandardTabCount() + let dynamicRange = Double(recommendedMin)...900 + let widthBinding = Binding( get: { Double(openNotchWidth) }, set: { newValue in - let clamped = min(max(newValue, notchWidthRange.lowerBound), notchWidthRange.upperBound) + let clamped = min(max(newValue, dynamicRange.lowerBound), dynamicRange.upperBound) let value = CGFloat(clamped) if openNotchWidth != value { openNotchWidth = value @@ -4033,7 +4041,7 @@ struct Appearance: View { VStack(alignment: .leading, spacing: 10) { Slider( value: widthBinding, - in: notchWidthRange, + in: dynamicRange, step: 10 ) { HStack { @@ -4047,22 +4055,28 @@ struct Appearance: View { .settingsHighlight(id: highlightID("Expanded notch width")) HStack { + Text("\(tabCount) tab\(tabCount == 1 ? "" : "s") enabled · min \(Int(recommendedMin)) px") + .font(.caption) + .foregroundStyle(.secondary) Spacer() Button("Reset Width") { - openNotchWidth = defaultOpenNotchWidth + openNotchWidth = recommendedMin } - .disabled(abs(openNotchWidth - defaultOpenNotchWidth) < 0.5) + .disabled(abs(openNotchWidth - recommendedMin) < 0.5) .buttonStyle(.bordered) } let description = enableMinimalisticUI ? "Width adjustments apply only to the standard notch layout. Disable Minimalistic UI to edit this value." - : "Extend the notch span so the clipboard, colour picker, and other trailing icons remain visible on scaled displays (e.g. More Space)." + : "Recommended minimum width adjusts automatically based on the number of enabled tabs." Text(description) .font(.caption) .foregroundStyle(.secondary) } + .onAppear { + enforceMinimumNotchWidth() + } } header: { HStack { Text("Notch Width") diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index 3757150..d972b3d 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -28,8 +28,72 @@ let downloadSneakSize: CGSize = .init(width: 65, height: 1) let batterySneakSize: CGSize = .init(width: 160, height: 1) var openNotchSize: CGSize { - let width = Defaults[.openNotchWidth] - return .init(width: width, height: 200) // Adjusted to a better middle ground + let storedWidth = Defaults[.openNotchWidth] + let minWidth = currentRecommendedMinimumNotchWidth() + let width = max(storedWidth, minWidth) + return .init(width: width, height: 200) +} + +// MARK: - Tab-Based Notch Width + +/// Counts the number of currently enabled standard notch tabs. +/// Mirrors the tab-building logic in ``TabSelectionView``. +func enabledStandardTabCount() -> Int { + var count = 0 + + // Home tab + if Defaults[.showStandardMediaControls] || Defaults[.showCalendar] || Defaults[.showMirror] { + count += 1 + } + + // Shelf tab + if Defaults[.dynamicShelf] { + count += 1 + } + + // Timer tab (only in .tab display mode) + if Defaults[.enableTimerFeature] && Defaults[.timerDisplayMode] == .tab { + count += 1 + } + + // Stats tab + if Defaults[.enableStatsFeature] { + count += 1 + } + + // Notes / Clipboard tab + if Defaults[.enableNotes] || (Defaults[.enableClipboardManager] && Defaults[.clipboardDisplayMode] == .separateTab) { + count += 1 + } + + // Terminal tab + if Defaults[.enableTerminalFeature] { + count += 1 + } + + return count +} + +/// Returns the recommended minimum notch width for the given tab count. +func recommendedMinimumNotchWidth(forTabCount count: Int) -> CGFloat { + if count >= 6 { return 770 } + if count >= 5 { return 690 } + return 640 +} + +/// Returns the recommended minimum notch width for the current tab configuration. +func currentRecommendedMinimumNotchWidth() -> CGFloat { + recommendedMinimumNotchWidth(forTabCount: enabledStandardTabCount()) +} + +/// Enforces the minimum notch width based on current tab count. +/// Only adjusts when not in minimalistic mode. +func enforceMinimumNotchWidth() { + guard !Defaults[.enableMinimalisticUI] else { return } + let minWidth = currentRecommendedMinimumNotchWidth() + if Defaults[.openNotchWidth] < minWidth { + Defaults[.openNotchWidth] = minWidth + } } private let minimalisticBaseOpenNotchSize: CGSize = .init(width: 420, height: 180) private let minimalisticLyricsExtraHeight: CGFloat = 40 From 5b489ee90d3ce9f56b988286bf1c9ee239fae457 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 5 Mar 2026 13:54:11 +0530 Subject: [PATCH 13/26] Update: Support for 2K resolution --- DynamicIsland/DynamicIslandApp.swift | 9 ++++-- .../Notch/DynamicIslandHeader.swift | 3 +- .../components/Notch/NotchHomeView.swift | 3 +- .../components/Settings/SettingsView.swift | 4 ++- DynamicIsland/sizing/matters.swift | 32 +++++++++++++++++-- 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index e23cd9c..dbac59e 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -369,10 +369,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func resizeWindow(_ window: NSWindow, on screen: NSScreen, to size: CGSize, animated: Bool) { let screenFrame = screen.frame + // Clamp width to screen width so the notch never extends beyond screen edges on scaled displays + let clampedWidth = min(size.width, screenFrame.width) + let clampedHeight = min(size.height, screenFrame.height) let centerX = screenFrame.midX - let newX = centerX - (size.width / 2) - let newY = screenFrame.origin.y + screenFrame.height - size.height - let targetFrame = NSRect(x: newX, y: newY, width: size.width, height: size.height) + let newX = centerX - (clampedWidth / 2) + let newY = screenFrame.origin.y + screenFrame.height - clampedHeight + let targetFrame = NSRect(x: newX, y: newY, width: clampedWidth, height: clampedHeight) window.setFrame(targetFrame, display: true) } diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index 860ce23..34e4610 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -53,10 +53,11 @@ struct DynamicIslandHeader: View { } if vm.notchState == .open && !Defaults[.enableMinimalisticUI] { + let spacerWidth = min(vm.closedNotchSize.width, 300) Rectangle() .fill(NSScreen.screens .first(where: { $0.localizedName == coordinator.selectedScreen })?.safeAreaInsets.top ?? 0 > 0 ? .black : .clear) - .frame(width: vm.closedNotchSize.width) + .frame(width: spacerWidth) .mask { NotchShape() } diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index f8603a6..7de0d23 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -32,7 +32,8 @@ struct MusicPlayerView: View { var body: some View { HStack(alignment: .top, spacing: 12) { - AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace).padding(.all, 5) + AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace) + .padding(.all, 5) MusicControlsView() .frame(maxWidth: .infinity, alignment: .leading) .drawingGroup() diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index eceac92..9bd3df3 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -3469,7 +3469,9 @@ struct Appearance: View { @State private var speed: CGFloat = 1.0 private var notchWidthRange: ClosedRange { - Double(currentRecommendedMinimumNotchWidth())...900 + let minW = Double(currentRecommendedMinimumNotchWidth()) + let maxW = min(900, Double(maxAllowedNotchWidth())) + return minW...max(minW, maxW) } private var defaultOpenNotchWidth: CGFloat { currentRecommendedMinimumNotchWidth() diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index d972b3d..b9418b2 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -30,10 +30,31 @@ let batterySneakSize: CGSize = .init(width: 160, height: 1) var openNotchSize: CGSize { let storedWidth = Defaults[.openNotchWidth] let minWidth = currentRecommendedMinimumNotchWidth() - let width = max(storedWidth, minWidth) + let maxWidth = maxAllowedNotchWidth() + let width = min(max(storedWidth, minWidth), maxWidth) return .init(width: width, height: 200) } +/// Maximum notch width based on the current screen's point width. +/// Prevents the notch from extending beyond the screen on scaled displays. +func maxAllowedNotchWidth(for screenName: String? = nil) -> CGFloat { + let screen: NSScreen? + if let screenName { + screen = NSScreen.screens.first { $0.localizedName == screenName } + } else { + screen = NSScreen.main + } + guard let screenWidth = screen?.frame.width, screenWidth > 0 else { + return 900 + } + return max(screenWidth - 60, 400) +} + +/// Convenience for the main screen. +func maxAllowedNotchWidth() -> CGFloat { + maxAllowedNotchWidth(for: nil) +} + // MARK: - Tab-Based Notch Width /// Counts the number of currently enabled standard notch tabs. @@ -87,12 +108,17 @@ func currentRecommendedMinimumNotchWidth() -> CGFloat { } /// Enforces the minimum notch width based on current tab count. +/// Also clamps to screen width so the notch never exceeds the display. /// Only adjusts when not in minimalistic mode. func enforceMinimumNotchWidth() { guard !Defaults[.enableMinimalisticUI] else { return } let minWidth = currentRecommendedMinimumNotchWidth() - if Defaults[.openNotchWidth] < minWidth { - Defaults[.openNotchWidth] = minWidth + let maxWidth = maxAllowedNotchWidth() + var width = Defaults[.openNotchWidth] + if width < minWidth { width = minWidth } + if width > maxWidth { width = maxWidth } + if Defaults[.openNotchWidth] != width { + Defaults[.openNotchWidth] = width } } private let minimalisticBaseOpenNotchSize: CGSize = .init(width: 420, height: 180) From 5accee5d02be0cda5b5fcce726f9148db07e83fb Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 5 Mar 2026 16:24:06 +0530 Subject: [PATCH 14/26] Update onboarding --- DynamicIsland/ContentView.swift | 9 ++- DynamicIsland/Localizable.xcstrings | 79 ++----------------- .../Onboarding/ProfileSelectionView.swift | 27 +++++++ .../components/Settings/SettingsView.swift | 29 +++++++ DynamicIsland/models/Constants.swift | 4 +- .../models/DynamicIslandViewModel.swift | 11 ++- 6 files changed, 78 insertions(+), 81 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 2573d18..5555fe7 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -396,9 +396,10 @@ struct ContentView: View { } } .onAppear(perform: { - runAfter(1) { - withAnimation(vm.animation) { - if coordinator.firstLaunch { + if coordinator.firstLaunch { + // Single open during first launch; closeHello() handles the timed close. + runAfter(1) { + withAnimation(vm.animation) { openNotch() } } @@ -1635,7 +1636,7 @@ struct ContentView: View { } private func shouldPreventAutoClose() -> Bool { - hasAnyActivePopovers() || vm.isAutoCloseSuppressed || SharingStateManager.shared.preventNotchClose + coordinator.firstLaunch || hasAnyActivePopovers() || vm.isAutoCloseSuppressed || SharingStateManager.shared.preventNotchClose } // Helper to prevent rapid haptic feedback diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 0fd79b0..37382a8 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1607,10 +1607,6 @@ } } }, - "±10 Seconds" : { - "comment" : "Display name for the \"±10 Seconds\" music skip behavior option.", - "isCommentAutoGenerated" : true - }, "0" : { "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", "isCommentAutoGenerated" : true, @@ -1927,10 +1923,6 @@ } } }, - "Accent" : { - "comment" : "Display name for the \"Accent\" caps lock indicator tint mode.", - "isCommentAutoGenerated" : true - }, "Accent color" : { "localizations" : { "ar" : { @@ -2276,10 +2268,6 @@ } } }, - "Adaptive gradient" : { - "comment" : "Name of the timer icon color mode option that uses an adaptive gradient.", - "isCommentAutoGenerated" : true - }, "Add" : { "localizations" : { "ar" : { @@ -3875,10 +3863,6 @@ } } }, - "Apple Music" : { - "comment" : "Localized name for the \"Now Playing\" media controller type.", - "isCommentAutoGenerated" : true - }, "Applies the notch-style parallax effect to the lock screen media widget album art." : { "localizations" : { "pt-BR" : { @@ -4293,10 +4277,6 @@ } } }, - "Bar" : { - "comment" : "Localized name for the 'Bar' timer progress style.", - "isCommentAutoGenerated" : true - }, "Battery" : { "localizations" : { "ar" : { @@ -8567,10 +8547,6 @@ } } }, - "Digital" : { - "comment" : "The display name for the \"Digital\" reminder presentation style.", - "isCommentAutoGenerated" : true - }, "Dimensions" : { "localizations" : { "pt-BR" : { @@ -8862,6 +8838,9 @@ } } } + }, + "Display Style" : { + }, "Displays a HUD notification when Bluetooth audio devices (headphones, AirPods, speakers) connect, showing device name and battery level." : { "comment" : "A footer for the \"Bluetooth Audio Devices\" section in the HUD settings.", @@ -11966,10 +11945,6 @@ } } }, - "Green" : { - "comment" : "Name of the \"Green\" Caps Lock Indicator Tint Mode.", - "isCommentAutoGenerated" : true - }, "Height:" : { "extractionState" : "stale", "localizations" : { @@ -14092,6 +14067,9 @@ } } } + }, + "Main screen style" : { + }, "Manage indicator preferences from Settings → Privacy. No manual microphone toggle is required." : { "comment" : "A description of how to manage the microphone privacy indicator preferences.", @@ -15647,6 +15625,7 @@ } }, "Minutes" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -17863,10 +17842,6 @@ } } }, - "Now Playing" : { - "comment" : "Localized name for each media controller type.", - "isCommentAutoGenerated" : true - }, "Number of lines kept in the scrollback buffer. Higher values use more memory." : { }, @@ -18242,10 +18217,6 @@ } } }, - "Panel" : { - "comment" : "Display name for the \"Panel\" clipboard display mode.", - "isCommentAutoGenerated" : true - }, "Panel mode shows clipboard in a floating window near the notch." : { "comment" : "A description of the \"Panel\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -19860,10 +19831,6 @@ } } }, - "Ring" : { - "comment" : "Localized name for the \"Ring\" timer progress style.", - "isCommentAutoGenerated" : true - }, "Rotation:" : { "extractionState" : "stale", "localizations" : { @@ -20690,10 +20657,6 @@ } } }, - "Separate Tab" : { - "comment" : "Name of the clipboard display mode option that opens the clipboard in a separate tab.", - "isCommentAutoGenerated" : true - }, "Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : { "comment" : "A description of the \"Separate Tab\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -21755,14 +21718,6 @@ } } }, - "Shows screen assistant as a dropdown attached to the AI button" : { - "comment" : "Description of the \"Popover\" display mode for the screen assistant.", - "isCommentAutoGenerated" : true - }, - "Shows screen assistant in a floating panel near the notch" : { - "comment" : "Description of the \"panel\" display mode for the screen assistant.", - "isCommentAutoGenerated" : true - }, "Shows the model's reasoning process before the final answer" : { "localizations" : { "fr" : { @@ -21825,10 +21780,6 @@ } } }, - "Skip forward or backward by ten seconds" : { - "comment" : "Description of the \"Skip forward or backward by ten seconds\" music skip behavior.", - "isCommentAutoGenerated" : true - }, "Slider color" : { "localizations" : { "ar" : { @@ -22397,10 +22348,6 @@ } } }, - "Spotify" : { - "comment" : "Add more media controller types here.", - "isCommentAutoGenerated" : true - }, "Square" : { "localizations" : { "ar" : { @@ -22513,10 +22460,6 @@ }, "Standard notch media controls are hidden. Re-enable the toggle above to restore them." : { - }, - "Standard previous/next track controls" : { - "comment" : "Description of a music skip behavior option that uses standard previous/next track controls.", - "isCommentAutoGenerated" : true }, "Start" : { "localizations" : { @@ -23878,10 +23821,6 @@ }, "shouldTranslate" : false }, - "Track Skip" : { - "comment" : "Name of the music skip behavior option that skips to the next track.", - "isCommentAutoGenerated" : true - }, "Transform" : { "extractionState" : "stale", "localizations" : { @@ -25803,10 +25742,6 @@ "comment" : "A footer note explaining the app's funding.", "isCommentAutoGenerated" : true }, - "Youtube Music" : { - "comment" : "Add more media player types here.", - "isCommentAutoGenerated" : true - }, "YouTube Music requires this third-party app to be installed: " : { "localizations" : { "ar" : { diff --git a/DynamicIsland/components/Onboarding/ProfileSelectionView.swift b/DynamicIsland/components/Onboarding/ProfileSelectionView.swift index 92b6594..d2a072c 100644 --- a/DynamicIsland/components/Onboarding/ProfileSelectionView.swift +++ b/DynamicIsland/components/Onboarding/ProfileSelectionView.swift @@ -209,6 +209,7 @@ func applyProfileSettings(_ profiles: Set) { if isDeveloper { Defaults[.enableColorPickerFeature] = true Defaults[.enableStatsFeature] = true + Defaults[.enableTerminalFeature] = true Defaults[.enableTimerFeature] = true Defaults[.enableScreenAssistant] = true Defaults[.showMirror] = false @@ -262,9 +263,35 @@ func applyProfileSettings(_ profiles: Set) { Defaults[.menubarIcon] = true Defaults[.enableHaptics] = true + // Lyrics disabled by default for all profiles + Defaults[.enableLyrics] = false + + // Weather widget defaults to inline style + Defaults[.lockScreenWeatherWidgetStyle] = .inline + + // Auto-detect notch: Dynamic Island for non-notch Macs, standard notch otherwise + if mainScreenHasNotch() { + Defaults[.externalDisplayStyle] = .notch + } else { + Defaults[.externalDisplayStyle] = .dynamicIsland + } + + // Lock screen glass: custom liquid glass v11 on macOS 26+ + if #available(macOS 26.0, *) { + Defaults[.lockScreenGlassStyle] = .liquid + Defaults[.lockScreenGlassCustomizationMode] = .customLiquid + Defaults[.lockScreenMusicLiquidGlassVariant] = .v11 + } + print("✅ Applied profile settings for: \(profiles.joined(separator: ", "))") } +/// Returns `true` when the main screen has a physical notch (safe area insets > 0). +private func mainScreenHasNotch() -> Bool { + guard let screen = NSScreen.main else { return false } + return screen.safeAreaInsets.top > 0 +} + #Preview { ProfileSelectionView(onContinue: { profiles in print("Selected profiles: \(profiles)") diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 9bd3df3..6c92f66 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -778,6 +778,7 @@ struct SettingsView: View { SettingsSearchEntry(tab: .shelf, title: "Quick Share Service", keywords: ["shelf", "share", "airdrop"], highlightID: SettingsTab.shelf.highlightID(for: "Quick Share Service")), // Appearance + SettingsSearchEntry(tab: .appearance, title: "Main screen style", keywords: ["dynamic island", "pill", "non-notch", "display style", "notch style"], highlightID: SettingsTab.appearance.highlightID(for: "Main screen style")), SettingsSearchEntry(tab: .appearance, title: "Settings icon in notch", keywords: ["settings button", "toolbar"], highlightID: SettingsTab.appearance.highlightID(for: "Settings icon in notch")), SettingsSearchEntry(tab: .appearance, title: "Enable window shadow", keywords: ["shadow", "appearance"], highlightID: SettingsTab.appearance.highlightID(for: "Enable window shadow")), SettingsSearchEntry(tab: .appearance, title: "Corner radius scaling", keywords: ["corner radius", "shape"], highlightID: SettingsTab.appearance.highlightID(for: "Corner radius scaling")), @@ -3457,6 +3458,7 @@ struct Appearance: View { @Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass @Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget @Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget + @Default(.externalDisplayStyle) private var externalDisplayStyle @State private var selectedListVisualizer: CustomVisualizer? = nil @State private var isIconImporterPresented = false @@ -3468,6 +3470,12 @@ struct Appearance: View { @State private var url: String = "" @State private var speed: CGFloat = 1.0 + /// Whether the main screen has a physical notch. + private var mainScreenHasPhysicalNotch: Bool { + guard let screen = NSScreen.main else { return false } + return screen.safeAreaInsets.top > 0 + } + private var notchWidthRange: ClosedRange { let minW = Double(currentRecommendedMinimumNotchWidth()) let maxW = min(900, Double(maxAllowedNotchWidth())) @@ -3528,6 +3536,27 @@ struct Appearance: View { Text("General") } + // Show display style picker only on non-notch Macs (main screen has no physical notch) + if !mainScreenHasPhysicalNotch { + Section { + Picker("Main screen style", selection: $externalDisplayStyle) { + ForEach(ExternalDisplayStyle.allCases) { style in + Text(style.localizedName) + .tag(style) + } + } + .onChange(of: externalDisplayStyle) { + NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil) + } + .settingsHighlight(id: highlightID("Main screen style")) + Text(externalDisplayStyle.description) + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("Display Style") + } + } + notchWidthControls() Section { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index c718ea5..bce1462 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -786,7 +786,7 @@ extension Defaults.Keys { static let lockScreenWeatherRefreshInterval = Key("lockScreenWeatherRefreshInterval", default: 30 * 60) static let lockScreenWeatherShowsLocation = Key("lockScreenWeatherShowsLocation", default: true) static let lockScreenWeatherShowsSunrise = Key("lockScreenWeatherShowsSunrise", default: true) - static let lockScreenWeatherWidgetStyle = Key("lockScreenWeatherWidgetStyle", default: .circular) + static let lockScreenWeatherWidgetStyle = Key("lockScreenWeatherWidgetStyle", default: .inline) static let lockScreenWeatherTemperatureUnit = Key("lockScreenWeatherTemperatureUnit", default: .celsius) static let lockScreenWeatherShowsAQI = Key("lockScreenWeatherShowsAQI", default: true) static let lockScreenWeatherAQIScale = Key("lockScreenWeatherAQIScale", default: .us) @@ -1078,7 +1078,7 @@ extension Defaults.Keys { static let enableMinimalisticUI = Key("enableMinimalisticUI", default: false) // MARK: Lyrics Feature - static let enableLyrics = Key("enableLyrics", default: true) + static let enableLyrics = Key("enableLyrics", default: false) // MARK: Notes Feature static let enableNotes = Key("enableNotes", default: false) diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index c98c713..aa101ef 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -395,11 +395,16 @@ class DynamicIslandViewModel: NSObject, ObservableObject { } } + private var helloCloseScheduled = false + func closeHello() { + guard !helloCloseScheduled else { return } + helloCloseScheduled = true DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in - self?.coordinator.firstLaunch = false - withAnimation(self?.animationLibrary.animation) { - self?.close() + guard let self else { return } + self.coordinator.firstLaunch = false + withAnimation(self.animationLibrary.animation) { + self.close() } } } From 6d01f6a1b2a87363d93b00cbd4f185ce97e57d9f Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 5 Mar 2026 20:56:15 +0530 Subject: [PATCH 15/26] Update Onboarding and temperature stuff --- DynamicIsland/Localizable.xcstrings | 73 +++++- .../Focus/DoNotDisturbLiveActivity.swift | 51 +++- .../components/Settings/SettingsView.swift | 12 + .../Stats/CPUStatsSharedViews.swift | 19 +- .../managers/DoNotDisturbManager.swift | 248 +++++++++++++----- DynamicIsland/models/Constants.swift | 9 +- DynamicIsland/utils/CPUSensorCollector.swift | 5 + 7 files changed, 340 insertions(+), 77 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 37382a8..0638a2b 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1607,6 +1607,10 @@ } } }, + "±10 Seconds" : { + "comment" : "Display name for the \"±10 Seconds\" music skip behavior option.", + "isCommentAutoGenerated" : true + }, "0" : { "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", "isCommentAutoGenerated" : true, @@ -1923,6 +1927,10 @@ } } }, + "Accent" : { + "comment" : "Display name for the \"Accent\" caps lock indicator tint mode.", + "isCommentAutoGenerated" : true + }, "Accent color" : { "localizations" : { "ar" : { @@ -2268,6 +2276,10 @@ } } }, + "Adaptive gradient" : { + "comment" : "Name of the timer icon color mode option that uses an adaptive gradient.", + "isCommentAutoGenerated" : true + }, "Add" : { "localizations" : { "ar" : { @@ -3863,6 +3875,10 @@ } } }, + "Apple Music" : { + "comment" : "Names for the different media player types.", + "isCommentAutoGenerated" : true + }, "Applies the notch-style parallax effect to the lock screen media widget album art." : { "localizations" : { "pt-BR" : { @@ -4277,6 +4293,10 @@ } } }, + "Bar" : { + "comment" : "Localized name for the \"Bar\" timer progress style.", + "isCommentAutoGenerated" : true + }, "Battery" : { "localizations" : { "ar" : { @@ -8547,6 +8567,10 @@ } } }, + "Digital" : { + "comment" : "The name of the \"Digital\" timer style.", + "isCommentAutoGenerated" : true + }, "Dimensions" : { "localizations" : { "pt-BR" : { @@ -11945,6 +11969,10 @@ } } }, + "Green" : { + "comment" : "Display name for the \"Green\" Caps Lock Indicator Tint Mode.", + "isCommentAutoGenerated" : true + }, "Height:" : { "extractionState" : "stale", "localizations" : { @@ -15625,7 +15653,6 @@ } }, "Minutes" : { - "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -17842,6 +17869,10 @@ } } }, + "Now Playing" : { + "comment" : "Localized name for each media controller type.", + "isCommentAutoGenerated" : true + }, "Number of lines kept in the scrollback buffer. Higher values use more memory." : { }, @@ -18217,6 +18248,10 @@ } } }, + "Panel" : { + "comment" : "Name for the \"Panel\" clipboard display mode.", + "isCommentAutoGenerated" : true + }, "Panel mode shows clipboard in a floating window near the notch." : { "comment" : "A description of the \"Panel\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -19831,6 +19866,10 @@ } } }, + "Ring" : { + "comment" : "Localized name for the 'Ring' timer progress style.", + "isCommentAutoGenerated" : true + }, "Rotation:" : { "extractionState" : "stale", "localizations" : { @@ -20657,6 +20696,10 @@ } } }, + "Separate Tab" : { + "comment" : "Name of the clipboard display mode option that opens a separate tab for pasting.", + "isCommentAutoGenerated" : true + }, "Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : { "comment" : "A description of the \"Separate Tab\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -21718,6 +21761,14 @@ } } }, + "Shows screen assistant as a dropdown attached to the AI button" : { + "comment" : "Description of the \"popover\" display mode for the screen assistant.", + "isCommentAutoGenerated" : true + }, + "Shows screen assistant in a floating panel near the notch" : { + "comment" : "Description of the \"panel\" display mode for the screen assistant.", + "isCommentAutoGenerated" : true + }, "Shows the model's reasoning process before the final answer" : { "localizations" : { "fr" : { @@ -21780,6 +21831,10 @@ } } }, + "Skip forward or backward by ten seconds" : { + "comment" : "Description of a music skip behavior option.", + "isCommentAutoGenerated" : true + }, "Slider color" : { "localizations" : { "ar" : { @@ -22348,6 +22403,10 @@ } } }, + "Spotify" : { + "comment" : "Add more media player types here.", + "isCommentAutoGenerated" : true + }, "Square" : { "localizations" : { "ar" : { @@ -22460,6 +22519,10 @@ }, "Standard notch media controls are hidden. Re-enable the toggle above to restore them." : { + }, + "Standard previous/next track controls" : { + "comment" : "Description of a music skip behavior option that uses standard previous/next track controls.", + "isCommentAutoGenerated" : true }, "Start" : { "localizations" : { @@ -23821,6 +23884,10 @@ }, "shouldTranslate" : false }, + "Track Skip" : { + "comment" : "Name of the music skip behavior option that skips to the next track.", + "isCommentAutoGenerated" : true + }, "Transform" : { "extractionState" : "stale", "localizations" : { @@ -25742,6 +25809,10 @@ "comment" : "A footer note explaining the app's funding.", "isCommentAutoGenerated" : true }, + "Youtube Music" : { + "comment" : "Option in settings to choose a media player that streams music from YouTube Music.", + "isCommentAutoGenerated" : true + }, "YouTube Music requires this third-party app to be installed: " : { "localizations" : { "ar" : { diff --git a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift index 98bf70b..5dc17e3 100644 --- a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift +++ b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift @@ -55,6 +55,7 @@ struct DoNotDisturbLiveActivity: View { .accessibilityLabel(accessibilityDescription) .onAppear(perform: handleInitialState) .onChange(of: manager.isDoNotDisturbActive, handleFocusStateChange) + .onChange(of: manager.focusToastTrigger, handleFocusModeSwitchToast) .onDisappear(perform: cancelPendingTasks) } @@ -333,19 +334,57 @@ struct DoNotDisturbLiveActivity: View { private func handleFocusStateChange(_ oldValue: Bool, _ isActive: Bool) { cancelPendingTasks() + if isActive { - withAnimation(.smooth(duration: 0.2)) { + // Force a clean collapsed baseline before animating ON. + // This avoids cases where prior OFF state leaves the view mid-transition. + withAnimation(.none) { showInactiveIcon = false + isExpanded = false + iconScale = 1.0 } + + // Run the expansion on the next runloop tick so the reset takes effect first. + DispatchQueue.main.async { + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + iconScale = 1.0 + isExpanded = true + } + if focusToastMode { + scheduleTransientCollapse() + } + } + } else { + triggerInactiveAnimation() + } + } + + private func handleFocusModeSwitchToast(_ oldValue: UUID, _ newValue: UUID) { + // Only show a toast when Focus is currently active. + guard manager.isDoNotDisturbActive else { return } + + // Only relevant for the non-persistent (brief toast) mode. + guard focusToastMode else { return } + + // Cancel any pending collapse so the new mode can animate in cleanly. + cancelPendingTasks() + + // Force a clean collapsed baseline before animating ON for the *new* mode. + // Without this, a mode switch can appear to start then cancel. + withAnimation(.none) { + showInactiveIcon = false + isExpanded = false + iconScale = 1.0 + } + + // Run the expansion on the next runloop tick so the reset takes effect first. + DispatchQueue.main.async { + guard manager.isDoNotDisturbActive else { return } withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { iconScale = 1.0 isExpanded = true } - if focusToastMode { - scheduleTransientCollapse() - } - } else { - triggerInactiveAnimation() + scheduleTransientCollapse() } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 6c92f66..bb0194d 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -851,6 +851,7 @@ struct SettingsView: View { SettingsSearchEntry(tab: .stats, title: "Enable system stats monitoring", keywords: ["stats", "monitoring"], highlightID: SettingsTab.stats.highlightID(for: "Enable system stats monitoring")), SettingsSearchEntry(tab: .stats, title: "Stop monitoring after closing the notch", keywords: ["stats", "auto stop"], highlightID: SettingsTab.stats.highlightID(for: "Stop monitoring after closing the notch")), SettingsSearchEntry(tab: .stats, title: "CPU Usage", keywords: ["cpu", "graph"], highlightID: SettingsTab.stats.highlightID(for: "CPU Usage")), + SettingsSearchEntry(tab: .stats, title: "Temperature unit", keywords: ["cpu", "temperature", "celsius", "fahrenheit"], highlightID: SettingsTab.stats.highlightID(for: "Temperature unit")), SettingsSearchEntry(tab: .stats, title: "Memory Usage", keywords: ["memory", "ram"], highlightID: SettingsTab.stats.highlightID(for: "Memory Usage")), SettingsSearchEntry(tab: .stats, title: "GPU Usage", keywords: ["gpu", "graphics"], highlightID: SettingsTab.stats.highlightID(for: "GPU Usage")), SettingsSearchEntry(tab: .stats, title: "Network Activity", keywords: ["network", "graph"], highlightID: SettingsTab.stats.highlightID(for: "Network Activity")), @@ -6157,6 +6158,7 @@ struct StatsSettings: View { @Default(.showGpuGraph) var showGpuGraph @Default(.showNetworkGraph) var showNetworkGraph @Default(.showDiskGraph) var showDiskGraph + @Default(.cpuTemperatureUnit) var cpuTemperatureUnit private func highlightID(_ title: String) -> String { SettingsTab.stats.highlightID(for: title) @@ -6246,6 +6248,16 @@ struct StatsSettings: View { Section { Defaults.Toggle("CPU Usage", key: .showCpuGraph) .settingsHighlight(id: highlightID("CPU Usage")) + + if showCpuGraph { + Picker("Temperature unit", selection: $cpuTemperatureUnit) { + ForEach(LockScreenWeatherTemperatureUnit.allCases) { unit in + Text(unit.rawValue).tag(unit) + } + } + .pickerStyle(.segmented) + .settingsHighlight(id: highlightID("Temperature unit")) + } Defaults.Toggle("Memory Usage", key: .showMemoryGraph) .settingsHighlight(id: highlightID("Memory Usage")) Defaults.Toggle("GPU Usage", key: .showGpuGraph) diff --git a/DynamicIsland/components/Stats/CPUStatsSharedViews.swift b/DynamicIsland/components/Stats/CPUStatsSharedViews.swift index 0b5a651..68150c2 100644 --- a/DynamicIsland/components/Stats/CPUStatsSharedViews.swift +++ b/DynamicIsland/components/Stats/CPUStatsSharedViews.swift @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import Defaults import SwiftUI struct StatsCard: View { @@ -163,7 +164,10 @@ private struct CPUTemperatureGauge: View { let tint: Color var size: CGFloat = 84 var lineWidth: CGFloat = 9 - private let maxTemperature: Double = 110 + @Default(.cpuTemperatureUnit) private var temperatureUnit + private var maxTemperature: Double { + temperatureUnit == .celsius ? 110 : 230 + } var body: some View { CircularGaugeView( @@ -177,14 +181,19 @@ private struct CPUTemperatureGauge: View { ) } + private var displayTemperature: Double? { + guard let temperature else { return nil } + return temperatureUnit == .celsius ? temperature : temperature * 9.0 / 5.0 + 32.0 + } + private var normalizedValue: Double { - guard let temperature, maxTemperature > 0 else { return 0 } - return min(max(temperature / maxTemperature, 0), 1) + guard let displayTemperature, maxTemperature > 0 else { return 0 } + return min(max(displayTemperature / maxTemperature, 0), 1) } private var centerPrimary: String { - guard let temperature else { return "—" } - return String(format: "%.0f°", temperature) + guard let displayTemperature else { return "—" } + return String(format: "%.0f%@", displayTemperature, temperatureUnit.symbol) } } diff --git a/DynamicIsland/managers/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift index 073693a..d2da5c0 100644 --- a/DynamicIsland/managers/DoNotDisturbManager.swift +++ b/DynamicIsland/managers/DoNotDisturbManager.swift @@ -30,6 +30,9 @@ final class DoNotDisturbManager: ObservableObject { @Published var currentFocusModeName: String = "" @Published var currentFocusModeIdentifier: String = "" + // Used by the brief-toast UI to show an ON toast when the active mode switches while Focus stays enabled. + @Published var focusToastTrigger: UUID = UUID() + private let notificationCenter = DistributedNotificationCenter.default() private let metadataExtractionQueue = DispatchQueue(label: "com.dynamicisland.focus.metadata", qos: .userInitiated) private let pollingQueue = DispatchQueue(label: "com.dynamicisland.focus.polling", qos: .utility) @@ -82,9 +85,8 @@ final class DoNotDisturbManager: ObservableObject { suspensionBehavior: .deliverImmediately ) - startAssertionsPolling() - applyMonitoringModeChange(Defaults[.focusMonitoringMode]) isMonitoring = true + applyMonitoringModeChange(Defaults[.focusMonitoringMode]) } func stopMonitoring() { @@ -128,30 +130,69 @@ final class DoNotDisturbManager: ObservableObject { let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedMode = FocusModeType.resolve(identifier: trimmedIdentifier, name: trimmedName) - - let finalIdentifier: String - if let identifier = trimmedIdentifier, !identifier.isEmpty { - finalIdentifier = identifier - } else { - finalIdentifier = resolvedMode.rawValue - } - - let finalName: String - if let name = trimmedName, !name.isEmpty { - finalName = name - } else if !resolvedMode.displayName.isEmpty { - finalName = resolvedMode.displayName - } else if let identifier = trimmedIdentifier, !identifier.isEmpty { - finalName = identifier - } else { - finalName = "Focus" - } + let isGenericFocusIdentifier = (trimmedIdentifier?.lowercased() == "com.apple.focus") + let usableIdentifier = (trimmedIdentifier?.isEmpty == false && !isGenericFocusIdentifier) ? trimmedIdentifier : nil + let usableName = (trimmedName?.isEmpty == false) ? trimmedName : nil let previousIdentifier = self.currentFocusModeIdentifier let previousName = self.currentFocusModeName let previousActive = self.isDoNotDisturbActive + // When neither identifier nor name is available, only update the active + // state without overwriting existing mode metadata. This prevents the + // assertions-poll (which often lacks metadata) from reverting a correct + // mode set by the log-stream or notification handler. + if usableIdentifier == nil && usableName == nil { + if let isActive = isActive, isActive != previousActive { + withAnimation(.smooth(duration: 0.25)) { + self.isDoNotDisturbActive = isActive + } + // First activation with no prior mode: default to DND. + if isActive && previousIdentifier.isEmpty { + self.currentFocusModeIdentifier = FocusModeType.doNotDisturb.rawValue + self.currentFocusModeName = FocusModeType.doNotDisturb.displayName + } + debugPrint("[DoNotDisturbManager] Focus active-only update -> source: \(source) | isActive: \(isActive)") + } + return + } + + let resolvedMode = FocusModeType.resolve(identifier: usableIdentifier, name: usableName) + + let finalIdentifier: String = usableIdentifier ?? resolvedMode.rawValue + + // Compute display name + let finalName: String + if resolvedMode == .custom, !FullDiskAccessAuthorization.hasPermission() { + finalName = "Focus" + } else if resolvedMode == .custom, FullDiskAccessAuthorization.hasPermission() { + // With FDA, prefer the real name from ModeConfigurations.json (fixes slug names like graduationcap.fill). + let lookedUp = FocusMetadataReader.shared.getDisplayName(for: trimmedName ?? "", identifier: finalIdentifier) + finalName = lookedUp.isEmpty ? "Focus" : lookedUp + } else if let name = trimmedName, !name.isEmpty { + let lower = name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch lower { + case "work": + finalName = "Work" + case "personal", "personal-time": + finalName = "Personal" + case "reduce-interruptions": + finalName = "Reduce Interruptions" + case "sleep", "sleep-mode": + finalName = "Sleep" + case "driving": + finalName = "Driving" + case "default", "dnd", "do-not-disturb", "do not disturb", "donotdisturb": + finalName = "Do Not Disturb" + default: + finalName = name + } + } else if !resolvedMode.displayName.isEmpty { + finalName = resolvedMode.displayName + } else { + finalName = "Focus" + } + let identifierChanged = finalIdentifier != previousIdentifier let nameChanged = finalName != previousName let shouldToggleActive = isActive.map { $0 != previousActive } ?? false @@ -161,10 +202,12 @@ final class DoNotDisturbManager: ObservableObject { } if nameChanged { - self.currentFocusModeName = finalName - .localizedCaseInsensitiveContains( - "Reduce Interruptions" - ) ? "Reduce Interr." : finalName + self.currentFocusModeName = finalName.localizedCaseInsensitiveContains("Reduce Interruptions") ? "Reduce Interr." : finalName + } + + // If Focus remains active and the mode switches (e.g., DND -> Sleep), trigger an ON toast for the new mode. + if isActive == nil, previousActive == true, identifierChanged { + self.focusToastTrigger = UUID() } if identifierChanged || nameChanged || shouldToggleActive { @@ -176,12 +219,16 @@ final class DoNotDisturbManager: ObservableObject { withAnimation(.smooth(duration: 0.25)) { self.isDoNotDisturbActive = isActive } + + // If Focus turned OFF, clear cached mode metadata after the OFF toast can use it. + if isActive == false { + self.currentFocusModeIdentifier = previousIdentifier + self.currentFocusModeName = previousName + } } } private func handleLogMetadataUpdate(identifier: String?, name: String?) { - guard Defaults[.focusMonitoringMode] == .useDevTools else { return } - metadataExtractionQueue.async { [weak self] in guard let self = self else { return } let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -336,9 +383,11 @@ private extension DoNotDisturbManager { guard isMonitoring else { return } if mode == .useDevTools { + stopAssertionsPolling() focusLogStream.start() } else { focusLogStream.stop() + startAssertionsPolling() } } @@ -541,11 +590,24 @@ extension FocusModeType { return } + // 1. Exact rawValue match (e.g., "com.apple.focus.work", "com.apple.donotdisturb.mode"). if let direct = FocusModeType(rawValue: normalized) ?? FocusModeType(rawValue: normalizedLowercased) { self = direct return } + // 2. macOS 26.3 custom Focus modes use `com.apple.donotdisturb.mode.`. + // Must be checked BEFORE the generic prefix match, otherwise the prefix + // `com.apple.donotdisturb.mode` would incorrectly resolve to .doNotDisturb. + if normalizedLowercased.hasPrefix("com.apple.donotdisturb.mode.") { + let suffix = String(normalizedLowercased.dropFirst("com.apple.donotdisturb.mode.".count)) + if !suffix.isEmpty && suffix != "default" { + self = .custom + return + } + } + + // 3. Prefix match for known built-in modes (e.g., com.apple.focus.personal-time -> .personal). if let resolved = FocusModeType.allCases.first(where: { guard !$0.rawValue.isEmpty else { return false } return normalized.hasPrefix($0.rawValue) || normalizedLowercased.hasPrefix($0.rawValue) @@ -554,6 +616,7 @@ extension FocusModeType { return } + // 4. Anything else under com.apple.focus is custom. if normalizedLowercased.hasPrefix("com.apple.focus") { self = .custom return @@ -570,6 +633,22 @@ extension FocusModeType { }) { return match } + + // Also try matching the name against known raw identifiers (e.g., a log line may emit "work" or "sleep" as the name). + let lower = name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch lower { + case "work": return .work + case "personal", "personal-time": return .personal + case "sleep", "sleep-mode": return .sleep + case "driving": return .driving + case "fitness": return .fitness + case "gaming": return .gaming + case "mindfulness": return .mindfulness + case "reading": return .reading + case "reduce-interruptions", "reduce interruptions": return .reduceInterruptions + case "do not disturb", "dnd", "donotdisturb", "do-not-disturb", "default": return .doNotDisturb + default: break + } } if let identifier, !identifier.isEmpty { @@ -580,13 +659,17 @@ extension FocusModeType { } func getCustomIconFromFile() -> String { - return FocusMetadataReader.shared - .getIcon(for: DoNotDisturbManager.shared.currentFocusModeName) + return FocusMetadataReader.shared.getIcon( + for: DoNotDisturbManager.shared.currentFocusModeName, + identifier: DoNotDisturbManager.shared.currentFocusModeIdentifier + ) } func getCustomAccentColorFromFile() -> Color { - return FocusMetadataReader.shared - .getAccentColor(for: DoNotDisturbManager.shared.currentFocusModeName) + return FocusMetadataReader.shared.getAccentColor( + for: DoNotDisturbManager.shared.currentFocusModeName, + identifier: DoNotDisturbManager.shared.currentFocusModeIdentifier + ) } } @@ -815,6 +898,7 @@ private final class FocusLogStream { private var pipe: Pipe? private var buffer = Data() private var isRunning = false + private var didTerminate = false private let metadataLock = NSLock() private var lastIdentifier: String? @@ -831,12 +915,13 @@ private final class FocusLogStream { process.executableURL = URL(fileURLWithPath: "/usr/bin/log") process.arguments = [ "stream", + "--no-backtrace", "--style", "compact", "--level", "info", "--predicate", - "subsystem == \"com.apple.focus\"" + "process == \"duetexpertd\" AND eventMessage CONTAINS \"semanticModeIdentifier\"" ] let pipe = Pipe() @@ -870,7 +955,8 @@ private final class FocusLogStream { self.process = process self.pipe = pipe self.isRunning = true - debugPrint("[FocusLogStream] Started unified log tail for com.apple.focus") + self.didTerminate = false + debugPrint("[FocusLogStream] Started unified log tail for duetexpertd/donotdisturbd Focus metadata") } catch { debugPrint("[FocusLogStream] Failed to start log stream: \(error)") pipe.fileHandleForReading.readabilityHandler = nil @@ -937,7 +1023,12 @@ private final class FocusLogStream { return } - if trimmed.contains("active mode assertion: (null)") || trimmed.contains("active activity: (null)") { + if trimmed.lowercased().contains("error") && trimmed.lowercased().contains("predicate") { + debugPrint("[FocusLogStream] log stream error: \(trimmed)") + } + + // Clear only when logs explicitly indicate no active mode (helps avoid wiping state during transitions). + if trimmed.contains("active mode assertion: (null)") || trimmed.contains("activeModeIdentifier: (null)") { clearMetadata() return } @@ -945,11 +1036,26 @@ private final class FocusLogStream { var updatedIdentifier: String? var updatedName: String? - if let identifier = FocusMetadataDecoder.extractIdentifier(from: trimmed), !identifier.isEmpty { + // Special-case parsing for donotdisturbd logs which include a full DNDMode description. + // Example: + if trimmed.contains(" String? { + guard let r = trimmed.range(of: key) else { return nil } + let suffix = trimmed[r.upperBound...] + guard let end = suffix.range(of: ";") else { return nil } + let value = suffix[.. DNDMode? { + + private func getModeConfig(for focusName: String, identifier: String? = nil) -> DNDMode? { guard FullDiskAccessAuthorization.hasPermission() else { return nil } + + let trimmedName = focusName.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) + do { let data = try Data(contentsOf: pathToDatabase) let root = try JSONDecoder().decode(DNDConfigRoot.self, from: data) - + for entry in root.data { for wrapper in entry.modeConfigurations.values { - if wrapper.mode.name - .localizedCaseInsensitiveCompare(focusName) == .orderedSame { - return wrapper.mode + let mode = wrapper.mode + + if let id = trimmedIdentifier, !id.isEmpty, + mode.modeIdentifier.compare(id, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame { + return mode + } + + if !trimmedName.isEmpty, + mode.name.compare(trimmedName, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame { + return mode } } } } catch { - print("JSON Error: \(error)") + print("ModeConfigurations.json decode error: \(error)") } + return nil } - + + func getDisplayName(for focus: String, identifier: String? = nil) -> String { + guard let mode = getModeConfig(for: focus, identifier: identifier) else { return "" } + return mode.name + } + /// Fetch the icon for the current focus from disk. If the focus is not found return the placeholder `app.badge` /// - Returns A string representing the sfSymbol of the current focus - func getIcon(for focus: String) -> String { - guard let mode = getModeConfig(for: focus) else { return "app.badge" } + func getIcon(for focus: String, identifier: String? = nil) -> String { + guard let mode = getModeConfig(for: focus, identifier: identifier) else { return "app.badge" } return mode.symbolImageName ?? "app.badge" } - + /// Fetch the accent color for the current focus from disk. If the focus is not found return the placeholder `Color.indigo` /// - Returns A Color representing the accent color for the current focus - func getAccentColor(for focus: String) -> Color { - guard let mode = getModeConfig(for: focus), + func getAccentColor(for focus: String, identifier: String? = nil) -> Color { + guard let mode = getModeConfig(for: focus, identifier: identifier), let colorName = mode.tintColorName else { return .indigo } - + return Color.stringToColor(for: colorName) } - } extension Color { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index bce1462..23ae426 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -794,10 +794,10 @@ extension Defaults.Keys { static let lockScreenWeatherProviderSource = Key("lockScreenWeatherProviderSource", default: .openMeteo) static let lockScreenWeatherVerticalOffset = Key("lockScreenWeatherVerticalOffset", default: 0) static let lockScreenMusicVerticalOffset = Key("lockScreenMusicVerticalOffset", default: 0) - static let lockScreenMusicPanelWidth = Key("lockScreenMusicPanelWidth", default: 420) + static let lockScreenMusicPanelWidth = Key("lockScreenMusicPanelWidth", default: 350) static let lockScreenMusicAlbumParallaxEnabled = Key("lockScreenMusicAlbumParallaxEnabled", default: false) static let lockScreenTimerVerticalOffset = Key("lockScreenTimerVerticalOffset", default: 0) - static let lockScreenTimerWidgetWidth = Key("lockScreenTimerWidgetWidth", default: 420) + static let lockScreenTimerWidgetWidth = Key("lockScreenTimerWidgetWidth", default: 350) static let lockScreenGlassStyle = Key("lockScreenGlassStyle", default: .liquid) static let lockScreenGlassCustomizationMode = Key( "lockScreenGlassCustomizationMode", @@ -903,13 +903,14 @@ extension Defaults.Keys { // MARK: Stats Feature static let enableStatsFeature = Key("enableStatsFeature", default: false) static let autoStartStatsMonitoring = Key("autoStartStatsMonitoring", default: true) - static let statsStopWhenNotchCloses = Key("statsStopWhenNotchCloses", default: false) + static let statsStopWhenNotchCloses = Key("statsStopWhenNotchCloses", default: true) static let statsUpdateInterval = Key("statsUpdateInterval", default: 1.0) static let showCpuGraph = Key("showCpuGraph", default: true) static let showMemoryGraph = Key("showMemoryGraph", default: true) static let showGpuGraph = Key("showGpuGraph", default: true) static let showNetworkGraph = Key("showNetworkGraph", default: false) static let showDiskGraph = Key("showDiskGraph", default: false) + static let cpuTemperatureUnit = Key("cpuTemperatureUnit", default: .celsius) // MARK: Terminal Feature static let enableTerminalFeature = Key("enableTerminalFeature", default: false) @@ -1069,7 +1070,7 @@ extension Defaults.Keys { static let capsLockIndicatorUseGreenColor = Key("capsLockIndicatorUseGreenColor", default: false) // Legacy toggle static let capsLockIndicatorTintMode = Key("capsLockIndicatorTintMode", default: .white) static let didMigrateCapsLockTintMode = Key("didMigrateCapsLockTintMode", default: false) - static let showCapsLockLabel = Key("showCapsLockLabel", default: true) + static let showCapsLockLabel = Key("showCapsLockLabel", default: false) // MARK: ImageService static let didClearLegacyURLCacheV1 = Key("didClearLegacyURLCacheV1", default: false) diff --git a/DynamicIsland/utils/CPUSensorCollector.swift b/DynamicIsland/utils/CPUSensorCollector.swift index dddd5ff..bf1aa04 100644 --- a/DynamicIsland/utils/CPUSensorCollector.swift +++ b/DynamicIsland/utils/CPUSensorCollector.swift @@ -30,6 +30,11 @@ struct CPUFrequencyMetrics: Equatable { struct CPUTemperatureMetrics: Equatable { let celsius: Double? + + var fahrenheit: Double? { + guard let celsius else { return nil } + return celsius * 9.0 / 5.0 + 32.0 + } } final class CPUSensorCollector { From dd890bd5e5eeaaf3efaa332a99b544334ddbcde1 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 5 Mar 2026 23:35:32 +0530 Subject: [PATCH 16/26] Update hardened runtime --- DynamicIsland.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 46809fe..00ed893 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -398,7 +398,7 @@ DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = NO; - ENABLE_HARDENED_RUNTIME = NO; + ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = YES; @@ -469,7 +469,7 @@ DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = NO; - ENABLE_HARDENED_RUNTIME = NO; + ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = YES; From d8ca8ceb02c66bb181e0e54abb3323867767d3b2 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 5 Mar 2026 23:57:13 +0530 Subject: [PATCH 17/26] Update BetterDisplay --- DynamicIsland/Localizable.xcstrings | 10 +- .../components/Settings/SettingsView.swift | 28 +++++- .../managers/BetterDisplayManager.swift | 95 ++++++++++++++++--- 3 files changed, 111 insertions(+), 22 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 0638a2b..c2380d3 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -4795,6 +4795,9 @@ "BetterDisplay Integration" : { "comment" : "The header text for the \"BetterDisplay Integration\" section in the settings view.", "isCommentAutoGenerated" : true + }, + "BetterDisplay is installed but not currently running. Launch BetterDisplay to enable integration." : { + }, "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." : { @@ -8517,10 +8520,6 @@ "comment" : "Name of a user profile for a designer.", "isCommentAutoGenerated" : true }, - "Detected" : { - "comment" : "A label indicating that BetterDisplay is detected on the system.", - "isCommentAutoGenerated" : true - }, "Detection Status" : { "comment" : "The header for a section that displays whether the screen recording detection feature is currently active or inactive.", "isCommentAutoGenerated" : true, @@ -17410,9 +17409,6 @@ "Not charging" : { "comment" : "Text displayed in the status text when the battery is not charging.", "isCommentAutoGenerated" : true - }, - "Not detected" : { - }, "Not Now" : { "localizations" : { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index bb0194d..685388d 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1934,6 +1934,26 @@ private struct BetterDisplayIntegrationSection: View { SettingsTab.hudAndOSD.highlightID(for: title) } + private var statusText: String { + if betterDisplayManager.isRunning { + return "Running" + } else if betterDisplayManager.isDetected { + return "Not running" + } else { + return "Not detected" + } + } + + private var statusColor: Color { + if betterDisplayManager.isRunning { + return .green + } else if betterDisplayManager.isDetected { + return .orange + } else { + return .secondary + } + } + var body: some View { Form { Section { @@ -1941,9 +1961,9 @@ private struct BetterDisplayIntegrationSection: View { VStack(alignment: .leading, spacing: 2) { Text("BetterDisplay") .font(.system(size: 13, weight: .medium)) - Text(betterDisplayManager.isDetected ? "Detected" : "Not detected") + Text(statusText) .font(.caption) - .foregroundStyle(betterDisplayManager.isDetected ? .green : .secondary) + .foregroundStyle(statusColor) } Spacer() @@ -1959,6 +1979,10 @@ private struct BetterDisplayIntegrationSection: View { 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) diff --git a/DynamicIsland/managers/BetterDisplayManager.swift b/DynamicIsland/managers/BetterDisplayManager.swift index 16a4b8a..6b66b2b 100644 --- a/DynamicIsland/managers/BetterDisplayManager.swift +++ b/DynamicIsland/managers/BetterDisplayManager.swift @@ -93,23 +93,33 @@ final class BetterDisplayManager: ObservableObject { private static let osdNotificationName = NSNotification.Name("com.betterdisplay.BetterDisplay.osd") private static let requestNotificationName = NSNotification.Name("com.betterdisplay.BetterDisplay.request") private static let responseNotificationName = NSNotification.Name("com.betterdisplay.BetterDisplay.response") + private static let launchedNotificationName = NSNotification.Name("pro.betterdisplay.BetterDisplay.launched") + private static let terminatedNotificationName = NSNotification.Name("pro.betterdisplay.BetterDisplay.terminated") // MARK: Published state - /// Whether BetterDisplay is currently detected on this machine. + /// Whether BetterDisplay is currently detected (installed) on this machine. @Published private(set) var isDetected: Bool = false + /// Whether BetterDisplay is currently running and ready for communication. + @Published private(set) var isRunning: Bool = false + // MARK: Private private var osdObserver: NSObjectProtocol? private var responseObserver: NSObjectProtocol? private var workspaceObserver: NSObjectProtocol? + private var workspaceTermObserver: NSObjectProtocol? + private var launchedObserver: NSObjectProtocol? + private var terminatedObserver: NSObjectProtocol? private var cancellables = Set() private weak var coordinator: DynamicIslandViewCoordinator? private init() { isDetected = Self.checkInstallation() + isRunning = Self.checkRunning() setupWorkspaceObserver() + setupLifecycleObservers() setupSettingsObserver() } @@ -119,8 +129,8 @@ final class BetterDisplayManager: ObservableObject { func configure(coordinator: DynamicIslandViewCoordinator) { self.coordinator = coordinator - // Start listening if integration is enabled and BetterDisplay is detected - if Defaults[.enableBetterDisplayIntegration] && isDetected { + // Start listening if integration is enabled and BetterDisplay is running + if Defaults[.enableBetterDisplayIntegration] && isRunning { startListening() } } @@ -128,18 +138,19 @@ final class BetterDisplayManager: ObservableObject { /// Refresh detection status (e.g. after app install/uninstall). func refreshDetectionStatus() { let wasDetected = isDetected + let wasRunning = isRunning isDetected = Self.checkInstallation() - if wasDetected && !isDetected { + isRunning = Self.checkRunning() + if wasRunning && !isRunning { stopListening() - } else if !wasDetected && isDetected && Defaults[.enableBetterDisplayIntegration] { + } else if !wasRunning && isRunning && Defaults[.enableBetterDisplayIntegration] { startListening() } } // MARK: - Detection - /// Check if BetterDisplay is installed by looking for its bundle ID in running apps - /// and falling back to NSWorkspace URL lookup. + /// Check if BetterDisplay is installed by looking for its bundle ID. static func checkInstallation() -> Bool { // Check running apps first (fast path) if NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bundleID }) { @@ -154,6 +165,11 @@ final class BetterDisplayManager: ObservableObject { return false } + /// Check if BetterDisplay is currently running. + static func checkRunning() -> Bool { + NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bundleID }) + } + // MARK: - OSD Listening private func startListening() { @@ -183,7 +199,7 @@ final class BetterDisplayManager: ObservableObject { // MARK: - OSD Handling private func handleOSDNotification(_ notification: Notification) { - guard Defaults[.enableBetterDisplayIntegration] else { return } + guard Defaults[.enableBetterDisplayIntegration], isRunning else { return } guard let notificationString = notification.object as? String else { NSLog("⚠️ BetterDisplay OSD: unexpected notification format") @@ -345,6 +361,12 @@ final class BetterDisplayManager: ObservableObject { parameters: [String: String?], completion: (@MainActor @Sendable (BetterDisplayResponseData?) -> Void)? = nil ) { + guard isRunning else { + NSLog("⚠️ BetterDisplay sendRequest skipped — app is not running") + completion?(nil) + return + } + let uuid = UUID().uuidString let request = BetterDisplayRequestData(uuid: uuid, commands: commands, parameters: parameters) @@ -429,16 +451,54 @@ final class BetterDisplayManager: ObservableObject { } } - // Also observe app termination - NSWorkspace.shared.notificationCenter.addObserver( + // Also observe app termination — handles crashes and force-quits + // (orderly quits are caught by the lifecycle observer below) + workspaceTermObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didTerminateApplicationNotification, object: nil, queue: .main - ) { notification in + ) { [weak self] notification in guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, app.bundleIdentifier == betterDisplayBundleID else { return } - // Don't clear isDetected on termination — app is still installed, just not running. + Task { @MainActor in + NSLog("🔴 BetterDisplay terminated (workspace notification)") + self?.isRunning = false + self?.stopListening() + } + } + } + + // MARK: - Lifecycle Observers (BetterDisplay launched/terminated notifications) + + /// Listen for distributed notifications sent by BetterDisplay itself + /// to know when it becomes ready and when it shuts down cleanly. + private func setupLifecycleObservers() { + launchedObserver = DistributedNotificationCenter.default().addObserver( + forName: Self.launchedNotificationName, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + NSLog("🟢 BetterDisplay launched notification received") + self?.isDetected = true + self?.isRunning = true + if Defaults[.enableBetterDisplayIntegration] { + self?.startListening() + } + } + } + + terminatedObserver = DistributedNotificationCenter.default().addObserver( + forName: Self.terminatedNotificationName, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + NSLog("🔴 BetterDisplay terminated notification received") + self?.isRunning = false + self?.stopListening() + } } } @@ -449,7 +509,7 @@ final class BetterDisplayManager: ObservableObject { .sink { [weak self] change in Task { @MainActor in guard let self else { return } - if change.newValue && self.isDetected { + if change.newValue && self.isRunning { self.startListening() } else { self.stopListening() @@ -463,9 +523,18 @@ final class BetterDisplayManager: ObservableObject { if let osdObserver { DistributedNotificationCenter.default().removeObserver(osdObserver) } + if let launchedObserver { + DistributedNotificationCenter.default().removeObserver(launchedObserver) + } + if let terminatedObserver { + DistributedNotificationCenter.default().removeObserver(terminatedObserver) + } if let workspaceObserver { NSWorkspace.shared.notificationCenter.removeObserver(workspaceObserver) } + if let workspaceTermObserver { + NSWorkspace.shared.notificationCenter.removeObserver(workspaceTermObserver) + } cancellables.removeAll() } } From 40cd7d0d921c5cc775278e5797e401fa5a6cb244 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 6 Mar 2026 01:13:09 +0530 Subject: [PATCH 18/26] Update entitlements --- DynamicIsland.xcodeproj/project.pbxproj | 6 ++++-- DynamicIsland/DynamicIsland.entitlements | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 00ed893..2e40658 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -391,12 +391,13 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -462,12 +463,13 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; diff --git a/DynamicIsland/DynamicIsland.entitlements b/DynamicIsland/DynamicIsland.entitlements index 2e86356..fbf9fd4 100644 --- a/DynamicIsland/DynamicIsland.entitlements +++ b/DynamicIsland/DynamicIsland.entitlements @@ -2,6 +2,10 @@ + com.apple.security.mach-lookup.global-name + + com.ebullioscopic.Atoll.xpc + com.apple.security.automation.apple-events com.apple.security.temporary-exception.apple-events From c52fe73331f12300999fa610f2620a9cc2a35280 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 6 Mar 2026 01:33:48 +0530 Subject: [PATCH 19/26] Fix: Hover Gesture --- DynamicIsland/ContentView.swift | 14 ++++++++------ DynamicIsland/extensions/PanGesture.swift | 13 +++++++++++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 5555fe7..e36f5b1 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -337,12 +337,6 @@ struct ContentView: View { // is not clipped by the outer frame constraint .padding(.horizontal, isIslandMode ? dynamicIslandShadowInset : 0) .padding(.top, pillTopOffset) - .padding(.bottom, notchBottomPadding) - // Hide off-screen when non-notch hide-until-hover is active - .offset(y: shouldHideUntilHover && !isHovering - ? -(vm.closedNotchSize.height + pillTopOffset + currentShadowPadding + 10) - : 0 - ) mainLayout .conditionalModifier(!useModernCloseAnimation) { view in @@ -395,6 +389,14 @@ struct ContentView: View { handleUpGesture(translation: translation, phase: phase) } } + // Shadow bottom padding and hide-until-hover offset applied AFTER + // interaction modifiers so .contentShape / .onHover only covers + // the actual notch content, not the shadow clearance below it. + .padding(.bottom, notchBottomPadding) + .offset(y: shouldHideUntilHover && !isHovering + ? -(vm.closedNotchSize.height + pillTopOffset + currentShadowPadding + 10) + : 0 + ) .onAppear(perform: { if coordinator.firstLaunch { // Single open during first launch; closeHello() handles the timed close. diff --git a/DynamicIsland/extensions/PanGesture.swift b/DynamicIsland/extensions/PanGesture.swift index 9eafd5d..7eaa2a7 100644 --- a/DynamicIsland/extensions/PanGesture.swift +++ b/DynamicIsland/extensions/PanGesture.swift @@ -76,7 +76,11 @@ private struct ScrollMonitor: NSViewRepresentable { private var active = false private let noiseThreshold: CGFloat = 0.2 private weak var observedView: NSView? - private let hoverInset: CGFloat = 14 + /// Small vertical inset so scroll gestures fire when the cursor + /// "kisses" the very top of the screen inside the physical notch + /// area. Kept at zero horizontally to avoid unwanted hover opens + /// from the sides. + private let verticalEdgeInset: CGFloat = 4 private var lastEventTimestamp: TimeInterval = 0 init(direction: PanDirection, threshold: CGFloat, action: @escaping (CGFloat, NSEvent.Phase) -> Void) { @@ -154,7 +158,12 @@ private struct ScrollMonitor: NSViewRepresentable { let windowPoint = window.convertPoint(fromScreen: screenPoint) let localPoint = view.convert(windowPoint, from: nil) - let hitArea = view.bounds.insetBy(dx: -hoverInset, dy: -hoverInset) + + // Extend the hit area vertically by a few points so the cursor + // at the very top screen edge (kissing the notch) still triggers + // scroll gestures. No horizontal extension to prevent false opens + // from the sides of the notch. + let hitArea = view.bounds.insetBy(dx: 0, dy: -verticalEdgeInset) return hitArea.contains(localPoint) } } From 0e7e863ba019cc913c699b45fe37090e9eb7311c Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 6 Mar 2026 01:46:51 +0530 Subject: [PATCH 20/26] Fix: Media Player timelineview --- .../components/Notch/MinimalisticMusicPlayerView.swift | 2 +- DynamicIsland/components/Notch/NotchHomeView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 664dc09..fd20d3b 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -650,7 +650,7 @@ private struct MinimalisticReminderDetailsView: View { // MARK: - Progress Bar (Full Width) @ObservedObject var musicManager = MusicManager.shared - @State private var sliderValue: Double = 0 + @State private var sliderValue: Double = MusicManager.shared.estimatedPlaybackPosition() @State private var dragging: Bool = false @State private var lastDragged: Date = .distantPast diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 7de0d23..7b3d0fa 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -133,7 +133,7 @@ struct MusicControlsView: View { @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject var musicManager = MusicManager.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared - @State private var sliderValue: Double = 0 + @State private var sliderValue: Double = MusicManager.shared.estimatedPlaybackPosition() @State private var dragging: Bool = false @State private var lastDragged: Date = .distantPast @State private var hudValue: Double = 0 From 806df9ae1e268d1785e59d6b3ba38bc81c648b79 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 6 Mar 2026 01:53:08 +0530 Subject: [PATCH 21/26] Fix: On Click open --- DynamicIsland/ContentView.swift | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index e36f5b1..d5cb4a6 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -149,6 +149,7 @@ struct ContentView: View { @State private var isHovering: Bool = false @State private var lastHapticTime: Date = Date() @State private var hoverClickMonitor: Any? + @State private var hoverClickLocalMonitor: Any? @State private var gestureProgress: CGFloat = .zero @State private var skipGestureActiveDirection: MusicManager.SkipDirection? @@ -1544,17 +1545,34 @@ struct ContentView: View { private func startHoverClickMonitor() { guard hoverClickMonitor == nil else { return } - hoverClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown]) { _ in + + let handleClick: @Sendable () -> Void = { [weak vm, weak lockScreenManager] in Task { @MainActor in + guard let vm, let lockScreenManager else { return } guard !lockScreenManager.isLocked else { return } guard vm.notchState == .closed else { return } - guard isHovering else { return } + guard self.isHovering else { return } if Defaults[.enableHaptics] { - triggerHapticIfAllowed() + self.triggerHapticIfAllowed() } - openNotch() + self.openNotch() } } + + // Global monitor catches clicks outside the app window (e.g. when + // the cursor is at the very top screen edge and the click goes to + // the system rather than our panel). + hoverClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown]) { _ in + handleClick() + } + + // Local monitor catches clicks that DO hit our window — at the + // screen edge SwiftUI's .onTapGesture may not fire reliably, but + // the NSEvent local monitor will. + hoverClickLocalMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { event in + handleClick() + return event + } } private func stopHoverClickMonitor() { @@ -1562,6 +1580,10 @@ struct ContentView: View { NSEvent.removeMonitor(hoverClickMonitor) self.hoverClickMonitor = nil } + if let hoverClickLocalMonitor { + NSEvent.removeMonitor(hoverClickLocalMonitor) + self.hoverClickLocalMonitor = nil + } } // MARK: - Hover Management From 82b092c545ab66473c53445cde28916cb6b8ab1d Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 6 Mar 2026 16:00:28 +0530 Subject: [PATCH 22/26] Enable Hardened Runtime --- .../Helpers}/NowPlayingTestClient | Bin DynamicIsland.xcodeproj/project.pbxproj | 49 ++++++------ .../xcshareddata/swiftpm/Package.resolved | 11 +-- DynamicIsland/Localizable.xcstrings | 73 +----------------- DynamicIsland/helpers/MediaChecker.swift | 10 ++- DynamicIsland/models/Constants.swift | 2 +- 6 files changed, 33 insertions(+), 112 deletions(-) rename {mediaremote-adapter => Contents/Helpers}/NowPlayingTestClient (100%) diff --git a/mediaremote-adapter/NowPlayingTestClient b/Contents/Helpers/NowPlayingTestClient similarity index 100% rename from mediaremote-adapter/NowPlayingTestClient rename to Contents/Helpers/NowPlayingTestClient diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 2e40658..d9d90f2 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -19,26 +19,35 @@ 7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 146BC4362D366E9B0047C66A /* MacroVisionKit */; }; 7E6856482EA963FD00D261F5 /* Lock.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E6856472EA963FD00D261F5 /* Lock.json */; }; 7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */; }; - 7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */; }; 7E8840D72E9A346700C5CB8C /* Dog waiting.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840D62E9A346700C5CB8C /* Dog waiting.json */; }; 7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840D82E9A347000C5CB8C /* Moody Dog.json */; }; 7E8840DB2E9A347800C5CB8C /* Orange Cat Peeping.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */; }; 7E9911B02EFD7D1B002EDFD5 /* Reindeer.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */; }; 7E9918722EFDAE5B002EDFD5 /* MediaRemoteAdapter.framework in Resources */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; }; - 7ECFE0C62F4A2323000DD9BD /* MijickCalendarView in Frameworks */ = {isa = PBXBuildFile; productRef = 7ECFE0C52F4A2323000DD9BD /* MijickCalendarView */; }; 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; }; 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; }; 7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 7EF19C162F577040005B9929 /* SwiftTerm */; }; 7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXCopyFilesBuildPhase section */ + 7EA69B1B2F5AE13500EB2D16 /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 7; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 7E25ECA02E6C6C3800700E72 /* CoreBrightness.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBrightness.framework; path = /System/Library/PrivateFrameworks/CoreBrightness.framework; sourceTree = ""; }; 7E25ECA22E6C6E2600700E72 /* OSD.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = OSD.framework; path = /System/Library/PrivateFrameworks/OSD.framework; sourceTree = ""; }; 7E25ECA42E6C6E7800700E72 /* DisplayServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DisplayServices.framework; path = /System/Library/PrivateFrameworks/DisplayServices.framework; sourceTree = ""; }; 7E6856472EA963FD00D261F5 /* Lock.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Lock.json; sourceTree = ""; }; 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */ = {isa = PBXFileReference; lastKnownFileType = text.script.perl; path = "mediaremote-adapter.pl"; sourceTree = ""; }; - 7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = NowPlayingTestClient; sourceTree = ""; }; 7E8840D62E9A346700C5CB8C /* Dog waiting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Dog waiting.json"; sourceTree = ""; }; 7E8840D82E9A347000C5CB8C /* Moody Dog.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Moody Dog.json"; sourceTree = ""; }; 7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Orange Cat Peeping.json"; sourceTree = ""; }; @@ -51,6 +60,11 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 7EA69B1C2F5AE15C00EB2D16 /* Contents */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Contents; + sourceTree = ""; + }; F54406E92CD4987A00C7AFD9 /* DynamicIsland */ = { isa = PBXFileSystemSynchronizedRootGroup; path = DynamicIsland; @@ -74,7 +88,6 @@ 7E6118172E24071E0050006C /* Collections in Frameworks */, 7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */, 7E6118152E24071E0050006C /* Sparkle in Frameworks */, - 7ECFE0C62F4A2323000DD9BD /* MijickCalendarView in Frameworks */, 7E288D122F1659EF0020C117 /* AtollExtensionKit in Frameworks */, 7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */, ); @@ -95,7 +108,6 @@ isa = PBXGroup; children = ( 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */, - 7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */, F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */, ); path = "mediaremote-adapter"; @@ -128,6 +140,7 @@ F54406DE2CD4987A00C7AFD9 = { isa = PBXGroup; children = ( + 7EA69B1C2F5AE15C00EB2D16 /* Contents */, 7E8840D52E9A341F00C5CB8C /* LottieAnimations */, 7E81304F2E3FB42700E08F3F /* Frameworks */, 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */, @@ -155,12 +168,14 @@ F54406E32CD4987A00C7AFD9 /* Sources */, F54406E42CD4987A00C7AFD9 /* Frameworks */, F54406E52CD4987A00C7AFD9 /* Resources */, + 7EA69B1B2F5AE13500EB2D16 /* CopyFiles */, ); buildRules = ( ); dependencies = ( ); fileSystemSynchronizedGroups = ( + 7EA69B1C2F5AE15C00EB2D16 /* Contents */, F54406E92CD4987A00C7AFD9 /* DynamicIsland */, ); name = DynamicIsland; @@ -175,7 +190,6 @@ 146BC4362D366E9B0047C66A /* MacroVisionKit */, 7E61181E2E24071E0050006C /* SkyLightWindow */, 7E288D112F1659EF0020C117 /* AtollExtensionKit */, - 7ECFE0C52F4A2323000DD9BD /* MijickCalendarView */, 7EF19C162F577040005B9929 /* SwiftTerm */, ); productName = DynamicIsland; @@ -222,7 +236,6 @@ 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */, 7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */, 7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */, - 7ECFE0C42F4A2323000DD9BD /* XCRemoteSwiftPackageReference "CalendarView" */, 7EF19C152F577040005B9929 /* XCRemoteSwiftPackageReference "SwiftTerm" */, ); preferredProjectObjectVersion = 77; @@ -242,7 +255,6 @@ files = ( 7E9918722EFDAE5B002EDFD5 /* MediaRemoteAdapter.framework in Resources */, 7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */, - 7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */, 7E6856482EA963FD00D261F5 /* Lock.json in Resources */, 7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */, 7E9911B02EFD7D1B002EDFD5 /* Reindeer.json in Resources */, @@ -394,7 +406,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N; @@ -436,7 +448,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = "1.3.0-beta"; + MARKETING_VERSION = 2.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll.dev; PRODUCT_NAME = Atoll; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -466,7 +478,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N; @@ -508,7 +520,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = "1.3.0-beta"; + MARKETING_VERSION = 2.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll; PRODUCT_NAME = Atoll; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -599,14 +611,6 @@ kind = branch; }; }; - 7ECFE0C42F4A2323000DD9BD /* XCRemoteSwiftPackageReference "CalendarView" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Mijick/CalendarView"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.1.1; - }; - }; 7EF19C152F577040005B9929 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/migueldeicaza/SwiftTerm"; @@ -691,11 +695,6 @@ package = 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */; productName = SkyLightWindow; }; - 7ECFE0C52F4A2323000DD9BD /* MijickCalendarView */ = { - isa = XCSwiftPackageProductDependency; - package = 7ECFE0C42F4A2323000DD9BD /* XCRemoteSwiftPackageReference "CalendarView" */; - productName = MijickCalendarView; - }; 7EF19C162F577040005B9929 /* SwiftTerm */ = { isa = XCSwiftPackageProductDependency; package = 7EF19C152F577040005B9929 /* XCRemoteSwiftPackageReference "SwiftTerm" */; diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a7a1b40..b422434 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e56c82928835ebd09a259cfbf30486c2ebf4c785c9519f893675e244e709ec46", + "originHash" : "d05fd8c8292418b6563db3202a99d4ef453b0f70d83d4d4cf879984c14452017", "pins" : [ { "identity" : "atollextensionkit", @@ -10,15 +10,6 @@ "revision" : "ebe35277572a349fad26abeb6fc552cb5251d584" } }, - { - "identity" : "calendarview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Mijick/CalendarView", - "state" : { - "revision" : "168cf14a50d33684caa2a411bbd0b3f988e9dff6", - "version" : "1.1.1" - } - }, { "identity" : "defaults", "kind" : "remoteSourceControl", diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index c2380d3..f47f5f4 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1607,10 +1607,6 @@ } } }, - "±10 Seconds" : { - "comment" : "Display name for the \"±10 Seconds\" music skip behavior option.", - "isCommentAutoGenerated" : true - }, "0" : { "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", "isCommentAutoGenerated" : true, @@ -1927,10 +1923,6 @@ } } }, - "Accent" : { - "comment" : "Display name for the \"Accent\" caps lock indicator tint mode.", - "isCommentAutoGenerated" : true - }, "Accent color" : { "localizations" : { "ar" : { @@ -2276,10 +2268,6 @@ } } }, - "Adaptive gradient" : { - "comment" : "Name of the timer icon color mode option that uses an adaptive gradient.", - "isCommentAutoGenerated" : true - }, "Add" : { "localizations" : { "ar" : { @@ -3875,10 +3863,6 @@ } } }, - "Apple Music" : { - "comment" : "Names for the different media player types.", - "isCommentAutoGenerated" : true - }, "Applies the notch-style parallax effect to the lock screen media widget album art." : { "localizations" : { "pt-BR" : { @@ -4293,10 +4277,6 @@ } } }, - "Bar" : { - "comment" : "Localized name for the \"Bar\" timer progress style.", - "isCommentAutoGenerated" : true - }, "Battery" : { "localizations" : { "ar" : { @@ -8566,10 +8546,6 @@ } } }, - "Digital" : { - "comment" : "The name of the \"Digital\" timer style.", - "isCommentAutoGenerated" : true - }, "Dimensions" : { "localizations" : { "pt-BR" : { @@ -11968,10 +11944,6 @@ } } }, - "Green" : { - "comment" : "Display name for the \"Green\" Caps Lock Indicator Tint Mode.", - "isCommentAutoGenerated" : true - }, "Height:" : { "extractionState" : "stale", "localizations" : { @@ -15652,6 +15624,7 @@ } }, "Minutes" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -17865,10 +17838,6 @@ } } }, - "Now Playing" : { - "comment" : "Localized name for each media controller type.", - "isCommentAutoGenerated" : true - }, "Number of lines kept in the scrollback buffer. Higher values use more memory." : { }, @@ -18244,10 +18213,6 @@ } } }, - "Panel" : { - "comment" : "Name for the \"Panel\" clipboard display mode.", - "isCommentAutoGenerated" : true - }, "Panel mode shows clipboard in a floating window near the notch." : { "comment" : "A description of the \"Panel\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -19862,10 +19827,6 @@ } } }, - "Ring" : { - "comment" : "Localized name for the 'Ring' timer progress style.", - "isCommentAutoGenerated" : true - }, "Rotation:" : { "extractionState" : "stale", "localizations" : { @@ -20692,10 +20653,6 @@ } } }, - "Separate Tab" : { - "comment" : "Name of the clipboard display mode option that opens a separate tab for pasting.", - "isCommentAutoGenerated" : true - }, "Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : { "comment" : "A description of the \"Separate Tab\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -21757,14 +21714,6 @@ } } }, - "Shows screen assistant as a dropdown attached to the AI button" : { - "comment" : "Description of the \"popover\" display mode for the screen assistant.", - "isCommentAutoGenerated" : true - }, - "Shows screen assistant in a floating panel near the notch" : { - "comment" : "Description of the \"panel\" display mode for the screen assistant.", - "isCommentAutoGenerated" : true - }, "Shows the model's reasoning process before the final answer" : { "localizations" : { "fr" : { @@ -21827,10 +21776,6 @@ } } }, - "Skip forward or backward by ten seconds" : { - "comment" : "Description of a music skip behavior option.", - "isCommentAutoGenerated" : true - }, "Slider color" : { "localizations" : { "ar" : { @@ -22399,10 +22344,6 @@ } } }, - "Spotify" : { - "comment" : "Add more media player types here.", - "isCommentAutoGenerated" : true - }, "Square" : { "localizations" : { "ar" : { @@ -22515,10 +22456,6 @@ }, "Standard notch media controls are hidden. Re-enable the toggle above to restore them." : { - }, - "Standard previous/next track controls" : { - "comment" : "Description of a music skip behavior option that uses standard previous/next track controls.", - "isCommentAutoGenerated" : true }, "Start" : { "localizations" : { @@ -23880,10 +23817,6 @@ }, "shouldTranslate" : false }, - "Track Skip" : { - "comment" : "Name of the music skip behavior option that skips to the next track.", - "isCommentAutoGenerated" : true - }, "Transform" : { "extractionState" : "stale", "localizations" : { @@ -25805,10 +25738,6 @@ "comment" : "A footer note explaining the app's funding.", "isCommentAutoGenerated" : true }, - "Youtube Music" : { - "comment" : "Option in settings to choose a media player that streams music from YouTube Music.", - "isCommentAutoGenerated" : true - }, "YouTube Music requires this third-party app to be installed: " : { "localizations" : { "ar" : { diff --git a/DynamicIsland/helpers/MediaChecker.swift b/DynamicIsland/helpers/MediaChecker.swift index b290dd9..8c23eeb 100644 --- a/DynamicIsland/helpers/MediaChecker.swift +++ b/DynamicIsland/helpers/MediaChecker.swift @@ -32,10 +32,7 @@ final class MediaChecker: Sendable { func checkDeprecationStatus() async throws -> Bool { guard let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"), - let nowPlayingTestClientPath = Bundle.main.url(forResource: "MediaRemoteAdapterTestClient", withExtension: nil)?.path, - //let frameworkPath = Bundle.main.privateFrameworksPath?.appending("/MediaRemoteAdapter.framework") - //let frameworkPath = Optional("/System/Library/PrivateFrameworks/MediaRemoteAdapter.framework") - let frameworkPath = + let frameworkPath = Bundle.main.resourceURL? .appendingPathComponent("MediaRemoteAdapter.framework") .path @@ -43,6 +40,11 @@ final class MediaChecker: Sendable { throw MediaCheckerError.missingResources } + let nowPlayingTestClientPath = + Bundle.main.bundleURL + .appendingPathComponent("Contents/Helpers/MediaRemoteAdapterTestClient") + .path + let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/perl") process.arguments = [scriptURL.path, frameworkPath, nowPlayingTestClientPath, "test"] diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 23ae426..d2592ed 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -691,7 +691,7 @@ extension Defaults.Keys { static let menubarIcon = Key("menubarIcon", default: true) static let showOnAllDisplays = Key("showOnAllDisplays", default: false) static let automaticallySwitchDisplay = Key("automaticallySwitchDisplay", default: true) - static let releaseName = Key("releaseName", default: "Bora Bora") + static let releaseName = Key("releaseName", default: "Seychelles") static let hideDynamicIslandFromScreenCapture = Key("hideDynamicIslandFromScreenCapture", default: false) // MARK: Behavior From 4c93e2b9012f5578a79b83adcfe0c1f7964a3e3b Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 6 Mar 2026 16:13:56 +0530 Subject: [PATCH 23/26] Change build settings --- DynamicIsland.xcodeproj/project.pbxproj | 39 +++++++++++-- DynamicIsland/Localizable.xcstrings | 73 ++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index d9d90f2..173f25e 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -33,12 +33,12 @@ /* Begin PBXCopyFilesBuildPhase section */ 7EA69B1B2F5AE13500EB2D16 /* CopyFiles */ = { isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 7; + buildActionMask = 8; + dstPath = Contents/Helpers; + dstSubfolderSpec = 1; files = ( ); - runOnlyForDeploymentPostprocessing = 0; + runOnlyForDeploymentPostprocessing = 1; }; /* End PBXCopyFilesBuildPhase section */ @@ -59,9 +59,36 @@ F54406E72CD4987A00C7AFD9 /* Atoll.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Atoll.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 7EA69B1E2F5AE61200EB2D16 /* Exceptions for "Contents" folder in "DynamicIsland" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Helpers/NowPlayingTestClient, + ); + target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ + 7EA69B202F5AE63200EB2D16 /* Exceptions for "Contents" folder in "Copy Files" phase from "DynamicIsland" target */ = { + isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; + attributesByRelativePath = { + Helpers/NowPlayingTestClient = (CodeSignOnCopy, ); + }; + buildPhase = 7EA69B1B2F5AE13500EB2D16 /* CopyFiles */; + membershipExceptions = ( + Helpers/NowPlayingTestClient, + ); + }; +/* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 7EA69B1C2F5AE15C00EB2D16 /* Contents */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 7EA69B1E2F5AE61200EB2D16 /* Exceptions for "Contents" folder in "DynamicIsland" target */, + 7EA69B202F5AE63200EB2D16 /* Exceptions for "Contents" folder in "Copy Files" phase from "DynamicIsland" target */, + ); path = Contents; sourceTree = ""; }; @@ -406,7 +433,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N; @@ -478,7 +505,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N; diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index f47f5f4..ecef966 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1607,6 +1607,10 @@ } } }, + "±10 Seconds" : { + "comment" : "Display name for the \"±10 Seconds\" music skip behavior option.", + "isCommentAutoGenerated" : true + }, "0" : { "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", "isCommentAutoGenerated" : true, @@ -1923,6 +1927,10 @@ } } }, + "Accent" : { + "comment" : "Display name for the \"Accent\" caps lock indicator tint mode.", + "isCommentAutoGenerated" : true + }, "Accent color" : { "localizations" : { "ar" : { @@ -2268,6 +2276,10 @@ } } }, + "Adaptive gradient" : { + "comment" : "Name of the \"Adaptive gradient\" timer icon color mode.", + "isCommentAutoGenerated" : true + }, "Add" : { "localizations" : { "ar" : { @@ -3863,6 +3875,10 @@ } } }, + "Apple Music" : { + "comment" : "Localized name for the \"Now Playing\" media controller type.", + "isCommentAutoGenerated" : true + }, "Applies the notch-style parallax effect to the lock screen media widget album art." : { "localizations" : { "pt-BR" : { @@ -4277,6 +4293,10 @@ } } }, + "Bar" : { + "comment" : "Localized name for the \"Bar\" timer progress style.", + "isCommentAutoGenerated" : true + }, "Battery" : { "localizations" : { "ar" : { @@ -8546,6 +8566,10 @@ } } }, + "Digital" : { + "comment" : "The name of the \"Digital\" style of reminder presentation.", + "isCommentAutoGenerated" : true + }, "Dimensions" : { "localizations" : { "pt-BR" : { @@ -11944,6 +11968,10 @@ } } }, + "Green" : { + "comment" : "Display name for the \"Green\" caps lock indicator tint mode.", + "isCommentAutoGenerated" : true + }, "Height:" : { "extractionState" : "stale", "localizations" : { @@ -15624,7 +15652,6 @@ } }, "Minutes" : { - "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -17838,6 +17865,10 @@ } } }, + "Now Playing" : { + "comment" : "Name of the \"Now Playing\" media controller type.", + "isCommentAutoGenerated" : true + }, "Number of lines kept in the scrollback buffer. Higher values use more memory." : { }, @@ -18213,6 +18244,10 @@ } } }, + "Panel" : { + "comment" : "Display name for the \"Panel\" clipboard display mode.", + "isCommentAutoGenerated" : true + }, "Panel mode shows clipboard in a floating window near the notch." : { "comment" : "A description of the \"Panel\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -19827,6 +19862,10 @@ } } }, + "Ring" : { + "comment" : "Localized name for the \"Ring\" timer progress style.", + "isCommentAutoGenerated" : true + }, "Rotation:" : { "extractionState" : "stale", "localizations" : { @@ -20653,6 +20692,10 @@ } } }, + "Separate Tab" : { + "comment" : "Name of the \"Separate Tab\" clipboard display mode.", + "isCommentAutoGenerated" : true + }, "Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : { "comment" : "A description of the \"Separate Tab\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -21714,6 +21757,14 @@ } } }, + "Shows screen assistant as a dropdown attached to the AI button" : { + "comment" : "Description of the \"Popover\" screen assistant display mode.", + "isCommentAutoGenerated" : true + }, + "Shows screen assistant in a floating panel near the notch" : { + "comment" : "Description of the \"Panel\" screen assistant display mode.", + "isCommentAutoGenerated" : true + }, "Shows the model's reasoning process before the final answer" : { "localizations" : { "fr" : { @@ -21776,6 +21827,10 @@ } } }, + "Skip forward or backward by ten seconds" : { + "comment" : "Description of the \"Skip forward or backward by ten seconds\" music skip behavior option.", + "isCommentAutoGenerated" : true + }, "Slider color" : { "localizations" : { "ar" : { @@ -22344,6 +22399,10 @@ } } }, + "Spotify" : { + "comment" : "Additional media player option.", + "isCommentAutoGenerated" : true + }, "Square" : { "localizations" : { "ar" : { @@ -22456,6 +22515,10 @@ }, "Standard notch media controls are hidden. Re-enable the toggle above to restore them." : { + }, + "Standard previous/next track controls" : { + "comment" : "Description of a music skip behavior option that uses standard previous/next track controls.", + "isCommentAutoGenerated" : true }, "Start" : { "localizations" : { @@ -23817,6 +23880,10 @@ }, "shouldTranslate" : false }, + "Track Skip" : { + "comment" : "Display name for the \"Track Skip\" music skip behavior option.", + "isCommentAutoGenerated" : true + }, "Transform" : { "extractionState" : "stale", "localizations" : { @@ -25738,6 +25805,10 @@ "comment" : "A footer note explaining the app's funding.", "isCommentAutoGenerated" : true }, + "Youtube Music" : { + "comment" : "Name for the \"Youtube Music\" media controller type in settings.", + "isCommentAutoGenerated" : true + }, "YouTube Music requires this third-party app to be installed: " : { "localizations" : { "ar" : { From 0934e75d70dcf0980cc1d8ee569546a01485a795 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 6 Mar 2026 16:18:08 +0530 Subject: [PATCH 24/26] Change BETA stage --- DynamicIsland/Localizable.xcstrings | 73 +------------------ .../components/Settings/SettingsView.swift | 6 +- 2 files changed, 4 insertions(+), 75 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index ecef966..f47f5f4 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1607,10 +1607,6 @@ } } }, - "±10 Seconds" : { - "comment" : "Display name for the \"±10 Seconds\" music skip behavior option.", - "isCommentAutoGenerated" : true - }, "0" : { "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", "isCommentAutoGenerated" : true, @@ -1927,10 +1923,6 @@ } } }, - "Accent" : { - "comment" : "Display name for the \"Accent\" caps lock indicator tint mode.", - "isCommentAutoGenerated" : true - }, "Accent color" : { "localizations" : { "ar" : { @@ -2276,10 +2268,6 @@ } } }, - "Adaptive gradient" : { - "comment" : "Name of the \"Adaptive gradient\" timer icon color mode.", - "isCommentAutoGenerated" : true - }, "Add" : { "localizations" : { "ar" : { @@ -3875,10 +3863,6 @@ } } }, - "Apple Music" : { - "comment" : "Localized name for the \"Now Playing\" media controller type.", - "isCommentAutoGenerated" : true - }, "Applies the notch-style parallax effect to the lock screen media widget album art." : { "localizations" : { "pt-BR" : { @@ -4293,10 +4277,6 @@ } } }, - "Bar" : { - "comment" : "Localized name for the \"Bar\" timer progress style.", - "isCommentAutoGenerated" : true - }, "Battery" : { "localizations" : { "ar" : { @@ -8566,10 +8546,6 @@ } } }, - "Digital" : { - "comment" : "The name of the \"Digital\" style of reminder presentation.", - "isCommentAutoGenerated" : true - }, "Dimensions" : { "localizations" : { "pt-BR" : { @@ -11968,10 +11944,6 @@ } } }, - "Green" : { - "comment" : "Display name for the \"Green\" caps lock indicator tint mode.", - "isCommentAutoGenerated" : true - }, "Height:" : { "extractionState" : "stale", "localizations" : { @@ -15652,6 +15624,7 @@ } }, "Minutes" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -17865,10 +17838,6 @@ } } }, - "Now Playing" : { - "comment" : "Name of the \"Now Playing\" media controller type.", - "isCommentAutoGenerated" : true - }, "Number of lines kept in the scrollback buffer. Higher values use more memory." : { }, @@ -18244,10 +18213,6 @@ } } }, - "Panel" : { - "comment" : "Display name for the \"Panel\" clipboard display mode.", - "isCommentAutoGenerated" : true - }, "Panel mode shows clipboard in a floating window near the notch." : { "comment" : "A description of the \"Panel\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -19862,10 +19827,6 @@ } } }, - "Ring" : { - "comment" : "Localized name for the \"Ring\" timer progress style.", - "isCommentAutoGenerated" : true - }, "Rotation:" : { "extractionState" : "stale", "localizations" : { @@ -20692,10 +20653,6 @@ } } }, - "Separate Tab" : { - "comment" : "Name of the \"Separate Tab\" clipboard display mode.", - "isCommentAutoGenerated" : true - }, "Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : { "comment" : "A description of the \"Separate Tab\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -21757,14 +21714,6 @@ } } }, - "Shows screen assistant as a dropdown attached to the AI button" : { - "comment" : "Description of the \"Popover\" screen assistant display mode.", - "isCommentAutoGenerated" : true - }, - "Shows screen assistant in a floating panel near the notch" : { - "comment" : "Description of the \"Panel\" screen assistant display mode.", - "isCommentAutoGenerated" : true - }, "Shows the model's reasoning process before the final answer" : { "localizations" : { "fr" : { @@ -21827,10 +21776,6 @@ } } }, - "Skip forward or backward by ten seconds" : { - "comment" : "Description of the \"Skip forward or backward by ten seconds\" music skip behavior option.", - "isCommentAutoGenerated" : true - }, "Slider color" : { "localizations" : { "ar" : { @@ -22399,10 +22344,6 @@ } } }, - "Spotify" : { - "comment" : "Additional media player option.", - "isCommentAutoGenerated" : true - }, "Square" : { "localizations" : { "ar" : { @@ -22515,10 +22456,6 @@ }, "Standard notch media controls are hidden. Re-enable the toggle above to restore them." : { - }, - "Standard previous/next track controls" : { - "comment" : "Description of a music skip behavior option that uses standard previous/next track controls.", - "isCommentAutoGenerated" : true }, "Start" : { "localizations" : { @@ -23880,10 +23817,6 @@ }, "shouldTranslate" : false }, - "Track Skip" : { - "comment" : "Display name for the \"Track Skip\" music skip behavior option.", - "isCommentAutoGenerated" : true - }, "Transform" : { "extractionState" : "stale", "localizations" : { @@ -25805,10 +25738,6 @@ "comment" : "A footer note explaining the app's funding.", "isCommentAutoGenerated" : true }, - "Youtube Music" : { - "comment" : "Name for the \"Youtube Music\" media controller type in settings.", - "isCommentAutoGenerated" : true - }, "YouTube Music requires this third-party app to be installed: " : { "localizations" : { "ar" : { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 685388d..2de48a5 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -436,7 +436,7 @@ struct SettingsView: View { HStack(spacing: 10) { sidebarIcon(for: tab) Text(tab.title) - if tab == .downloads || tab == .hudAndOSD { + if tab == .downloads { Spacer() Text("BETA") .font(.system(size: 9, weight: .bold)) @@ -449,14 +449,14 @@ struct SettingsView: View { ) } else if tab == .extensions { Spacer() - Text("ALPHA") + Text("BETA") .font(.system(size: 9, weight: .bold)) .foregroundStyle(.white) .padding(.horizontal, 6) .padding(.vertical, 2) .background( Capsule() - .fill(Color.orange) + .fill(Color.blue) ) } } From 59b72cafd0f33949c58587ccf6d8a18bef0d1633 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 6 Mar 2026 19:45:10 +0530 Subject: [PATCH 25/26] Release prep --- DynamicIsland/Localizable.xcstrings | 73 ++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index f47f5f4..f1e2a2d 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1607,6 +1607,10 @@ } } }, + "±10 Seconds" : { + "comment" : "Display name for the \"±10 Seconds\" music skip behavior option.", + "isCommentAutoGenerated" : true + }, "0" : { "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", "isCommentAutoGenerated" : true, @@ -1923,6 +1927,10 @@ } } }, + "Accent" : { + "comment" : "Display name for the \"Accent\" caps lock indicator tint mode.", + "isCommentAutoGenerated" : true + }, "Accent color" : { "localizations" : { "ar" : { @@ -2268,6 +2276,10 @@ } } }, + "Adaptive gradient" : { + "comment" : "Name of the timer icon color mode option that uses an adaptive gradient.", + "isCommentAutoGenerated" : true + }, "Add" : { "localizations" : { "ar" : { @@ -3863,6 +3875,10 @@ } } }, + "Apple Music" : { + "comment" : "Names of the different music streaming services.", + "isCommentAutoGenerated" : true + }, "Applies the notch-style parallax effect to the lock screen media widget album art." : { "localizations" : { "pt-BR" : { @@ -4277,6 +4293,10 @@ } } }, + "Bar" : { + "comment" : "Localized name for the \"Bar\" timer progress style.", + "isCommentAutoGenerated" : true + }, "Battery" : { "localizations" : { "ar" : { @@ -8546,6 +8566,10 @@ } } }, + "Digital" : { + "comment" : "The display name for the \"Digital\" reminder presentation style.", + "isCommentAutoGenerated" : true + }, "Dimensions" : { "localizations" : { "pt-BR" : { @@ -11944,6 +11968,10 @@ } } }, + "Green" : { + "comment" : "Display name for the \"Green\" Caps Lock Indicator Tint Mode.", + "isCommentAutoGenerated" : true + }, "Height:" : { "extractionState" : "stale", "localizations" : { @@ -15624,7 +15652,6 @@ } }, "Minutes" : { - "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -17838,6 +17865,10 @@ } } }, + "Now Playing" : { + "comment" : "Localized name for each media controller type.", + "isCommentAutoGenerated" : true + }, "Number of lines kept in the scrollback buffer. Higher values use more memory." : { }, @@ -18213,6 +18244,10 @@ } } }, + "Panel" : { + "comment" : "Display name for the \"Panel\" clipboard display mode.", + "isCommentAutoGenerated" : true + }, "Panel mode shows clipboard in a floating window near the notch." : { "comment" : "A description of the \"Panel\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -19827,6 +19862,10 @@ } } }, + "Ring" : { + "comment" : "Localized name for the \"Ring\" timer progress style.", + "isCommentAutoGenerated" : true + }, "Rotation:" : { "extractionState" : "stale", "localizations" : { @@ -20653,6 +20692,10 @@ } } }, + "Separate Tab" : { + "comment" : "Name of the clipboard display mode option that opens the clipboard in a separate tab.", + "isCommentAutoGenerated" : true + }, "Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : { "comment" : "A description of the \"Separate Tab\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -21714,6 +21757,14 @@ } } }, + "Shows screen assistant as a dropdown attached to the AI button" : { + "comment" : "Description of the \"popover\" display mode for the screen assistant.", + "isCommentAutoGenerated" : true + }, + "Shows screen assistant in a floating panel near the notch" : { + "comment" : "Description of the \"panel\" display mode for the screen assistant.", + "isCommentAutoGenerated" : true + }, "Shows the model's reasoning process before the final answer" : { "localizations" : { "fr" : { @@ -21776,6 +21827,10 @@ } } }, + "Skip forward or backward by ten seconds" : { + "comment" : "Description of the \"Skip forward or backward by ten seconds\" music skip behavior option.", + "isCommentAutoGenerated" : true + }, "Slider color" : { "localizations" : { "ar" : { @@ -22344,6 +22399,10 @@ } } }, + "Spotify" : { + "comment" : "Name of a media controller type for Spotify.", + "isCommentAutoGenerated" : true + }, "Square" : { "localizations" : { "ar" : { @@ -22456,6 +22515,10 @@ }, "Standard notch media controls are hidden. Re-enable the toggle above to restore them." : { + }, + "Standard previous/next track controls" : { + "comment" : "Description of a music skip behavior option that uses standard previous/next track controls.", + "isCommentAutoGenerated" : true }, "Start" : { "localizations" : { @@ -23817,6 +23880,10 @@ }, "shouldTranslate" : false }, + "Track Skip" : { + "comment" : "Display name for the \"Track Skip\" music skip behavior.", + "isCommentAutoGenerated" : true + }, "Transform" : { "extractionState" : "stale", "localizations" : { @@ -25738,6 +25805,10 @@ "comment" : "A footer note explaining the app's funding.", "isCommentAutoGenerated" : true }, + "Youtube Music" : { + "comment" : "Name for the \"Youtube Music\" media controller type in settings.", + "isCommentAutoGenerated" : true + }, "YouTube Music requires this third-party app to be installed: " : { "localizations" : { "ar" : { From 1b651f2f093e27416c295ead1c634157f799a8b1 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 6 Mar 2026 20:33:57 +0530 Subject: [PATCH 26/26] Update Appcast --- Updates/appcast.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Updates/appcast.xml b/Updates/appcast.xml index d78629c..c79bea4 100644 --- a/Updates/appcast.xml +++ b/Updates/appcast.xml @@ -2,6 +2,17 @@ Atoll + + 2.0.0 + Fri, 06 Mar 2026 20:16:36 +0530 + 7 + 2.0.0 + 14.6 + + + + + 1.3.0-beta Sat, 31 Jan 2026 23:54:50 +0530