feat(electron): add desktop wrapper packaging Vojo as Windows zip with privileged vojo:// scheme, HashRouter override and native chrome

This commit is contained in:
heaven 2026-05-18 01:50:16 +03:00
parent 5dbe83aa9d
commit b26340fa7d
14 changed files with 3217 additions and 14 deletions

18
.gitignore vendored
View file

@ -4,14 +4,24 @@ node_modules
devAssets devAssets
config.local.json config.local.json
electron/dist-electron
release
.DS_Store .DS_Store
.idea .idea
.vscode .vscode/*
!.vscode/tasks.json
.codex .codex
.claude .claude
docs/ai/desired_features.md
docs/ai/bugs.md
docs/plans docs/plans
docs docs/design
docs/ai/*
!docs/ai/README.md
!docs/ai/android.md
!docs/ai/architecture.md
!docs/ai/electron.md
!docs/ai/i18n.md
!docs/ai/overview.md
!docs/ai/server-side.md
vite.config.*.timestamp-*.mjs vite.config.*.timestamp-*.mjs

104
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,104 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Deploy to vojo.chat",
"type": "shell",
"command": "npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/cinny/",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Deploy widgets",
"type": "shell",
"command": "(cd apps/widget-telegram && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/telegram/) & PID1=$!; (cd apps/widget-discord && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/discord/) & PID2=$!; (cd apps/widget-whatsapp && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/whatsapp/) & PID3=$!; FAIL=0; wait $PID1 || FAIL=1; wait $PID2 || FAIL=1; wait $PID3 || FAIL=1; exit $FAIL",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Build Android APK",
"type": "shell",
"command": "npm run build:android:debug",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Deploy to Android (ADB)",
"type": "shell",
"command": "npm run build:android:debug && adb install -r android/app/build/outputs/apk/debug/app-debug.apk",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Connect to Android device (ADB)",
"type": "shell",
"command": "adb connect 192.168.1.204:5555",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Start Electron (dev)",
"type": "shell",
"command": "npm run electron:dev",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Build Electron Windows",
"type": "shell",
"command": "npm run build:electron:win",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Deploy Discord bridge",
"type": "shell",
"command": "docker build -t vojo-mautrix-discord:custom . && docker save vojo-mautrix-discord:custom | gzip | ssh vojo-superuser@187.127.77.124 'gunzip | docker load'",
"options": {
"cwd": "${workspaceFolder}/../vojo-mautrix-discord"
},
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"showReuseMessage": false
},
"problemMatcher": []
}
]
}

View file

@ -19,6 +19,7 @@ Any agent (Claude Code, Cursor, Codex, Windsurf, Cline, Copilot, Aider, …) wor
| [architecture.md](architecture.md) | Stack, source layout, routing, features, state management, Matrix SDK patterns, git workflow | | [architecture.md](architecture.md) | Stack, source layout, routing, features, state management, Matrix SDK patterns, git workflow |
| [i18n.md](i18n.md) | i18next setup, translation patterns, Russian-language quality standards, localization progress | | [i18n.md](i18n.md) | i18next setup, translation patterns, Russian-language quality standards, localization progress |
| [android.md](android.md) | Capacitor wrapper, Android build chain, edge-to-edge, Service Worker invariants, ADB workflow | | [android.md](android.md) | Capacitor wrapper, Android build chain, edge-to-edge, Service Worker invariants, ADB workflow |
| [electron.md](electron.md) | Electron desktop wrapper, privileged `vojo://` scheme for SW, build chain, IPC security, Windows distribution |
| [bugs.md](bugs.md) | Known bugs & regressions | | [bugs.md](bugs.md) | Known bugs & regressions |
| [server-side.md](server-side.md) | Some configs that deployd on server | | [server-side.md](server-side.md) | Some configs that deployd on server |

235
docs/ai/electron.md Normal file
View file

@ -0,0 +1,235 @@
# Electron Desktop
Vojo as a native desktop app (Windows .exe first, macOS/Linux later) via
**Electron** wrapping the same Vite `dist/` that web/Capacitor consume.
## Why not Tauri
Tauri 2 uses the system WebView (WebView2 on Windows). Service Worker
registration on custom schemes is **«won't fix»** per Tauri's own maintainer
([tauri#13031](https://github.com/tauri-apps/tauri/issues/13031), Aug 2025).
Vojo's SW is load-bearing for authenticated Matrix media (MSC3916). The
official Tauri workaround (`tauri-plugin-localhost`) is itself flagged in
Tauri's docs as «considerable security risks» — exposes a local HTTP port,
any process on the user's machine can hit it. Unacceptable for a Matrix
client storing E2EE keys.
Electron bundles its own Chromium, so SW works as in Chrome after
`protocol.registerSchemesAsPrivileged({ allowServiceWorkers: true, ... })`.
Element Desktop uses the same **privileged-scheme** mechanism but with a
different media-auth strategy: their scheme privileges set is just
`{ standard, secure, supportFetchAPI }` (no `allowServiceWorkers`), and they
inject the `Authorization` header for Matrix media via
`session.defaultSession.webRequest.onBeforeSendHeaders` — Service Workers
aren't load-bearing for them. Vojo keeps the SW because that's how the web
build authenticates media; re-implementing the auth in a main-process hook
just for desktop would diverge renderer code paths. Our privilege set is a
superset of Element's by design (also Matrix, also AGPL — still our
architectural reference for the wider Electron shell).
## Source layout
```
electron/
├── main.ts # main process — window, privileged scheme, IPC
├── preload.ts # contextBridge: window.vojoElectron API
├── tsconfig.json # CJS output, Node target — separate from src/
└── dist-electron/ # tsc output (gitignored)
└── main.js, preload.js # generated
src/app/utils/electron.ts # renderer-side: isElectron(), openExternalUrl(), setupExternalLinkHandler()
electron-builder.json # packaging config (NSIS for Windows)
release/ # electron-builder output (gitignored)
```
## Build chain
```bash
npm run electron:typecheck # tsc --noEmit -p electron/tsconfig.json
npm run electron:build # tsc → electron/dist-electron/*.js (+ package.json override)
npm run electron:dev # vite + electron in parallel (concurrently + wait-on)
npm run electron:start # electron only — DEV mode (loads localhost:8080)
npm run electron:start:prod # electron only — PROD mode (loads vojo://, requires npm run build first)
npm run build:electron:win # native build: vite build → electron:build → electron-builder --win
# ONLY works on Windows host (or WSL with Wine installed)
npm run build:electron:win:docker # cross-build from Linux/WSL via electronuserland/builder:wine
# Docker image ~3GB on first run; output in release/
```
### M1 vs M2 mode toggle
`isDev` in [`electron/main.ts`](../../electron/main.ts) is:
```ts
const isDev = !app.isPackaged && process.env.VOJO_ELECTRON_PROD !== '1';
```
- **Packaged binary** (`.exe`/`.dmg`/`.AppImage`) → `isDev = false` always
- **Unpackaged, dev**: `electron:dev` / `electron:start` → loads `http://localhost:8080`
- **Unpackaged, prod-mode test** (`electron:start:prod`) → loads `vojo://app/index.html`
The prod-mode env override exists so M2 (verifying the privileged scheme + service worker actually register) can be tested locally **without** running `electron-builder` for every change. The packaged binary uses the same code path.
### Cross-building Windows .exe from Linux/WSL
`build:electron:win:docker` runs the build inside
`electronuserland/builder:wine-mono` — the official Wine-based image.
**`electron-builder.json::win.signAndEditExecutable = false` is required**
for this cross-build to finish. Without it, electron-builder invokes
`rcedit.exe` through Wine to stamp `FileDescription`/`ProductName`/version
metadata onto the bundled `Vojo.exe`. The Wine docker images
(`:wine`, `:wine-mono`) ship **without Xvfb**, so rcedit hangs forever
trying to create a Win32 window for COM apartment init — see
[electron-userland/electron-builder#6191](https://github.com/electron-userland/electron-builder/issues/6191).
Cost of the workaround: the `.exe` Properties dialog on Windows shows
generic «Electron 42.1.0» metadata instead of «Vojo». Cosmetic only;
the binary itself runs correctly. Revisit when CI moves to a real
Windows runner (M3 GitHub Actions), where `signAndEditExecutable` can
flip back to `true`. Three host caches are mounted in to speed up subsequent builds:
- `~/.cache/electron` — Electron runtime download cache (~150MB)
- `~/.cache/electron-builder` — NSIS / app-update binaries
- `${PWD}` — project source (read-write, output goes to `release/`)
This is the workflow we use locally on WSL because Wine isn't installed natively. The same artifact is produced as a native Windows build. CI (M3, future) will run `electron-builder --win` directly on `windows-latest`.
`electron:build` writes `electron/dist-electron/package.json` with
`{"type":"commonjs"}` to override the root `"type":"module"` for the
compiled `.js` files. Required because Electron's main process loader
expects CJS unless you opt into ESM (which has separate pitfalls).
## Custom protocol — load-bearing
In production, the renderer is loaded from `vojo://app/` (trailing slash,
NOT `vojo://app/index.html` — that was the original choice and produced a
«Join index.html» screen because React Router parsed the `index.html`
segment as a space alias). The `vojo` scheme is registered as privileged
BEFORE `app.whenReady()` with `allowServiceWorkers: true`, `secure: true`,
`standard: true`, `supportFetchAPI: true`, `corsEnabled: true`,
`stream: true`, `codeCache: true`. This is the **one** thing that makes
the Vojo SW work in the packaged build. **Do not change
`loadURL(vojo://...)` to `loadFile(...)`** — SW will silently fail to
register.
`protocol.handle('vojo', ...)` maps `vojo://app/<path>` → file lookup
inside the packaged `dist/`. Two guards:
1. **Host check**: only `vojo://app` is accepted; any other host returns
403. Otherwise `vojo://evil/...` would resolve into a separate
(cookie/SW/IndexedDB-isolated) copy of the bundle with detached
storage — same content but split-brain state.
2. **Path-traversal guard**: `path.relative(distDir, filePath)` is checked
for `..`-prefix or absolute output — the canonical Node check.
`filePath.startsWith(distDir)` is **insufficient** (`/a/dist_evil` is a
prefix-match for `/a/dist`).
## IPC — security stance
- `contextIsolation: true`, `nodeIntegration: false` — Electron defaults.
- `preload.ts` exposes a minimal API: `platform` (string), `openExternal(url)`.
- `ipcMain.handle('vojo:open-external')` validates url scheme against an
allowlist (`http:`, `https:`, `mailto:`) and length (≤ 8KB) before calling
`shell.openExternal`. Don't widen the allowlist without thinking — e.g.
`file:` would let the renderer ask the OS to open arbitrary files.
- `setWindowOpenHandler` denies all `window.open()` and routes safe URLs
through `shell.openExternal`.
- `will-navigate` intercepts top-frame navigation: only `vojo://` (prod) and
`http://localhost:8080` (dev) are internal; everything else opens
externally.
## Push notifications — different model from Android
Android uses FCM + foreground service + Sygnal. **Electron does NOT use this
path.** Desktop model = «always running app»: matrix-js-sdk sync stays open
while the app is launched (Discord/Slack/Element Desktop pattern), and timeline
events fan out to `new Notification(...)` directly. Closed app = no
notifications. This is intentional and matches user expectations for desktop
Matrix clients.
The existing [`usePushNotifications.ts`](../../src/app/hooks/usePushNotifications.ts)
VAPID/Web-Push flow is web/PWA-only on this branch — Electron path is to
be added in **M4** of [`docs/plans/electron_desktop.md`](../plans/electron_desktop.md).
## Window chrome — Windows Controls Overlay
On Windows the `BrowserWindow` uses `titleBarStyle: 'hidden'` plus
`titleBarOverlay` to remove the native title bar but **keep real OS
min/max/close buttons** (drawn by Windows itself in the top-right
corner, with their native hover/snap/accessibility behavior). The
remaining bar area takes our Dawn-dark palette (`#0d0e11` /
`#e0e0e8` symbol color) so it blends with the renderer rather than
clashing with the system accent. Same pattern as Discord, Slack,
VS Code, Element Desktop.
macOS gets `titleBarStyle: 'hiddenInset'` (traffic lights inset
over a clean dark area). Linux keeps the system frame to avoid
breaking GTK/KWin window decorations.
**Caveat**: the 32px overlay area floats ON TOP of the renderer's
pixels (the renderer's content extends to `y=0`). If you put critical
UI in the top-right 100px, it visually overlaps the buttons. The
buttons remain clickable regardless, but content may look obscured.
Vojo's current SidebarNav (66px wide on the left) and PageRoot
content don't have anything load-bearing in the top-right strip, so
this is fine — verify before adding new top-bar widgets there.
**Drag region** — Electron does NOT make the hidden-titlebar area
draggable automatically. `main.ts` injects CSS via
`webContents.insertCSS` on `dom-ready` that adds a `body::before`
overlay with `-webkit-app-region: drag` sized by the Window Controls
Overlay API `env(titlebar-area-*)` variables. Body gets matching
`padding-top: env(titlebar-area-height, 32px)` so renderer content
shifts down instead of sitting under the drag strip. If new Vojo UI
ever puts elements at `top: 0` with `position: fixed`, they'll need
explicit `top: env(titlebar-area-height, 32px)` to clear the drag
region — body padding doesn't shift fixed children.
**Theme switching**: `titleBarOverlay.color` is currently hardcoded
dark. When light theme is fully wired up (see `architecture.md`
«Known follow-ups for light theme»), call
`win.setTitleBarOverlay({color, symbolColor})` from a theme-change
IPC to keep the bar in sync. Tracked as a follow-up, not implemented
in M2.
## Renderer-side platform detection
[`src/app/utils/electron.ts`](../../src/app/utils/electron.ts) mirrors the
shape of [`capacitor.ts`](../../src/app/utils/capacitor.ts): `isElectron()`,
`openExternalUrl(url)`, `setupExternalLinkHandler()`. The handlers from both
files are called in [`src/index.tsx`](../../src/index.tsx) at boot — each is
a no-op on the other platform.
Do **not** unify them into a single `platform.ts` — Capacitor's wrapper
imports `@capacitor/browser` at top level and runs in WebView even in dev;
Electron's wrapper has no peer dependency and detects via
`window.vojoElectron`.
## Build doesn't ship Android stuff
`electron-builder.json` `files` field includes ONLY `dist/**`,
`electron/dist-electron/**`, and `package.json`. The `android/` directory
is NOT in the asar — keeps the .exe lean and avoids accidentally shipping
keystore paths or build artifacts.
## Known caveats
- **`__dirname` in CJS output points to `electron/dist-electron/`** — that's
why `distDir = path.resolve(__dirname, '..', '..', 'dist')` walks up two
levels. If you change `outDir` in `electron/tsconfig.json`, retune this
path.
- **`sandbox: true` is on.** Preload uses only `contextBridge` and
`ipcRenderer`, both of which are sandbox-compatible per
[Electron sandbox docs](https://www.electronjs.org/docs/latest/tutorial/sandbox).
Don't add `fs`/`path`/`child_process` to preload — that requires
`sandbox: false` and weakens isolation. If you ever do, document why.
- **No code-signing in M0..M3.** SmartScreen on Windows shows «Windows
protected your PC» dialog; user clicks «More info → Run anyway». Drop-off
exists but acceptable for alpha. **OV cert (~$65/yr) does NOT bypass
SmartScreen instantly** — reputation accrues over ~thousands of installs
(weeksmonths). **EV cert (~$250-400/yr + hardware token / cloud HSM)
gives instant SmartScreen pass.** Plan accordingly: OV is a trap for
app-launch UX; either accept unsigned + click-through, or budget EV.
- **No auto-updater in M0..M3.** `electron-updater` requires a signed
build for differential updates; revisit when signing is in place.

25
electron-builder.json Normal file
View file

@ -0,0 +1,25 @@
{
"appId": "chat.vojo.desktop",
"productName": "Vojo",
"asar": true,
"directories": {
"output": "release"
},
"files": ["dist/**/*", "electron/dist-electron/**/*", "package.json"],
"extraMetadata": {
"main": "electron/dist-electron/main.js"
},
"win": {
"target": ["zip"],
"artifactName": "Vojo-${version}-win-${arch}.${ext}",
"signAndEditExecutable": false
},
"mac": {
"target": ["dmg"],
"category": "public.app-category.social-networking"
},
"linux": {
"target": ["AppImage", "deb"],
"category": "Network"
}
}

