From 0b61122dd4604a054a2dd97f2d6435c0849f0b9c Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 10 Jul 2026 15:45:11 -0300 Subject: [PATCH 1/2] refactor: use NSGlassEffectView directly behind availability guards Replace NSClassFromString lookups and the private setTintColor: selector with compile-time NSGlassEffectView usage under #if compiler(>=6.2) + @available(macOS 26.0, *), matching the pattern the ghostty submodule already uses. This also fixes window-level Liquid Glass never activating: the old code cast NSGlassEffectView to NSVisualEffectView.Type, which fails at runtime because NSGlassEffectView subclasses NSView, so the fallback path always ran even on macOS 26. --- Sources/BrowserWindowPortal.swift | 2 +- Sources/ContentView.swift | 160 ++++++++++++++++------------- Sources/SidebarVisuals.swift | 35 +++---- Sources/TerminalWindowPortal.swift | 2 +- 4 files changed, 103 insertions(+), 96 deletions(-) diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index e102d98fa0e..ab71f338fa5 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -2327,7 +2327,7 @@ final class WindowBrowserPortal: HostedViewPortalRegistry { private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? { guard let contentView = window.contentView else { return nil } - if contentView.className == "NSGlassEffectView", + if WindowGlassEffect.isGlassEffectView(contentView), let foreground = contentView.subviews.first(where: { $0 !== hostView }) { return (contentView, foreground) } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 169d1a85eb3..0d5d26e4b94 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -172,14 +172,30 @@ struct ShortcutHintPillBackground: View { } } -/// Applies NSGlassEffectView (macOS 26+) to a window, falling back to NSVisualEffectView +/// Applies NSGlassEffectView (macOS 26+) to a window, falling back to NSVisualEffectView. +/// The glass path requires both a macOS 26 SDK at build time and macOS 26 at runtime. enum WindowGlassEffect { private static var glassViewKey: UInt8 = 0 private static var originalContentViewKey: UInt8 = 0 private static var tintOverlayKey: UInt8 = 0 static var isAvailable: Bool { - NSClassFromString("NSGlassEffectView") != nil + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + return true + } + #endif + return false + } + + /// True when `view` is the native macOS 26 glass view. + static func isGlassEffectView(_ view: NSView) -> Bool { + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + return view is NSGlassEffectView + } + #endif + return false } static func apply(to window: NSWindow, tintColor: NSColor? = nil) { @@ -192,73 +208,72 @@ enum WindowGlassEffect { return } - let bounds = originalContentView.bounds - - // Create the glass/blur view - let glassView: NSVisualEffectView - let usingGlassEffectView: Bool - - // Try NSGlassEffectView first (macOS 26 Tahoe+) - if let glassClass = NSClassFromString("NSGlassEffectView") as? NSVisualEffectView.Type { - usingGlassEffectView = true - glassView = glassClass.init(frame: bounds) - glassView.wantsLayer = true - glassView.layer?.cornerRadius = 0 - - // Apply tint color via private API - if let color = tintColor { - let selector = NSSelectorFromString("setTintColor:") - if glassView.responds(to: selector) { - glassView.perform(selector, with: color) - } - } - } else { - usingGlassEffectView = false - // Fallback to NSVisualEffectView - glassView = NSVisualEffectView(frame: bounds) - glassView.blendingMode = .behindWindow - // Favor a lighter fallback so behind-window glass reads more transparent. - glassView.material = .underWindowBackground - glassView.state = .active - glassView.wantsLayer = true + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + applyGlass(to: window, originalContentView: originalContentView, tintColor: tintColor) + return } + #endif + applyVisualEffectFallback(to: window, originalContentView: originalContentView, tintColor: tintColor) + } + #if compiler(>=6.2) + @available(macOS 26.0, *) + private static func applyGlass(to window: NSWindow, originalContentView: NSView, tintColor: NSColor?) { + let glassView = NSGlassEffectView(frame: originalContentView.bounds) + glassView.wantsLayer = true + glassView.cornerRadius = 0 + glassView.tintColor = tintColor glassView.autoresizingMask = [.width, .height] - if usingGlassEffectView { - // NSGlassEffectView is a full replacement for the contentView. - objc_setAssociatedObject(window, &originalContentViewKey, originalContentView, .OBJC_ASSOCIATION_RETAIN) - window.contentView = glassView + // NSGlassEffectView is a full replacement for the contentView. + objc_setAssociatedObject(window, &originalContentViewKey, originalContentView, .OBJC_ASSOCIATION_RETAIN) + window.contentView = glassView - // Re-add the original SwiftUI hosting view on top of the glass, filling entire area. - originalContentView.translatesAutoresizingMaskIntoConstraints = false - originalContentView.wantsLayer = true - originalContentView.layer?.backgroundColor = NSColor.clear.cgColor - glassView.addSubview(originalContentView) + // Re-add the original SwiftUI hosting view on top of the glass, filling entire area. + // Kept as a manual subview (not NSGlassEffectView.contentView) so the window portal + // installation code can rely on the subview hierarchy. + originalContentView.translatesAutoresizingMaskIntoConstraints = false + originalContentView.wantsLayer = true + originalContentView.layer?.backgroundColor = NSColor.clear.cgColor + glassView.addSubview(originalContentView) - NSLayoutConstraint.activate([ - originalContentView.topAnchor.constraint(equalTo: glassView.topAnchor), - originalContentView.bottomAnchor.constraint(equalTo: glassView.bottomAnchor), - originalContentView.leadingAnchor.constraint(equalTo: glassView.leadingAnchor), - originalContentView.trailingAnchor.constraint(equalTo: glassView.trailingAnchor) - ]) - } else { - // For NSVisualEffectView fallback (macOS 13-15), do NOT replace window.contentView. - // Replacing contentView can break traffic light rendering with - // `.fullSizeContentView` + `titlebarAppearsTransparent`. - glassView.translatesAutoresizingMaskIntoConstraints = false - originalContentView.addSubview(glassView, positioned: .below, relativeTo: nil) + NSLayoutConstraint.activate([ + originalContentView.topAnchor.constraint(equalTo: glassView.topAnchor), + originalContentView.bottomAnchor.constraint(equalTo: glassView.bottomAnchor), + originalContentView.leadingAnchor.constraint(equalTo: glassView.leadingAnchor), + originalContentView.trailingAnchor.constraint(equalTo: glassView.trailingAnchor) + ]) - NSLayoutConstraint.activate([ - glassView.topAnchor.constraint(equalTo: originalContentView.topAnchor), - glassView.bottomAnchor.constraint(equalTo: originalContentView.bottomAnchor), - glassView.leadingAnchor.constraint(equalTo: originalContentView.leadingAnchor), - glassView.trailingAnchor.constraint(equalTo: originalContentView.trailingAnchor) - ]) - } + objc_setAssociatedObject(window, &glassViewKey, glassView, .OBJC_ASSOCIATION_RETAIN) + } + #endif + + private static func applyVisualEffectFallback(to window: NSWindow, originalContentView: NSView, tintColor: NSColor?) { + let bounds = originalContentView.bounds + let glassView = NSVisualEffectView(frame: bounds) + glassView.blendingMode = .behindWindow + // Favor a lighter fallback so behind-window glass reads more transparent. + glassView.material = .underWindowBackground + glassView.state = .active + glassView.wantsLayer = true + glassView.autoresizingMask = [.width, .height] + + // For the NSVisualEffectView fallback, do NOT replace window.contentView. + // Replacing contentView can break traffic light rendering with + // `.fullSizeContentView` + `titlebarAppearsTransparent`. + glassView.translatesAutoresizingMaskIntoConstraints = false + originalContentView.addSubview(glassView, positioned: .below, relativeTo: nil) + + NSLayoutConstraint.activate([ + glassView.topAnchor.constraint(equalTo: originalContentView.topAnchor), + glassView.bottomAnchor.constraint(equalTo: originalContentView.bottomAnchor), + glassView.leadingAnchor.constraint(equalTo: originalContentView.leadingAnchor), + glassView.trailingAnchor.constraint(equalTo: originalContentView.trailingAnchor) + ]) - // Add tint overlay between glass and content (for fallback) - if let tintColor, !usingGlassEffectView { + // Add tint overlay between glass and content + if let tintColor { let tintOverlay = NSView(frame: bounds) tintOverlay.translatesAutoresizingMaskIntoConstraints = false tintOverlay.wantsLayer = true @@ -273,7 +288,6 @@ enum WindowGlassEffect { objc_setAssociatedObject(window, &tintOverlayKey, tintOverlay, .OBJC_ASSOCIATION_RETAIN) } - // Store reference objc_setAssociatedObject(window, &glassViewKey, glassView, .OBJC_ASSOCIATION_RETAIN) } @@ -284,17 +298,15 @@ enum WindowGlassEffect { } private static func updateTint(on glassView: NSView, color: NSColor?, window: NSWindow) { - // For NSGlassEffectView, use setTintColor: - if glassView.className == "NSGlassEffectView" { - let selector = NSSelectorFromString("setTintColor:") - if glassView.responds(to: selector) { - glassView.perform(selector, with: color) - } - } else { - // For NSVisualEffectView fallback, update the tint overlay - if let tintOverlay = objc_getAssociatedObject(window, &tintOverlayKey) as? NSView { - tintOverlay.layer?.backgroundColor = color?.cgColor - } + #if compiler(>=6.2) + if #available(macOS 26.0, *), let glass = glassView as? NSGlassEffectView { + glass.tintColor = color + return + } + #endif + // For NSVisualEffectView fallback, update the tint overlay + if let tintOverlay = objc_getAssociatedObject(window, &tintOverlayKey) as? NSView { + tintOverlay.layer?.backgroundColor = color?.cgColor } } @@ -303,7 +315,7 @@ enum WindowGlassEffect { return } - if glassView.className == "NSGlassEffectView" { + if isGlassEffectView(glassView) { if let originalContentView = objc_getAssociatedObject(window, &originalContentViewKey) as? NSView { originalContentView.removeFromSuperview() originalContentView.translatesAutoresizingMaskIntoConstraints = true diff --git a/Sources/SidebarVisuals.swift b/Sources/SidebarVisuals.swift index 877b6a9187e..014fe793e07 100644 --- a/Sources/SidebarVisuals.swift +++ b/Sources/SidebarVisuals.swift @@ -1107,17 +1107,18 @@ private struct SidebarVisualEffectBackground: NSViewRepresentable { } static var liquidGlassAvailable: Bool { - NSClassFromString("NSGlassEffectView") != nil + WindowGlassEffect.isAvailable } func makeNSView(context: Context) -> NSView { - // Try NSGlassEffectView if preferred or if we want to test availability - if preferLiquidGlass, let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type { - let glass = glassClass.init(frame: .zero) + #if compiler(>=6.2) + if preferLiquidGlass, #available(macOS 26.0, *) { + let glass = NSGlassEffectView(frame: .zero) glass.autoresizingMask = [.width, .height] glass.wantsLayer = true return glass } + #endif // Use NSVisualEffectView let view = NSVisualEffectView() @@ -1128,22 +1129,16 @@ private struct SidebarVisualEffectBackground: NSViewRepresentable { } func updateNSView(_ nsView: NSView, context: Context) { - // Configure based on view type - if nsView.className == "NSGlassEffectView" { - // NSGlassEffectView configuration via private API - nsView.alphaValue = max(0.0, min(1.0, opacity)) - nsView.layer?.cornerRadius = cornerRadius - nsView.layer?.masksToBounds = cornerRadius > 0 - - // Try to set tint color via private selector - if let color = tintColor { - let selector = NSSelectorFromString("setTintColor:") - if nsView.responds(to: selector) { - nsView.perform(selector, with: color) - } - } - } else if let visualEffect = nsView as? NSVisualEffectView { - // NSVisualEffectView configuration + #if compiler(>=6.2) + if #available(macOS 26.0, *), let glass = nsView as? NSGlassEffectView { + glass.alphaValue = max(0.0, min(1.0, opacity)) + glass.cornerRadius = cornerRadius + glass.tintColor = tintColor + return + } + #endif + + if let visualEffect = nsView as? NSVisualEffectView { visualEffect.material = material visualEffect.blendingMode = blendingMode visualEffect.state = state diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index ba40e6b7115..95bd47061c7 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -873,7 +873,7 @@ final class WindowTerminalPortal: HostedViewPortalRegistry { // If NSGlassEffectView wraps the original content view, install inside the glass view // so terminals are above the glass background but below SwiftUI content. - if contentView.className == "NSGlassEffectView", + if WindowGlassEffect.isGlassEffectView(contentView), let foreground = contentView.subviews.first(where: { $0 !== hostView }) { return (contentView, foreground) } From 97ac351e6e3d5740b530e6d10f2459d84267b514 Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 10 Jul 2026 15:45:11 -0300 Subject: [PATCH 2/2] ci: prefer Xcode 26 on macOS runners CI and release currently pick the image-default Xcode 16.4 (macOS 15 SDK), which compiles the new glass path out of shipped builds. Prefer the newest Xcode 26 install when present, falling back to the image default, so release builds link against the macOS 26 SDK and Liquid Glass reaches users. --- .github/workflows/ci.yml | 63 ++++++++++++++++++++--------------- .github/workflows/release.yml | 23 +++++++------ 2 files changed, 49 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2dc43cbcd88..243f42326c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,17 +180,20 @@ jobs: - name: Select Xcode run: | set -euo pipefail - if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then - XCODE_DIR="/Applications/Xcode.app/Contents/Developer" - else + # Prefer Xcode 26 (macOS 26 SDK) so NSGlassEffectView / Liquid Glass compiles; + # fall back to the image default when no Xcode 26 install exists. + XCODE_APP="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -n 1 || true)" + if [ -z "$XCODE_APP" ] && [ -d "/Applications/Xcode.app" ]; then + XCODE_APP="/Applications/Xcode.app" + fi + if [ -z "$XCODE_APP" ]; then XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | sort | tail -n 1 || true)" - if [ -n "$XCODE_APP" ]; then - XCODE_DIR="$XCODE_APP/Contents/Developer" - else - echo "No Xcode.app found under /Applications" >&2 - exit 1 - fi fi + if [ -z "$XCODE_APP" ]; then + echo "No Xcode.app found under /Applications" >&2 + exit 1 + fi + XCODE_DIR="$XCODE_APP/Contents/Developer" echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" export DEVELOPER_DIR="$XCODE_DIR" xcodebuild -version @@ -346,17 +349,20 @@ jobs: - name: Select Xcode run: | set -euo pipefail - if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then - XCODE_DIR="/Applications/Xcode.app/Contents/Developer" - else + # Prefer Xcode 26 (macOS 26 SDK) so NSGlassEffectView / Liquid Glass compiles; + # fall back to the image default when no Xcode 26 install exists. + XCODE_APP="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -n 1 || true)" + if [ -z "$XCODE_APP" ] && [ -d "/Applications/Xcode.app" ]; then + XCODE_APP="/Applications/Xcode.app" + fi + if [ -z "$XCODE_APP" ]; then XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | sort | tail -n 1 || true)" - if [ -n "$XCODE_APP" ]; then - XCODE_DIR="$XCODE_APP/Contents/Developer" - else - echo "No Xcode.app found under /Applications" >&2 - exit 1 - fi fi + if [ -z "$XCODE_APP" ]; then + echo "No Xcode.app found under /Applications" >&2 + exit 1 + fi + XCODE_DIR="$XCODE_APP/Contents/Developer" echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" export DEVELOPER_DIR="$XCODE_DIR" xcodebuild -version @@ -519,17 +525,20 @@ jobs: - name: Select Xcode run: | set -euo pipefail - if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then - XCODE_DIR="/Applications/Xcode.app/Contents/Developer" - else + # Prefer Xcode 26 (macOS 26 SDK) so NSGlassEffectView / Liquid Glass compiles; + # fall back to the image default when no Xcode 26 install exists. + XCODE_APP="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -n 1 || true)" + if [ -z "$XCODE_APP" ] && [ -d "/Applications/Xcode.app" ]; then + XCODE_APP="/Applications/Xcode.app" + fi + if [ -z "$XCODE_APP" ]; then XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | sort | tail -n 1 || true)" - if [ -n "$XCODE_APP" ]; then - XCODE_DIR="$XCODE_APP/Contents/Developer" - else - echo "No Xcode.app found under /Applications" >&2 - exit 1 - fi fi + if [ -z "$XCODE_APP" ]; then + echo "No Xcode.app found under /Applications" >&2 + exit 1 + fi + XCODE_DIR="$XCODE_APP/Contents/Developer" echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" export DEVELOPER_DIR="$XCODE_DIR" xcodebuild -version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 755162c81a6..fce8116d113 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -168,17 +168,20 @@ jobs: if: steps.guard_release_assets.outputs.skip_all != 'true' run: | set -euo pipefail - if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then - XCODE_DIR="/Applications/Xcode.app/Contents/Developer" - else - XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -n 1 || true)" - if [ -n "$XCODE_APP" ]; then - XCODE_DIR="$XCODE_APP/Contents/Developer" - else - echo "No Xcode.app found under /Applications" >&2 - exit 1 - fi + # Prefer Xcode 26 (macOS 26 SDK) so NSGlassEffectView / Liquid Glass compiles; + # fall back to the image default when no Xcode 26 install exists. + XCODE_APP="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -n 1 || true)" + if [ -z "$XCODE_APP" ] && [ -d "/Applications/Xcode.app" ]; then + XCODE_APP="/Applications/Xcode.app" + fi + if [ -z "$XCODE_APP" ]; then + XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | sort | tail -n 1 || true)" + fi + if [ -z "$XCODE_APP" ]; then + echo "No Xcode.app found under /Applications" >&2 + exit 1 fi + XCODE_DIR="$XCODE_APP/Contents/Developer" echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" export DEVELOPER_DIR="$XCODE_DIR" xcodebuild -version