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 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) }