264
electron/main.ts Normal file
View file

@ -0,0 +1,264 @@
import { app, BrowserWindow, protocol, net, shell, ipcMain } from 'electron';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { existsSync, promises as fsp } from 'node:fs';
// Dev-mode loads from Vite dev-server (http://localhost:8080) so HMR works.
// Prod-mode loads from in-process custom scheme `vojo://app/index.html`.
// `VOJO_ELECTRON_PROD=1` forces prod-mode in an un-packaged binary so the
// scheme + service-worker path can be validated without re-running
// electron-builder. The packaged app sets `app.isPackaged === true`.
const isDev = !app.isPackaged && process.env.VOJO_ELECTRON_PROD !== '1';
const DEV_URL = 'http://localhost:8080';
const APP_SCHEME = 'vojo';
const APP_HOST = 'app';
// Extensions that look like real web assets; for these, a missing file is a
// genuine 404. Anything else (including Matrix-flavoured `roomId/userId`
// segments like `!foo:vojo.chat` whose `path.extname` returns `.chat`) is
// treated as a SPA route and falls back to `index.html`. The allowlist is
// intentionally narrow — extend only when adding a new bundled asset kind.
const WEB_ASSET_EXTENSIONS = new Set([
'.js',
'.mjs',
'.cjs',
'.css',
'.html',
'.htm',
'.map',
'.json',
'.txt',
'.xml',
'.svg',
'.ico',
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.avif',
'.woff',
'.woff2',
'.ttf',
'.otf',
'.wasm',
]);
protocol.registerSchemesAsPrivileged([
{
scheme: APP_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
allowServiceWorkers: true,
corsEnabled: true,
stream: true,
codeCache: true,
},
},
]);
const ALLOWED_EXTERNAL_SCHEMES = new Set(['http:', 'https:', 'mailto:']);
const isSafeExternal = (raw: unknown): raw is string => {
if (typeof raw !== 'string' || raw.length > 8 * 1024) return false;
try {
return ALLOWED_EXTERNAL_SCHEMES.has(new URL(raw).protocol);
} catch {
return false;
}
};
const distDir = path.resolve(__dirname, '..', '..', 'dist');
// React Router defaults to BrowserRouter against `window.location.pathname`,
// which for `vojo://app/...` would treat URL segments as routes (e.g.
// `vojo://app/index.html` resolved as a space alias `index.html`). Vojo
// already supports HashRouter via `clientConfig.hashRouter.enabled` in
// `config.json` — we override that to `true` for the Electron renderer so
// every route lives in `window.location.hash`, leaving the pathname stable
// for the protocol handler. The web/Android bundles see the unmodified
// config (hash router off).
const patchConfigForElectron = (raw: string): string => {
try {
const config: Record<string, unknown> = JSON.parse(raw);
const existing = (config.hashRouter as { basename?: string } | undefined) ?? {};
config.hashRouter = {
enabled: true,
basename: typeof existing.basename === 'string' ? existing.basename : '/',
};
return JSON.stringify(config);
} catch {
return raw;
}
};
const registerAppProtocol = () => {
protocol.handle(APP_SCHEME, async (request) => {
let url: URL;
try {
url = new URL(request.url);
} catch {
return new Response(null, { status: 400 });
}
// Reject foreign origins under the same scheme. `protocol.handle` does
// not validate the URL host, so without this check a renderer-side
// `window.location = 'vojo://evil/...'` would resolve into a separate
// (cookie/SW/IndexedDB-isolated) copy of the bundle — same content but
// detached storage. Only `vojo://app` is accepted.
if (url.hostname !== APP_HOST) {
return new Response(null, { status: 403 });
}
const rel = (url.pathname || '/').replace(/^\/+/, '') || 'index.html';
let filePath = path.normalize(path.join(distDir, rel));
// Path-traversal guard. `filePath.startsWith(distDir)` is unsafe — a
// sibling directory `/a/dist_evil` passes the prefix check against
// `/a/dist`. `path.relative` yields `..`-leading or absolute output
// for paths escaping `distDir`, which is the canonical Node check.
let relFromDist = path.relative(distDir, filePath);
if (relFromDist.startsWith('..') || path.isAbsolute(relFromDist)) {
return new Response(null, { status: 403 });
}
// SPA fallback for paths without a real file on disk. Under HashRouter
// (the Electron default — see `patchConfigForElectron`) reloads arrive
// with pathname `/`, so this rarely fires; kept as a safety net for
// manual URL edits and if someone ever reverts the HashRouter patch.
// We MUST NOT gate via `path.extname() !== ''` — Matrix room/user IDs
// like `!foo:vojo.chat` parse as having extension `.chat` and would
// wrongly 404. Use a narrow allowlist of real web-asset extensions.
if (!existsSync(filePath)) {
const ext = path.extname(rel).toLowerCase();
if (ext !== '' && WEB_ASSET_EXTENSIONS.has(ext)) {
return new Response('Not Found', { status: 404 });
}
filePath = path.join(distDir, 'index.html');
relFromDist = 'index.html';
}
// Override top-level `config.json` to force HashRouter on in Electron.
// Exact-match via `path.relative` is Windows-safe (case + separator
// normalization), unlike `path.dirname(filePath) === distDir`.
if (relFromDist === 'config.json') {
const raw = await fsp.readFile(filePath, 'utf-8');
return new Response(patchConfigForElectron(raw), {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
}
return net.fetch(pathToFileURL(filePath).toString());
});
};
const createWindow = async () => {
const win = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 640,
minHeight: 480,
backgroundColor: '#0d0e11',
autoHideMenuBar: true,
// Native-looking chrome: Windows draws real min/max/close buttons via
// Window Controls Overlay, the rest of the bar matches our Dawn palette.
// Same pattern as Discord/Slack/VS Code/Element Desktop. On macOS
// `hiddenInset` keeps traffic lights but inset over a clean dark area.
// Linux keeps the system frame to avoid breaking GTK-decoration UX.
...(process.platform === 'win32' && {
titleBarStyle: 'hidden' as const,
titleBarOverlay: {
color: '#0d0e11',
symbolColor: '#e0e0e8',
height: 32,
},
}),
...(process.platform === 'darwin' && {
titleBarStyle: 'hiddenInset' as const,
}),
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
// sandbox: true is safe here — preload uses only `contextBridge` and
// `ipcRenderer`, both of which are exposed inside the Electron sandbox
// per https://www.electronjs.org/docs/latest/tutorial/sandbox.
sandbox: true,
},
});
// With `titleBarStyle: 'hidden'` Windows draws the min/max/close buttons
// but the rest of the bar is the renderer's pixel area — and Electron does
// NOT mark it draggable automatically. Inject a CSS drag-region overlay
// sized by the Window Controls Overlay API env() variables (the canonical
// way Microsoft / Chromium expose the safe drag area excluding the OS
// button strip). Body gets matching padding-top so content shifts down
// by 32px instead of sitting under the drag region.
if (process.platform === 'win32') {
win.webContents.on('dom-ready', () => {
win.webContents.insertCSS(`
body { padding-top: env(titlebar-area-height, 32px) !important; }
body::before {
content: "";
position: fixed;
top: env(titlebar-area-y, 0);
left: env(titlebar-area-x, 0);
width: env(titlebar-area-width, calc(100vw - 138px));
height: env(titlebar-area-height, 32px);
-webkit-app-region: drag;
z-index: 2147483647;
}
`);
});
}
win.webContents.setWindowOpenHandler(({ url }) => {
if (isSafeExternal(url)) shell.openExternal(url);
return { action: 'deny' };
});
win.webContents.on('will-navigate', (event, url) => {
let target: URL;
try {
target = new URL(url);
} catch {
event.preventDefault();
return;
}
// Strict match for the in-app origin. `target.protocol === 'vojo:'` alone
// would treat `vojo://evil/...` as internal even though the protocol
// handler now rejects it; preventing navigation upstream avoids the
// round-trip and keeps the renderer pinned to the canonical origin.
const isInternal =
(target.protocol === `${APP_SCHEME}:` && target.hostname === APP_HOST) ||
(isDev && target.origin === DEV_URL);
if (isInternal) return;
event.preventDefault();
if (isSafeExternal(url)) shell.openExternal(url);
});
if (isDev) {
await win.loadURL(DEV_URL);
win.webContents.openDevTools({ mode: 'detach' });
} else {
await win.loadURL(`${APP_SCHEME}://${APP_HOST}/`);
}
};
app.whenReady().then(() => {
if (!isDev) registerAppProtocol();
ipcMain.handle('vojo:open-external', async (_event, url: unknown) => {
if (isSafeExternal(url)) await shell.openExternal(url);
});
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});

