diff --git a/electron/cli.mjs b/electron/cli.mjs index c207370c..1334f579 100644 --- a/electron/cli.mjs +++ b/electron/cli.mjs @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; import crossSpawn from 'cross-spawn'; import fs from 'node:fs'; import path from 'node:path'; @@ -102,6 +102,34 @@ function parseBuilderArgs(args) { return builderArgs; } +/** + * Patch the local Electron.app's Info.plist so the macOS dock shows + * "Dr. Claw" instead of "Electron" during development. Only runs on macOS; + * no-ops silently on other platforms. + */ +function patchElectronPlistForDev() { + if (process.platform !== 'darwin') return; + + const plistPath = path.join( + projectRoot, 'node_modules/electron/dist/Electron.app/Contents/Info.plist', + ); + if (!fs.existsSync(plistPath)) return; + + const productName = 'Dr. Claw'; + try { + for (const key of ['CFBundleName', 'CFBundleDisplayName']) { + execFileSync('/usr/libexec/PlistBuddy', ['-c', `Set :${key} ${productName}`, plistPath]); + } + } catch { + // PlistBuddy "Set" fails if the key doesn't exist — try "Add" instead. + try { + execFileSync('/usr/libexec/PlistBuddy', [ + '-c', `Add :CFBundleDisplayName string ${productName}`, plistPath, + ]); + } catch { /* best-effort */ } + } +} + async function main() { if (command === 'prepare') { await prepareElectronRuntime(); @@ -110,6 +138,7 @@ async function main() { if (command === 'dev') { await prepareNodeDevRuntime(); + patchElectronPlistForDev(); await run(npmBin(), ['run', 'build']); await run(npxBin(), ['electron', 'electron/main.mjs']); return; diff --git a/electron/main.mjs b/electron/main.mjs index aff4e274..478f65fc 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -738,7 +738,7 @@ function createWindow(baseUrl) { backgroundColor: '#0b1220', autoHideMenuBar: !isMac, icon: iconPath, - titleBarStyle: 'default', + titleBarStyle: isMac ? 'hiddenInset' : 'default', webPreferences: { contextIsolation: true, sandbox: true, @@ -847,11 +847,18 @@ function createWindow(baseUrl) { async function boot() { try { - const iconPath = path.join(resolveAppRoot(), 'build', 'icon.png'); - if (isMac && fs.existsSync(iconPath)) { - const dockIcon = nativeImage.createFromPath(iconPath); - if (!dockIcon.isEmpty()) { - app.dock.setIcon(dockIcon); + if (isMac) { + // In packaged builds the bundled .icns gets the macOS squircle mask + // automatically, so skip the runtime override. In dev mode there's no + // .app bundle, so set the dock icon from the pre-masked PNG. + if (!app.isPackaged) { + const dockIconPath = path.join(resolveAppRoot(), 'build', 'icon-dock.png'); + if (fs.existsSync(dockIconPath)) { + const dockIcon = nativeImage.createFromPath(dockIconPath); + if (!dockIcon.isEmpty()) { + app.dock.setIcon(dockIcon); + } + } } } diff --git a/scripts/build-electron-icons.mjs b/scripts/build-electron-icons.mjs index 9fb88601..720605b1 100644 --- a/scripts/build-electron-icons.mjs +++ b/scripts/build-electron-icons.mjs @@ -1,5 +1,7 @@ +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import process from 'node:process'; import sharp from 'sharp'; import pngToIco from 'png-to-ico'; @@ -7,34 +9,80 @@ const projectRoot = process.cwd(); const buildDir = path.join(projectRoot, 'build'); const iconsetDir = path.join(buildDir, 'icon.iconset'); const sourceIcon = path.join(projectRoot, 'public', 'dr-claw.png'); +const isMac = process.platform === 'darwin'; -const macIconSizes = [ - 16, 32, 64, 128, 256, 512, 1024, -]; - +// iconutil requires exactly these base sizes — each gets a @2x retina variant. +// 512@2x = 1024x1024, which is the largest size in the .iconset. +const macIconSizes = [16, 32, 128, 256, 512]; const winIconSizes = [16, 24, 32, 48, 64, 128, 256]; +// macOS squircle corner radius ≈ 22.37% of icon size (Apple HIG continuous curve). +const MAC_CORNER_RATIO = 0.2237; + +/** + * Create a rounded-rectangle (squircle) mask as an SVG buffer. + * When composited with `dest-in`, the source image is clipped to this shape. + */ +function squircleMask(size) { + const r = Math.round(size * MAC_CORNER_RATIO); + return Buffer.from( + ``, + ); +} + await fs.promises.rm(iconsetDir, { recursive: true, force: true }); await fs.promises.mkdir(iconsetDir, { recursive: true }); await fs.promises.mkdir(buildDir, { recursive: true }); +// --- macOS .iconset (raw squares — macOS applies its own mask to .icns) ------- for (const size of macIconSizes) { const baseName = `icon_${size}x${size}`; - const outputPath = path.join(iconsetDir, `${baseName}.png`); - await sharp(sourceIcon).resize(size, size).png().toFile(outputPath); + await sharp(sourceIcon).resize(size, size).png().toFile(path.join(iconsetDir, `${baseName}.png`)); + await sharp(sourceIcon).resize(size * 2, size * 2).png().toFile(path.join(iconsetDir, `${baseName}@2x.png`)); +} - if (size <= 512) { - const retinaPath = path.join(iconsetDir, `${baseName}@2x.png`); - await sharp(sourceIcon).resize(size * 2, size * 2).png().toFile(retinaPath); +// --- macOS .icns via iconutil (macOS only) ------------------------------------ +if (isMac) { + const icnsPath = path.join(buildDir, 'icon.icns'); + try { + execFileSync('iconutil', ['--convert', 'icns', iconsetDir, '--output', icnsPath]); + } catch (err) { + console.warn('iconutil failed (non-macOS host?) — skipping .icns generation:', err.message); } } +// --- Generic icon.png for BrowserWindow (Windows/Linux taskbar) --------------- await sharp(sourceIcon).resize(512, 512).png().toFile(path.join(buildDir, 'icon.png')); +// --- macOS dock icon with squircle corners (used by app.dock.setIcon) --------- +// Native macOS icons have ~18% padding around the artwork inside the dock tile. +// We resize the artwork to ~82% of the canvas and center it on a transparent +// background so the icon matches the visual weight of Finder, Safari, etc. +const dockCanvas = 512; +const dockArtwork = Math.round(dockCanvas * 0.82); +const dockOffset = Math.round((dockCanvas - dockArtwork) / 2); + +const artwork = await sharp(sourceIcon).resize(dockArtwork, dockArtwork).png().toBuffer(); +const maskedArtwork = await sharp(artwork) + .composite([{ input: squircleMask(dockArtwork), blend: 'dest-in' }]) + .png() + .toBuffer(); + +await sharp({ + create: { width: dockCanvas, height: dockCanvas, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, +}) + .composite([{ input: maskedArtwork, left: dockOffset, top: dockOffset }]) + .png() + .toFile(path.join(buildDir, 'icon-dock.png')); + +// --- Windows .ico ------------------------------------------------------------- const icoBuffer = await pngToIco( await Promise.all( winIconSizes.map(async (size) => sharp(sourceIcon).resize(size, size).png().toBuffer()), ), ); - await fs.promises.writeFile(path.join(buildDir, 'icon.ico'), icoBuffer); + +console.log('Electron icons built → build/{icon.png, icon-dock.png, icon.ico, icon.icns, icon.iconset/}'); diff --git a/src/components/main-content/view/subcomponents/MainContentHeader.tsx b/src/components/main-content/view/subcomponents/MainContentHeader.tsx index c0ec3490..8088bb68 100644 --- a/src/components/main-content/view/subcomponents/MainContentHeader.tsx +++ b/src/components/main-content/view/subcomponents/MainContentHeader.tsx @@ -13,9 +13,15 @@ export default function MainContentHeader({ onMenuClick, }: MainContentHeaderProps) { return ( -