Я хотел сделать маленькую OSD-панель яркости на macOS так, чтобы она выглядела как системный HUD: не просто полупрозрачная плашка поверх обоев, а нормальное стекло, через которое видно и немного преломляется рабочий стол. По дороге выяснилось неприятное: SwiftUI .glassEffect для такой задачи не подходит вообще, публичный NSGlassEffectView даёт только половину результата, а вид, близкий к системному HUD, появляется уже после ковыряния приватного CAFilter glassBackground. В итоге App-Store-safe способа получить именно такой эффект я не нашёл, скорее всего его и нет. Ниже — весь путь, с кодом, ошибками и местами, где я сам сначала чинил не то.
Финальный вариант держится на приватных штуках Core Animation: CABackdropLayer, CAFilter, селектор set_variant:, имена входов фильтра. Публичной документации на CAFilter и CABackdropLayer нет. Я разбирал их через дамп дерева слоёв на живой macOS 26, то есть ровно тем способом, после которого надо самому отвечать за хрупкость результата. В Mac App Store такое почти наверняка завернут на ревью, тем не менее, пара моих приложений успешно ревью проходили, да и Telegram держится на том же самом (его исходники есть на github).
Я пишу Lumen — menubar-контрол яркости для macOS, вдохновился я проектом Lunar, только он требует оплатить, а обходить каждые 2 недели это мне надоело, тем более что в Lunar есть некоторые моменты, которые меня не устраивают. Кроме собственно управления подсветкой там есть transient OSD: меняешь яркость, в правом верхнем углу всплывает карточка с именем дисплея и слайдером, через пару секунд исчезает. По поведению — как системный HUD громкости или яркости. И мне хотелось, чтобы визуально он тоже был таким же: рабочий стол под ним виден, фон читается, стекло не превращается в серую плитку.
Проверял всё на macOS 27 beta 1/2. У приватных ключей нет контракта на стабильность, так что в следующих бетах или релизах имена могут уехать, но на моей памяти такое случалось только с клавиатурой в iOS.

