721 lines
33 KiB
Swift
721 lines
33 KiB
Swift
// Copyright (c) 2026 GiantAccel, LLC
|
|
// XStats modifications Copyright (C) 2026 ysicing
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
|
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
|
|
|
|
import AppKit
|
|
import AIUsage
|
|
import Localization
|
|
import Metrics
|
|
import SwiftUI
|
|
import WidgetData
|
|
import WidgetKit
|
|
|
|
@MainActor
|
|
public final class AppController: NSObject, NSApplicationDelegate {
|
|
private let model = AppModel(quotaCacheURL: AIQuotaCacheStore.defaultURL)
|
|
private let widgetStore = WidgetSnapshotStore()
|
|
private var menuBar: MenuBarController!
|
|
private var calendarMenuBar: CalendarMenuBarController!
|
|
private var mainWindow: MainWindowController!
|
|
private var aiUsageSettingsWindow: AIUsageSettingsWindowController!
|
|
private var updateWindow: UpdateWindowController!
|
|
private var speedTestWindow: SpeedTestWindowController!
|
|
private var projectPurgeWindow: ProjectPurgeWindowController!
|
|
private var egressWindow: EgressWindowController!
|
|
private var rest: RestController { model.rest }
|
|
private var restWindows: RestWindowController!
|
|
private var restMenuBar: RestMenuBarController!
|
|
private var updateTimer: Timer?
|
|
private var widgetTimer: Timer?
|
|
private let hotKeys = HotKeyCenter()
|
|
private var workspaceObservers: [NSObjectProtocol] = []
|
|
private var screenLocked = false
|
|
private var screenTimePausedByWorkspace = false
|
|
private var preparingToTerminate = false
|
|
|
|
public override init() {
|
|
super.init()
|
|
}
|
|
|
|
public func applicationDidFinishLaunching(_ notification: Notification) {
|
|
_ = DiagnosticsExporter.launchDate
|
|
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? tr("开发版")
|
|
Log.app.notice("XStats \(version, privacy: .public) 启动,macOS \(ProcessInfo.processInfo.operatingSystemVersionString, privacy: .public)")
|
|
NSApp.mainMenu = MainMenu.make(target: self, settingsAction: #selector(openSettingsFromMenu),
|
|
updateAction: #selector(checkForUpdatesFromMenu))
|
|
menuBar = MenuBarController(model: model)
|
|
calendarMenuBar = CalendarMenuBarController(model: model)
|
|
mainWindow = MainWindowController(model: model)
|
|
mainWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() }
|
|
aiUsageSettingsWindow = AIUsageSettingsWindowController(model: model)
|
|
aiUsageSettingsWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() }
|
|
updateWindow = UpdateWindowController(model: model)
|
|
speedTestWindow = SpeedTestWindowController(model: model)
|
|
speedTestWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() }
|
|
projectPurgeWindow = ProjectPurgeWindowController(model: model)
|
|
projectPurgeWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() }
|
|
egressWindow = EgressWindowController(model: model)
|
|
egressWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() }
|
|
restWindows = RestWindowController(rest: rest)
|
|
restMenuBar = RestMenuBarController(rest: rest, open: { [weak self] in
|
|
self?.model.openMainWindow(.rest)
|
|
}, toggleHUD: { [weak self] in
|
|
self?.restWindows.toggleHUD()
|
|
})
|
|
rest.onRestChange = { [weak self] active in
|
|
if active { self?.restWindows.showRest() } else { self?.restWindows.hideRest() }
|
|
}
|
|
rest.onStateChange = { [weak self] in self?.restMenuBar.update() }
|
|
model.collapseToRestHUD = { [weak self] in
|
|
self?.restWindows.showHUD()
|
|
self?.mainWindow.close()
|
|
}
|
|
menuBar.update()
|
|
calendarMenuBar.start()
|
|
|
|
// 设置已并入主窗口:所有“设置…”入口都打开主窗口的通用设置
|
|
model.openSettings = { [weak self] in
|
|
self?.menuBar.dismissPopovers()
|
|
self?.calendarMenuBar.dismiss()
|
|
self?.mainWindow.show(tab: .settingsGeneral)
|
|
}
|
|
model.openMainWindow = { [weak self] tab in
|
|
self?.menuBar.dismissPopovers()
|
|
self?.calendarMenuBar.dismiss()
|
|
self?.mainWindow.show(tab: tab)
|
|
}
|
|
model.openAIUsageSettings = { [weak self] in
|
|
self?.menuBar.dismissPopovers()
|
|
self?.calendarMenuBar.dismiss()
|
|
self?.aiUsageSettingsWindow.show()
|
|
}
|
|
model.openEgressWindow = { [weak self] in
|
|
self?.menuBar.dismissPopovers()
|
|
self?.calendarMenuBar.dismiss()
|
|
self?.egressWindow.show()
|
|
}
|
|
model.openSpeedTestWindow = { [weak self] in
|
|
self?.menuBar.dismissPopovers()
|
|
self?.calendarMenuBar.dismiss()
|
|
self?.speedTestWindow.show()
|
|
}
|
|
model.openProjectPurgeWindow = { [weak self] in
|
|
guard let self, self.model.settings.cleanerEnabled else { return }
|
|
self.menuBar.dismissPopovers()
|
|
self.calendarMenuBar.dismiss()
|
|
self.projectPurgeWindow.show()
|
|
}
|
|
model.quit = { NSApp.terminate(nil) }
|
|
model.helper.refreshStatus()
|
|
Task { await model.helper.verifyVersion() }
|
|
model.helper.onReconnect = { [weak self] in
|
|
Task { [weak self] in
|
|
await self?.model.fans.reapply()
|
|
await self?.model.keepAwake.reapply()
|
|
}
|
|
}
|
|
|
|
observeWorkspace()
|
|
model.screenTime.start()
|
|
observeScreenTimeHistorySetting()
|
|
observeRestSettings()
|
|
observeCleanerSetting()
|
|
observeRestMode()
|
|
observeRestSound()
|
|
rest.sync()
|
|
restMenuBar.sync()
|
|
observeModel()
|
|
observeProbeSettings()
|
|
observeAIUsageSchedule()
|
|
observeAIUsageState()
|
|
observeWidgetState()
|
|
syncWidgetSnapshot()
|
|
widgetTimer = Timer.scheduledTimer(withTimeInterval: 60 * 60, repeats: true) { [weak self] _ in
|
|
MainActor.assumeIsolated {
|
|
guard let self else { return }
|
|
self.model.screenTime.checkpoint()
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
await self.model.screenTime.pending?.value
|
|
self.syncWidgetSnapshot()
|
|
}
|
|
}
|
|
}
|
|
widgetTimer?.tolerance = 5 * 60
|
|
model.aiUsage.start()
|
|
applyAppearance()
|
|
updateNetworkVisibility()
|
|
appliedLanguage = model.settings.language
|
|
startUpdateChecks()
|
|
model.alerts.openTab = { [weak self] tab in
|
|
self?.menuBar.dismissPopovers()
|
|
self?.calendarMenuBar.dismiss()
|
|
self?.mainWindow.show(tab: tab)
|
|
}
|
|
model.alerts.start()
|
|
hotKeys.onAction = { [weak self] action in self?.perform(action) }
|
|
applyHotKeys()
|
|
|
|
// 开发调试:--show-panel [overview|cpu|memory|network|gpu|disk|temperature|fan|battery|aiUsage] 启动后展开并固定弹窗
|
|
// (overview 是合并模式的状态总览;合并模式下给某一项则打开面板并切到这一项);--show-window 打开主窗口;
|
|
// --show-egress 打开出口与分流窗口
|
|
let arguments = CommandLine.arguments
|
|
if let index = arguments.firstIndex(of: "--show-panel") {
|
|
let argument = arguments.dropFirst(index + 1).first
|
|
let item = argument.flatMap(MenuBarItem.init(rawValue:)) ?? .network
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
|
self?.menuBar.pinsNextPopover = true
|
|
if argument == "overview" {
|
|
self?.menuBar.toggleOverview()
|
|
} else {
|
|
self?.menuBar.togglePopover(item)
|
|
}
|
|
}
|
|
}
|
|
if arguments.contains("--show-calendar") {
|
|
model.settings.calendarEnabled = true
|
|
calendarMenuBar.update()
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
|
self?.calendarMenuBar.show()
|
|
}
|
|
}
|
|
if arguments.contains("--show-window") {
|
|
mainWindow.show(tab: nil)
|
|
}
|
|
if arguments.contains("--show-speedtest") {
|
|
speedTestWindow.show()
|
|
}
|
|
// 开发调试:--probe <域名> 打开测速窗口并立即用全球探针 ping 这个域名
|
|
if let index = arguments.firstIndex(of: "--probe"), let domain = arguments.dropFirst(index + 1).first {
|
|
speedTestWindow.show()
|
|
model.speedTest.globalpingTarget = domain
|
|
model.speedTest.runGlobalping()
|
|
}
|
|
if arguments.contains("--show-egress") {
|
|
egressWindow.show()
|
|
}
|
|
// 开发调试:--explain-process <进程名> 打开进程页并用 Apple 智能解释该进程
|
|
if let index = arguments.firstIndex(of: "--explain-process"), let name = arguments.dropFirst(index + 1).first {
|
|
mainWindow.show(tab: .processes)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in
|
|
guard let self else { return }
|
|
let processes = self.model.store.processes
|
|
guard let process = processes.first(where: { $0.name.localizedCaseInsensitiveContains(name) }) ?? processes.first else { return }
|
|
self.model.explainProcess(.init(process))
|
|
}
|
|
}
|
|
|
|
let appModel = model
|
|
appModel.bluetooth.setDemand(appModel.bluetoothDemand)
|
|
Task { [weak self] in
|
|
await appModel.hub.update(appModel.demand)
|
|
await appModel.hub.start { [weak self] snapshot in
|
|
appModel.handle(snapshot)
|
|
self?.menuBar.refreshImages()
|
|
}
|
|
}
|
|
}
|
|
|
|
public func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
|
guard !preparingToTerminate else { return .terminateLater }
|
|
preparingToTerminate = true
|
|
Task { @MainActor in
|
|
await model.screenTime.prepareForTermination()
|
|
sender.reply(toApplicationShouldTerminate: true)
|
|
}
|
|
return .terminateLater
|
|
}
|
|
|
|
public func applicationWillTerminate(_ notification: Notification) {
|
|
rest.prepareForTermination()
|
|
widgetTimer?.invalidate()
|
|
calendarMenuBar.stop()
|
|
model.aiUsage.stop()
|
|
model.keepAwake.releaseForTermination()
|
|
if model.fans.mode != .automatic || model.keepAwake.lidClosedActive {
|
|
model.helper.restoreDefaultsSynchronously()
|
|
}
|
|
}
|
|
|
|
/// 再次打开应用(访达或启动台)时显示主窗口
|
|
public func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
|
if !flag { mainWindow.show(tab: nil) }
|
|
return true
|
|
}
|
|
|
|
@objc private func openSettingsFromMenu() { model.openSettings() }
|
|
|
|
@objc private func checkForUpdatesFromMenu() {
|
|
model.updates.check(userInitiated: true)
|
|
mainWindow.show(tab: .settingsAbout)
|
|
}
|
|
|
|
private func applyHotKeys() {
|
|
hotKeys.apply(model.settings.hotKeys)
|
|
model.hotKeyConflicts = hotKeys.conflicts
|
|
withObservationTracking {
|
|
_ = model.settings.hotKeys
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor in self?.applyHotKeys() }
|
|
}
|
|
}
|
|
|
|
private func perform(_ action: HotKeyAction) {
|
|
Log.app.info("快捷键:\(action.rawValue, privacy: .public)")
|
|
switch action {
|
|
case .toggleMainWindow:
|
|
if mainWindow.isVisible && NSApp.isActive { mainWindow.close() } else { mainWindow.show(tab: nil) }
|
|
case .showProcesses:
|
|
menuBar.dismissPopovers()
|
|
mainWindow.show(tab: .processes)
|
|
case .toggleKeepAwake:
|
|
Task { await model.keepAwake.setActive(!model.keepAwake.isActive) }
|
|
case .purgeMemory:
|
|
Task { await model.maintenance.run(.purgeMemory) }
|
|
}
|
|
}
|
|
|
|
/// 启动 10 秒后按策略检查,运行中每小时判断周期是否到期。
|
|
private func startUpdateChecks() {
|
|
model.updates.onPrompt = { [weak self] in
|
|
self?.menuBar.dismissPopovers()
|
|
self?.calendarMenuBar.dismiss()
|
|
self?.updateWindow.show()
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in
|
|
self?.model.updates.checkIfNeeded()
|
|
}
|
|
updateTimer = Timer.scheduledTimer(withTimeInterval: 60 * 60, repeats: true) { [weak self] _ in
|
|
MainActor.assumeIsolated { self?.model.updates.checkIfNeeded() }
|
|
}
|
|
updateTimer?.tolerance = 5 * 60
|
|
}
|
|
|
|
/// 默认只在菜单栏运行、不占程序坞;打开了“在程序坞显示图标”时,有窗口开着才出现在程序坞与 ⌘Tab 里
|
|
private func updateActivationPolicy() {
|
|
let hasWindow = mainWindow.isVisible || aiUsageSettingsWindow.isVisible
|
|
|| egressWindow.isVisible || speedTestWindow.isVisible || projectPurgeWindow.isVisible
|
|
let policy: NSApplication.ActivationPolicy = hasWindow && model.settings.showDockIcon ? .regular : .accessory
|
|
guard NSApp.activationPolicy() != policy else { return }
|
|
NSApp.setActivationPolicy(policy)
|
|
if hasWindow { NSApp.activate() }
|
|
}
|
|
|
|
// MARK: 观察
|
|
|
|
/// 设置或可见内容变化时,更新采样范围、网络探测并重绘菜单栏
|
|
private func observeModel() {
|
|
withObservationTracking {
|
|
_ = model.demand
|
|
_ = model.bluetoothDemand
|
|
_ = model.settings.menuBarItems
|
|
_ = model.settings.menuBarLayout
|
|
_ = model.settings.menuBarStyle
|
|
_ = model.settings.networkStyle
|
|
_ = model.settings.styleOverrides
|
|
_ = model.settings.colorizeHighLoad
|
|
_ = model.settings.useFahrenheit
|
|
_ = model.settings.hiddenPopoverSections
|
|
_ = model.settings.expandedPopoverSections
|
|
_ = model.keepAwake.isActive
|
|
_ = model.settings.appearance
|
|
_ = model.settings.showDockIcon
|
|
_ = model.isNetworkDetailVisible
|
|
_ = model.settings.probeEnabled
|
|
_ = model.settings.probeInBackground
|
|
_ = model.settings.enabledAlerts
|
|
_ = model.settings.language
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.applyLanguageIfChanged()
|
|
self.model.alerts.applySettings()
|
|
await self.model.hub.update(self.model.demand)
|
|
self.model.bluetooth.setDemand(self.model.bluetoothDemand)
|
|
self.applyAppearance()
|
|
self.updateActivationPolicy()
|
|
self.menuBar.update()
|
|
self.menuBar.refreshPopoverHeight()
|
|
self.updateNetworkVisibility()
|
|
self.observeModel()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 探测目标变化时清空历史重新探测;关闭公网 IP 查询时清除已显示的结果
|
|
private func observeProbeSettings() {
|
|
withObservationTracking {
|
|
_ = model.settings.probeTarget
|
|
_ = model.settings.publicIPLookup
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.model.network.restartProbing()
|
|
self.model.network.clearPublicAddresses()
|
|
if self.model.settings.publicIPLookup, self.model.isNetworkDetailVisible {
|
|
self.model.network.lookUpPublicAddresses()
|
|
}
|
|
self.observeProbeSettings()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// AI 配额使用独立的分钟级轮询;开关或间隔变化时重建这一条调度,不影响系统指标采样。
|
|
private func observeAIUsageSchedule() {
|
|
withObservationTracking {
|
|
_ = model.settings.aiUsageEnabled
|
|
_ = model.settings.aiUsageSources
|
|
_ = model.settings.aiUsageShowsLocalUsage
|
|
_ = model.settings.aiUsageRefreshMinutes
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.model.aiUsage.start()
|
|
// 模块关闭时菜单栏保留展示偏好但隐藏实际项目,切换主开关需要重算布局。
|
|
self.menuBar.update()
|
|
self.menuBar.refreshPopoverHeight()
|
|
self.observeAIUsageSchedule()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 一次低频查询结束后只重绘菜单栏和弹窗;不重启轮询,避免形成刷新回路。
|
|
private func observeAIUsageState() {
|
|
withObservationTracking {
|
|
_ = model.aiUsage.states
|
|
_ = model.aiUsage.lastAttemptAt
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.menuBar.refreshImages()
|
|
self.menuBar.refreshPopoverHeight()
|
|
self.observeAIUsageState()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Widget 只读共享的展示摘要;所有凭据读取和网络查询仍留在主应用。
|
|
private func observeWidgetState() {
|
|
withObservationTracking {
|
|
_ = model.settings.aiUsageEnabled
|
|
_ = model.settings.aiUsageSources
|
|
_ = model.settings.aiUsageShowsLocalUsage
|
|
_ = model.settings.publicIPLookup
|
|
_ = model.settings.language
|
|
_ = model.settings.calendarFirstWeekday
|
|
_ = model.settings.calendarFeatures
|
|
_ = model.aiUsage.states
|
|
_ = model.aiUsage.quotaStates
|
|
_ = model.network.publicResults
|
|
_ = model.settings.historyEnabled
|
|
_ = model.screenTime.today
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.syncWidgetSnapshot()
|
|
self.observeWidgetState()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func syncWidgetSnapshot() {
|
|
let settings = model.settings
|
|
let previous = widgetStore.load()
|
|
let calendar = CalendarEngine.gregorian()
|
|
let today = calendar.startOfDay(for: .now)
|
|
let todayKey = CalendarEngine.today(at: today)?.id
|
|
let dataVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
|
|
let reusesCalendar = previous.calendarDataVersion == dataVersion
|
|
// 多预备几天,Widget 午夜换日时无需唤醒主应用;主应用运行期间按小时补足窗口。
|
|
let calendarDays: [WidgetSnapshot.CalendarSummary] = {
|
|
if reusesCalendar, previous.showsSeasonal != nil, previous.calendarDays?.count == 8,
|
|
previous.calendarDays?.first?.dateKey == todayKey {
|
|
return previous.calendarDays ?? []
|
|
}
|
|
return (0..<8).compactMap { offset in
|
|
guard let date = calendar.date(byAdding: .day, value: offset, to: today),
|
|
let day = CalendarEngine.today(at: date) else { return nil }
|
|
return CalendarEngine.widgetSummary(for: day)
|
|
}
|
|
}()
|
|
let monthFeatures = settings.calendarFeatures.map(\.rawValue).sorted()
|
|
let monthSummaries: [WidgetSnapshot.MonthSummary] = (0...1).compactMap { offset in
|
|
guard let date = calendar.date(byAdding: .month, value: offset, to: today) else { return nil }
|
|
let parts = calendar.dateComponents([.year, .month], from: date)
|
|
guard let year = parts.year, let month = parts.month else { return nil }
|
|
let key = WidgetSnapshot.monthKey(year: year, month: month)
|
|
if reusesCalendar, let cached = previous.monthSummaries?.first(where: {
|
|
$0.monthKey == key && $0.firstWeekday == settings.calendarFirstWeekday
|
|
&& $0.featureKeys == monthFeatures && $0.days.count == 42
|
|
}) {
|
|
return cached
|
|
}
|
|
return CalendarEngine.widgetMonthSummary(year: year, month: month,
|
|
firstWeekday: settings.calendarFirstWeekday,
|
|
features: settings.calendarFeatures)
|
|
}
|
|
let quotas: [WidgetSnapshot.Quota] = settings.aiUsageEnabled
|
|
? AIProviderID.allCases.filter { settings.aiUsageSources.contains($0) }.flatMap { provider -> [WidgetSnapshot.Quota] in
|
|
let state = model.aiUsage.quotaState(for: provider)
|
|
guard let quota = state.snapshot else { return [] }
|
|
return quota.windows.map { window in
|
|
WidgetSnapshot.Quota(provider: provider.rawValue, kind: window.kind.rawValue,
|
|
remainingPercent: window.remainingPercent, resetsAt: window.resetsAt,
|
|
fetchedAt: quota.fetchedAt, isStale: state.isStale)
|
|
}
|
|
} : []
|
|
let dailyTokens: [WidgetSnapshot.DailyTokenUsage]? = settings.aiUsageEnabled && settings.aiUsageShowsLocalUsage
|
|
? AIProviderID.allCases.filter { settings.aiUsageSources.contains($0) }.compactMap { provider in
|
|
guard let report = model.aiUsage.localReport(for: provider) else { return nil }
|
|
let tokens = LocalUsageReport.total(report.selected(days: 1, now: today, calendar: calendar)).total
|
|
return WidgetSnapshot.DailyTokenUsage(provider: provider.rawValue, day: today, tokens: tokens)
|
|
} : nil
|
|
let addresses: [WidgetSnapshot.Address] = settings.publicIPLookup
|
|
? IPFamily.allCases.compactMap { family in
|
|
guard let result = model.network.publicResults[family],
|
|
let ip = family == .v4 ? result.ipv4 : result.ipv6 else { return nil }
|
|
return WidgetSnapshot.Address(family: family.rawValue, ip: ip,
|
|
countryCode: result.countryCode, city: result.city,
|
|
organization: result.organization,
|
|
purityScore: result.purity?.score, purityGrade: result.purity?.grade,
|
|
region: result.region, cityEnglish: result.cityEnglish,
|
|
regionEnglish: result.regionEnglish, asn: result.asn,
|
|
isNative: result.isNative, ipType: result.ipType,
|
|
riskFlags: result.risk?.flags)
|
|
} : []
|
|
let snapshot = WidgetSnapshot(aiEnabled: settings.aiUsageEnabled, quotas: quotas,
|
|
dailyTokens: dailyTokens,
|
|
localUsageEnabled: settings.aiUsageShowsLocalUsage,
|
|
publicIPEnabled: settings.publicIPLookup, addresses: addresses,
|
|
language: settings.language.rawValue,
|
|
calendarFirstWeekday: settings.calendarFirstWeekday,
|
|
showsLunar: settings.calendarFeatures.contains(.lunar),
|
|
calendarDays: calendarDays, monthSummaries: monthSummaries,
|
|
showsSeasonal: settings.calendarFeatures.contains(.seasonal),
|
|
calendarDataVersion: dataVersion,
|
|
screenTime: settings.historyEnabled
|
|
? .init(day: today, seconds: Int(model.screenTime.currentToday(at: .now)) / 900 * 900) : nil)
|
|
guard snapshot != previous, widgetStore.save(snapshot) else { return }
|
|
for kind in snapshot.changedWidgetKinds(from: previous) {
|
|
WidgetCenter.shared.reloadTimelines(ofKind: kind)
|
|
}
|
|
}
|
|
|
|
private var appliedLanguage: AppLanguage?
|
|
|
|
/// 切换语言后重建应用菜单、菜单栏图标文字与已打开的弹窗
|
|
private func applyLanguageIfChanged() {
|
|
let language = model.settings.language
|
|
guard appliedLanguage != nil, appliedLanguage != language else {
|
|
appliedLanguage = language
|
|
return
|
|
}
|
|
appliedLanguage = language
|
|
NSApp.mainMenu = MainMenu.make(target: self, settingsAction: #selector(openSettingsFromMenu),
|
|
updateAction: #selector(checkForUpdatesFromMenu))
|
|
menuBar.dismissPopovers()
|
|
menuBar.refreshImages()
|
|
speedTestWindow.refreshLanguage()
|
|
projectPurgeWindow.refreshLanguage()
|
|
egressWindow.refreshLanguage()
|
|
aiUsageSettingsWindow.refreshLanguage()
|
|
updateWindow.refreshLanguage()
|
|
}
|
|
|
|
private func updateNetworkVisibility() {
|
|
model.network.setVisibility(inMenuBar: model.settings.menuBarItems.contains(.network),
|
|
detailVisible: model.isNetworkDetailVisible)
|
|
}
|
|
|
|
private func applyAppearance() {
|
|
switch model.settings.appearance {
|
|
case .system: NSApp.appearance = nil
|
|
case .light: NSApp.appearance = NSAppearance(named: .aqua)
|
|
case .dark: NSApp.appearance = NSAppearance(named: .darkAqua)
|
|
}
|
|
}
|
|
|
|
/// 屏幕休眠、系统睡眠、切换用户、锁屏时暂停采样与探测;唤醒后重新下发风扇设置
|
|
private func observeWorkspace() {
|
|
let center = NSWorkspace.shared.notificationCenter
|
|
let pauses: [Notification.Name] = [NSWorkspace.screensDidSleepNotification,
|
|
NSWorkspace.willSleepNotification,
|
|
NSWorkspace.sessionDidResignActiveNotification]
|
|
let resumes: [Notification.Name] = [NSWorkspace.screensDidWakeNotification,
|
|
NSWorkspace.didWakeNotification,
|
|
NSWorkspace.sessionDidBecomeActiveNotification]
|
|
for name in pauses {
|
|
workspaceObservers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
|
MainActor.assumeIsolated { self?.pauseForInactivity() }
|
|
})
|
|
}
|
|
for name in resumes {
|
|
workspaceObservers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
|
MainActor.assumeIsolated { self?.resumeFromInactivity() }
|
|
})
|
|
}
|
|
// 锁屏后显示器可能仍亮着一段时间;这期间不应计入专注,也不应继续播放休息音效。
|
|
let distributed = DistributedNotificationCenter.default()
|
|
workspaceObservers.append(distributed.addObserver(forName: .init("com.apple.screenIsLocked"),
|
|
object: nil, queue: .main) { [weak self] _ in
|
|
MainActor.assumeIsolated {
|
|
self?.screenLocked = true
|
|
self?.pauseForInactivity()
|
|
}
|
|
})
|
|
workspaceObservers.append(distributed.addObserver(forName: .init("com.apple.screenIsUnlocked"),
|
|
object: nil, queue: .main) { [weak self] _ in
|
|
MainActor.assumeIsolated {
|
|
self?.screenLocked = false
|
|
self?.resumeFromInactivity()
|
|
}
|
|
})
|
|
}
|
|
|
|
private func pauseForInactivity() {
|
|
screenTimePausedByWorkspace = true
|
|
menuBar.dismissPopovers()
|
|
model.network.setPaused(true)
|
|
model.aiUsage.setPaused(true)
|
|
model.history.flush()
|
|
model.screenTime.stop()
|
|
restWindows.hideRest()
|
|
rest.suspend()
|
|
Task { await model.hub.setPaused(true) }
|
|
}
|
|
|
|
private func resumeFromInactivity() {
|
|
// 系统唤醒时常仍停在锁屏界面,要等解锁后再恢复。
|
|
guard !screenLocked else { return }
|
|
screenTimePausedByWorkspace = false
|
|
model.network.setPaused(false)
|
|
model.aiUsage.setPaused(false)
|
|
model.screenTime.start()
|
|
rest.sync()
|
|
if rest.phase.isResting && rest.isRunning { restWindows.ensureRestVisible() }
|
|
Task {
|
|
await model.hub.setPaused(false)
|
|
await model.fans.reapply()
|
|
}
|
|
}
|
|
|
|
private func observeScreenTimeHistorySetting() {
|
|
withObservationTracking {
|
|
_ = model.settings.historyEnabled
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
self.model.screenTime.historySettingChanged()
|
|
if self.model.settings.historyEnabled && !self.screenTimePausedByWorkspace {
|
|
self.model.screenTime.start()
|
|
} else {
|
|
self.model.screenTime.stop()
|
|
}
|
|
self.observeScreenTimeHistorySetting()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func observeRestSettings() {
|
|
withObservationTracking {
|
|
_ = model.settings.restEnabled
|
|
_ = model.settings.restWorkMinutes
|
|
_ = model.settings.restBreakMinutes
|
|
_ = model.settings.restLongBreakMinutes
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
self.rest.restart()
|
|
self.restMenuBar.sync()
|
|
if !self.model.settings.restEnabled { self.restWindows.hideHUD() }
|
|
self.observeRestSettings()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 关闭清理模块时停止尚未结束的扫描或清理,并关闭其独立窗口。
|
|
private func observeCleanerSetting() {
|
|
withObservationTracking {
|
|
_ = model.settings.cleanerEnabled
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
if !self.model.settings.cleanerEnabled {
|
|
self.model.cleaner.cancelClean()
|
|
self.model.cleaner.cancelOperation()
|
|
self.model.projectPurge.isConfirming = false
|
|
self.model.projectPurge.cancelScan()
|
|
self.model.projectPurge.cancelClean()
|
|
self.projectPurgeWindow.close()
|
|
}
|
|
self.observeCleanerSetting()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func observeRestMode() {
|
|
withObservationTracking {
|
|
_ = model.settings.restMode
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
self.rest.modeDidChange()
|
|
self.observeRestMode()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func observeRestSound() {
|
|
withObservationTracking {
|
|
_ = model.settings.restSound
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor [weak self] in
|
|
self?.rest.syncSound()
|
|
self?.observeRestSound()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum XStatsApplication {
|
|
@MainActor
|
|
public static func main() {
|
|
// 滚动条一律用浮层样式:接了鼠标时系统默认是常驻滚动条,SwiftUI 的 ScrollView 会给它预留一条宽度,
|
|
// 弹窗里的内容就会整体偏左。这是本应用自己的偏好域,只影响 XStats
|
|
UserDefaults.standard.set("WhenScrolling", forKey: "AppleShowScrollBars")
|
|
let app = NSApplication.shared
|
|
// 界面语言在创建任何界面之前确定,切换后重启生效
|
|
let stored = UserDefaults.standard.string(forKey: AppSettings.languageKey).flatMap(AppLanguage.init(rawValue:)) ?? .system
|
|
L10n.configure(stored)
|
|
if let index = CommandLine.arguments.firstIndex(of: "--snapshot") {
|
|
let path = CommandLine.arguments.dropFirst(index + 1).first ?? "snapshots"
|
|
// --language 接受语言代码或设置枚举值,缺失翻译记录到 untranslated.txt。
|
|
if let languageIndex = CommandLine.arguments.firstIndex(of: "--language"),
|
|
let identifier = CommandLine.arguments.dropFirst(languageIndex + 1).first {
|
|
let language = AppLanguage(rawValue: identifier) ?? AppLanguage.resolve(preferredLanguages: [identifier])
|
|
L10n.configure(language)
|
|
let log = URL(fileURLWithPath: path).appendingPathComponent("untranslated.txt")
|
|
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: path), withIntermediateDirectories: true)
|
|
try? FileManager.default.removeItem(at: log)
|
|
L10n.missLog = log
|
|
}
|
|
app.setActivationPolicy(.prohibited)
|
|
Task {
|
|
await SnapshotRenderer.run(outputDirectory: URL(fileURLWithPath: path))
|
|
exit(0)
|
|
}
|
|
app.run()
|
|
return
|
|
}
|
|
stored.applyToProcessLocale()
|
|
|
|
let controller = AppController()
|
|
app.delegate = controller
|
|
app.setActivationPolicy(.accessory)
|
|
withExtendedLifetime(controller) {
|
|
app.run()
|
|
}
|
|
}
|
|
}
|