mirror of
https://github.com/aculix/knotchy.git
synced 2026-10-27 07:15:53 +00:00
165 lines
7.5 KiB
Swift
165 lines
7.5 KiB
Swift
#if DEBUG
|
|
import AppKit
|
|
import KnotchyCore
|
|
import QuartzCore
|
|
import SwiftUI
|
|
|
|
// `Knotchy --tour <dir> <url>` saves PNGs of each state; `--tour-timing <url>` reports dropped frames.
|
|
// Checks the UI without screen recording permission; video doesn't appear in the snapshots.
|
|
@MainActor
|
|
enum DebugTour {
|
|
static func run(controller: SessionController, snapshot: ((String) -> Void)?, url: URL) async {
|
|
let meter = FrameMeter()
|
|
func wait(_ seconds: Double) async { try? await Task.sleep(for: .seconds(seconds)) }
|
|
func step(_ name: String, _ times: [Double], _ action: () -> Void) async {
|
|
if snapshot == nil { meter.start() }
|
|
action()
|
|
var elapsed = 0.0
|
|
for time in times {
|
|
await wait(time - elapsed)
|
|
elapsed = time
|
|
snapshot?("\(name)-\(Int(time * 1000))ms")
|
|
}
|
|
if snapshot == nil { print("\(name): \(meter.stop())") }
|
|
}
|
|
|
|
await step("01-no-link", [0.08, 0.2, 0.6, 1.0]) { controller.open(text: "no link here") }
|
|
await wait(2.5)
|
|
await step("02-opening", [0.06, 0.12, 0.2, 0.3, 0.45, 0.7, 1.2]) { controller.open(url) }
|
|
for _ in 0..<300 where controller.state != .live(.peek) { await wait(0.1) }
|
|
await wait(1)
|
|
await step("04-expand", [0.08, 0.16, 0.25, 0.4, 1.0]) { controller.expand() }
|
|
await step("05-shrink", [0.08, 0.16, 0.3, 1.0]) { controller.shrink() }
|
|
await step("06-fold", [0.06, 0.12, 0.2, 0.35, 1.0]) { controller.clickedOutside() }
|
|
await wait(0.5)
|
|
await step("07-unfold", [0.06, 0.12, 0.2, 0.35, 1.0]) { controller.pointerEntered() }
|
|
await step("08-destroy", [0.08, 0.2, 0.4, 0.8, 1.6, 2.2, 2.6]) { controller.destroy() }
|
|
await wait(1)
|
|
await step("09-reopen", [0.06, 0.12, 0.2, 0.3, 0.45, 0.7, 1.2]) { controller.open(url) }
|
|
for _ in 0..<300 where controller.state != .live(.peek) { await wait(0.1) }
|
|
await step("10-destroy", [0.3, 2.6]) { controller.destroy() }
|
|
await wait(1)
|
|
NSApp.terminate(nil)
|
|
}
|
|
|
|
static func argument(after flag: String) -> String? {
|
|
let all = CommandLine.arguments
|
|
guard let index = all.firstIndex(of: flag), index + 1 < all.count else { return nil }
|
|
return all[index + 1]
|
|
}
|
|
|
|
/// `Knotchy --setup-snapshot <dir>`; with `KNOTCHY_PRETEND=no-virtualization` it shows minimal mode.
|
|
static func snapshotSetup(app: AppDelegate, to directory: URL) async {
|
|
app.setupWindow.show()
|
|
try? await Task.sleep(for: .seconds(5))
|
|
guard let window = app.setupWindow.window, let view = window.contentView else { return }
|
|
print("setup: window \(window.frame.size)")
|
|
DebugSnapshot.write(view, to: directory.appendingPathComponent("setup.png"))
|
|
}
|
|
}
|
|
|
|
extension DebugTour {
|
|
static func snapshotSettings(app: AppDelegate, to directory: URL) async {
|
|
let settings = app.settings
|
|
let saved = (settings.networkMode, settings.proxyHost)
|
|
let cases: [(String, SettingsTab, AppSettings.NetworkMode)] = [
|
|
("general", .general, .direct), ("network-direct", .network, .direct),
|
|
("network-proxy", .network, .proxy), ("network-vpn", .network, .vpn), ("vm", .vm, .direct),
|
|
]
|
|
for (name, section, mode) in cases {
|
|
settings.networkMode = mode
|
|
if mode == .proxy { settings.proxyHost = "192.168.1.20" }
|
|
app.settingsWindow.show(section)
|
|
try? await Task.sleep(for: .seconds(0.8))
|
|
guard let window = app.settingsWindow.window, let view = window.contentView else { continue }
|
|
print("\(name): window \(window.frame.size)")
|
|
DebugSnapshot.write(view, to: directory.appendingPathComponent("settings-\(name).png"))
|
|
}
|
|
app.settingsWindow.window?.orderOut(nil)
|
|
(settings.networkMode, settings.proxyHost) = saved
|
|
}
|
|
|
|
static func timeNetworkSwitching(app: AppDelegate) async {
|
|
let settings = app.settings
|
|
let saved = settings.networkMode
|
|
settings.networkMode = .direct
|
|
app.settingsWindow.show(.general)
|
|
try? await Task.sleep(for: .seconds(1))
|
|
let meter = FrameMeter()
|
|
let steps: [(String, () -> Void)] = [
|
|
("section network", { app.settingsWindow.show(.network) }),
|
|
("mode proxy", { settings.networkMode = .proxy }),
|
|
("mode vpn", { settings.networkMode = .vpn }),
|
|
("mode direct", { settings.networkMode = .direct }),
|
|
("section vm", { app.settingsWindow.show(.vm) }),
|
|
("section general", { app.settingsWindow.show(.general) }),
|
|
("section network", { app.settingsWindow.show(.network) }),
|
|
]
|
|
for (name, step) in steps {
|
|
meter.start()
|
|
step()
|
|
try? await Task.sleep(for: .seconds(0.8))
|
|
let size = app.settingsWindow.window?.frame.size ?? .zero
|
|
print("\(name) (window \(Int(size.width))x\(Int(size.height))): \(meter.stop())")
|
|
}
|
|
app.settingsWindow.window?.orderOut(nil)
|
|
settings.networkMode = saved
|
|
}
|
|
}
|
|
|
|
enum DebugSnapshot {
|
|
@MainActor
|
|
static func write(_ view: NSView, to url: URL) {
|
|
guard let layer = view.layer, let window = view.window else { return }
|
|
let scale = window.backingScaleFactor
|
|
let size = view.bounds.size
|
|
guard let context = CGContext(data: nil, width: Int(size.width * scale), height: Int(size.height * scale),
|
|
bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpace(name: CGColorSpace.sRGB)!,
|
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return }
|
|
context.setFillColor(CGColor(gray: 0.55, alpha: 1))
|
|
context.fill(CGRect(x: 0, y: 0, width: size.width * scale, height: size.height * scale))
|
|
context.scaleBy(x: scale, y: scale)
|
|
if view.isFlipped || !layer.isGeometryFlipped {
|
|
context.translateBy(x: 0, y: size.height)
|
|
context.scaleBy(x: 1, y: -1)
|
|
}
|
|
(layer.presentation() ?? layer).render(in: context)
|
|
guard let image = context.makeImage(),
|
|
let destination = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else { return }
|
|
CGImageDestinationAddImage(destination, image, nil)
|
|
CGImageDestinationFinalize(destination)
|
|
}
|
|
}
|
|
|
|
// SwiftUI animates on the main thread, so a late display-link callback is a dropped frame.
|
|
@MainActor
|
|
final class FrameMeter: NSObject {
|
|
private var link: CADisplayLink?
|
|
private var stamps: [CFTimeInterval] = []
|
|
private var nominal: CFTimeInterval = 1.0 / 60
|
|
|
|
func start() {
|
|
stamps = []
|
|
link = (NSScreen.main ?? NSScreen.screens[0]).displayLink(target: self, selector: #selector(tick(_:)))
|
|
link?.add(to: .main, forMode: .common)
|
|
}
|
|
|
|
@objc private func tick(_ link: CADisplayLink) {
|
|
stamps.append(CACurrentMediaTime())
|
|
nominal = link.targetTimestamp - link.timestamp
|
|
}
|
|
|
|
func stop() -> String {
|
|
link?.invalidate()
|
|
link = nil
|
|
let intervals = zip(stamps.dropFirst(), stamps).map { $0 - $1 }
|
|
func ms(_ t: CFTimeInterval) -> String { String(format: "%.0f", t * 1000) }
|
|
// "duration@end", both in ms, end measured from the start.
|
|
let late = zip(intervals, stamps.dropFirst()).filter { $0.0 > nominal * 1.5 }
|
|
.map { "\(ms($0.0))@\(ms($0.1 - (stamps.first ?? 0)))" }
|
|
return "\(stamps.count) frames at \(ms(nominal)) ms, worst \(ms(intervals.max() ?? 0)) ms, "
|
|
+ "\(late.count) late \(late)"
|
|
}
|
|
}
|
|
#endif
|