Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Restart Window Burn after changing any of the three permissions.
| Mode | Shortcut | What it does |
| --- | --- | --- |
| Burn & Close | `⌃⌥⌘B` | Closes the front window and burns its snapshot from a randomized edge. |
| Torch | `⌃⌥⌘F` | Turns the cursor into an animated torch. Click a window, then add up to eight ignition points. |
| Torch | `⌃⌥⌘F` | Turns the cursor into an animated torch. Burn up to four windows at once, with up to eight ignition points per window. |
| Soak & Burn | `⌃⌥⌘U` | Hold and drag the 18+ badge over a window to soak and tear it, then ignite the remaining edges with the torch. |
| Test Effect | Menu bar | Plays a harmless demo without closing a real window. |

Expand Down
158 changes: 120 additions & 38 deletions Sources/WindowBurn/BurnCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,28 @@ import AppKit
import OSLog
import WindowBurnCore

@MainActor
private final class TorchWindowSession {
let accessibleWindow: AccessibleWindow
let overlay = BurnOverlayController()
var captureFrame: CGRect
var pendingClicks: [CGPoint]
var isOverlayReady = false

init(accessibleWindow: AccessibleWindow, initialClick: CGPoint) {
self.accessibleWindow = accessibleWindow
captureFrame = accessibleWindow.target.frame
pendingClicks = [initialClick]
}
}

