diff --git a/Core-Monitor.xcodeproj/project.pbxproj b/Core-Monitor.xcodeproj/project.pbxproj index dfc5696d..93249bfb 100644 --- a/Core-Monitor.xcodeproj/project.pbxproj +++ b/Core-Monitor.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ DABD00042F95000100000004 /* HardwareRescueDiagnosticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABD00022F95000100000002 /* HardwareRescueDiagnosticsTests.swift */; }; DABD10022F96000100000002 /* CoreMonitorShareKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABD10012F96000100000001 /* CoreMonitorShareKitTests.swift */; }; DABD20022F97000100000002 /* SMCTemperatureSensorCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABD20012F97000100000001 /* SMCTemperatureSensorCatalogTests.swift */; }; + DABD30022F98000100000002 /* WeatherViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABD30012F98000100000001 /* WeatherViewModelTests.swift */; }; + DABD30042F98000100000004 /* TouchBarCustomizationSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABD30032F98000100000003 /* TouchBarCustomizationSettingsTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -55,6 +57,8 @@ DABD00022F95000100000002 /* HardwareRescueDiagnosticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareRescueDiagnosticsTests.swift; sourceTree = ""; }; DABD10012F96000100000001 /* CoreMonitorShareKitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreMonitorShareKitTests.swift; sourceTree = ""; }; DABD20012F97000100000001 /* SMCTemperatureSensorCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMCTemperatureSensorCatalogTests.swift; sourceTree = ""; }; + DABD30012F98000100000001 /* WeatherViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherViewModelTests.swift; sourceTree = ""; }; + DABD30032F98000100000003 /* TouchBarCustomizationSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchBarCustomizationSettingsTests.swift; sourceTree = ""; }; E5B12BED2CCB8BBEC03C227F /* Core-MonitorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Core-MonitorTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -106,6 +110,8 @@ DABD00012F95000100000001 /* DashboardProcessSamplingPolicyTests.swift */, DABD00022F95000100000002 /* HardwareRescueDiagnosticsTests.swift */, DABD20012F97000100000001 /* SMCTemperatureSensorCatalogTests.swift */, + DABD30012F98000100000001 /* WeatherViewModelTests.swift */, + DABD30032F98000100000003 /* TouchBarCustomizationSettingsTests.swift */, ); path = "Core-MonitorTests"; sourceTree = ""; @@ -314,6 +320,8 @@ DABD00032F95000100000003 /* DashboardProcessSamplingPolicyTests.swift in Sources */, DABD00042F95000100000004 /* HardwareRescueDiagnosticsTests.swift in Sources */, DABD20022F97000100000002 /* SMCTemperatureSensorCatalogTests.swift in Sources */, + DABD30022F98000100000002 /* WeatherViewModelTests.swift in Sources */, + DABD30042F98000100000004 /* TouchBarCustomizationSettingsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Core-Monitor/CoreMonTouchBarController.swift b/Core-Monitor/CoreMonTouchBarController.swift index b71ce52d..f515ee29 100644 --- a/Core-Monitor/CoreMonTouchBarController.swift +++ b/Core-Monitor/CoreMonTouchBarController.swift @@ -165,7 +165,9 @@ final class CoreMonTouchBarController: NSObject { } private func updateWeatherMonitoring() { - let shouldRunWeather = isStarted && configuredItems.values.contains(where: { $0.builtInKind == .weather }) + let shouldRunWeather = isStarted + && customizationSettings.weatherEnabled + && configuredItems.values.contains(where: { $0.builtInKind == .weather }) guard shouldRunWeather != isWeatherRunning else { return } isWeatherRunning = shouldRunWeather diff --git a/Core-Monitor/NowPlayingTouchBarView.swift b/Core-Monitor/NowPlayingTouchBarView.swift deleted file mode 100644 index dc9a187e..00000000 --- a/Core-Monitor/NowPlayingTouchBarView.swift +++ /dev/null @@ -1,168 +0,0 @@ -import AppKit -import MediaPlayer - -final class NowPlayingTouchBarView: NSView, TouchBarThemable { - var theme: TouchBarTheme = .dark { - didSet { applyTheme() } - } - - private let artworkView = NSImageView(frame: .zero) - private let titleLabel = NSTextField(labelWithString: "Now Playing") - - private var refreshTimer: Timer? - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - setup() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setup() - } - - deinit { - NotificationCenter.default.removeObserver(self) - refreshTimer?.invalidate() - refreshTimer = nil - } - - private func setup() { - wantsLayer = false - - artworkView.wantsLayer = true - artworkView.layer?.cornerRadius = 6 - artworkView.layer?.masksToBounds = true - artworkView.imageScaling = .scaleProportionallyUpOrDown - artworkView.image = placeholderArtwork() - - titleLabel.font = NSFont.systemFont(ofSize: 12, weight: .semibold) - titleLabel.lineBreakMode = .byTruncatingTail - titleLabel.maximumNumberOfLines = 1 - - addSubview(artworkView) - addSubview(titleLabel) - - applyTheme() - updateFromNowPlaying() - - refreshTimer = Timer.scheduledTimer(withTimeInterval: TB.refreshInterval, repeats: true) { [weak self] _ in - self?.updateFromNowPlaying() - } - if let refreshTimer { - refreshTimer.tolerance = TB.refreshInterval * 0.2 - RunLoop.main.add(refreshTimer, forMode: .common) - } - - } - - private func updateFromNowPlaying() { - let info = currentNowPlayingInfo() - - let title = clean(info[MPMediaItemPropertyTitle] as? String) - let artist = clean(info[MPMediaItemPropertyArtist] as? String) - - titleLabel.stringValue = title ?? "Now Playing" - titleLabel.toolTip = artist - - if let artwork = info[MPMediaItemPropertyArtwork] as? MPMediaItemArtwork { - artworkView.image = artwork.image(at: NSSize(width: 28, height: 28)) - } else { - artworkView.image = placeholderArtwork() - } - - needsLayout = true - needsDisplay = true - } - - private func currentNowPlayingInfo() -> [String: Any] { - if let info = MPNowPlayingInfoCenter.default().nowPlayingInfo, - info[MPMediaItemPropertyTitle] != nil || info[MPMediaItemPropertyArtwork] != nil { - return info - } - - return musicAppNowPlayingInfo() - } - - private func musicAppNowPlayingInfo() -> [String: Any] { - let script = """ - tell application "Music" - if it is running then - if player state is playing or player state is paused then - set trackName to name of current track - set artistName to artist of current track - return trackName & linefeed & artistName - end if - end if - end tell - return "" - """ - - guard let appleScript = NSAppleScript(source: script) else { - return [:] - } - - var error: NSDictionary? - guard let output = appleScript.executeAndReturnError(&error).stringValue, - !output.isEmpty else { - return [:] - } - - let parts = output.components(separatedBy: .newlines) - let title = parts.first.flatMap(clean) - let artist = parts.dropFirst().first.flatMap(clean) - - var info: [String: Any] = [:] - if let title { info[MPMediaItemPropertyTitle] = title } - if let artist { info[MPMediaItemPropertyArtist] = artist } - return info - } - - private func clean(_ value: String?) -> String? { - guard let value else { return nil } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private func placeholderArtwork() -> NSImage? { - let size = NSSize(width: 22, height: 22) - let image = NSImage(size: size) - image.lockFocus() - - NSColor(calibratedWhite: 0.90, alpha: 1).setFill() - NSBezierPath(roundedRect: NSRect(origin: .zero, size: size), xRadius: 8, yRadius: 8).fill() - - let symbol = NSImage(systemSymbolName: "music.note", accessibilityDescription: nil)? - .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold)) - symbol?.isTemplate = true - symbol?.draw(in: NSRect(x: 6, y: 6, width: 10, height: 10)) - - image.unlockFocus() - image.isTemplate = false - return image - } - - override var intrinsicContentSize: NSSize { - NSSize(width: 150, height: TB.stripH) - } - - override func layout() { - super.layout() - - let artSize = NSSize(width: 22, height: 22) - let artX: CGFloat = 4 - let artY = floor((bounds.height - artSize.height) / 2) - artworkView.frame = NSRect(x: artX, y: artY, width: artSize.width, height: artSize.height) - - let labelX = artworkView.frame.maxX + 10 - let labelY = floor((bounds.height - 14) / 2) + 1 - let availableWidth = max(bounds.width - labelX - 4, 0) - titleLabel.sizeToFit() - let titleWidth = min(titleLabel.frame.width, availableWidth) - titleLabel.frame = NSRect(x: labelX, y: labelY, width: titleWidth, height: 14) - } - - private func applyTheme() { - titleLabel.textColor = theme.primaryTextColor - } -} diff --git a/Core-Monitor/PockWidgetSources/Weather/WeatherWidget.swift b/Core-Monitor/PockWidgetSources/Weather/WeatherWidget.swift index f2e407be..5f2d36a7 100644 --- a/Core-Monitor/PockWidgetSources/Weather/WeatherWidget.swift +++ b/Core-Monitor/PockWidgetSources/Weather/WeatherWidget.swift @@ -49,14 +49,14 @@ final class WeatherWidget: NSView, TouchBarThemable { let tooltip: String switch state { case .idle: - compactTitleLabel.stringValue = "Weather" - compactSubtitleLabel.stringValue = "Waiting for location" - expandedTitleLabel.stringValue = "Weather" - expandedSubtitleLabel.stringValue = "Waiting for location" + compactTitleLabel.stringValue = "Weather Off" + compactSubtitleLabel.stringValue = "Enable in Settings" + expandedTitleLabel.stringValue = "Weather Off" + expandedSubtitleLabel.stringValue = "Enable Live Weather in Settings" detailLabel.stringValue = "" compactIconView.image = defaultImage() expandedIconView.image = defaultImage() - tooltip = "Waiting for location" + tooltip = "Enable Live Weather in Settings" case .loading: compactTitleLabel.stringValue = "Weather" compactSubtitleLabel.stringValue = "Updating weather" diff --git a/Core-Monitor/SettingsWindow.swift b/Core-Monitor/SettingsWindow.swift index edf3b77f..4333bc96 100644 --- a/Core-Monitor/SettingsWindow.swift +++ b/Core-Monitor/SettingsWindow.swift @@ -268,6 +268,14 @@ private struct TouchBarSettingsTab: View { } } + Section { + Toggle("Enable Live Weather", isOn: weatherBinding) + } header: { + Text("Live Weather") + } footer: { + Text("Off by default. Enabling this adds the Weather widget, requests location access, and contacts Apple WeatherKit and CoreLocation. Core-Monitor does not receive this data.") + } + Section("Presets") { ForEach(TouchBarPreset.all) { preset in HStack { @@ -331,7 +339,7 @@ private struct TouchBarSettingsTab: View { } Section("Built-In Widgets") { - ForEach(TouchBarWidgetKind.allCases) { kind in + ForEach(TouchBarWidgetKind.allCases.filter { $0 != .weather }) { kind in Toggle(kind.title, isOn: widgetBinding(kind)) } } @@ -355,6 +363,14 @@ private struct TouchBarSettingsTab: View { } } + private var weatherBinding: Binding { + Binding { + settings.weatherEnabled + } set: { enabled in + settings.setWeatherEnabled(enabled) + } + } + private func widgetBinding(_ kind: TouchBarWidgetKind) -> Binding { Binding { settings.contains(kind) diff --git a/Core-Monitor/TouchBarCustomizationCompatibility.swift b/Core-Monitor/TouchBarCustomizationCompatibility.swift index 66f95aa6..0f14ab85 100644 --- a/Core-Monitor/TouchBarCustomizationCompatibility.swift +++ b/Core-Monitor/TouchBarCustomizationCompatibility.swift @@ -245,17 +245,17 @@ struct TouchBarPreset: Identifiable, Equatable { static let classic = TouchBarPreset( id: "classic", title: "Classic", - subtitle: "Status, weather, controls, dock, and CPU", + subtitle: "Status, controls, dock, and CPU", theme: .dark, - items: [.builtIn(.worldClocks), .builtIn(.weather), .builtIn(.controlCenter), .builtIn(.dock), .builtIn(.cpu)] + items: [.builtIn(.worldClocks), .builtIn(.controlCenter), .builtIn(.dock), .builtIn(.cpu)] ) static let detailed = TouchBarPreset( id: "detailed", title: "Detailed", - subtitle: "Status with weather and expanded stats", + subtitle: "Status with controls and expanded stats", theme: .light, - items: [.builtIn(.worldClocks), .builtIn(.weather), .builtIn(.controlCenter), .builtIn(.detailedStats)] + items: [.builtIn(.worldClocks), .builtIn(.controlCenter), .builtIn(.detailedStats)] ) static let fullStrip = TouchBarPreset( @@ -265,7 +265,6 @@ struct TouchBarPreset: Identifiable, Equatable { theme: .dark, items: [ .builtIn(.worldClocks), - .builtIn(.weather), .builtIn(.controlCenter), .builtIn(.dock), .builtIn(.cpu), @@ -280,15 +279,22 @@ struct TouchBarPreset: Identifiable, Equatable { static let compact = TouchBarPreset( id: "compact", title: "Compact", - subtitle: "Weather, CPU, network, and memory pressure", + subtitle: "CPU, network, and memory pressure", theme: .dark, - items: [.builtIn(.weather), .builtIn(.cpu), .builtIn(.network), .builtIn(.ramPressure)] + items: [.builtIn(.cpu), .builtIn(.network), .builtIn(.ramPressure)] ) static let all: [TouchBarPreset] = [.classic, .detailed, .fullStrip, .compact] } -private struct PersistedTouchBarConfigurationV6: Codable { +private struct PersistedTouchBarConfigurationV7: Codable { + var theme: StoredTouchBarTheme + var items: [TouchBarItemConfiguration] + var presentationMode: TouchBarPresentationMode + var weatherEnabled: Bool +} + +private struct LegacyPersistedTouchBarConfigurationV6: Codable { var theme: StoredTouchBarTheme var items: [TouchBarItemConfiguration] var presentationMode: TouchBarPresentationMode @@ -339,6 +345,13 @@ final class TouchBarCustomizationSettings: ObservableObject { } } + @Published private(set) var weatherEnabled: Bool { + didSet { + guard isApplyingConfiguration == false else { return } + persistAndNotify() + } + } + var estimatedWidth: CGFloat { let gaps = max(CGFloat(items.count - 1), 0) * TB.groupGap return items.reduce(0) { $0 + $1.estimatedWidth } + gaps @@ -354,8 +367,9 @@ final class TouchBarCustomizationSettings: ObservableObject { } } - private let defaultsKey = "coremonitor.touchBarConfiguration.v6" - private let legacyDefaultsKey = "coremonitor.touchBarConfiguration.v5" + private let defaultsKey = "coremonitor.touchBarConfiguration.v7" + private let legacyDefaultsKey = "coremonitor.touchBarConfiguration.v6" + private let olderLegacyDefaultsKey = "coremonitor.touchBarConfiguration.v5" private let legacyWidgetOnlyDefaultsKey = "coremonitor.touchBarConfiguration.v4" private let legacyPresentationModeKey = "coremonitor.touchBarMode" private let defaults: UserDefaults @@ -368,18 +382,31 @@ final class TouchBarCustomizationSettings: ObservableObject { ) ?? .app if let data = defaults.data(forKey: defaultsKey), - let decoded = try? JSONDecoder().decode(PersistedTouchBarConfigurationV6.self, from: data) { + let decoded = try? JSONDecoder().decode(PersistedTouchBarConfigurationV7.self, from: data) { + let normalizedItems = Self.normalizedItems(decoded.items) theme = decoded.theme.theme - items = Self.normalizedItems(decoded.items) + items = normalizedItems presentationMode = decoded.presentationMode + weatherEnabled = decoded.weatherEnabled + && normalizedItems.contains(where: { $0.builtInKind == .weather }) return } if let data = defaults.data(forKey: legacyDefaultsKey), + let decoded = try? JSONDecoder().decode(LegacyPersistedTouchBarConfigurationV6.self, from: data) { + theme = decoded.theme.theme + items = Self.normalizedItems(decoded.items) + presentationMode = decoded.presentationMode + weatherEnabled = false + return + } + + if let data = defaults.data(forKey: olderLegacyDefaultsKey), let decoded = try? JSONDecoder().decode(LegacyPersistedTouchBarConfigurationV5.self, from: data) { theme = decoded.theme.theme items = Self.normalizedItems(decoded.items) presentationMode = fallbackPresentation + weatherEnabled = false return } @@ -388,39 +415,54 @@ final class TouchBarCustomizationSettings: ObservableObject { theme = decoded.theme.theme items = Self.normalizedItems(decoded.widgets.map(TouchBarItemConfiguration.builtIn)) presentationMode = fallbackPresentation + weatherEnabled = false return } theme = Self.defaultPreset.theme items = Self.defaultPreset.items presentationMode = fallbackPresentation + weatherEnabled = false } func applyPreset(_ preset: TouchBarPreset) { - applyConfiguration(theme: preset.theme, items: preset.items) + applyConfiguration(theme: preset.theme, items: preset.items, weatherEnabled: false) } func restoreDefaults() { applyConfiguration( theme: Self.defaultPreset.theme, items: Self.defaultPreset.items, - presentationMode: .app + presentationMode: .app, + weatherEnabled: false ) } + func setWeatherEnabled(_ enabled: Bool) { + var updatedItems = items + if enabled && contains(.weather) == false { + updatedItems.append(.builtIn(.weather)) + } + applyConfiguration(items: updatedItems, weatherEnabled: enabled) + } + func contains(_ kind: TouchBarWidgetKind) -> Bool { items.contains(where: { $0.builtInKind == kind }) } func toggle(_ kind: TouchBarWidgetKind) { var updatedItems = items + var updatedWeatherEnabled: Bool? if let index = items.firstIndex(where: { $0.builtInKind == kind }) { guard updatedItems.count > 1 else { return } updatedItems.remove(at: index) + if kind == .weather { + updatedWeatherEnabled = false + } } else { updatedItems.append(.builtIn(kind)) } - applyConfiguration(items: updatedItems) + applyConfiguration(items: updatedItems, weatherEnabled: updatedWeatherEnabled) } func moveUp(_ item: TouchBarItemConfiguration) { @@ -441,7 +483,10 @@ final class TouchBarCustomizationSettings: ObservableObject { guard let index = items.firstIndex(of: item), items.count > 1 else { return } var updatedItems = items updatedItems.remove(at: index) - applyConfiguration(items: updatedItems) + applyConfiguration( + items: updatedItems, + weatherEnabled: item.builtInKind == .weather ? false : nil + ) } func addPinnedApps(urls: [URL]) { @@ -502,10 +547,11 @@ final class TouchBarCustomizationSettings: ObservableObject { } private func persistAndNotify() { - let payload = PersistedTouchBarConfigurationV6( + let payload = PersistedTouchBarConfigurationV7( theme: StoredTouchBarTheme(theme: theme), items: items, - presentationMode: presentationMode + presentationMode: presentationMode, + weatherEnabled: weatherEnabled ) if let data = try? JSONEncoder().encode(payload) { @@ -518,15 +564,18 @@ final class TouchBarCustomizationSettings: ObservableObject { private func applyConfiguration( theme: TouchBarTheme? = nil, items: [TouchBarItemConfiguration]? = nil, - presentationMode: TouchBarPresentationMode? = nil + presentationMode: TouchBarPresentationMode? = nil, + weatherEnabled: Bool? = nil ) { let resolvedTheme = theme ?? self.theme let resolvedItems = Self.normalizedItems(items ?? self.items) let resolvedPresentationMode = presentationMode ?? self.presentationMode + let resolvedWeatherEnabled = weatherEnabled ?? self.weatherEnabled guard resolvedTheme != self.theme || resolvedItems != self.items - || resolvedPresentationMode != self.presentationMode else { + || resolvedPresentationMode != self.presentationMode + || resolvedWeatherEnabled != self.weatherEnabled else { return } @@ -534,6 +583,7 @@ final class TouchBarCustomizationSettings: ObservableObject { self.theme = resolvedTheme self.items = resolvedItems self.presentationMode = resolvedPresentationMode + self.weatherEnabled = resolvedWeatherEnabled isApplyingConfiguration = false persistAndNotify() } diff --git a/Core-Monitor/TouchBarUtilityWidgets.swift b/Core-Monitor/TouchBarUtilityWidgets.swift index 2e7bc5e7..fe916ca2 100644 --- a/Core-Monitor/TouchBarUtilityWidgets.swift +++ b/Core-Monitor/TouchBarUtilityWidgets.swift @@ -688,7 +688,7 @@ final class DockTouchBarWidget: NSStackView, TouchBarThemable { name: app.localizedName ?? "App", bundleIdentifier: app.bundleIdentifier, url: nil, - icon: app.icon ?? NSWorkspace.shared.icon(forFile: "/System/Applications/App Store.app"), + icon: app.icon ?? fallbackApplicationIcon(), isRunning: true ) } @@ -705,10 +705,16 @@ final class DockTouchBarWidget: NSStackView, TouchBarThemable { let bundleIdentifier = tileData["bundle-identifier"] as? String let fileURLString = (tileData["file-data"] as? [String: Any])?["_CFURLString"] as? String let url = fileURLString.flatMap { URL(string: $0) } - let icon = url.map { NSWorkspace.shared.icon(forFile: $0.path) } ?? NSWorkspace.shared.icon(forFile: "/System/Applications/App Store.app") + let icon = url.map { NSWorkspace.shared.icon(forFile: $0.path) } ?? fallbackApplicationIcon() return DockTouchBarItem(index: index + 100, name: label, bundleIdentifier: bundleIdentifier, url: url, icon: icon, isRunning: false) } } + + private func fallbackApplicationIcon() -> NSImage { + let image = NSImage(systemSymbolName: "app.fill", accessibilityDescription: "Application") ?? NSImage() + image.isTemplate = true + return image + } } private struct DockTouchBarItem { diff --git a/Core-Monitor/WeatherService.swift b/Core-Monitor/WeatherService.swift index 79ca8c15..5020df1f 100644 --- a/Core-Monitor/WeatherService.swift +++ b/Core-Monitor/WeatherService.swift @@ -236,11 +236,8 @@ final class WeatherLocationAccessController: NSObject, ObservableObject, CLLocat @available(macOS 13.0, *) @MainActor final class LiveWeatherService: WeatherProviding { - - private let service = WeatherService() - nonisolated func currentWeather(for location: CLLocation) async throws -> WeatherSnapshot { - let weather = try await service.weather(for: location) + let weather = try await WeatherService.shared.weather(for: location) let current = weather.currentWeather let locationName = await Self.locationName(for: location) let nextRainSummary = Self.nextRainSummary(from: weather) @@ -379,15 +376,13 @@ final class WeatherViewModel: ObservableObject { private var isRunning = false private var lastSnapshot: WeatherSnapshot? - private static let fallbackLocation = CLLocation(latitude: 37.3346, longitude: -122.0090) - /// Refresh interval in seconds (default 10 min) var refreshInterval: TimeInterval = 600 init(provider: WeatherProviding) { self.provider = provider self.locationAccess = WeatherLocationAccessController.shared - self.fallbackProvider = provider is MockWeatherService ? nil : MockWeatherService() + self.fallbackProvider = nil self.weatherCapabilityEnabled = WeatherKitCapability.isEnabled bindLocationAccess() } @@ -409,6 +404,9 @@ final class WeatherViewModel: ObservableObject { guard !isRunning else { return } isRunning = true locationAccess.refreshStatus() + if weatherCapabilityEnabled(), locationAccess.authorizationStatus == .notDetermined { + locationAccess.requestAccess() + } scheduleRefresh() } @@ -447,7 +445,7 @@ final class WeatherViewModel: ObservableObject { func refreshNow() async { guard weatherCapabilityEnabled() else { - await refreshFallbackWeather() + state = .error("WeatherKit is unavailable in this build.") return } @@ -460,13 +458,21 @@ final class WeatherViewModel: ObservableObject { case .authorizedAlways, .authorizedWhenInUse: if let currentLocation = locationAccess.currentLocation { location = currentLocation + } else if let currentLocation = await locationAccess.requestCurrentLocation() { + location = currentLocation } else { - location = await locationAccess.requestCurrentLocation() ?? Self.fallbackLocation + state = .error("Current location is unavailable. Try again after macOS resolves your location.") + return } - case .notDetermined, .denied, .restricted: - location = Self.fallbackLocation + case .notDetermined: + state = .error("Allow location access to load live weather.") + return + case .denied, .restricted: + state = .error("Enable location access in System Settings to load live weather.") + return @unknown default: - location = Self.fallbackLocation + state = .error("Location access is unavailable.") + return } state = .loading @@ -482,46 +488,13 @@ final class WeatherViewModel: ObservableObject { if let fallbackProvider, let fallbackSnapshot = try? await fetchWeatherSnapshot( using: fallbackProvider, - location: Self.fallbackLocation + location: location ) { applyLoadedSnapshot(fallbackSnapshot) return } - state = .error(errorMessage(for: authorizationStatus, underlyingError: error)) - } - } - - private func errorMessage( - for authorizationStatus: CLAuthorizationStatus, - underlyingError: Error - ) -> String { - switch authorizationStatus { - case .notDetermined: - return "Weather fallback is unavailable right now. Request location from Touch Bar settings for live local weather." - case .denied, .restricted: - return "Weather fallback is unavailable right now. Enable location in System Settings for live local weather." - default: - return underlyingError.localizedDescription - } - } - - private func refreshFallbackWeather() async { - state = .loading - - do { - let snapshot = try await fetchWeatherSnapshot( - using: fallbackProvider ?? provider, - location: Self.fallbackLocation - ) - applyLoadedSnapshot(snapshot) - } catch { - if let lastSnapshot { - state = .loaded(lastSnapshot) - return - } - - state = .error("Weather fallback is unavailable right now.") + state = .error(error.localizedDescription) } } diff --git a/Core-Monitor/WeatherTouchBarView.swift b/Core-Monitor/WeatherTouchBarView.swift index 1792edee..b262a813 100644 --- a/Core-Monitor/WeatherTouchBarView.swift +++ b/Core-Monitor/WeatherTouchBarView.swift @@ -108,7 +108,7 @@ final class WeatherTouchBarView: NSView { private func estimatedWidth() -> CGFloat { switch state { case .idle: - return 82 + return 105 case .loading: return 80 case .loaded(let s): @@ -137,7 +137,7 @@ final class WeatherTouchBarView: NSView { switch state { case .idle: hideAll() - tempLabel.stringValue = "Waiting" + tempLabel.stringValue = "Weather Off" tempLabel.isHidden = false tempLabel.sizeToFit() tempLabel.frame = NSRect( diff --git a/Core-MonitorTests/TouchBarCustomizationSettingsTests.swift b/Core-MonitorTests/TouchBarCustomizationSettingsTests.swift index d27c2961..ccd27aad 100644 --- a/Core-MonitorTests/TouchBarCustomizationSettingsTests.swift +++ b/Core-MonitorTests/TouchBarCustomizationSettingsTests.swift @@ -3,6 +3,54 @@ import XCTest @MainActor final class TouchBarCustomizationSettingsTests: XCTestCase { + func testFreshConfigurationKeepsLiveWeatherOff() { + let settings = makeSettings(suiteName: "TouchBarCustomizationSettingsTests.freshWeather") + + XCTAssertFalse(settings.weatherEnabled) + XCTAssertFalse(settings.contains(.weather)) + } + + func testEnablingLiveWeatherAddsWidgetAndPersistsConsent() { + let suiteName = "TouchBarCustomizationSettingsTests.enableWeather" + guard let defaults = UserDefaults(suiteName: suiteName) else { + return XCTFail("Expected a dedicated defaults suite for Touch Bar tests.") + } + defaults.removePersistentDomain(forName: suiteName) + + let settings = TouchBarCustomizationSettings(defaults: defaults) + settings.setWeatherEnabled(true) + + XCTAssertTrue(settings.weatherEnabled) + XCTAssertTrue(settings.contains(.weather)) + + let restored = TouchBarCustomizationSettings(defaults: defaults) + XCTAssertTrue(restored.weatherEnabled) + XCTAssertTrue(restored.contains(.weather)) + } + + func testLegacyConfigurationDoesNotImplicitlyConsentToLiveWeather() throws { + let suiteName = "TouchBarCustomizationSettingsTests.legacyWeather" + guard let defaults = UserDefaults(suiteName: suiteName) else { + return XCTFail("Expected a dedicated defaults suite for Touch Bar tests.") + } + defaults.removePersistentDomain(forName: suiteName) + + let legacyConfiguration = LegacyTouchBarConfigurationV6( + theme: "dark", + items: [.builtIn(.weather), .builtIn(.cpu)], + presentationMode: .app + ) + defaults.set( + try JSONEncoder().encode(legacyConfiguration), + forKey: "coremonitor.touchBarConfiguration.v6" + ) + + let settings = TouchBarCustomizationSettings(defaults: defaults) + + XCTAssertFalse(settings.weatherEnabled) + XCTAssertTrue(settings.contains(.weather)) + } + func testNormalizedItemsDeduplicatesBuiltInsAndPinnedPaths() { let appPath = "/Applications/Utilities/Terminal.app" let folderPath = "/Applications" @@ -90,8 +138,33 @@ final class TouchBarCustomizationSettingsTests: XCTestCase { } } +private struct LegacyTouchBarConfigurationV6: Encodable { + let theme: String + let items: [TouchBarItemConfiguration] + let presentationMode: TouchBarPresentationMode +} + @MainActor final class CoreMonTouchBarControllerTests: XCTestCase { + func testWeatherMonitoringStaysIdleWithoutExplicitOptIn() async { + let settings = makeSettings(suiteName: "CoreMonTouchBarControllerTests.weatherOptIn") + settings.items = [.builtIn(.weather), .builtIn(.cpu)] + let monitor = SystemMonitor() + let controller = CoreMonTouchBarController( + weatherProvider: MockWeatherService(), + monitor: monitor, + customizationSettings: settings + ) + + controller.start() + try? await Task.sleep(nanoseconds: 50_000_000) + controller.stop() + + guard case .idle = controller.weatherViewModel.state else { + return XCTFail("Weather monitoring must not start without persisted opt-in.") + } + } + func testReloadCustomizationRebuildsTouchBarWithUpdatedIdentifiers() { let settings = makeSettings(suiteName: "CoreMonTouchBarControllerTests.reloadCustomization") settings.items = [.builtIn(.weather), .builtIn(.cpu)] @@ -104,7 +177,10 @@ final class CoreMonTouchBarControllerTests: XCTestCase { let initialTouchBar = controller.touchBar XCTAssertEqual( controller.touchBar.defaultItemIdentifiers, - [.builtIn(.weather).touchBarIdentifier, .builtIn(.cpu).touchBarIdentifier] + [ + TouchBarItemConfiguration.builtIn(.weather).touchBarIdentifier, + TouchBarItemConfiguration.builtIn(.cpu).touchBarIdentifier + ] ) settings.items = [.builtIn(.cpu), .builtIn(.network)] @@ -113,7 +189,10 @@ final class CoreMonTouchBarControllerTests: XCTestCase { XCTAssertFalse(controller.touchBar === initialTouchBar) XCTAssertEqual( controller.touchBar.defaultItemIdentifiers, - [.builtIn(.cpu).touchBarIdentifier, .builtIn(.network).touchBarIdentifier] + [ + TouchBarItemConfiguration.builtIn(.cpu).touchBarIdentifier, + TouchBarItemConfiguration.builtIn(.network).touchBarIdentifier + ] ) } diff --git a/Core-MonitorTests/WeatherViewModelTests.swift b/Core-MonitorTests/WeatherViewModelTests.swift index 14498b53..a7285581 100644 --- a/Core-MonitorTests/WeatherViewModelTests.swift +++ b/Core-MonitorTests/WeatherViewModelTests.swift @@ -5,35 +5,34 @@ import Combine @MainActor final class WeatherViewModelTests: XCTestCase { - func testRefreshNowUsesFallbackLocationWhenAccessIsNotDetermined() async { + func testRefreshNowDoesNotFetchWithoutLocationAuthorization() async { let provider = RecordingWeatherProvider() let locationAccess = MockWeatherLocationAccess(status: .notDetermined, currentLocation: nil) - let viewModel = WeatherViewModel(provider: provider, locationAccess: locationAccess) + let viewModel = WeatherViewModel( + provider: provider, + locationAccess: locationAccess, + weatherCapabilityEnabled: { true } + ) await viewModel.refreshNow() - guard let requestedLocation = provider.requestedLocation else { - return XCTFail("Expected the weather provider to receive a fallback location.") - } - - XCTAssertEqual(requestedLocation.coordinate.latitude, 37.3346, accuracy: 0.0001) - XCTAssertEqual(requestedLocation.coordinate.longitude, -122.0090, accuracy: 0.0001) + XCTAssertNil(provider.requestedLocation) switch viewModel.state { - case .loaded(let snapshot): - XCTAssertEqual(snapshot.locationName, "Recorded") + case .error(let message): + XCTAssertEqual(message, "Allow location access to load live weather.") default: - XCTFail("Expected a loaded fallback weather snapshot.") + XCTFail("Expected weather to remain gated by location authorization.") } XCTAssertEqual(locationAccess.requestAccessCallCount, 0) XCTAssertEqual(locationAccess.requestCurrentLocationCallCount, 0) } - func testRefreshNowUsesFallbackProviderWhenWeatherKitCapabilityIsMissing() async { + func testRefreshNowDoesNotFetchWhenWeatherKitCapabilityIsMissing() async { let provider = RecordingWeatherProvider() let fallbackProvider = RecordingWeatherProvider() - let locationAccess = MockWeatherLocationAccess(status: .authorizedWhenInUse, currentLocation: nil) + let locationAccess = MockWeatherLocationAccess(status: .authorizedAlways, currentLocation: nil) let viewModel = WeatherViewModel( provider: provider, locationAccess: locationAccess, @@ -44,34 +43,32 @@ final class WeatherViewModelTests: XCTestCase { await viewModel.refreshNow() XCTAssertNil(provider.requestedLocation) - - guard let requestedLocation = fallbackProvider.requestedLocation else { - return XCTFail("Expected the fallback weather provider to receive a fallback location.") - } - - XCTAssertEqual(requestedLocation.coordinate.latitude, 37.3346, accuracy: 0.0001) - XCTAssertEqual(requestedLocation.coordinate.longitude, -122.0090, accuracy: 0.0001) + XCTAssertNil(fallbackProvider.requestedLocation) switch viewModel.state { - case .loaded(let snapshot): - XCTAssertEqual(snapshot.locationName, "Recorded") + case .error(let message): + XCTAssertEqual(message, "WeatherKit is unavailable in this build.") default: - XCTFail("Expected a loaded fallback weather snapshot.") + XCTFail("Expected a WeatherKit capability error.") } XCTAssertEqual(locationAccess.refreshCallCount, 0) XCTAssertEqual(locationAccess.requestCurrentLocationCallCount, 0) } - func testRefreshNowRequestsLiveLocationBeforeUsingFallback() async { + func testRefreshNowRequestsLiveLocationBeforeFetchingWeather() async { let provider = RecordingWeatherProvider() let currentLocation = CLLocation(latitude: 24.8607, longitude: 67.0011) let locationAccess = MockWeatherLocationAccess( - status: .authorizedWhenInUse, + status: .authorizedAlways, currentLocation: nil, requestedCurrentLocation: currentLocation ) - let viewModel = WeatherViewModel(provider: provider, locationAccess: locationAccess) + let viewModel = WeatherViewModel( + provider: provider, + locationAccess: locationAccess, + weatherCapabilityEnabled: { true } + ) await viewModel.refreshNow() @@ -84,39 +81,44 @@ final class WeatherViewModelTests: XCTestCase { XCTAssertEqual(locationAccess.requestCurrentLocationCallCount, 1) } - func testRefreshNowUsesFallbackLocationWhenAuthorizedWithoutAvailableCurrentLocation() async { + func testRefreshNowDoesNotFetchWhenCurrentLocationIsUnavailable() async { let provider = RecordingWeatherProvider() - let locationAccess = MockWeatherLocationAccess(status: .authorizedWhenInUse, currentLocation: nil) - let viewModel = WeatherViewModel(provider: provider, locationAccess: locationAccess) + let locationAccess = MockWeatherLocationAccess(status: .authorizedAlways, currentLocation: nil) + let viewModel = WeatherViewModel( + provider: provider, + locationAccess: locationAccess, + weatherCapabilityEnabled: { true } + ) await viewModel.refreshNow() - guard let requestedLocation = provider.requestedLocation else { - return XCTFail("Expected the weather provider to receive a fallback location.") - } - - XCTAssertEqual(requestedLocation.coordinate.latitude, 37.3346, accuracy: 0.0001) - XCTAssertEqual(requestedLocation.coordinate.longitude, -122.0090, accuracy: 0.0001) + XCTAssertNil(provider.requestedLocation) + XCTAssertEqual(locationAccess.requestCurrentLocationCallCount, 1) switch viewModel.state { - case .loaded(let snapshot): - XCTAssertEqual(snapshot.locationName, "Recorded") + case .error(let message): + XCTAssertEqual(message, "Current location is unavailable. Try again after macOS resolves your location.") default: - XCTFail("Expected a loaded weather snapshot.") + XCTFail("Expected an unavailable-location error.") } } - func testStartDoesNotRequestLocationAuthorizationOnLaunch() async { + func testStartRequestsLocationAfterExplicitWeatherOptIn() async { let provider = RecordingWeatherProvider() let locationAccess = MockWeatherLocationAccess(status: .notDetermined, currentLocation: nil) - let viewModel = WeatherViewModel(provider: provider, locationAccess: locationAccess) + let viewModel = WeatherViewModel( + provider: provider, + locationAccess: locationAccess, + weatherCapabilityEnabled: { true } + ) viewModel.refreshInterval = 3_600 viewModel.start() try? await Task.sleep(nanoseconds: 50_000_000) viewModel.stop() - XCTAssertEqual(locationAccess.requestAccessCallCount, 0) + XCTAssertEqual(locationAccess.requestAccessCallCount, 1) + XCTAssertNil(provider.requestedLocation) } func testStartRefreshesImmediatelyWhenLocationAccessChanges() async { @@ -127,7 +129,11 @@ final class WeatherViewModelTests: XCTestCase { } let locationAccess = MockWeatherLocationAccess(status: .notDetermined, currentLocation: nil) - let viewModel = WeatherViewModel(provider: provider, locationAccess: locationAccess) + let viewModel = WeatherViewModel( + provider: provider, + locationAccess: locationAccess, + weatherCapabilityEnabled: { true } + ) viewModel.refreshInterval = 3_600 viewModel.start() @@ -135,7 +141,7 @@ final class WeatherViewModelTests: XCTestCase { let currentLocation = CLLocation(latitude: 24.8607, longitude: 67.0011) locationAccess.emitChange( - status: .authorizedWhenInUse, + status: .authorizedAlways, currentLocation: currentLocation ) @@ -154,11 +160,16 @@ final class WeatherViewModelTests: XCTestCase { func testRefreshNowUsesFallbackProviderWhenLiveProviderFails() async { let provider = FailingWeatherProvider() let fallbackProvider = RecordingWeatherProvider() - let locationAccess = MockWeatherLocationAccess(status: .authorizedWhenInUse, currentLocation: nil) + let currentLocation = CLLocation(latitude: 24.8607, longitude: 67.0011) + let locationAccess = MockWeatherLocationAccess( + status: .authorizedAlways, + currentLocation: currentLocation + ) let viewModel = WeatherViewModel( provider: provider, locationAccess: locationAccess, - fallbackProvider: fallbackProvider + fallbackProvider: fallbackProvider, + weatherCapabilityEnabled: { true } ) await viewModel.refreshNow() @@ -167,8 +178,8 @@ final class WeatherViewModelTests: XCTestCase { return XCTFail("Expected the fallback weather provider to be used.") } - XCTAssertEqual(requestedLocation.coordinate.latitude, 37.3346, accuracy: 0.0001) - XCTAssertEqual(requestedLocation.coordinate.longitude, -122.0090, accuracy: 0.0001) + XCTAssertEqual(requestedLocation.coordinate.latitude, currentLocation.coordinate.latitude, accuracy: 0.0001) + XCTAssertEqual(requestedLocation.coordinate.longitude, currentLocation.coordinate.longitude, accuracy: 0.0001) switch viewModel.state { case .loaded(let snapshot): diff --git a/README.md b/README.md index 6106fb9e..ac8573cd 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ the mac app store build is sandboxed and does not include fan control, since tha ## privacy -no account, no sign in, no telemetry required. everything runs on your machine. +no account, no sign in, no ads, and no analytics. monitoring data stays on your mac. live weather is off by default; if you explicitly enable it, core-monitor asks macos for location access and requests weather from apple weatherkit. the developer never receives that data. ## compatibility diff --git a/docs/Mac-App-Store/index.html b/docs/Mac-App-Store/index.html index 66ce0185..9642d74e 100644 --- a/docs/Mac-App-Store/index.html +++ b/docs/Mac-App-Store/index.html @@ -68,7 +68,7 @@ "Memory pressure and storage usage", "Battery, network, uptime and thermal state", "Menu bar extra with a live summary", - "Local weather when location access is granted" + "Live Weather after explicit opt-in and location access" ], "offers": { "@type": "Offer", @@ -142,7 +142,7 @@

Included

  • Memory usage and pressure
  • Network throughput and startup disk usage
  • Battery status, thermal state, uptime and load averages
  • -
  • Local weather, only after you grant location access
  • +
  • Live Weather, only after you enable it and grant location access
  • SwiftUI dashboard and a menu bar extra
  • @@ -167,7 +167,7 @@

    Installed by Apple

    WeatherKit, on request

    -

    The weather card asks for location only when you turn it on. Decline, and the rest of the app runs unchanged.

    +

    Live Weather is off by default. Turn on Enable Live Weather in Settings to request location access; decline, and the rest of the app runs unchanged.

    Nothing collected

    diff --git a/docs/Mac-App-Store/privacy/index.html b/docs/Mac-App-Store/privacy/index.html index efa81567..2bf024ed 100644 --- a/docs/Mac-App-Store/privacy/index.html +++ b/docs/Mac-App-Store/privacy/index.html @@ -75,7 +75,7 @@

    What the app reads, locally

  • Network throughput
  • Startup disk usage
  • Uptime and load averages
  • -
  • Your location, only if you enable the weather card
  • +
  • Your location, only if you enable Live Weather
  • @@ -94,16 +94,16 @@

    What never happens

    -

    Location is the only ask

    -

    The app requests location only after you press Enable Location in the weather card. It never asks for camera, microphone, contacts, photos, accessibility, input monitoring, automation, or full disk access.

    +

    Location is explicitly opt-in

    +

    Live Weather is off by default. The app requests location only after you turn on Enable Live Weather in Settings. It never asks for camera, microphone, contacts, photos, accessibility, input monitoring, automation, or full disk access.

    -

    One network use

    -

    The network carries WeatherKit data and the Apple Weather attribution required while weather is active. Nothing else goes out.

    +

    WeatherKit, only while enabled

    +

    While Live Weather is enabled, the app sends the device location to Apple WeatherKit and receives weather data for that location. The Core-Monitor developer does not receive it. Core-Monitor’s own source makes no other network requests; macOS and App Store components may independently contact Apple services.

    Revoke any time

    -

    Turn weather off in the app, or withdraw location access in macOS System Settings. Everything else keeps working either way.

    +

    Turn Live Weather off in the app, or withdraw location access in macOS System Settings. Turning it off stops Core-Monitor’s weather requests. Everything else keeps working either way.

    diff --git a/docs/index.html b/docs/index.html index d82ea3ec..692ecf28 100644 --- a/docs/index.html +++ b/docs/index.html @@ -120,7 +120,7 @@ "name": "Does it phone home?", "acceptedAnswer": { "@type": "Answer", - "text": "No. There is no analytics code and no account anywhere in the app. The one network call is optional WeatherKit weather for the Touch Bar, and it only runs if you switch it on." + "text": "Core-Monitor has no account, advertising, or analytics, and it does not send monitoring data to the developer. Live Weather is off by default. If you explicitly enable it, the app asks macOS for location access and requests weather from Apple WeatherKit." } }, { @@ -323,7 +323,7 @@

    The Touch Bar, put back to work

    Widgets, overlays and shortcuts -

    Stats, weather, shortcuts and memory pressure, arranged with a live width preview so a layout never runs off the edge of the strip. The strip holds its place across apps, so you can check a reading or launch something without switching windows.

    +

    Stats, optional live weather, shortcuts and memory pressure, arranged with a live width preview so a layout never runs off the edge of the strip. Live weather stays off until you enable it. The strip holds its place across apps, so you can check a reading or launch something without switching windows.

    @@ -422,7 +422,7 @@

    Questions before you install

    Does it phone home? -

    No. There is no analytics code and no account anywhere in the app. The one network call is optional WeatherKit weather for the Touch Bar, and it only runs if you switch it on.

    +

    Core-Monitor has no account, advertising, or analytics, and it does not send monitoring data to the developer. Live Weather is off by default. If you explicitly enable it, the app asks macOS for location access and requests weather from Apple WeatherKit. Turning it off stops Core-Monitor’s weather requests; macOS and App Store components may still make their own connections to Apple services.

    Can fan control damage my Mac? diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 7ddbca83..2714c109 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -79,7 +79,7 @@ No for monitoring. Yes only for fan writes and related control modes. ### Is Core-Monitor private? -Yes. Sensor reads stay on the Mac, the app does not require an account, and the core experience does not depend on telemetry. +Yes. Sensor reads stay on the Mac, and the app has no account, advertising, or analytics. Live Weather is off by default. If the user explicitly enables it, the app asks macOS for location access and requests weather from Apple WeatherKit; the Core-Monitor developer does not receive that data. ### Does Core-Monitor support Touch Bar? diff --git a/docs/llms.txt b/docs/llms.txt index f8c2c524..9db27848 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -25,7 +25,7 @@ For the exact current count, query https://api.github.com/repos/offyotto/Core-Mo - Hardware focus: Apple Silicon Macs - Price: free - License: GPL-3.0 -- Privacy: no account required, no telemetry in the core monitoring experience +- Privacy: no account, advertising, or analytics; monitoring data stays local; Live Weather is off by default and contacts Apple WeatherKit only after explicit opt-in - Helper model: monitoring works without the privileged helper; the helper is only needed for fan writes - App Store edition: sandboxed read-only Mac system monitor with no helper, no fan control, no AppleSMC access, and no private APIs diff --git a/scripts/docs/generate_wiki.py b/scripts/docs/generate_wiki.py index e79d1bb7..ab9a1055 100644 --- a/scripts/docs/generate_wiki.py +++ b/scripts/docs/generate_wiki.py @@ -287,8 +287,8 @@ def infer_area(path: str) -> str: "The versioned persisted structs are the compatibility boundary for old user layouts.", ], "Core-Monitor/WeatherService.swift": [ - "Owns WeatherKit capability detection, optional location access, fallback coordinates, attribution, and view-model state.", - "Startup behavior is intentionally permission-safe: location prompting should only happen after explicit user intent.", + "Owns WeatherKit capability detection, opt-in location access, attribution, and view-model state.", + "It never fetches weather without both authorization and a current location; location prompting only happens after explicit user intent.", ], "Core-Monitor/HelperDiagnosticsExporter.swift": [ "Builds exportable JSON reports for helper signing, helper installation, launch-at-login, menu bar reachability, and recovery recommendations.", @@ -693,7 +693,7 @@ def generate_removed_pages(deleted: dict[str, list[CommitInfo]], commit_links: d - Product removals include the retired alerts screen, CoreVisor/QEMU, benchmark/leaderboard surfaces, updater deltas, the old topbar extension, old diagnostics experiments, and replaced media. - Repository hygiene removals include Xcode caches, packaged app artifacts, old generated projects, and stale workflow files. -- The current product is narrower and clearer: Apple Silicon monitoring, local dashboard/menu bar status, optional helper-backed fan control, Touch Bar widgets, WeatherKit when entitled, support diagnostics, and signed release distribution. +- The current product is narrower and clearer: Apple Silicon monitoring, local dashboard/menu bar status, optional helper-backed fan control, Touch Bar widgets, opt-in WeatherKit when entitled, support diagnostics, and signed release distribution. """, ) @@ -989,7 +989,7 @@ def generate_indexes(commits: list[CommitInfo], files: list[str], file_links: di """ Touch Bar support combines AppKit `NSTouchBar`, custom NSViews, Pock-style widget wrappers, and SwiftUI configuration UI. -`CoreMonTouchBarController` presents and rebuilds items. `TouchBarCustomizationCompatibility` persists layouts, pinned apps, pinned folders, custom command widgets, themes, presets, and compatibility migrations. `TouchBarUtilityWidgets`, `GroupViews`, `WeatherTouchBarView`, `NowPlayingTouchBarView`, and Pock widget sources render the visible strip. +`CoreMonTouchBarController` presents and rebuilds items. `TouchBarCustomizationCompatibility` persists layouts, pinned apps, pinned folders, custom command widgets, themes, presets, weather consent, and compatibility migrations. `TouchBarUtilityWidgets`, `GroupViews`, `WeatherTouchBarView`, and Pock widget sources render the visible strip. The point of the Touch Bar layer is persistent quick access above other apps: live status, weather, launchers, folders, and scripts without dragging users back to the dashboard. @@ -1011,18 +1011,18 @@ def generate_indexes(commits: list[CommitInfo], files: list[str], file_links: di "Weather-And-Location.md", "Weather And Location", """ -Weather is optional and WeatherKit-dependent. `WeatherService.swift` abstracts providers and location access. `WeatherLocationAccessSection.swift`, `WeatherTouchBarItem.swift`, `WeatherTouchBarView.swift`, and the Pock Weather widget consume the model. +Weather is optional and WeatherKit-dependent. `WeatherService.swift` abstracts providers and location access. The Live Weather toggle in `SettingsWindow.swift`, `WeatherTouchBarItem.swift`, `WeatherTouchBarView.swift`, and the Pock Weather widget consume the model. -The critical behavior is permission gating. Weather should not trigger a location prompt at launch. The user must explicitly opt in. Builds without WeatherKit entitlement should show clear capability messaging instead of a vague failure. +The critical behavior is permission and network gating. Live Weather is persisted as a separate consent flag and defaults to off, including migrations from older configurations. Only an explicit opt-in may start the view model, request location permission, or contact Apple WeatherKit. Builds without the WeatherKit entitlement should show clear capability messaging instead of a vague failure. -Weather attribution is loaded separately and should respect appearance. Fallback coordinates and dormant states are tested because launch-time prompts were a real regression. +Weather attribution is loaded separately and should respect appearance. There are no fallback coordinates: without authorization and a current location, the provider is not called. Tests cover dormant state, consent persistence, legacy migration, and permission gating. """, ), ( "Privacy-And-Permissions.md", "Privacy And Permissions", """ -The product promise is local-first monitoring. Sensor reads stay on the Mac. No account is required. The helper is optional. Weather location access is opt-in. Helper diagnostics are explicit export files, not background telemetry. +The product promise is local-first monitoring. Sensor reads stay on the Mac. No account is required. The helper is optional. Live Weather is off by default and contacts Apple WeatherKit only after explicit opt-in and location authorization. The developer does not receive that data. Helper diagnostics are explicit export files, not background telemetry. Privacy-sensitive areas include top-process sampling, disk process activity, battery/power information, helper diagnostics, location access, and custom command widgets.