6
electron/preload.ts Normal file
View file

@ -0,0 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('vojoElectron', {
platform: process.platform,
openExternal: (url: string): Promise<void> => ipcRenderer.invoke('vojo:open-external', url),
});

18
electron/tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2022", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist-electron",
"rootDir": ".",
"resolveJsonModule": true,
"isolatedModules": true,
"sourceMap": true,
"types": ["node"]
},
"include": ["*.ts"]
}

2486
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.0.0" "node": ">=22.12.0"
}, },
"scripts": { "scripts": {
"start": "vite", "start": "vite",
@ -26,6 +26,15 @@
"build:android:debug": "npm run build && npm run android:strip-sourcemaps && npm run android:sync && npm run android:apk:debug", "build:android:debug": "npm run build && npm run android:strip-sourcemaps && npm run android:sync && npm run android:apk:debug",
"build:android:release": "npm run build && npm run android:strip-sourcemaps && npm run android:sync && npm run android:apk:release", "build:android:release": "npm run build && npm run android:strip-sourcemaps && npm run android:sync && npm run android:apk:release",
"build:android:aab": "npm run build && npm run android:strip-sourcemaps && npm run android:sync && npm run android:aab:release", "build:android:aab": "npm run build && npm run android:strip-sourcemaps && npm run android:sync && npm run android:aab:release",
"electron:typecheck": "tsc --noEmit -p electron/tsconfig.json",
"electron:build": "tsc -p electron/tsconfig.json && node -e \"require('fs').writeFileSync('electron/dist-electron/package.json', JSON.stringify({type:'commonjs'}))\"",
"electron:dev": "concurrently -k -n vite,electron -c blue,green \"npm:start\" \"wait-on tcp:8080 && npm run electron:build && electron electron/dist-electron/main.js\"",
"electron:start": "electron electron/dist-electron/main.js",
"electron:start:prod": "cross-env VOJO_ELECTRON_PROD=1 electron electron/dist-electron/main.js",
"build:electron:win": "npm run build && npm run electron:build && electron-builder --win",
"build:electron:win:docker": "docker run --rm -v ${PWD}:/project -v ~/.cache/electron:/root/.cache/electron -v ~/.cache/electron-builder:/root/.cache/electron-builder -w /project electronuserland/builder:wine-mono /bin/bash -c \"trap 'chown -R 1000:1000 /project/dist /project/release /project/electron/dist-electron 2>/dev/null || true' EXIT; npm run build && npm run electron:build && npx electron-builder --win\"",
"build:electron:mac": "npm run build && npm run electron:build && electron-builder --mac",
"build:electron:linux": "npm run build && npm run electron:build && electron-builder --linux",
"prepare": "husky install", "prepare": "husky install",
"commit": "git-cz" "commit": "git-cz"
}, },
@ -126,7 +135,11 @@
"@typescript-eslint/parser": "7.18.0", "@typescript-eslint/parser": "7.18.0",
"@vitejs/plugin-react": "4.2.0", "@vitejs/plugin-react": "4.2.0",
"buffer": "6.0.3", "buffer": "6.0.3",
"concurrently": "9.2.1",
"cross-env": "7.0.3",
"cz-conventional-changelog": "3.3.0", "cz-conventional-changelog": "3.3.0",
"electron": "42.1.0",
"electron-builder": "26.8.1",
"eslint": "8.57.1", "eslint": "8.57.1",
"eslint-config-airbnb": "19.0.4", "eslint-config-airbnb": "19.0.4",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "8.5.0",
@ -141,6 +154,7 @@
"vite": "5.4.19", "vite": "5.4.19",
"vite-plugin-pwa": "0.20.5", "vite-plugin-pwa": "0.20.5",
"vite-plugin-static-copy": "1.0.4", "vite-plugin-static-copy": "1.0.4",
"vite-plugin-top-level-await": "1.4.4" "vite-plugin-top-level-await": "1.4.4",
"wait-on": "9.0.10"
} }
} }

