Every Electron side project starts with the same three weeks.
You wire up window controls, and they look wrong next to the OS chrome. You build a theme system and half your components don't follow it. You store the user's API key in a plaintext JSON file because you'll "encrypt it later". And the auto-updater — the thing your users actually touch — gets bolted on in a panic the day before release.
Then the app you meant to build gets whatever time is left.
I got tired of writing that same shell. So I turned it into a framework: electron-shell-framework — a reusable Electron app shell that is a platform, not an app. You build a new desktop app (chat client, dashboard, internal tool) by dropping in pages. You never rewrite the shell.
What "app shell" means, concretely
Here's the actual chrome you inherit on day one:
┌──────────┬──────────────────────────────────────────────┬──────────┐
│ │ [tabs] Dashboard | Settings | Communication │ │
│ Left ├──────────────────────────────────────────────┤ Right │
│ Sidebar │ │ Panel │
│ (nav, │ Content — the active page │ (notif, │
│ collapse)│ │ log) │
└──────────┴──────────────────────────────────────────────┴──────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ FooterBar — version · platform · app name │
└──────────────────────────────────────────────────────────────────────┘
-
One top bar, not two. The tab strip and the window controls (min/max/close at 60% native opacity) share a single 40px bar. A toggle collapses the tabs into a slim strip that keeps the active page name. The bar stays draggable (
app-drag); interactive regions opt out (app-no-drag). - Left sidebar — collapsible, drag-resizable (150–320px), profile + settings in the footer.
- Right panel — page-scoped: each page can declare its own inspector, with notifications / activity-log views as the default. Collapses to an arrow-only rail.
- Bottom panel — terminal-style strip, collapsed by default.
- Footer bar — full-width status frame.
- Dark/light theme — both sidebars included, persisted across launches.
The page registry is the whole extension point
Every screen is a PageDefinition:
// src/renderer/src/types/pages.ts
import type { ComponentType, CSSProperties } from 'react'
export interface PageDefinition {
id: string
label: string
description?: string
/** Rendered in the left sidebar (collapsed mode shows icon only). */
icon: ComponentType<{ className?: string; size?: number | string; style?: CSSProperties }>
component: ComponentType
/** Optional right-panel view for this page (chat inspectors, detail panes…) */
rightPanel?: ComponentType
/** Groups this page under a category in the tab bar (rendered as a sub-tab). */
category?: string
/** When false the page hides from the sidebar (e.g. settings). */
showInSidebar?: boolean
}
Register it once and the shell does the rest — sidebar icon, tab, content area, optional right panel:
// src/renderer/src/pages/registry.tsx
export const PAGES: PageDefinition[] = [
{
id: 'dashboard',
label: 'Dashboard',
description: 'KPI grid + activity',
icon: BarChart3,
component: DashboardPage
},
{
id: 'chat',
label: 'Chat',
category: 'Communication',
icon: MessageSquare,
component: ChatPage
},
// …
]
A new app is five steps: clone → write MyPage.tsx → add an entry to the array → rename → npm run dev. No shell edits, ever. Pages that share a category get grouped into a sub-tab cluster in the top bar — that's how you scale from 4 pages to 15 without tab soup.
Config that isn't a plaintext JSON file
Most Electron tutorials store settings with JSON.stringify and ship it. This shell routes every value through safeStorage — DPAPI on Windows, Keychain on macOS — so nothing hits disk unencrypted:
// src/main/config-store.ts (excerpt)
private encode(value: ConfigValue): string {
const serialized = JSON.stringify(value)
if (this.load().encrypted && safeStorage.isEncryptionAvailable()) {
return `enc:${safeStorage.encryptString(serialized).toString('base64')}`
}
return `raw:${Buffer.from(serialized, 'utf-8').toString('base64')}`
}
Each entry is stored as enc:… or raw:…, so the file is self-describing and degrades gracefully on machines without a keyring. The renderer gets config.get / config.set / config.has over IPC — it never sees a filesystem path.
The IPC contract: one door, one shape
Renderer ↔ main communication goes through window.api only. sandbox: true, contextIsolation: true, nodeIntegration: false — and no raw ipcRenderer passthrough, so a compromised renderer cannot invoke an arbitrary channel.
| API | Channel | Purpose |
|---|---|---|
window.api.config.get/set/has(key) |
config:* |
Encrypted config store |
window.api.app.version()/ping() |
app:* |
Version + platform health check |
window.api.window.minimize/maximize/close() |
window:* |
Frameless window controls |
window.api.window.setOpacity(v) |
window:setOpacity |
Native window opacity (persisted) |
window.api.update.check()/quitAndInstall() |
update:* |
Auto-update (GitHub releases) |
window.api.update.onStatus(fn) |
push update:status
|
Live update events → Settings UI |
Two files define the contract — src/main/ipc.ts and src/preload/index.ts — and a future backend (HTTP, WebSocket, DB) hooks in right there without the shell ever changing.
shell-cli: one command from clone to installer
I hate READMEs that are really 14 manual steps. So the repo ships a zero-dependency CLI — Node stdlib only, so it runs before you've even npm installed:
node scripts/shell-cli.js install
— Step 1/6: Preflight (Node ≥ 20, npm, git)
— Step 2/6: Install dependencies
— Step 3/6: Sanity checks (typecheck, lint, unit tests)
— Step 4/6: Build
— Step 5/6: Run
— Step 6/6: Package (optional — pass --package)
check, dev, build, run, test, package and help map to what you'd expect, and install runs the full flow: preflight → deps → checks → build → run → package.
Packaging and updates, wired from commit one
-
npm run dist:win→ an NSIS installer (custom install directory, desktop + start-menu shortcuts) and a portable exe, inrelease/<version>/. - Auto-update through
electron-updater+ the GitHubpublishprovider. TagvX.Y.Z, push, and packaged installs pick it up from Settings → Updates, with live status events streamed to the UI:checking → available → downloading (%) → downloaded. -
.github/workflows/release.ymlbuilds and publishes on Windows, macOS and Linux from the same tag.
Quality gates included (the part that saves you)
- Vitest unit tests for the stores, theme helpers and the registry.
- Playwright e2e that launches the real Electron app and asserts the chrome exists and works — including this wonderfully pedantic one:
test('window controls are pinned to the right edge', async () => {
const closeBox = await page.getByLabel('Close', { exact: true }).boundingBox()
const innerWidth: number = await page.evaluate(() => window.innerWidth)
expect(Math.abs(closeBox!.x + closeBox!.width - innerWidth)).toBeLessThanOrEqual(2)
})
Because "no trailing pixel gap" is exactly the kind of thing you otherwise fix by hand 400 times.
- ESLint + Prettier + Husky / lint-staged on pre-commit.
Theming: restyle variables, not components
All colors live as CSS variables in src/renderer/src/styles/theme.css and are mapped into Tailwind v4 via @theme inline, so utilities like bg-card, border-input, text-muted-foreground resolve to your tokens — including the separate --sidebar-*, --tab-* and --rightpanel-* surface tokens most templates forget about.
To brand an app: change the variables. Every component, both themes, all four panels follow automatically.
What I got wrong the first time (an honest security pass)
Before calling this v0.1 I ran it against Electron's official security checklist and wrote every gap down in docs/framework-security-report.md instead of pretending it wasn't there.
Already right: sandboxed preload exposing only a namespaced API, CSP present, setWindowOpenHandler denies popups, safeStorage-encrypted config, frameless surface.
Still on me before v0.2: flip the @electron/fuses switches (RunAsNode: false, EnableNodeOptionsEnvironmentVariable: false, ASAR integrity validation), add sender validation on every ipcMain handler (main-frame check + argument validation), block will-navigate, allowlist shell.openExternal targets, move the packaged renderer off file:// to a privileged app:// protocol, and tighten the production CSP — that dev-only ws://localhost:* in connect-src should never ship.
If you're building an Electron app right now, that list is worth stealing even if you skip the framework.
How is this different from a starter template?
- Starters give you a build config and a blank
App.tsx. This gives you the chrome: four panels, one merged top bar, a resizable layout engine, and a registry that turns a page into a tab + sidebar entry + right panel. - Starters stop at
npm run build. Here the release path is already wired: NSIS + portable, GitHub releases, in-app update status UI. - The security work is documented, not implied — an explicit checklist with the remaining gaps listed as tasks, so you know what you're inheriting.
What it is not (v1 scope, honestly)
- No backend, no server, no web attach — in production the renderer loads from
file://. Pure desktop. - No dynamic/closeable tabs, no splash screen, no i18n yet — those are on the roadmap.
- It's a shell, not a UI kit. You get the primitives (Radix-based shadcn-style components, a recharts chart block, a zod + react-hook-form block), but your app's soul is your pages.
Try it
git clone https://github.com/hlsitechio/electron-shell-framework
cd electron-shell-framework
node scripts/shell-cli.js install # preflight → deps → checks → build → run
Under the hood: Electron 44 · electron-vite 5 · electron-builder 26 · React 19 · TypeScript 5.9 · Tailwind CSS v4 · Radix primitives · zustand · zod + react-hook-form · recharts · Vitest + Playwright.
If you build something on it — or you have a "this is missing from every Electron template" item I should add to the roadmap — tell me in the comments. Issues and PRs are welcome.
Top comments (1)
Use it, Share It, modify it ... Please give it a like on my Github ❤️
Thank you !
Any recommandation to add ? Feel free to leave me a comments .. I will upgrade it for potentialy more themes ... widgets ... etc ...