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