Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d624ddde03 | |||
| d3ace8d611 | |||
| 177507bbc8 | |||
| d915e2a868 | |||
| a3d15cf971 | |||
| 5e72cd34f0 | |||
| b004d0472b | |||
| 6ed98fb21c | |||
| d8bf4b1db8 | |||
| fb2351ffe7 | |||
| 12f7d2b484 | |||
| 2c40ef0964 | |||
| ceb778a040 | |||
| d55b6e0b7a | |||
| 5a2fdacebe |
@@ -161,3 +161,24 @@ LOG_TO_FILE=true
|
||||
# LOG_FILE_PATH=logs/application/app.log
|
||||
# LOG_MAX_FILE_SIZE=50M
|
||||
# LOG_RETENTION_DAYS=7
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Memory Optimization (Low-RAM configurations)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Node.js heap limit in MB (default: 256 for Docker, system default for npm)
|
||||
# OMNIROUTE_MEMORY_MB=256
|
||||
|
||||
# Prompt cache settings
|
||||
# PROMPT_CACHE_MAX_SIZE=50
|
||||
# PROMPT_CACHE_MAX_BYTES=2097152
|
||||
# PROMPT_CACHE_TTL_MS=300000
|
||||
|
||||
# Semantic cache settings (temperature=0 responses)
|
||||
# SEMANTIC_CACHE_MAX_SIZE=100
|
||||
# SEMANTIC_CACHE_MAX_BYTES=4194304
|
||||
# SEMANTIC_CACHE_TTL_MS=1800000
|
||||
|
||||
# In-memory log buffers
|
||||
# PROXY_LOG_MAX_ENTRIES=200
|
||||
# CALL_LOGS_MAX=200
|
||||
# STREAM_HISTORY_MAX=50
|
||||
|
||||
@@ -102,3 +102,6 @@ app.log
|
||||
|
||||
# Backup directories
|
||||
app.__qa_backup/
|
||||
|
||||
# Electron (subproject dependency lock)
|
||||
electron/package-lock.json
|
||||
|
||||
@@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [1.6.4] — 2026-02-28
|
||||
|
||||
### 🖥️ Electron Desktop — Code Review Hardening (16 Fixes)
|
||||
|
||||
#### 🔴 Critical
|
||||
|
||||
- **Server readiness** — Window now waits for server health check before loading URL; no more blank screens on cold start (#1)
|
||||
- **Restart timeout** — `restart-server` IPC handler now has 5s timeout + `SIGKILL` to prevent indefinite hangs (#2)
|
||||
- **Port change lifecycle** — `changePort()` now stops and restarts the server on the new port instead of just reloading the URL (#3)
|
||||
|
||||
#### 🟡 Important
|
||||
|
||||
- **Tray cleanup** — Old `Tray` instance is now destroyed before recreating, preventing duplicate icons and memory leaks (#4)
|
||||
- **IPC event emission** — Main process now emits `server-status` and `port-changed` events to renderer, making React hooks functional (#5)
|
||||
- **Listener accumulation** — Preload now returns disposer functions for precise listener cleanup instead of `removeAllListeners` (#6)
|
||||
- **useIsElectron performance** — Replaced `useState`+`useEffect` with `useSyncExternalStore` to eliminate 5x unnecessary re-renders (#7)
|
||||
|
||||
#### 🔵 Minor
|
||||
|
||||
- Removed dead `isProduction` variable (#8)
|
||||
- Platform-conditional `titleBarStyle` — `hiddenInset` only on macOS, `default` on Windows/Linux (#9)
|
||||
- `stdio: pipe` — Server output captured for logging and readiness detection instead of `inherit` (#10)
|
||||
- Shared `AppInfo` type — `useElectronAppInfo` now uses the shared interface from `types.d.ts` (#11)
|
||||
- `useDataDir` error state — Now exposes errors instead of swallowing silently (#12)
|
||||
- Synced `electron/package.json` version to `1.6.4` (#13)
|
||||
- Removed dead `omniroute://` protocol config — no handler existed (#14)
|
||||
- **Content Security Policy** — Added CSP via `session.webRequest.onHeadersReceived` (#15)
|
||||
- Simplified preload validation — Generic `safeInvoke`/`safeSend`/`safeOn` wrappers reduce boilerplate (#16)
|
||||
|
||||
### 🧪 Test Suite Expansion
|
||||
|
||||
- **76 tests** across 15 suites (up from 64 tests / 9 suites)
|
||||
- New: server readiness timeout, restart race condition, CSP directives, platform options, disposer pattern, generic IPC wrappers
|
||||
|
||||
---
|
||||
|
||||
## [1.6.3] — 2026-02-28
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Database data preservation on upgrade** — Previously, upgrading from older versions (e.g. v1.2.0 → v1.6.x) could cause data loss by renaming the existing database when a legacy `schema_migrations` table was detected. Now checks for actual data before deciding to reset ([#146](https://github.com/diegosouzapw/OmniRoute/issues/146))
|
||||
- **Hardcoded build-machine paths in npm package** — Next.js standalone output baked absolute paths from the build machine into `server.js` and `required-server-files.json`. On other machines these paths don't exist, causing `ENOENT` errors. The prepublish script now sanitizes all build paths to relative ([#147](https://github.com/diegosouzapw/OmniRoute/issues/147))
|
||||
|
||||
---
|
||||
|
||||
## [1.6.2] — 2026-02-27
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Provider labels in Combos** — Combo cards now show user-defined provider names instead of long UUID identifiers, making complex multi-provider combos easier to read ([#121](https://github.com/diegosouzapw/OmniRoute/issues/121))
|
||||
- **Improved request log labels** — RequestLoggerV2 resolves OpenAI-compatible provider IDs to user-defined names via provider nodes lookup
|
||||
- **Smarter API key display** — `formatApiKey()` now shows the full key name for named keys instead of truncating them
|
||||
|
||||
---
|
||||
|
||||
## [1.6.1] — 2026-02-27
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
@@ -759,6 +814,8 @@ New environment variables:
|
||||
|
||||
---
|
||||
|
||||
[1.6.3]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.6.3
|
||||
[1.6.2]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.6.2
|
||||
[1.6.1]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.6.1
|
||||
[1.6.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.6.0
|
||||
[1.5.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.5.0
|
||||
|
||||
@@ -19,6 +19,7 @@ LABEL org.opencontainers.image.title="omniroute" \
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=20128
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV NODE_OPTIONS="--max-old-space-size=256"
|
||||
|
||||
# Data directory inside Docker — must match the volume mount in docker-compose.yml
|
||||
ENV DATA_DIR=/app/data
|
||||
|
||||
@@ -247,6 +247,29 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ تطبيق سطح المكتب — غير متصل ومتاح دائمًا
|
||||
|
||||
> 🆕 **جديد!** أصبح OmniRoute متاحًا الآن كـ **تطبيق سطح مكتب أصلي** لنظام Windows وmacOS وLinux.
|
||||
|
||||
- 🖥️ **نافذة أصلية** — نافذة مخصصة مع تكامل شريط النظام
|
||||
- 🔄 **بدء تلقائي** — تشغيل OmniRoute عند تسجيل الدخول
|
||||
- 🔔 **إشعارات أصلية** — تنبيهات عند نفاد الحصة
|
||||
- ⚡ **تثبيت بنقرة واحدة** — NSIS (Windows)، DMG (macOS)، AppImage (Linux)
|
||||
- 🌐 **وضع غير متصل** — يعمل بالكامل بدون إنترنت
|
||||
|
||||
```bash
|
||||
npm run electron:dev # وضع التطوير
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 التوثيق الكامل: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 لمحة سريعة عن الأسعار
|
||||
|
||||
| الطبقة | مقدم | التكلفة | إعادة ضبط الحصص | الأفضل لـ |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Ценообразуването с един поглед
|
||||
|
||||
| Ниво | Доставчик | Цена | Нулиране на квота | Най-добро за |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Prissætning på et øjeblik
|
||||
|
||||
| Tier | Udbyder | Omkostninger | Kvote nulstilling | Bedst til |
|
||||
|
||||
@@ -247,6 +247,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop-App — Offline & Immer Aktiv
|
||||
|
||||
> 🆕 **NEU!** OmniRoute ist jetzt als **native Desktop-Anwendung** für Windows, macOS und Linux verfügbar.
|
||||
|
||||
- 🖥️ **Natives Fenster** — Dediziertes App-Fenster mit System-Tray-Integration
|
||||
- 🔄 **Autostart** — OmniRoute beim Systemstart starten
|
||||
- 🔔 **Native Benachrichtigungen** — Warnungen bei Kontingent-Erschöpfung
|
||||
- ⚡ **Ein-Klick-Installation** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline-Modus** — Funktioniert vollständig offline mit integriertem Server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Entwicklungsmodus
|
||||
npm run electron:build # Aktuelle Plattform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Vollständige Dokumentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Preisübersicht
|
||||
|
||||
| Tier | Anbieter | Kosten | Kontingent-Reset | Am besten für |
|
||||
|
||||
@@ -247,6 +247,34 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Aplicación de Escritorio — Sin Conexión y Siempre Activo
|
||||
|
||||
> 🆕 **¡NUEVO!** OmniRoute ahora está disponible como **aplicación de escritorio nativa** para Windows, macOS y Linux.
|
||||
|
||||
Ejecuta OmniRoute como una aplicación de escritorio autónoma — sin terminal, sin navegador, sin internet necesario para modelos locales. La app basada en Electron incluye:
|
||||
|
||||
- 🖥️ **Ventana Nativa** — Ventana dedicada con integración en la bandeja del sistema
|
||||
- 🔄 **Inicio Automático** — Inicia OmniRoute al iniciar sesión
|
||||
- 🔔 **Notificaciones Nativas** — Recibe alertas sobre cuota o problemas de proveedores
|
||||
- ⚡ **Instalación con Un Clic** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Modo Sin Conexión** — Funciona completamente offline con servidor incluido
|
||||
|
||||
### Inicio Rápido
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Modo desarrollo
|
||||
npm run electron:build # Plataforma actual
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Documentación completa: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Precios Resumidos
|
||||
|
||||
| Tier | Proveedor | Costo | Reset de Cuota | Mejor Para |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Hinnoittelu yhdellä silmäyksellä
|
||||
|
||||
| Taso | Palveluntarjoaja | Kustannukset | Kiintiön nollaus | Paras |
|
||||
|
||||
@@ -247,6 +247,32 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Application Bureau — Hors Ligne et Toujours Actif
|
||||
|
||||
> 🆕 **NOUVEAU !** OmniRoute est maintenant disponible en tant qu'**application de bureau native** pour Windows, macOS et Linux.
|
||||
|
||||
Exécutez OmniRoute en tant qu'application de bureau autonome — sans terminal, sans navigateur, sans internet requis pour les modèles locaux.
|
||||
|
||||
- 🖥️ **Fenêtre Native** — Fenêtre dédiée avec intégration dans la barre d'état système
|
||||
- 🔄 **Démarrage Automatique** — Lancez OmniRoute à la connexion système
|
||||
- 🔔 **Notifications Natives** — Alertes pour l'épuisement de quota ou les problèmes
|
||||
- ⚡ **Installation en Un Clic** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Mode Hors Ligne** — Fonctionne entièrement hors ligne avec serveur intégré
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Mode développement
|
||||
npm run electron:build # Plateforme actuelle
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Documentation complète : [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Aperçu des tarifs
|
||||
|
||||
| Tier | Fournisseur | Coût | Réinitialisation | Idéal pour |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 תמחור במבט חטוף
|
||||
|
||||
| שכבה | ספק | עלות | איפוס מכסה | הטוב ביותר עבור |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Árazás egy pillantásra
|
||||
|
||||
| Tier | Szolgáltató | Költség | Kvóta visszaállítása | Legjobb a |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Sekilas tentang Harga
|
||||
|
||||
| Tingkat | Penyedia | Biaya | Reset Kuota | Terbaik Untuk |
|
||||
|
||||
@@ -144,6 +144,30 @@ docker run -d \
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 मूल्य निर्धारण एक नज़र में
|
||||
|
||||
| टियर | प्रदाता | लागत | कोटा रीसेट | के लिए सर्वश्रेष्ठ |
|
||||
|
||||
@@ -247,6 +247,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Panoramica Prezzi
|
||||
|
||||
| Tier | Provider | Costo | Reset Quota | Ideale Per |
|
||||
|
||||
@@ -247,6 +247,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ デスクトップアプリ — オフライン&常時稼働
|
||||
|
||||
> 🆕 **新機能!** OmniRouteが**ネイティブデスクトップアプリケーション**としてWindows、macOS、Linuxで利用可能になりました。
|
||||
|
||||
- 🖥️ **ネイティブウィンドウ** — システムトレイ統合付きの専用ウィンドウ
|
||||
- 🔄 **自動起動** — システムログイン時にOmniRouteを起動
|
||||
- 🔔 **ネイティブ通知** — クォータ枯渇やプロバイダー問題のアラート
|
||||
- ⚡ **ワンクリックインストール** — NSIS (Windows)、DMG (macOS)、AppImage (Linux)
|
||||
- 🌐 **オフラインモード** — 内蔵サーバーで完全オフライン動作
|
||||
|
||||
```bash
|
||||
npm run electron:dev # 開発モード
|
||||
npm run electron:build # 現在のプラットフォーム
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 完全なドキュメント:[`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 価格の概要
|
||||
|
||||
| 階層 | プロバイダー | コスト | クォータのリセット | 最適な用途 |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ 데스크톱 앱 — 오프라인 & 상시 가동
|
||||
|
||||
> 🆕 **새 기능!** OmniRoute가 Windows, macOS, Linux용 **네이티브 데스크톱 앱**으로 출시되었습니다.
|
||||
|
||||
- 🖥️ **네이티브 윈도우** — 시스템 트레이 통합 전용 창
|
||||
- 🔄 **자동 시작** — 시스템 로그인 시 OmniRoute 실행
|
||||
- 🔔 **네이티브 알림** — 할당량 소진 또는 공급자 문제 알림
|
||||
- ⚡ **원클릭 설치** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **오프라인 모드** — 내장 서버로 완전 오프라인 작동
|
||||
|
||||
```bash
|
||||
npm run electron:dev # 개발 모드
|
||||
npm run electron:build # 현재 플랫폼
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 전체 문서: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 가격 한눈에 보기
|
||||
|
||||
| 계층 | 공급자 | 비용 | 할당량 재설정 | 최고의 대상 |
|
||||
|
||||
@@ -259,6 +259,43 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
Run OmniRoute as a standalone desktop app — no terminal, no browser, no internet required for local models. The Electron-based app includes:
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Development mode
|
||||
npm run electron:dev
|
||||
|
||||
# Build for your platform
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg) — x64 & arm64
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
### System Tray
|
||||
|
||||
When minimized, OmniRoute lives in your system tray with quick actions:
|
||||
|
||||
- Open dashboard
|
||||
- Change server port
|
||||
- Quit application
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Pricing at a Glance
|
||||
|
||||
| Tier | Provider | Cost | Quota Reset | Best For |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Sekilas Pandang Harga
|
||||
|
||||
| Peringkat | Pembekal | Kos | Set Semula Kuota | Terbaik Untuk |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Prijzen in één oogopslag
|
||||
|
||||
| Niveau | Aanbieder | Kosten | Quotum opnieuw instellen | Beste voor |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Priser på et øyeblikk
|
||||
|
||||
| Nivå | Leverandør | Kostnad | Kvote Tilbakestill | Best for |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Pagpepresyo sa isang Sulyap
|
||||
|
||||
| Tier | Provider | Gastos | I-reset ang Quota | Pinakamahusay Para sa |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Ceny w skrócie
|
||||
|
||||
| Poziom | Dostawca | Koszt | Reset przydziału | Najlepsze dla |
|
||||
|
||||
@@ -247,6 +247,45 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Aplicativo Desktop — Offline e Sempre Ativo
|
||||
|
||||
> 🆕 **NOVO!** O OmniRoute agora está disponível como **aplicativo desktop nativo** para Windows, macOS e Linux.
|
||||
|
||||
Execute o OmniRoute como um aplicativo desktop autônomo — sem terminal, sem navegador, sem internet necessária para modelos locais. O app baseado em Electron inclui:
|
||||
|
||||
- 🖥️ **Janela Nativa** — Janela dedicada com integração à bandeja do sistema
|
||||
- 🔄 **Iniciar Automaticamente** — Inicie o OmniRoute ao fazer login no sistema
|
||||
- 🔔 **Notificações Nativas** — Receba alertas sobre esgotamento de cota ou problemas
|
||||
- ⚡ **Instalação com Um Clique** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Modo Offline** — Funciona totalmente offline com servidor embarcado
|
||||
|
||||
### Início Rápido
|
||||
|
||||
```bash
|
||||
# Modo desenvolvimento
|
||||
npm run electron:dev
|
||||
|
||||
# Compilar para sua plataforma
|
||||
npm run electron:build # Plataforma atual
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg) — x64 & arm64
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
### Bandeja do Sistema
|
||||
|
||||
Quando minimizado, o OmniRoute fica na bandeja do sistema com ações rápidas:
|
||||
|
||||
- Abrir dashboard
|
||||
- Alterar porta do servidor
|
||||
- Sair do aplicativo
|
||||
|
||||
📖 Documentação completa: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Preços Resumidos
|
||||
|
||||
| Tier | Provedor | Custo | Reset de Cota | Melhor Para |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Visão geral dos preços
|
||||
|
||||
| Nível | Provedor | Custo | Redefinição de cota | Melhor para |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Prețurile dintr-o privire
|
||||
|
||||
| Nivelul | Furnizor | Cost | Resetare cotă | Cel mai bun pentru |
|
||||
|
||||
@@ -247,6 +247,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Настольное Приложение — Оффлайн и Всегда Активно
|
||||
|
||||
> 🆕 **НОВИНКА!** OmniRoute теперь доступен как **нативное настольное приложение** для Windows, macOS и Linux.
|
||||
|
||||
- 🖥️ **Нативное Окно** — Выделенное окно с интеграцией в системный трей
|
||||
- 🔄 **Автозапуск** — Запуск OmniRoute при входе в систему
|
||||
- 🔔 **Нативные Уведомления** — Оповещения об исчерпании квоты
|
||||
- ⚡ **Установка в Один Клик** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Оффлайн Режим** — Полностью работает без интернета
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Режим разработки
|
||||
npm run electron:build # Текущая платформа
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Полная документация: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Обзор цен
|
||||
|
||||
| Tier | Провайдер | Стоимость | Сброс квоты | Лучше всего для |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Prehľad cien
|
||||
|
||||
| Úroveň | Poskytovateľ | Náklady | Obnovenie kvóty | Najlepšie pre |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Prissättning i en överblick
|
||||
|
||||
| Nivå | Leverantör | Kostnad | Kvotåterställning | Bäst för |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 ราคาโดยสรุป
|
||||
|
||||
| ชั้น | ผู้ให้บริการ | ราคา | รีเซ็ตโควต้า | ดีที่สุดสำหรับ |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Короткий огляд цін
|
||||
|
||||
| Рівень | Постачальник | Вартість | Скидання квоти | Найкраще для |
|
||||
|
||||
@@ -249,6 +249,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Desktop App — Offline & Always-On
|
||||
|
||||
> 🆕 **NEW!** OmniRoute is now available as a **native desktop application** for Windows, macOS, and Linux.
|
||||
|
||||
- 🖥️ **Native Window** — Dedicated app window with system tray integration
|
||||
- 🔄 **Auto-Start** — Launch OmniRoute on system login
|
||||
- 🔔 **Native Notifications** — Get alerts for quota exhaustion or provider issues
|
||||
- ⚡ **One-Click Install** — NSIS (Windows), DMG (macOS), AppImage (Linux)
|
||||
- 🌐 **Offline Mode** — Works fully offline with bundled server
|
||||
|
||||
```bash
|
||||
npm run electron:dev # Development mode
|
||||
npm run electron:build # Current platform
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 Full documentation: [`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Sơ lược về giá
|
||||
|
||||
| Bậc | Nhà cung cấp | Chi phí | Đặt lại hạn ngạch | Tốt nhất cho |
|
||||
|
||||
@@ -247,6 +247,30 @@ docker compose --profile cli up -d
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ 桌面应用 — 离线 & 始终在线
|
||||
|
||||
> 🆕 **全新!** OmniRoute 现已提供适用于 Windows、macOS 和 Linux 的**原生桌面应用程序**。
|
||||
|
||||
- 🖥️ **原生窗口** — 专属应用窗口,集成系统托盘
|
||||
- 🔄 **自动启动** — 系统登录时启动 OmniRoute
|
||||
- 🔔 **原生通知** — 配额耗尽或提供商问题时收到提醒
|
||||
- ⚡ **一键安装** — NSIS (Windows)、DMG (macOS)、AppImage (Linux)
|
||||
- 🌐 **离线模式** — 内置服务器,完全离线工作
|
||||
|
||||
```bash
|
||||
npm run electron:dev # 开发模式
|
||||
npm run electron:build # 当前平台
|
||||
npm run electron:build:win # Windows (.exe)
|
||||
npm run electron:build:mac # macOS (.dmg)
|
||||
npm run electron:build:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
📖 完整文档:[`electron/README.md`](electron/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 💰 定价概览
|
||||
|
||||
| 层级 | 提供商 | 费用 | 配额重置 | 最适合 |
|
||||
|
||||
+71
-7
@@ -12,15 +12,69 @@
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { homedir, platform } from "node:os";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const ROOT = join(__dirname, "..");
|
||||
const APP_DIR = join(ROOT, "app");
|
||||
|
||||
// ── Load .env file (for global npm install) ─────────────────
|
||||
function loadEnvFile() {
|
||||
const envPaths = [];
|
||||
|
||||
// 1. DATA_DIR/.env if set
|
||||
if (process.env.DATA_DIR) {
|
||||
envPaths.push(join(process.env.DATA_DIR, ".env"));
|
||||
}
|
||||
|
||||
// 2. ~/.omniroute/.env (default data dir)
|
||||
const home = homedir();
|
||||
if (home) {
|
||||
if (platform() === "win32") {
|
||||
const appData = process.env.APPDATA || join(home, "AppData", "Roaming");
|
||||
envPaths.push(join(appData, "omniroute", ".env"));
|
||||
} else {
|
||||
envPaths.push(join(home, ".omniroute", ".env"));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. ./.env (current working directory)
|
||||
envPaths.push(join(process.cwd(), ".env"));
|
||||
|
||||
for (const envPath of envPaths) {
|
||||
try {
|
||||
if (existsSync(envPath)) {
|
||||
const content = readFileSync(envPath, "utf-8");
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
// Skip empty lines and comments
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx > 0) {
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
const value = trimmed.slice(eqIdx + 1).trim();
|
||||
// Don't override existing env vars
|
||||
if (process.env[key] === undefined) {
|
||||
// Remove surrounding quotes
|
||||
process.env[key] = value.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` \x1b[2m📋 Loaded env from ${envPath}\x1b[0m`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors reading env files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile();
|
||||
|
||||
// ── Parse args ─────────────────────────────────────────────
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -35,6 +89,10 @@ if (args.includes("--help") || args.includes("-h")) {
|
||||
omniroute --help Show this help
|
||||
omniroute --version Show version
|
||||
|
||||
\x1b[1mConfig:\x1b[0m
|
||||
Loads .env from: ~/.omniroute/.env or ./.env
|
||||
Memory limit: OMNIROUTE_MEMORY_MB (default: 512)
|
||||
|
||||
\x1b[1mAfter starting:\x1b[0m
|
||||
Dashboard: http://localhost:<dashboard-port>
|
||||
API: http://localhost:<api-port>/v1
|
||||
@@ -83,11 +141,11 @@ const noOpen = args.includes("--no-open");
|
||||
// ── Banner ─────────────────────────────────────────────────
|
||||
console.log(`
|
||||
\x1b[36m ____ _ ____ _
|
||||
/ __ \\ (_) __ \\ | |
|
||||
| | | |_ __ ___ _ __ _| |__) |___ _ _| |_ ___
|
||||
| | | | '_ \` _ \\| '_ \\ | _ // _ \\| | | | __/ _ \\
|
||||
| |__| | | | | | | | | | | | \\ \\ (_) | |_| | || __/
|
||||
\\____/|_| |_| |_|_| |_|_|_| \\_\\___/ \\__,_|\\__\\___|
|
||||
/ __ \\ (_) __ \\ | |
|
||||
| | | |_ __ ___ _ __ _| |__) |___ _ _| |_ ___
|
||||
| | | | '_ \` _ \\| '_ \\ | _ // _ \\| | | | __/ _ \\
|
||||
| |__| | | | | | | | | | | | \\ \\ (_) | |_| | || __/
|
||||
\\____/|_| |_| |_|_| |_|_|_| \\_\\___/ \\__,_|\\__\\___|
|
||||
\x1b[0m`);
|
||||
|
||||
// ── Node.js version check ──────────────────────────────────
|
||||
@@ -117,6 +175,11 @@ if (!existsSync(serverJs)) {
|
||||
// ── Start server ───────────────────────────────────────────
|
||||
console.log(` \x1b[2m⏳ Starting server...\x1b[0m\n`);
|
||||
|
||||
// Sanitize memory limit — parseInt to prevent command injection (#150)
|
||||
const rawMemory = parseInt(process.env.OMNIROUTE_MEMORY_MB || "512", 10);
|
||||
const memoryLimit =
|
||||
Number.isFinite(rawMemory) && rawMemory >= 64 && rawMemory <= 16384 ? rawMemory : 512;
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
OMNIROUTE_PORT: String(port),
|
||||
@@ -125,9 +188,10 @@ const env = {
|
||||
API_PORT: String(apiPort),
|
||||
HOSTNAME: "0.0.0.0",
|
||||
NODE_ENV: "production",
|
||||
NODE_OPTIONS: `--max-old-space-size=${memoryLimit}`,
|
||||
};
|
||||
|
||||
const server = spawn("node", [serverJs], {
|
||||
const server = spawn("node", [`--max-old-space-size=${memoryLimit}`, serverJs], {
|
||||
cwd: APP_DIR,
|
||||
env,
|
||||
stdio: "pipe",
|
||||
|
||||
+73
-14
@@ -318,6 +318,25 @@ Model: cc/claude-opus-4-6
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Global npm install (Recommended)
|
||||
|
||||
```bash
|
||||
npm install -g omniroute
|
||||
|
||||
# Create config directory
|
||||
mkdir -p ~/.omniroute
|
||||
|
||||
# Create .env file (see .env.example)
|
||||
cp .env.example ~/.omniroute/.env
|
||||
|
||||
# Start server
|
||||
omniroute
|
||||
# Or with custom port:
|
||||
omniroute --port 3000
|
||||
```
|
||||
|
||||
The CLI automatically loads `.env` from `~/.omniroute/.env` or `./.env`.
|
||||
|
||||
### VPS Deployment
|
||||
|
||||
```bash
|
||||
@@ -337,6 +356,43 @@ npm run start
|
||||
# Or: pm2 start npm --name omniroute -- start
|
||||
```
|
||||
|
||||
### PM2 Deployment (Low Memory)
|
||||
|
||||
For servers with limited RAM, use the memory limit option:
|
||||
|
||||
```bash
|
||||
# With 512MB limit (default)
|
||||
pm2 start npm --name omniroute -- start
|
||||
|
||||
# Or with custom memory limit
|
||||
OMNIROUTE_MEMORY_MB=512 pm2 start npm --name omniroute -- start
|
||||
|
||||
# Or using ecosystem.config.js
|
||||
pm2 start ecosystem.config.js
|
||||
```
|
||||
|
||||
Create `ecosystem.config.js`:
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "omniroute",
|
||||
script: "npm",
|
||||
args: "start",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
OMNIROUTE_MEMORY_MB: "512",
|
||||
JWT_SECRET: "your-secret",
|
||||
INITIAL_PASSWORD: "your-password",
|
||||
},
|
||||
node_args: "--max-old-space-size=512",
|
||||
max_memory_restart: "300M",
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
@@ -351,20 +407,23 @@ For host-integrated mode with CLI binaries, see the Docker section in the main d
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --------------------- | ------------------------------------ | ------------------------------------------------------- |
|
||||
| `JWT_SECRET` | `omniroute-default-secret-change-me` | JWT signing secret (**change in production**) |
|
||||
| `INITIAL_PASSWORD` | `123456` | First login password |
|
||||
| `DATA_DIR` | `~/.omniroute` | Data directory (db, usage, logs) |
|
||||
| `PORT` | framework default | Service port (`20128` in examples) |
|
||||
| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) |
|
||||
| `NODE_ENV` | runtime default | Set `production` for deploy |
|
||||
| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL |
|
||||
| `CLOUD_URL` | `https://omniroute.dev` | Cloud sync endpoint base URL |
|
||||
| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys |
|
||||
| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` |
|
||||
| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs |
|
||||
| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (behind HTTPS reverse proxy) |
|
||||
| Variable | Default | Description |
|
||||
| ------------------------- | ------------------------------------ | ------------------------------------------------------- |
|
||||
| `JWT_SECRET` | `omniroute-default-secret-change-me` | JWT signing secret (**change in production**) |
|
||||
| `INITIAL_PASSWORD` | `123456` | First login password |
|
||||
| `DATA_DIR` | `~/.omniroute` | Data directory (db, usage, logs) |
|
||||
| `PORT` | framework default | Service port (`20128` in examples) |
|
||||
| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) |
|
||||
| `NODE_ENV` | runtime default | Set `production` for deploy |
|
||||
| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL |
|
||||
| `CLOUD_URL` | `https://omniroute.dev` | Cloud sync endpoint base URL |
|
||||
| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys |
|
||||
| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` |
|
||||
| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs |
|
||||
| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (behind HTTPS reverse proxy) |
|
||||
| `OMNIROUTE_MEMORY_MB` | `512` | Node.js heap limit in MB |
|
||||
| `PROMPT_CACHE_MAX_SIZE` | `50` | Max prompt cache entries |
|
||||
| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max semantic cache entries |
|
||||
|
||||
For the full environment variable reference, see the [README](../README.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# OmniRoute Electron Desktop App
|
||||
|
||||
This directory contains the Electron desktop application wrapper for OmniRoute.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
electron/
|
||||
├── main.js # Main process (window management, IPC)
|
||||
├── preload.js # Preload script (secure bridge to renderer)
|
||||
├── package.json # Electron-specific dependencies
|
||||
├── types.d.ts # TypeScript definitions
|
||||
└── assets/ # Application icons and resources
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Build the Next.js app first:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. Install Electron dependencies:
|
||||
```bash
|
||||
cd electron
|
||||
npm install
|
||||
```
|
||||
|
||||
### Running in Development
|
||||
|
||||
1. Start the Next.js development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. In another terminal, start Electron:
|
||||
```bash
|
||||
cd electron
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Running in Production Mode
|
||||
|
||||
1. Build Next.js in standalone mode:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. Start Electron:
|
||||
```bash
|
||||
cd electron
|
||||
npm start
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
### Build for Current Platform
|
||||
```bash
|
||||
cd electron
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Build for Specific Platforms
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
npm run build:win
|
||||
|
||||
# macOS
|
||||
npm run build:mac
|
||||
|
||||
# Linux
|
||||
npm run build:linux
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
Built applications are placed in `dist-electron/`:
|
||||
- Windows: `.exe` installer (NSIS)
|
||||
- macOS: `.dmg` installer
|
||||
- Linux: `.AppImage`
|
||||
|
||||
## Features
|
||||
|
||||
- **System Tray Integration**: Minimize to tray, quick actions
|
||||
- **Native Notifications**: Desktop notifications
|
||||
- **Window Management**: Minimize, maximize, close
|
||||
- **Auto-start**: Option to launch on system startup
|
||||
- **Offline Support**: Local server bundled with the app
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The Electron app respects these environment variables:
|
||||
- `OMNIROUTE_PORT`: Server port (default: 20128)
|
||||
- `NODE_ENV`: Set to 'production' for production builds
|
||||
|
||||
### Custom Icon
|
||||
|
||||
Place your icons in `assets/`:
|
||||
- `icon.ico` - Windows icon (256x256)
|
||||
- `icon.icns` - macOS icon bundle
|
||||
- `icon.png` - Linux/general use (512x512)
|
||||
- `tray-icon.png` - System tray icon (16x16 or 32x32)
|
||||
|
||||
## IPC Channels
|
||||
|
||||
| Channel | Direction | Description |
|
||||
|---------|-----------|-------------|
|
||||
| `get-app-info` | Renderer → Main | Get app name, version, platform |
|
||||
| `open-external` | Renderer → Main | Open URL in default browser |
|
||||
| `get-data-dir` | Renderer → Main | Get data directory path |
|
||||
| `restart-server` | Renderer → Main | Restart the internal server |
|
||||
| `server-status` | Main → Renderer | Server status updates |
|
||||
| `port-changed` | Main → Renderer | Port change notifications |
|
||||
|
||||
## Security
|
||||
|
||||
- `contextIsolation: true` - Isolates renderer from Node.js
|
||||
- `nodeIntegration: false` - No direct Node.js access in renderer
|
||||
- Preload script validates IPC channels
|
||||
- No remote code execution
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### App Won't Start
|
||||
|
||||
1. Check if port 20128 is available
|
||||
2. Check logs in the console
|
||||
3. Verify the build output exists
|
||||
|
||||
### White Screen
|
||||
|
||||
1. Verify Next.js build exists
|
||||
2. Check the server URL in main.js
|
||||
3. Check for console errors
|
||||
|
||||
### Build Fails
|
||||
|
||||
1. Ensure you have build tools installed:
|
||||
- Windows: Visual Studio Build Tools
|
||||
- macOS: Xcode Command Line Tools
|
||||
- Linux: build-essential, libsecret-1-dev
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* OmniRoute Electron Desktop App - Main Process
|
||||
*
|
||||
* This is the entry point for the Electron desktop application.
|
||||
* It manages the main window, system tray, server lifecycle, and IPC communication.
|
||||
*
|
||||
* Code Review Fixes Applied:
|
||||
* #1 Server readiness — wait for health check before loading window
|
||||
* #2 Restart timeout — 5s timeout + SIGKILL to prevent hanging
|
||||
* #3 changePort — stop + restart server on new port
|
||||
* #4 Tray cleanup — destroy old tray before recreating
|
||||
* #5 Emit server-status/port-changed IPC events
|
||||
* #8 Removed dead isProduction variable
|
||||
* #9 Platform-conditional titleBarStyle
|
||||
* #10 stdio: pipe + stdout/stderr capture for readiness detection
|
||||
* #14 Removed dead omniroute:// protocol (no handler existed)
|
||||
* #15 Content Security Policy via session headers
|
||||
*/
|
||||
|
||||
const {
|
||||
app,
|
||||
BrowserWindow,
|
||||
ipcMain,
|
||||
Tray,
|
||||
Menu,
|
||||
nativeImage,
|
||||
shell,
|
||||
session,
|
||||
} = require("electron");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const fs = require("fs");
|
||||
|
||||
// ── Single Instance Lock ───────────────────────────────────
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
app.on("second-instance", () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Environment Detection ──────────────────────────────────
|
||||
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||
|
||||
// ── Paths ──────────────────────────────────────────────────
|
||||
const APP_PATH = app.getAppPath();
|
||||
const RESOURCES_PATH = !isDev ? process.resourcesPath : APP_PATH;
|
||||
const NEXT_SERVER_PATH = path.join(RESOURCES_PATH, "app");
|
||||
|
||||
// ── State ──────────────────────────────────────────────────
|
||||
let mainWindow = null;
|
||||
let tray = null;
|
||||
let nextServer = null;
|
||||
let serverPort = 20128;
|
||||
|
||||
const getServerUrl = () => `http://localhost:${serverPort}`;
|
||||
|
||||
// ── Helper: Send IPC event to renderer (#5) ────────────────
|
||||
function sendToRenderer(channel, data) {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(channel, data);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper: Wait for server readiness (#1, #10) ────────────
|
||||
async function waitForServer(url, timeoutMs = 30000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok || res.status < 500) return true;
|
||||
} catch {
|
||||
/* server not ready yet */
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
console.warn("[Electron] Server readiness timeout — showing window anyway");
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Helper: Wait for server process exit with timeout (#2) ─
|
||||
async function waitForServerExit(proc, timeoutMs = 5000) {
|
||||
if (!proc) return;
|
||||
await Promise.race([
|
||||
new Promise((r) => proc.once("exit", r)),
|
||||
new Promise((r) =>
|
||||
setTimeout(() => {
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
r();
|
||||
}, timeoutMs)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Content Security Policy (#15) ──────────────────────────
|
||||
function setupContentSecurityPolicy() {
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
`connect-src 'self' http://localhost:* ws://localhost:* https://*.omniroute.online https://*.omniroute.dev`,
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"media-src 'self'",
|
||||
].join("; ");
|
||||
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
"Content-Security-Policy": [csp],
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Create Window ──────────────────────────────────────────
|
||||
function createWindow() {
|
||||
// Platform-conditional options (#9)
|
||||
const platformWindowOptions =
|
||||
process.platform === "darwin"
|
||||
? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 16 } }
|
||||
: { titleBarStyle: "default" };
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 1024,
|
||||
minHeight: 700,
|
||||
title: "OmniRoute",
|
||||
icon: path.join(RESOURCES_PATH, "assets", "icon.png"),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: true,
|
||||
},
|
||||
show: false,
|
||||
backgroundColor: "#0a0a0a",
|
||||
...platformWindowOptions,
|
||||
});
|
||||
|
||||
// Load the Next.js app
|
||||
mainWindow.loadURL(getServerUrl());
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools({ mode: "detach" });
|
||||
}
|
||||
|
||||
// Show window when ready
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
// Handle external links — validate URL protocol to prevent RCE
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
if (["http:", "https:"].includes(parsedUrl.protocol)) {
|
||||
shell.openExternal(url);
|
||||
} else {
|
||||
console.warn("[Electron] Blocked unsafe protocol:", parsedUrl.protocol);
|
||||
}
|
||||
} catch {
|
||||
console.error("[Electron] Blocked invalid URL:", url);
|
||||
}
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Handle window close — minimize to tray
|
||||
mainWindow.on("close", (event) => {
|
||||
if (!app.isQuitting) {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
// ── System Tray ────────────────────────────────────────────
|
||||
function createTray() {
|
||||
// Fix #4: Destroy old tray before recreating
|
||||
if (tray) {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
}
|
||||
|
||||
const iconPath = path.join(RESOURCES_PATH, "assets", "tray-icon.png");
|
||||
let icon;
|
||||
try {
|
||||
icon = nativeImage.createFromPath(iconPath);
|
||||
if (icon.isEmpty()) icon = nativeImage.createEmpty();
|
||||
} catch {
|
||||
icon = nativeImage.createEmpty();
|
||||
}
|
||||
|
||||
tray = new Tray(icon);
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: "Open OmniRoute",
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Open Dashboard",
|
||||
click: () => shell.openExternal(getServerUrl()),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Server Port",
|
||||
submenu: [
|
||||
{ label: `Port: ${serverPort}`, enabled: false },
|
||||
{ type: "separator" },
|
||||
{ label: "20128", click: () => changePort(20128) },
|
||||
{ label: "3000", click: () => changePort(3000) },
|
||||
{ label: "8080", click: () => changePort(8080) },
|
||||
],
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
app.isQuitting = true;
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
tray.setToolTip("OmniRoute");
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
tray.on("double-click", () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Change Port (#3: now restarts server) ──────────────────
|
||||
async function changePort(newPort) {
|
||||
if (newPort === serverPort) return;
|
||||
|
||||
const oldPort = serverPort;
|
||||
serverPort = newPort;
|
||||
|
||||
sendToRenderer("server-status", { status: "restarting", port: newPort });
|
||||
|
||||
// Stop current server and wait for exit
|
||||
const serverToStop = nextServer;
|
||||
stopNextServer();
|
||||
await waitForServerExit(serverToStop);
|
||||
|
||||
// Start server on new port
|
||||
startNextServer();
|
||||
await waitForServer(getServerUrl());
|
||||
|
||||
// Reload window and update tray
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.loadURL(getServerUrl());
|
||||
}
|
||||
createTray();
|
||||
|
||||
sendToRenderer("port-changed", serverPort);
|
||||
sendToRenderer("server-status", { status: "running", port: serverPort });
|
||||
console.log(`[Electron] Port changed: ${oldPort} → ${serverPort}`);
|
||||
}
|
||||
|
||||
// ── Server Lifecycle (#1, #5, #10) ─────────────────────────
|
||||
function startNextServer() {
|
||||
if (isDev) {
|
||||
console.log("[Electron] Dev mode — connect to existing Next.js server");
|
||||
sendToRenderer("server-status", { status: "running", port: serverPort });
|
||||
return;
|
||||
}
|
||||
|
||||
const serverScript = path.join(NEXT_SERVER_PATH, "server.js");
|
||||
if (!fs.existsSync(serverScript)) {
|
||||
console.error("[Electron] Server script not found:", serverScript);
|
||||
sendToRenderer("server-status", { status: "error", port: serverPort });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Electron] Starting Next.js server on port", serverPort);
|
||||
sendToRenderer("server-status", { status: "starting", port: serverPort });
|
||||
|
||||
// Fix #10: Use pipe instead of inherit for logging & readiness detection
|
||||
nextServer = spawn("node", [serverScript], {
|
||||
cwd: NEXT_SERVER_PATH,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(serverPort),
|
||||
NODE_ENV: "production",
|
||||
},
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
// Capture server output for logging
|
||||
nextServer.stdout?.on("data", (data) => {
|
||||
const text = data.toString();
|
||||
process.stdout.write(`[Server] ${text}`);
|
||||
|
||||
// Detect server ready
|
||||
if (text.includes("Ready") || text.includes("started") || text.includes("listening")) {
|
||||
sendToRenderer("server-status", { status: "running", port: serverPort });
|
||||
}
|
||||
});
|
||||
|
||||
nextServer.stderr?.on("data", (data) => {
|
||||
process.stderr.write(`[Server:err] ${data}`);
|
||||
});
|
||||
|
||||
nextServer.on("error", (err) => {
|
||||
console.error("[Electron] Failed to start server:", err);
|
||||
sendToRenderer("server-status", { status: "error", port: serverPort });
|
||||
});
|
||||
|
||||
nextServer.on("exit", (code) => {
|
||||
console.log("[Electron] Server exited with code:", code);
|
||||
sendToRenderer("server-status", { status: "stopped", port: serverPort });
|
||||
nextServer = null;
|
||||
});
|
||||
}
|
||||
|
||||
function stopNextServer() {
|
||||
if (nextServer) {
|
||||
nextServer.kill("SIGTERM");
|
||||
nextServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── IPC Handlers ───────────────────────────────────────────
|
||||
function setupIpcHandlers() {
|
||||
ipcMain.handle("get-app-info", () => ({
|
||||
name: app.getName(),
|
||||
version: app.getVersion(),
|
||||
platform: process.platform,
|
||||
isDev,
|
||||
port: serverPort,
|
||||
}));
|
||||
|
||||
ipcMain.handle("open-external", (_event, url) => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
if (["http:", "https:"].includes(parsedUrl.protocol)) {
|
||||
shell.openExternal(url);
|
||||
}
|
||||
} catch {
|
||||
console.error("[Electron] Blocked invalid URL:", url);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("get-data-dir", () => app.getPath("userData"));
|
||||
|
||||
// Fix #2: Add timeout to restart
|
||||
ipcMain.handle("restart-server", async () => {
|
||||
const serverToStop = nextServer;
|
||||
stopNextServer();
|
||||
await waitForServerExit(serverToStop);
|
||||
startNextServer();
|
||||
await waitForServer(getServerUrl());
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Window controls
|
||||
ipcMain.on("window-minimize", () => mainWindow?.minimize());
|
||||
|
||||
ipcMain.on("window-maximize", () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.isMaximized() ? mainWindow.unmaximize() : mainWindow.maximize();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("window-close", () => mainWindow?.close());
|
||||
}
|
||||
|
||||
// ── App Lifecycle ──────────────────────────────────────────
|
||||
app.whenReady().then(async () => {
|
||||
// Fix #15: Set up CSP before any content loads
|
||||
setupContentSecurityPolicy();
|
||||
|
||||
// Fix #1: Start server and WAIT for readiness before showing window
|
||||
startNextServer();
|
||||
if (!isDev) {
|
||||
await waitForServer(getServerUrl());
|
||||
}
|
||||
|
||||
createWindow();
|
||||
createTray();
|
||||
setupIpcHandlers();
|
||||
|
||||
// macOS: recreate window when dock icon clicked
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
} else if (mainWindow) {
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Quit when all windows closed (except macOS)
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up before quit
|
||||
app.on("before-quit", () => {
|
||||
app.isQuitting = true;
|
||||
stopNextServer();
|
||||
});
|
||||
|
||||
// Global error handlers
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error("[Electron] Uncaught Exception:", error);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
console.error("[Electron] Unhandled Rejection:", reason);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"name": "omniroute-desktop",
|
||||
"version": "1.6.4",
|
||||
"description": "OmniRoute Desktop Application",
|
||||
"main": "main.js",
|
||||
"author": "OmniRoute Team",
|
||||
"license": "MIT",
|
||||
"homepage": "https://omniroute.online",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "electron . --no-sandbox",
|
||||
"build": "electron-builder",
|
||||
"build:win": "electron-builder --win",
|
||||
"build:mac": "electron-builder --mac",
|
||||
"build:linux": "electron-builder --linux",
|
||||
"pack": "electron-builder --dir"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
"electron-builder": "^25.1.8"
|
||||
},
|
||||
"build": {
|
||||
"appId": "online.omniroute.desktop",
|
||||
"productName": "OmniRoute",
|
||||
"copyright": "Copyright © 2025 OmniRoute",
|
||||
"directories": {
|
||||
"output": "dist-electron",
|
||||
"buildResources": "assets"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../.next/standalone",
|
||||
"to": "app",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../.next/static",
|
||||
"to": "app/.next/static",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../public",
|
||||
"to": "app/public",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "assets/icon.ico"
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "assets/icon.icns",
|
||||
"category": "public.app-category.productivity"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "assets/icons",
|
||||
"category": "Utility"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"installerIcon": "assets/icon.ico",
|
||||
"uninstallerIcon": "assets/icon.ico"
|
||||
},
|
||||
"dmg": {
|
||||
"contents": [
|
||||
{
|
||||
"x": 130,
|
||||
"y": 220
|
||||
},
|
||||
{
|
||||
"x": 410,
|
||||
"y": 220,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* OmniRoute Electron Desktop App - Preload Script
|
||||
*
|
||||
* Secure bridge between renderer (Next.js) and main process (Electron).
|
||||
* Uses contextIsolation: true for maximum security.
|
||||
*
|
||||
* Code Review Fixes Applied:
|
||||
* #6 Listener accumulation — return disposer functions instead of using removeAllListeners
|
||||
* #16 Simplified channel validation — generic wrapper reduces boilerplate
|
||||
*/
|
||||
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
// ── Channel Whitelist ──────────────────────────────────────
|
||||
const VALID_CHANNELS = {
|
||||
invoke: ["get-app-info", "open-external", "get-data-dir", "restart-server"],
|
||||
send: ["window-minimize", "window-maximize", "window-close"],
|
||||
receive: ["server-status", "port-changed"],
|
||||
};
|
||||
|
||||
// ── Fix #16: Generic IPC wrappers ──────────────────────────
|
||||
function safeInvoke(channel, ...args) {
|
||||
if (!VALID_CHANNELS.invoke.includes(channel)) {
|
||||
return Promise.reject(new Error(`Blocked IPC invoke: ${channel}`));
|
||||
}
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
}
|
||||
|
||||
function safeSend(channel, ...args) {
|
||||
if (VALID_CHANNELS.send.includes(channel)) {
|
||||
ipcRenderer.send(channel, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// Fix #6: Return disposer function for proper listener cleanup
|
||||
function safeOn(channel, callback) {
|
||||
if (!VALID_CHANNELS.receive.includes(channel)) return () => {};
|
||||
const handler = (_event, data) => callback(data);
|
||||
ipcRenderer.on(channel, handler);
|
||||
// Return a disposer — caller removes only THIS specific listener
|
||||
return () => ipcRenderer.removeListener(channel, handler);
|
||||
}
|
||||
|
||||
// ── Expose API to Renderer ─────────────────────────────────
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
// ── Invoke (async, returns Promise) ──────────────────────
|
||||
getAppInfo: () => safeInvoke("get-app-info"),
|
||||
openExternal: (url) => safeInvoke("open-external", url),
|
||||
getDataDir: () => safeInvoke("get-data-dir"),
|
||||
restartServer: () => safeInvoke("restart-server"),
|
||||
|
||||
// ── Send (fire-and-forget) ───────────────────────────────
|
||||
minimizeWindow: () => safeSend("window-minimize"),
|
||||
maximizeWindow: () => safeSend("window-maximize"),
|
||||
closeWindow: () => safeSend("window-close"),
|
||||
|
||||
// ── Receive (event listeners) ────────────────────────────
|
||||
// Fix #6: Returns a disposer function for precise cleanup
|
||||
onServerStatus: (callback) => safeOn("server-status", callback),
|
||||
onPortChanged: (callback) => safeOn("port-changed", callback),
|
||||
|
||||
// ── Static Properties ────────────────────────────────────
|
||||
isElectron: true,
|
||||
platform: process.platform,
|
||||
});
|
||||
Vendored
+51
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* OmniRoute Electron Types
|
||||
*
|
||||
* TypeScript definitions for the Electron API exposed to the renderer process.
|
||||
*
|
||||
* Updated to reflect:
|
||||
* - Fix #6: onServerStatus/onPortChanged return disposer functions
|
||||
* - Removed removeServerStatusListener/removePortChangedListener (replaced by disposers)
|
||||
*/
|
||||
|
||||
export interface AppInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
platform: "win32" | "darwin" | "linux";
|
||||
isDev: boolean;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface ServerStatus {
|
||||
status: "starting" | "running" | "stopped" | "restarting" | "error";
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
// ── Invoke (async) ─────────────────────────────────────
|
||||
getAppInfo(): Promise<AppInfo>;
|
||||
openExternal(url: string): Promise<void>;
|
||||
getDataDir(): Promise<string>;
|
||||
restartServer(): Promise<{ success: boolean }>;
|
||||
|
||||
// ── Send (fire-and-forget) ─────────────────────────────
|
||||
minimizeWindow(): void;
|
||||
maximizeWindow(): void;
|
||||
closeWindow(): void;
|
||||
|
||||
// ── Receive (returns disposer for cleanup) ─────────────
|
||||
onServerStatus(callback: (data: ServerStatus) => void): () => void;
|
||||
onPortChanged(callback: (port: number) => void): () => void;
|
||||
|
||||
// ── Static Properties ──────────────────────────────────
|
||||
isElectron: boolean;
|
||||
platform: "win32" | "darwin" | "linux";
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Generated
+505
-9
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"open-sse"
|
||||
@@ -52,6 +53,8 @@
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"husky": "^9.1.7",
|
||||
@@ -60,7 +63,8 @@
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.0"
|
||||
"typescript-eslint": "^8.56.0",
|
||||
"wait-on": "^9.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0 <24.0.0"
|
||||
@@ -365,6 +369,13 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
@@ -1003,6 +1014,60 @@
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/address": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",
|
||||
"integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^11.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/formula": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz",
|
||||
"integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/hoek": {
|
||||
"version": "11.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz",
|
||||
"integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/pinpoint": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz",
|
||||
"integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/tlds": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz",
|
||||
"integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/topo": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz",
|
||||
"integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^11.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -2280,7 +2345,7 @@
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
@@ -3010,7 +3075,7 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -3917,6 +3982,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
@@ -3952,6 +4024,18 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -4336,6 +4420,94 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -4371,6 +4543,19 @@
|
||||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
@@ -4388,6 +4573,47 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "4.1.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.3",
|
||||
"supports-color": "8.1.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
@@ -4435,6 +4661,24 @@
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@epic-web/invariant": "^1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "dist/bin/cross-env.js",
|
||||
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -4454,7 +4698,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
@@ -4784,6 +5028,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -5858,6 +6112,46 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -5886,6 +6180,7 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -5956,6 +6251,16 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
|
||||
@@ -7032,6 +7337,25 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "18.0.2",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz",
|
||||
"integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/address": "^5.1.1",
|
||||
"@hapi/formula": "^3.0.2",
|
||||
"@hapi/hoek": "^11.0.7",
|
||||
"@hapi/pinpoint": "^2.0.1",
|
||||
"@hapi/tlds": "^1.1.1",
|
||||
"@hapi/topo": "^6.0.2",
|
||||
"@standard-schema/spec": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
|
||||
@@ -7504,6 +7828,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -8495,7 +8826,7 @@
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
@@ -8514,7 +8845,7 @@
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
@@ -8673,6 +9004,13 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
||||
@@ -8826,6 +9164,7 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
@@ -8975,6 +9314,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
@@ -9114,6 +9463,16 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-array-concat": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||
@@ -9430,6 +9789,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
@@ -10071,6 +10443,16 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||
@@ -10277,7 +10659,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -10510,6 +10892,26 @@
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wait-on": {
|
||||
"version": "9.0.4",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz",
|
||||
"integrity": "sha512-k8qrgfwrPVJXTeFY8tl6BxVHiclK11u72DVKhpybHfUL/K6KM4bdyK9EhIVYGytB5MJe/3lq4Tf0hrjM+pvJZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.5",
|
||||
"joi": "^18.0.2",
|
||||
"lodash": "^4.17.23",
|
||||
"minimist": "^1.2.8",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"bin": {
|
||||
"wait-on": "bin/wait-on"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -10721,6 +11123,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
@@ -10744,6 +11156,90 @@
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yargs/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
+10
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "1.6.1",
|
||||
"version": "1.6.4",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -48,6 +48,11 @@
|
||||
"build:cli": "node scripts/prepublish.mjs",
|
||||
"start": "node scripts/run-next.mjs start",
|
||||
"lint": "eslint .",
|
||||
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:20128 && cd electron && npm run dev\"",
|
||||
"electron:build": "npm run build && cd electron && npm run build",
|
||||
"electron:build:win": "npm run build && cd electron && npm run build:win",
|
||||
"electron:build:mac": "npm run build && cd electron && npm run build:mac",
|
||||
"electron:build:linux": "npm run build && cd electron && npm run build:linux",
|
||||
"test": "node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:unit": "node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:plan3": "node --test tests/unit/plan3-p0.test.mjs",
|
||||
@@ -98,6 +103,8 @@
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"husky": "^9.1.7",
|
||||
@@ -106,7 +113,8 @@
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.0"
|
||||
"typescript-eslint": "^8.56.0",
|
||||
"wait-on": "^9.0.4"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,mjs}": [
|
||||
|
||||
@@ -51,6 +51,38 @@ console.log(" 📋 Copying standalone build to app/...");
|
||||
mkdirSync(APP_DIR, { recursive: true });
|
||||
cpSync(standaloneDir, APP_DIR, { recursive: true });
|
||||
|
||||
// ── Step 5.5: Sanitize hardcoded build-machine paths ───────
|
||||
// Next.js standalone bakes absolute build-time paths into server.js and
|
||||
// required-server-files.json (outputFileTracingRoot, appDir, turbopack root).
|
||||
// Replace the build machine's absolute path with "." (current directory)
|
||||
// so paths resolve relative to wherever the standalone app/ is installed.
|
||||
console.log(" 🧹 Sanitizing build-machine paths...");
|
||||
const buildRoot = ROOT.replace(/\\/g, "/"); // normalise for regex safety
|
||||
const sanitizeTargets = [
|
||||
join(APP_DIR, "server.js"),
|
||||
join(APP_DIR, ".next", "required-server-files.json"),
|
||||
];
|
||||
let sanitisedCount = 0;
|
||||
for (const filePath of sanitizeTargets) {
|
||||
if (!existsSync(filePath)) continue;
|
||||
let content = readFileSync(filePath, "utf8");
|
||||
// Escape special regex characters in the path
|
||||
const escaped = buildRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(escaped, "g");
|
||||
const matches = content.match(re);
|
||||
if (matches) {
|
||||
// Replace with "." so Next.js resolves paths relative to the standalone dir
|
||||
content = content.replace(re, ".");
|
||||
writeFileSync(filePath, content);
|
||||
sanitisedCount += matches.length;
|
||||
}
|
||||
}
|
||||
if (sanitisedCount > 0) {
|
||||
console.log(` ✅ Sanitised ${sanitisedCount} hardcoded path references`);
|
||||
} else {
|
||||
console.log(" ℹ️ No hardcoded paths found to sanitise");
|
||||
}
|
||||
|
||||
// ── Step 6: Copy static assets ─────────────────────────────
|
||||
const staticSrc = join(ROOT, ".next", "static");
|
||||
const staticDest = join(APP_DIR, ".next", "static");
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function CombosPage() {
|
||||
const notify = useNotificationStore();
|
||||
const [proxyTargetCombo, setProxyTargetCombo] = useState(null);
|
||||
const [proxyConfig, setProxyConfig] = useState(null);
|
||||
const [providerNodes, setProviderNodes] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -60,14 +61,16 @@ export default function CombosPage() {
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [combosRes, providersRes, metricsRes] = await Promise.all([
|
||||
const [combosRes, providersRes, metricsRes, nodesRes] = await Promise.all([
|
||||
fetch("/api/combos"),
|
||||
fetch("/api/providers"),
|
||||
fetch("/api/combos/metrics"),
|
||||
fetch("/api/provider-nodes"),
|
||||
]);
|
||||
const combosData = await combosRes.json();
|
||||
const providersData = await providersRes.json();
|
||||
const metricsData = await metricsRes.json();
|
||||
const nodesData = nodesRes.ok ? await nodesRes.json() : { nodes: [] };
|
||||
|
||||
if (combosRes.ok) setCombos(combosData.combos || []);
|
||||
if (providersRes.ok) {
|
||||
@@ -77,6 +80,7 @@ export default function CombosPage() {
|
||||
setActiveProviders(active);
|
||||
}
|
||||
if (metricsRes.ok) setMetrics(metricsData.metrics || {});
|
||||
setProviderNodes(nodesData.nodes || []);
|
||||
} catch (error) {
|
||||
console.log("Error fetching data:", error);
|
||||
} finally {
|
||||
@@ -231,6 +235,7 @@ export default function CombosPage() {
|
||||
key={combo.id}
|
||||
combo={combo}
|
||||
metrics={metrics[combo.name]}
|
||||
providerNodes={providerNodes}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onEdit={() => setEditingCombo(combo)}
|
||||
@@ -310,6 +315,7 @@ function ComboCard({
|
||||
onProxy,
|
||||
hasProxy,
|
||||
onToggle,
|
||||
providerNodes,
|
||||
}) {
|
||||
const strategy = combo.strategy || "priority";
|
||||
const models = combo.models || [];
|
||||
@@ -317,6 +323,17 @@ function ComboCard({
|
||||
const t = useTranslations("combos");
|
||||
const tc = useTranslations("common");
|
||||
|
||||
// Resolve provider UUID to user-defined name
|
||||
const formatModelDisplay = (modelValue) => {
|
||||
const parts = modelValue.split("/");
|
||||
if (parts.length !== 2) return modelValue;
|
||||
const [providerIdentifier, modelId] = parts;
|
||||
const matchedNode = (providerNodes || []).find(
|
||||
(node) => node.id === providerIdentifier || node.prefix === providerIdentifier
|
||||
);
|
||||
return matchedNode ? `${matchedNode.name}/${modelId}` : modelValue;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="sm" className={`group ${isDisabled ? "opacity-50" : ""}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -381,7 +398,7 @@ function ComboCard({
|
||||
key={index}
|
||||
className="text-[10px] font-mono bg-black/5 dark:bg-white/5 px-1.5 py-0.5 rounded text-text-muted"
|
||||
>
|
||||
{model}
|
||||
{formatModelDisplay(model)}
|
||||
{strategy === "weighted" && weight > 0 ? ` (${weight}%)` : ""}
|
||||
</code>
|
||||
);
|
||||
|
||||
+70
-30
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* In-memory LRU cache for LLM prompt/response pairs.
|
||||
* Uses content hashing for cache keys to handle semantic deduplication.
|
||||
* Memory-optimized with byte-based limits.
|
||||
*
|
||||
* @module lib/cacheLayer
|
||||
*/
|
||||
@@ -19,22 +20,30 @@ import crypto from "node:crypto";
|
||||
* @property {number} hits - Number of times this entry was accessed
|
||||
*/
|
||||
|
||||
const DEFAULT_MAX_ENTRIES = 50;
|
||||
const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const DEFAULT_TTL = 300000;
|
||||
|
||||
export class LRUCache {
|
||||
/** @type {Map<string, CacheEntry>} */
|
||||
#cache = new Map();
|
||||
#maxSize;
|
||||
#maxBytes;
|
||||
#defaultTTL;
|
||||
#currentSize = 0;
|
||||
#currentBytes = 0;
|
||||
#stats = { hits: 0, misses: 0, evictions: 0 };
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {number} [options.maxSize=100] - Max number of entries
|
||||
* @param {number} [options.maxSize=50] - Max number of entries (reduced for memory)
|
||||
* @param {number} [options.maxBytes=2097152] - Max bytes (default: 2MB)
|
||||
* @param {number} [options.defaultTTL=300000] - Default TTL in ms (5 min)
|
||||
*/
|
||||
constructor(options: any = {}) {
|
||||
this.#maxSize = options.maxSize ?? 100;
|
||||
this.#defaultTTL = options.defaultTTL ?? 300000;
|
||||
constructor(options: { maxSize?: number; maxBytes?: number; defaultTTL?: number } = {}) {
|
||||
this.#maxSize = options.maxSize ?? DEFAULT_MAX_ENTRIES;
|
||||
this.#maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
this.#defaultTTL = options.defaultTTL ?? DEFAULT_TTL;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +51,7 @@ export class LRUCache {
|
||||
* @param {Object} params - Parameters to hash
|
||||
* @returns {string} Cache key
|
||||
*/
|
||||
static generateKey(params) {
|
||||
static generateKey(params: Record<string, unknown>) {
|
||||
const normalized = JSON.stringify(params, Object.keys(params).sort());
|
||||
return crypto.createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
||||
}
|
||||
@@ -52,7 +61,7 @@ export class LRUCache {
|
||||
* @param {string} key
|
||||
* @returns {*|undefined}
|
||||
*/
|
||||
get(key) {
|
||||
get(key: string) {
|
||||
const entry = this.#cache.get(key);
|
||||
|
||||
if (!entry) {
|
||||
@@ -60,15 +69,12 @@ export class LRUCache {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check TTL
|
||||
if (Date.now() - entry.createdAt > entry.ttl) {
|
||||
this.#cache.delete(key);
|
||||
this.#currentSize--;
|
||||
this.#deleteEntry(key, entry);
|
||||
this.#stats.misses++;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Move to end (most recently used)
|
||||
this.#cache.delete(key);
|
||||
entry.hits++;
|
||||
this.#cache.set(key, entry);
|
||||
@@ -83,18 +89,25 @@ export class LRUCache {
|
||||
* @param {*} value
|
||||
* @param {number} [ttl] - Override default TTL
|
||||
*/
|
||||
set(key, value, ttl) {
|
||||
// If key exists, delete it first (will be re-added at end)
|
||||
set(key: string, value: unknown, ttl?: number) {
|
||||
const entrySize = this.#estimateSize(value);
|
||||
|
||||
if (this.#cache.has(key)) {
|
||||
this.#cache.delete(key);
|
||||
const oldEntry = this.#cache.get(key)!;
|
||||
this.#currentBytes -= oldEntry.size || 0;
|
||||
this.#currentSize--;
|
||||
this.#cache.delete(key);
|
||||
}
|
||||
|
||||
// Evict oldest entries if at capacity
|
||||
while (this.#currentSize >= this.#maxSize) {
|
||||
while (
|
||||
(this.#currentSize >= this.#maxSize || this.#currentBytes + entrySize > this.#maxBytes) &&
|
||||
this.#cache.size > 0
|
||||
) {
|
||||
const oldestKey = this.#cache.keys().next().value;
|
||||
this.#cache.delete(oldestKey);
|
||||
this.#currentSize--;
|
||||
const oldestEntry = this.#cache.get(oldestKey);
|
||||
if (oldestEntry) {
|
||||
this.#deleteEntry(oldestKey, oldestEntry);
|
||||
}
|
||||
this.#stats.evictions++;
|
||||
}
|
||||
|
||||
@@ -103,12 +116,34 @@ export class LRUCache {
|
||||
value,
|
||||
createdAt: Date.now(),
|
||||
ttl: ttl ?? this.#defaultTTL,
|
||||
size: JSON.stringify(value).length,
|
||||
size: entrySize,
|
||||
hits: 0,
|
||||
};
|
||||
|
||||
this.#cache.set(key, entry);
|
||||
this.#currentSize++;
|
||||
this.#currentBytes += entrySize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate size of a value in bytes.
|
||||
*/
|
||||
#estimateSize(value: unknown): number {
|
||||
try {
|
||||
return JSON.stringify(value).length * 2;
|
||||
} catch {
|
||||
return 1024;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an entry and update counters.
|
||||
*/
|
||||
#deleteEntry(key: string, entry: { size?: number }) {
|
||||
this.#cache.delete(key);
|
||||
this.#currentSize--;
|
||||
this.#currentBytes -= entry.size || 0;
|
||||
if (this.#currentBytes < 0) this.#currentBytes = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,12 +151,11 @@ export class LRUCache {
|
||||
* @param {string} key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
has(key) {
|
||||
has(key: string) {
|
||||
const entry = this.#cache.get(key);
|
||||
if (!entry) return false;
|
||||
if (Date.now() - entry.createdAt > entry.ttl) {
|
||||
this.#cache.delete(key);
|
||||
this.#currentSize--;
|
||||
this.#deleteEntry(key, entry);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -132,10 +166,10 @@ export class LRUCache {
|
||||
* @param {string} key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
delete(key) {
|
||||
if (this.#cache.has(key)) {
|
||||
this.#cache.delete(key);
|
||||
this.#currentSize--;
|
||||
delete(key: string) {
|
||||
const entry = this.#cache.get(key);
|
||||
if (entry) {
|
||||
this.#deleteEntry(key, entry);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -145,14 +179,17 @@ export class LRUCache {
|
||||
clear() {
|
||||
this.#cache.clear();
|
||||
this.#currentSize = 0;
|
||||
this.#currentBytes = 0;
|
||||
}
|
||||
|
||||
/** @returns {{ size: number, maxSize: number, hits: number, misses: number, evictions: number, hitRate: number }} */
|
||||
/** @returns {{ size: number, maxSize: number, bytes: number, maxBytes: number, hits: number, misses: number, evictions: number, hitRate: number }} */
|
||||
getStats() {
|
||||
const total = this.#stats.hits + this.#stats.misses;
|
||||
return {
|
||||
size: this.#currentSize,
|
||||
maxSize: this.#maxSize,
|
||||
bytes: this.#currentBytes,
|
||||
maxBytes: this.#maxBytes,
|
||||
...this.#stats,
|
||||
hitRate: total > 0 ? (this.#stats.hits / total) * 100 : 0,
|
||||
};
|
||||
@@ -161,18 +198,21 @@ export class LRUCache {
|
||||
|
||||
// ─── Prompt Cache Singleton ─────────────────
|
||||
|
||||
let promptCache;
|
||||
let promptCache: LRUCache | null = null;
|
||||
|
||||
/**
|
||||
* Get the global prompt cache instance.
|
||||
* @param {Object} [options]
|
||||
* @returns {LRUCache}
|
||||
*/
|
||||
export function getPromptCache(options?: any) {
|
||||
export function getPromptCache(
|
||||
options?: { maxSize?: number; maxBytes?: number; defaultTTL?: number } & Record<string, unknown>
|
||||
) {
|
||||
if (!promptCache) {
|
||||
promptCache = new LRUCache({
|
||||
maxSize: parseInt(process.env.PROMPT_CACHE_MAX_SIZE || "200", 10),
|
||||
defaultTTL: parseInt(process.env.PROMPT_CACHE_TTL_MS || "600000", 10),
|
||||
maxSize: parseInt(process.env.PROMPT_CACHE_MAX_SIZE || "50", 10),
|
||||
maxBytes: parseInt(process.env.PROMPT_CACHE_MAX_BYTES || String(2 * 1024 * 1024), 10),
|
||||
defaultTTL: parseInt(process.env.PROMPT_CACHE_TTL_MS || "300000", 10),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
+43
-11
@@ -322,28 +322,60 @@ export function getDbInstance() {
|
||||
return _db;
|
||||
}
|
||||
|
||||
// Detect and replace old incompatible schema
|
||||
// Detect and handle old schema format — preserve data when possible (#146)
|
||||
if (fs.existsSync(SQLITE_FILE)) {
|
||||
try {
|
||||
const probe = new Database(SQLITE_FILE, { readonly: true });
|
||||
const hasOldSchema = probe
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'")
|
||||
.get();
|
||||
probe.close();
|
||||
|
||||
if (hasOldSchema) {
|
||||
const oldPath = SQLITE_FILE + ".old-schema";
|
||||
console.log(
|
||||
`[DB] Old incompatible schema detected — renaming to ${path.basename(oldPath)}`
|
||||
);
|
||||
fs.renameSync(SQLITE_FILE, oldPath);
|
||||
for (const ext of ["-wal", "-shm"]) {
|
||||
// Check if the DB has actual data we should preserve
|
||||
let hasData = false;
|
||||
try {
|
||||
const count = probe.prepare("SELECT COUNT(*) as c FROM provider_connections").get() as
|
||||
| { c: number }
|
||||
| undefined;
|
||||
hasData = count && count.c > 0;
|
||||
} catch {
|
||||
// Table might not exist at all — truly incompatible
|
||||
}
|
||||
probe.close();
|
||||
|
||||
if (hasData) {
|
||||
// Data exists — preserve it! Just drop the old migration tracking table
|
||||
// and let our new migration system (CREATE TABLE IF NOT EXISTS) take over
|
||||
console.log(
|
||||
`[DB] Old schema_migrations table found but data exists — preserving data (#146)`
|
||||
);
|
||||
const fixDb = new Database(SQLITE_FILE);
|
||||
try {
|
||||
if (fs.existsSync(SQLITE_FILE + ext)) fs.unlinkSync(SQLITE_FILE + ext);
|
||||
} catch {
|
||||
/* ok */
|
||||
fixDb.exec("DROP TABLE IF EXISTS schema_migrations");
|
||||
// Clean up WAL/SHM files that might be stale
|
||||
fixDb.pragma("wal_checkpoint(TRUNCATE)");
|
||||
} catch (e) {
|
||||
console.warn("[DB] Could not clean up old schema table:", e.message);
|
||||
} finally {
|
||||
fixDb.close();
|
||||
}
|
||||
} else {
|
||||
// No data — safe to rename and start fresh
|
||||
const oldPath = SQLITE_FILE + ".old-schema";
|
||||
console.log(
|
||||
`[DB] Old incompatible schema detected (empty) — renaming to ${path.basename(oldPath)}`
|
||||
);
|
||||
fs.renameSync(SQLITE_FILE, oldPath);
|
||||
for (const ext of ["-wal", "-shm"]) {
|
||||
try {
|
||||
if (fs.existsSync(SQLITE_FILE + ext)) fs.unlinkSync(SQLITE_FILE + ext);
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
probe.close();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[DB] Could not probe existing DB, will create fresh:", e.message);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getDbInstance, isCloud, isBuildPhase } from "./db/core";
|
||||
|
||||
const shouldPersistToDisk = !isCloud && !isBuildPhase;
|
||||
|
||||
const MAX_ENTRIES = 500;
|
||||
const MAX_ENTRIES = parseInt(process.env.PROXY_LOG_MAX_ENTRIES || "200", 10);
|
||||
|
||||
interface ProxyInfo {
|
||||
type: string;
|
||||
|
||||
@@ -16,14 +16,15 @@ import { getDbInstance } from "./db/core";
|
||||
|
||||
// ─── Singleton ─────────────────
|
||||
|
||||
let memoryCache;
|
||||
let memoryCache: LRUCache | null = null;
|
||||
let stats = { hits: 0, misses: 0, tokensSaved: 0 };
|
||||
|
||||
function getMemoryCache() {
|
||||
if (!memoryCache) {
|
||||
memoryCache = new LRUCache({
|
||||
maxSize: parseInt(process.env.SEMANTIC_CACHE_MAX_SIZE || "500", 10),
|
||||
defaultTTL: parseInt(process.env.SEMANTIC_CACHE_TTL_MS || "3600000", 10), // 1h
|
||||
maxSize: parseInt(process.env.SEMANTIC_CACHE_MAX_SIZE || "100", 10),
|
||||
maxBytes: parseInt(process.env.SEMANTIC_CACHE_MAX_BYTES || String(4 * 1024 * 1024), 10),
|
||||
defaultTTL: parseInt(process.env.SEMANTIC_CACHE_TTL_MS || "1800000", 10),
|
||||
});
|
||||
}
|
||||
return memoryCache;
|
||||
|
||||
@@ -12,7 +12,7 @@ import fs from "fs";
|
||||
import { getDbInstance } from "../db/core";
|
||||
import { shouldPersistToDisk, CALL_LOGS_DIR } from "./migrations";
|
||||
|
||||
const CALL_LOGS_MAX = 500;
|
||||
const CALL_LOGS_MAX = parseInt(process.env.CALL_LOGS_MAX || "200", 10);
|
||||
const LOG_RETENTION_DAYS = parseInt(process.env.LOG_RETENTION_DAYS || "7", 10);
|
||||
|
||||
/** Fields that should always be redacted from logged payloads */
|
||||
|
||||
@@ -43,29 +43,32 @@ const DEFAULT_VISIBLE = Object.fromEntries(COLUMNS.map((c) => [c.key, true]));
|
||||
/**
|
||||
* Get a friendly display label for compatible providers.
|
||||
* Converts long IDs like "openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441"
|
||||
* to readable labels like "OAI-Compat".
|
||||
* to readable labels. If providerNodes are available, uses user-defined name;
|
||||
* otherwise falls back to "OAI-Compat".
|
||||
*/
|
||||
function getProviderDisplayLabel(provider: string): string {
|
||||
function getProviderDisplayLabel(provider: string, providerNodes?: any[]): string {
|
||||
if (!provider) return "-";
|
||||
if (provider.startsWith("openai-compatible-")) {
|
||||
// Extract the "chat" or custom-name part after the prefix
|
||||
const suffix = provider.replace("openai-compatible-", "");
|
||||
// If it's just "chat-<uuid>", show "OAI-Compat"
|
||||
// If it has a meaningful name, include it
|
||||
const parts = suffix.split("-");
|
||||
if (parts.length > 1 && parts[1]?.length >= 8) {
|
||||
// Looks like chat-<uuid>, just show category
|
||||
return `OAI-COMPAT`;
|
||||
if (provider.startsWith("openai-compatible-") || provider.startsWith("anthropic-compatible-")) {
|
||||
// Try to find user-defined name from provider nodes
|
||||
if (providerNodes?.length) {
|
||||
const matchedNode = providerNodes.find(
|
||||
(node) => node.id === provider || node.prefix === provider
|
||||
);
|
||||
if (matchedNode?.name) return matchedNode.name;
|
||||
}
|
||||
return `OAI: ${suffix.slice(0, 16).toUpperCase()}`;
|
||||
}
|
||||
if (provider.startsWith("anthropic-compatible-")) {
|
||||
const suffix = provider.replace("anthropic-compatible-", "");
|
||||
const parts = suffix.split("-");
|
||||
if (parts.length > 1 && parts[1]?.length >= 8) {
|
||||
return `ANT-COMPAT`;
|
||||
// Fallback to generic labels
|
||||
if (provider.startsWith("openai-compatible-")) {
|
||||
const suffix = provider.replace("openai-compatible-", "");
|
||||
const parts = suffix.split("-");
|
||||
if (parts.length > 1 && parts[1]?.length >= 8) return `OAI-COMPAT`;
|
||||
return `OAI: ${suffix.slice(0, 16).toUpperCase()}`;
|
||||
}
|
||||
if (provider.startsWith("anthropic-compatible-")) {
|
||||
const suffix = provider.replace("anthropic-compatible-", "");
|
||||
const parts = suffix.split("-");
|
||||
if (parts.length > 1 && parts[1]?.length >= 8) return `ANT-COMPAT`;
|
||||
return `ANT: ${suffix.slice(0, 16).toUpperCase()}`;
|
||||
}
|
||||
return `ANT: ${suffix.slice(0, 16).toUpperCase()}`;
|
||||
}
|
||||
return null; // Not a compatible provider, use default PROVIDER_COLORS
|
||||
}
|
||||
@@ -90,6 +93,7 @@ export default function RequestLoggerV2() {
|
||||
const [detailData, setDetailData] = useState(null);
|
||||
const intervalRef = useRef(null);
|
||||
const hasLoadedRef = useRef(false);
|
||||
const [providerNodes, setProviderNodes] = useState([]);
|
||||
|
||||
// Column visibility with localStorage persistence
|
||||
const [visibleColumns, setVisibleColumns] = useState(() => {
|
||||
@@ -147,6 +151,14 @@ export default function RequestLoggerV2() {
|
||||
fetchLogs(showLoading);
|
||||
}, [fetchLogs]);
|
||||
|
||||
// Fetch provider nodes for display labels
|
||||
useEffect(() => {
|
||||
fetch("/api/provider-nodes")
|
||||
.then((r) => (r.ok ? r.json() : { nodes: [] }))
|
||||
.then((d) => setProviderNodes(d.nodes || []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Auto-refresh
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
@@ -299,7 +311,7 @@ export default function RequestLoggerV2() {
|
||||
>
|
||||
<option value="">All Providers</option>
|
||||
{uniqueProviders.map((p) => {
|
||||
const compatLabel = getProviderDisplayLabel(p);
|
||||
const compatLabel = getProviderDisplayLabel(p, providerNodes);
|
||||
const pc = PROVIDER_COLORS[p];
|
||||
return (
|
||||
<option key={p} value={p}>
|
||||
@@ -441,7 +453,7 @@ export default function RequestLoggerV2() {
|
||||
|
||||
{/* Dynamic Provider Quick Filters (from data) */}
|
||||
{uniqueProviders.map((p) => {
|
||||
const compatLabel = getProviderDisplayLabel(p);
|
||||
const compatLabel = getProviderDisplayLabel(p, providerNodes);
|
||||
const pc = PROVIDER_COLORS[p] || {
|
||||
bg: "#374151",
|
||||
text: "#fff",
|
||||
@@ -575,7 +587,7 @@ export default function RequestLoggerV2() {
|
||||
text: "#fff",
|
||||
label: (protocolKey || log.provider || "-").toUpperCase(),
|
||||
};
|
||||
const compatLabel = getProviderDisplayLabel(log.provider);
|
||||
const compatLabel = getProviderDisplayLabel(log.provider, providerNodes);
|
||||
const providerColor = PROVIDER_COLORS[log.provider] || {
|
||||
bg: "#374151",
|
||||
text: "#fff",
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* Code Review Fixes Applied:
|
||||
* #7 useIsElectron — useSyncExternalStore for zero re-renders
|
||||
* #11 Import AppInfo type instead of inline duplication
|
||||
* #12 useDataDir — add error state (was swallowed silently)
|
||||
*/
|
||||
|
||||
// ── Fix #7: Module-level detection (no state, no re-renders) ──
|
||||
|
||||
function getIsElectronSnapshot(): boolean {
|
||||
return typeof window !== "undefined" && window.electronAPI?.isElectron === true;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): boolean {
|
||||
return false; // SSR always returns false
|
||||
}
|
||||
|
||||
const noop = () => () => {};
|
||||
|
||||
/**
|
||||
* Check if running in Electron — zero re-renders via useSyncExternalStore
|
||||
*/
|
||||
export function useIsElectron(): boolean {
|
||||
return useSyncExternalStore(noop, getIsElectronSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* App info shape from Electron main process
|
||||
* Fix #11: Single source of truth (matches electron/types.d.ts)
|
||||
*/
|
||||
interface AppInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
platform: string;
|
||||
isDev: boolean;
|
||||
port: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Electron app information
|
||||
*/
|
||||
export function useElectronAppInfo() {
|
||||
const hasApi = getIsElectronSnapshot();
|
||||
const [appInfo, setAppInfo] = useState<AppInfo | null>(null);
|
||||
const [loading, setLoading] = useState(hasApi);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.electronAPI) return;
|
||||
|
||||
window.electronAPI
|
||||
.getAppInfo()
|
||||
.then((info) => {
|
||||
setAppInfo(info);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { appInfo, loading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data directory path
|
||||
* Fix #12: Now exposes error state (was swallowed silently)
|
||||
*/
|
||||
export function useDataDir() {
|
||||
const hasApi = getIsElectronSnapshot();
|
||||
const [dataDir, setDataDir] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(hasApi);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.electronAPI) return;
|
||||
|
||||
window.electronAPI
|
||||
.getDataDir()
|
||||
.then((dir) => {
|
||||
setDataDir(dir);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err : new Error(String(err)));
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { dataDir, loading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Window controls for Electron
|
||||
*/
|
||||
export function useWindowControls() {
|
||||
const isElectron = useIsElectron();
|
||||
|
||||
const minimize = useCallback(() => {
|
||||
if (isElectron && window.electronAPI) {
|
||||
window.electronAPI.minimizeWindow();
|
||||
}
|
||||
}, [isElectron]);
|
||||
|
||||
const maximize = useCallback(() => {
|
||||
if (isElectron && window.electronAPI) {
|
||||
window.electronAPI.maximizeWindow();
|
||||
}
|
||||
}, [isElectron]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
if (isElectron && window.electronAPI) {
|
||||
window.electronAPI.closeWindow();
|
||||
}
|
||||
}, [isElectron]);
|
||||
|
||||
return { isElectron, minimize, maximize, close };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open external URL in default browser
|
||||
*/
|
||||
export function useOpenExternal() {
|
||||
const isElectron = useIsElectron();
|
||||
|
||||
const openExternal = useCallback(
|
||||
async (url: string) => {
|
||||
if (isElectron && window.electronAPI) {
|
||||
await window.electronAPI.openExternal(url);
|
||||
} else {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
},
|
||||
[isElectron]
|
||||
);
|
||||
|
||||
return { openExternal };
|
||||
}
|
||||
|
||||
/**
|
||||
* Server controls for Electron
|
||||
*/
|
||||
export function useServerControls() {
|
||||
const isElectron = useIsElectron();
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
|
||||
const restart = useCallback(async () => {
|
||||
if (!isElectron || !window.electronAPI) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
setRestarting(true);
|
||||
try {
|
||||
const result = await window.electronAPI.restartServer();
|
||||
return result;
|
||||
} finally {
|
||||
setRestarting(false);
|
||||
}
|
||||
}, [isElectron]);
|
||||
|
||||
return { isElectron, restart, restarting };
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for server status updates
|
||||
* Fix #6: Uses disposer returned by preload for precise cleanup
|
||||
*/
|
||||
export function useServerStatus(onStatus: (status: { status: string; port: number }) => void) {
|
||||
const isElectron = useIsElectron();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron || !window.electronAPI) return;
|
||||
|
||||
const dispose = window.electronAPI.onServerStatus(onStatus);
|
||||
return dispose;
|
||||
}, [isElectron, onStatus]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for port changes
|
||||
* Fix #6: Uses disposer returned by preload for precise cleanup
|
||||
*/
|
||||
export function usePortChanged(onPortChanged: (port: number) => void) {
|
||||
const isElectron = useIsElectron();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron || !window.electronAPI) return;
|
||||
|
||||
const dispose = window.electronAPI.onPortChanged(onPortChanged);
|
||||
return dispose;
|
||||
}, [isElectron, onPortChanged]);
|
||||
}
|
||||
@@ -80,16 +80,16 @@ export function maskAccount(account) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an API key label, masking both name and ID.
|
||||
* Format an API key label, showing full name but masking the ID.
|
||||
* @param {string} apiKeyName - Human-readable name of the key
|
||||
* @param {string} apiKeyId - Unique ID of the key
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatApiKeyLabel(apiKeyName, apiKeyId) {
|
||||
if (!apiKeyName && !apiKeyId) return "—";
|
||||
const maskedName = apiKeyName ? maskSegment(apiKeyName, 2, 1) : "key";
|
||||
if (!apiKeyId) return maskedName;
|
||||
return `${maskedName} (${maskSegment(apiKeyId, 4, 4)})`;
|
||||
const displayName = apiKeyName || "key";
|
||||
if (!apiKeyId) return displayName;
|
||||
return `${displayName} (${maskSegment(apiKeyId, 4, 4)})`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -173,7 +173,7 @@ export class StreamTracker {
|
||||
// ─── Active Stream Registry ─────────────────
|
||||
|
||||
const activeStreams = new Map<string, StreamTracker>();
|
||||
const MAX_COMPLETED_HISTORY = 100;
|
||||
const MAX_COMPLETED_HISTORY = parseInt(process.env.STREAM_HISTORY_MAX || "50", 10);
|
||||
const completedStreams: ReturnType<StreamTracker["getSummary"]>[] = [];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Tests for CLI Memory Sanitization (bin/omniroute.mjs)
|
||||
*
|
||||
* Tests cover:
|
||||
* - Memory limit parsing and validation
|
||||
* - Command injection prevention
|
||||
* - Boundary values
|
||||
* - .env file loading
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// ─── Memory Limit Sanitization Tests ─────────────────────────
|
||||
|
||||
describe("CLI Memory Limit Sanitization", () => {
|
||||
/**
|
||||
* Replicate the memory sanitization logic from bin/omniroute.mjs
|
||||
*/
|
||||
function sanitizeMemoryLimit(envValue) {
|
||||
const rawMemory = parseInt(envValue || "512", 10);
|
||||
return Number.isFinite(rawMemory) && rawMemory >= 64 && rawMemory <= 16384 ? rawMemory : 512;
|
||||
}
|
||||
|
||||
it("should default to 512 when no env var set", () => {
|
||||
assert.equal(sanitizeMemoryLimit(undefined), 512);
|
||||
assert.equal(sanitizeMemoryLimit(null), 512);
|
||||
assert.equal(sanitizeMemoryLimit(""), 512);
|
||||
});
|
||||
|
||||
it("should accept valid numeric values", () => {
|
||||
assert.equal(sanitizeMemoryLimit("128"), 128);
|
||||
assert.equal(sanitizeMemoryLimit("256"), 256);
|
||||
assert.equal(sanitizeMemoryLimit("512"), 512);
|
||||
assert.equal(sanitizeMemoryLimit("1024"), 1024);
|
||||
assert.equal(sanitizeMemoryLimit("2048"), 2048);
|
||||
assert.equal(sanitizeMemoryLimit("4096"), 4096);
|
||||
assert.equal(sanitizeMemoryLimit("8192"), 8192);
|
||||
});
|
||||
|
||||
it("should accept boundary values", () => {
|
||||
assert.equal(sanitizeMemoryLimit("64"), 64); // minimum
|
||||
assert.equal(sanitizeMemoryLimit("16384"), 16384); // maximum
|
||||
});
|
||||
|
||||
it("should reject values below minimum (64MB)", () => {
|
||||
assert.equal(sanitizeMemoryLimit("0"), 512);
|
||||
assert.equal(sanitizeMemoryLimit("1"), 512);
|
||||
assert.equal(sanitizeMemoryLimit("32"), 512);
|
||||
assert.equal(sanitizeMemoryLimit("63"), 512);
|
||||
assert.equal(sanitizeMemoryLimit("-1"), 512);
|
||||
assert.equal(sanitizeMemoryLimit("-9999"), 512);
|
||||
});
|
||||
|
||||
it("should reject values above maximum (16384MB)", () => {
|
||||
assert.equal(sanitizeMemoryLimit("16385"), 512);
|
||||
assert.equal(sanitizeMemoryLimit("99999"), 512);
|
||||
assert.equal(sanitizeMemoryLimit("1000000"), 512);
|
||||
});
|
||||
|
||||
// ── Command Injection Prevention ──────────────────────────
|
||||
|
||||
it("should prevent command injection via shell metacharacters", () => {
|
||||
// parseInt('256; rm -rf /') returns 256 which is valid — but the sanitized
|
||||
// integer value is safe because it's a pure number, not a shell string
|
||||
const r1 = sanitizeMemoryLimit("256; rm -rf /");
|
||||
assert.equal(typeof r1, "number"); // always returns a safe number
|
||||
assert.ok(r1 >= 64 && r1 <= 16384); // within safe range
|
||||
|
||||
// These produce NaN via parseInt → fallback to 512
|
||||
assert.equal(sanitizeMemoryLimit("`id`"), 512);
|
||||
assert.equal(sanitizeMemoryLimit("$(whoami)"), 512);
|
||||
});
|
||||
|
||||
it("should prevent injection via Node.js spawn args", () => {
|
||||
// These would be dangerous if passed unsanitized to spawn()
|
||||
assert.equal(sanitizeMemoryLimit("--require=/tmp/malware.js"), 512);
|
||||
assert.equal(sanitizeMemoryLimit("--experimental-modules"), 512);
|
||||
assert.equal(sanitizeMemoryLimit("-e 'process.exit()'"), 512);
|
||||
});
|
||||
|
||||
it("should handle non-numeric strings gracefully", () => {
|
||||
assert.equal(sanitizeMemoryLimit("abc"), 512);
|
||||
assert.equal(sanitizeMemoryLimit("twelve"), 512);
|
||||
assert.equal(sanitizeMemoryLimit("NaN"), 512);
|
||||
assert.equal(sanitizeMemoryLimit("Infinity"), 512);
|
||||
assert.equal(sanitizeMemoryLimit("undefined"), 512);
|
||||
});
|
||||
|
||||
it("should handle special numeric formats", () => {
|
||||
// parseInt('0x100') = 0 (stops at 'x'), outside 64–16384? No, 0 < 64 → fallback
|
||||
assert.equal(sanitizeMemoryLimit("0x100"), 512);
|
||||
// parseInt('1e3') = 1, which is < 64 → fallback
|
||||
assert.equal(sanitizeMemoryLimit("1e3"), 512);
|
||||
assert.equal(sanitizeMemoryLimit("512.7"), 512); // parseInt truncates → 512 ✅
|
||||
assert.equal(sanitizeMemoryLimit(" 256 "), 256); // whitespace → 256 ✅
|
||||
});
|
||||
});
|
||||
|
||||
// ─── .env Loading Tests ──────────────────────────────────────
|
||||
|
||||
describe("CLI .env File Loading", () => {
|
||||
/**
|
||||
* Simulate the .env parsing logic from bin/omniroute.mjs
|
||||
*/
|
||||
function parseEnvLine(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) return null;
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx === -1) return null;
|
||||
const key = trimmed.substring(0, eqIdx).trim();
|
||||
let value = trimmed.substring(eqIdx + 1).trim();
|
||||
// Remove surrounding quotes
|
||||
value = value.replace(/^["']|["']$/g, "");
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
it("should parse KEY=VALUE lines", () => {
|
||||
const result = parseEnvLine("PORT=3000");
|
||||
assert.deepEqual(result, { key: "PORT", value: "3000" });
|
||||
});
|
||||
|
||||
it("should strip surrounding quotes", () => {
|
||||
const dq = parseEnvLine('JWT_SECRET="my-secret"');
|
||||
assert.deepEqual(dq, { key: "JWT_SECRET", value: "my-secret" });
|
||||
|
||||
const sq = parseEnvLine("JWT_SECRET='my-secret'");
|
||||
assert.deepEqual(sq, { key: "JWT_SECRET", value: "my-secret" });
|
||||
});
|
||||
|
||||
it("should skip comments", () => {
|
||||
assert.equal(parseEnvLine("# This is a comment"), null);
|
||||
assert.equal(parseEnvLine(" # Indented comment"), null);
|
||||
});
|
||||
|
||||
it("should skip empty lines", () => {
|
||||
assert.equal(parseEnvLine(""), null);
|
||||
assert.equal(parseEnvLine(" "), null);
|
||||
});
|
||||
|
||||
it("should skip lines without =", () => {
|
||||
assert.equal(parseEnvLine("MALFORMED_LINE"), null);
|
||||
});
|
||||
|
||||
it("should handle values with equals signs", () => {
|
||||
const result = parseEnvLine("DATABASE_URL=postgres://user:pass@host:5432/db?sslmode=require");
|
||||
assert.equal(result.key, "DATABASE_URL");
|
||||
assert.equal(result.value, "postgres://user:pass@host:5432/db?sslmode=require");
|
||||
});
|
||||
|
||||
it("should handle empty values", () => {
|
||||
const result = parseEnvLine("EMPTY_VAR=");
|
||||
assert.deepEqual(result, { key: "EMPTY_VAR", value: "" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Tests for Electron main process (electron/main.js)
|
||||
*
|
||||
* Covers:
|
||||
* - URL validation & RCE prevention
|
||||
* - IPC channel security
|
||||
* - Server readiness polling logic
|
||||
* - Restart timeout + SIGKILL
|
||||
* - Port change lifecycle
|
||||
* - CSP header structure
|
||||
* - Platform-conditional window options
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// ─── URL Validation Tests ────────────────────────────────────
|
||||
|
||||
describe("Electron URL Validation", () => {
|
||||
function validateExternalUrl(url) {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
if (["http:", "https:"].includes(parsedUrl.protocol)) {
|
||||
return { allowed: true, url };
|
||||
}
|
||||
return { allowed: false, reason: `Blocked protocol: ${parsedUrl.protocol}` };
|
||||
} catch {
|
||||
return { allowed: false, reason: "Invalid URL" };
|
||||
}
|
||||
}
|
||||
|
||||
it("should allow http URLs", () => {
|
||||
assert.equal(validateExternalUrl("http://example.com").allowed, true);
|
||||
});
|
||||
|
||||
it("should allow https URLs", () => {
|
||||
assert.equal(validateExternalUrl("https://github.com/diegosouzapw/OmniRoute").allowed, true);
|
||||
});
|
||||
|
||||
it("should block file:// protocol (RCE risk)", () => {
|
||||
const result = validateExternalUrl("file:///etc/passwd");
|
||||
assert.equal(result.allowed, false);
|
||||
assert.match(result.reason, /Blocked protocol/);
|
||||
});
|
||||
|
||||
it("should block javascript: protocol (XSS risk)", () => {
|
||||
assert.equal(validateExternalUrl("javascript:alert(1)").allowed, false);
|
||||
});
|
||||
|
||||
it("should block custom protocol handlers", () => {
|
||||
assert.equal(validateExternalUrl("vscode://extensions/install?name=malware").allowed, false);
|
||||
});
|
||||
|
||||
it("should block data: URIs", () => {
|
||||
assert.equal(validateExternalUrl("data:text/html,<script>alert(1)</script>").allowed, false);
|
||||
});
|
||||
|
||||
it("should reject empty string", () => {
|
||||
assert.equal(validateExternalUrl("").allowed, false);
|
||||
});
|
||||
|
||||
it("should reject malformed URL", () => {
|
||||
const result = validateExternalUrl("not a url");
|
||||
assert.equal(result.allowed, false);
|
||||
assert.match(result.reason, /Invalid URL/);
|
||||
});
|
||||
|
||||
it("should allow localhost URLs", () => {
|
||||
assert.equal(validateExternalUrl("http://localhost:20128/dashboard").allowed, true);
|
||||
});
|
||||
|
||||
it("should allow URLs with paths and query params", () => {
|
||||
assert.equal(validateExternalUrl("https://example.com/path?q=test&page=1#hash").allowed, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Window Open Handler Tests ───────────────────────────────
|
||||
|
||||
describe("Electron Window Open Handler", () => {
|
||||
function windowOpenHandler({ url }) {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
if (["http:", "https:"].includes(parsedUrl.protocol)) {
|
||||
return { action: "allow-external" };
|
||||
}
|
||||
return { action: "deny" };
|
||||
} catch {
|
||||
return { action: "deny" };
|
||||
}
|
||||
}
|
||||
|
||||
it("should deny all windows (external links go to browser)", () => {
|
||||
const result = windowOpenHandler({ url: "https://example.com" });
|
||||
assert.ok(result.action);
|
||||
});
|
||||
|
||||
it("should deny file:// URLs", () => {
|
||||
assert.equal(windowOpenHandler({ url: "file:///etc/passwd" }).action, "deny");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IPC Channel Validation Tests ────────────────────────────
|
||||
|
||||
describe("IPC Channel Validation", () => {
|
||||
const VALID_CHANNELS = {
|
||||
invoke: ["get-app-info", "open-external", "get-data-dir", "restart-server"],
|
||||
send: ["window-minimize", "window-maximize", "window-close"],
|
||||
receive: ["server-status", "port-changed"],
|
||||
};
|
||||
|
||||
function isValidChannel(channel, type) {
|
||||
return VALID_CHANNELS[type]?.includes(channel) ?? false;
|
||||
}
|
||||
|
||||
it("should allow valid invoke channels", () => {
|
||||
assert.equal(isValidChannel("get-app-info", "invoke"), true);
|
||||
assert.equal(isValidChannel("open-external", "invoke"), true);
|
||||
assert.equal(isValidChannel("get-data-dir", "invoke"), true);
|
||||
assert.equal(isValidChannel("restart-server", "invoke"), true);
|
||||
});
|
||||
|
||||
it("should allow valid send channels", () => {
|
||||
assert.equal(isValidChannel("window-minimize", "send"), true);
|
||||
assert.equal(isValidChannel("window-maximize", "send"), true);
|
||||
assert.equal(isValidChannel("window-close", "send"), true);
|
||||
});
|
||||
|
||||
it("should allow valid receive channels", () => {
|
||||
assert.equal(isValidChannel("server-status", "receive"), true);
|
||||
assert.equal(isValidChannel("port-changed", "receive"), true);
|
||||
});
|
||||
|
||||
it("should block unknown channels", () => {
|
||||
assert.equal(isValidChannel("execute-arbitrary-code", "invoke"), false);
|
||||
assert.equal(isValidChannel("delete-all-data", "send"), false);
|
||||
assert.equal(isValidChannel("malicious-event", "receive"), false);
|
||||
assert.equal(isValidChannel("", "invoke"), false);
|
||||
});
|
||||
|
||||
it("should handle undefined type gracefully", () => {
|
||||
assert.equal(isValidChannel("get-app-info", "nonexistent"), false);
|
||||
assert.equal(isValidChannel("test", undefined), false);
|
||||
});
|
||||
|
||||
it("should block prototype pollution attempts", () => {
|
||||
assert.equal(isValidChannel("constructor", "invoke"), false);
|
||||
assert.equal(isValidChannel("__proto__", "invoke"), false);
|
||||
assert.equal(isValidChannel("toString", "invoke"), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Server Port Validation Tests ────────────────────────────
|
||||
|
||||
describe("Server Port Management", () => {
|
||||
it("should have valid default port", () => {
|
||||
const DEFAULT_PORT = 20128;
|
||||
assert.ok(DEFAULT_PORT > 0 && DEFAULT_PORT <= 65535);
|
||||
});
|
||||
|
||||
it("should validate port numbers", () => {
|
||||
function isValidPort(port) {
|
||||
return Number.isFinite(port) && port > 0 && port <= 65535;
|
||||
}
|
||||
assert.equal(isValidPort(20128), true);
|
||||
assert.equal(isValidPort(3000), true);
|
||||
assert.equal(isValidPort(8080), true);
|
||||
assert.equal(isValidPort(0), false);
|
||||
assert.equal(isValidPort(-1), false);
|
||||
assert.equal(isValidPort(70000), false);
|
||||
assert.equal(isValidPort(NaN), false);
|
||||
});
|
||||
|
||||
it("should generate correct server URL", () => {
|
||||
const port = 20128;
|
||||
assert.equal(`http://localhost:${port}`, "http://localhost:20128");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Server Readiness Tests (#1) ─────────────────────────────
|
||||
|
||||
describe("Server Readiness Logic", () => {
|
||||
it("waitForServer should timeout and return false", async () => {
|
||||
// Simulate the polling logic with an always-failing fetch
|
||||
async function waitForServer(url, timeoutMs = 100) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok || res.status < 500) return true;
|
||||
} catch {
|
||||
/* not ready */
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Should timeout immediately since nothing is running on that port
|
||||
const result = await waitForServer("http://localhost:59999", 100);
|
||||
assert.equal(result, false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Restart Timeout Tests (#2) ──────────────────────────────
|
||||
|
||||
describe("Restart Timeout Logic", () => {
|
||||
it("should resolve even if process doesn't exit", async () => {
|
||||
// Simulate the timeout race
|
||||
const start = Date.now();
|
||||
await Promise.race([
|
||||
new Promise((r) => setTimeout(r, 100000)), // simulates hung process
|
||||
new Promise((r) => setTimeout(r, 50)), // timeout
|
||||
]);
|
||||
const elapsed = Date.now() - start;
|
||||
assert.ok(elapsed < 200, "Should resolve in ~50ms via timeout");
|
||||
});
|
||||
|
||||
it("should resolve immediately if process exits first", async () => {
|
||||
const start = Date.now();
|
||||
await Promise.race([
|
||||
new Promise((r) => setTimeout(r, 10)), // simulates fast exit
|
||||
new Promise((r) => setTimeout(r, 5000)), // timeout
|
||||
]);
|
||||
const elapsed = Date.now() - start;
|
||||
assert.ok(elapsed < 200, "Should resolve in ~10ms via exit");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CSP Tests (#15) ─────────────────────────────────────────
|
||||
|
||||
describe("Content Security Policy", () => {
|
||||
it("should have all required CSP directives", () => {
|
||||
const directives = [
|
||||
"default-src",
|
||||
"connect-src",
|
||||
"script-src",
|
||||
"style-src",
|
||||
"font-src",
|
||||
"img-src",
|
||||
"media-src",
|
||||
];
|
||||
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"connect-src 'self' http://localhost:* ws://localhost:*",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"media-src 'self'",
|
||||
].join("; ");
|
||||
|
||||
for (const directive of directives) {
|
||||
assert.ok(csp.includes(directive), `CSP should contain ${directive}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("should not allow unsafe script sources from external domains", () => {
|
||||
const scriptSrc = "script-src 'self' 'unsafe-inline' 'unsafe-eval'";
|
||||
assert.ok(!scriptSrc.includes("http://"), "Should not allow external http scripts");
|
||||
assert.ok(!scriptSrc.includes("*"), "Should not wildcard script sources");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Platform-Conditional Tests (#9) ─────────────────────────
|
||||
|
||||
describe("Platform-Conditional Window Options", () => {
|
||||
it("should return hiddenInset for macOS", () => {
|
||||
const platform = "darwin";
|
||||
const options =
|
||||
platform === "darwin"
|
||||
? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 16 } }
|
||||
: { titleBarStyle: "default" };
|
||||
|
||||
assert.equal(options.titleBarStyle, "hiddenInset");
|
||||
assert.deepEqual(options.trafficLightPosition, { x: 16, y: 16 });
|
||||
});
|
||||
|
||||
it("should return default for Windows/Linux", () => {
|
||||
for (const platform of ["win32", "linux"]) {
|
||||
const options =
|
||||
platform === "darwin" ? { titleBarStyle: "hiddenInset" } : { titleBarStyle: "default" };
|
||||
assert.equal(options.titleBarStyle, "default");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Tests for Electron preload script (electron/preload.js)
|
||||
*
|
||||
* Covers:
|
||||
* - Channel whitelist (Fix #16: validates generic wrappers)
|
||||
* - API surface correctness
|
||||
* - Security boundary enforcement
|
||||
* - Disposer pattern for listeners (Fix #6)
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// ─── Channel Whitelist Tests ─────────────────────────────────
|
||||
|
||||
describe("Preload Channel Whitelist", () => {
|
||||
const VALID_CHANNELS = {
|
||||
invoke: ["get-app-info", "open-external", "get-data-dir", "restart-server"],
|
||||
send: ["window-minimize", "window-maximize", "window-close"],
|
||||
receive: ["server-status", "port-changed"],
|
||||
};
|
||||
|
||||
function isValidChannel(channel, type) {
|
||||
return VALID_CHANNELS[type]?.includes(channel) ?? false;
|
||||
}
|
||||
|
||||
it("should have exactly 4 invoke channels", () => {
|
||||
assert.equal(VALID_CHANNELS.invoke.length, 4);
|
||||
});
|
||||
|
||||
it("should have exactly 3 send channels", () => {
|
||||
assert.equal(VALID_CHANNELS.send.length, 3);
|
||||
});
|
||||
|
||||
it("should have exactly 2 receive channels", () => {
|
||||
assert.equal(VALID_CHANNELS.receive.length, 2);
|
||||
});
|
||||
|
||||
it("should not allow crossing channel types", () => {
|
||||
for (const ch of VALID_CHANNELS.invoke) {
|
||||
assert.equal(isValidChannel(ch, "send"), false, `${ch} should not be valid as send`);
|
||||
}
|
||||
for (const ch of VALID_CHANNELS.send) {
|
||||
assert.equal(isValidChannel(ch, "invoke"), false, `${ch} should not be valid as invoke`);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject null/undefined channels", () => {
|
||||
assert.equal(isValidChannel(null, "invoke"), false);
|
||||
assert.equal(isValidChannel(undefined, "invoke"), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── API Surface Tests ───────────────────────────────────────
|
||||
|
||||
describe("Preload API Surface", () => {
|
||||
// Updated: removed removeServerStatusListener/removePortChangedListener (Fix #6)
|
||||
const EXPECTED_API_METHODS = [
|
||||
"getAppInfo",
|
||||
"openExternal",
|
||||
"getDataDir",
|
||||
"restartServer",
|
||||
"minimizeWindow",
|
||||
"maximizeWindow",
|
||||
"closeWindow",
|
||||
"onServerStatus", // now returns disposer
|
||||
"onPortChanged", // now returns disposer
|
||||
];
|
||||
|
||||
const EXPECTED_API_PROPERTIES = ["isElectron", "platform"];
|
||||
|
||||
it("should define all expected method names", () => {
|
||||
for (const method of EXPECTED_API_METHODS) {
|
||||
assert.ok(typeof method === "string" && method.length > 0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should define expected property names", () => {
|
||||
for (const prop of EXPECTED_API_PROPERTIES) {
|
||||
assert.ok(typeof prop === "string" && prop.length > 0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should have correct total API surface (11 items — reduced from 13)", () => {
|
||||
const totalApi = EXPECTED_API_METHODS.length + EXPECTED_API_PROPERTIES.length;
|
||||
assert.equal(totalApi, 11);
|
||||
});
|
||||
|
||||
it("should not expose any Node.js internals", () => {
|
||||
const DANGEROUS_APIS = [
|
||||
"require",
|
||||
"process",
|
||||
"child_process",
|
||||
"fs",
|
||||
"exec",
|
||||
"spawn",
|
||||
"eval",
|
||||
"__dirname",
|
||||
"__filename",
|
||||
];
|
||||
const all = [...EXPECTED_API_METHODS, ...EXPECTED_API_PROPERTIES];
|
||||
for (const api of DANGEROUS_APIS) {
|
||||
assert.ok(!all.includes(api), `'${api}' should NOT be exposed`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Disposer Pattern Tests (#6) ─────────────────────────────
|
||||
|
||||
describe("Preload Listener Disposer Pattern", () => {
|
||||
it("safeOn should return a function (disposer)", () => {
|
||||
// Simulate the safeOn pattern from the new preload.js
|
||||
const VALID_RECEIVE = ["server-status", "port-changed"];
|
||||
const listeners = [];
|
||||
|
||||
function safeOn(channel, callback) {
|
||||
if (!VALID_RECEIVE.includes(channel)) return () => {};
|
||||
const handler = { channel, callback };
|
||||
listeners.push(handler);
|
||||
return () => {
|
||||
const idx = listeners.indexOf(handler);
|
||||
if (idx !== -1) listeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
// Add a listener
|
||||
const dispose = safeOn("server-status", () => {});
|
||||
assert.equal(typeof dispose, "function");
|
||||
assert.equal(listeners.length, 1);
|
||||
|
||||
// Dispose it
|
||||
dispose();
|
||||
assert.equal(listeners.length, 0);
|
||||
});
|
||||
|
||||
it("safeOn should reject invalid channels and return noop disposer", () => {
|
||||
const VALID_RECEIVE = ["server-status", "port-changed"];
|
||||
|
||||
function safeOn(channel, callback) {
|
||||
if (!VALID_RECEIVE.includes(channel)) return () => {};
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const dispose = safeOn("malicious-event", () => {});
|
||||
assert.equal(typeof dispose, "function");
|
||||
// Should not throw
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("multiple listeners should be independently disposable", () => {
|
||||
const listeners = [];
|
||||
function safeOn(channel, callback) {
|
||||
const handler = { channel, callback };
|
||||
listeners.push(handler);
|
||||
return () => {
|
||||
const idx = listeners.indexOf(handler);
|
||||
if (idx !== -1) listeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
const dispose1 = safeOn("server-status", () => "a");
|
||||
const dispose2 = safeOn("server-status", () => "b");
|
||||
const dispose3 = safeOn("port-changed", () => "c");
|
||||
assert.equal(listeners.length, 3);
|
||||
|
||||
// Remove only the second one
|
||||
dispose2();
|
||||
assert.equal(listeners.length, 2);
|
||||
assert.equal(listeners[0].callback(), "a");
|
||||
assert.equal(listeners[1].callback(), "c");
|
||||
|
||||
// Remove first
|
||||
dispose1();
|
||||
assert.equal(listeners.length, 1);
|
||||
|
||||
// Double-dispose should be safe
|
||||
dispose1();
|
||||
assert.equal(listeners.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Generic Wrapper Tests (#16) ─────────────────────────────
|
||||
|
||||
describe("Generic IPC Wrappers", () => {
|
||||
const VALID_CHANNELS = {
|
||||
invoke: ["get-app-info", "open-external", "get-data-dir", "restart-server"],
|
||||
send: ["window-minimize", "window-maximize", "window-close"],
|
||||
};
|
||||
|
||||
function safeInvoke(channel) {
|
||||
if (!VALID_CHANNELS.invoke.includes(channel)) {
|
||||
return { blocked: true };
|
||||
}
|
||||
return { blocked: false, channel };
|
||||
}
|
||||
|
||||
function safeSend(channel) {
|
||||
if (!VALID_CHANNELS.send.includes(channel)) {
|
||||
return { blocked: true };
|
||||
}
|
||||
return { blocked: false, channel };
|
||||
}
|
||||
|
||||
it("safeInvoke should allow valid channels", () => {
|
||||
for (const ch of VALID_CHANNELS.invoke) {
|
||||
assert.equal(safeInvoke(ch).blocked, false);
|
||||
}
|
||||
});
|
||||
|
||||
it("safeInvoke should block invalid channels", () => {
|
||||
assert.equal(safeInvoke("shell-exec").blocked, true);
|
||||
assert.equal(safeInvoke("").blocked, true);
|
||||
assert.equal(safeInvoke("__proto__").blocked, true);
|
||||
});
|
||||
|
||||
it("safeSend should allow valid channels", () => {
|
||||
for (const ch of VALID_CHANNELS.send) {
|
||||
assert.equal(safeSend(ch).blocked, false);
|
||||
}
|
||||
});
|
||||
|
||||
it("safeSend should block invalid channels", () => {
|
||||
assert.equal(safeSend("window-nuke").blocked, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Open External URL Validation Tests ──────────────────────
|
||||
|
||||
describe("Preload openExternal Security", () => {
|
||||
function validateBeforeOpen(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return ["http:", "https:"].includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const SAFE_URLS = [
|
||||
"https://github.com",
|
||||
"http://localhost:20128",
|
||||
"https://omniroute.dev/docs",
|
||||
"https://example.com/path?q=1&p=2#section",
|
||||
];
|
||||
|
||||
const DANGEROUS_URLS = [
|
||||
"file:///etc/passwd",
|
||||
"file:///C:/Windows/System32",
|
||||
"javascript:alert(document.cookie)",
|
||||
"vscode://extensions",
|
||||
"data:text/html,<h1>pwned</h1>",
|
||||
"blob:http://evil.com/abc123",
|
||||
"ftp://unsafe-server.com",
|
||||
"ssh://attacker.com",
|
||||
"smb://network-share",
|
||||
"",
|
||||
" ",
|
||||
"not-a-url",
|
||||
];
|
||||
|
||||
for (const url of SAFE_URLS) {
|
||||
it(`should allow safe URL: ${url.substring(0, 40)}`, () => {
|
||||
assert.equal(validateBeforeOpen(url), true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const url of DANGEROUS_URLS) {
|
||||
it(`should block dangerous URL: ${url.substring(0, 40) || "(empty)"}`, () => {
|
||||
assert.equal(validateBeforeOpen(url), false);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user