40
src/app/utils/electron.ts Normal file
View file

@ -0,0 +1,40 @@
type VojoElectronApi = {
platform: NodeJS.Platform;
openExternal: (url: string) => Promise<void>;
};
declare global {
interface Window {
vojoElectron?: VojoElectronApi;
}
}
export const isElectron = (): boolean => typeof window !== 'undefined' && !!window.vojoElectron;
export const openExternalUrl = async (url: string): Promise<void> => {
const api = window.vojoElectron;
if (api) {
await api.openExternal(url);
return;
}
window.open(url, '_blank', 'noopener');
};
export const setupExternalLinkHandler = (): (() => void) | undefined => {
const api = window.vojoElectron;
if (!api) return undefined;
const handler = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement).closest?.(
'a[target="_blank"]'
) as HTMLAnchorElement | null;
if (!anchor?.href) return;
if (anchor.dataset.mentionId) return;
e.preventDefault();
api.openExternal(anchor.href);
};
document.addEventListener('click', handler, true);
return () => document.removeEventListener('click', handler, true);
};

View file

@ -20,9 +20,11 @@ import './app/i18n';
import { pushSessionToSW } from './sw-session'; import { pushSessionToSW } from './sw-session';
import { getFallbackSession } from './app/state/sessions'; import { getFallbackSession } from './app/state/sessions';
import { setupExternalLinkHandler } from './app/utils/capacitor'; import { setupExternalLinkHandler } from './app/utils/capacitor';
import { setupExternalLinkHandler as setupElectronExternalLinkHandler } from './app/utils/electron';
document.body.classList.add(configClass, varsClass); document.body.classList.add(configClass, varsClass);
setupExternalLinkHandler(); setupExternalLinkHandler();
setupElectronExternalLinkHandler();
// Input-mode detector for hover/focus styling. Capacitor's Android Chromium // Input-mode detector for hover/focus styling. Capacitor's Android Chromium
// WebView synthesises `:hover` and `:focus-visible` on the focused element // WebView synthesises `:hover` and `:focus-visible` on the focused element

