feat(desktop): macOS vibrancy for the left sidebar with a toggle
Add native macOS vibrancy behind the left sidebar (the only translucent surface; header/chat/right sidebar stay opaque), plus a setting to turn it off. - Window created with vibrancy applied after first show (avoids the cold-launch no-composite quirk); minimize/restore suppress the frost during the genie animation. Renderer frosts the sidebar via --sidebar-vibrancy-overlay once data-oc-vibrancy[-ready] are set; project-actions pill matches when open. - data-oc-vibrancy-ready defaults are set in cssGenerator (DOM guaranteed), not the preload (document-start race left the sidebar un-frosted on launch). - prefers-reduced-transparency falls back to solid surfaces. - Appearance settings (macOS desktop only): a checkbox to enable/disable vibrancy, persisted to settings.json and applied via a Save & restart button (vibrancy is a window-creation option, so it needs a relaunch).
This commit is contained in:
@@ -1480,6 +1480,36 @@ const emitToAllWindows = (event, detail) => {
|
||||
}
|
||||
};
|
||||
|
||||
// macOS vibrancy: the native NSVisualEffectView needs a moment to settle after
|
||||
// the window is shown/restored. Until then the renderer keeps the sidebar solid
|
||||
// to avoid a flash of raw transparency; once ready it switches to the
|
||||
// translucent overlay. We toggle this readiness over the same IPC bridge.
|
||||
// Apply vibrancy to a live, on-screen window. Done after show (not in the
|
||||
// BrowserWindow constructor) because macOS otherwise leaves the material
|
||||
// uncomposited on a cold launch until the window gets a state change.
|
||||
const applyMacVibrancy = (browserWindow) => {
|
||||
if (process.platform !== 'darwin' || !browserWindow || browserWindow.isDestroyed()) return;
|
||||
try {
|
||||
browserWindow.setVibrancy('sidebar');
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const setMacVibrancyReady = (browserWindow, ready) => {
|
||||
if (process.platform !== 'darwin' || !browserWindow || browserWindow.isDestroyed()) return;
|
||||
emitToWindow(browserWindow, 'openchamber:vibrancy-ready', { ready });
|
||||
};
|
||||
|
||||
const scheduleMacVibrancyReady = (browserWindow, delayMs = 160) => {
|
||||
if (process.platform !== 'darwin' || !browserWindow || browserWindow.isDestroyed()) return;
|
||||
setMacVibrancyReady(browserWindow, false);
|
||||
const timer = setTimeout(() => {
|
||||
if (browserWindow.isDestroyed() || browserWindow.isMinimized() || !browserWindow.isVisible()) return;
|
||||
setMacVibrancyReady(browserWindow, true);
|
||||
}, delayMs);
|
||||
if (typeof timer?.unref === 'function') timer.unref();
|
||||
};
|
||||
|
||||
|
||||
const setTaskbarProgress = (value) => {
|
||||
if (process.platform !== 'win32') return;
|
||||
for (const browserWindow of BrowserWindow.getAllWindows()) {
|
||||
@@ -1772,6 +1802,8 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
||||
const desktopHome = os.homedir() || '';
|
||||
const desktopMacosMajor = String(macosMajorVersion());
|
||||
const usesCustomTitleBar = process.platform === 'darwin' || process.platform === 'win32';
|
||||
// macOS vibrancy, on by default; users can disable it (Appearance settings).
|
||||
const useVibrancy = process.platform === 'darwin' && readSettingsRoot().desktopVibrancy !== false;
|
||||
const titleBarOverlayEnabled = false;
|
||||
const autoHidesNativeMenuBar = process.platform !== 'darwin';
|
||||
const windowIconPath = getWindowIconPath();
|
||||
@@ -1786,7 +1818,11 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
||||
minHeight: MIN_WINDOW_HEIGHT,
|
||||
icon: windowIconPath,
|
||||
show: false,
|
||||
backgroundColor: '#151313',
|
||||
backgroundColor: useVibrancy ? '#00000000' : '#151313',
|
||||
// Vibrancy is applied after the window is shown (see applyMacVibrancy), not
|
||||
// here: setting it in the constructor leaves the material uncomposited on a
|
||||
// cold launch until a window event. No `transparent: true` either — vibrancy
|
||||
// alone is enough and composites reliably once applied to a live window.
|
||||
frame: process.platform === 'win32' ? false : undefined,
|
||||
autoHideMenuBar: autoHidesNativeMenuBar,
|
||||
// Electron's hiddenInset adds its own extra inset, which leaves the controls
|
||||
@@ -1801,6 +1837,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
||||
`--openchamber-client-token=${desktopClientToken}`,
|
||||
`--openchamber-home=${desktopHome}`,
|
||||
`--openchamber-macos-major=${desktopMacosMajor}`,
|
||||
`--openchamber-mac-vibrancy=${useVibrancy ? '1' : '0'}`,
|
||||
`--openchamber-boot-outcome=${JSON.stringify(state.bootOutcome || null)}`,
|
||||
],
|
||||
preload: isDev ? path.join(__dirname, 'preload.mjs') : path.join(app.getAppPath(), 'preload.mjs'),
|
||||
@@ -1847,11 +1884,20 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
||||
browserWindow.setTrafficLightPosition({ x: 16, y: 17 });
|
||||
} catch {}
|
||||
};
|
||||
browserWindow.on('minimize', refreshTrafficLights);
|
||||
browserWindow.on('minimize', () => {
|
||||
refreshTrafficLights();
|
||||
setMacVibrancyReady(browserWindow, false);
|
||||
});
|
||||
browserWindow.on('restore', () => {
|
||||
refreshTrafficLights();
|
||||
setTimeout(refreshTrafficLights, 250);
|
||||
scheduleMacVibrancyReady(browserWindow, 180);
|
||||
});
|
||||
// Only suppress vibrancy around the minimize/restore cycle (it flashes raw
|
||||
// transparency during the genie animation). A plain show — cold launch from
|
||||
// the dock, un-hide — must NOT suppress, or the sidebar gets stuck solid
|
||||
// when the post-show `ready` re-enable is skipped while the window is still
|
||||
// animating in.
|
||||
browserWindow.on('show', refreshTrafficLights);
|
||||
browserWindow.on('focus', refreshTrafficLights);
|
||||
}
|
||||
@@ -1976,6 +2022,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
||||
browserWindow.once('ready-to-show', () => {
|
||||
browserWindow.show();
|
||||
browserWindow.focus();
|
||||
if (useVibrancy) applyMacVibrancy(browserWindow);
|
||||
});
|
||||
|
||||
if (url) {
|
||||
@@ -2103,6 +2150,8 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj
|
||||
const desktopClientToken = effectiveRuntimeConfig.clientToken || '';
|
||||
const desktopHome = os.homedir() || '';
|
||||
const desktopMacosMajor = String(macosMajorVersion());
|
||||
// macOS vibrancy, on by default; users can disable it (Appearance settings).
|
||||
const useVibrancy = process.platform === 'darwin' && readSettingsRoot().desktopVibrancy !== false;
|
||||
const browserWindow = new BrowserWindow({
|
||||
title: 'OpenChamber Mini Chat',
|
||||
width: MINI_CHAT_WINDOW_WIDTH,
|
||||
@@ -2111,7 +2160,11 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj
|
||||
minHeight: MINI_CHAT_MIN_WINDOW_HEIGHT,
|
||||
icon: getWindowIconPath(),
|
||||
show: false,
|
||||
backgroundColor: '#151313',
|
||||
backgroundColor: useVibrancy ? '#00000000' : '#151313',
|
||||
// Vibrancy is applied after the window is shown (see applyMacVibrancy), not
|
||||
// here: setting it in the constructor leaves the material uncomposited on a
|
||||
// cold launch until a window event. No `transparent: true` either — vibrancy
|
||||
// alone is enough and composites reliably once applied to a live window.
|
||||
frame: process.platform === 'win32' ? false : undefined,
|
||||
autoHideMenuBar: process.platform !== 'darwin',
|
||||
titleBarStyle: process.platform === 'darwin' || process.platform === 'win32' ? 'hidden' : 'default',
|
||||
@@ -2161,13 +2214,17 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj
|
||||
browserWindow.setTrafficLightPosition({ x: 16, y: 17 });
|
||||
} catch {}
|
||||
};
|
||||
// Suppress vibrancy only around minimize/restore, never on a plain show.
|
||||
browserWindow.on('show', refreshTrafficLights);
|
||||
browserWindow.on('focus', refreshTrafficLights);
|
||||
browserWindow.on('minimize', () => setMacVibrancyReady(browserWindow, false));
|
||||
browserWindow.on('restore', () => scheduleMacVibrancyReady(browserWindow, 180));
|
||||
}
|
||||
|
||||
browserWindow.once('ready-to-show', () => {
|
||||
browserWindow.show();
|
||||
browserWindow.focus();
|
||||
if (useVibrancy) applyMacVibrancy(browserWindow);
|
||||
});
|
||||
|
||||
browserWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
@@ -3309,13 +3366,23 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
}
|
||||
|
||||
case 'desktop_set_vibrancy': {
|
||||
// Vibrancy (macOS blur) is not supported in the Electron shell for our
|
||||
// titleBarStyle:'hidden' setup. Persist the
|
||||
// disabled state so settings UI reflects it; args.enabled is ignored.
|
||||
// Vibrancy + transparent backing are window-creation options, so the
|
||||
// change only takes effect on a fresh launch. Persist the preference,
|
||||
// then relaunch the app.
|
||||
const enabled = args.enabled === true;
|
||||
await mutateSettingsRoot((root) => {
|
||||
root.desktopVibrancy = false;
|
||||
root.desktopVibrancy = enabled;
|
||||
});
|
||||
return { enabled: false, requiresRestart: false };
|
||||
setImmediate(() => {
|
||||
try {
|
||||
prepareForQuit();
|
||||
app.relaunch();
|
||||
app.exit(0);
|
||||
} catch (err) {
|
||||
log.error('[electron] desktop_set_vibrancy relaunch failed', err);
|
||||
}
|
||||
});
|
||||
return { enabled, requiresRestart: true };
|
||||
}
|
||||
|
||||
case 'desktop_check_for_updates': {
|
||||
|
||||
@@ -17,6 +17,10 @@ const clientToken = readArgValue('--openchamber-client-token');
|
||||
const homeDirectory = readArgValue('--openchamber-home');
|
||||
const macosMajorRaw = readArgValue('--openchamber-macos-major');
|
||||
const macosMajor = Number.parseInt(macosMajorRaw, 10);
|
||||
const macVibrancySupported = process.platform === 'darwin';
|
||||
// Effective state for this window (main process resolves the saved preference
|
||||
// and passes it in). Defaults on when supported unless explicitly '0'.
|
||||
const hasMacVibrancy = macVibrancySupported && readArgValue('--openchamber-mac-vibrancy') !== '0';
|
||||
|
||||
// Preload re-executes on every cross-origin navigation (we run with
|
||||
// sandbox:false, per-document). Two separate concerns to balance:
|
||||
@@ -72,6 +76,8 @@ if (Number.isFinite(macosMajor) && macosMajor > 0) {
|
||||
|
||||
contextBridge.exposeInMainWorld('__OPENCHAMBER_ELECTRON__', {
|
||||
runtime: 'electron',
|
||||
macVibrancy: hasMacVibrancy,
|
||||
macVibrancySupported,
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('__OPENCHAMBER_PLATFORM__', process.platform);
|
||||
@@ -120,6 +126,18 @@ const dispatchNativeEvent = (event, detail) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Toggles the frost on/off in response to the main process around the
|
||||
// minimize/restore cycle. The default ("ready") state is set reliably in the
|
||||
// renderer (cssGenerator) — not here — because this preload runs at
|
||||
// document-start when documentElement may not exist yet.
|
||||
const setVibrancyReady = (ready) => {
|
||||
if (!hasMacVibrancy) return;
|
||||
try {
|
||||
document.documentElement.toggleAttribute('data-oc-vibrancy-ready', ready === true);
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
|
||||
// Main-process events are read-only notifications (update progress,
|
||||
// window focus, etc.) — safe to deliver to any page rendered in this
|
||||
// webContents. The events themselves don't grant capability.
|
||||
@@ -133,6 +151,10 @@ ipcRenderer.on('openchamber:emit', (_evt, payload) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === 'openchamber:vibrancy-ready') {
|
||||
setVibrancyReady(payload.detail?.ready === true);
|
||||
}
|
||||
|
||||
dispatchNativeEvent(event, payload.detail);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user