Skip to content

Commit 1416403

Browse files
committed
feat: implement window control IPC handlers and integrate custom title bar in the Shell layout
1 parent 3672137 commit 1416403

9 files changed

Lines changed: 377 additions & 10 deletions

File tree

src/main/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* @file src/main/index.ts
33
*
44
* @created 07.03.2026
5-
* @modified 07.03.2026
5+
* @modified 08.03.2026
66
*
77
* @author Christian Blank <christianblank91@protonmail.com>
88
* @copyright 2026
@@ -46,7 +46,7 @@ const createWindow = (): BrowserWindow => {
4646
minHeight: 600,
4747
show: false,
4848
autoHideMenuBar: true,
49-
titleBarStyle: 'default',
49+
titleBarStyle: 'hidden',
5050
webPreferences: {
5151
preload: join(import.meta.dirname, '../preload/index.mjs'),
5252
nodeIntegration: false,
@@ -62,6 +62,15 @@ const createWindow = (): BrowserWindow => {
6262
log.info('Main window displayed')
6363
})
6464

65+
// Notify the renderer whenever the window is maximized or restored so the
66+
// custom title bar can swap the maximize/restore button icon accordingly.
67+
win.on('maximize', () => {
68+
win.webContents.send('window:maximize-changed', { isMaximized: true })
69+
})
70+
win.on('unmaximize', () => {
71+
win.webContents.send('window:maximize-changed', { isMaximized: false })
72+
})
73+
6574
// Open external links in the system browser rather than a new Electron window
6675
win.webContents.setWindowOpenHandler((details) => {
6776
void shell.openExternal(details.url)

src/main/ipc/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { registerBackupsIpc } from './backups.ipc'
2929
import { registerUpdaterIpc } from './updater.ipc'
3030
import { registerDialogIpc } from './dialog.ipc'
3131
import { registerAppIpc } from './app.ipc'
32+
import { registerWindowIpc } from './window.ipc'
3233

3334
/**
3435
* Registers all IPC handlers for every implemented domain.
@@ -50,5 +51,6 @@ export const registerIpcHandlers = (): void => {
5051
registerUpdaterIpc()
5152
registerDialogIpc()
5253
registerAppIpc()
54+
registerWindowIpc()
5355
log.info('[ipc] all handlers registered')
5456
}

src/main/ipc/window.ipc.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* @file src/main/ipc/window.ipc.ts
3+
*
4+
* @created 08.03.2026
5+
* @modified 08.03.2026
6+
*
7+
* @author Christian Blank <christianblank91@protonmail.com>
8+
* @copyright 2026
9+
*
10+
* @description IPC handlers for native window controls (minimize, maximize,
11+
* close). Called from the custom title bar in the renderer because
12+
* `titleBarStyle: 'hidden'` removes the OS-native control buttons.
13+
*/
14+
15+
import { ipcMain, BrowserWindow } from 'electron'
16+
17+
/**
18+
* Registers IPC handlers that drive the three native window control actions:
19+
* minimize, toggle-maximize, and close. Each handler resolves the sender's
20+
* window from the event's WebContents so that multiple windows are handled
21+
* correctly if they ever coexist.
22+
*/
23+
export const registerWindowIpc = (): void => {
24+
ipcMain.handle('window:minimize', (event): void => {
25+
BrowserWindow.fromWebContents(event.sender)?.minimize()
26+
})
27+
28+
ipcMain.handle('window:maximize', (event): void => {
29+
const win = BrowserWindow.fromWebContents(event.sender)
30+
if (!win) return
31+
if (win.isMaximized()) {
32+
win.unmaximize()
33+
} else {
34+
win.maximize()
35+
}
36+
})
37+
38+
ipcMain.handle('window:close', (event): void => {
39+
BrowserWindow.fromWebContents(event.sender)?.close()
40+
})
41+
}

src/preload/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import type {
4444
RegistryServer,
4545
TestResult,
4646
BackupEntry,
47+
WindowMaximizeChangedPayload,
4748
} from '../shared/channels'
4849

4950
/**
@@ -562,6 +563,23 @@ const api = {
562563
*/
563564
updaterInstall: (): Promise<void> => ipcRenderer.invoke('updater:install'),
564565

566+
// ── Window controls ───────────────────────────────────────────────────────
567+
568+
/**
569+
* Minimizes the application window.
570+
*/
571+
windowMinimize: (): Promise<void> => ipcRenderer.invoke('window:minimize'),
572+
573+
/**
574+
* Toggles the window between maximized and restored states.
575+
*/
576+
windowMaximize: (): Promise<void> => ipcRenderer.invoke('window:maximize'),
577+
578+
/**
579+
* Closes the application window.
580+
*/
581+
windowClose: (): Promise<void> => ipcRenderer.invoke('window:close'),
582+
565583
// ── Push events (main → renderer) ─────────────────────────────────────────
566584

567585
/**
@@ -626,6 +644,23 @@ const api = {
626644
ipcRenderer.on('updater:update-downloaded', wrapped)
627645
return () => ipcRenderer.removeListener('updater:update-downloaded', wrapped)
628646
},
647+
648+
/**
649+
* Registers a handler called whenever the window is maximized or restored.
650+
* The title bar uses this to toggle the maximize/restore button icon.
651+
* Returns a cleanup function that removes the listener.
652+
*
653+
* @param handler - Callback receiving the new maximize state.
654+
* @returns A cleanup function that removes the listener.
655+
*/
656+
onMaximizeChanged: (handler: (payload: WindowMaximizeChangedPayload) => void): (() => void) => {
657+
const wrapped = (
658+
_event: Parameters<Parameters<typeof ipcRenderer.on>[1]>[0],
659+
payload: WindowMaximizeChangedPayload,
660+
) => handler(payload)
661+
ipcRenderer.on('window:maximize-changed', wrapped)
662+
return () => ipcRenderer.removeListener('window:maximize-changed', wrapped)
663+
},
629664
} as const
630665

631666
// Expose the typed bridge to the renderer process

src/renderer/components/layout/Shell.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* @file src/renderer/components/layout/Shell.tsx
33
*
44
* @created 07.03.2026
5-
* @modified 07.03.2026
5+
* @modified 08.03.2026
66
*
77
* @author Christian Blank <christianblank91@protonmail.com>
88
* @copyright 2026
@@ -14,17 +14,25 @@
1414

1515
import { Outlet } from '@tanstack/react-router'
1616
import { Sidebar } from './Sidebar'
17+
import { TitleBar } from './TitleBar'
1718

1819
/**
19-
* Full-window layout with sidebar on the left and the active page content
20-
* on the right. The `<Outlet />` renders whichever route is currently active.
20+
* Full-window layout with a custom title bar on top, then sidebar on the left
21+
* and the active page content on the right. The `<Outlet />` renders whichever
22+
* route is currently active.
2123
*/
2224
const Shell = () => (
23-
<div className="flex h-screen overflow-hidden bg-background text-foreground" data-testid="shell">
24-
<Sidebar />
25-
<main className="flex-1 overflow-y-auto p-6" role="main" id="main-content">
26-
<Outlet />
27-
</main>
25+
<div
26+
className="flex flex-col h-screen overflow-hidden bg-background text-foreground"
27+
data-testid="shell"
28+
>
29+
<TitleBar />
30+
<div className="flex flex-1 overflow-hidden">
31+
<Sidebar />
32+
<main className="flex-1 overflow-y-auto p-6" role="main" id="main-content">
33+
<Outlet />
34+
</main>
35+
</div>
2836
</div>
2937
)
3038

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* @file src/renderer/components/layout/TitleBar.tsx
3+
*
4+
* @created 08.03.2026
5+
* @modified 08.03.2026
6+
*
7+
* @author Christian Blank <christianblank91@protonmail.com>
8+
* @copyright 2026
9+
*
10+
* @description Custom Electron title bar rendered in the renderer process.
11+
* Replaces the OS-native title bar (hidden via `titleBarStyle: 'hidden'`).
12+
* Provides an app label, a full-width drag region, and Win32-style window
13+
* control buttons (minimize, maximize/restore, close) that delegate to the
14+
* main process via IPC.
15+
*/
16+
17+
import { useEffect, useState } from 'react'
18+
import { Minus, Square, Copy, X } from 'lucide-react'
19+
import '../../lib/electron.d'
20+
21+
/**
22+
* Renders the custom application title bar with a drag region and window
23+
* control buttons. Subscribes to the `window:maximize-changed` push event
24+
* so the maximize/restore icon stays in sync with the actual window state.
25+
*/
26+
const TitleBar = () => {
27+
const [isMaximized, setIsMaximized] = useState(false)
28+
29+
useEffect(() => {
30+
const cleanup = window.api.onMaximizeChanged(({ isMaximized: maximized }) => {
31+
setIsMaximized(maximized)
32+
})
33+
return cleanup
34+
}, [])
35+
36+
return (
37+
<header
38+
className="flex h-8 shrink-0 items-center justify-between bg-background border-b select-none"
39+
style={{ WebkitAppRegion: 'drag' }}
40+
data-testid="title-bar"
41+
>
42+
<span className="pl-3 text-xs text-muted-foreground" data-testid="title-bar-label">
43+
aidrelay
44+
</span>
45+
46+
<div
47+
className="flex h-full"
48+
style={{ WebkitAppRegion: 'no-drag' }}
49+
data-testid="title-bar-controls"
50+
>
51+
{/* Minimize */}
52+
<button
53+
type="button"
54+
aria-label="Minimize window"
55+
className="flex w-12 h-full items-center justify-center text-muted-foreground hover:bg-muted transition-colors"
56+
onClick={() => void window.api.windowMinimize()}
57+
data-testid="title-bar-minimize"
58+
>
59+
<Minus size={14} />
60+
</button>
61+
62+
{/* Maximize / Restore */}
63+
<button
64+
type="button"
65+
aria-label={isMaximized ? 'Restore window' : 'Maximize window'}
66+
className="flex w-12 h-full items-center justify-center text-muted-foreground hover:bg-muted transition-colors"
67+
onClick={() => void window.api.windowMaximize()}
68+
data-testid="title-bar-maximize"
69+
>
70+
{isMaximized ? <Copy size={14} /> : <Square size={14} />}
71+
</button>
72+
73+
{/* Close */}
74+
<button
75+
type="button"
76+
aria-label="Close window"
77+
className="flex w-12 h-full items-center justify-center text-muted-foreground hover:bg-destructive hover:text-destructive-foreground transition-colors"
78+
onClick={() => void window.api.windowClose()}
79+
data-testid="title-bar-close"
80+
>
81+
<X size={14} />
82+
</button>
83+
</div>
84+
</header>
85+
)
86+
}
87+
88+
export { TitleBar }

0 commit comments

Comments
 (0)