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))."
|
}
|
}
|
}
|
}
|