Skip to content
Merged
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@ let package = Package(
]
),
.testTarget(name: "WindowBurnCoreTests", dependencies: ["WindowBurnCore"]),
.testTarget(name: "WindowBurnInputTests", dependencies: ["WindowBurn", "WindowBurnCore"]),
]
)
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,20 @@ Development checks:
```bash
swift format lint --strict --recursive Package.swift Sources Tests
swift test
swift script/check-renderer-alpha.swift
./script/build_and_run.sh --verify
```

To inspect capture fidelity and material rendering on a disposable native window:

```bash
./script/build_and_run.sh --quality-review "$PWD/dist/quality-review"
```

This saves native/cover/replacement screenshots, pixel comparisons, and GPU timings without
closing your documents. See the [quality audit](docs/quality-audit.md) for the design,
reproduction steps, and remaining limitations.

To prepare a universal Developer ID-signed and notarized release locally:

```bash
Expand Down
94 changes: 86 additions & 8 deletions Sources/WindowBurn/AccessibilityWindowService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import AppKit
import ApplicationServices
import WindowBurnCore

struct AccessibleWindow {
// AX handles are immutable references; all mutable interaction state lives in the coordinator.
struct AccessibleWindow: @unchecked Sendable {
let target: TargetWindow
let element: AXUIElement
}

struct AccessibleWindowControl {
struct AccessibleWindowControl: @unchecked Sendable {
let window: AccessibleWindow
let kind: WindowControlKind
let button: AXUIElement
Expand Down Expand Up @@ -339,7 +340,7 @@ enum AccessibilityWindowService {
)
}

private static func window(matching target: TargetWindow) -> AccessibleWindow? {
static func window(matching target: TargetWindow) -> AccessibleWindow? {
let application = AXUIElementCreateApplication(target.ownerPID)
guard
let windowValues = copyAttribute(kAXWindowsAttribute, from: application)
Expand All @@ -364,6 +365,42 @@ enum AccessibilityWindowService {
size: size
)
}
return window(matching: target, among: windows)
}

/// Enumerates an application's windows once for a resolver pass. Each AX read
/// uses the remaining pass budget, including reads on returned child handles.
static func preflightWindows(
ownerPID: pid_t, deadline: TimeInterval
) -> [AccessibleWindow] {
let application = AXUIElementCreateApplication(ownerPID)
guard
let elements = copyAttribute(kAXWindowsAttribute, from: application, deadline: deadline)
as? [AXUIElement]
else { return [] }

var windows: [AccessibleWindow] = []
for element in elements {
guard ProcessInfo.processInfo.systemUptime < deadline else { break }
guard
let position = pointAttribute(kAXPositionAttribute, from: element, deadline: deadline),
let size = sizeAttribute(kAXSizeAttribute, from: element, deadline: deadline),
size.width > 1, size.height > 1
else { continue }
let title = copyAttribute(kAXTitleAttribute, from: element, deadline: deadline) as? String
windows.append(
AccessibleWindow(
target: TargetWindow(
ownerPID: ownerPID, title: title, frame: CGRect(origin: position, size: size)),
element: element
))
}
return windows
}

static func window(
matching target: TargetWindow, among windows: [AccessibleWindow]
) -> AccessibleWindow? {
let candidates = windows.enumerated().map { index, window in
WindowCandidate(
id: UInt32(index),
Expand All @@ -382,26 +419,67 @@ enum AccessibilityWindowService {
return windows[Int(match.id)]
}

/// Resolver-worker preflight; never called by the blocking mouse callback.
static func closeControl(
in window: AccessibleWindow, deadline: TimeInterval
) -> (AccessibleWindowControl, CGRect)? {
guard
let value = copyAttribute(
kAXCloseButtonAttribute, from: window.element, deadline: deadline),
CFGetTypeID(value) == AXUIElementGetTypeID()
else { return nil }
let button = unsafeDowncast(value, to: AXUIElement.self)
guard
(copyAttribute(kAXEnabledAttribute, from: button, deadline: deadline) as? Bool) != false,
let position = pointAttribute(kAXPositionAttribute, from: button, deadline: deadline),
let size = sizeAttribute(kAXSizeAttribute, from: button, deadline: deadline),
size.width > 0, size.height > 0
else { return nil }
return (
AccessibleWindowControl(window: window, kind: .close, button: button),
CGRect(origin: position, size: size)
)
}

private static func copyAttribute(
_ attribute: String,
from element: AXUIElement
from element: AXUIElement,
deadline: TimeInterval? = nil
) -> CFTypeRef? {
if let deadline {
let remaining = deadline - ProcessInfo.processInfo.systemUptime
guard remaining > 0 else { return nil }
// AX timeouts apply only to this exact handle, not its children. Restore
// the global default before publishing handles used later for native actions.
AXUIElementSetMessagingTimeout(element, Float(min(0.05, remaining)))
}
defer {
if deadline != nil { AXUIElementSetMessagingTimeout(element, 0) }
}
var value: CFTypeRef?
let result = AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
return result == .success ? value : nil
}

private static func pointAttribute(_ attribute: String, from element: AXUIElement) -> CGPoint? {
guard let value = copyAttribute(attribute, from: element) else { return nil }
private static func pointAttribute(
_ attribute: String, from element: AXUIElement, deadline: TimeInterval? = nil
) -> CGPoint? {
guard let value = copyAttribute(attribute, from: element, deadline: deadline) else {
return nil
}
let axValue = unsafeDowncast(value, to: AXValue.self)
guard AXValueGetType(axValue) == .cgPoint else { return nil }

var point = CGPoint.zero
return AXValueGetValue(axValue, .cgPoint, &point) ? point : nil
}

private static func sizeAttribute(_ attribute: String, from element: AXUIElement) -> CGSize? {
guard let value = copyAttribute(attribute, from: element) else { return nil }
private static func sizeAttribute(
_ attribute: String, from element: AXUIElement, deadline: TimeInterval? = nil
) -> CGSize? {
guard let value = copyAttribute(attribute, from: element, deadline: deadline) else {
return nil
}
let axValue = unsafeDowncast(value, to: AXValue.self)
guard AXValueGetType(axValue) == .cgSize else { return nil }

Expand Down
93 changes: 63 additions & 30 deletions Sources/WindowBurn/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import AppKit
import Carbon.HIToolbox
import Metal
import OSLog
import WindowBurnCore

@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private let logger = Logger(subsystem: "dev.malikov.WindowBurn", category: "app")
private let coordinator = BurnCoordinator()
private var qualityReview: QualityReviewController?
private let torchCursor = TorchCursorController()
private var burnHotKey: GlobalHotKey?
private var torchHotKey: GlobalHotKey?
Expand All @@ -19,14 +21,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var isSoakAndBurnModeEnabled = false

func applicationDidFinishLaunching(_ notification: Notification) {
if let argumentIndex = CommandLine.arguments.firstIndex(of: "--quality-review"),
CommandLine.arguments.indices.contains(argumentIndex + 1)
{
let output = URL(fileURLWithPath: CommandLine.arguments[argumentIndex + 1], isDirectory: true)
let review = QualityReviewController()
qualityReview = review
Task { @MainActor in
do {
try await review.run(outputDirectory: output)
} catch {
try? FileManager.default.createDirectory(at: output, withIntermediateDirectories: true)
try? "FAILED: \(error.localizedDescription)".write(
to: output.appendingPathComponent("status.txt"), atomically: true, encoding: .utf8
)
logger.error("Quality review failed: \(error.localizedDescription, privacy: .public)")
}
NSApp.terminate(nil)
}
return
}
installStatusItem()
// Prewarm effects before enabling interception on the dedicated input thread.
if let device = MTLCreateSystemDefaultDevice() {
do {
try BurnRenderer.prewarm(device: device)
} catch {
logger.error("Effect prewarm failed: \(error.localizedDescription, privacy: .public)")
}
}
installHotKeys()
InputDiagnostics.start()
coordinator.excludedCaptureWindowIDs = { [weak self] in
self?.torchCursor.captureWindowIDs ?? []
}
coordinator.onSoakAndBurnPhaseChange = { [weak self] phase in
self?.updateSoakAndBurnCursor(for: phase)
}
coordinator.onSoakCaptureStateChange = { [weak self] isCapturing in
self?.torchCursor.setTemporarilyHidden(isCapturing)
}
coordinator.onDestructiveCloseFailure = { [weak self] in
self?.disableInteractiveModesAfterCloseFailure()
}
Expand All @@ -42,12 +73,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
"Window Burn is ready; accessibility=\(PermissionService.hasAccessibilityAccess), screenCapture=\(PermissionService.hasScreenCaptureAccess), inputMonitoring=\(PermissionService.hasInputMonitoringAccess)"
)
if isDemoLaunch {
DispatchQueue.main.async { [weak self] in
self?.coordinator.showDemo()
Task { @MainActor [weak self] in
await self?.coordinator.showDemo()
}
} else if isSoakDemoLaunch {
DispatchQueue.main.async { [weak self] in
self?.coordinator.showSoakDemo()
Task { @MainActor [weak self] in
await self?.coordinator.showSoakDemo()
}
} else if isTorchLaunch {
DispatchQueue.main.async { [weak self] in
Expand Down Expand Up @@ -79,6 +110,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
item.button?.toolTip = "Window Burn — burn ⌃⌥⌘B, torch ⌃⌥⌘F, soak & burn ⌃⌥⌘U"

let menu = NSMenu()
menu.delegate = self
let burnItem = NSMenuItem(
title: "Burn & Close Front Window",
action: #selector(burnFrontWindow),
Expand Down Expand Up @@ -154,6 +186,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
statusItem = item
}

func menuWillOpen(_ menu: NSMenu) {
windowControlInterceptor?.isMenuTracking = true
}

func menuDidClose(_ menu: NSMenu) {
windowControlInterceptor?.isMenuTracking = false
}

private func installHotKeys() {
do {
burnHotKey = try GlobalHotKey(
Expand Down Expand Up @@ -190,30 +230,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
closeHandler: { [weak self] control in
self?.coordinator.interceptWindowControl(control) ?? false
},
torchHandler: { [weak self] location in
guard let self else { return false }
return torchCursor.withoutOverlay {
coordinator.interceptTorchClick(at: location)
}
torchHandler: { [weak self] location, window in
self?.coordinator.interceptTorchClick(
at: location, resolvedWindow: window
) ?? false
},
soakAndBurnHandler: { [weak self] event, location in
guard let self else { return false }
switch event {
case .down:
torchCursor.beginPointerDrag(atQuartzPoint: location)
case .dragged:
torchCursor.move(toQuartzPoint: location)
case .up:
torchCursor.endPointerDrag(atQuartzPoint: location)
}
if case .down = event {
return torchCursor.withoutOverlay {
coordinator.interceptSoakAndBurn(event, at: location)
}
}
return coordinator.interceptSoakAndBurn(event, at: location)
soakAndBurnHandler: { [weak self] event, location, window in
self?.coordinator.interceptSoakAndBurn(
event, at: location, resolvedWindow: window
) ?? false
}
)
windowControlInterceptor?.interactionState = { [weak self] in
self?.coordinator.pointerInteractionState ?? ([], false)
}
torchCursor.pointerLocation = { [weak self] in
self?.windowControlInterceptor?.latestPointerLocation
}
} catch {
logger.error(
"Mouse interception is unavailable: \(error.localizedDescription, privacy: .public)"
Expand All @@ -226,7 +259,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}

@objc private func showDemo() {
coordinator.showDemo()
Task { await coordinator.showDemo() }
}

@objc private func toggleTorchMode() {
Expand Down
Loading