From 3117b69debb2f97d8d8c22686e3c4c48a7ef8a7e Mon Sep 17 00:00:00 2001 From: gentpan Date: Sun, 13 Sep 2026 08:11:54 +0500 Subject: [PATCH] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E8=AF=8A=E6=96=AD=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E4=B8=8E=E8=BF=90=E8=A1=8C=E6=97=A5=E5=BF=97=E3=80=81?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E9=80=BB=E8=BE=91=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 +- Helper/main.swift | 12 ++ Packages/OpenStatsKit/Package.swift | 1 + .../OpenStatsUI/AppKit/AppController.swift | 3 + .../OpenStatsUI/Settings/SettingsPages.swift | 37 +++- .../Sources/OpenStatsUI/State/AppModel.swift | 1 + .../OpenStatsUI/State/Diagnostics.swift | 203 ++++++++++++++++++ .../OpenStatsUI/State/FanController.swift | 1 + .../State/GeoDatabaseController.swift | 1 + .../OpenStatsUI/State/HelperClient.swift | 33 ++- .../State/KeepAwakeController.swift | 1 + .../State/MaintenanceController.swift | 2 + .../OpenStatsUI/State/UpdateController.swift | 4 + .../OpenStatsUITests/OpenStatsUITests.swift | 191 ++++++++++++++++ 14 files changed, 481 insertions(+), 12 deletions(-) create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/State/Diagnostics.swift create mode 100644 Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index fc72cd0..968ebca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ #### 新增 - 在线升级:启动时和之后每天检查官网版本清单,发现新版本时弹出版本号与更新摘要,可一键安装、以后再说或跳过此版本;下载后依次核对 sha256、包名与版本、开发者签名团队和 Apple 公证,任何一项不通过都放弃安装;旧版先备份再原地替换,失败自动还原,装完自动重启到新版。「设置 · 关于」与应用菜单里可以手动检查更新。 -- 辅助工具版本检查:启动时核对正在运行的辅助工具版本,比应用旧时在风扇、合盖运行和辅助工具设置里提示“重新安装”,侧边栏对应项目显示提醒圆点;有新版本时“关于”旁同样显示圆点。 +- 辅助工具版本检查:启动时核对正在运行的辅助工具版本;旧进程还在运行时先断开让它退出,由系统启动新版本,仍然旧时在风扇、合盖运行和辅助工具设置里提示“重新安装”,侧边栏对应项目显示提醒圆点;有新版本时“关于”旁同样显示圆点。 +- 导出诊断信息(设置 · 关于):把版本、系统与辅助工具状态、主要设置、最近 3 天的运行日志、清理记录和最近的崩溃报告打包成 zip,反馈问题时附上;自动去掉用户目录名、IP 与硬件地址,不含序列号。应用与辅助工具的关键操作和错误写入系统日志。 #### 调整 diff --git a/Helper/main.swift b/Helper/main.swift index da042ec..a4fad57 100644 --- a/Helper/main.swift +++ b/Helper/main.swift @@ -8,8 +8,12 @@ import Foundation import HelperShared +import os import SMC +/// 写入系统统一日志,应用“导出诊断信息”时一并收集 +private let log = Logger(subsystem: "com.openstats.helper", category: "helper") + final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProtocol, @unchecked Sendable { /// 所有可变状态只在该队列上访问 private let queue = DispatchQueue(label: "com.openstats.helper.state") @@ -26,8 +30,10 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto private var idleExit: DispatchWorkItem? func run() { + log.notice("辅助工具启动,协议版本 \(HelperConstants.protocolVersion)") queue.sync { fans = (try? SMCConnection()).map(FanControl.init) + if fans == nil { log.error("无法打开 SMC") } recoverFromPreviousRun() scheduleIdleExit() } @@ -72,6 +78,7 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto manualFans.insert(fan) reply(nil) } catch { + log.error("设置风扇 \(fan) 失败:\(String(describing: error), privacy: .public)") reply("设置风扇失败:\(error)") } } @@ -86,6 +93,7 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto manualFans.remove(fan) reply(nil) } catch { + log.error("恢复风扇 \(fan) 失败:\(String(describing: error), privacy: .public)") reply("恢复风扇失败:\(error)") } } @@ -123,6 +131,7 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto let result = runTool("/usr/sbin/networksetup", DNSConfiguration.networksetupArguments(service: service, servers: servers)) guard result.status == 0 else { + log.error("networksetup 失败:\(result.output, privacy: .public)") return reply("networksetup 执行失败:\(result.output.trimmingCharacters(in: .whitespacesAndNewlines))") } reply(run(.flushDNS)) @@ -135,6 +144,7 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto connections.remove(id) guard connections.isEmpty else { return } // 应用已退出或崩溃:把系统恢复到默认状态 + log.info("客户端全部断开,恢复风扇与睡眠设置") _ = resetFans() if sleepDisabledByHelper { _ = applySleepDisabled(false) } scheduleIdleExit() @@ -143,6 +153,7 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto private func recoverFromPreviousRun() { guard let data = try? Data(contentsOf: stateURL), let state = try? PropertyListDecoder().decode(PersistedState.self, from: data) else { return } + log.notice("上次异常退出,恢复风扇与睡眠设置") if state.sleepDisabled { sleepDisabledByHelper = true _ = applySleepDisabled(false) @@ -154,6 +165,7 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto idleExit?.cancel() let work = DispatchWorkItem { [weak self] in guard let self, self.connections.isEmpty else { return } + log.info("空闲退出") exit(0) } idleExit = work diff --git a/Packages/OpenStatsKit/Package.swift b/Packages/OpenStatsKit/Package.swift index 8391b4c..af68e71 100644 --- a/Packages/OpenStatsKit/Package.swift +++ b/Packages/OpenStatsKit/Package.swift @@ -25,6 +25,7 @@ let package = Package( .testTarget(name: "CleanerTests", dependencies: ["Cleaner"]), .testTarget(name: "HelperSharedTests", dependencies: ["HelperShared"]), .testTarget(name: "UpdatesTests", dependencies: ["Updates"]), + .testTarget(name: "OpenStatsUITests", dependencies: ["OpenStatsUI", "Metrics"]), ], swiftLanguageModes: [.v6] ) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift index de38ea9..e5e4256 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift @@ -16,6 +16,9 @@ public final class AppController: NSObject, NSApplicationDelegate { } public func applicationDidFinishLaunching(_ notification: Notification) { + _ = DiagnosticsExporter.launchDate + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "开发版" + Log.app.notice("OpenStats \(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) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift index 9085939..d12d575 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift @@ -73,7 +73,7 @@ struct GeneralSettings: View { } } GroupRow { - SettingRow(title: "刷新频率", subtitle: "只显示菜单栏时的采样间隔;打开弹窗或主窗口时固定为 1 秒") { + SettingRow(title: "刷新频率", subtitle: "只显示菜单栏时的采样间隔;打开弹窗或主窗口时为 1 秒(进程页 2 秒)") { SegmentedControl(selection: $settings.refreshSeconds, options: AppSettings.refreshOptions.map { ($0, "\($0) 秒") }) .frame(width: DS.Size.sidebarWidth + DS.Space.s12) @@ -89,6 +89,40 @@ struct GeneralSettings: View { } } +// MARK: - 诊断 + +struct DiagnosticsSettings: View { + @Environment(AppModel.self) private var model + + var body: some View { + let diagnostics = model.diagnostics + + SettingsGroup(caption: "反馈问题") { + GroupRow(showsDivider: false) { + SettingRow(title: "导出诊断信息", + subtitle: "打包版本、系统与辅助工具状态、主要设置、最近 3 天的运行日志和崩溃报告,反馈问题时附上;会去掉序列号、IP 与硬件地址") { + Button(diagnostics.phase == .collecting ? "正在导出…" : "导出…") { diagnostics.export(model: model) } + .buttonStyle(DSButtonStyle(kind: .secondary)) + .disabled(diagnostics.phase == .collecting) + } + } + switch diagnostics.phase { + case .finished(let url): + GroupRow { + InfoBanner(icon: "checkmark.circle.fill", text: "已导出 \(url.lastPathComponent)", tone: .success) { + Button("在访达中显示") { diagnostics.reveal() } + .buttonStyle(DSButtonStyle(kind: .secondary)) + } + } + case .failed(let message): + GroupRow { InfoBanner(icon: "exclamationmark.triangle.fill", text: message, tone: .error) } + default: + EmptyView() + } + } + } +} + // MARK: - 菜单栏 struct MenuBarSettings: View { @@ -612,6 +646,7 @@ struct AboutSettings: View { } UpdateSettings() + DiagnosticsSettings() SettingsGroup(caption: "致谢") { GroupRow(showsDivider: false) { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index 7930123..d951796 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -19,6 +19,7 @@ public final class AppModel { public let geo: GeoDatabaseController public let explainer = ProcessExplainer() public let updates: UpdateController + let diagnostics = DiagnosticsExporter() @ObservationIgnored public let hub = MetricsHub() public var isMainWindowVisible = false diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/Diagnostics.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/Diagnostics.swift new file mode 100644 index 0000000..3a776dd --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/Diagnostics.swift @@ -0,0 +1,203 @@ +import AppKit +import Foundation +import HelperShared +import Metrics +import Observation +import os + +/// 运行日志:写入系统统一日志(子系统 com.openstats.app),导出诊断信息时一并收集 +enum Log { + static let subsystem = "com.openstats.app" + static let app = Logger(subsystem: subsystem, category: "app") + static let helper = Logger(subsystem: subsystem, category: "helper") + static let fans = Logger(subsystem: subsystem, category: "fans") + static let power = Logger(subsystem: subsystem, category: "power") + static let network = Logger(subsystem: subsystem, category: "network") + static let update = Logger(subsystem: subsystem, category: "update") +} + +/// 导出诊断信息:版本与系统、辅助工具状态、主要设置、最近 3 天的运行日志、清理记录与崩溃报告,打成一个 zip。 +/// 不包含序列号、IP 地址与硬件地址。 +@MainActor +@Observable +final class DiagnosticsExporter { + enum Phase: Equatable { + case idle + case collecting + case finished(URL) + case failed(String) + } + + private(set) var phase: Phase = .idle + + func export(model: AppModel) { + guard phase != .collecting else { return } + let panel = NSSavePanel() + panel.nameFieldStringValue = "OpenStats-诊断-\(Self.fileDate()).zip" + panel.allowedContentTypes = [.zip] + panel.directoryURL = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first + NSApp.activate() + guard panel.runModal() == .OK, let destination = panel.url else { return } + + phase = .collecting + Task { + let summary = await Self.summary(model: model) + do { + try await Task.detached { try Self.writeArchive(summary: summary, to: destination) }.value + Log.app.notice("已导出诊断信息") + phase = .finished(destination) + } catch { + phase = .failed("导出失败:\(error.localizedDescription)") + } + } + } + + func reveal() { + if case .finished(let url) = phase { NSWorkspace.shared.activateFileViewerSelecting([url]) } + } + + // MARK: 内容 + + private static func summary(model: AppModel) async -> String { + let bundle = Bundle.main + let settings = model.settings + let store = model.store + let process = ProcessInfo.processInfo + let remoteVersion = await model.helper.remoteProtocolVersion() + var lines: [String] = [] + func section(_ title: String) { lines.append(""); lines.append("## \(title)") } + func row(_ key: String, _ value: Any?) { lines.append("\(key): \(value.map { "\($0)" } ?? "—")") } + + lines.append("# OpenStats 诊断信息") + row("导出时间", ISO8601DateFormatter().string(from: Date())) + + section("应用") + row("版本", bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString")) + row("构建", bundle.object(forInfoDictionaryKey: "CFBundleVersion")) + row("位置", bundle.bundleURL.path.replacingOccurrences(of: NSHomeDirectory(), with: "~")) + row("签名团队", CodeSigningInfo.currentTeamIdentifier() ?? "未签名(开发构建)") + row("已运行", Format.uptime(since: launchDate)) + + section("系统") + row("机型", "\(store.system.modelName)(\(store.system.modelIdentifier))") + row("macOS", "\(store.system.osVersion)(\(store.system.osBuild))") + row("处理器核心", process.activeProcessorCount) + row("内存", Format.bytes(process.physicalMemory)) + row("开机以来", Format.uptime(since: Date().addingTimeInterval(-process.systemUptime))) + row("语言", Locale.preferredLanguages.prefix(3).joined(separator: ", ")) + row("温度传感器", store.sensors.map { "\($0.temperatures.count) 组" }) + row("风扇", store.sensors.map { "\($0.fans.count) 个" }) + row("电池", store.battery.map { "\(Format.percent($0.level)),\($0.isPluggedIn ? "接通电源" : "使用电池"),健康 \($0.health.map(Format.percent) ?? "—")" } ?? "无") + + section("辅助工具") + row("状态", model.helper.status.title) + if case .unavailable(let reason) = model.helper.status { row("原因", reason) } + row("协议版本", "应用 \(HelperConstants.protocolVersion) / 辅助工具 \(remoteVersion.map(String.init) ?? "未连接")") + row("最近错误", model.helper.lastError) + + section("状态") + row("风扇模式", model.fans.mode.title) + row("防休眠", model.keepAwake.isActive ? model.keepAwake.mode.title : "关闭") + row("合盖运行", model.keepAwake.lidClosedActive ? "开启" : "关闭") + row("登录时启动", model.launchAtLoginEnabled ? "开启" : "关闭") + row("在线升级", "上次检查 \(model.updates.lastChecked.map { ISO8601DateFormatter().string(from: $0) } ?? "从未"),最新 \(model.updates.release?.version ?? "当前版本")") + let geo = model.geo.installed.values.sorted { $0.edition < $1.edition }.map { "\($0.edition) \($0.build)" } + row("归属地数据库", geo.isEmpty ? "未安装" : geo.joined(separator: ",")) + + section("设置") + row("菜单栏项目", settings.orderedMenuBarItems.map(\.rawValue).joined(separator: ", ")) + row("菜单栏布局", settings.menuBarLayout.rawValue) + row("菜单栏风格", settings.menuBarStyle.rawValue) + row("刷新间隔", "\(settings.refreshSeconds) 秒") + row("外观", settings.appearance.rawValue) + row("连接探测", settings.probeEnabled ? "\(settings.probeTarget.rawValue),\(settings.probeSeconds) 秒,后台\(settings.probeInBackground ? "开启" : "关闭")" : "关闭") + row("公网 IP 查询", settings.publicIPLookup ? "开启" : "关闭") + row("风扇安全温度", "\(settings.fanSafetyTemperature)°C") + row("合盖电量下限", "\(settings.lidModeBatteryFloor)%") + return lines.joined(separator: "\n") + "\n" + } + + nonisolated static func writeArchive(summary: String, to destination: URL) throws { + let manager = FileManager.default + let work = manager.temporaryDirectory.appendingPathComponent("OpenStats-diagnostics-\(UUID().uuidString)") + let folder = work.appendingPathComponent("OpenStats-诊断-\(fileDate())") + try manager.createDirectory(at: folder, withIntermediateDirectories: true) + defer { try? manager.removeItem(at: work) } + + try summary.write(to: folder.appendingPathComponent("summary.txt"), atomically: true, encoding: .utf8) + + // 统一日志:应用与辅助工具最近 3 天的记录 + let log = run("/usr/bin/log", ["show", "--style", "compact", "--last", "3d", "--info", + "--predicate", "subsystem == \"\(Log.subsystem)\" OR subsystem == \"com.openstats.helper\""]) + try redact(log).write(to: folder.appendingPathComponent("openstats.log"), atomically: true, encoding: .utf8) + + let home = URL(fileURLWithPath: NSHomeDirectory()) + let cleanup = home.appendingPathComponent("Library/Logs/OpenStats/cleanup.log") + if let text = try? String(contentsOf: cleanup, encoding: .utf8) { + let recent = text.split(separator: "\n").suffix(500).joined(separator: "\n") + try redact(recent).write(to: folder.appendingPathComponent("cleanup.log"), atomically: true, encoding: .utf8) + } + + // 最近 5 份崩溃 / 卡死报告 + let reports = home.appendingPathComponent("Library/Logs/DiagnosticReports") + let crashes = ((try? manager.contentsOfDirectory(at: reports, includingPropertiesForKeys: [.contentModificationDateKey])) ?? []) + .filter { $0.lastPathComponent.hasPrefix("OpenStats") } + .sorted { modified($0) > modified($1) } + .prefix(5) + if !crashes.isEmpty { + let crashFolder = folder.appendingPathComponent("crashes") + try manager.createDirectory(at: crashFolder, withIntermediateDirectories: true) + for crash in crashes { + try? manager.copyItem(at: crash, to: crashFolder.appendingPathComponent(crash.lastPathComponent)) + } + } + + try? manager.removeItem(at: destination) + let zip = run("/usr/bin/ditto", ["-c", "-k", "--sequesterRsrc", "--keepParent", folder.path, destination.path]) + guard manager.fileExists(atPath: destination.path) else { + throw NSError(domain: "OpenStats", code: 1, userInfo: [NSLocalizedDescriptionKey: zip]) + } + } + + /// 去掉用户目录名、IPv4 / IPv6 地址与硬件地址 + nonisolated static func redact(_ text: String) -> String { + var result = text.replacingOccurrences(of: NSHomeDirectory(), with: "~") + // 顺序有关:硬件地址先于 IPv6;IPv6 至少 4 段或含 “::”,避免把 12:30:45 这样的时间当成地址 + let patterns: [(String, String)] = [ + (#"\b(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}\b"#, ""), + (#"\b(?:[0-9a-fA-F]{1,4}:){3,7}[0-9a-fA-F]{1,4}\b"#, ""), + (#"(?:\b[0-9a-fA-F]{1,4})?(?::[0-9a-fA-F]{1,4})*::(?:[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4})*\b)?"#, ""), + (#"\b(?:\d{1,3}\.){3}\d{1,3}\b"#, ""), + ] + for (pattern, replacement) in patterns { + result = result.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression) + } + return result + } + + nonisolated private static func modified(_ url: URL) -> Date { + (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + } + + nonisolated private static func fileDate() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmm" + return formatter.string(from: Date()) + } + + nonisolated private static func run(_ executable: String, _ arguments: [String]) -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { try process.run() } catch { return error.localizedDescription } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return String(decoding: data, as: UTF8.self) + } + + /// AppController 启动时读取一次,记下应用启动时间 + static let launchDate = Date() +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/FanController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/FanController.swift index 529d400..cdbf93c 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/FanController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/FanController.swift @@ -104,6 +104,7 @@ public final class FanController { } private func show(_ message: String, isError: Bool) { + if isError { Log.fans.error("\(message, privacy: .public)") } else { Log.fans.info("\(message, privacy: .public)") } notice = message noticeIsError = isError } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/GeoDatabaseController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/GeoDatabaseController.swift index 2dd6b77..42ad1d8 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/GeoDatabaseController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/GeoDatabaseController.swift @@ -91,6 +91,7 @@ public final class GeoDatabaseController { message = (failure.text, true) } catch { message = ("更新失败:\(error.localizedDescription)", true) + Log.network.error("归属地数据库更新失败:\(error.localizedDescription, privacy: .public)") } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift index 3fd24cd..06fde2f 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift @@ -68,6 +68,7 @@ public final class HelperClient { try service.register() } catch { lastError = "安装失败:\(error.localizedDescription)" + Log.helper.error("安装失败:\(error.localizedDescription, privacy: .public)") } refreshStatus() if status == .requiresApproval { @@ -90,6 +91,7 @@ public final class HelperClient { } } catch { lastError = "卸载失败:\(error.localizedDescription)" + Log.helper.error("卸载失败:\(error.localizedDescription, privacy: .public)") } refreshStatus() } @@ -98,22 +100,32 @@ public final class HelperClient { public func reinstall() async { await uninstall() install() - await verifyVersion(recheckDelay: .zero) + await verifyVersion(allowRestart: false) } - /// 核对辅助工具的协议版本。辅助工具空闲 30 秒后自动退出,下次调用时 launchd 会启动应用包里的新版本, - /// 所以第一次发现版本旧时等它退出后再确认一次,避免刚升级完就误报 - func verifyVersion(recheckDelay: Duration = .seconds(45)) async { - guard let version = await remoteProtocolVersion(), version < HelperConstants.protocolVersion else { + /// 核对辅助工具的协议版本。版本旧通常是替换应用后旧的辅助工具进程还在运行(新应用一连上它就不会空闲退出): + /// 先断开连接让它空闲 30 秒后退出,再连接时 launchd 会启动应用包里的新版本;仍然旧才提示重新安装 + func verifyVersion(allowRestart: Bool = true) async { + let remote = await remoteProtocolVersion() + Log.helper.info("辅助工具状态 \(self.status.title, privacy: .public),协议版本 \(remote.map(String.init) ?? "未连接", privacy: .public)") + guard let version = remote, version < HelperConstants.protocolVersion else { isOutdated = false return } - if recheckDelay > .zero { - try? await Task.sleep(for: recheckDelay) - guard let again = await remoteProtocolVersion() else { return } - isOutdated = again < HelperConstants.protocolVersion - } else { + guard allowRestart else { isOutdated = true + return + } + connection?.invalidate() + connection = nil + try? await Task.sleep(for: .seconds(40)) + guard let again = await remoteProtocolVersion() else { return } + isOutdated = again < HelperConstants.protocolVersion + if isOutdated { + Log.helper.error("辅助工具协议版本 \(again) 低于应用的 \(HelperConstants.protocolVersion)") + } else { + // 新的辅助工具进程没有之前的风扇与睡眠设置,重新下发 + onReconnect?() } } @@ -187,6 +199,7 @@ public final class HelperClient { return await withCheckedContinuation { (continuation: CheckedContinuation) in let once = ResumeOnce(continuation) let proxy = connection.remoteObjectProxyWithErrorHandler { error in + Log.helper.error("XPC 调用失败:\(error.localizedDescription, privacy: .public)") once.resume("无法连接辅助工具:\(error.localizedDescription)") } as? OpenStatsHelperProtocol guard let proxy else { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/KeepAwakeController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/KeepAwakeController.swift index 10522d0..c0259ff 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/KeepAwakeController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/KeepAwakeController.swift @@ -183,6 +183,7 @@ public final class KeepAwakeController { } private func show(_ message: String, isError: Bool) { + if isError { Log.power.error("\(message, privacy: .public)") } else { Log.power.info("\(message, privacy: .public)") } notice = message noticeIsError = isError } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift index 46a4978..727ca0b 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift @@ -36,6 +36,7 @@ public final class MaintenanceController { : await Self.runWithAdministratorPrompt(shell: command.shellCommand, prompt: prompt) if let error { + Log.app.error("\(command.rawValue, privacy: .public) 失败:\(error, privacy: .public)") outcomes[command] = Outcome(text: error, isError: error != Self.cancelled) return } @@ -71,6 +72,7 @@ public final class MaintenanceController { error = await Self.runWithAdministratorPrompt(shell: shell, prompt: "OpenStats 需要管理员权限来修改“\(service)”的 DNS。") } if let error { + Log.network.error("设置 DNS 失败:\(error, privacy: .public)") dnsOutcome = Outcome(text: error, isError: error != Self.cancelled) } else { dnsOutcome = Outcome(text: servers.isEmpty ? "已恢复自动获取 DNS" : "DNS 已设置为 \(servers.joined(separator: "、"))", isError: false) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UpdateController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UpdateController.swift index a3cae7b..5253f98 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UpdateController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UpdateController.swift @@ -87,9 +87,11 @@ public final class UpdateController { } release = latest phase = .available + Log.update.notice("发现新版本 \(latest.version, privacy: .public)") // 手动检查时总是提示;自动检查时跳过用户选择忽略的版本 if userInitiated || latest.version != skippedVersion { onPrompt() } case .failure(let error): + Log.update.error("检查更新失败:\(error.localizedDescription, privacy: .public)") // 自动检查失败不打扰用户,只在关于页里显示 phase = userInitiated ? .failed(error.localizedDescription) : (release == nil ? .idle : .available) } @@ -168,11 +170,13 @@ public final class UpdateController { try await replaceWithAdministratorPrompt(app, with: candidate) } skippedVersion = nil + Log.update.notice("已安装 \(release.version, privacy: .public),重新启动") try? FileManager.default.removeItem(at: work) try UpdateInstaller.relaunch(app) terminate() } catch { // 取消下载时 URLSession 抛出的是 URLError,按任务是否被取消判断 + if !Task.isCancelled { Log.update.error("安装更新失败:\(error.localizedDescription, privacy: .public)") } phase = Task.isCancelled ? .available : .failed((error as? LocalizedError)?.errorDescription ?? error.localizedDescription) } diff --git a/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift b/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift new file mode 100644 index 0000000..cd62f05 --- /dev/null +++ b/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift @@ -0,0 +1,191 @@ +import Foundation +import Metrics +import Testing +@testable import OpenStatsUI + +private func isolatedDefaults() -> UserDefaults { + let name = "OpenStatsUITests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults +} + +@MainActor +@Suite struct SettingsTests { + @Test func defaultsAndInvalidValues() { + let defaults = isolatedDefaults() + defaults.set(7, forKey: "refreshSeconds") + defaults.set("bogus", forKey: "panelTab") + let settings = AppSettings(defaults: defaults) + #expect(settings.refreshSeconds == 2) + #expect(settings.panelTab == .overview) + #expect(settings.menuBarItems == [.cpu, .memory, .network]) + #expect(settings.probeEnabled && settings.probeInBackground && settings.autoCheckUpdates) + } + + @Test func persistsChanges() { + let defaults = isolatedDefaults() + let settings = AppSettings(defaults: defaults) + settings.menuBarItems = [.gpu, .fan] + settings.probeInBackground = false + settings.hiddenPopoverSections = [.cpuHeatmap] + let reloaded = AppSettings(defaults: defaults) + #expect(reloaded.menuBarItems == [.gpu, .fan]) + #expect(!reloaded.probeInBackground) + #expect(reloaded.hiddenPopoverSections == [.cpuHeatmap]) + #expect(reloaded.orderedMenuBarItems == [.gpu, .fan]) + } +} + +@Suite struct PanelTabTests { + @Test func sidebarGroupsCoverEveryTabOnce() { + let grouped = PanelTab.monitors + PanelTab.tools + PanelTab.settings + #expect(grouped.count == PanelTab.allCases.count) + #expect(Set(grouped) == Set(PanelTab.allCases)) + #expect(PanelTab.settings.allSatisfy { $0.isSettings }) + #expect(PanelTab.settingsAbout.headerTitle == "设置 · 关于") + } + + @Test func menuBarItemsMapToPages() { + for item in MenuBarItem.allCases { + let tab = PanelTab(item: item) + #expect(tab.menuBarItem == item || (item == .fan && tab == .thermal)) + } + } +} + +@MainActor +@Suite struct DemandTests { + private func model() -> AppModel { + AppModel(settings: AppSettings(defaults: isolatedDefaults())) + } + + @Test func menuBarOnlySamplesAtUserInterval() { + let model = model() + model.settings.refreshSeconds = 5 + #expect(model.demand.interval == .seconds(5)) + #expect(!model.demand.systemProcesses) + #expect(!model.demand.disk) + } + + @Test func processesPageRefreshesEveryTwoSeconds() { + let model = model() + model.isMainWindowVisible = true + model.settings.panelTab = .processes + #expect(model.demand.interval == .seconds(2)) + #expect(model.demand.processes && model.demand.systemProcesses) + + model.openPopover = .cpu + #expect(model.demand.interval == .seconds(1)) + + model.openPopover = nil + model.settings.panelTab = .overview + #expect(model.demand.interval == .seconds(1)) + #expect(!model.demand.systemProcesses) + } + + @Test func networkDetailVisibility() { + let model = model() + #expect(!model.isNetworkDetailVisible) + model.openPopover = .network + #expect(model.isNetworkDetailVisible) + model.openPopover = nil + model.isMainWindowVisible = true + model.settings.panelTab = .network + #expect(model.isNetworkDetailVisible) + } +} + +@MainActor +@Suite struct UpdateControllerTests { + @Test func automaticCheckRespectsSettingAndInterval() { + let defaults = isolatedDefaults() + let settings = AppSettings(defaults: defaults) + settings.autoCheckUpdates = false + let controller = UpdateController(settings: settings, defaults: defaults) + controller.checkIfNeeded() + #expect(controller.phase == .idle) + + settings.autoCheckUpdates = true + defaults.set(Date(), forKey: "updateLastChecked") + let recent = UpdateController(settings: settings, defaults: defaults) + recent.checkIfNeeded() + #expect(recent.phase == .idle) + } + + @Test func developmentBuildCannotInstall() { + // 测试进程不是签名的 OpenStats.app + let controller = UpdateController(settings: AppSettings(defaults: isolatedDefaults())) + #expect(controller.installBlockedReason != nil) + } +} + +@Suite struct DiagnosticsTests { + @Test func redactsAddressesAndHome() { + let text = "\(NSHomeDirectory())/Library/x 192.168.1.20 fe80::1c2b:3aff:fe4d:5e6f 2001:db8:0:0:1:2:3:4 ::1 a4:83:e7:12:34:56 12:30:45 版本 0.2.0" + let redacted = DiagnosticsExporter.redact(text) + #expect(redacted.hasPrefix("~/Library/x")) + #expect(!redacted.contains("192.168.1.20")) + #expect(!redacted.contains("a4:83:e7")) + #expect(!redacted.contains("fe80::1c2b")) + #expect(!redacted.contains("2001:db8")) + #expect(!redacted.contains("::1")) + #expect(redacted.contains("")) + #expect(redacted.contains("12:30:45")) + #expect(redacted.contains("0.2.0")) + } +} + +@MainActor +@Suite struct ProcessRowTests { + private func process(_ pid: Int32, _ name: String, bundle: String?, cpu: Double, memory: UInt64, owned: Bool = true) -> ProcessUsage { + var usage = ProcessUsage(pid: pid, name: name, executablePath: nil, appBundlePath: bundle, cpu: cpu, memory: memory) + usage.isOwned = owned + usage.threads = 4 + return usage + } + + @Test func groupsHelpersIntoTheirApp() { + let rows = ProcessRowModel.grouped([ + process(10, "Safari", bundle: "/Applications/Safari.app", cpu: 10, memory: 100), + process(11, "Safari Web Content", bundle: "/Applications/Safari.app", cpu: 5, memory: 300), + process(12, "cloudd", bundle: nil, cpu: 1, memory: 50), + ]) + #expect(rows.count == 2) + let safari = rows.first { $0.count == 2 } + #expect(safari?.cpu == 15) + #expect(safari?.memory == 400) + #expect(safari?.threads == 8) + #expect(safari?.pid == nil) + } + + @Test func blocksQuittingProtectedProcesses() { + #expect(ProcessRowModel(process: process(1, "launchd", bundle: nil, cpu: 0, memory: 1, owned: false)).quitBlockedReason != nil) + #expect(ProcessRowModel(process: process(getpid(), "OpenStats", bundle: nil, cpu: 0, memory: 1)).quitBlockedReason != nil) + #expect(ProcessRowModel(process: process(99, "loginwindow", bundle: nil, cpu: 0, memory: 1)).quitBlockedReason != nil) + #expect(ProcessRowModel(process: process(98, "Notes", bundle: nil, cpu: 0, memory: 1)).quitBlockedReason == nil) + } + + @Test func narrowTablesDropColumns() { + #expect(ProcessColumn.visible(forWidth: 2000) == ProcessColumn.allCases) + #expect(ProcessColumn.visible(forWidth: 300) == [.name, .cpu, .memory, .user]) + } +} + +@Suite struct DiagnosticsArchiveTests { + @Test func writesZipWithSummaryAndLog() throws { + let destination = FileManager.default.temporaryDirectory.appendingPathComponent("diag-\(UUID().uuidString).zip") + defer { try? FileManager.default.removeItem(at: destination) } + try DiagnosticsExporter.writeArchive(summary: "# 测试\n", to: destination) + let listing = Process() + listing.executableURL = URL(fileURLWithPath: "/usr/bin/zipinfo") + listing.arguments = ["-1", destination.path] + let pipe = Pipe() + listing.standardOutput = pipe + try listing.run() + let names = String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + listing.waitUntilExit() + #expect(names.contains("summary.txt")) + #expect(names.contains("openstats.log")) + } +}