diff --git a/.changeset/26563231.md b/.changeset/26563231.md new file mode 100644 index 0000000..d4b9ede --- /dev/null +++ b/.changeset/26563231.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Refresh tile previews on every overlay show so live updates aren't masked by the previous capture diff --git a/.changeset/6e57fbf8.md b/.changeset/6e57fbf8.md new file mode 100644 index 0000000..aece2a8 --- /dev/null +++ b/.changeset/6e57fbf8.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +Search mode (cmd+F): filter tiles by substring match on app name + window title; return commits, esc / Cancel clears. diff --git a/README.md b/README.md index 2414d8f..8c439ad 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Requires macOS 14+. | ⌘W | Close selected window | | ⌘`delete` | Ignore / un-ignore selected window | | ⌘Y | Toggle "show hidden" view | +| ⌘F | Search / filter visible windows (substring match on app + title) | | ⌥`g`/`b`/`r`/`y`/`o`/`p` | Tag selected tile (green/blue/red/yellow/orange/purple) | | ⌥`0` | Clear tag on selected tile | | `esc` | Dismiss overlay | @@ -55,7 +56,7 @@ Right-click the `⌘ ⌘` Dock icon and pick **Open Config…** — that opens ` Binding spec — modifier tokens: `cmd`, `shift`, `opt` (or `option`/`alt`), `ctrl`. Special keys: `esc`, `space`, `return`, `delete`, `left`, `right`, `up`, `down`. Anything else is a single character. -Actions: `pick`, `dismiss`, `move-left|right|up|down`, `swap-left|right|up|down`, `pick-1` … `pick-9`, `ignore`, `toggle-hidden`, `close`, `tag-green|blue|red|yellow|orange|purple|clear`. +Actions: `pick`, `dismiss`, `move-left|right|up|down`, `swap-left|right|up|down`, `pick-1` … `pick-9`, `ignore`, `toggle-hidden`, `close`, `search`, `tag-green|blue|red|yellow|orange|purple|clear`. ## Build diff --git a/Sources/cmdcmd/Config.swift b/Sources/cmdcmd/Config.swift index 46977ac..3471244 100644 --- a/Sources/cmdcmd/Config.swift +++ b/Sources/cmdcmd/Config.swift @@ -332,6 +332,7 @@ struct Config: Codable { ("cmd+w", .close), ("cmd+delete", .ignore), ("cmd+y", .toggleHidden), + ("cmd+f", .search), ("opt+g", .tagGreen), ("opt+b", .tagBlue), ("opt+r", .tagRed), diff --git a/Sources/cmdcmd/Keymap.swift b/Sources/cmdcmd/Keymap.swift index 7c4771f..34d7554 100644 --- a/Sources/cmdcmd/Keymap.swift +++ b/Sources/cmdcmd/Keymap.swift @@ -21,6 +21,7 @@ enum Action: String, Codable, Hashable { case tagOrange = "tag-orange" case tagPurple = "tag-purple" case tagClear = "tag-clear" + case search case pick1 = "pick-1" case pick2 = "pick-2" case pick3 = "pick-3" @@ -53,6 +54,7 @@ enum Action: String, Codable, Hashable { case .tagOrange: return "Tag orange" case .tagPurple: return "Tag purple" case .tagClear: return "Clear tag" + case .search: return "Search / filter visible windows" case .pick1: return "Pick tile 1" case .pick2: return "Pick tile 2" case .pick3: return "Pick tile 3" @@ -135,6 +137,7 @@ final class Keymap { "cmd+delete": .ignore, "cmd+y": .toggleHidden, "cmd+w": .close, + "cmd+f": .search, "opt+g": .tagGreen, "opt+b": .tagBlue, "opt+r": .tagRed, "opt+y": .tagYellow, "opt+o": .tagOrange, "opt+p": .tagPurple, "opt+0": .tagClear, "1": .pick1, "2": .pick2, "3": .pick3, "4": .pick4, "5": .pick5, diff --git a/Sources/cmdcmd/Log.swift b/Sources/cmdcmd/Log.swift index 60dcc9d..cc1d424 100644 --- a/Sources/cmdcmd/Log.swift +++ b/Sources/cmdcmd/Log.swift @@ -8,7 +8,7 @@ enum Log { FileManager.default.createFile(atPath: path, contents: nil) } let h = try? FileHandle(forWritingTo: url) - try? h?.seekToEnd() + _ = try? h?.seekToEnd() return h }() diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index 4f83358..7dd2ef8 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -58,6 +58,9 @@ final class Overlay { private var appActivationObserver: NSObjectProtocol? private var activityTimer: Timer? private let hint = HintPill() + private let search = SearchField() + private var searchQuery: String = "" + private var searching: Bool = false private var cachedShareable: SCShareableContent? private var cachedShareableAt: CFAbsoluteTime = 0 @@ -492,15 +495,12 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B updateSelection() } let live = config.livePreviewsEnabled - let delay: TimeInterval = config.animations ? Self.pickDuration + 0.05 : 0 - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [ordered] in - Task { - await withTaskGroup(of: Void.self) { group in - for t in ordered { - group.addTask { - await t.snapshot() - if live { await t.start() } - } + Task { + await withTaskGroup(of: Void.self) { group in + for t in ordered { + group.addTask { + await t.snapshot() + if live { await t.start() } } } } @@ -509,10 +509,14 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B private func rebuildDisplayed() { let ignored = ignoredKeys - let displayed = allTiles.filter { showIgnored ? true : !ignored.contains($0.ignoreKey) } + let baseDisplayed = allTiles.filter { showIgnored ? true : !ignored.contains($0.ignoreKey) } + let displayed = baseDisplayed.filter { Self.matches(tile: $0, query: searchQuery) } + let visibleSet = Set(displayed.map { ObjectIdentifier($0) }) for t in allTiles { let isIgnored = ignored.contains(t.ignoreKey) - t.layer.isHidden = showIgnored ? false : isIgnored + let inSearch = visibleSet.contains(ObjectIdentifier(t)) + let hiddenByIgnore = showIgnored ? false : isIgnored + t.layer.isHidden = hiddenByIgnore || !inSearch t.layer.opacity = (showIgnored && isIgnored) ? 0.3 : 1.0 t.setNumber(nil) t.tintColorName = paneColors[CGWindowID(t.scWindow.windowID)] @@ -529,6 +533,64 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B updateSelection() } + private static func matches(tile: Tile, query: String) -> Bool { + let q = query.trimmingCharacters(in: .whitespaces) + if q.isEmpty { return true } + let app = tile.scWindow.owningApplication?.applicationName ?? "" + let title = tile.scWindow.title ?? "" + let haystack = app + " " + title + return haystack.localizedCaseInsensitiveContains(q) + } + + private func enterSearch() { + guard let win = window, let host = win.contentView else { return } + searching = true + search.onChange = { [weak self] q in self?.searchQueryChanged(q) } + search.onCommit = { [weak self] in self?.dispatch(.pick) } + search.onCancel = { [weak self] in self?.commitSearch() } + search.onArrow = { [weak self] d in self?.dispatchSearchArrow(d) } + search.show(in: host, query: searchQuery) + findSearchTextField(in: host)?.onCmdF = { [weak self] in self?.commitSearch() } + } + + private func dispatchSearchArrow(_ d: SearchField.ArrowDirection) { + switch d { + case .left: dispatch(.moveLeft) + case .right: dispatch(.moveRight) + case .up: dispatch(.moveUp) + case .down: dispatch(.moveDown) + } + } + + private func findSearchTextField(in view: NSView) -> SearchTextField? { + for sub in view.subviews { + if let f = sub as? SearchTextField { return f } + if let nested = findSearchTextField(in: sub) { return nested } + } + return nil + } + + private func commitSearch() { + searching = false + search.hide() + if let v = view { window?.makeFirstResponder(v) } + } + + private func cancelSearch() { + searching = false + searchQuery = "" + search.hide() + rebuildDisplayed() + layoutTilesAnimated() + if let v = view { window?.makeFirstResponder(v) } + } + + private func searchQueryChanged(_ q: String) { + searchQuery = q + rebuildDisplayed() + layoutTilesAnimated() + } + private func tagSelectedColor(_ name: String?) { guard tiles.indices.contains(selectedIndex) else { return } let id = CGWindowID(tiles[selectedIndex].scWindow.windowID) @@ -575,7 +637,10 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B switch action { case .pick: pick() case .dismiss: - if showIgnored { toggleShowIgnored() } else { dismiss() } + if showIgnored { toggleShowIgnored() } + else if !searchQuery.isEmpty { cancelSearch() } + else { dismiss() } + case .search: enterSearch() case .moveLeft: move(dx: -1, dy: 0) case .moveRight: move(dx: 1, dy: 0) case .moveUp: move(dx: 0, dy: -1) @@ -683,6 +748,9 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B selectedIndex = 0 showIgnored = false lastLetterJump = nil + searching = false + searchQuery = "" + search.hide() view?.resetMomentaryPeek() hint.hide() Task(priority: .utility) { @@ -966,6 +1034,10 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B if i == selectedIndex { t.layer.zPosition = 1 t.setFrame(target) + // Fade the accent border + blue glow during the zoom: at full + // size they dominate the screen and read as a flash of color. + t.layer.borderWidth = 0 + t.layer.shadowOpacity = 0 } else { t.layer.opacity = 0 } @@ -985,6 +1057,10 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B if i < savedFrames.count { t.setFrame(savedFrames[i]) } t.layer.zPosition = 0 t.layer.opacity = 1 + if i == selectedIndex, t.highlight == .subtle { + t.layer.borderWidth = 3 + t.layer.shadowOpacity = 0.6 + } } CATransaction.commit() resumeFrames(after: 0.12) diff --git a/Sources/cmdcmd/SearchField.swift b/Sources/cmdcmd/SearchField.swift new file mode 100644 index 0000000..d1eeb4c --- /dev/null +++ b/Sources/cmdcmd/SearchField.swift @@ -0,0 +1,216 @@ +import AppKit + +/// Bottom-center search field shown when the user enters search mode. +/// Contains an editable text field plus a Cancel button. The text field +/// receives all keystrokes while visible; the host wires up callbacks for +/// query changes, return (commit), and esc/cancel. +final class SearchField { + private var container: NSView? + private var field: SearchTextField? + private var cancelButton: NSButton? + private weak var hostWindow: NSWindow? + + enum ArrowDirection { case left, right, up, down } + + var onChange: ((String) -> Void)? + var onCommit: (() -> Void)? + var onCancel: (() -> Void)? + var onArrow: ((ArrowDirection) -> Void)? + + var isVisible: Bool { container?.superview != nil && !(container?.isHidden ?? true) } + + var query: String { field?.stringValue ?? "" } + + func show(in host: NSView, query: String) { + hostWindow = host.window + let view = container ?? makeContainer() + if container == nil { + container = view + host.addSubview(view) + } else if view.superview !== host { + view.removeFromSuperview() + host.addSubview(view) + } + view.isHidden = false + field?.stringValue = query + layout(in: host.bounds) + if let f = field { + host.window?.makeFirstResponder(f) + // Place cursor at end after string assignment. + f.currentEditor()?.selectedRange = NSRange(location: query.count, length: 0) + } + } + + func hide() { + container?.isHidden = true + if let host = container?.superview, let win = host.window { + // Return first responder to the OverlayView so keymap routing resumes. + win.makeFirstResponder(host) + } + } + + func reset() { + container?.removeFromSuperview() + container = nil + field = nil + cancelButton = nil + } + + func relayout(in bounds: CGRect) { + guard isVisible else { return } + layout(in: bounds) + } + + private func layout(in bounds: CGRect) { + guard let view = container else { return } + let width: CGFloat = 360 + let height: CGFloat = 36 + view.frame = CGRect( + x: (bounds.width - width) / 2, + y: 24, + width: width, + height: height + ) + let buttonWidth: CGFloat = 70 + let pad: CGFloat = 8 + field?.frame = CGRect(x: pad, y: 6, width: width - buttonWidth - pad * 3, height: height - 12) + cancelButton?.frame = CGRect( + x: width - buttonWidth - pad, + y: 4, + width: buttonWidth, + height: height - 8 + ) + } + + private func makeContainer() -> NSView { + let v = NSView(frame: .zero) + v.wantsLayer = true + let layer = v.layer! + layer.backgroundColor = NSColor.black.withAlphaComponent(0.65).cgColor + layer.cornerRadius = 12 + layer.masksToBounds = true + + let f = SearchTextField() + f.isBezeled = false + f.isBordered = false + f.drawsBackground = false + f.focusRingType = .none + f.font = NSFont.systemFont(ofSize: 14, weight: .regular) + f.textColor = .white + f.placeholderAttributedString = NSAttributedString( + string: "Search apps & windows…", + attributes: [ + .foregroundColor: NSColor.white.withAlphaComponent(0.45), + .font: NSFont.systemFont(ofSize: 14, weight: .regular), + ] + ) + f.target = self + f.action = #selector(commit) + f.delegate = TextDelegate.shared + TextDelegate.shared.host = self + v.addSubview(f) + field = f + + let btn = NSButton(title: "Done", target: self, action: #selector(cancel)) + btn.bezelStyle = .rounded + btn.controlSize = .small + btn.font = NSFont.systemFont(ofSize: 12, weight: .medium) + v.addSubview(btn) + cancelButton = btn + + return v + } + + @objc private func commit() { + onCommit?() + } + + @objc private func cancel() { + onCancel?() + } + + fileprivate func didChangeText() { + onChange?(query) + } + + fileprivate func didPressEscape() { + onCancel?() + } + + fileprivate func didPressArrow(_ d: ArrowDirection) { + onArrow?(d) + } + + private final class TextDelegate: NSObject, NSTextFieldDelegate { + static let shared = TextDelegate() + weak var host: SearchField? + + func controlTextDidChange(_ obj: Notification) { + host?.didChangeText() + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { + if selector == #selector(NSResponder.cancelOperation(_:)) { + host?.didPressEscape() + return true + } + // Swallow all caret-movement selectors so the cursor never moves + // inside the field. Forward the cardinal arrows to tile selection. + if let dir = Self.arrowDirection(for: selector) { + host?.didPressArrow(dir) + return true + } + if Self.movementSelectors.contains(selector) { + return true + } + return false + } + + private static func arrowDirection(for selector: Selector) -> ArrowDirection? { + switch selector { + case #selector(NSResponder.moveLeft(_:)): return .left + case #selector(NSResponder.moveRight(_:)): return .right + case #selector(NSResponder.moveUp(_:)): return .up + case #selector(NSResponder.moveDown(_:)): return .down + default: return nil + } + } + + private static let movementSelectors: Set = [ + #selector(NSResponder.moveLeftAndModifySelection(_:)), + #selector(NSResponder.moveRightAndModifySelection(_:)), + #selector(NSResponder.moveUpAndModifySelection(_:)), + #selector(NSResponder.moveDownAndModifySelection(_:)), + #selector(NSResponder.moveWordLeft(_:)), + #selector(NSResponder.moveWordRight(_:)), + #selector(NSResponder.moveWordLeftAndModifySelection(_:)), + #selector(NSResponder.moveWordRightAndModifySelection(_:)), + #selector(NSResponder.moveToBeginningOfLine(_:)), + #selector(NSResponder.moveToEndOfLine(_:)), + #selector(NSResponder.moveToBeginningOfLineAndModifySelection(_:)), + #selector(NSResponder.moveToEndOfLineAndModifySelection(_:)), + #selector(NSResponder.moveToBeginningOfDocument(_:)), + #selector(NSResponder.moveToEndOfDocument(_:)), + #selector(NSResponder.moveToBeginningOfDocumentAndModifySelection(_:)), + #selector(NSResponder.moveToEndOfDocumentAndModifySelection(_:)), + #selector(NSResponder.moveToLeftEndOfLine(_:)), + #selector(NSResponder.moveToRightEndOfLine(_:)), + ] + } +} + +/// NSTextField subclass that intercepts cmd+f so the host can re-trigger +/// search mode (toggle/refocus) without inserting an "f" character. +final class SearchTextField: NSTextField { + var onCmdF: (() -> Void)? + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + let mods = event.modifierFlags.intersection([.command, .shift, .option, .control]) + if mods == [.command], + (event.charactersIgnoringModifiers ?? "").lowercased() == "f" { + onCmdF?() + return true + } + return super.performKeyEquivalent(with: event) + } +} diff --git a/Sources/cmdcmd/Tile.swift b/Sources/cmdcmd/Tile.swift index 6738496..febc7cd 100644 --- a/Sources/cmdcmd/Tile.swift +++ b/Sources/cmdcmd/Tile.swift @@ -73,6 +73,20 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { private var lastPixelBuffer: CVPixelBuffer? var suppressFrames = false private let queue = DispatchQueue(label: "cmdcmd.tile", qos: .userInteractive) + private var restartAttempts = 0 + private static let maxRestartAttempts = 6 + // Frame-delivery diagnostics. Updated only on the SCStream sample queue. + private var loggedFirstDelivery = false + private var loggedFirstLiveFrame = false + private var completeCount = 0 + private var skippedIdle = 0 + private var skippedBlank = 0 + private var skippedSuspended = 0 + private var skippedOther = 0 + private var lastSkipLogAt: CFAbsoluteTime = 0 + private var lastHeartbeatLogAt: CFAbsoluteTime = 0 + private var lastFrameAt: CFAbsoluteTime = 0 + private var watchdog: DispatchSourceTimer? init(scWindow: SCWindow, ownerPID: pid_t) { self.scWindow = scWindow @@ -154,7 +168,6 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { CATransaction.setDisableActions(true) inner.contents = cached CATransaction.commit() - self.hasRenderedFrame = true } } @@ -196,11 +209,32 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { } func setFrame(_ rect: CGRect) { + let newBounds = CGRect(origin: .zero, size: rect.size) + let newShadowPath = CGPath(roundedRect: newBounds, cornerWidth: 10, cornerHeight: 10, transform: nil) + let oldShadowPath = layer.shadowPath + let duration = CATransaction.animationDuration() + let actionsDisabled = CATransaction.disableActions() + layer.frame = rect - content.frame = CGRect(origin: .zero, size: rect.size).insetBy(dx: 1, dy: 1) + content.frame = newBounds.insetBy(dx: 1, dy: 1) + + // CALayer does not return a default action for shadowPath, so an + // implicit animation never starts. Without this, the path snaps to + // the new (often much larger) size at t=0 while layer.frame is still + // animating, which paints a phantom shadow far outside the small + // starting tile. + if !actionsDisabled, duration > 0, let oldShadowPath { + let anim = CABasicAnimation(keyPath: "shadowPath") + anim.fromValue = oldShadowPath + anim.toValue = newShadowPath + anim.duration = duration + anim.timingFunction = CATransaction.animationTimingFunction() + layer.add(anim, forKey: "shadowPath") + } + layer.shadowPath = newShadowPath + CATransaction.begin() CATransaction.setDisableActions(true) - layer.shadowPath = CGPath(roundedRect: CGRect(origin: .zero, size: rect.size), cornerWidth: 10, cornerHeight: 10, transform: nil) layoutLabel() CATransaction.commit() } @@ -295,7 +329,21 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { CATransaction.commit() } - private static let thumbMaxDim: CGFloat = 800 + private static let thumbFloor: CGFloat = 700 + private static let thumbCeiling: CGFloat = 2200 + private static let thumbHeadroom: CGFloat = 1.5 + + /// Target snapshot resolution (longest side, in pixels) sized to the tile's + /// current on-screen footprint. Many small tiles → smaller thumbs; a few + /// large tiles → sharper thumbs. Live capture is unaffected and always + /// runs at full window resolution. + private func currentThumbMaxDim() -> CGFloat { + let scale = NSScreen.main?.backingScaleFactor ?? 2 + let longest = max(layer.frame.width, layer.frame.height) + guard longest > 0 else { return 1400 } + let target = longest * scale * Self.thumbHeadroom + return max(Self.thumbFloor, min(Self.thumbCeiling, target)) + } private func captureConfig(maxDim: CGFloat? = nil) -> SCStreamConfiguration { let config = SCStreamConfiguration() @@ -319,12 +367,27 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { return config } + private func captureImageSafely(filter: SCContentFilter, config: SCStreamConfiguration) async throws -> CGImage { + try await withCheckedThrowingContinuation { cont in + SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) { image, error in + if let image { + cont.resume(returning: image) + } else { + cont.resume(throwing: error ?? NSError( + domain: "cmdcmd.Tile", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "captureImage returned no image"])) + } + } + } + } + func snapshot() async { - if cancelled || hasRenderedFrame || hasRenderedLiveFrame { return } + if cancelled || hasRenderedLiveFrame { return } let filter = SCContentFilter(desktopIndependentWindow: scWindow) - let config = captureConfig(maxDim: Tile.thumbMaxDim) + let config = captureConfig(maxDim: currentThumbMaxDim()) do { - let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) + let image = try await captureImageSafely(filter: filter, config: config) if cancelled || hasRenderedLiveFrame { return } Tile.setCachedFrame(image, for: CGWindowID(scWindow.windowID)) await MainActor.run { @@ -334,6 +397,7 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { self.content.contents = image CATransaction.commit() self.hasRenderedFrame = true + self.lastSignificantChangeAt = CFAbsoluteTimeGetCurrent() } } catch { Log.write("tile snapshot failed wid=\(scWindow.windowID): \(error)") @@ -356,14 +420,32 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { return } self.stream = s + let title = scWindow.title ?? "" + let bid = scWindow.owningApplication?.bundleIdentifier ?? "?" + Log.write("tile stream started wid=\(scWindow.windowID) bid=\(bid) title=\"\(title)\" size=\(config.width)x\(config.height) attempt=\(restartAttempts)") + startWatchdog() } catch { - Log.write("tile start failed wid=\(scWindow.windowID): \(error)") + Log.write("tile start failed wid=\(scWindow.windowID): \(Tile.describe(error))") + } + } + + private static func describe(_ error: Error) -> String { + let ns = error as NSError + var info = "domain=\(ns.domain) code=\(ns.code)" + if let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError { + info += " underlying=\(underlying.domain)/\(underlying.code)" + } + if let reason = ns.localizedFailureReason { + info += " reason=\"\(reason)\"" } + info += " desc=\"\(ns.localizedDescription)\"" + return info } private func cacheLastFrameDeferred() { let id = CGWindowID(self.scWindow.windowID) let q = self.queue + let cap = currentThumbMaxDim() Tile.cacheQueue.async { var pb: CVPixelBuffer? q.sync { @@ -374,7 +456,7 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { let ci = CIImage(cvPixelBuffer: pb) let extent = ci.extent let largest = max(extent.width, extent.height) - let factor = largest > Tile.thumbMaxDim ? Tile.thumbMaxDim / largest : 1 + let factor = largest > cap ? cap / largest : 1 let scaled = factor < 1 ? ci.transformed(by: CGAffineTransform(scaleX: factor, y: factor)) : ci @@ -387,6 +469,7 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { func stop() async { cancelled = true suppressFrames = true + stopWatchdog() cacheLastFrameDeferred() guard let s = stream else { return } self.stream = nil @@ -396,6 +479,7 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { func stopSync(group: DispatchGroup) { cancelled = true suppressFrames = true + stopWatchdog() cacheLastFrameDeferred() guard let s = stream else { return } self.stream = nil @@ -411,9 +495,25 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { let attachments = (CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]])?.first let statusRaw = (attachments?[.status] as? Int).flatMap(SCFrameStatus.init(rawValue:)) + if !loggedFirstDelivery { + loggedFirstDelivery = true + Log.write("tile first delivery wid=\(scWindow.windowID) status=\(Tile.describe(statusRaw))") + } if statusRaw == .idle || statusRaw == .blank || statusRaw == .suspended { + switch statusRaw { + case .idle: skippedIdle += 1 + case .blank: skippedBlank += 1 + case .suspended: skippedSuspended += 1 + default: skippedOther += 1 + } + maybeLogSkipSummary() return } + if statusRaw == .complete { + completeCount += 1 + lastFrameAt = CFAbsoluteTimeGetCurrent() + maybeLogHeartbeat() + } var significantChange = false if let attachments { @@ -435,9 +535,15 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { } } - guard significantChange || !hasRenderedLiveFrame else { return } - self.lastPixelBuffer = pixelBuffer + if restartAttempts != 0 { restartAttempts = 0 } + + if !loggedFirstLiveFrame { + loggedFirstLiveFrame = true + let w = CVPixelBufferGetWidth(pixelBuffer) + let h = CVPixelBufferGetHeight(pixelBuffer) + Log.write("tile first live frame wid=\(scWindow.windowID) status=\(Tile.describe(statusRaw)) size=\(w)x\(h) skipped(idle=\(skippedIdle) blank=\(skippedBlank) suspended=\(skippedSuspended) other=\(skippedOther))") + } if suppressFrames { return } @@ -452,6 +558,57 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { } } + private func maybeLogSkipSummary() { + let now = CFAbsoluteTimeGetCurrent() + if lastSkipLogAt == 0 { lastSkipLogAt = now; return } + if now - lastSkipLogAt < 5 { return } + lastSkipLogAt = now + Log.write("tile skip summary wid=\(scWindow.windowID) live=\(loggedFirstLiveFrame) complete=\(completeCount) idle=\(skippedIdle) blank=\(skippedBlank) suspended=\(skippedSuspended) other=\(skippedOther)") + } + + private func maybeLogHeartbeat() { + let now = CFAbsoluteTimeGetCurrent() + if lastHeartbeatLogAt == 0 { lastHeartbeatLogAt = now; return } + if now - lastHeartbeatLogAt < 5 { return } + lastHeartbeatLogAt = now + Log.write("tile heartbeat wid=\(scWindow.windowID) complete=\(completeCount) skipped(idle=\(skippedIdle) blank=\(skippedBlank) suspended=\(skippedSuspended) other=\(skippedOther))") + } + + /// Fires every 5s; if no frame arrived in the last 5s, log a stall. + private func startWatchdog() { + stopWatchdog() + let t = DispatchSource.makeTimerSource(queue: queue) + t.schedule(deadline: .now() + 5, repeating: 5) + t.setEventHandler { [weak self] in + guard let self, !self.cancelled else { return } + let now = CFAbsoluteTimeGetCurrent() + let elapsed = self.lastFrameAt == 0 ? -1 : now - self.lastFrameAt + if self.lastFrameAt == 0 || elapsed > 5 { + Log.write("tile stall wid=\(self.scWindow.windowID) sinceLastFrame=\(String(format: "%.1f", elapsed))s complete=\(self.completeCount) skipped(idle=\(self.skippedIdle) blank=\(self.skippedBlank) suspended=\(self.skippedSuspended) other=\(self.skippedOther)) streamAlive=\(self.stream != nil)") + } + } + t.resume() + watchdog = t + } + + private func stopWatchdog() { + watchdog?.cancel() + watchdog = nil + } + + private static func describe(_ status: SCFrameStatus?) -> String { + guard let status else { return "nil" } + switch status { + case .complete: return "complete" + case .idle: return "idle" + case .blank: return "blank" + case .suspended: return "suspended" + case .started: return "started" + case .stopped: return "stopped" + @unknown default: return "raw(\(status.rawValue))" + } + } + func updateActivity(now: CFAbsoluteTime) { let elapsed = now - lastSignificantChangeAt let activeWithin: CFTimeInterval = 0.5 @@ -474,6 +631,50 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { } func stream(_ stream: SCStream, didStopWithError error: Error) { - Log.write("tile stream stopped: \(error)") + let title = scWindow.title ?? "" + let bid = scWindow.owningApplication?.bundleIdentifier ?? "?" + Log.write("tile stream stopped wid=\(scWindow.windowID) bid=\(bid) title=\"\(title)\" hadFrame=\(hasRenderedLiveFrame) suppressed=\(suppressFrames) cancelled=\(cancelled) complete=\(completeCount) skipped(idle=\(skippedIdle) blank=\(skippedBlank) suspended=\(skippedSuspended) other=\(skippedOther)): \(Tile.describe(error))") + self.stream = nil + stopWatchdog() + promoteLastFrameToLayer() + if cancelled { return } + let attempt = restartAttempts + 1 + restartAttempts = attempt + guard attempt <= Self.maxRestartAttempts else { + Log.write("tile giving up restart wid=\(scWindow.windowID) after \(attempt) attempts") + return + } + let delay = min(5.0, 0.5 * Double(attempt)) + Log.write("tile scheduling restart wid=\(scWindow.windowID) attempt=\(attempt) delay=\(delay)s") + Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard let self, !self.cancelled, self.stream == nil else { return } + await self.start() + } + } + + /// Convert the most recent pixel buffer into a CGImage and assign it to the + /// tile's layer. Used when the live stream stops so the tile retains a + /// stable image instead of a recycled IOSurface. + private func promoteLastFrameToLayer() { + let q = self.queue + Tile.cacheQueue.async { [weak self] in + guard let self else { return } + var pb: CVPixelBuffer? + q.sync { + pb = self.lastPixelBuffer + } + guard let pb else { return } + let ci = CIImage(cvPixelBuffer: pb) + guard let cg = Tile.ciContext.createCGImage(ci, from: ci.extent) else { return } + Tile.setCachedFrame(cg, for: CGWindowID(self.scWindow.windowID)) + DispatchQueue.main.async { [weak self] in + guard let self, !self.cancelled else { return } + CATransaction.begin() + CATransaction.setDisableActions(true) + self.content.contents = cg + CATransaction.commit() + } + } } }