feat(electron): add desktop wrapper packaging Vojo as Windows zip with privileged vojo:// scheme, HashRouter override and native chrome
This commit is contained in:
parent
ac072a1ddc
commit
f8ef53f911
14 changed files with 3217 additions and 14 deletions
18
.gitignore
vendored
18
.gitignore
vendored
|
|
@ -4,14 +4,24 @@ node_modules
|
|||
devAssets
|
||||
config.local.json
|
||||
|
||||
electron/dist-electron
|
||||
release
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
.vscode/*
|
||||
!.vscode/tasks.json
|
||||
.codex
|
||||
.claude
|
||||
docs/ai/desired_features.md
|
||||
docs/ai/bugs.md
|
||||
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
|
||||
|
|
|
|||
104
.vscode/tasks.json
vendored
Normal file
104
.vscode/tasks.json
vendored
Normal 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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [server-side.md](server-side.md) | Some configs that deployd on server |
|
||||
|
||||
|
|
|
|||
235
docs/ai/electron.md
Normal file
235
docs/ai/electron.md
Normal 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
|
||||
(weeks–months). **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
25
electron-builder.json
Normal 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
264
electron/main.ts
Normal 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
6
electron/preload.ts
Normal 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
18
electron/tsconfig.json
Normal 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
2486
package-lock.json
generated
File diff suppressed because it is too large
Load diff
18
package.json
18
package.json
|
|
@ -5,7 +5,7 @@
|
|||
"main": "index.js",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"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: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",
|
||||
"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",
|
||||
"commit": "git-cz"
|
||||
},
|
||||
|
|
@ -126,7 +135,11 @@
|
|||
"@typescript-eslint/parser": "7.18.0",
|
||||
"@vitejs/plugin-react": "4.2.0",
|
||||
"buffer": "6.0.3",
|
||||
"concurrently": "9.2.1",
|
||||
"cross-env": "7.0.3",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"electron": "42.1.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-airbnb": "19.0.4",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
|
|
@ -141,6 +154,7 @@
|
|||
"vite": "5.4.19",
|
||||
"vite-plugin-pwa": "0.20.5",
|
||||
"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
40
src/app/utils/electron.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -20,9 +20,11 @@ import './app/i18n';
|
|||
import { pushSessionToSW } from './sw-session';
|
||||
import { getFallbackSession } from './app/state/sessions';
|
||||
import { setupExternalLinkHandler } from './app/utils/capacitor';
|
||||
import { setupExternalLinkHandler as setupElectronExternalLinkHandler } from './app/utils/electron';
|
||||
|
||||
document.body.classList.add(configClass, varsClass);
|
||||
setupExternalLinkHandler();
|
||||
setupElectronExternalLinkHandler();
|
||||
|
||||
// Input-mode detector for hover/focus styling. Capacitor's Android Chromium
|
||||
// WebView synthesises `:hover` and `:focus-visible` on the focused element
|
||||
|
|
|
|||
|
|
@ -13,6 +13,6 @@
|
|||
"skipLibCheck": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"exclude": ["node_modules", "dist", "electron"],
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,7 +193,10 @@ function serveStaticPagesDevMirror() {
|
|||
pages.forEach(({ prefix, file }) => {
|
||||
server.middlewares.use(prefix, (_req, res, next) => {
|
||||
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('Cache-Control', 'no-cache');
|
||||
res.end(fs.readFileSync(filePath));
|
||||
|
|
@ -241,6 +244,13 @@ export default defineConfig({
|
|||
},
|
||||
server: {
|
||||
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,
|
||||
fs: {
|
||||
// Allow serving files from one level up to the project root
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue