From 10c0ce1e667292bce45d24a75f8736f2773e3764 Mon Sep 17 00:00:00 2001 From: Htx <17922172+Houtx@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:42:14 +0800 Subject: [PATCH] Add status change notifications and latency metrics --- InputStatus/App/AppModel.swift | 107 +++++++++++++++++- InputStatus/App/InputStatusApp.swift | 69 +++++++++-- InputStatus/Resources/InputStatus-Info.plist | 4 +- .../Resources/InputStatusWidget-Info.plist | 4 +- InputStatus/Shared/StatusModels.swift | 30 +++++ README.md | 4 + Tests/StatusCoreTests.swift | 55 +++++++++ release-notes/v1.6.0.md | 9 ++ 8 files changed, 268 insertions(+), 14 deletions(-) create mode 100644 release-notes/v1.6.0.md diff --git a/InputStatus/App/AppModel.swift b/InputStatus/App/AppModel.swift index 7b6407e..803d640 100644 --- a/InputStatus/App/AppModel.swift +++ b/InputStatus/App/AppModel.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import UserNotifications import WidgetKit @MainActor @@ -7,11 +8,18 @@ final class AppModel: ObservableObject { @Published private(set) var cachedStatus: CachedStatus? @Published private(set) var isRefreshing = false @Published private(set) var errorMessage: String? + @Published private(set) var statusNotificationsEnabled: Bool + @Published private(set) var notificationPermissionDenied = false + private static let notificationsEnabledKey = "status-change-notifications-enabled" + private let userNotificationCenter = UNUserNotificationCenter.current() private var refreshTask: Task? private var refreshObserver: NSObjectProtocol? init() { + statusNotificationsEnabled = UserDefaults.standard.bool( + forKey: Self.notificationsEnabledKey + ) cachedStatus = StatusStore.load() errorMessage = cachedStatus?.lastError refreshObserver = NotificationCenter.default.addObserver( @@ -21,6 +29,7 @@ final class AppModel: ObservableObject { ) { [weak self] _ in Task { @MainActor [weak self] in self?.refreshNow() } } + reconcileNotificationAuthorization() startRefreshing() } @@ -42,6 +51,29 @@ final class AppModel: ObservableObject { Task { await refresh() } } + func setStatusNotificationsEnabled(_ isEnabled: Bool) { + guard isEnabled else { + updateNotificationPreference(false) + notificationPermissionDenied = false + return + } + + userNotificationCenter.requestAuthorization(options: [.alert, .sound]) { + [weak self] granted, error in + Task { @MainActor [weak self] in + guard let self else { return } + self.updateNotificationPreference(granted) + self.notificationPermissionDenied = !granted + if let error { + NSLog( + "Unable to authorize Input Status notifications: %@", + error.localizedDescription + ) + } + } + } + } + private func startRefreshing() { refreshTask = Task { [weak self] in guard let self else { return } @@ -63,14 +95,87 @@ final class AppModel: ObservableObject { defer { isRefreshing = false } do { - cachedStatus = try await StatusRefresher.refresh() + let previousSnapshot = cachedStatus?.snapshot + let refreshedStatus = try await StatusRefresher.refresh() + cachedStatus = refreshedStatus errorMessage = nil WidgetCenter.shared.reloadTimelines(ofKind: InputStatusConstants.widgetKind) + + if let previousSnapshot, + let currentSnapshot = refreshedStatus.snapshot, + let change = StatusChange(from: previousSnapshot, to: currentSnapshot) { + sendStatusChangeNotification(change) + } } catch { cachedStatus = StatusStore.load() errorMessage = error.localizedDescription } } + + private func reconcileNotificationAuthorization() { + guard statusNotificationsEnabled else { return } + + userNotificationCenter.getNotificationSettings { [weak self] settings in + Task { @MainActor [weak self] in + guard let self else { return } + if settings.authorizationStatus == .denied { + self.updateNotificationPreference(false) + self.notificationPermissionDenied = true + } else if settings.authorizationStatus == .notDetermined { + self.setStatusNotificationsEnabled(true) + } + } + } + } + + private func updateNotificationPreference(_ isEnabled: Bool) { + statusNotificationsEnabled = isEnabled + UserDefaults.standard.set(isEnabled, forKey: Self.notificationsEnabledKey) + } + + private func sendStatusChangeNotification(_ change: StatusChange) { + guard statusNotificationsEnabled else { return } + + let content = UNMutableNotificationContent() + if !change.newlyOffline.isEmpty && change.recovered.isEmpty { + content.title = change.newlyOffline.count == 1 ? "服务出现异常" : "多个服务出现异常" + } else if change.newlyOffline.isEmpty { + content.title = change.recovered.count == 1 ? "服务已恢复" : "多个服务已恢复" + } else { + content.title = "服务状态发生变化" + } + + var details: [String] = [] + if !change.newlyOffline.isEmpty { + details.append("异常:\(summarizedModels(change.newlyOffline))") + } + if !change.recovered.isEmpty { + details.append("恢复:\(summarizedModels(change.recovered))") + } + content.body = details.joined(separator: ";") + content.sound = .default + content.threadIdentifier = "input-status-service-changes" + + let request = UNNotificationRequest( + identifier: "input-status-service-change", + content: content, + trigger: nil + ) + userNotificationCenter.add(request) { error in + if let error { + NSLog( + "Unable to deliver Input Status notification: %@", + error.localizedDescription + ) + } + } + } + + private func summarizedModels(_ models: [String]) -> String { + let visibleModels = models.prefix(3).joined(separator: "、") + guard models.count > 3 else { return visibleModels } + return "\(visibleModels) 等 \(models.count) 项" + } } extension Notification.Name { diff --git a/InputStatus/App/InputStatusApp.swift b/InputStatus/App/InputStatusApp.swift index 9b92928..d42f892 100644 --- a/InputStatus/App/InputStatusApp.swift +++ b/InputStatus/App/InputStatusApp.swift @@ -3,9 +3,11 @@ import Combine import ServiceManagement @preconcurrency import Sparkle import SwiftUI +import UserNotifications @MainActor -final class InputStatusAppDelegate: NSObject, NSApplicationDelegate, SPUStandardUserDriverDelegate { +final class InputStatusAppDelegate: NSObject, NSApplicationDelegate, + SPUStandardUserDriverDelegate, UNUserNotificationCenterDelegate { let model = AppModel() lazy var updaterController = SPUStandardUpdaterController( startingUpdater: true, @@ -21,6 +23,7 @@ final class InputStatusAppDelegate: NSObject, NSApplicationDelegate, SPUStandard func applicationDidFinishLaunching(_ notification: Notification) { _ = updaterController + UNUserNotificationCenter.current().delegate = self registerLoginItemIfNeeded() desktopWidgetController = DesktopWidgetWindowController(model: model) desktopWidgetController?.show() @@ -37,6 +40,14 @@ final class InputStatusAppDelegate: NSObject, NSApplicationDelegate, SPUStandard desktopWidgetController?.toggle() } + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) + } + nonisolated func standardUserDriverWillHandleShowingUpdate( _ handleShowingUpdate: Bool, forUpdate update: SUAppcastItem, @@ -125,6 +136,27 @@ private struct StatusMenuView: View { Label("显示/隐藏桌面挂件", systemImage: "rectangle.on.rectangle") } + Toggle( + isOn: Binding( + get: { model.statusNotificationsEnabled }, + set: { model.setStatusNotificationsEnabled($0) } + ) + ) { + Label( + "状态变化通知", + systemImage: model.statusNotificationsEnabled ? "bell.fill" : "bell" + ) + } + .toggleStyle(.switch) + + if model.notificationPermissionDenied { + Button { + openNotificationSettings() + } label: { + Label("打开通知设置…", systemImage: "gearshape") + } + } + Divider() Button { appDelegate.updaterController.checkForUpdates(nil) @@ -205,12 +237,9 @@ private struct StatusMenuView: View { Spacer() - Text(service.uptimePercent, format: .number.precision(.fractionLength(1))) + Text(serviceMetrics(service)) .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) - Text("%") - .font(.caption) - .foregroundStyle(.secondary) } .padding(.vertical, 6) } @@ -246,6 +275,13 @@ private struct StatusMenuView: View { .help("退出") } } + + private func openNotificationSettings() { + guard let url = URL( + string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension" + ) else { return } + NSWorkspace.shared.open(url) + } } @MainActor @@ -515,12 +551,9 @@ private struct DesktopWidgetView: View { Spacer(minLength: 6) - Text(service.uptimePercent, format: .number.precision(.fractionLength(1))) + Text(serviceMetrics(service)) .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) - Text("%") - .font(.caption2) - .foregroundStyle(.secondary) } .frame(height: 23) } @@ -548,6 +581,24 @@ private struct DesktopWidgetView: View { } } +private func serviceMetrics(_ service: StatusService) -> String { + let uptime = service.uptimePercent.formatted( + .number.precision(.fractionLength(1)) + ) + guard let latencyMS = service.last?.latencyMS else { return "\(uptime)%" } + + let latency: String + if latencyMS < 1_000 { + latency = "\(latencyMS)ms" + } else { + let seconds = (Double(latencyMS) / 1_000).formatted( + .number.precision(.fractionLength(1)) + ) + latency = "\(seconds)s" + } + return "\(uptime)% · \(latency)" +} + private struct DesktopGlassModifier: ViewModifier { @ViewBuilder func body(content: Content) -> some View { diff --git a/InputStatus/Resources/InputStatus-Info.plist b/InputStatus/Resources/InputStatus-Info.plist index 5d499cd..499fcd1 100644 --- a/InputStatus/Resources/InputStatus-Info.plist +++ b/InputStatus/Resources/InputStatus-Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.5.1 + 1.6.0 CFBundleSupportedPlatforms MacOSX @@ -40,7 +40,7 @@ CFBundleVersion - 7 + 8 LSUIElement LSMinimumSystemVersion diff --git a/InputStatus/Resources/InputStatusWidget-Info.plist b/InputStatus/Resources/InputStatusWidget-Info.plist index db7fd82..47b7c50 100644 --- a/InputStatus/Resources/InputStatusWidget-Info.plist +++ b/InputStatus/Resources/InputStatusWidget-Info.plist @@ -23,13 +23,13 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.5.1 + 1.6.0 CFBundleSupportedPlatforms MacOSX CFBundleVersion - 7 + 8 CHSDisableImplicitWidgetDiscovery NSExtension diff --git a/InputStatus/Shared/StatusModels.swift b/InputStatus/Shared/StatusModels.swift index 70d79a6..aaf3a30 100644 --- a/InputStatus/Shared/StatusModels.swift +++ b/InputStatus/Shared/StatusModels.swift @@ -192,6 +192,36 @@ struct StatusSnapshot: Codable, Equatable, Sendable { } } +struct StatusChange: Equatable, Sendable { + let newlyOffline: [String] + let recovered: [String] + + init?(from previous: StatusSnapshot, to current: StatusSnapshot) { + let previousStates = Dictionary( + uniqueKeysWithValues: previous.services.map { + ($0.model.lowercased(), $0.isOnline) + } + ) + + newlyOffline = current.services.compactMap { service in + guard !service.isOnline, + previousStates[service.model.lowercased()] != false else { + return nil + } + return service.model + } + recovered = current.services.compactMap { service in + guard service.isOnline, + previousStates[service.model.lowercased()] == false else { + return nil + } + return service.model + } + + guard !newlyOffline.isEmpty || !recovered.isEmpty else { return nil } + } +} + struct CachedStatus: Codable, Equatable, Sendable { var snapshot: StatusSnapshot? var fetchedAt: Date? diff --git a/README.md b/README.md index f3732a0..7c7a07a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ - macOS 26 使用原生 Liquid Glass,macOS 14/15 使用半透明材质回退。 - 支持锁定挂件位置,避免误拖动;解锁后可移动并自动保存位置。 - 约每两分钟自动刷新,右上角按钮可立即刷新。 +- 显示每项服务的可用率和最近响应耗时。 +- 可选状态变化通知,仅在服务异常或恢复时发送 macOS 通知。 - 菜单栏同步显示状态,并可显示或隐藏桌面挂件。 - 网络异常时保留最近一次成功数据,超过约四分钟会标记为过期。 - 每小时自动检查 GitHub Release,在后台下载并安全安装签名更新。 @@ -69,6 +71,7 @@ cd Project-Input-Status - 点击刷新图标立即请求最新状态。 - 点击“打开状态页”查看完整状态页。 - 点击菜单栏状态图标,可显示或隐藏桌面挂件。 +- 在菜单栏开启“状态变化通知”,首次开启时允许 macOS 通知权限。 - 点击菜单栏中的“检查更新…”立即检查 GitHub 最新版本。 - 挂件解锁后可以拖动,位置会在下次启动时恢复。 @@ -87,6 +90,7 @@ cd Project-Input-Status - 向 `https://status.input.im/api/status` 请求公开状态数据。 - 向 GitHub Releases 请求签名更新源,有新版本时下载 DMG。 - 状态缓存和挂件偏好仅保存在本机应用沙箱中。 +- 通知由 macOS 在本机生成,不会向其他服务发送状态记录。 ## 开发 diff --git a/Tests/StatusCoreTests.swift b/Tests/StatusCoreTests.swift index 3ab77d9..03a6b70 100644 --- a/Tests/StatusCoreTests.swift +++ b/Tests/StatusCoreTests.swift @@ -103,6 +103,61 @@ private struct StatusCoreTests { _ = try inconsistent.validated(referenceDate: now) } + let offlineProbe = ProbeResult(timestamp: now, isOK: false) + let previouslyOffline = StatusService( + model: "Legacy-Service", + uptimePercent: 98, + last: offlineProbe, + history: [offlineProbe] + ) + let previousStatus = StatusSnapshot( + allOK: false, + generatedAt: now, + services: [service, previouslyOffline] + ) + let currentStatus = StatusSnapshot( + allOK: false, + generatedAt: now.addingTimeInterval(60), + services: [ + StatusService( + model: "gpt-test", + uptimePercent: 99, + last: offlineProbe, + history: [offlineProbe] + ), + StatusService( + model: "legacy-service", + uptimePercent: 99, + last: history.last, + history: history + ), + StatusService( + model: "new-service", + uptimePercent: 0, + last: offlineProbe, + history: [offlineProbe] + ) + ] + ) + let statusChange = try { + guard let change = StatusChange(from: previousStatus, to: currentStatus) else { + throw TestFailure.expectation("Status transitions must be detected") + } + return change + }() + try expect( + statusChange.newlyOffline == ["gpt-test", "new-service"], + "Offline and newly introduced failing services must be reported" + ) + try expect( + statusChange.recovered == ["legacy-service"], + "Recovered services must be matched case-insensitively" + ) + try expect( + StatusChange(from: currentStatus, to: currentStatus) == nil, + "Unchanged snapshots must not create notifications" + ) + let legacyEpoch = StatusSnapshot( allOK: true, generatedAt: Date(timeIntervalSince1970: 800_000_000), diff --git a/release-notes/v1.6.0.md b/release-notes/v1.6.0.md new file mode 100644 index 0000000..737f00b --- /dev/null +++ b/release-notes/v1.6.0.md @@ -0,0 +1,9 @@ +# Input Status 1.6.0 + +- 新增可选的 macOS 状态变化通知,服务出现异常或恢复时主动提醒。 +- 通知仅针对真实状态切换,首次加载、重复刷新和网络请求失败不会打扰。 +- 菜单栏可随时开启或关闭通知,权限被拒绝时可直达系统通知设置。 +- 桌面挂件和菜单栏服务列表新增最近响应耗时,与可用率一起显示。 +- 新增状态切换测试,覆盖异常、恢复、新服务和无变化场景。 + +本版本继续提供 Universal 2 DMG,同时支持 Apple Silicon 和 Intel Mac。