21e9dd7c95e3f58a19a7c7342ff3d6584eb9097e..86f0b4bd20cdec86dfb5bdb7978d1f6ff4af111a
2026-08-14 Ariver
[verified] feat: export performance diagnostics
86f0b4 diff | tree
2026-08-14 Ariver
docs: freeze Aligner 0.1.13 baseline
9c5edf diff | tree
9 files modified
2 files added
537 ■■■■■ changed files
.codebase-memory/artifact.json 18 ●●●● patch | view | raw | blame | history
.codebase-memory/graph.db.zst patch | view | raw | blame | history
C1.source/CODEGRAPH.md 47 ●●●● patch | view | raw | blame | history
C1.source/Resources/Aligner-Info.plist 4 ●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/AlignerApplicationDelegate.swift 25 ●●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/DevelopmentDiagnostics.swift 8 ●●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/PerformanceDiagnosticsExporter.swift 157 ●●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/QuickSwitchSessionController.swift 34 ●●●●● patch | view | raw | blame | history
C1.source/Sources/AlignerCore/Diagnostics/PerformanceDiagnosticArchiveWriter.swift 154 ●●●●● patch | view | raw | blame | history
C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift 84 ●●●●● patch | view | raw | blame | history
CHANGELOG.md 6 ●●●●● patch | view | raw | blame | history
.codebase-memory/artifact.json
@@ -1,11 +1,11 @@
{
    "schema_version": 1,
    "commit": "1fea9bfc8a7639bb3573a7cf2a449ab55065ab9e",
    "indexed_at": "2026-06-27T17:07:38Z",
    "project": "Users-ar-Projects-Aligner2-03-O",
    "nodes": 3988,
    "edges": 12382,
    "original_size": 7864320,
    "compressed_size": 1453118,
    "compression_level": 9
}
    "commit": "21e9dd7c95e3f58a19a7c7342ff3d6584eb9097e",
    "indexed_at": "2026-08-14T12:21:29Z",
    "project": "Users-ar-Projects-Aligner-03-O",
    "nodes": 4041,
    "edges": 12465,
    "original_size": 12386304,
    "compressed_size": 1518740,
    "compression_level": 19
}
.codebase-memory/graph.db.zst
Binary files differ
C1.source/CODEGRAPH.md
@@ -1,6 +1,6 @@
# Aligner CODEGRAPH
最后更新:2026-06-28
最后更新:2026-08-14
## 用途
@@ -23,16 +23,17 @@
当前基线:
- 功能系列:`0.1.x`
- 当前可核对 tag:`v0.1.12-build20260625.2320`
- 最近本地验收候选包:`0.1.12 / 20260625.2320`
- 最后冻结前验证:2026-06-28,`swift build`、`swift test`、`round015-entitlement-fixture-qa.sh`、`round1-main-ui-qa.sh` 通过;主 UI QA 日志目录 `/tmp/aligner-round01-main-ui-qa-20260628_210821`。
- 发布事实:`0.1.12` 是本地 QA 候选代码冻结 / rollback baseline;不是正式官网 release,Developer ID、notarization、staple、Gatekeeper、macOS 14 Phase C、下载源和正式发布 manifest 仍未闭环。
- 当前可核对 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`
- SwiftPM 根目录:`/Users/ar/Projects/Aligner/03-O/C1.source`
- codebase-memory 共享产物:`/Users/ar/Projects/Aligner/03-O/.codebase-memory/graph.db.zst`
- codebase-memory 当前状态:stale;`03-O/.codebase-memory/artifact.json` 仍指向旧项目名 `Users-ar-Projects-Aligner2-03-O` 和 commit `1fea9bfc8a7639bb3573a7cf2a449ab55065ab9e`,不得作为当前代码事实源。
- codebase-memory 当前状态:refreshed;`03-O/.codebase-memory/artifact.json` 指向项目 `Users-ar-Projects-Aligner-03-O` 和源码 commit `21e9dd7c95e3f58a19a7c7342ff3d6584eb9097e`。
- codebase-memory 预期索引范围:`C1.source`、`C3.tools`
- codebase-memory 旧产物规模:`3988` nodes / `12382` edges
- codebase-memory 当前产物规模:`4041` nodes / `12465` edges
## 更新触发
@@ -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
职责:
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>
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")
    }
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
C1.source/Sources/Aligner/PerformanceDiagnosticsExporter.swift
New file
@@ -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)
    }
}
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
C1.source/Sources/AlignerCore/Diagnostics/PerformanceDiagnosticArchiveWriter.swift
New file
@@ -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))."
            }
        }
    }
}
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])
    }
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 裁挡的问题。