mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
fix(window): add the macOS blur surface for Background Opacity and Window Blur (partial)
macOS configured no vibrancy surface at all, and an opaque fill sat between the desktop and the terminal, so Background Opacity and Window Blur both looked like no-ops. Adds a platform-gated blur surface (`resolveMainWindowBlurSurface`, covering macOS vibrancy / Windows acrylic / Linux fallback) and a root class that drops the opaque fill so the opacity token reaches the compositor. > [!WARNING] > **This does not close #8797 — it is deliberately `Refs`, not `Fixes`.** > I measured it on macOS with a dedicated isolated profile, identical app state, same wallpaper and window geometry, native `screencapture`, toggling only `windowBackgroundBlur` with a restart between runs: > > | Region | Blur OFF | Blur ON | Mean abs diff | > | --- | --- | --- | --- | > | Terminal pane | RGB(48.0, 53.0, 61.0) | RGB(50.0, 55.0, 63.1) | 2.0 / 255 | > | Sidebar | RGB(46.3, 46.4, 46.4) | RGB(46.3, 46.4, 46.4) | 0 — pixel-identical | > > The renderer half provably works (`window-blur` class applied; `html`/`body`/`#root` compute to `rgba(0,0,0,0)`; `.xterm` at `rgba(40,44,52,0.55)`), but the native vibrancy never reaches the screen, so the setting is still not visibly effective. Most likely Electron keeps an opaque window backing without `transparent: true`, which #8482 removed on purpose. > > Kept because it is a correct, unit-tested prerequisite. Merge or hold at your discretion — one machine, one macOS version, external display. Refs #8797 Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -333,12 +333,43 @@ describe('createMainWindow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('never requests macOS vibrancy or transparency when window blur is enabled (#8482)', () => {
|
||||
for (const [platform, expected] of [
|
||||
['darwin', { backgroundMaterial: undefined }],
|
||||
['win32', { backgroundMaterial: 'acrylic' }],
|
||||
['linux', { backgroundMaterial: undefined }]
|
||||
] satisfies [NodeJS.Platform, { backgroundMaterial: string | undefined }][]) {
|
||||
it('gives window blur a real material without transparency, and stays opaque when off (#8482, #8797)', () => {
|
||||
for (const [platform, blur, expected] of [
|
||||
[
|
||||
'darwin',
|
||||
false,
|
||||
{ vibrancy: undefined, backgroundMaterial: undefined, background: '#ffffff' }
|
||||
],
|
||||
[
|
||||
'win32',
|
||||
false,
|
||||
{ vibrancy: undefined, backgroundMaterial: undefined, background: '#ffffff' }
|
||||
],
|
||||
[
|
||||
'linux',
|
||||
false,
|
||||
{ vibrancy: undefined, backgroundMaterial: undefined, background: '#ffffff' }
|
||||
],
|
||||
[
|
||||
'darwin',
|
||||
true,
|
||||
{ vibrancy: 'under-window', backgroundMaterial: undefined, background: undefined }
|
||||
],
|
||||
[
|
||||
'win32',
|
||||
true,
|
||||
{ vibrancy: undefined, backgroundMaterial: 'acrylic', background: undefined }
|
||||
],
|
||||
['linux', true, { vibrancy: undefined, backgroundMaterial: undefined, background: '#ffffff' }]
|
||||
] satisfies [
|
||||
NodeJS.Platform,
|
||||
boolean,
|
||||
{
|
||||
vibrancy: string | undefined
|
||||
backgroundMaterial: string | undefined
|
||||
background: string | undefined
|
||||
}
|
||||
][]) {
|
||||
browserWindowMock.mockReset()
|
||||
const webContents = {
|
||||
on: vi.fn(),
|
||||
@@ -373,16 +404,19 @@ describe('createMainWindow', () => {
|
||||
withPlatform(platform, () =>
|
||||
createMainWindow({
|
||||
getUI: () => ({}),
|
||||
getSettings: () => ({ windowBackgroundBlur: true }),
|
||||
getSettings: () => ({ windowBackgroundBlur: blur }),
|
||||
updateUI: vi.fn()
|
||||
} as never)
|
||||
)
|
||||
|
||||
const browserWindowOptions = browserWindowMock.mock.calls[0]?.[0]
|
||||
expect(browserWindowOptions.vibrancy).toBeUndefined()
|
||||
// Why: `transparent` is what suppressed vibrancy and forced per-frame alpha compositing (#8482).
|
||||
expect(browserWindowOptions.transparent).toBeUndefined()
|
||||
expect(browserWindowOptions.vibrancy).toBe(expected.vibrancy)
|
||||
expect(browserWindowOptions.visualEffectState).toBe(expected.vibrancy ? 'active' : undefined)
|
||||
expect(browserWindowOptions.backgroundMaterial).toBe(expected.backgroundMaterial)
|
||||
expect(browserWindowOptions.backgroundColor).toBe('#ffffff')
|
||||
// Why: an opaque fill would paint over the material, which is the #8797 no-op.
|
||||
expect(browserWindowOptions.backgroundColor).toBe(expected.background)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation'
|
||||
import { closeDashboardPopout } from './dashboard-popout-window'
|
||||
import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation'
|
||||
import { isMacosTahoeOrNewer } from './macos-tahoe-release'
|
||||
import { resolveMainWindowBlurSurface } from './main-window-blur-surface'
|
||||
import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard'
|
||||
|
||||
// Why: show/restore/resume can overlap before the size nudge resets; never capture the temporary width as the next baseline.
|
||||
@@ -247,11 +248,13 @@ export function createMainWindow(
|
||||
// Why: webview guests expose no safe transcript insertion target; let Cmd/Ctrl+E reach the page instead of dropping dictation text.
|
||||
return false
|
||||
})
|
||||
const blur = settings?.windowBackgroundBlur ?? false
|
||||
// Why: only Windows acrylic is ever visible; macOS vibrancy+transparent sat behind our opaque background yet
|
||||
// forced per-frame WindowServer alpha compositing (#8482). Applies at creation only, so it needs a restart.
|
||||
const platformBlurOptions =
|
||||
blur && process.platform === 'win32' ? { backgroundMaterial: 'acrylic' as const } : {}
|
||||
// Why: the blur material is the window backdrop, so an opaque fill has to be dropped alongside it (#8797).
|
||||
// Applies at creation only, so it needs a restart.
|
||||
const { backgroundColor, blurOptions } = resolveMainWindowBlurSurface({
|
||||
platform: process.platform,
|
||||
blur: settings?.windowBackgroundBlur ?? false,
|
||||
dark: nativeTheme.shouldUseDarkColors
|
||||
})
|
||||
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: savedBounds?.width ?? defaultBounds.width,
|
||||
@@ -265,7 +268,7 @@ export function createMainWindow(
|
||||
acceptFirstMouse: true,
|
||||
// Why: auto-hide the Windows/Linux menu bar to save a row (Alt reveals it); macOS uses the system menu bar anyway.
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff',
|
||||
...(backgroundColor ? { backgroundColor } : {}),
|
||||
// Why: macOS 'hiddenInset' keeps native traffic lights in our custom titlebar; Windows 'hidden' removes the OS title bar so it doesn't double up.
|
||||
titleBarStyle:
|
||||
process.platform === 'darwin'
|
||||
@@ -285,7 +288,7 @@ export function createMainWindow(
|
||||
}
|
||||
: {}),
|
||||
icon: getAppIconPath(settings?.appIcon),
|
||||
...platformBlurOptions,
|
||||
...blurOptions,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: true,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveMainWindowBlurSurface } from './main-window-blur-surface'
|
||||
|
||||
const PLATFORMS: NodeJS.Platform[] = ['darwin', 'win32', 'linux']
|
||||
|
||||
describe('resolveMainWindowBlurSurface (#8797)', () => {
|
||||
it('keeps the opaque fill and no material when blur is off (#8482 default path)', () => {
|
||||
for (const platform of PLATFORMS) {
|
||||
expect(resolveMainWindowBlurSurface({ platform, blur: false, dark: true })).toEqual({
|
||||
backgroundColor: '#0a0a0a',
|
||||
blurOptions: {}
|
||||
})
|
||||
expect(resolveMainWindowBlurSurface({ platform, blur: false, dark: false })).toEqual({
|
||||
backgroundColor: '#ffffff',
|
||||
blurOptions: {}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('drops the opaque fill so macOS vibrancy is the window backdrop', () => {
|
||||
for (const dark of [true, false]) {
|
||||
const surface = resolveMainWindowBlurSurface({ platform: 'darwin', blur: true, dark })
|
||||
expect(surface.backgroundColor).toBeUndefined()
|
||||
expect(surface.blurOptions).toEqual({ vibrancy: 'under-window', visualEffectState: 'active' })
|
||||
}
|
||||
})
|
||||
|
||||
it('drops the opaque fill so Windows acrylic is visible', () => {
|
||||
const surface = resolveMainWindowBlurSurface({ platform: 'win32', blur: true, dark: true })
|
||||
expect(surface.backgroundColor).toBeUndefined()
|
||||
expect(surface.blurOptions).toEqual({ backgroundMaterial: 'acrylic' })
|
||||
})
|
||||
|
||||
it('leaves Linux opaque because it has no supported blur material', () => {
|
||||
expect(resolveMainWindowBlurSurface({ platform: 'linux', blur: true, dark: true })).toEqual({
|
||||
backgroundColor: '#0a0a0a',
|
||||
blurOptions: {}
|
||||
})
|
||||
})
|
||||
|
||||
it('never requests window transparency (#8482)', () => {
|
||||
for (const platform of PLATFORMS) {
|
||||
for (const blur of [true, false]) {
|
||||
const surface = resolveMainWindowBlurSurface({ platform, blur, dark: true })
|
||||
expect(surface.blurOptions).not.toHaveProperty('transparent')
|
||||
expect(surface.backgroundColor).not.toBe('#00000000')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Window-creation options that decide whether the platform blur material has a surface to render on.
|
||||
*
|
||||
* Why: an always-opaque `backgroundColor` paints over macOS vibrancy / Windows acrylic, so Window Blur
|
||||
* and terminal Background Opacity both looked like no-ops (#8797). `transparent` is deliberately never
|
||||
* set: it is what suppressed vibrancy and took the window off the opaque fast path (#8482).
|
||||
*/
|
||||
|
||||
export type MainWindowBlurSurface = {
|
||||
/** Omitted when a blur material owns the window backdrop; an opaque fill would hide it. */
|
||||
backgroundColor?: string
|
||||
/** Spread into the BrowserWindow constructor. */
|
||||
blurOptions: {
|
||||
vibrancy?: 'under-window'
|
||||
visualEffectState?: 'active'
|
||||
backgroundMaterial?: 'acrylic'
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMainWindowBlurSurface(input: {
|
||||
platform: NodeJS.Platform
|
||||
blur: boolean
|
||||
dark: boolean
|
||||
}): MainWindowBlurSurface {
|
||||
const opaqueBackgroundColor = input.dark ? '#0a0a0a' : '#ffffff'
|
||||
|
||||
if (!input.blur) {
|
||||
return { backgroundColor: opaqueBackgroundColor, blurOptions: {} }
|
||||
}
|
||||
|
||||
if (input.platform === 'darwin') {
|
||||
// Why 'active': otherwise macOS freezes the material to a static snapshot whenever the window is unfocused.
|
||||
return { blurOptions: { vibrancy: 'under-window', visualEffectState: 'active' } }
|
||||
}
|
||||
|
||||
if (input.platform === 'win32') {
|
||||
return { blurOptions: { backgroundMaterial: 'acrylic' } }
|
||||
}
|
||||
|
||||
// Linux has no supported material; a clear (unblurred) window would be worse than an opaque one.
|
||||
return { backgroundColor: opaqueBackgroundColor, blurOptions: {} }
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { resolveLeftTitlebarChromeLayout } from '@/lib/titlebar-left-chrome'
|
||||
import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface'
|
||||
import { buildAppFontFamily } from '@/lib/app-font-family'
|
||||
import { applyWindowBlurRootClass } from '@/lib/window-blur-root-class'
|
||||
import { toast } from 'sonner'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
@@ -1474,6 +1475,11 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
}, [settings?.appFontFamily])
|
||||
|
||||
// Why: vibrancy/acrylic sit behind the web contents; an opaque app root hides them (#8797).
|
||||
useEffect(() => {
|
||||
applyWindowBlurRootClass(document.documentElement, settings?.windowBackgroundBlur ?? false)
|
||||
}, [settings?.windowBackgroundBlur])
|
||||
|
||||
// Refresh GitHub data (PR/issue status) when window regains focus
|
||||
useEffect(() => {
|
||||
const handler = (): void => {
|
||||
|
||||
@@ -698,6 +698,16 @@ html.native-shell .app-layout {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Why: macOS vibrancy / Windows acrylic render behind the web contents, so the app root must not
|
||||
paint an opaque fill over them (#8797). Native-shell only — a browser tab has no material behind
|
||||
it, so clearing the fill there would just leak the page background. Chrome keeps its own tokens. */
|
||||
html.native-shell.window-blur,
|
||||
html.native-shell.window-blur body,
|
||||
html.native-shell.window-blur #root,
|
||||
html.native-shell.window-blur .app-layout {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Tab-group split resize handle — wide hit area, visible center line. */
|
||||
.tab-group-split-resize-handle {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyWindowBlurRootClass, WINDOW_BLUR_ROOT_CLASS } from './window-blur-root-class'
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
return readFileSync(join(process.cwd(), relativePath), 'utf8')
|
||||
}
|
||||
|
||||
describe('window blur root class (#8797)', () => {
|
||||
it('toggles the root class from the setting', () => {
|
||||
const root = document.documentElement
|
||||
|
||||
applyWindowBlurRootClass(root, true)
|
||||
expect(root.classList.contains(WINDOW_BLUR_ROOT_CLASS)).toBe(true)
|
||||
|
||||
applyWindowBlurRootClass(root, false)
|
||||
expect(root.classList.contains(WINDOW_BLUR_ROOT_CLASS)).toBe(false)
|
||||
})
|
||||
|
||||
it('clears the native-shell app root fill so the platform blur material shows through', () => {
|
||||
expect(readSource('src/renderer/src/assets/main.css')).toMatch(
|
||||
/html\.native-shell\.window-blur,\s*html\.native-shell\.window-blur body,\s*html\.native-shell\.window-blur #root,\s*html\.native-shell\.window-blur \.app-layout\s*\{\s*background: transparent;\s*\}/
|
||||
)
|
||||
})
|
||||
|
||||
it('drives the class from windowBackgroundBlur in App', () => {
|
||||
const appSource = readSource('src/renderer/src/App.tsx')
|
||||
|
||||
expect(appSource).toMatch(
|
||||
/applyWindowBlurRootClass\(\s*document\.documentElement,\s*settings\?\.windowBackgroundBlur \?\? false\s*\)/
|
||||
)
|
||||
expect(appSource).toContain('}, [settings?.windowBackgroundBlur])')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Root class that drops the app's opaque fill so the platform blur material behind the web contents shows (#8797). */
|
||||
export const WINDOW_BLUR_ROOT_CLASS = 'window-blur'
|
||||
|
||||
export function applyWindowBlurRootClass(root: HTMLElement, enabled: boolean): void {
|
||||
root.classList.toggle(WINDOW_BLUR_ROOT_CLASS, enabled)
|
||||
}
|
||||
Reference in New Issue
Block a user