* fix(security): 修复辅助工具鉴权降级与五处正确性缺陷
代码审查(安全/质量/性能/架构四维度)发现的问题,逐条修复。
辅助工具鉴权(High)
ad-hoc 构建下 clientRequirement 退化为只校验 bundle identifier,不含
anchor 约束,任何进程 codesign -s - 伪造同名即可连上 root 辅助工具,
驱动 DNS、睡眠、本地快照与风扇。project.yml 默认就是 ad-hoc 签名,
而 make build 是文档化的安装路径,开发机上属常态。
改为无团队签名时返回 nil 并拒绝连接,与在线升级在 ad-hoc 下直接禁用
的策略一致;此时 App 走一次性管理员授权回退,功能不丢。
卸载器批量误删
validate 只拒绝 com.apple. 前缀,包名为 "com" 的应用可通过;随后
hasPrefix("com.") 把 leftovers 扫描的十余个目录(Preferences、Caches、
Containers、Saved Application State 等)下所有 com.* 条目当成残留列出
并默认勾选,一次点击即整体移入废纸篓。前缀匹配改为要求至少三段反向
域名,精确匹配不受影响。
退出时静默跳过恢复睡眠
restoreDefaultsSynchronously 的 guard 在循环体内用 return 而非 continue,
第一步取代理失败就连带跳过第二步,Mac 保持禁止睡眠。同时把两步各等 1 秒
改为总预算 1.5 秒,避免主线程退出卡顿。
公网 IP 查询重入保护失效
isLookingUpPublic 同时承担重入锁与转圈开关两个相反职责,有缓存且非强制
刷新时恒为 false,guard 形同虚设,两个 Task 可各发三次请求并发写缓存,
失败结果可能覆盖好结果。拆出独立的 isFetchingPublic,并用 defer 确保
所有退出路径复位。
诊断导出的崩溃报告未脱敏
日志与清理记录都走了 redact,但 .ips 崩溃报告原样入包,其中可能夹带
进程内存中的 WebDAV 目录、用户名、公网 IP 与主机名,与界面“不含 IP、
序列号与硬件地址”的声明不符。改为同样脱敏后写入。
PowerSampler
channels(in:) 的 unsafeBitCast 改为 Unmanaged.fromOpaque.takeUnretainedValue。
未加 deinit 释放 IOReport 订阅:IOReport 是私有框架,无公开释放入口且
无法验证其是否为 CF 对象,而全进程只有一个 PowerSampler、订阅各只创建
一次且随进程存在,不是增长型泄漏——已就此写明注释。
补回归测试:clientRequirement 的 ad-hoc 分支与 anchor 约束、
matchesIdentifier 的过宽前缀与派生名匹配。
make test 141 项通过(新增 4 项),Release 完整构建通过。
* fix(security): 完善辅助工具升级与卸载边界校验
* fix(ui): 拆分评分条视图以解决 CI 类型检查超时
* fix(webdav): 规范化目录路径以兼容 macOS 15
208 lines
11 KiB
Swift
208 lines
11 KiB
Swift
import AppKit
|
||
import Foundation
|
||
import HelperShared
|
||
import Localization
|
||
import Metrics
|
||
import Observation
|
||
import os
|
||
|
||
/// 运行日志:写入系统统一日志(子系统 work.12306.xstats.app),导出诊断信息时一并收集
|
||
enum Log {
|
||
static let subsystem = "work.12306.xstats.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")
|
||
static let sync = Logger(subsystem: subsystem, category: "sync")
|
||
}
|
||
|
||
/// 导出诊断信息:版本与系统、辅助工具状态、主要设置、最近 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 = tr("XStats-诊断-\(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(tr("导出失败:\(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(tr("# XStats 诊断信息"))
|
||
row(tr("导出时间"), ISO8601DateFormatter().string(from: Date()))
|
||
|
||
section(tr("应用"))
|
||
row(tr("版本"), bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString"))
|
||
row(tr("构建"), bundle.object(forInfoDictionaryKey: "CFBundleVersion"))
|
||
row(tr("位置"), bundle.bundleURL.path.replacingOccurrences(of: NSHomeDirectory(), with: "~"))
|
||
row(tr("签名团队"), CodeSigningInfo.currentTeamIdentifier() ?? tr("未签名(开发构建)"))
|
||
row(tr("已运行"), Format.uptime(since: launchDate))
|
||
|
||
section(tr("系统"))
|
||
row(tr("机型"), tr("\(store.system.modelName)(\(store.system.modelIdentifier))"))
|
||
row("macOS", tr("\(store.system.osVersion)(\(store.system.osBuild))"))
|
||
row(tr("处理器核心"), process.activeProcessorCount)
|
||
row(tr("内存"), Format.bytes(process.physicalMemory))
|
||
row(tr("开机以来"), Format.uptime(since: Date().addingTimeInterval(-process.systemUptime)))
|
||
row(tr("语言"), Locale.preferredLanguages.prefix(3).joined(separator: ", "))
|
||
row(tr("温度传感器"), store.sensors.map { tr("\($0.temperatures.count) 组") })
|
||
row(tr("风扇"), store.sensors.map { tr("\($0.fans.count) 个") })
|
||
row(tr("电池"), store.battery.map { tr("\(Format.percent($0.level)),\($0.isPluggedIn ? tr("接通电源") : tr("使用电池")),健康 \($0.health.map(Format.percent) ?? "—")") } ?? tr("无"))
|
||
|
||
section(tr("辅助工具"))
|
||
row(tr("状态"), model.helper.status.title)
|
||
if case .unavailable(let reason) = model.helper.status { row(tr("原因"), reason) }
|
||
row(tr("协议版本"), tr("应用 \(HelperConstants.protocolVersion) / 辅助工具 \(remoteVersion.map(String.init) ?? tr("未连接"))"))
|
||
row(tr("最近错误"), model.helper.lastError)
|
||
|
||
section(tr("状态"))
|
||
row(tr("风扇模式"), model.fans.mode.title)
|
||
row(tr("防休眠"), model.keepAwake.isActive ? model.keepAwake.mode.title : tr("关闭"))
|
||
row(tr("合盖运行"), model.keepAwake.lidClosedActive ? tr("开启") : tr("关闭"))
|
||
row(tr("登录时启动"), model.launchAtLoginEnabled ? tr("开启") : tr("关闭"))
|
||
row(tr("在线升级"), tr("上次检查 \(model.updates.lastChecked.map { ISO8601DateFormatter().string(from: $0) } ?? tr("从未")),最新 \(model.updates.release?.version ?? tr("当前版本"))"))
|
||
|
||
section(tr("设置"))
|
||
row(tr("菜单栏项目"), settings.orderedMenuBarItems.map(\.rawValue).joined(separator: ", "))
|
||
row(tr("菜单栏布局"), settings.menuBarLayout.rawValue)
|
||
row(tr("菜单栏风格"), settings.menuBarStyle.rawValue)
|
||
row(tr("刷新间隔"), tr("\(settings.refreshSeconds) 秒"))
|
||
row(tr("外观"), settings.appearance.rawValue)
|
||
row(tr("连接探测"), settings.probeEnabled ? tr("\(settings.probeTarget.rawValue),后台\(settings.probeInBackground ? tr("开启") : tr("关闭"))") : tr("关闭"))
|
||
row(tr("公网 IP 查询"), settings.publicIPLookup ? tr("开启") : tr("关闭"))
|
||
row(tr("风扇安全温度"), "\(settings.fanSafetyTemperature)°C")
|
||
row(tr("合盖电量下限"), "\(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("XStats-diagnostics-\(UUID().uuidString)")
|
||
let folder = work.appendingPathComponent(tr("XStats-诊断-\(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 == \"work.12306.xstats.helper\""])
|
||
try redact(log).write(to: folder.appendingPathComponent("xstats.log"), atomically: true, encoding: .utf8)
|
||
|
||
let home = URL(fileURLWithPath: NSHomeDirectory())
|
||
let cleanup = home.appendingPathComponent("Library/Logs/XStats/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("XStats") }
|
||
.sorted { modified($0) > modified($1) }
|
||
.prefix(5)
|
||
if !crashes.isEmpty {
|
||
let crashFolder = folder.appendingPathComponent("crashes")
|
||
try manager.createDirectory(at: crashFolder, withIntermediateDirectories: true)
|
||
// 崩溃报告同样要脱敏:.ips 里可能夹带进程内存中的字符串(WebDAV 目录、用户名、公网 IP、
|
||
// 主机名),而界面承诺导出内容不含 IP、序列号与硬件地址。读不出文本的不放进压缩包。
|
||
for crash in crashes {
|
||
guard let text = try? String(contentsOf: crash, encoding: .utf8) else { continue }
|
||
try? redact(text).write(to: crashFolder.appendingPathComponent(crash.lastPathComponent),
|
||
atomically: true, encoding: .utf8)
|
||
}
|
||
}
|
||
|
||
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: "XStats", 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"#, "<MAC>"),
|
||
(#"\b(?:[0-9a-fA-F]{1,4}:){3,7}[0-9a-fA-F]{1,4}\b"#, "<IPv6>"),
|
||
(#"(?:\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)?"#, "<IPv6>"),
|
||
(#"\b(?:\d{1,3}\.){3}\d{1,3}\b"#, "<IPv4>"),
|
||
]
|
||
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()
|
||
}
|