Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion electron/cli.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand Down
19 changes: 13 additions & 6 deletions electron/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,7 @@ function createWindow(baseUrl) {
backgroundColor: '#0b1220',
autoHideMenuBar: !isMac,
icon: iconPath,
titleBarStyle: 'default',
titleBarStyle: isMac ? 'hiddenInset' : 'default',
webPreferences: {
contextIsolation: true,
sandbox: true,
Expand Down Expand Up @@ -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);
}
}
}
}

Expand Down
68 changes: 58 additions & 10 deletions scripts/build-electron-icons.mjs
Original file line number Diff line number Diff line change
@@ -1,40 +1,88 @@
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';

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(
`<svg width="${size}" height="${size}">` +
`<rect x="0" y="0" width="${size}" height="${size}" rx="${r}" ry="${r}" fill="white"/>` +
`</svg>`,
);
}

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/}');
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ export default function MainContentHeader({
onMenuClick,
}: MainContentHeaderProps) {
return (
<div className="bg-background border-b border-border/60 px-3 sm:px-4 pwa-header-safe flex-shrink-0">
/*
* electron-drag: makes the header bar a window drag handle on macOS.
* Interactive children (title area with potential buttons, tab switcher)
* use electron-no-drag to restore normal pointer events inside the drag
* region. The trailing empty flex-1 div stays draggable as dead space.
*/
<div className="bg-background border-b border-border/60 px-3 sm:px-4 pwa-header-safe flex-shrink-0 electron-drag">
<div className="flex items-center gap-3 py-1.5 sm:py-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0 flex-1 electron-no-drag">
{isMobile && <MobileMenuButton onMenuClick={onMenuClick} />}
<MainContentTitle
activeTab={activeTab}
Expand All @@ -25,7 +31,7 @@ export default function MainContentHeader({
/>
</div>

<div className="hidden sm:flex justify-center flex-1">
<div className="hidden sm:flex justify-center flex-1 electron-no-drag">
{selectedProject && activeTab !== 'dashboard' && activeTab !== 'trash' && (
<MainContentTabSwitcher
activeTab={activeTab}
Expand All @@ -35,6 +41,7 @@ export default function MainContentHeader({
)}
</div>

{/* Empty right spacer — stays as drag region */}
<div className="flex-1 hidden sm:block" />
</div>
</div>
Expand Down
12 changes: 8 additions & 4 deletions src/components/sidebar/view/subcomponents/SidebarCollapsed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,18 @@ export default function SidebarCollapsed({
onShowVersionModal,
t,
}: SidebarCollapsedProps) {
const isMacDesktop = typeof navigator !== 'undefined'
&& /Electron/.test(navigator.userAgent) && /Macintosh/.test(navigator.userAgent);

return (
<div
className="h-full flex flex-col items-center pt-3 pb-3 gap-1 bg-background/80 backdrop-blur-sm w-12"
className="h-full flex flex-col items-center pb-3 gap-1 bg-background/80 backdrop-blur-sm w-12 electron-drag"
style={{ paddingTop: isMacDesktop ? '32px' : '12px' }}
>
{/* Expand button with brand logo */}
<button
onClick={onExpand}
className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-accent/80 transition-colors group"
className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-accent/80 transition-colors group electron-no-drag"
aria-label={t('common:versionUpdate.ariaLabels.showSidebar')}
title={t('common:versionUpdate.ariaLabels.showSidebar')}
>
Expand All @@ -37,7 +41,7 @@ export default function SidebarCollapsed({
{/* Settings */}
<button
onClick={onShowSettings}
className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-accent/80 transition-colors group"
className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-accent/80 transition-colors group electron-no-drag"
aria-label={t('actions.settings')}
title={t('actions.settings')}
>
Expand All @@ -48,7 +52,7 @@ export default function SidebarCollapsed({
{updateAvailable && (
<button
onClick={onShowVersionModal}
className="relative w-8 h-8 rounded-lg flex items-center justify-center hover:bg-accent/80 transition-colors"
className="relative w-8 h-8 rounded-lg flex items-center justify-center hover:bg-accent/80 transition-colors electron-no-drag"
aria-label={t('common:versionUpdate.ariaLabels.updateAvailable')}
title={t('common:versionUpdate.ariaLabels.updateAvailable')}
>
Expand Down
42 changes: 28 additions & 14 deletions src/components/sidebar/view/subcomponents/SidebarHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,24 +55,38 @@ export default function SidebarHeader({
</div>
);

// UA detection — Chromium always injects "Electron/x.y.z" into the user
// agent regardless of preload/contextBridge, so this is unconditionally
// reliable. Module-level detection via window.isElectron can miss if the
// bundle evaluates before the preload wires contextBridge values.
const isDesktopApp = typeof navigator !== 'undefined' && /Electron/.test(navigator.userAgent);
const isMacDesktop = isDesktopApp && /Macintosh/.test(navigator.userAgent);

return (
<div className="flex-shrink-0">
{/* Desktop header */}
<div className="hidden md:block px-3 pt-3 pb-2">
<div className="flex items-center justify-between gap-2">
{IS_PLATFORM ? (
<a
href="https://github.com/OpenLAIR/dr-claw"
className="flex items-center gap-2.5 min-w-0 hover:opacity-80 transition-opacity"
title={t('tooltips.viewEnvironments')}
>
<LogoBlock />
</a>
) : (
<LogoBlock />
<div className="hidden md:block px-3 pt-3 pb-2 electron-drag">
<div
className="flex items-center gap-2"
style={{ paddingLeft: isMacDesktop ? '68px' : '0px' }}
>
{!isDesktopApp && (
IS_PLATFORM ? (
<a
href="https://github.com/OpenLAIR/dr-claw"
className="flex items-center gap-2.5 min-w-0 hover:opacity-80 transition-opacity electron-no-drag"
title={t('tooltips.viewEnvironments')}
>
<LogoBlock />
</a>
) : (
<div className="min-w-0">
<LogoBlock />
</div>
)
)}

<div className="flex items-center gap-0.5 flex-shrink-0">
<div className="flex items-center gap-0.5 flex-shrink-0 ml-auto electron-no-drag">
<Button
type="button"
variant="ghost"
Expand Down Expand Up @@ -113,7 +127,7 @@ export default function SidebarHeader({

{/* Search bar */}
{!isLoading && (
<div className="mt-2.5 space-y-2">
<div className="mt-2.5 space-y-2 electron-no-drag">
{projectsCount > 0 && (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground/50 pointer-events-none" />
Expand Down
18 changes: 17 additions & 1 deletion src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,7 @@
padding-bottom: env(safe-area-inset-bottom);
}

/* PWA specific header adjustments - uses CSS variables for consistency */
/* PWA specific header adjustments - uses CSS variables for consistency */
.pwa-header-safe {
padding-top: var(--header-base-padding);
}
Expand Down Expand Up @@ -905,3 +905,19 @@
transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
}

/* -----------------------------------------------------------------------
* Electron: custom title bar drag regions — placed OUTSIDE @layer so these
* rules are not subject to Tailwind cascade-layer priority.
* -webkit-app-region: drag → window drag handle
* -webkit-app-region: no-drag → interactive elements inside a drag region
* ----------------------------------------------------------------------- */
.electron-drag {
-webkit-app-region: drag;
app-region: drag;
}
.electron-no-drag {
-webkit-app-region: no-drag;
app-region: no-drag;
}

Loading