diff --git a/scripts/build-macos-effects.mjs b/scripts/build-macos-effects.mjs index 1398095..0e8377e 100644 --- a/scripts/build-macos-effects.mjs +++ b/scripts/build-macos-effects.mjs @@ -5,7 +5,27 @@ import { fileURLToPath } from "node:url"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const srcFile = path.join(rootDir, "native/macos/window-effects.mm"); -const outFile = path.join(rootDir, "src/bun/libMacWindowEffects.dylib"); +export const outFile = path.join(rootDir, "src/bun/libMacWindowEffects.dylib"); +const minDylibBytes = 10_000; + +function normalizeArch(value) { + if (value === "x64" || value === "amd64" || value === "x86_64") { + return "x86_64"; + } + if (value === "arm64" || value === "aarch64") { + return "arm64"; + } + return null; +} + +export function resolveTargetArch() { + return ( + normalizeArch(process.env.ELECTROBUN_TARGET_ARCH) || + normalizeArch(process.env.ELECTROBUN_ARCH) || + normalizeArch(process.arch) || + "arm64" + ); +} function createPlaceholder() { mkdirSync(path.dirname(outFile), { recursive: true }); @@ -13,6 +33,49 @@ function createPlaceholder() { console.log(`build-macos-effects: created placeholder dylib at ${outFile}`); } +function readDylibArch(dylibPath) { + const lipo = spawnSync("lipo", ["-info", dylibPath], { encoding: "utf8" }); + if (lipo.status === 0) { + return lipo.stdout; + } + + const file = spawnSync("file", ["-b", dylibPath], { encoding: "utf8" }); + return file.stdout || ""; +} + +export function validateMacosEffectsDylib({ + dylibPath = outFile, + expectedArch = resolveTargetArch(), + required = process.platform === "darwin", +} = {}) { + if (!required) { + return true; + } + + if (!existsSync(dylibPath)) { + console.error(`build-macos-effects: missing dylib at ${dylibPath}`); + return false; + } + + const { size } = statSync(dylibPath); + if (size < minDylibBytes) { + console.error( + `build-macos-effects: dylib at ${dylibPath} is too small (${size} bytes)`, + ); + return false; + } + + const archInfo = readDylibArch(dylibPath); + if (!archInfo.includes(expectedArch)) { + console.error( + `build-macos-effects: dylib architecture mismatch (expected ${expectedArch}): ${archInfo.trim()}`, + ); + return false; + } + + return true; +} + if (process.platform !== "darwin") { createPlaceholder(); process.exit(0); @@ -25,12 +88,16 @@ if (!existsSync(srcFile)) { mkdirSync(path.dirname(outFile), { recursive: true }); +const targetArch = resolveTargetArch(); const result = spawnSync( "xcrun", [ "clang++", "-dynamiclib", "-fobjc-arc", + "-arch", + targetArch, + "-mmacosx-version-min=11.0", "-framework", "Cocoa", srcFile, @@ -44,5 +111,11 @@ if (result.status !== 0) { process.exit(result.status ?? 1); } +if (!validateMacosEffectsDylib({ dylibPath: outFile, expectedArch: targetArch })) { + process.exit(1); +} + const { size } = statSync(outFile); -console.log(`build-macos-effects: built ${outFile} (${size} bytes)`); +console.log( + `build-macos-effects: built ${outFile} (${size} bytes, arch=${targetArch})`, +); diff --git a/scripts/pre-build.mjs b/scripts/pre-build.mjs index 32046ce..307eba0 100644 --- a/scripts/pre-build.mjs +++ b/scripts/pre-build.mjs @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "dev"; @@ -46,6 +46,21 @@ if (buildMacosEffects.status !== 0) { process.exit(buildMacosEffects.status ?? 1); } +if (process.platform === "darwin") { + const { validateMacosEffectsDylib, outFile } = await import( + pathToFileURL( + path.join(rootDir, "scripts/build-macos-effects.mjs"), + ).href + ); + + if (!validateMacosEffectsDylib({ dylibPath: outFile })) { + console.error( + "pre-build: macOS window effects dylib is missing or invalid; blur and traffic lights will not work in packaged builds.", + ); + process.exit(1); + } +} + const prepareIcons = spawnSync( "bun", [ diff --git a/scripts/run-electrobun-build.mjs b/scripts/run-electrobun-build.mjs index 29be7f0..7ad3f77 100644 --- a/scripts/run-electrobun-build.mjs +++ b/scripts/run-electrobun-build.mjs @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { getReleaseArch } from "./release-artifact-utils.mjs"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -36,6 +36,21 @@ if (macosEffectsResult.status !== 0) { process.exit(macosEffectsResult.status ?? 1); } +if (process.platform === "darwin") { + const { validateMacosEffectsDylib, outFile } = await import( + pathToFileURL( + path.join(rootDir, "scripts/build-macos-effects.mjs"), + ).href + ); + + if (!validateMacosEffectsDylib({ dylibPath: outFile })) { + console.error( + "run-electrobun-build: macOS window effects dylib is missing or invalid.", + ); + process.exit(1); + } +} + const ensureCoreEnv = { ...process.env, ELECTROBUN_TARGET_ARCH: targetArch, diff --git a/src/desktop/macos-window-effects.js b/src/desktop/macos-window-effects.js index b953f34..aafa3cc 100644 --- a/src/desktop/macos-window-effects.js +++ b/src/desktop/macos-window-effects.js @@ -4,22 +4,38 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' export const MAC_WINDOW_CORNER_RADIUS = 15 +export const MAC_TRAFFIC_LIGHT_OFFSET = { x: 14, y: 12 } + +const DYLIB_NAME = 'libMacWindowEffects.dylib' +const MIN_DYLIB_BYTES = 10_000 function resolveDylibPath() { + const candidates = new Set() const moduleDir = path.dirname(fileURLToPath(import.meta.url)) - const candidates = [ - path.join(moduleDir, '../bun/libMacWindowEffects.dylib'), - path.join(moduleDir, 'libMacWindowEffects.dylib') - ] + + candidates.add(path.join(moduleDir, DYLIB_NAME)) + candidates.add(path.join(moduleDir, '../bun', DYLIB_NAME)) + candidates.add(path.join(moduleDir, '../../bun', DYLIB_NAME)) + candidates.add(path.join(process.cwd(), 'src/bun', DYLIB_NAME)) + + for (const execPath of [process.execPath, process.argv0].filter(Boolean)) { + const execDir = path.dirname(execPath) + candidates.add( + path.join(execDir, '../Resources/app/bun', DYLIB_NAME) + ) + candidates.add(path.join(execDir, 'Resources/app/bun', DYLIB_NAME)) + candidates.add(path.join(execDir, DYLIB_NAME)) + } for (const candidate of candidates) { - if (!existsSync(candidate)) { + const resolved = path.resolve(candidate) + if (!existsSync(resolved)) { continue } try { - if (statSync(candidate).size > 0) { - return candidate + if (statSync(resolved).size >= MIN_DYLIB_BYTES) { + return resolved } } catch { // Ignore unreadable paths and keep searching. @@ -29,50 +45,99 @@ function resolveDylibPath() { return null } -export function applyMacOSWindowEffects(mainWindow) { - if (process.platform !== 'darwin' || !mainWindow?.ptr) { - return - } - +function loadMacWindowEffectsLibrary() { const dylibPath = resolveDylibPath() if (!dylibPath) { console.warn( 'macOS vibrancy: native effects library not found; using transparent window only.' ) - return + return null } try { - const lib = dlopen(dylibPath, { - enableWindowVibrancy: { - args: [FFIType.ptr, FFIType.f64], - returns: FFIType.bool - }, - ensureWindowShadow: { - args: [FFIType.ptr], - returns: FFIType.bool - }, - setWindowCornerRadius: { - args: [FFIType.ptr, FFIType.f64], - returns: FFIType.bool - } - }) + return { + path: dylibPath, + lib: dlopen(dylibPath, { + enableWindowVibrancy: { + args: [FFIType.ptr, FFIType.f64], + returns: FFIType.bool + }, + ensureWindowShadow: { + args: [FFIType.ptr], + returns: FFIType.bool + }, + setWindowCornerRadius: { + args: [FFIType.ptr, FFIType.f64], + returns: FFIType.bool + }, + setWindowTrafficLightsPosition: { + args: [FFIType.ptr, FFIType.f64, FFIType.f64], + returns: FFIType.bool + }, + setNativeWindowDragRegion: { + args: [FFIType.ptr, FFIType.f64, FFIType.f64], + returns: FFIType.bool + } + }) + } + } catch (error) { + console.warn( + `macOS vibrancy: failed to load native effects library (${dylibPath}):`, + error + ) + return null + } +} +function applyTrafficLightsPosition(lib, mainWindow, attempt = 0) { + const applied = lib.symbols.setWindowTrafficLightsPosition( + mainWindow.ptr, + MAC_TRAFFIC_LIGHT_OFFSET.x, + MAC_TRAFFIC_LIGHT_OFFSET.y + ) + + if (applied || attempt >= 10) { + return applied + } + + setTimeout(() => applyTrafficLightsPosition(lib, mainWindow, attempt + 1), 50) + return false +} + +export function applyMacOSWindowEffects(mainWindow) { + if (process.platform !== 'darwin' || !mainWindow?.ptr) { + return + } + + const loaded = loadMacWindowEffectsLibrary() + if (!loaded) { + return + } + + const { path: dylibPath, lib } = loaded + + try { const vibrancyEnabled = lib.symbols.enableWindowVibrancy( mainWindow.ptr, MAC_WINDOW_CORNER_RADIUS ) const shadowEnabled = lib.symbols.ensureWindowShadow(mainWindow.ptr) + const trafficLightsScheduled = applyTrafficLightsPosition(lib, mainWindow) mainWindow.on?.('resize', () => { lib.symbols.setWindowCornerRadius( mainWindow.ptr, MAC_WINDOW_CORNER_RADIUS ) + lib.symbols.setWindowTrafficLightsPosition( + mainWindow.ptr, + MAC_TRAFFIC_LIGHT_OFFSET.x, + MAC_TRAFFIC_LIGHT_OFFSET.y + ) }) console.log( - `macOS vibrancy applied (vibrancy=${vibrancyEnabled}, shadow=${shadowEnabled}, cornerRadius=${MAC_WINDOW_CORNER_RADIUS})` + `macOS vibrancy applied (dylib=${dylibPath}, vibrancy=${vibrancyEnabled}, shadow=${shadowEnabled}, trafficLights=${trafficLightsScheduled}, cornerRadius=${MAC_WINDOW_CORNER_RADIUS})` ) } catch (error) { console.warn( diff --git a/src/desktop/window.js b/src/desktop/window.js index 1e4f562..119dd5b 100644 --- a/src/desktop/window.js +++ b/src/desktop/window.js @@ -1,6 +1,9 @@ import Electrobun, { BrowserWindow, Updater, Utils } from 'electrobun/bun' import { applyApplicationMenu, setupApplicationMenuEvents } from './menu.js' -import { applyMacOSWindowEffects } from './macos-window-effects.js' +import { + applyMacOSWindowEffects, + MAC_TRAFFIC_LIGHT_OFFSET +} from './macos-window-effects.js' import { sendToRenderer, setMessageSender } from './notify.js' const isMacOS = process.platform === 'darwin' @@ -137,7 +140,9 @@ export async function createMainWindow(rpc) { url, rpc, titleBarStyle: 'hiddenInset', - ...(isMacOS ? { transparent: true } : {}), + ...(isMacOS + ? { transparent: true, trafficLightOffset: MAC_TRAFFIC_LIGHT_OFFSET } + : {}), frame: { width: 1200, height: 800,