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 {