From 471c8b8a6f8e58090c862576377fd9a503b3dbb9 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 22:14:52 -0700 Subject: [PATCH 1/6] test: a window snapshot waits behind a busy main actor; a background toggle reaches the Home (red) debug.window_snapshot is now built by DebugWindowSnapshot.controlMethod (no behavior change) so a test can register it. Both tests fail on this commit: the snapshot times out at the control-plane deadline while the main actor is busy, and with no key window a child window in front takes the Chief Settings toggle instead of its window. Co-Authored-By: Claude Opus 5.5 --- .../CmuxNextApp/Control/AppControl.swift | 5 +-- .../Control/DebugWindowSnapshot.swift | 9 ++++++ .../DebugWindowSnapshotDeadlineTests.swift | 31 +++++++++++++++++++ .../HomeChiefSettingsToggleTests.swift | 17 ++++++++++ 4 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugWindowSnapshotDeadlineTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/AppControl.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/AppControl.swift index 9d6f027106c7..9ccb4d988ef3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/AppControl.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/AppControl.swift @@ -295,10 +295,7 @@ final class AppControl { return .value(DebugWindowList.list(services: services)) }, .mainActor("debug.page_popup") { _ in .value(DebugPagePopup.list()) }, - .async("debug.window_snapshot") { [weak services] call in - guard let services = await MainActor.run(body: { services }) else { return .null } - return await DebugWindowSnapshot.captureAsync(call.params, services: services) - }, + DebugWindowSnapshot.controlMethod(services: services), .mainActor("debug.window.focus") { [weak services] call in guard let services else { return .value(.null) } return .value(DebugKey.focusWindow(call.params, services: services)) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift index a3240c6c46aa..d0f90980fe33 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift @@ -1,5 +1,6 @@ import AppKit import CmuxNextBrowser +import CmuxNextControl import CmuxNextDesign import CmuxNextPages import CmuxNextSettings @@ -44,6 +45,14 @@ import WebKit /// far. Check those (or `debug.toast`) instead of looking for the toast in /// the PNG. enum DebugWindowSnapshot { + /// The control route, `debug.window_snapshot`. + static func controlMethod(services: AppServices) -> ControlMethod { + .async("debug.window_snapshot") { [weak services] call in + guard let services = await MainActor.run(body: { services }) else { return .null } + return await captureAsync(call.params, services: services) + } + } + @MainActor static func capture(_ params: [String: JSONValue], services: AppServices) async -> JSONValue { guard let window = window(params, services: services) else { return .object(["error": .string("no such window")]) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugWindowSnapshotDeadlineTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugWindowSnapshotDeadlineTests.swift new file mode 100644 index 000000000000..8e483187145f --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugWindowSnapshotDeadlineTests.swift @@ -0,0 +1,31 @@ +import AppKit +@testable import CmuxNextApp +import CmuxNextControl +import CmuxNextSettings +import Foundation +import Testing + +/// `debug.window_snapshot` captures on the main actor. On a capture mini that also runs builds and +/// tests, or while the app is still launching, the wait for the main actor passed the 2 s +/// control-plane deadline: the snapshot timed out, the capture had no still, and pr-capture refused +/// it. The capture itself takes about 50 ms once it runs, so the method keeps its own deadline and +/// waits behind a busy main actor instead of failing. +@MainActor @Suite(.serialized) struct DebugWindowSnapshotDeadlineTests { + @Test func aSnapshotWaitsBehindABusyMainActor() async throws { + _ = NSApplication.shared + let services = AppServices(environment: AppEnvironment.current([:])) + let router = ControlRouter(identity: ControlIdentity(version: "1.0", build: "1", bundleID: "com.cmuxterm.app.debug.test", tag: "test", + processID: getpid()), + executor: RegistryControlBridge(registry: services.registry), settings: nil, + configuration: ControlRouter.Configuration(requestDeadline: .milliseconds(50))) + router.register([DebugWindowSnapshot.controlMethod(services: services)]) + let request = ControlRequest(id: "1", method: "debug.window_snapshot", params: ["window": .string("999999999")]) + let reply = Task.detached { await router.handle(request) } + // The main actor stays busy past the control-plane deadline, as on a loaded capture mini. + let busyUntil = ContinuousClock.now + .milliseconds(400) + while ContinuousClock.now < busyUntil {} + // With the control-plane deadline this was a `timeout` failure. + let value = try await reply.value.get() + #expect(value["error"]?.stringValue == "no such window") + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeChiefSettingsToggleTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeChiefSettingsToggleTests.swift index 642c3d222062..0843b61e4d16 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeChiefSettingsToggleTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeChiefSettingsToggleTests.swift @@ -25,6 +25,23 @@ import Testing #expect(HomeChiefSettingsPopover.toggleTarget(key: nil, ordered: [hidden, shown]) === shown) } + /// With no key window, a visible child window in front (Chromium's page window, an overlay + /// panel) stands for the window it belongs to. AppKit lists a child window before its parent + /// when the app is not active, so a toggle run from the CLI with cmux in the background (every + /// capture slot) reached the page window, no Home answered, and the popover never opened. + @Test func withoutAKeyWindowAChildInFrontStandsForItsWindow() { + let (home, page) = (Self.window(), Self.window()) + home.orderFrontRegardless() + home.addChildWindow(page, ordered: .above) + defer { + home.removeChildWindow(page) + page.orderOut(nil) + home.orderOut(nil) + } + #expect(page.isVisible) + #expect(HomeChiefSettingsPopover.toggleTarget(key: nil, ordered: [page, home]) === home) + } + /// The open popover is a child panel with the keys: it stands for its window, so a second /// toggle reaches the Home that opened it and closes it. @Test func aChildPanelWithTheKeysStandsForItsWindow() { From 88261ccc56bc53be52ff35cc051ddb6cf6de6c59 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 22:15:49 -0700 Subject: [PATCH 2/6] fix: a window snapshot waits behind a busy main actor; a background toggle reaches the Home debug.window_snapshot keeps its own 30 s deadline instead of the 2 s control-plane one. On a capture mini that also runs builds and tests (cua-5 at load 30 to 44 on 14 cores) the wait for the main actor passed 2 s, the snapshot timed out, and pr-capture refused the capture for want of a still. The capture itself takes about 50 ms. With no key window, home.toggleChiefSettings resolves the frontmost visible window to the window it belongs to. Outside an active app AppKit lists Chromium page child window first, so a toggle from the CLI with cmux in the background (every capture slot) reached no Home and the popover never opened. Co-Authored-By: Claude Opus 5.5 --- .../CmuxNextApp/Control/DebugWindowSnapshot.swift | 11 +++++++++-- .../HomeChiefSettingsPopover.swift | 12 +++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift index d0f90980fe33..312e0c830ce8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift @@ -45,12 +45,19 @@ import WebKit /// far. Check those (or `debug.toast`) instead of looking for the toast in /// the PNG. enum DebugWindowSnapshot { - /// The control route, `debug.window_snapshot`. + /// How long `debug.window_snapshot` may take, instead of the 2 s control-plane deadline. The + /// capture runs on the main actor and takes about 50 ms once it runs, but on a capture mini + /// that also runs builds and tests, or while the app is still launching, the wait for the + /// main actor alone passed 2 s: the snapshot timed out and the capture had no still. Its + /// callers wait 10 to 90 s (pr-capture's driver 30 s). + static let deadline: Duration = .seconds(30) + + /// The control route, `debug.window_snapshot`, with its own ``deadline``. static func controlMethod(services: AppServices) -> ControlMethod { .async("debug.window_snapshot") { [weak services] call in guard let services = await MainActor.run(body: { services }) else { return .null } return await captureAsync(call.params, services: services) - } + }.withDeadline(.fixed(deadline)) } @MainActor diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefSettingsPopover.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefSettingsPopover.swift index 95246760d96e..92c3a0ee0dc5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefSettingsPopover.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefSettingsPopover.swift @@ -43,12 +43,14 @@ public final class HomeChiefSettingsPopover { /// The window whose Home answers `home.toggleChiefSettings`: the key window, else the frontmost /// visible one (`ordered` front to back; a CLI run while another app is in front), else none. - /// A child panel with the keys (this popover itself, a feed or composer popup) stands for the - /// window it belongs to, so a second toggle closes the popover it opened. + /// A child window stands for the window it belongs to: a child panel with the keys (this + /// popover itself, a feed or composer popup), so a second toggle closes the popover it opened, + /// and with no key window a child listed first (Chromium's page window: outside an active app + /// AppKit lists it before its parent, and a capture slot is never active). public static func toggleTarget(key: NSWindow?, ordered: [NSWindow]) -> NSWindow? { - var window = key - while let parent = window?.parent { window = parent } - return window ?? ordered.first { $0.isVisible } + guard var window = key ?? ordered.first(where: { $0.isVisible }) else { return nil } + while let parent = window.parent { window = parent } + return window } /// Whether the Home in `window` answers a toggle posted for `target` (``toggleTarget(key:ordered:)``'s From 3992fa84b5f37eb392714e3cec8cd4c9bd50baec Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 22:40:28 -0700 Subject: [PATCH 3/6] Composite a window with its popovers and menus in one image A window snapshot composited the window, then its overlay panels alone, then the menus over it. A glass popover composited alone blurs nothing and came out grey. The window, its child windows and the app's windows over it now go to the window server as one composite, so the glass blurs the window under it as on screen. Where a Chromium page image is painted over that, the overlays come back on top through a coverage mask. Only this app's windows are composited, as before. Co-Authored-By: Claude Opus 5.5 --- .../Control/DebugWindowSnapshot.swift | 78 +++++++++++-------- .../Screenshots/AppWindowImage.swift | 34 +++++--- .../Screenshots/WindowImageLayer.swift | 20 +++++ .../Windows/NSWindow+Screenshot.swift | 12 +++ .../Windows/NSWindow+Snapshot.swift | 59 +++++++++++--- .../WindowImageLayerOverlayTests.swift | 60 ++++++++++++++ .../WindowSnapshotOverlaysTests.swift | 11 +++ 7 files changed, 219 insertions(+), 55 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WindowImageLayerOverlayTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift index 312e0c830ce8..abd8de65bc6f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift @@ -23,13 +23,16 @@ import WebKit /// visible child windows the image includes) and `child_windows_failed` /// (how many it leaves out because the window server gave no image of /// them; AppKit drawing never includes a child window). Child windows are -/// composited by the window server over the window: a Chromium page draws -/// into its own child window, and overlay panels sit above content. +/// composited by the window server with the window: a Chromium page draws +/// into its own child window, and overlay panels and popovers sit above content. /// `floating_windows` counts the app's other windows the window server shows -/// above the window and over it, painted on top cut to its frame: an open +/// above the window and over it, cut to its frame: an open /// context menu (not in AppKit's window list), a hover card or popover that is /// not its child; another of the app's titled windows in front at the same -/// level, with its own child windows and sheets, is left out. The +/// level, with its own child windows and sheets, is left out. The window, its +/// child windows and those windows are one window server composite +/// (``NSWindow/compositedSnapshotWithOverlays()``), so a glass popover or menu +/// blurs the window under it, as on screen (composited apart, it came out grey). The /// async verb also paints each engine's own image of every shown page /// (`webviews`, `chromium_pages`, with `_composited` and `_failed` counts), /// since the window server @@ -67,22 +70,20 @@ enum DebugWindowSnapshot { let path = params["path"]?.stringValue.map { ($0 as NSString).expandingTildeInPath } ?? (NSTemporaryDirectory() as NSString).appendingPathComponent("cmux-window-\(kind)-\(window.windowNumber).png") do { - let snapshot = try window.snapshotImage() + // The window with its child windows and the app's menus, cards and popovers over it, + // composited together as on screen; else AppKit drawing (no child windows). + let together = window.compositedSnapshotWithOverlays() + let snapshot = try together.map { (image: $0.image, method: WindowSnapshotMethod.composited) } ?? window.snapshotImage() // Read before the encode's await: a sheet may close meanwhile. let children = window.visibleChildWindows.count - // The app's menus, cards and popovers over the window go on top, as on screen. - let floating = window.floatingWindowsSnapshot().flatMap { windows in - WindowImageLayer.composite(base: snapshot.image, window: window, layers: [.window(windows.image)]) - .map { (image: $0, count: windows.count) } - } - let output = floating?.image ?? snapshot.image + let output = snapshot.image try await NSWindow.encodeSnapshotPNG(output, to: URL(fileURLWithPath: path)) return .object([ "path": .string(path), "width": JSONValue(output.width), "height": JSONValue(output.height), "kind": .string(kind), "window_number": JSONValue(window.windowNumber), "method": .string(snapshot.method.rawValue), "child_windows": JSONValue(snapshot.method == .composited ? children : 0), "child_windows_failed": JSONValue(snapshot.method == .composited ? 0 : children), - "floating_windows": JSONValue(floating?.count ?? 0), + "floating_windows": JSONValue(together?.floating ?? 0), "refusal_hud": hudMessage(services), "refusal_hud_count": JSONValue(services.refusalHUD.shownCount), ]) } catch { @@ -91,12 +92,12 @@ enum DebugWindowSnapshot { } /// Captures the window and paints each visible page over the window - /// image, in screen order: the window (with its page child windows), the - /// WebKit pages, the Chromium pages, the overlay panels above content, - /// then the app's menus open over it. The base is the window server's - /// image whenever it gives one: it is the window as on screen, WebKit - /// pages included (they are layers of this window), so no WebKit image - /// is painted over it. AppKit drawing + /// image, in screen order: the window (with its child windows and the + /// app's menus open over it), the WebKit pages, the Chromium pages, then + /// the overlay panels and menus again where they cover a page. The base is + /// the window server's image whenever it gives one: it is the window as on + /// screen, WebKit pages and overlays included (WebKit pages are layers of + /// this window), so no WebKit image is painted over it. AppKit drawing /// is only the fallback (no window server image, a host without a lit /// display): it leaves out WebKit's remote content, so each page's own /// image is painted there, and it drops Liquid Glass with the views @@ -115,20 +116,21 @@ enum DebugWindowSnapshot { ?? (NSTemporaryDirectory() as NSString).appendingPathComponent("cmux-window-\(kind)-\(window.windowNumber).png") do { let webViews = visibleWebViews(in: window) - // The window server's image first: it shows the window as on - // screen, WebKit pages included. Only without it, AppKit drawing - // with the live web views hidden, so the page snapshots below fill - // each rectangle exactly once. The overlay panels go on top at the - // end, so either base has only the page windows. - let composited = window.compositedSnapshot(includeChild: WindowOverlayHost.isPageWindow) - let base = try composited.map { (image: $0, method: WindowSnapshotMethod.composited) } + // The window server's image first: the window as on screen, WebKit + // pages, child windows and the app's menus and popovers over it + // composited together, so glass over the window blurs it. Only + // without it, AppKit drawing with the live web views hidden, so the + // page snapshots below fill each rectangle exactly once, and the + // overlay panels painted on top at the end. + let together = window.compositedSnapshotWithOverlays() + let base = try together.map { (image: $0.image, method: WindowSnapshotMethod.composited) } ?? (webViews.isEmpty ? appKitBaseImage(for: window) : nativeBaseImage(for: window, hiding: webViews)) var layers: [WindowImageLayer] = [] // The child windows each layer brings, so the result counts only // the ones the image includes (none from a missing layer). let children = window.visibleChildWindows let pageChildren = children.filter(WindowOverlayHost.isPageWindow).count - var childrenIncluded = base.method == .composited ? pageChildren : 0 + var childrenIncluded = base.method == .composited ? children.count : 0 if base.method == .appkit, let pageWindows = window.childWindowsSnapshot(includeChild: WindowOverlayHost.isPageWindow) { layers.append(.window(pageWindows)) childrenIncluded += pageChildren @@ -166,13 +168,23 @@ enum DebugWindowSnapshot { guard let image = await page.chromiumImage() else { pagesFailed += 1; continue } layers.append(.page(image, rect)) } - if let panels = window.childWindowsSnapshot(includeChild: { !WindowOverlayHost.isPageWindow($0) }) { - layers.append(.window(panels)) - childrenIncluded += children.count - pageChildren + var floatingCount = together?.floating ?? 0 + if base.method == .composited { + // A Chromium page image covered the panels, popovers and menus over its page: + // their pixels come back from the window server's composite, the page under them. + if !layers.isEmpty, let overlays = window.overlaysSnapshot(includeChild: { !WindowOverlayHost.isPageWindow($0) }) { + layers.append(.overlay(base.image, coverage: overlays)) + } + } else { + if let panels = window.childWindowsSnapshot(includeChild: { !WindowOverlayHost.isPageWindow($0) }) { + layers.append(.window(panels)) + childrenIncluded += children.count - pageChildren + } + // Menus, and cards or popovers not attached to the window, go on top. + let floating = window.floatingWindowsSnapshot() + if let floating { layers.append(.window(floating.image)) } + floatingCount = floating?.count ?? 0 } - // Menus, and cards or popovers not attached to the window, go on top. - let floating = window.floatingWindowsSnapshot() - if let floating { layers.append(.window(floating.image)) } let painted = WindowImageLayer.composite(base: base.image, window: window, layers: layers) let output = painted ?? base.image // AppKit/WebKit capture and compositing stay on the main actor; @@ -184,7 +196,7 @@ enum DebugWindowSnapshot { "webviews": JSONValue(webViews.count), "webviews_composited": JSONValue(webViews.count - failed), "webviews_failed": JSONValue(failed), "child_windows": JSONValue(childrenIncluded), "child_windows_failed": JSONValue(children.count - childrenIncluded), - "floating_windows": JSONValue(painted == nil ? 0 : floating?.count ?? 0), + "floating_windows": JSONValue(base.method == .composited || painted != nil ? floatingCount : 0), "chromium_pages": JSONValue(pages.count + reactPages.count), "chromium_pages_composited": JSONValue(pages.count + reactPages.count - pagesFailed), "chromium_pages_failed": JSONValue(pagesFailed), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/AppWindowImage.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/AppWindowImage.swift index 11a776942675..04c92fe5242b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/AppWindowImage.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/AppWindowImage.swift @@ -4,14 +4,16 @@ import CmuxNextDesign import CmuxNextPages /// One of this app's main windows as on screen, for `cmux window|pane|tab -/// screenshot`: the window server's image of the window and its Chromium -/// page windows (``NSWindow/screenshotImage(includeChild:)``: Ghostty's -/// Metal terminals and WebKit pages included, no Screen Recording grant, -/// background and covered windows too), then Chromium's own image of each -/// shown page painted over its page area (without the grant the window -/// server leaves out what Chromium's GPU process draws), then the overlay -/// panels, open menus and the app's other windows above the content. Nothing is focused, raised or -/// activated. +/// screenshot`: the window server's image of the window with its child +/// windows and the app's menus and popovers over it, composited together so +/// glass over the window blurs it (``NSWindow/screenshotImageWithOverlays()``: +/// Ghostty's Metal terminals and WebKit pages included, no Screen Recording +/// grant, background and covered windows too), then Chromium's own image of +/// each shown page painted over its page area (without the grant the window +/// server leaves out what Chromium's GPU process draws), then the overlays +/// again where they cover a page. Without that image, the window and its page +/// windows (``NSWindow/screenshotImage(includeChild:)``) with the overlay +/// panels and open menus painted on top. Nothing is focused, raised or activated. @MainActor struct AppWindowImage { let services: AppServices @@ -19,7 +21,8 @@ struct AppWindowImage { /// The window's image at its backing scale. /// - Throws: ``WindowScreenshotFailure`` when the window server gives no image. func capture(_ window: NSWindow) async throws -> CGImage { - let base = try await window.screenshotImage(includeChild: WindowOverlayHost.isPageWindow) + let together = window.screenshotImageWithOverlays()?.image + let base = try await together ?? window.screenshotImage(includeChild: WindowOverlayHost.isPageWindow) var layers: [WindowImageLayer] = [] for (page, rect) in DebugWindowSnapshot.chromiumPages(in: window, services: services) { // A page that cannot give its image keeps the window server's @@ -33,10 +36,17 @@ struct AppWindowImage { guard let image = await page.chromiumImage() else { continue } layers.append(.page(image, rect)) } - if let panels = window.childWindowsSnapshot(includeChild: { !WindowOverlayHost.isPageWindow($0) }) { - layers.append(.window(panels)) + if together != nil { + // A page image covered the panels, popovers and menus over its page: they come back. + if !layers.isEmpty, let overlays = window.overlaysSnapshot(includeChild: { !WindowOverlayHost.isPageWindow($0) }) { + layers.append(.overlay(base, coverage: overlays)) + } + } else { + if let panels = window.childWindowsSnapshot(includeChild: { !WindowOverlayHost.isPageWindow($0) }) { + layers.append(.window(panels)) + } + if let floating = window.floatingWindowsSnapshot() { layers.append(.window(floating.image)) } } - if let floating = window.floatingWindowsSnapshot() { layers.append(.window(floating.image)) } return WindowImageLayer.composite(base: base, window: window, layers: layers) ?? base } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/WindowImageLayer.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/WindowImageLayer.swift index 91a54a98691e..140ce719213f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/WindowImageLayer.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/WindowImageLayer.swift @@ -10,6 +10,10 @@ enum WindowImageLayer { case page(CGImage, NSRect, holes: [NSRect] = []) /// A window server image of child windows, the size of the window. case window(CGImage) + /// The window's own composite (`full`, its overlays included) painted back where `coverage` + /// (its overlays alone, the size of the window) covers what was painted below: a popover or + /// menu over a page image shows as on screen, with the page under it. + case overlay(CGImage, coverage: CGImage) /// `base` (an image of `window`) with `layers` painted over it, bottom first; nil without layers. static func composite(base: CGImage, window: NSWindow, layers: [WindowImageLayer]) -> CGImage? { @@ -48,8 +52,24 @@ enum WindowImageLayer { context.restoreGState() case .window(let image): context.draw(image, in: whole) + case .overlay(let full, let coverage): + guard let covered = masked(full, by: coverage, width: width, height: height, space: space) else { continue } + context.draw(covered, in: whole) } } return context.makeImage() } + + /// `image` kept only where `mask` has alpha (premultiplied: `image` times the mask's alpha), + /// both drawn at `width` x `height` pixels. + static func masked(_ image: CGImage, by mask: CGImage, width: Int, height: Int, space: CGColorSpace) -> CGImage? { + guard let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, + space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil } + let whole = CGRect(x: 0, y: 0, width: width, height: height) + context.interpolationQuality = .high + context.draw(mask, in: whole) + context.setBlendMode(.sourceIn) + context.draw(image, in: whole) + return context.makeImage() + } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Screenshot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Screenshot.swift index 341a16fbc7e1..e71bbc55a442 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Screenshot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Screenshot.swift @@ -41,6 +41,18 @@ extension NSWindow { throw WindowScreenshotFailure.blank } + /// This window with every child window and the app's windows over it composited together + /// (``compositedSnapshotWithOverlays()``: glass panels and menus blur the window under them, + /// as on screen) at the backing scale, with how many of the app's other windows it includes; + /// nil when the window is not on screen or the window server gives no image (the caller then + /// takes ``screenshotImage(includeChild:)``). + public func screenshotImageWithOverlays() -> (image: CGImage, floating: Int)? { + guard windowNumber > 0, !isMiniaturized, isVisible, let snapshot = compositedSnapshotWithOverlays() else { return nil } + let scale = backingScaleFactor + let width = Int((frame.width * scale).rounded()), height = Int((frame.height * scale).rounded()) + return snapshot.image.resampled(width: width, height: height).map { ($0, snapshot.floating) } + } + /// This window alone through ScreenCaptureKit's current-process content /// (no Screen Recording consent), at the backing scale; nil when the /// window server lists no such window of this process or the image is diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Snapshot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Snapshot.swift index 954d5e2353fb..1b5759abbaa8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Snapshot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Snapshot.swift @@ -95,17 +95,50 @@ extension NSWindow { /// of the window then shows them as on screen. Menus are not in AppKit's window list, so /// the window server's list chooses (``ownWindows(above:pid:region:layer:skipping:ordinary:)``). public func floatingWindowsSnapshot() -> (image: CGImage, count: Int)? { - guard windowNumber > 0, isOnActiveSpace, let primary = NSScreen.screens.first else { return nil } - let above = (CGWindowListCopyWindowInfo([.optionOnScreenAboveWindow], CGWindowID(windowNumber)) as? [[String: Any]] ?? []) - .compactMap(ListedWindow.init) - let numbers = Self.ownWindows(above: above, pid: getpid(), region: Self.globalRect(of: frame, primaryHeight: primary.frame.maxY), - layer: level.rawValue, skipping: Set(visibleChildWindows.map(\.windowNumber)), - ordinary: Self.ordinaryWindowTrees(besides: self)) + let numbers = floatingWindowNumbers() guard !numbers.isEmpty, let image = Self.windowServerImage(ofNumbers: numbers, croppedTo: self), image.width > 0, image.height > 0, !Self.isBlank(image) else { return nil } return (image, numbers.count) } + /// The numbers of the windows ``floatingWindowsSnapshot()`` paints, topmost first. + private func floatingWindowNumbers() -> [Int] { + guard windowNumber > 0, isOnActiveSpace, let primary = NSScreen.screens.first else { return [] } + let above = (CGWindowListCopyWindowInfo([.optionOnScreenAboveWindow], CGWindowID(windowNumber)) as? [[String: Any]] ?? []) + .compactMap(ListedWindow.init) + return Self.ownWindows(above: above, pid: getpid(), region: Self.globalRect(of: frame, primaryHeight: primary.frame.maxY), + layer: level.rawValue, skipping: Set(visibleChildWindows.map(\.windowNumber)), + ordinary: Self.ordinaryWindowTrees(besides: self)) + } + + /// This window, every visible child window and the app's windows over it + /// (``floatingWindowsSnapshot()``: an open menu, a card or popover that is not its child) + /// composited together by the window server and cropped to the window's frame, with how many + /// of those other windows it includes; nil without an image (not on screen, or all + /// transparent). Composited together, a glass or vibrancy panel blurs the window under it, as + /// on screen; composited alone (``childWindowsSnapshot(includeChild:)``) it blurs nothing and + /// comes out grey. Windows of other apps are never included. + public func compositedSnapshotWithOverlays() -> (image: CGImage, floating: Int)? { + let floating = floatingWindowNumbers() + let children = visibleChildWindows.map(\.windowNumber) + let image = children.isEmpty && floating.isEmpty + ? Self.windowServerImage(of: windowNumber) + : Self.windowServerImage(ofNumbers: Self.frontToBack([windowNumber] + children + floating), croppedTo: self) + guard let image, image.width > 0, image.height > 0, !Self.isBlank(image) else { return nil } + return (image, floating.count) + } + + /// The window's overlays alone: its visible child windows `includeChild` accepts and the app's + /// windows over it, composited on a transparent image the size of this window, or nil without + /// one. Their alpha says where they cover the window: a page image painted over a + /// ``compositedSnapshotWithOverlays()`` gives those pixels back to them. + public func overlaysSnapshot(includeChild: (NSWindow) -> Bool) -> CGImage? { + let numbers = visibleChildWindows.filter(includeChild).map(\.windowNumber) + floatingWindowNumbers() + guard !numbers.isEmpty, let image = Self.windowServerImage(ofNumbers: Self.frontToBack(numbers), croppedTo: self), + image.width > 0, image.height > 0, !Self.isBlank(image) else { return nil } + return image + } + /// From `above` (the windows the window server shows above a window, topmost first), the /// numbers of this process's visible windows over `region` that a snapshot paints over it: /// not the `skipping` ones (its child windows, composited already), and not the ones that @@ -196,12 +229,18 @@ extension NSWindow { /// Windows it does not list on screen keep AppKit's order behind them /// (each child after its parent is above it, so reversed). private static func frontToBack(_ numbers: [Int]) -> [Int] { - let wanted = Set(numbers) let onScreen = (CGWindowListCopyWindowInfo([.optionOnScreenOnly], kCGNullWindowID) as? [[String: Any]] ?? []) .compactMap { ($0[kCGWindowNumber as String] as? NSNumber)?.intValue } - .filter { wanted.contains($0) } - let listed = Set(onScreen) - return onScreen + numbers.reversed().filter { !listed.contains($0) } + return frontToBack(numbers, onScreen: onScreen) + } + + /// `numbers` in the order of `onScreen` (the window server's on-screen list, topmost first), + /// then the ones it does not list, last of `numbers` first; each number once. + nonisolated static func frontToBack(_ numbers: [Int], onScreen: [Int]) -> [Int] { + let wanted = Set(numbers) + var seen = Set() + let listed = onScreen.filter { wanted.contains($0) && seen.insert($0).inserted } + return listed + numbers.reversed().filter { seen.insert($0).inserted } } /// Whether every pixel of `image` is transparent (sampled at 64 x 64). diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WindowImageLayerOverlayTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WindowImageLayerOverlayTests.swift new file mode 100644 index 000000000000..7dbb7387b664 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WindowImageLayerOverlayTests.swift @@ -0,0 +1,60 @@ +import AppKit +@testable import CmuxNextApp +import Testing + +/// A window snapshot's base is the window server's composite of the window with its popovers and +/// menus, and Chromium's own page image is painted over it. Where a popover or menu covers the +/// page, its pixels come back from the composite (`WindowImageLayer.overlay`), so it shows over +/// the page as on screen instead of under the page image. +@MainActor @Suite struct WindowImageLayerOverlayTests { + private static let side = 40 + + private static func image(_ fill: (CGContext) -> Void) throws -> CGImage { + let space = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + let context = try #require(CGContext(data: nil, width: side, height: side, bitsPerComponent: 8, bytesPerRow: side * 4, + space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + fill(context) + return try #require(context.makeImage()) + } + + private static func solid(_ color: CGColor) throws -> CGImage { + try image { context in + context.setFillColor(color) + context.fill(CGRect(x: 0, y: 0, width: side, height: side)) + } + } + + /// The RGBA bytes of column `x` (the same in every row of these images). + private static func pixel(_ image: CGImage, x: Int) throws -> [UInt8] { + let space = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + var bytes = [UInt8](repeating: 0, count: side * side * 4) + let drawn = bytes.withUnsafeMutableBytes { buffer -> Bool in + guard let context = CGContext(data: buffer.baseAddress, width: side, height: side, bitsPerComponent: 8, bytesPerRow: side * 4, + space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return false } + context.draw(image, in: CGRect(x: 0, y: 0, width: side, height: side)) + return true + } + #expect(drawn) + let start = (side / 2 * side + x) * 4 + return Array(bytes[start.. Date: Wed, 21 Oct 2026 22:43:40 -0700 Subject: [PATCH 4/6] fix: a window screenshot awaits its fallback outside ?? Co-Authored-By: Claude Opus 5.5 --- .../Sources/CmuxNextApp/Screenshots/AppWindowImage.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/AppWindowImage.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/AppWindowImage.swift index 04c92fe5242b..8d569a18c7c3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/AppWindowImage.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/AppWindowImage.swift @@ -22,7 +22,8 @@ struct AppWindowImage { /// - Throws: ``WindowScreenshotFailure`` when the window server gives no image. func capture(_ window: NSWindow) async throws -> CGImage { let together = window.screenshotImageWithOverlays()?.image - let base = try await together ?? window.screenshotImage(includeChild: WindowOverlayHost.isPageWindow) + let base: CGImage + if let together { base = together } else { base = try await window.screenshotImage(includeChild: WindowOverlayHost.isPageWindow) } var layers: [WindowImageLayer] = [] for (page, rect) in DebugWindowSnapshot.chromiumPages(in: window, services: services) { // A page that cannot give its image keeps the window server's From d04352ff7e7f0d1649b9f0970925caf0776fab54 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 22:51:52 -0700 Subject: [PATCH 5/6] Keep the capture tool change to debug.window_snapshot Two open PRs (#23972, #23983) rework cmux window screenshot in AppWindowImage and WindowImageLayer. Leave both files as on base: the overlay cut is an NSWindow helper painted as an ordinary window layer. Co-Authored-By: Claude Opus 5.5 --- .../Control/DebugWindowSnapshot.swift | 4 +- .../Screenshots/AppWindowImage.swift | 35 ++++------- .../Screenshots/WindowImageLayer.swift | 20 ------- .../Windows/NSWindow+Screenshot.swift | 12 ---- .../Windows/NSWindow+Snapshot.swift | 32 +++++++--- .../WindowImageLayerOverlayTests.swift | 60 ------------------- .../WindowSnapshotOverlaysTests.swift | 35 +++++++++++ 7 files changed, 73 insertions(+), 125 deletions(-) delete mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WindowImageLayerOverlayTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift index abd8de65bc6f..0a19392ccd85 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift @@ -172,8 +172,8 @@ enum DebugWindowSnapshot { if base.method == .composited { // A Chromium page image covered the panels, popovers and menus over its page: // their pixels come back from the window server's composite, the page under them. - if !layers.isEmpty, let overlays = window.overlaysSnapshot(includeChild: { !WindowOverlayHost.isPageWindow($0) }) { - layers.append(.overlay(base.image, coverage: overlays)) + if !layers.isEmpty, let overlays = window.overlays(cutFrom: base.image, includeChild: { !WindowOverlayHost.isPageWindow($0) }) { + layers.append(.window(overlays)) } } else { if let panels = window.childWindowsSnapshot(includeChild: { !WindowOverlayHost.isPageWindow($0) }) { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/AppWindowImage.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/AppWindowImage.swift index 8d569a18c7c3..11a776942675 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/AppWindowImage.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/AppWindowImage.swift @@ -4,16 +4,14 @@ import CmuxNextDesign import CmuxNextPages /// One of this app's main windows as on screen, for `cmux window|pane|tab -/// screenshot`: the window server's image of the window with its child -/// windows and the app's menus and popovers over it, composited together so -/// glass over the window blurs it (``NSWindow/screenshotImageWithOverlays()``: -/// Ghostty's Metal terminals and WebKit pages included, no Screen Recording -/// grant, background and covered windows too), then Chromium's own image of -/// each shown page painted over its page area (without the grant the window -/// server leaves out what Chromium's GPU process draws), then the overlays -/// again where they cover a page. Without that image, the window and its page -/// windows (``NSWindow/screenshotImage(includeChild:)``) with the overlay -/// panels and open menus painted on top. Nothing is focused, raised or activated. +/// screenshot`: the window server's image of the window and its Chromium +/// page windows (``NSWindow/screenshotImage(includeChild:)``: Ghostty's +/// Metal terminals and WebKit pages included, no Screen Recording grant, +/// background and covered windows too), then Chromium's own image of each +/// shown page painted over its page area (without the grant the window +/// server leaves out what Chromium's GPU process draws), then the overlay +/// panels, open menus and the app's other windows above the content. Nothing is focused, raised or +/// activated. @MainActor struct AppWindowImage { let services: AppServices @@ -21,9 +19,7 @@ struct AppWindowImage { /// The window's image at its backing scale. /// - Throws: ``WindowScreenshotFailure`` when the window server gives no image. func capture(_ window: NSWindow) async throws -> CGImage { - let together = window.screenshotImageWithOverlays()?.image - let base: CGImage - if let together { base = together } else { base = try await window.screenshotImage(includeChild: WindowOverlayHost.isPageWindow) } + let base = try await window.screenshotImage(includeChild: WindowOverlayHost.isPageWindow) var layers: [WindowImageLayer] = [] for (page, rect) in DebugWindowSnapshot.chromiumPages(in: window, services: services) { // A page that cannot give its image keeps the window server's @@ -37,17 +33,10 @@ struct AppWindowImage { guard let image = await page.chromiumImage() else { continue } layers.append(.page(image, rect)) } - if together != nil { - // A page image covered the panels, popovers and menus over its page: they come back. - if !layers.isEmpty, let overlays = window.overlaysSnapshot(includeChild: { !WindowOverlayHost.isPageWindow($0) }) { - layers.append(.overlay(base, coverage: overlays)) - } - } else { - if let panels = window.childWindowsSnapshot(includeChild: { !WindowOverlayHost.isPageWindow($0) }) { - layers.append(.window(panels)) - } - if let floating = window.floatingWindowsSnapshot() { layers.append(.window(floating.image)) } + if let panels = window.childWindowsSnapshot(includeChild: { !WindowOverlayHost.isPageWindow($0) }) { + layers.append(.window(panels)) } + if let floating = window.floatingWindowsSnapshot() { layers.append(.window(floating.image)) } return WindowImageLayer.composite(base: base, window: window, layers: layers) ?? base } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/WindowImageLayer.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/WindowImageLayer.swift index 140ce719213f..91a54a98691e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/WindowImageLayer.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Screenshots/WindowImageLayer.swift @@ -10,10 +10,6 @@ enum WindowImageLayer { case page(CGImage, NSRect, holes: [NSRect] = []) /// A window server image of child windows, the size of the window. case window(CGImage) - /// The window's own composite (`full`, its overlays included) painted back where `coverage` - /// (its overlays alone, the size of the window) covers what was painted below: a popover or - /// menu over a page image shows as on screen, with the page under it. - case overlay(CGImage, coverage: CGImage) /// `base` (an image of `window`) with `layers` painted over it, bottom first; nil without layers. static func composite(base: CGImage, window: NSWindow, layers: [WindowImageLayer]) -> CGImage? { @@ -52,24 +48,8 @@ enum WindowImageLayer { context.restoreGState() case .window(let image): context.draw(image, in: whole) - case .overlay(let full, let coverage): - guard let covered = masked(full, by: coverage, width: width, height: height, space: space) else { continue } - context.draw(covered, in: whole) } } return context.makeImage() } - - /// `image` kept only where `mask` has alpha (premultiplied: `image` times the mask's alpha), - /// both drawn at `width` x `height` pixels. - static func masked(_ image: CGImage, by mask: CGImage, width: Int, height: Int, space: CGColorSpace) -> CGImage? { - guard let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, - space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil } - let whole = CGRect(x: 0, y: 0, width: width, height: height) - context.interpolationQuality = .high - context.draw(mask, in: whole) - context.setBlendMode(.sourceIn) - context.draw(image, in: whole) - return context.makeImage() - } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Screenshot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Screenshot.swift index e71bbc55a442..341a16fbc7e1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Screenshot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Screenshot.swift @@ -41,18 +41,6 @@ extension NSWindow { throw WindowScreenshotFailure.blank } - /// This window with every child window and the app's windows over it composited together - /// (``compositedSnapshotWithOverlays()``: glass panels and menus blur the window under them, - /// as on screen) at the backing scale, with how many of the app's other windows it includes; - /// nil when the window is not on screen or the window server gives no image (the caller then - /// takes ``screenshotImage(includeChild:)``). - public func screenshotImageWithOverlays() -> (image: CGImage, floating: Int)? { - guard windowNumber > 0, !isMiniaturized, isVisible, let snapshot = compositedSnapshotWithOverlays() else { return nil } - let scale = backingScaleFactor - let width = Int((frame.width * scale).rounded()), height = Int((frame.height * scale).rounded()) - return snapshot.image.resampled(width: width, height: height).map { ($0, snapshot.floating) } - } - /// This window alone through ScreenCaptureKit's current-process content /// (no Screen Recording consent), at the backing scale; nil when the /// window server lists no such window of this process or the image is diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Snapshot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Snapshot.swift index 1b5759abbaa8..2f2139918278 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Snapshot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Snapshot.swift @@ -128,15 +128,31 @@ extension NSWindow { return (image, floating.count) } - /// The window's overlays alone: its visible child windows `includeChild` accepts and the app's - /// windows over it, composited on a transparent image the size of this window, or nil without - /// one. Their alpha says where they cover the window: a page image painted over a - /// ``compositedSnapshotWithOverlays()`` gives those pixels back to them. - public func overlaysSnapshot(includeChild: (NSWindow) -> Bool) -> CGImage? { + /// `composite` (this window's ``compositedSnapshotWithOverlays()``) kept only where its overlays + /// cover the window, transparent elsewhere: the visible child windows `includeChild` accepts + /// and the app's windows over it. Nil without any. A page image painted over the composite + /// covers the popovers and menus over its page; this, painted over the page image, gives + /// them back, with the window's blur under them. + public func overlays(cutFrom composite: CGImage, includeChild: (NSWindow) -> Bool) -> CGImage? { let numbers = visibleChildWindows.filter(includeChild).map(\.windowNumber) + floatingWindowNumbers() - guard !numbers.isEmpty, let image = Self.windowServerImage(ofNumbers: Self.frontToBack(numbers), croppedTo: self), - image.width > 0, image.height > 0, !Self.isBlank(image) else { return nil } - return image + guard !numbers.isEmpty, let coverage = Self.windowServerImage(ofNumbers: Self.frontToBack(numbers), croppedTo: self), + coverage.width > 0, coverage.height > 0, !Self.isBlank(coverage) else { return nil } + return Self.masked(composite, by: coverage) + } + + /// `image` kept where `mask` is opaque and faded where it is translucent (premultiplied: + /// `image` times the mask's alpha), at `image`'s size. + nonisolated static func masked(_ image: CGImage, by mask: CGImage) -> CGImage? { + let width = image.width, height = image.height + guard width > 0, height > 0, let space = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, + space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil } + let whole = CGRect(x: 0, y: 0, width: width, height: height) + context.interpolationQuality = .high + context.draw(mask, in: whole) + context.setBlendMode(.sourceIn) + context.draw(image, in: whole) + return context.makeImage() } /// From `above` (the windows the window server shows above a window, topmost first), the diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WindowImageLayerOverlayTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WindowImageLayerOverlayTests.swift deleted file mode 100644 index 7dbb7387b664..000000000000 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WindowImageLayerOverlayTests.swift +++ /dev/null @@ -1,60 +0,0 @@ -import AppKit -@testable import CmuxNextApp -import Testing - -/// A window snapshot's base is the window server's composite of the window with its popovers and -/// menus, and Chromium's own page image is painted over it. Where a popover or menu covers the -/// page, its pixels come back from the composite (`WindowImageLayer.overlay`), so it shows over -/// the page as on screen instead of under the page image. -@MainActor @Suite struct WindowImageLayerOverlayTests { - private static let side = 40 - - private static func image(_ fill: (CGContext) -> Void) throws -> CGImage { - let space = try #require(CGColorSpace(name: CGColorSpace.sRGB)) - let context = try #require(CGContext(data: nil, width: side, height: side, bitsPerComponent: 8, bytesPerRow: side * 4, - space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) - fill(context) - return try #require(context.makeImage()) - } - - private static func solid(_ color: CGColor) throws -> CGImage { - try image { context in - context.setFillColor(color) - context.fill(CGRect(x: 0, y: 0, width: side, height: side)) - } - } - - /// The RGBA bytes of column `x` (the same in every row of these images). - private static func pixel(_ image: CGImage, x: Int) throws -> [UInt8] { - let space = try #require(CGColorSpace(name: CGColorSpace.sRGB)) - var bytes = [UInt8](repeating: 0, count: side * side * 4) - let drawn = bytes.withUnsafeMutableBytes { buffer -> Bool in - guard let context = CGContext(data: buffer.baseAddress, width: side, height: side, bitsPerComponent: 8, bytesPerRow: side * 4, - space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return false } - context.draw(image, in: CGRect(x: 0, y: 0, width: side, height: side)) - return true - } - #expect(drawn) - let start = (side / 2 * side + x) * 4 - return Array(bytes[start.. CGContext { + try #require(CGContext(data: bytes, width: side, height: side, bitsPerComponent: 8, bytesPerRow: side * 4, space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + } + func image(_ color: CGColor, width: Int) throws -> CGImage { + let drawing = try context() + drawing.setFillColor(color) + drawing.fill(CGRect(x: 0, y: 0, width: width, height: side)) + return try #require(drawing.makeImage()) + } + // The window server's composite: the popover (blue) over the window. The popover alone covers the left half. + let composite = try image(CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 1), width: side) + let coverage = try image(CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1), width: side / 2) + let cut = try #require(NSWindow.masked(composite, by: coverage)) + #expect(cut.width == side && cut.height == side) + // Chromium's page image (green) fills the window, then the cut goes on top. + let page = try image(CGColor(srgbRed: 0, green: 1, blue: 0, alpha: 1), width: side) + var bytes = [UInt8](repeating: 0, count: side * side * 4) + try bytes.withUnsafeMutableBytes { buffer in + let result = try context(buffer.baseAddress) + let whole = CGRect(x: 0, y: 0, width: side, height: side) + result.draw(page, in: whole) + result.draw(cut, in: whole) + } + func pixel(_ x: Int) -> [UInt8] { Array(bytes[(side / 2 * side + x) * 4..<(side / 2 * side + x) * 4 + 4]) } + #expect(pixel(5) == [0, 0, 255, 255]) + #expect(pixel(side - 5) == [0, 255, 0, 255]) + } + @Test func theRegionIsInGlobalDisplayCoordinates() { // The primary screen is 1000 points tall; a window 100 points above its bottom edge. let frame = NSRect(x: 50, y: 100, width: 400, height: 300) From 705ea1b2d403efac25961e94d8878fa198e11cc9 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 25 Oct 2026 18:48:14 -0700 Subject: [PATCH 6/6] debug.window_snapshot: a translucent overlay is not faded twice; cut overlays before the page awaits Where the overlays alone are translucent (a shadow, a fade, an edge), the cut now takes their own pixels instead of the composite times their alpha, which painted over a page image faded them twice. The overlays are cut right after the composite, before the page images' awaits, so a popover that opens meanwhile is not cut from a composite it is not in. The deadline test waits for the request to start before it blocks the main actor. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a1603a-6462-72f0-a4b0-6937eb58e4c3&host=cosmic-gray --- .../Control/DebugWindowSnapshot.swift | 6 ++- .../Windows/NSWindow+Snapshot.swift | 51 +++++++++++++------ .../DebugWindowSnapshotDeadlineTests.swift | 12 ++++- .../WindowSnapshotOverlaysTests.swift | 31 +++++++++++ 4 files changed, 83 insertions(+), 17 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift index 58ba4746e752..7261c4037356 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugWindowSnapshot.swift @@ -126,6 +126,10 @@ enum DebugWindowSnapshot { let together = window.compositedSnapshotWithOverlays() let base = try together.map { (image: $0.image, method: WindowSnapshotMethod.composited) } ?? (webViews.isEmpty ? appKitBaseImage(for: window) : nativeBaseImage(for: window, hiding: webViews)) + // The overlays cut from the composite now, before the page images' awaits: a popover + // that opened meanwhile is not in the composite, and its cut would be the window under it. + let overlays = base.method == .composited + ? window.overlays(cutFrom: base.image, includeChild: { !WindowOverlayHost.isPageWindow($0) }) : nil var layers: [WindowImageLayer] = [] // The child windows each layer brings, so the result counts only // the ones the image includes (none from a missing layer). @@ -173,7 +177,7 @@ enum DebugWindowSnapshot { if base.method == .composited { // A Chromium page image covered the panels, popovers and menus over its page: // their pixels come back from the window server's composite, the page under them. - if !layers.isEmpty, let overlays = window.overlays(cutFrom: base.image, includeChild: { !WindowOverlayHost.isPageWindow($0) }) { + if !layers.isEmpty, let overlays { layers.append(.window(overlays)) } } else { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Snapshot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Snapshot.swift index e5ae06d1f739..0cc2997284eb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Snapshot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/NSWindow+Snapshot.swift @@ -204,9 +204,9 @@ extension NSWindow { return (image, floating.count) } - /// `composite` (this window's ``compositedSnapshotWithOverlays()``) kept only where its overlays - /// cover the window, transparent elsewhere: the visible child windows `includeChild` accepts - /// and the app's windows over it. Nil without any. A page image painted over the composite + /// The overlays of `composite` (this window's ``compositedSnapshotWithOverlays()``): the + /// visible child windows `includeChild` accepts and the app's windows over it, transparent + /// elsewhere (``masked(_:by:)``). Nil without any. A page image painted over the composite /// covers the popovers and menus over its page; this, painted over the page image, gives /// them back, with the window's blur under them. public func overlays(cutFrom composite: CGImage, includeChild: (NSWindow) -> Bool) -> CGImage? { @@ -218,19 +218,40 @@ extension NSWindow { return Self.masked(composite, by: coverage) } - /// `image` kept where `mask` is opaque and faded where it is translucent (premultiplied: - /// `image` times the mask's alpha), at `image`'s size. - nonisolated static func masked(_ image: CGImage, by mask: CGImage) -> CGImage? { + /// At `image`'s size: `image` (the window's composite) where `coverage` (the overlays alone) is + /// opaque, so glass there keeps the blur of the window under it; `coverage` itself where it is + /// translucent (a shadow, a fade, an antialiased edge), since the composite there already + /// blends the overlay with the window, and painted over a page it would fade the overlay twice; + /// transparent elsewhere. + nonisolated static func masked(_ image: CGImage, by coverage: CGImage) -> CGImage? { let width = image.width, height = image.height - guard width > 0, height > 0, let space = CGColorSpace(name: CGColorSpace.sRGB), - let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, - space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil } - let whole = CGRect(x: 0, y: 0, width: width, height: height) - context.interpolationQuality = .high - context.draw(mask, in: whole) - context.setBlendMode(.sourceIn) - context.draw(image, in: whole) - return context.makeImage() + guard width > 0, height > 0, let space = CGColorSpace(name: CGColorSpace.sRGB) else { return nil } + let info = CGImageAlphaInfo.premultipliedLast.rawValue + func pixels(_ source: CGImage) -> [UInt8]? { + var bytes = [UInt8](repeating: 0, count: width * height * 4) + let drawn = bytes.withUnsafeMutableBytes { buffer -> Bool in + guard let context = CGContext(data: buffer.baseAddress, width: width, height: height, bitsPerComponent: 8, + bytesPerRow: width * 4, space: space, bitmapInfo: info) else { return false } + context.interpolationQuality = .high + context.draw(source, in: CGRect(x: 0, y: 0, width: width, height: height)) + return true + } + return drawn ? bytes : nil + } + guard var result = pixels(coverage), let composite = pixels(image) else { return nil } + result.withUnsafeMutableBufferPointer { out in + composite.withUnsafeBufferPointer { under in + for index in stride(from: 0, to: out.count, by: 4) where out[index + 3] == 255 { + out[index] = under[index] + out[index + 1] = under[index + 1] + out[index + 2] = under[index + 2] + } + } + } + guard let provider = CGDataProvider(data: Data(result) as CFData) else { return nil } + return CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: width * 4, space: space, + bitmapInfo: CGBitmapInfo(rawValue: info), provider: provider, decode: nil, shouldInterpolate: false, + intent: .defaultIntent) } /// From `above` (the windows the window server shows above a window, topmost first), the diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugWindowSnapshotDeadlineTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugWindowSnapshotDeadlineTests.swift index 8e483187145f..3c02d3e23879 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugWindowSnapshotDeadlineTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugWindowSnapshotDeadlineTests.swift @@ -20,7 +20,12 @@ import Testing configuration: ControlRouter.Configuration(requestDeadline: .milliseconds(50))) router.register([DebugWindowSnapshot.controlMethod(services: services)]) let request = ControlRequest(id: "1", method: "debug.window_snapshot", params: ["window": .string("999999999")]) - let reply = Task.detached { await router.handle(request) } + let entered = DispatchSemaphore(value: 0) + let reply = Task.detached { + entered.signal() + return await router.handle(request) + } + Self.block(on: entered) // the request's clock runs before the main actor goes busy // The main actor stays busy past the control-plane deadline, as on a loaded capture mini. let busyUntil = ContinuousClock.now + .milliseconds(400) while ContinuousClock.now < busyUntil {} @@ -28,4 +33,9 @@ import Testing let value = try await reply.value.get() #expect(value["error"]?.stringValue == "no such window") } + + /// A synchronous wait, which async code may not call directly. + private nonisolated static func block(on semaphore: DispatchSemaphore) { + semaphore.wait() + } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/WindowSnapshotOverlaysTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/WindowSnapshotOverlaysTests.swift index 220a5bd63f0b..df81c66df380 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/WindowSnapshotOverlaysTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/WindowSnapshotOverlaysTests.swift @@ -113,6 +113,37 @@ import Testing #expect(pixel(side - 5) == [0, 255, 0, 255]) } + /// A translucent overlay (half-transparent blue) over the window (red) shows over a page image + /// (green) as it would on screen, half blue and half green, not faded twice. + @Test func aTranslucentOverlayIsNotFadedTwice() throws { + let side = 8 + let space = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + func context(_ bytes: UnsafeMutableRawPointer? = nil) throws -> CGContext { + try #require(CGContext(data: bytes, width: side, height: side, bitsPerComponent: 8, bytesPerRow: side * 4, space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + } + func image(_ colors: [CGColor]) throws -> CGImage { + let drawing = try context() + for color in colors { + drawing.setFillColor(color) + drawing.fill(CGRect(x: 0, y: 0, width: side, height: side)) + } + return try #require(drawing.makeImage()) + } + let blue = CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 0.5) + let composite = try image([CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1), blue]) // the window server's composite + let cut = try #require(NSWindow.masked(composite, by: try image([blue]))) + var bytes = [UInt8](repeating: 0, count: side * side * 4) + try bytes.withUnsafeMutableBytes { buffer in + let result = try context(buffer.baseAddress) + let whole = CGRect(x: 0, y: 0, width: side, height: side) + result.draw(try image([CGColor(srgbRed: 0, green: 1, blue: 0, alpha: 1)]), in: whole) + result.draw(cut, in: whole) + } + let pixel = bytes[0..<4].map(Int.init) + #expect(pixel[0] <= 1 && abs(pixel[1] - 128) <= 2 && abs(pixel[2] - 128) <= 2 && pixel[3] == 255, "\(pixel)") + } + @Test func theRegionIsInGlobalDisplayCoordinates() { // The primary screen is 1000 points tall; a window 100 points above its bottom edge. let frame = NSRect(x: 50, y: 100, width: 400, height: 300)