@MainActor
final class BurnCoordinator {
private let logger = Logger(subsystem: "dev.malikov.WindowBurn", category: "burn")
private let overlay = BurnOverlayController()
private var isBusy = false
private var torchCaptureFrame: CGRect?
private var pendingTorchClicks: [CGPoint] = []
private var isTorchOverlayActive = false
private var torchSessionRegistry = TorchWindowSessionRegistry()
private var torchSessions: [UUID: TorchWindowSession] = [:]
private var soakAndBurnSession = SoakAndBurnSession()
private var soakedWindow: AccessibleWindow?
private var soakCaptureFrame: CGRect?
Expand All @@ -23,7 +37,7 @@ final class BurnCoordinator {
var onDestructiveCloseFailure: (() -> Void)?

func burnFrontWindow() {
guard !isBusy else { return }
guard !isBusy, torchSessions.isEmpty else { return }
isBusy = true

Task { @MainActor [weak self] in
Expand All @@ -41,18 +55,26 @@ final class BurnCoordinator {
)
let profile = BurnProfile.random()

try await AccessibilityWindowService.closeDiscardingUnsavedChanges(accessibleWindow)
try overlay.present(
image: capturedWindow.image,
shadowImage: capturedWindow.shadowImage,
shadowSamplingOffset: capturedWindow.shadowSamplingOffset,
panelFrame: panelFrame,
profile: profile,
startImmediately: false,
onFirstFrame: nil,
completion: { [weak self] in self?.isBusy = false }
)
try await AccessibilityWindowService.closeDiscardingUnsavedChanges(accessibleWindow)
guard overlay.activateReplacementSurface() else {
throw BurnOverlayError.rendererUnavailable
}
overlay.startBurning()
logger.info(
"Burning front window owned by pid \(accessibleWindow.target.ownerPID), duration \(profile.duration, format: .fixed(precision: 2))s"
)
} catch {
overlay.dismiss()
isBusy = false
onDestructiveCloseFailure?()
logger.error("Burn failed: \(error.localizedDescription, privacy: .public)")
Expand All @@ -62,7 +84,7 @@ final class BurnCoordinator {
}

func interceptWindowControl(_ control: AccessibleWindowControl) -> Bool {
guard !isBusy else { return false }
guard !isBusy, torchSessions.isEmpty else { return false }
isBusy = true

Task { @MainActor [weak self] in
Expand All @@ -80,19 +102,27 @@ final class BurnCoordinator {
)
let profile = BurnProfile.random()

actionPerformed = true
try await AccessibilityWindowService.closeDiscardingUnsavedChanges(control.window)
try overlay.present(
image: capturedWindow.image,
shadowImage: capturedWindow.shadowImage,
shadowSamplingOffset: capturedWindow.shadowSamplingOffset,
panelFrame: panelFrame,
profile: profile,
startImmediately: false,
onFirstFrame: nil,
completion: { [weak self] in self?.isBusy = false }
)
actionPerformed = true
try await AccessibilityWindowService.closeDiscardingUnsavedChanges(control.window)
guard overlay.activateReplacementSurface() else {
throw BurnOverlayError.rendererUnavailable
}
overlay.startBurning()
logger.info(
"Burning intercepted \(control.kind.logName, privacy: .public) action for pid \(control.window.target.ownerPID), duration \(profile.duration, format: .fixed(precision: 2))s"
)
} catch {
overlay.dismiss()
if !actionPerformed {
do {
try AccessibilityWindowService.perform(control)
Expand All @@ -115,29 +145,37 @@ final class BurnCoordinator {
}

func interceptTorchClick(at screenPoint: CGPoint) -> Bool {
if let captureFrame = torchCaptureFrame,
if let sessionID = torchSessionRegistry.sessionID(containing: screenPoint),
let session = torchSessions[sessionID],
let ignition = TorchBurnGeometry.normalizedIgnition(
screenPoint: screenPoint,
captureFrame: captureFrame
captureFrame: session.captureFrame
)
{
if isTorchOverlayActive {
let added = overlay.addIgnition(ignition)
if session.isOverlayReady {
let added = session.overlay.addIgnition(ignition)
if added {
logger.info(
"Added another torch ignition at \(ignition.x, format: .fixed(precision: 2)), \(ignition.y, format: .fixed(precision: 2))"
"Added another torch ignition to session \(sessionID.uuidString, privacy: .public) at \(ignition.x, format: .fixed(precision: 2)), \(ignition.y, format: .fixed(precision: 2))"
)
} else {
logger.info(
"Ignored torch ignition because the active window already has the maximum number of points"
"Ignored torch ignition because session \(sessionID.uuidString, privacy: .public) already has the maximum number of points"
)
}
} else if pendingTorchClicks.count < TorchIgnitionField.maximumCount {
pendingTorchClicks.append(screenPoint)
} else if session.pendingClicks.count < TorchIgnitionField.maximumCount {
session.pendingClicks.append(screenPoint)
}
return true
}

if torchSessionRegistry.isAtCapacity {
logger.info(
"Ignored torch ignition because \(TorchWindowSessionRegistry.maximumConcurrentWindows) window sessions are already active"
)
return true
}

guard
!isBusy,
let accessibleWindow = AccessibilityWindowService.window(at: screenPoint),
Expand All @@ -149,24 +187,35 @@ final class BurnCoordinator {
return false
}

isBusy = true
torchCaptureFrame = accessibleWindow.target.frame
pendingTorchClicks = [screenPoint]
isTorchOverlayActive = false
let sessionID = UUID()
guard
torchSessionRegistry.register(
id: sessionID,
captureFrame: accessibleWindow.target.frame
)
else {
return true
}
torchSessions[sessionID] = TorchWindowSession(
accessibleWindow: accessibleWindow,
initialClick: screenPoint
)

Task { @MainActor [weak self] in
guard let self else { return }

do {
guard let session = torchSessions[sessionID] else { return }
let capturedWindow = try await WindowCaptureService.capture(
target: accessibleWindow.target
target: session.accessibleWindow.target
)
guard let session = torchSessions[sessionID] else { return }
let panelFrame = ScreenCoordinateConverter.appKitFrame(
for: capturedWindow.captureFrame,
mainDisplayHeight: NSScreen.screens.first?.frame.maxY ?? 0,
padding: BurnOverlayController.padding
)
let ignitionPoints = pendingTorchClicks.compactMap {
let ignitionPoints = session.pendingClicks.compactMap {
TorchBurnGeometry.normalizedIgnition(
screenPoint: $0,
captureFrame: capturedWindow.captureFrame
Expand All @@ -176,27 +225,51 @@ final class BurnCoordinator {
throw WindowCaptureError.noMatchingWindow
}

torchCaptureFrame = capturedWindow.captureFrame
session.captureFrame = capturedWindow.captureFrame
guard
torchSessionRegistry.updateCaptureFrame(
id: sessionID,
captureFrame: capturedWindow.captureFrame
)
else {
throw WindowCaptureError.noMatchingWindow
}
let profile = BurnProfile.randomTorch()
try await AccessibilityWindowService.closeDiscardingUnsavedChanges(accessibleWindow)
try overlay.present(
try session.overlay.present(
image: capturedWindow.image,
shadowImage: capturedWindow.shadowImage,
shadowSamplingOffset: capturedWindow.shadowSamplingOffset,
panelFrame: panelFrame,
profile: profile,
style: .torch(initialIgnitions: ignitionPoints),
startImmediately: false,
onFirstFrame: nil,
completion: { [weak self] in
self?.finishTorchBurn()
self?.finishTorchBurn(sessionID)
}
)
isTorchOverlayActive = true
session.isOverlayReady = true
session.pendingClicks = []
try await AccessibilityWindowService.closeDiscardingUnsavedChanges(
session.accessibleWindow
)
guard
torchSessions[sessionID] != nil,
session.overlay.activateReplacementSurface()
else {
throw BurnOverlayError.rendererUnavailable
}
session.overlay.startBurning()
logger.info(
"Started torch burn for pid \(accessibleWindow.target.ownerPID) with \(ignitionPoints.count) ignition point(s), duration \(profile.duration, format: .fixed(precision: 2))s"
"Started torch session \(sessionID.uuidString, privacy: .public) for pid \(session.accessibleWindow.target.ownerPID) with \(ignitionPoints.count) ignition point(s), duration \(profile.duration, format: .fixed(precision: 2))s; \(self.torchSessionRegistry.count) concurrent window(s)"
)
} catch {
finishTorchBurn()
torchSessions[sessionID]?.overlay.dismiss()
finishTorchBurn(sessionID)
onDestructiveCloseFailure?()
logger.error("Torch burn failed: \(error.localizedDescription, privacy: .public)")
logger.error(
"Torch session \(sessionID.uuidString, privacy: .public) failed: \(error.localizedDescription, privacy: .public)"
)
showError(error)
}
}
Expand Down Expand Up @@ -247,7 +320,7 @@ final class BurnCoordinator {
}

func showDemo() {
guard !isBusy else { return }
guard !isBusy, torchSessions.isEmpty else { return }
isBusy = true
logger.info("Starting demo burn")

Expand Down Expand Up @@ -283,7 +356,7 @@ final class BurnCoordinator {
}

func showSoakDemo() {
guard !isBusy else { return }
guard !isBusy, torchSessions.isEmpty else { return }
isBusy = true
logger.info("Starting soak-and-burn demo")

Expand Down Expand Up @@ -352,17 +425,18 @@ final class BurnCoordinator {
}
}

private func finishTorchBurn() {
isBusy = false
torchCaptureFrame = nil
pendingTorchClicks = []
isTorchOverlayActive = false
logger.info("Torch burn completed")
private func finishTorchBurn(_ sessionID: UUID) {
guard torchSessions.removeValue(forKey: sessionID) != nil else { return }
torchSessionRegistry.remove(id: sessionID)
logger.info(
"Torch session \(sessionID.uuidString, privacy: .public) completed; \(self.torchSessionRegistry.count) concurrent window(s) remain"
)
}

private func beginSoaking(at screenPoint: CGPoint) -> Bool {
guard
!isBusy,
torchSessions.isEmpty,
let accessibleWindow = AccessibilityWindowService.window(at: screenPoint),
TorchBurnGeometry.normalizedIgnition(
screenPoint: screenPoint,
Expand Down Expand Up @@ -421,6 +495,8 @@ final class BurnCoordinator {
try overlay.present(
image: capturedWindow.image,
backdropImage: capturedWindow.backdropImage,
shadowImage: capturedWindow.shadowImage,
shadowSamplingOffset: capturedWindow.shadowSamplingOffset,
panelFrame: panelFrame,
profile: profile,
style: .soakAndBurn(initialSoakPoints: initialSoakPoints),
Expand Down Expand Up @@ -469,10 +545,16 @@ final class BurnCoordinator {
soakCloseTask = Task { @MainActor [weak self] in
guard let self else { return }
do {
guard overlay.prepareForIgnitionHandoff() else {
overlay.dismiss()
resetSoakAndBurn()
return
}
try await AccessibilityWindowService.closeDiscardingUnsavedChanges(accessibleWindow)
guard generation == soakGeneration, !Task.isCancelled else { return }
soakCloseTask = nil
guard
overlay.activateReplacementSurface(),
soakAndBurnSession.beginBurning(),
overlay.igniteSoakedWindow(at: ignition)
else {
Expand Down
Loading