From 86f0b4bd20cdec86dfb5bdb7978d1f6ff4af111a Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 14 Aug 2026 22:41:54 +0800
Subject: [PATCH] [verified] feat: export performance diagnostics
---
C1.source/Sources/Aligner/DevelopmentDiagnostics.swift | 8
C1.source/Sources/AlignerCore/Diagnostics/PerformanceDiagnosticArchiveWriter.swift | 154 +++++++++++++++++
C1.source/CODEGRAPH.md | 33 +++
C1.source/Sources/Aligner/PerformanceDiagnosticsExporter.swift | 157 +++++++++++++++++
C1.source/Resources/Aligner-Info.plist | 4
C1.source/Sources/Aligner/AlignerApplicationDelegate.swift | 25 ++
C1.source/Sources/Aligner/QuickSwitchSessionController.swift | 34 +++
CHANGELOG.md | 6
C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift | 84 +++++++++
9 files changed, 503 insertions(+), 2 deletions(-)
diff --git a/C1.source/CODEGRAPH.md b/C1.source/CODEGRAPH.md
index bb59b00..fc72b95 100644
--- a/C1.source/CODEGRAPH.md
+++ b/C1.source/CODEGRAPH.md
@@ -25,6 +25,7 @@
- 功能系列:`0.1.x`
- 当前可核对 tag:`v0.1.13-build20260814.0445`
- 最近本地验收候选包:`0.1.13 / 20260814.0445`
+- 当前开发变更:`0.1.14 / 20260814.2230` 本地性能诊断包,尚未形成新的冻结基线。
- 最后冻结前验证:2026-08-14,`swift build`、`swift test`、`round1-app-shelf-fixture-qa.sh`、DMG 挂载元数据检查和 mounted App `codesign --verify --deep --strict` 通过;用户实测两行 App Shelf 遮挡问题已解决。
- 发布事实:`0.1.13` 是本地验收通过的代码冻结 / rollback baseline;不是正式官网 release,Developer ID、notarization、staple、Gatekeeper、下载源和正式发布 manifest 仍未闭环。
- Git 根目录:`/Users/ar/Projects/Aligner/03-O`
@@ -59,6 +60,7 @@
| `QuickSwitch.Spaces` | Space 枚举、窗口 Space 归属、Space 激活 | `Sources/Aligner/Infrastructure/PrivateAPI/SkyLightSpaceEnumerator.swift` |
| `QuickSwitch.ThemeAndSettings` | 视图模式、主题、设置持久化、Settings 入口 | `Sources/AlignerCore/Preferences/AlignerPreferences.swift` |
| `QuickSwitch.TriggerAndPermissions` | 快捷键、权限、Force Quit 放行、权限窗口 | `Sources/Aligner/AlignerApplicationDelegate.swift` |
+| `Diagnostics.PerformanceExport` | 性能事件循环日志、跨机器诊断包导出和归档结构 | `Sources/Aligner/DevelopmentDiagnostics.swift` |
| `QuickSwitch.Screenshots` | 真实截图、ScreenCaptureKit、骨架 fallback | `Sources/AlignerCore/Screenshots/ScreenCaptureKitScreenshotProvider.swift` |
| `Commerce.Entitlement` | Round01.5 试用、授权、Pro feature gate 模型 | `Sources/AlignerCore/Commerce/` |
@@ -110,6 +112,37 @@
- `../C2.builds/Z-研发中间产物/qa-reports/round01-overlay-qa-report.json`
- `../C2.builds/Z-研发中间产物/qa-reports/round01-close-card-confirmation-report.json`
+## Node: Diagnostics.PerformanceExport
+
+职责:
+
+- 保留最近的结构化性能事件,并在用户遇到卡顿后导出本地 zip 诊断包。
+- 汇总版本、硬件、显示器、权限、偏好、最近 Quick Switch 阶段耗时,以及应用名称和窗口数量。
+
+核心文件:
+
+- `Sources/Aligner/DevelopmentDiagnostics.swift`
+- `Sources/Aligner/PerformanceDiagnosticsExporter.swift`
+- `Sources/Aligner/QuickSwitchSessionController.swift`
+- `Sources/AlignerCore/Diagnostics/PerformanceDiagnosticArchiveWriter.swift`
+
+关键入口:
+
+- `AlignerApplicationDelegate.exportPerformanceDiagnostics()`
+- `QuickSwitchSessionController.performanceDiagnosticSummary()`
+- `PerformanceDiagnosticArchiveWriter.write(...)`
+
+已保护行为:
+
+- 导出使用最近一次已加载的 Quick Switch 快照;不得为了导出再次触发窗口枚举、截图或 Space 查询。
+- 诊断包允许包含应用名称、bundle identifier、进程 ID 和窗口聚合数量;不包含窗口标题、窗口截图或键入内容。
+- 日志写入与归档在 UI 主线程之外执行,且沿用已有 10MB 循环日志上限。
+
+QA 入口:
+
+- `Tests/AlignerCoreTests/AlignerCoreTests.swift`
+- `swift test`
+
## Node: QuickSwitch.Snapshot
职责:
diff --git a/C1.source/Resources/Aligner-Info.plist b/C1.source/Resources/Aligner-Info.plist
index 00a2234..4dd1261 100644
--- a/C1.source/Resources/Aligner-Info.plist
+++ b/C1.source/Resources/Aligner-Info.plist
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
- <string>0.1.13</string>
+ <string>0.1.14</string>
<key>CFBundleVersion</key>
- <string>20260814.0445</string>
+ <string>20260814.2230</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHighResolutionCapable</key>
diff --git a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
index 9904f96..83d35ba 100644
--- a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
+++ b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
@@ -27,6 +27,7 @@
private var statusItem: NSStatusItem?
private var verticalWaterfallViewModeMenuItems: [NSMenuItem] = []
private var horizontalWaterfallViewModeMenuItems: [NSMenuItem] = []
+ private let performanceDiagnosticsExporter = PerformanceDiagnosticsExporter()
func applicationDidFinishLaunching(_ notification: Notification) {
let bundlePathSummary = DevelopmentDiagnostics.pathSummary(Bundle.main.bundlePath)
@@ -104,6 +105,7 @@
menu.addItem(menuItem(title: "Settings...", action: #selector(showSettingsFromMenu), keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(menuItem(title: "Check Permissions...", action: #selector(showPermissions), keyEquivalent: ""))
+ menu.addItem(menuItem(title: "Export Performance Diagnostics...", action: #selector(exportPerformanceDiagnostics), keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(menuItem(title: "Show Quick Switch", action: #selector(showQuickSwitchFromMenu), keyEquivalent: ""))
menu.addItem(menuItem(title: "Hide Quick Switch", action: #selector(hideQuickSwitchFromMenu), keyEquivalent: ""))
@@ -139,6 +141,7 @@
appMenu.addItem(menuItem(title: "Settings...", action: #selector(showSettingsFromMenu), keyEquivalent: ","))
appMenu.addItem(.separator())
appMenu.addItem(menuItem(title: "Check Permissions...", action: #selector(showPermissions), keyEquivalent: ""))
+ appMenu.addItem(menuItem(title: "Export Performance Diagnostics...", action: #selector(exportPerformanceDiagnostics), keyEquivalent: ""))
appMenu.addItem(menuItem(title: "Show Quick Switch", action: #selector(showQuickSwitchFromMenu), keyEquivalent: ""))
appMenu.addItem(menuItem(title: "Hide Quick Switch", action: #selector(hideQuickSwitchFromMenu), keyEquivalent: ""))
appMenu.addItem(quickSwitchViewModeMenuItem())
@@ -890,6 +893,7 @@
return [
"hasAboutItem": statusItem?.menu?.item(withTitle: "About Aligner...") != nil,
"hasSettingsItem": statusItem?.menu?.item(withTitle: "Settings...") != nil,
+ "hasExportPerformanceDiagnosticsItem": statusItem?.menu?.item(withTitle: "Export Performance Diagnostics...") != nil,
"hasQuickSwitchViewSubmenu": statusItem?.menu?.item(withTitle: "Quick Switch View")?.submenu != nil,
"hasVerticalColumnsItem": verticalItem != nil,
"hasHorizontalWaterfallItem": horizontalItem != nil,
@@ -907,6 +911,7 @@
"hasAppMenu": appMenu != nil,
"hasAboutItem": appMenu?.item(withTitle: "About Aligner...") != nil,
"hasSettingsItem": appMenu?.item(withTitle: "Settings...") != nil,
+ "hasExportPerformanceDiagnosticsItem": appMenu?.item(withTitle: "Export Performance Diagnostics...") != nil,
"settingsKeyEquivalent": appMenu?.item(withTitle: "Settings...")?.keyEquivalent ?? "",
"hasQuickSwitchViewSubmenuInAppMenu": appMenu?.item(withTitle: "Quick Switch View")?.submenu != nil,
"hasQuickSwitchViewSubmenuInViewMenu": viewMenu?.item(withTitle: "Quick Switch View")?.submenu != nil
@@ -948,6 +953,26 @@
openSettingsWindow(source: "menu")
}
+ @objc private func exportPerformanceDiagnostics() {
+ let versionBuild = appVersionBuild()
+ let preferences = preferenceStore.read()
+ let quickSwitch = quickSwitchSessionController?.performanceDiagnosticSummary() ?? [
+ "snapshotLoaded": false,
+ "quickSwitchVisible": false,
+ "applicationCount": 0,
+ "windowCount": 0,
+ "applications": []
+ ]
+ performanceDiagnosticsExporter.export(
+ version: versionBuild.version,
+ build: versionBuild.build,
+ preferences: preferences,
+ accessibility: permissionStatusString(accessibilityStatus()),
+ screenRecording: permissionStatusString(screenRecordingStatus()),
+ quickSwitch: quickSwitch
+ )
+ }
+
private func showSettingsFromQuickSwitch() {
openSettingsWindow(source: "quickSwitch")
}
diff --git a/C1.source/Sources/Aligner/DevelopmentDiagnostics.swift b/C1.source/Sources/Aligner/DevelopmentDiagnostics.swift
index 9e64cb6..8bed8c5 100644
--- a/C1.source/Sources/Aligner/DevelopmentDiagnostics.swift
+++ b/C1.source/Sources/Aligner/DevelopmentDiagnostics.swift
@@ -9,6 +9,14 @@
return baseURL.appendingPathComponent("aligner-dev.log").path
}()
+ static var logURLs: [URL] {
+ let currentURL = URL(fileURLWithPath: logPath)
+ let previousURL = currentURL
+ .deletingLastPathComponent()
+ .appendingPathComponent("aligner-dev.previous.log")
+ return [currentURL, previousURL]
+ }
+
private static let queue = DispatchQueue(label: "com.ar.Aligner.development-diagnostics")
private static let maxLogBytes: UInt64 = 10 * 1024 * 1024
private static let runID = UUID().uuidString
diff --git a/C1.source/Sources/Aligner/PerformanceDiagnosticsExporter.swift b/C1.source/Sources/Aligner/PerformanceDiagnosticsExporter.swift
new file mode 100644
index 0000000..1aea448
--- /dev/null
+++ b/C1.source/Sources/Aligner/PerformanceDiagnosticsExporter.swift
@@ -0,0 +1,157 @@
+import AppKit
+import Darwin
+import Foundation
+import AlignerCore
+
+@MainActor
+final class PerformanceDiagnosticsExporter {
+ func export(
+ version: String,
+ build: String,
+ preferences: AlignerPreferences,
+ accessibility: String,
+ screenRecording: String,
+ quickSwitch: [String: Any]
+ ) {
+ let savePanel = NSSavePanel()
+ savePanel.title = "导出性能诊断包"
+ savePanel.nameFieldStringValue = "Aligner-Diagnostics-\(fileTimestamp()).zip"
+ savePanel.allowedContentTypes = [.zip]
+ savePanel.canCreateDirectories = true
+
+ NSApp.activate(ignoringOtherApps: true)
+ guard savePanel.runModal() == .OK, let destinationURL = savePanel.url else {
+ DevelopmentDiagnostics.log("diagnostics.export.cancelled")
+ return
+ }
+
+ let summaryData: Data
+ do {
+ summaryData = try makeSummaryData(
+ version: version,
+ build: build,
+ preferences: preferences,
+ accessibility: accessibility,
+ screenRecording: screenRecording,
+ quickSwitch: quickSwitch
+ )
+ } catch {
+ showFailure(error)
+ return
+ }
+
+ let logURLs = DevelopmentDiagnostics.logURLs
+ DevelopmentDiagnostics.logSync("diagnostics.export.start", ["logFileCount": logURLs.count])
+ DispatchQueue.global(qos: .utility).async {
+ do {
+ _ = try PerformanceDiagnosticArchiveWriter.write(
+ to: destinationURL,
+ summaryJSON: summaryData,
+ eventLogURLs: logURLs
+ )
+ DispatchQueue.main.async {
+ DevelopmentDiagnostics.log("diagnostics.export.succeeded", [
+ "archiveCreated": true
+ ])
+ self.showSuccess(destinationURL)
+ }
+ } catch {
+ DispatchQueue.main.async {
+ DevelopmentDiagnostics.log("diagnostics.export.failed", DevelopmentDiagnostics.errorSummaryFields(error))
+ self.showFailure(error)
+ }
+ }
+ }
+ }
+
+ private func makeSummaryData(
+ version: String,
+ build: String,
+ preferences: AlignerPreferences,
+ accessibility: String,
+ screenRecording: String,
+ quickSwitch: [String: Any]
+ ) throws -> Data {
+ let processInfo = ProcessInfo.processInfo
+ let systemVersion = processInfo.operatingSystemVersion
+ let displaySummaries = NSScreen.screens.map { screen in
+ [
+ "frame": NSStringFromRect(screen.frame),
+ "visibleFrame": NSStringFromRect(screen.visibleFrame),
+ "scale": screen.backingScaleFactor,
+ "isMain": screen == NSScreen.main
+ ] as [String: Any]
+ }
+ let summary: [String: Any] = [
+ "schemaVersion": 1,
+ "collectedAt": ISO8601DateFormatter().string(from: Date()),
+ "app": [
+ "name": "Aligner",
+ "version": version,
+ "build": build,
+ "bundleIdentifier": Bundle.main.bundleIdentifier ?? ""
+ ],
+ "system": [
+ "model": hardwareModel(),
+ "osVersion": processInfo.operatingSystemVersionString,
+ "osVersionComponents": "\(systemVersion.majorVersion).\(systemVersion.minorVersion).\(systemVersion.patchVersion)",
+ "processorCount": processInfo.processorCount,
+ "activeProcessorCount": processInfo.activeProcessorCount,
+ "physicalMemoryBytes": processInfo.physicalMemory,
+ "displays": displaySummaries
+ ],
+ "permissions": [
+ "accessibility": accessibility,
+ "screenRecording": screenRecording
+ ],
+ "preferences": [
+ "showMinimizedWindows": preferences.showMinimizedWindows,
+ "showFullscreenWindows": preferences.showFullscreenWindows,
+ "waterfallViewMode": preferences.quickSwitchWaterfallViewMode.rawValue,
+ "theme": preferences.theme.rawValue,
+ "language": preferences.language.rawValue,
+ "spaceDisplayStrategy": preferences.spaceDisplayStrategy.rawValue
+ ],
+ "quickSwitch": quickSwitch,
+ "collection": [
+ "includesApplicationNames": true,
+ "includesWindowCounts": true,
+ "includesWindowTitles": false,
+ "includesScreenshots": false,
+ "includesTypedText": false
+ ]
+ ]
+ return try JSONSerialization.data(withJSONObject: summary, options: [.prettyPrinted, .sortedKeys])
+ }
+
+ private func showSuccess(_ destinationURL: URL) {
+ let alert = NSAlert()
+ alert.messageText = "性能诊断包已导出"
+ alert.informativeText = "请把 \(destinationURL.lastPathComponent) 发给 Aligner 支持人员。"
+ alert.alertStyle = .informational
+ alert.addButton(withTitle: "好")
+ alert.runModal()
+ }
+
+ private func showFailure(_ error: Error) {
+ let alert = NSAlert(error: error)
+ alert.messageText = "无法导出性能诊断包"
+ alert.runModal()
+ }
+
+ private func fileTimestamp() -> String {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.dateFormat = "yyyyMMdd-HHmmss"
+ return formatter.string(from: Date())
+ }
+
+ private func hardwareModel() -> String {
+ var size = 0
+ guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 0 else { return "unknown" }
+ var buffer = [CChar](repeating: 0, count: size)
+ guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { return "unknown" }
+ let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
+ return String(decoding: bytes, as: UTF8.self)
+ }
+}
diff --git a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
index 8122f9a..79515d9 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
@@ -426,6 +426,40 @@
]
}
+ func performanceDiagnosticSummary() -> [String: Any] {
+ let applications = currentSnapshot?.appGroups.map { group in
+ [
+ "name": group.app.name,
+ "bundleIdentifier": group.app.bundleIdentifier,
+ "processIdentifier": group.app.processIdentifier ?? NSNull(),
+ "windowCount": group.windows.count,
+ "minimizedWindowCount": group.windows.filter { $0.window.isMinimized }.count,
+ "fullscreenWindowCount": group.windows.filter { $0.window.isFullscreen }.count
+ ] as [String: Any]
+ } ?? []
+
+ return [
+ "snapshotLoaded": currentSnapshot != nil,
+ "snapshotError": lastSnapshotError ?? NSNull(),
+ "quickSwitchVisible": isVisible,
+ "displayCount": currentSnapshot?.displays.count ?? 0,
+ "spaceCount": currentSnapshot?.displays.flatMap(\.spaces).count ?? 0,
+ "applicationCount": applications.count,
+ "windowCount": currentSnapshot?.appGroups.flatMap(\.windows).count ?? 0,
+ "applications": applications,
+ "timingsMilliseconds": [
+ "overlayOpen": overlayOpenElapsedMilliseconds ?? NSNull(),
+ "snapshotStart": snapshotStartElapsedMilliseconds ?? NSNull(),
+ "snapshot": snapshotDurationMilliseconds ?? NSNull(),
+ "lastActivation": lastActivationDurationMilliseconds ?? NSNull()
+ ] as [String: Any],
+ "snapshotRanOnMainThread": snapshotRanOnMainThread ?? NSNull(),
+ "lastActivationResult": lastActivationResult.map(Self.activationResultString) ?? NSNull(),
+ "lastActivationError": lastActivationError ?? NSNull(),
+ "lastDismissReason": lastDismissReason.map(Self.dismissReasonString) ?? NSNull()
+ ]
+ }
+
func prepareLifecycleRun(targetCycles: Int) {
lifecycleTargetCycles = max(0, targetCycles)
lifecycleCompletedCycles = 0
diff --git a/C1.source/Sources/AlignerCore/Diagnostics/PerformanceDiagnosticArchiveWriter.swift b/C1.source/Sources/AlignerCore/Diagnostics/PerformanceDiagnosticArchiveWriter.swift
new file mode 100644
index 0000000..31200ef
--- /dev/null
+++ b/C1.source/Sources/AlignerCore/Diagnostics/PerformanceDiagnosticArchiveWriter.swift
@@ -0,0 +1,154 @@
+import Foundation
+
+public enum PerformanceDiagnosticArchiveWriter {
+ public static func write(
+ to destinationURL: URL,
+ summaryJSON: Data,
+ eventLogURLs: [URL],
+ fileManager: FileManager = .default
+ ) throws -> URL {
+ guard !fileManager.fileExists(atPath: destinationURL.path) else {
+ throw ArchiveError.destinationAlreadyExists
+ }
+ let workingDirectory = fileManager.temporaryDirectory
+ .appendingPathComponent("Aligner-Diagnostics-\(UUID().uuidString)", isDirectory: true)
+ let contentsDirectory = workingDirectory
+ .appendingPathComponent("Aligner-Diagnostics", isDirectory: true)
+
+ try fileManager.createDirectory(at: contentsDirectory, withIntermediateDirectories: true)
+ defer { try? fileManager.removeItem(at: workingDirectory) }
+
+ try summaryJSON.write(
+ to: contentsDirectory.appendingPathComponent("summary.json"),
+ options: [.atomic]
+ )
+ try readmeText.write(
+ to: contentsDirectory.appendingPathComponent("README.txt"),
+ atomically: true,
+ encoding: .utf8
+ )
+
+ var copiedLogCount = 0
+ for (index, sourceURL) in eventLogURLs.enumerated() where fileManager.fileExists(atPath: sourceURL.path) {
+ let name = index == 0 ? "performance-events.log" : "performance-events-previous.log"
+ try sanitizedEventLogData(from: sourceURL).write(
+ to: contentsDirectory.appendingPathComponent(name),
+ options: [.atomic]
+ )
+ copiedLogCount += 1
+ }
+
+ if copiedLogCount == 0 {
+ try "No performance events have been recorded yet. Reproduce the issue, then export again.\n".write(
+ to: contentsDirectory.appendingPathComponent("performance-events.log"),
+ atomically: true,
+ encoding: .utf8
+ )
+ }
+
+ let temporaryArchiveURL = destinationURL
+ .deletingLastPathComponent()
+ .appendingPathComponent(".Aligner-Diagnostics-\(UUID().uuidString).zip")
+ defer { try? fileManager.removeItem(at: temporaryArchiveURL) }
+ try createArchive(
+ sourceDirectory: contentsDirectory,
+ destinationURL: temporaryArchiveURL
+ )
+ try fileManager.moveItem(at: temporaryArchiveURL, to: destinationURL)
+ return destinationURL
+ }
+
+ private static func createArchive(sourceDirectory: URL, destinationURL: URL) throws {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
+ process.arguments = [
+ "-c",
+ "-k",
+ "--sequesterRsrc",
+ "--keepParent",
+ sourceDirectory.path,
+ destinationURL.path
+ ]
+ try process.run()
+ process.waitUntilExit()
+ guard process.terminationStatus == 0 else {
+ throw ArchiveError.dittoFailed(status: process.terminationStatus)
+ }
+ }
+
+ private static func sanitizedEventLogData(from sourceURL: URL) throws -> Data {
+ let source = try String(contentsOf: sourceURL, encoding: .utf8)
+ let sanitized = source
+ .split(whereSeparator: \.isNewline)
+ .compactMap(sanitizedEventLine)
+ .joined(separator: "\n")
+ return Data((sanitized.isEmpty ? "No exportable performance events were recorded.\n" : sanitized + "\n").utf8)
+ }
+
+ private static func sanitizedEventLine(_ line: Substring) -> String? {
+ let source = String(line)
+ guard let event = tokenValue(named: "event", in: source) else { return nil }
+
+ let allowedKeys = [
+ "ts",
+ "event",
+ "pid",
+ "runID",
+ "generation",
+ "durationMilliseconds",
+ "overlayOpenElapsedMilliseconds",
+ "startElapsedMilliseconds",
+ "snapshotStartElapsedMilliseconds",
+ "lastActivationDurationMilliseconds",
+ "appCount",
+ "windowCount",
+ "displayCount",
+ "spaceCount",
+ "screenshotEligibleCount",
+ "screenshotResolvedCount",
+ "screenshotPendingCount",
+ "screenshotNotRequestedCount",
+ "ranOnMainThread",
+ "result",
+ "reason"
+ ]
+ let values = allowedKeys.compactMap { key -> String? in
+ let value = key == "event" ? event : tokenValue(named: key, in: source)
+ return value.map { "\(key)=\($0)" }
+ }
+ return values.isEmpty ? nil : values.joined(separator: " ")
+ }
+
+ private static func tokenValue(named key: String, in line: String) -> String? {
+ let prefix = "\(key)="
+ guard let range = line.range(of: prefix) else { return nil }
+ let suffix = line[range.upperBound...]
+ guard let first = suffix.first, first != "\"" else { return nil }
+ let value = suffix.prefix { !$0.isWhitespace }
+ return value.isEmpty ? nil : String(value)
+ }
+
+ private static let readmeText = """
+ Aligner performance diagnostics
+
+ This archive contains local performance information for troubleshooting:
+ - summary.json: app version, system, display, permissions, preferences, recent Quick Switch timings, application names, and window counts.
+ - performance-events.log: recent performance events with a fixed allowlist of timing and count fields.
+
+ It does not include window titles, screenshots, or typed text.
+ """
+
+ private enum ArchiveError: LocalizedError {
+ case destinationAlreadyExists
+ case dittoFailed(status: Int32)
+
+ var errorDescription: String? {
+ switch self {
+ case .destinationAlreadyExists:
+ return "A diagnostic archive already exists at the selected location."
+ case .dittoFailed(let status):
+ return "Failed to create the diagnostic archive (ditto exit status \(status))."
+ }
+ }
+ }
+}
diff --git a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
index 7c025f4..c0197df 100644
--- a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
+++ b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
@@ -9,6 +9,90 @@
XCTAssertEqual(AlignerAppInfo.minimumMacOSVersion, "14.0")
}
+ func testPerformanceDiagnosticArchiveWriterCreatesInspectableArchive() throws {
+ let fileManager = FileManager.default
+ let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
+ let logURL = rootURL.appendingPathComponent("events.log")
+ let archiveURL = rootURL.appendingPathComponent("diagnostics.zip")
+ let extractionURL = rootURL.appendingPathComponent("extracted", isDirectory: true)
+ defer { try? fileManager.removeItem(at: rootURL) }
+
+ try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true)
+ try "event=quickSwitch.snapshot.success durationMilliseconds=42\n".write(to: logURL, atomically: true, encoding: .utf8)
+ let summary = try JSONSerialization.data(withJSONObject: ["schemaVersion": 1], options: [])
+
+ _ = try PerformanceDiagnosticArchiveWriter.write(
+ to: archiveURL,
+ summaryJSON: summary,
+ eventLogURLs: [logURL]
+ )
+ XCTAssertTrue(fileManager.fileExists(atPath: archiveURL.path))
+
+ let unzipProcess = Process()
+ unzipProcess.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
+ unzipProcess.arguments = ["-x", "-k", archiveURL.path, extractionURL.path]
+ try unzipProcess.run()
+ unzipProcess.waitUntilExit()
+ XCTAssertEqual(unzipProcess.terminationStatus, 0)
+ let contentsURL = extractionURL.appendingPathComponent("Aligner-Diagnostics", isDirectory: true)
+ XCTAssertTrue(fileManager.fileExists(atPath: contentsURL.appendingPathComponent("summary.json").path))
+ XCTAssertTrue(fileManager.fileExists(atPath: contentsURL.appendingPathComponent("performance-events.log").path))
+ XCTAssertTrue(fileManager.fileExists(atPath: contentsURL.appendingPathComponent("README.txt").path))
+ }
+
+ func testPerformanceDiagnosticArchiveWriterDoesNotOverwriteExistingArchive() throws {
+ let fileManager = FileManager.default
+ let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
+ let archiveURL = rootURL.appendingPathComponent("diagnostics.zip")
+ defer { try? fileManager.removeItem(at: rootURL) }
+
+ try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true)
+ try Data("existing archive".utf8).write(to: archiveURL)
+ let summary = try JSONSerialization.data(withJSONObject: ["schemaVersion": 1], options: [])
+
+ XCTAssertThrowsError(
+ try PerformanceDiagnosticArchiveWriter.write(
+ to: archiveURL,
+ summaryJSON: summary,
+ eventLogURLs: []
+ )
+ )
+ XCTAssertEqual(try Data(contentsOf: archiveURL), Data("existing archive".utf8))
+ }
+
+ func testPerformanceDiagnosticArchiveWriterFiltersSensitiveLogFields() throws {
+ let fileManager = FileManager.default
+ let rootURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
+ let sourceLogURL = rootURL.appendingPathComponent("events.log")
+ let archiveURL = rootURL.appendingPathComponent("diagnostics.zip")
+ let extractionURL = rootURL.appendingPathComponent("extracted", isDirectory: true)
+ defer { try? fileManager.removeItem(at: rootURL) }
+
+ try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true)
+ try "event=quickSwitch.snapshot.success ts=2026-08-14T00:00:00Z durationMilliseconds=42 reportPathBasename=private-notes.txt title=secret\n".write(to: sourceLogURL, atomically: true, encoding: .utf8)
+ let summary = try JSONSerialization.data(withJSONObject: ["schemaVersion": 1], options: [])
+ _ = try PerformanceDiagnosticArchiveWriter.write(
+ to: archiveURL,
+ summaryJSON: summary,
+ eventLogURLs: [sourceLogURL]
+ )
+
+ let unzipProcess = Process()
+ unzipProcess.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
+ unzipProcess.arguments = ["-x", "-k", archiveURL.path, extractionURL.path]
+ try unzipProcess.run()
+ unzipProcess.waitUntilExit()
+ XCTAssertEqual(unzipProcess.terminationStatus, 0)
+ let exportedLogURL = extractionURL
+ .appendingPathComponent("Aligner-Diagnostics", isDirectory: true)
+ .appendingPathComponent("performance-events.log")
+ let exportedLog = try String(contentsOf: exportedLogURL, encoding: .utf8)
+ XCTAssertTrue(exportedLog.contains("event=quickSwitch.snapshot.success"))
+ XCTAssertTrue(exportedLog.contains("durationMilliseconds=42"))
+ XCTAssertFalse(exportedLog.contains("private-notes.txt"))
+ XCTAssertFalse(exportedLog.contains("title=secret"))
+ }
+
func testRound0OnlyDefinesQuickSwitchEntryPoint() {
XCTAssertEqual(EntryPoint.allCases, [.quickSwitch])
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c07ae53..8814333 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## 0.1.14 / 20260814.2230
+
+- 新增本地性能诊断包导出入口,可从菜单栏或 Aligner 应用菜单保存为 zip 并发送给测试负责人。
+- 诊断包包含版本、设备与显示器、权限、偏好、最近 Quick Switch 性能阶段、应用名称和窗口数量,以及循环性能日志。
+- 诊断包不包含窗口标题、截图或键入内容;新增归档结构单测,覆盖 zip 的摘要、性能日志和说明文件。
+
## 0.1.13 / 20260814.0445
- 修复 Quick Switch 在 App Shelf 自动换成两行时,首行 App 图标被 Space Lane 裁挡的问题。
--
Gitblit v1.9.3