View file

@ -13,6 +13,6 @@
"skipLibCheck": true, "skipLibCheck": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"] "lib": ["ES2020", "DOM", "DOM.Iterable"]
}, },
"exclude": ["node_modules", "dist"], "exclude": ["node_modules", "dist", "electron"],
"include": ["src"] "include": ["src"]
} }

View file

@ -193,7 +193,10 @@ function serveStaticPagesDevMirror() {
pages.forEach(({ prefix, file }) => { pages.forEach(({ prefix, file }) => {
server.middlewares.use(prefix, (_req, res, next) => { server.middlewares.use(prefix, (_req, res, next) => {
const filePath = path.resolve(file); const filePath = path.resolve(file);
if (!fs.existsSync(filePath)) return next(); if (!fs.existsSync(filePath)) {
next();
return;
}
res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Cache-Control', 'no-cache');
res.end(fs.readFileSync(filePath)); res.end(fs.readFileSync(filePath));
@ -241,6 +244,13 @@ export default defineConfig({
}, },
server: { server: {
port: 8080, port: 8080,
// Vojo's electron:dev wires `wait-on tcp:8080` against this exact port,
// and `electron/main.ts` hard-codes `http://localhost:8080` as DEV_URL.
// Without `strictPort: true` Vite silently falls forward to :8081 when
// :8080 is taken (stale dev process, widget dev-server, Caddy), and
// Electron blindly waits for / loads the original port — wrong content
// or hang. Fail-fast is the better default for dev.
strictPort: true,
host: true, host: true,
fs: { fs: {
// Allow serving files from one level up to the project root // Allow serving files from one level up to the project root