Первая попытка: SwiftUI .glassEffect
Начал я, конечно, с самого очевидного. В macOS 26 есть SwiftUI-стекло, значит делаем модификатор и используем его как фон карточки. Для систем младше 26 – обычный fallback на .ultraThinMaterial:
// GlassBackground.swift:5-36 struct LiquidGlass: ViewModifier { var cornerRadius: CGFloat = 22 var tint: Color? = nil func body(content: Content) -> some View { if #available(macOS 26.0, *) { content .glassEffect( glass(), in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) ) } else { content .background( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill(.ultraThinMaterial) .overlay( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .strokeBorder(.white.opacity(0.12), lineWidth: 1) ) ) } } @available(macOS 26.0, *) private func glass() -> Glass { if let tint { return .regular.tint(tint) } return .regular } }
Внутри обычного окна, где за стеклом лежит другой SwiftUI-контент, это выглядит вполне прилично. Собственно, примерно так его и показывают: стекло как слой внутри уже существующего интерфейса. Да, не совсем то, что хотелось бы, но если добавить в glass .clear, то похоже. Не совсем то, но терпимо.
А вот когда я вынес карточку в отдельный borderless-прозрачный NSPanel, всё развалилось. Вместо HUD — плоская тёмная плашка. Никакого преломления рабочего стола, никакого ощущения системного стекла. Просто полупрозрачный прямоугольник поверх обоев.
Я начал крутить стили: .regular, .clear, разные варианты tint. Визуально почти ноль реакции. А одна попытка «заставить» попап рисовать стеклянный фон дала вообще чёрную подложку. Вот тут стало понятно, что проблема не в радиусе и не в выбранном стиле.
Что именно сэмплит SwiftUI-стекло
Сначала я думал, что где-то недокрутил параметры материала. Но если переключение .regular на .clear не меняет вообще ничего, значит стеклянному эффекту просто нечего обрабатывать.
SwiftUI .glassEffect сэмплит содержимое своего окна. Не рабочего стола, не соседних окон, не того, что реально находится под прозрачной панелью, а именно того, что уже есть внутри того же window backing store. Когда стекло лежит поверх другого контента в обычном окне — отлично, у него есть «картинка под ним». В моём случае окно прозрачное и borderless, а под ним десктоп и чужие окна. Для SwiftUI-эффекта это не входные данные, он их не видит.
Получается такая схема:
SwiftUI .glassEffect NSGlassEffectView +-------------------+ +-------------------+ | окно панели | | окно панели | | +-------------+ | | +-------------+ | | | стекло | | | | стекло | | | +------+------+ | | +------+------+ | | | сэмплит | | | сэмплит | | v | +---------+---------+ | то же окно | v +-------------------+ десктоп за окном (нечего: окно (обои, чужие окна) прозрачное)
Это не баг моего кода, просто граница применимости. .glassEffect — хороший декор для внутриоконного контента, не более. Для floating HUD, оверлея или панели, которая должна честно просвечивать рабочий стол, он не подходит.
AppKit NSGlassEffectView: уже ближе
После этого я спустился в AppKit. В macOS 26 появился NSGlassEffectView, и это уже правильный класс задачи: он умеет работать с backdrop за окном.
Идея простая: не SwiftUI-вью со стеклянным модификатором, а наоборот — AppKit-стекло как подложка панели, а SwiftUI-контент поверх него. То есть NSGlassEffectView становится contentView панели, а карточка с текстом и слайдером кладётся внутрь.
// OSDController.swift:126-144 private func makeGlassView() -> NSView { if #available(macOS 26.0, *) { let glass = NSGlassEffectView() glass.cornerRadius = cornerRadius glass.style = .clear return glass } let effect = NSVisualEffectView() effect.material = .hudWindow effect.blendingMode = .behindWindow effect.state = .active effect.wantsLayer = true effect.layer?.cornerRadius = cornerRadius effect.layer?.masksToBounds = true return effect }
Тут есть неочевидная деталь, которую легко пропустить при переносе: у NSGlassEffectView контент надо класть в contentView. Не через обычный addSubview. А вот у fallback-ветки с NSVisualEffectView — наоборот, обычный addSubview.
// OSDController.swift:110-124 private func build(card: BrightnessCard, on panel: NSPanel) { let hosting = NSHostingView(rootView: card) hosting.frame = NSRect(origin: .zero, size: hosting.fittingSize) let glass = makeGlassView() glass.frame = hosting.frame if #available(macOS 26.0, *), let glass = glass as? NSGlassEffectView { glass.contentView = hosting } else { hosting.autoresizingMask = [.width, .height] glass.addSubview(hosting) } panel.contentView = glass glassView = glass hostingView = hosting }
Порядок тут тоже не случайный. Сначала создаю NSHostingView, сразу меряю его через fittingSize, потом создаю стеклянную подложку нужного размера, потом вставляю hosting внутрь стекла, и только в конце отдаю всё это панели. То есть в окно попадает уже собранная конструкция. Ниже покажу, почему это важно: с NSGlassEffectView можно словить очень странный AttributeGraph-краш, если дать ему неявно анимировать layout в момент, когда SwiftUI ещё только раскладывается.
Сама карточка остаётся обычной SwiftUI-вьюхой: белый текст, тёмные полупрозрачные подложки в местах, где нужна читаемость, и .colorScheme(.dark).
// BrightnessCard.swift:11-36 var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text(display.title) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.white) Spacer() // ...кнопка раскрытия с .background(Circle().fill(.white.opacity(0.12))) } SystemBrightnessSlider(display: display, setXDR: setXDR, showNits: display.showsNits) } .padding(.horizontal, 20) .padding(.vertical, 16) .frame(width: 400) .colorScheme(.dark) }
Панель должна быть прозрачной
Чтобы NSGlassEffectView вообще увидел backdrop, само окно не должно перекрывать его непрозрачной заливкой. Панель у меня такая:
// OSDController.swift:161-178 private func makePanel() -> NSPanel { let panel = NSPanel( contentRect: NSRect(x: 0, y: 0, width: 360, height: 120), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) panel.isFloatingPanel = true panel.level = .statusBar panel.backgroundColor = .clear panel.isOpaque = false panel.hasShadow = true panel.hidesOnDeactivate = false panel.ignoresMouseEvents = false panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary] panel.isReleasedWhenClosed = false return panel }
Критичные строки — backgroundColor = .clear и isOpaque = false. Без них окно само становится подложкой, и сэмплить десктоп уже бессмысленно: стекло смотрит не на рабочий стол, а на непрозрачную поверхность окна.
Остальные флаги — уже поведение HUD. .nonactivatingPanel, чтобы не красть фокус. level = .statusBar, чтобы висеть поверх обычных окон. collectionBehavior, чтобы нормально жить на разных Spaces и в fullscreen. Именно в такой конфигурации SwiftUI-стекло в начале и упёрлось в ограничение: с точки зрения .glassEffect под ним было пусто.
AttributeGraph-краш: неприятная мелочь, которая может отнять целый вечер
Сначала всё вроде работало. Потом приложение начало падать то при повторном показе панели, то при resize. В консоли — AttributeGraph precondition failure и SIGABRT. Приятного мало: это не тот крэш, где сразу понимаешь, что обратился к свойству, которое сейчас nil. Похоже, причина в том, что NSGlassEffectView неявно анимирует каким-то образом layout. Если в этот момент внутри него впервые появляетсяNSHostingView, SwiftUI может уронить свою транзакцию. Получается неочевидная связка: AppKit glass анимирует изменение layout, а SwiftUI hosting в эту же секунду пытается построить дерево.
Фикс у меня получился из двух частей.
Первая — собирать glass + hosting полностью до вставки в окно. Это тот порядок из build(card:on:): сначала fittingSize, потом стекло под готовый размер, потом contentView, потом panel.contentView.
Вторая — всё, что трогает layout во время показа, оборачивать в NSAnimationContext нулевой длительности (аналогичные трюки иногда встречал в чужом коде с UIView.animate(withDuration: 0.0, ...)) и запрещать implicit animations:
// OSDController.swift:146-152 private func withoutImplicitAnimation(_ body: () -> Void) { NSAnimationContext.runAnimationGroup { ctx in ctx.duration = 0 ctx.allowsImplicitAnimation = false body() } }
В show внутри этой обёртки обновляется rootView, позиционируется панель и вызывается layout. Появление при этом без fade: alphaValue = 1 сразу. Это не так красиво, как мне хотелось бы, но зато без падений, что важнее.
И вот тут публичный API заканчивается
После перехода на NSGlassEffectView стало сильно лучше: фон за панелью реально появился, эффект стекла применяется, это уже не тёмная SwiftUI-палшка, но до системного HUD всё равно не дотягивает.
Стоковый .clear даёт молочный фон, вместо читаемой картинки под пилюлей — размазанная каша. Системный HUD громкости или яркости при этом выглядит иначе: фон под ним почти резкий, обои читаются, размытие не съедает всё содержимое. Стиль .regular на отдельной панели оказался ещё хуже: плотная молочная плашка. Для меня это повод полезть во внутрянку слоёв и разобраться, что же происходит.
Лезем в дерево слоёв: кто именно мылит
Первой гипотезой был просто радиус скругления. Фон размыт – уменьшим радиус. Я его уменьшал, фон правда становился чуть резче, но характер проблемы оставался, значит, inputBlurRadius – не то, надо смотреть, что есть ещё.
Дальше я обошёл layer tree у NSGlassEffectView: рекурсивно печатал имя класса слоя через String(describing: type(of: layer)) и фильтры на каждом слое. Среди прочего нашёлся приватный CABackdropLayer (про него уже известно давно, тот же Telegram iOS его использует), а на нём — приватный CAFilter с именем glassBackground.
Поиск backdrop-слоёв у меня выглядел так:
private static func findBackdropLayers(in layer: CALayer) -> [CALayer] { var result: [CALayer] = [] if String(describing: type(of: layer)) == "CABackdropLayer" { result.append(layer) } for sub in layer.sublayers ?? [] { result.append(contentsOf: findBackdropLayers(in: sub)) } return result }
А также дамп всего дерева:
private func dumpTree(_ layer: CALayer, depth: Int) { guard depth < 12 else { return } let pad = String(repeating: " ", count: depth) let cls = String(describing: type(of: layer)) let fnames = (layer.filters ?? []).map { ($0 as AnyObject).value(forKey: "name") as? String ?? "?" } let fsuffix = fnames.isEmpty ? "" : " filters=\(fnames)" NSLog("WholeWindowLiquidGlass: \(pad)\(cls) frame=\(layer.frame)\(fsuffix)") layer.sublayers?.forEach { dumpTree($0, depth: depth + 1) } }
В выводе как раз видно CABackdropLayer и filters=["glassBackground"]. С этого места я уже работал не с NSGlassEffectView, а с его внутренней Core Animation-кухней.
Что лежит в inputKeys у glassBackground
Получив фильтр, стоит узнать про inputKeys:
let keys = (filter as AnyObject).value(forKey: "inputKeys") as? [String]
Ключи там разбились на несколько групп.
Первая группа — blur:
inputBlurRadius— обычный общий радиус размытия;inputBlurOpacity0…inputBlurOpacity4— пять отдельных слоёв размытия. Они включаются с разной силой в разных частях стекла, поэтому фон мылится не равномерно, а полосами;inputBlurDistance0…inputBlurDistance4— параметры, которые задают положение/дистанцию этих слоёв.
То есть проблема была не в одном blur radius, а в нескольких дополнительных слоях размытия. Поэтому верх и низ панели выглядели по-разному, а простое уменьшение inputBlurRadius не лечило картинку до конца.
Вторая группа — face-матрица, то есть цвет, насыщенность и общая «плита» материала. Именно она делает .regular таким молочным:
private let faceKeys = [ "inputFaceColorMatrixWhite", "inputFaceColorMatrixBlack", "inputFaceColorMatrixSaturation", "inputFaceColorMatrixFillColor", "inputFaceColorMatrixMaxLuma", "inputFaceColorMatrixMaxLumaSDR", ]
Фикс: обнулить пять полос прогрессивного блюра
Когда стало ясно, что «мыло» приходит из пяти inputBlurOpacity, решение оказалось довольно простым: обнулить все пять полос и оставить небольшой равномерный blur, примерно 2.5. После этого фон под панелью стал читаться почти как в системном HUD. Не стерильно резкий, но уже без той молочной каши.
private func tuneGlassBlur() { guard #available(macOS 26.0, *), glassView is NSGlassEffectView, let layer = glassView?.layer else { return } for backdrop in Self.findBackdropLayers(in: layer) { backdrop.setValue(2.5, forKeyPath: "filters.glassBackground.inputBlurRadius") for band in 0 ... 4 { backdrop.setValue(0.0, forKeyPath: "filters.glassBackground.inputBlurOpacity\(band)") } } }
Стоковый .clear, судя по визуальному поведению, держит radius где-то в районе 7–8 и плюс включает полосы. Отсюда и «молоко». После 2.5 + opacity0...4 = 0 фон стал читаемым, а стекло не потеряло характер.
Более устойчивый вариант: искать фильтр по ключам, а не по имени
В OSDController я сначала хардкодил имя glassBackground. В универсальном пакете сделал аккуратнее: ищу фильтр не по имени, а по наличию inputBlurOpacity0 в inputKeys. Имя приватного фильтра Apple может переименовать быстрее, чем саму механику progressive blur. Не гарантия, но, как мне кажется, должно быть надежнее.
@discardableResult private func tuneGlassFilters(on layer: CALayer) -> Bool { guard let filters = layer.filters, !filters.isEmpty else { return false } var touched = false for f in filters { let obj = f as AnyObject guard let keys = obj.value(forKey: "inputKeys") as? [String], keys.contains("inputBlurOpacity0") else { continue } let name = (obj.value(forKey: "name") as? String) ?? "" func set(_ value: Any, _ key: String) { obj.setValue(value, forKey: key) if !name.isEmpty { layer.setValue(value, forKeyPath: "filters.\(name).\(key)") } } if configuration.flattenProgressiveBlur { for key in keys where key.hasPrefix("inputBlurOpacity") { set(0.0, key) } if keys.contains("inputBlurRadius") { set(configuration.backdropBlurRadius, "inputBlurRadius") } } if let clearFace { for (k, v) in clearFace where keys.contains(k) { set(v, k) } } touched = true } if touched { layer.filters = filters } return touched }
Тут была ещё одна маленькая, но неприятная проблема. Я меняю значения на объектах фильтров через KVC, но слой не всегда перечитывает эти изменения сам. После серии правок надо переприсвоить массив фильтров целиком: layer.filters = filters. Без этого можно полчаса смотреть на код, который точно работает, и на экран, где ничего не поменялось. Угадайте, откуда я знаю.
Почему правки сами откатываются
Первое применение сработало. Потом через мгновение плашка снова стала выглядеть дефолтной. Не потому что ключи неправильные, а потому что AppKit сам пересобирает glass-слои, и это происходит в нескольких местах, где AppKit решает обновить внутренности эффекта и ставит дефолтные значения.
В Lumen я решил это display link’ом:
private func startGlassTuning() { tuneGlassBlur() tuneLink?.invalidate() guard let glassView else { return } let link = glassView.displayLink(target: self, selector: #selector(tuneTick)) link.add(to: .main, forMode: .common) tuneLink = link } @objc private func tuneTick() { guard let panel, panel.isVisible else { tuneLink?.invalidate() tuneLink = nil return } tuneGlassBlur() }
NSView.displayLink(target:selector:) есть с macOS 14 и привязан к конкретной вью. Для transient OSD это удобно: панель скрылась — линк инвалидируется. Для постоянного окна я делаю иначе: tuner живёт ассоциированным объектом окна и держит display link весь lifetime окна, а в deinit аккуратно его инвалидирует.
Дополнение: как нейтрализовать .regular-плашки поверх whole-window стекла
У сценария с обычным окном всплыла отдельная проблема. Допустим, всё окно прозрачное, снизу лежит NSGlassEffectView(.clear), а поверх интерфейс SwiftUI, где какие-то карточки используют .glassEffect(.regular). Эти карточки тоже становятся молочными.
Выбрасывать их не хотелось. Иногда такая плашка нужна — не как фон окна, а как тело для текста и кнопок. Я сделал так: один раз захватываю нейтральную face-color матрицу с .clear-подложки и копирую её на .regular-фильтры. При этом inputFaceOpacity намеренно не копирую, иначе у плашки пропадает всё содержимое, и текст повисает над голым фоном.
private func captureClearFace() { guard clearFace == nil, let backing = window?.contentView?.subviews.first(where: { $0.identifier == glassBackingID }), let backingLayer = backing.layer else { return } var found: [String: Any]? walk(backingLayer) { layer in guard found == nil, let filters = layer.filters else { return } for f in filters { let obj = f as AnyObject guard let keys = obj.value(forKey: "inputKeys") as? [String], keys.contains("inputBlurOpacity0") else { continue } var dict: [String: Any] = [:] for k in faceKeys where keys.contains(k) { if let v = obj.value(forKey: k) { dict[k] = v } } found = dict return } } if let found { clearFace = found } }
Это уже не обязательно для маленькой OSD-пилюли, но полезно, если делать целое окно с таким стилем.
set_variant:6: почти системная светлая пилюля
По дороге нашлась ещё одна приватная ручка: селектор set_variant:. Variant 6 даёт светлый HUD-вид, очень близкий к системным пилюлям громкости и яркости.
Swift, естественно, не знает приватный селектор как нормальный метод. Поэтому вызов идёт через responds(to:), method(for:) и unsafeBitCast IMP в C-функцию:
private static func applyVariant(_ variant: Int, to glass: NSGlassEffectView) { let sel = NSSelectorFromString("set_variant:") guard glass.responds(to: sel) else { return } let fn = unsafeBitCast(glass.method(for: sel), to: (@convention(c) (AnyObject, Selector, Int) -> Void).self) fn(glass, sel, variant) }
Граница применимости важная. На маленькой OSD-панели variant 6 выглядит хорошо. На целое окно его ставить нельзя: он переопределяет appearance в светлую сторону и выбеливает весь контент (выглядело прямо очень плохо), поэтому в whole-window конфиге variant по умолчанию nil.
Fallback для систем до macOS 26
На macOS младше 26 NSGlassEffectView нет, поэтому я возвращаю NSVisualEffectView:
let effect = NSVisualEffectView() effect.material = .hudWindow effect.blendingMode = .behindWindow effect.state = .active effect.wantsLayer = true effect.layer?.cornerRadius = cornerRadius effect.layer?.masksToBounds = true
Это не Liquid Glass, но должно выглядеть нормально + blendingMode = .behindWindow тоже сэмплит то, что за окном.
Обобщение: WholeWindowLiquidGlass
Тот же подход я вынес в SwiftPM-пакет WholeWindowLiquidGlass: один файл, Swift 6, SwiftUI-модификатор .wholeWindowLiquidGlass(). Он ставит NSGlassEffectView(.clear) самым нижним subview прозрачного окна и поднимает тот же tuner. На системах младше macOS 26 — no-op. Повторная установка защищена через identifier, так что модификатор можно не бояться вызвать лишний раз.
Публичный вход выглядит так:
// WholeWindowLiquidGlass.swift public extension View { @ViewBuilder func wholeWindowLiquidGlass(_ configuration: WholeWindowGlassConfiguration = .init()) -> some View { #if os(macOS) if #available(macOS 26.0, *) { background(WholeWindowGlassInstaller(configuration: configuration)) } else { self } #else self #endif } }
Под капотом всё то же: прозрачное окно (isOpaque = false, backgroundColor = .clear), NSGlassEffectView(.clear) снизу, zero-duration animation context против AttributeGraph-краша и tuner, который flatten’ит progressive blur. Без tuner’а получается публичный .clear с молочным фоном, а не HUD.
Что в итоге
В итоге получился рабочий способ делать системный HUD на macOS 26+: https://github.com/vientooscuro/WholeWindowLiquidGlass, для меня, как для человека, которому очень важно, чтобы было “крвсиво”, это был отличный результат. К слову, все мои персональные проекты начинаются с того, чтобы сделать “красиво”: так я сделал и приложение, которое 12 лет назад оказалось в топ-4 в категории “музыка”, так я писал свой собственный клиент для почты и многое другое.
Ссылки на документацию:
NSGlassEffectView— https://developer.apple.com/documentation/appkit/nsglasseffectviewNSVisualEffectView(.hudWindow,.behindWindow) — https://developer.apple.com/documentation/appkit/nsvisualeffectviewCALayer.filters— https://developer.apple.com/documentation/quartzcore/calayerSwiftUI
glassEffect(_:in:)иGlass— https://developer.apple.com/documentation/swiftui/view/glasseffect(_:in:) , https://developer.apple.com/documentation/swiftui/glassWWDC25, «Meet Liquid Glass» (219) — https://developer.apple.com/videos/play/wwdc2025/219/
WWDC25, «Build an AppKit app with the new design» (310) — https://developer.apple.com/videos/play/wwdc2025/310/
CAFilter и CABackdropLayer публичной документации не имеют. Всё, что про них написано выше, получено дампом дерева слоёв на macOS 26.
LeshaRB
А lumen где можно глянуть?
vientooscuro Автор
https://gelfand.dev, там, список моих приложений. Правда там у Lumen кнопка "Mac AppStore", а ведет просто напрямую на нотаризованный dmg-файл