Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 106 additions & 1 deletion InputStatus/App/AppModel.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import Combine
import Foundation
import UserNotifications
import WidgetKit

@MainActor
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<Void, Never>?
private var refreshObserver: NSObjectProtocol?

init() {
statusNotificationsEnabled = UserDefaults.standard.bool(
forKey: Self.notificationsEnabledKey
)
cachedStatus = StatusStore.load()
errorMessage = cachedStatus?.lastError
refreshObserver = NotificationCenter.default.addObserver(
Expand All @@ -21,6 +29,7 @@ final class AppModel: ObservableObject {
) { [weak self] _ in
Task { @MainActor [weak self] in self?.refreshNow() }
}
reconcileNotificationAuthorization()
startRefreshing()
}

Expand All @@ -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 }
Expand All @@ -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 {
Expand Down
69 changes: 60 additions & 9 deletions InputStatus/App/InputStatusApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions InputStatus/Resources/InputStatus-Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.5.1</string>
<string>1.6.0</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
Expand All @@ -40,7 +40,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>7</string>
<string>8</string>
<key>LSUIElement</key>
<true/>
<key>LSMinimumSystemVersion</key>
Expand Down
4 changes: 2 additions & 2 deletions InputStatus/Resources/InputStatusWidget-Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.5.1</string>
<string>1.6.0</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
</array>
<key>CFBundleVersion</key>
<string>7</string>
<string>8</string>
<key>CHSDisableImplicitWidgetDiscovery</key>
<false/>
<key>NSExtension</key>
Expand Down
30 changes: 30 additions & 0 deletions InputStatus/Shared/StatusModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
- macOS 26 使用原生 Liquid Glass,macOS 14/15 使用半透明材质回退。
- 支持锁定挂件位置,避免误拖动;解锁后可移动并自动保存位置。
- 约每两分钟自动刷新,右上角按钮可立即刷新。
- 显示每项服务的可用率和最近响应耗时。
- 可选状态变化通知,仅在服务异常或恢复时发送 macOS 通知。
- 菜单栏同步显示状态,并可显示或隐藏桌面挂件。
- 网络异常时保留最近一次成功数据,超过约四分钟会标记为过期。
- 每小时自动检查 GitHub Release,在后台下载并安全安装签名更新。
Expand Down Expand Up @@ -69,6 +71,7 @@ cd Project-Input-Status
- 点击刷新图标立即请求最新状态。
- 点击“打开状态页”查看完整状态页。
- 点击菜单栏状态图标,可显示或隐藏桌面挂件。
- 在菜单栏开启“状态变化通知”,首次开启时允许 macOS 通知权限。
- 点击菜单栏中的“检查更新…”立即检查 GitHub 最新版本。
- 挂件解锁后可以拖动,位置会在下次启动时恢复。

Expand All @@ -87,6 +90,7 @@ cd Project-Input-Status
- 向 `https://status.input.im/api/status` 请求公开状态数据。
- 向 GitHub Releases 请求签名更新源,有新版本时下载 DMG。
- 状态缓存和挂件偏好仅保存在本机应用沙箱中。
- 通知由 macOS 在本机生成,不会向其他服务发送状态记录。

## 开发

Expand Down
Loading