9 files modified
2 files added
| | |
| | | { |
| | | "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 |
| | | } |
| | |
| | | # Aligner CODEGRAPH |
| | | |
| | | 最后更新:2026-06-28 |
| | | 最后更新:2026-08-14 |
| | | |
| | | ## 用途 |
| | | |
| | |
| | | 当前基线: |
| | | |
| | | - 功能系列:`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 |
| | | |
| | | ## 更新触发 |
| | | |
| | |
| | | | `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/` | |
| | | |
| | |
| | | - `../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 |
| | | |
| | | 职责: |
| | |
| | | <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> |
| | |
| | | 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) |
| | |
| | | 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: "")) |
| | |
| | | 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()) |
| | |
| | | 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, |
| | |
| | | "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 |
| | |
| | | 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") |
| | | } |
| | |
| | | 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 |
| New file |
| | |
| | | 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) |
| | | } |
| | | } |
| | |
| | | ] |
| | | } |
| | | |
| | | 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 |
| New file |
| | |
| | | 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))." |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | 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 |
| | | |
| | | ## 0.1.14 / 20260814.2230 |
| | | |
| | | - 新增本地性能诊断包导出入口,可从菜单栏或 Aligner 应用菜单保存为 zip 并发送给测试负责人。 |
| | | - 诊断包包含版本、设备与显示器、权限、偏好、最近 Quick Switch 性能阶段、应用名称和窗口数量,以及循环性能日志。 |
| | | - 诊断包不包含窗口标题、截图或键入内容;新增归档结构单测,覆盖 zip 的摘要、性能日志和说明文件。 |
| | | |
| | | ## 0.1.13 / 20260814.0445 |
| | | |
| | | - 修复 Quick Switch 在 App Shelf 自动换成两行时,首行 App 图标被 Space Lane 裁挡的问题。 |