diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json index 77e9744d..c81f1214 100644 --- a/.cursor/worktrees.json +++ b/.cursor/worktrees.json @@ -1,5 +1,3 @@ { - "setup-worktree": [ - "npm install" - ] + "setup-worktree": ["npm install"] } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3eea3a0f..1709f872 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,9 +3,9 @@ name: Build on: push: tags: - - 'v*' + - "v*" branches: - - 'main' + - "main" pull_request: branches: [main] workflow_dispatch: @@ -31,8 +31,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' + node-version: "20" + cache: "npm" - name: Install dependencies run: npm ci diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 0e12a59d..327840d5 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -3,12 +3,12 @@ name: Mobile Build on: push: tags: - - 'v*' # Unified release: same tag as desktop + - "v*" # Unified release: same tag as desktop branches: - - 'main' + - "main" pull_request: branches: [main] - workflow_dispatch: # Allow manual trigger anytime + workflow_dispatch: # Allow manual trigger anytime jobs: # Check for potential mobile sync issues — blocks mobile build if issues found @@ -212,15 +212,15 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' + node-version: "20" + cache: "npm" cache-dependency-path: mobile/package-lock.json - name: Setup Java uses: actions/setup-java@v4 with: - distribution: 'temurin' - java-version: '17' + distribution: "temurin" + java-version: "17" - name: Setup Android SDK uses: android-actions/setup-android@v3 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 56947ee6..a923b06f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -3,7 +3,7 @@ name: Nightly Build on: schedule: # Run at 2:00 AM UTC every day - - cron: '0 2 * * *' + - cron: "0 2 * * *" jobs: check-changes: @@ -52,8 +52,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' + node-version: "20" + cache: "npm" - name: Install dependencies run: npm ci @@ -197,4 +197,4 @@ jobs: git reset --hard origin/main && npm install && npm run build:web - ' \ No newline at end of file + ' diff --git a/.vscode/settings.json b/.vscode/settings.json index 7a73a41b..0967ef42 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,2 +1 @@ -{ -} \ No newline at end of file +{} diff --git a/CLAUDE.md b/CLAUDE.md index cf3ec054..6c321fa9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ wavespeed-desktop/ - **`src/workflow/browser/run-in-browser.ts`**: Workflow execution (browser only): AI task via apiClient, free-tool nodes, I/O nodes - **`src/workflow/ipc/ipc-client.ts`**: Typed IPC client for workflow, execution, models, cost, history, registry, settings - **`src/workflow/types/node-defs.ts`**: NodeTypeDefinition, WaveSpeedModel, ParamDefinition -- **`src/workflow/types/ipc.ts`**: IPC channel types (workflow:*, execution:*, models:*, cost:*, history:*, etc.) +- **`src/workflow/types/ipc.ts`**: IPC channel types (workflow:_, execution:_, models:_, cost:_, history:\*, etc.) - **`src/workflow/hooks/useFreeToolListener.ts`**: Listens for free-tool execution requests from main process (used by Layout) - **`src/workflow/lib/free-tool-runner.ts`**: Runs free-tool nodes (e.g. background-remover) and returns outputs to main - **`src/components/playground/DynamicForm.tsx`**: Generates forms from model schemas @@ -149,18 +149,19 @@ Authentication: `Authorization: Bearer {API_KEY}` ### Endpoints -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/v3/models` | GET | List available models with schemas | -| `/api/v3/{model}` | POST | Run a prediction | -| `/api/v3/predictions/{id}/result` | GET | Poll for prediction result | -| `/api/v3/predictions` | POST | Get prediction history (with date filters) | -| `/api/v3/media/upload/binary` | POST | Upload files (multipart/form-data) | -| `/api/v3/balance` | GET | Get account balance (returns `{ data: { balance: number } }`) | +| Endpoint | Method | Description | +| --------------------------------- | ------ | ------------------------------------------------------------- | +| `/api/v3/models` | GET | List available models with schemas | +| `/api/v3/{model}` | POST | Run a prediction | +| `/api/v3/predictions/{id}/result` | GET | Poll for prediction result | +| `/api/v3/predictions` | POST | Get prediction history (with date filters) | +| `/api/v3/media/upload/binary` | POST | Upload files (multipart/form-data) | +| `/api/v3/balance` | GET | Get account balance (returns `{ data: { balance: number } }`) | ### History API The predictions history endpoint requires a POST request with JSON body: + ```json { "page": 1, @@ -185,19 +186,23 @@ npm run build:all # Build for all platforms ## Common Tasks ### Adding a new page + 1. Create component in `src/pages/` 2. Add route in `src/App.tsx` 3. Add navigation item in `src/components/layout/Sidebar.tsx` under the appropriate section (Create, Manage, or Tools) ### Adding a new API method + 1. Add method to `WaveSpeedClient` class in `src/api/client.ts` 2. Add types in `src/types/` if needed ### Modifying the build + 1. Build config is in `package.json` under `"build"` key 2. GitHub Actions in `.github/workflows/` ### Adding a new UI component (shadcn/ui pattern) + 1. Create component in `src/components/ui/` following the existing pattern 2. Use `@radix-ui/*` primitives (already installed: dialog, select, dropdown-menu, etc.) 3. Use `cn()` for className merging diff --git a/README.md b/README.md index 1f94a47a..4034cce3 100644 --- a/README.md +++ b/README.md @@ -34,20 +34,20 @@ The Android app shares the same React codebase as the desktop version, giving yo 12 free AI-powered creative tools that run entirely in your browser. No API key required, no usage limits, completely free. Also available as a standalone web app at [wavespeed.ai/studio](https://wavespeed.ai/studio) — fully responsive, works on desktop, tablet, and mobile browsers. -| Tool | Description | -|------|-------------| -| **Image Enhancer** | Upscale images 2x–4x using ESRGAN with slim, medium, and thick quality options | -| **Video Enhancer** | Frame-by-frame video upscaling with real-time progress and ETA | -| **Face Enhancer** | Detect faces with YOLO v8 and enhance with GFPGAN v1.4 (WebGPU accelerated) | -| **Face Swapper** | Swap faces using InsightFace (SCRFD + ArcFace + Inswapper) with optional GFPGAN post-processing | +| Tool | Description | +| ---------------------- | ------------------------------------------------------------------------------------------------- | +| **Image Enhancer** | Upscale images 2x–4x using ESRGAN with slim, medium, and thick quality options | +| **Video Enhancer** | Frame-by-frame video upscaling with real-time progress and ETA | +| **Face Enhancer** | Detect faces with YOLO v8 and enhance with GFPGAN v1.4 (WebGPU accelerated) | +| **Face Swapper** | Swap faces using InsightFace (SCRFD + ArcFace + Inswapper) with optional GFPGAN post-processing | | **Background Remover** | Remove backgrounds instantly — outputs foreground, background, and mask with individual downloads | -| **Image Eraser** | Remove unwanted objects with LaMa inpainting, smart crop and blend (WebGPU accelerated) | -| **Segment Anything** | Interactive object segmentation with point prompts using SlimSAM | -| **Video Converter** | Convert between MP4, WebM, AVI, MOV, MKV with codec and quality options | -| **Audio Converter** | Convert between MP3, WAV, AAC, FLAC, OGG with bitrate control | -| **Image Converter** | Batch convert between JPG, PNG, WebP, GIF, BMP with quality settings | -| **Media Trimmer** | Trim video and audio by selecting start and end times | -| **Media Merger** | Merge multiple video or audio files into one | +| **Image Eraser** | Remove unwanted objects with LaMa inpainting, smart crop and blend (WebGPU accelerated) | +| **Segment Anything** | Interactive object segmentation with point prompts using SlimSAM | +| **Video Converter** | Convert between MP4, WebM, AVI, MOV, MKV with codec and quality options | +| **Audio Converter** | Convert between MP3, WAV, AAC, FLAC, OGG with bitrate control | +| **Image Converter** | Batch convert between JPG, PNG, WebP, GIF, BMP with quality settings | +| **Media Trimmer** | Trim video and audio by selecting start and end times | +| **Media Merger** | Merge multiple video or audio files into one | ![WaveSpeed Creative Studio](https://github.com/user-attachments/assets/67359fa7-8ff4-4001-a982-eb4802e5b841) @@ -186,15 +186,15 @@ npm run dev ### Scripts -| Script | Description | -|--------|-------------| -| `npm run dev` | Start development server with hot reload | -| `npx vite` | Start web-only dev server (no Electron) | -| `npm run build` | Build the application | -| `npm run build:win` | Build for Windows | -| `npm run build:mac` | Build for macOS | -| `npm run build:linux` | Build for Linux | -| `npm run build:all` | Build for all platforms | +| Script | Description | +| --------------------- | ---------------------------------------- | +| `npm run dev` | Start development server with hot reload | +| `npx vite` | Start web-only dev server (no Electron) | +| `npm run build` | Build the application | +| `npm run build:win` | Build for Windows | +| `npm run build:mac` | Build for macOS | +| `npm run build:linux` | Build for Linux | +| `npm run build:all` | Build for all platforms | ### Mobile Development @@ -263,6 +263,7 @@ wavespeed-desktop/ ## Tech Stack ### Desktop + - **Framework**: Electron + electron-vite - **Frontend**: React 18 + TypeScript - **Styling**: Tailwind CSS + shadcn/ui @@ -272,6 +273,7 @@ wavespeed-desktop/ - **Workflow Database**: sql.js (SQLite in-process) ### Mobile + - **Framework**: Capacitor 6 - **Frontend**: React 18 + TypeScript (shared with desktop) - **Styling**: Tailwind CSS + shadcn/ui (shared) @@ -290,14 +292,14 @@ Get your API key from [WaveSpeedAI](https://wavespeed.ai) The application uses the WaveSpeedAI API v3: -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/v3/models` | GET | List available models | -| `/api/v3/{model}` | POST | Run a prediction | -| `/api/v3/predictions/{id}/result` | GET | Get prediction result | -| `/api/v3/predictions` | POST | Get prediction history | -| `/api/v3/media/upload/binary` | POST | Upload files | -| `/api/v3/balance` | GET | Get account balance | +| Endpoint | Method | Description | +| --------------------------------- | ------ | ---------------------- | +| `/api/v3/models` | GET | List available models | +| `/api/v3/{model}` | POST | Run a prediction | +| `/api/v3/predictions/{id}/result` | GET | Get prediction result | +| `/api/v3/predictions` | POST | Get prediction history | +| `/api/v3/media/upload/binary` | POST | Upload files | +| `/api/v3/balance` | GET | Get account balance | ## Contributing diff --git a/data/templates/ai-generation/image-to-video.json b/data/templates/ai-generation/image-to-video.json index f36c8185..0efef1aa 100644 --- a/data/templates/ai-generation/image-to-video.json +++ b/data/templates/ai-generation/image-to-video.json @@ -2,12 +2,7 @@ "name": "图生视频", "i18nKey": "imageToVideo", "description": "将静态图片转换为动态视频,并进行高清放大处理", - "tags": [ - "图片", - "视频", - "AI生成", - "视频放大" - ], + "tags": ["图片", "视频", "AI生成", "视频放大"], "category": "ai-generation", "author": "WaveSpeed", "thumbnail": "https://d1q70pf5vjeyhc.cloudfront.net/media/fb8f674bbb1a429d947016fd223cfae1/images/1759814613446857978_B0Cyvroj.jpeg", @@ -93,4 +88,4 @@ } ] } -} \ No newline at end of file +} diff --git a/data/templates/audio-processing/music_generate.json b/data/templates/audio-processing/music_generate.json index 217a178d..235fddaf 100644 --- a/data/templates/audio-processing/music_generate.json +++ b/data/templates/audio-processing/music_generate.json @@ -2,12 +2,7 @@ "name": "音乐生成", "i18nKey": "musicGenerate", "description": "通过歌词和风格标签生成原创音乐,支持多种音乐风格", - "tags": [ - "音乐", - "生成", - "AI", - "歌词" - ], + "tags": ["音乐", "生成", "AI", "歌词"], "category": "audio-conversion", "author": "WaveSpeed", "thumbnail": "https://d1q70pf5vjeyhc.wavespeed.ai/media/images/1752486874849919611_uMhlqGOZ.png", diff --git a/data/templates/image-processing/image-edit.json b/data/templates/image-processing/image-edit.json index ad7d3ce3..ce93f2d2 100644 --- a/data/templates/image-processing/image-edit.json +++ b/data/templates/image-processing/image-edit.json @@ -2,12 +2,7 @@ "name": "图像编辑", "i18nKey": "imageEdit", "description": "使用AI模型编辑图像,支持自定义提示词进行精确修改", - "tags": [ - "图像", - "编辑", - "AI", - "提示词" - ], + "tags": ["图像", "编辑", "AI", "提示词"], "category": "image-processing", "author": "WaveSpeed", "thumbnail": "https://d1q70pf5vjeyhc.cloudfront.net/media/a171ce9f725a41f49d5694458dba5f2a/images/1771092349188456413_57k0enwG.jpg", @@ -81,11 +76,7 @@ "type": "enum", "description": "The format of the output image.", "default": "jpeg", - "enum": [ - "jpeg", - "png", - "webp" - ], + "enum": ["jpeg", "png", "webp"], "fieldType": "select" } ] diff --git a/data/templates/video-processing/video-edit.json b/data/templates/video-processing/video-edit.json index 5a717f91..c5548ab9 100644 --- a/data/templates/video-processing/video-edit.json +++ b/data/templates/video-processing/video-edit.json @@ -2,11 +2,7 @@ "name": "视频编辑", "i18nKey": "videoEdit", "description": "使用AI模型编辑视频内容,支持通过提示词修改视频中的元素", - "tags": [ - "视频", - "编辑", - "AI" - ], + "tags": ["视频", "编辑", "AI"], "category": "video-editing", "author": "WaveSpeed", "thumbnail": "https://d1q70pf5vjeyhc.wavespeed.ai/media/images/1763608542503495122_E5Jc0qQc.png", @@ -49,10 +45,7 @@ "type": "enum", "description": "The resolution of the output video.", "default": "480p", - "enum": [ - "480p", - "720p" - ], + "enum": ["480p", "720p"], "fieldType": "select" }, { diff --git a/docs/mobile-desktop-unified-release.md b/docs/mobile-desktop-unified-release.md index 91016736..982b7f83 100644 --- a/docs/mobile-desktop-unified-release.md +++ b/docs/mobile-desktop-unified-release.md @@ -16,24 +16,24 @@ Mobile 通过 Vite alias 覆写了 Desktop 的 13 个文件。当 Desktop 更新 将 13 个覆写文件合并为 2 个,通过条件判断(`isCapacitorNative()`)让同一份代码兼容两个平台。 -| 覆写文件 | 合并前 | 合并后 | -|---------|-------|-------| -| slider, PromptOptimizer, DynamicForm | 3 个独立 mobile 版本 | 合并到 Desktop 代码 | -| 4 个 Worker Hooks | 4 个独立 mobile 版本 | 合并到 Desktop 代码 | -| API client | 独立 mobile 版本 | 合并到 Desktop 代码 | -| FormField, SizeSelector | 独立 mobile 版本 | 合并到 Desktop 代码 | -| FileUpload, AudioRecorder | 独立 mobile 版本 | 合并到 Desktop 代码 | -| **SettingsPage** | 独立 mobile 版本 | **保留覆写**(差异过大) | -| **i18n** | 独立 mobile 版本 | **保留覆写**(架构不同) | +| 覆写文件 | 合并前 | 合并后 | +| ------------------------------------ | -------------------- | ------------------------ | +| slider, PromptOptimizer, DynamicForm | 3 个独立 mobile 版本 | 合并到 Desktop 代码 | +| 4 个 Worker Hooks | 4 个独立 mobile 版本 | 合并到 Desktop 代码 | +| API client | 独立 mobile 版本 | 合并到 Desktop 代码 | +| FormField, SizeSelector | 独立 mobile 版本 | 合并到 Desktop 代码 | +| FileUpload, AudioRecorder | 独立 mobile 版本 | 合并到 Desktop 代码 | +| **SettingsPage** | 独立 mobile 版本 | **保留覆写**(差异过大) | +| **i18n** | 独立 mobile 版本 | **保留覆写**(架构不同) | **结果:13 → 2 个覆写文件(减少 85%)。** Desktop 日常开发的改动会自动同步到 Mobile。 ### 仍需手动同步的 2 个文件 -| 文件 | 保留覆写的原因 | -|------|--------------| +| 文件 | 保留覆写的原因 | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------ | | `src/pages/SettingsPage.tsx` | Desktop 有 Electron 自动更新、SD 模型管理;Mobile 有 APK 下载更新、Cache API、WASM 模型预下载。功能差异 >40% | -| `src/i18n/index.ts` | Mobile 通过 deepMerge 扩展 Desktop 翻译,架构层面不同 | +| `src/i18n/index.ts` | Mobile 通过 deepMerge 扩展 Desktop 翻译,架构层面不同 | CI 中已加入检测:如果这 2 个文件在 Desktop 端被修改,构建时会输出警告提醒手动同步。 @@ -75,14 +75,14 @@ push tag v1.0.48 一个 GitHub Release 包含所有平台安装包: -| 文件 | 平台 | -|------|-----| -| WaveSpeed-Desktop-win-x64.exe | Windows | -| WaveSpeed-Desktop-mac-x64.dmg | macOS Intel | -| WaveSpeed-Desktop-mac-arm64.dmg | macOS Apple Silicon | -| WaveSpeed-Desktop-linux-x86_64.AppImage | Linux | -| WaveSpeed-Mobile-{version}.apk | Android(带版本号) | -| WaveSpeed-Mobile.apk | Android(固定链接用) | +| 文件 | 平台 | +| --------------------------------------- | --------------------- | +| WaveSpeed-Desktop-win-x64.exe | Windows | +| WaveSpeed-Desktop-mac-x64.dmg | macOS Intel | +| WaveSpeed-Desktop-mac-arm64.dmg | macOS Apple Silicon | +| WaveSpeed-Desktop-linux-x86_64.AppImage | Linux | +| WaveSpeed-Mobile-{version}.apk | Android(带版本号) | +| WaveSpeed-Mobile.apk | Android(固定链接用) | ### 版本号 @@ -98,13 +98,13 @@ Mobile 版本号自动跟随 Desktop。CI 中自动完成: 所有平台均有一键下载徽章,指向 `/releases/latest/download/`,永远是最新版本: -| 平台 | 链接 | -|------|-----| -| Windows | `releases/latest/download/WaveSpeed-Desktop-win-x64.exe` | -| macOS Intel | `releases/latest/download/WaveSpeed-Desktop-mac-x64.dmg` | -| macOS Silicon | `releases/latest/download/WaveSpeed-Desktop-mac-arm64.dmg` | -| Linux | `releases/latest/download/WaveSpeed-Desktop-linux-x86_64.AppImage` | -| Android | `releases/latest/download/WaveSpeed-Mobile.apk` | +| 平台 | 链接 | +| ------------- | ------------------------------------------------------------------ | +| Windows | `releases/latest/download/WaveSpeed-Desktop-win-x64.exe` | +| macOS Intel | `releases/latest/download/WaveSpeed-Desktop-mac-x64.dmg` | +| macOS Silicon | `releases/latest/download/WaveSpeed-Desktop-mac-arm64.dmg` | +| Linux | `releases/latest/download/WaveSpeed-Desktop-linux-x86_64.AppImage` | +| Android | `releases/latest/download/WaveSpeed-Mobile.apk` | --- @@ -112,21 +112,21 @@ Mobile 版本号自动跟随 Desktop。CI 中自动完成: ### Desktop -| 功能 | 支持 | -|------|-----| -| 后台自动检查更新 | ✅ 启动后 3 秒自动检查 | -| 应用内下载 | ✅ 后台静默下载,显示进度 | -| 自动安装 | ✅ 退出时自动安装新版本 | -| 更新频道 | ✅ Stable / Nightly 可切换 | +| 功能 | 支持 | +| ---------------- | -------------------------- | +| 后台自动检查更新 | ✅ 启动后 3 秒自动检查 | +| 应用内下载 | ✅ 后台静默下载,显示进度 | +| 自动安装 | ✅ 退出时自动安装新版本 | +| 更新频道 | ✅ Stable / Nightly 可切换 | ### Mobile -| 功能 | 支持 | -|------|-----| -| 手动检查更新 | ✅ Settings → 检查更新 | +| 功能 | 支持 | +| -------------- | ------------------------------------ | +| 手动检查更新 | ✅ Settings → 检查更新 | | 应用内下载 APK | ✅ 下载到 Downloads 文件夹,显示进度 | -| 自动安装 | ❌ 需用户手动点击 APK 安装 | -| 后台自动检查 | ❌ 需用户主动触发 | +| 自动安装 | ❌ 需用户手动点击 APK 安装 | +| 后台自动检查 | ❌ 需用户主动触发 | ### Mobile 自动升级的限制 @@ -137,6 +137,7 @@ Android 侧载(sideload)应用无法实现静默自动更新,这是 Androi 3. **安全策略**:APK 自动替换需要系统级权限,普通应用无法获取 **如需实现 Mobile 真正的自动更新,需要上架 Google Play Store。** Google Play 提供: + - 后台自动下载更新 - Wi-Fi 下静默安装 - 灰度发布 / 分阶段推送 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index ea16f903..7dbfd6c1 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,20 +1,25 @@ -import { resolve } from 'path' -import { defineConfig, externalizeDepsPlugin } from 'electron-vite' -import react from '@vitejs/plugin-react' -import type { Plugin } from 'vite' +import { resolve } from "path"; +import { defineConfig, externalizeDepsPlugin } from "electron-vite"; +import react from "@vitejs/plugin-react"; +import type { Plugin } from "vite"; // Stub Capacitor modules for desktop dev server (they only run on mobile) -const CAPACITOR_MODULES = ['@capacitor/core', '@capacitor/filesystem', '@capacitor/browser'] +const CAPACITOR_MODULES = [ + "@capacitor/core", + "@capacitor/filesystem", + "@capacitor/browser", +]; function stubCapacitorPlugin(): Plugin { return { - name: 'stub-capacitor', + name: "stub-capacitor", resolveId(id) { - if (CAPACITOR_MODULES.includes(id)) return '\0' + id + if (CAPACITOR_MODULES.includes(id)) return "\0" + id; }, load(id) { - if (id.startsWith('\0@capacitor/')) return 'export default {}; export const CapacitorHttp = {}; export const Filesystem = {}; export const Directory = {}; export const Browser = {};' - } - } + if (id.startsWith("\0@capacitor/")) + return "export default {}; export const CapacitorHttp = {}; export const Filesystem = {}; export const Directory = {}; export const Browser = {};"; + }, + }; } export default defineConfig({ @@ -23,52 +28,48 @@ export default defineConfig({ build: { rollupOptions: { input: { - index: resolve(__dirname, 'electron/main.ts') + index: resolve(__dirname, "electron/main.ts"), }, - external: ['sql.js'] - } - } + external: ["sql.js"], + }, + }, }, preload: { plugins: [externalizeDepsPlugin()], build: { rollupOptions: { input: { - index: resolve(__dirname, 'electron/preload.ts') - } - } - } + index: resolve(__dirname, "electron/preload.ts"), + }, + }, + }, }, renderer: { - root: '.', + root: ".", resolve: { alias: { - '@': resolve(__dirname, 'src') - } + "@": resolve(__dirname, "src"), + }, }, plugins: [stubCapacitorPlugin(), react()], build: { rollupOptions: { - input: resolve(__dirname, 'index.html'), + input: resolve(__dirname, "index.html"), // Externalize Capacitor modules - they're only used in mobile builds - external: CAPACITOR_MODULES - } + external: CAPACITOR_MODULES, + }, }, optimizeDeps: { - include: [ - 'onnxruntime-web', - 'upscaler', - '@huggingface/transformers' - ], - exclude: ['@google/model-viewer'] + include: ["onnxruntime-web", "upscaler", "@huggingface/transformers"], + exclude: ["@google/model-viewer"], }, server: { port: 5173, - strictPort: false, // Auto-find available port if 5173 is in use - host: '0.0.0.0' + strictPort: false, // Auto-find available port if 5173 is in use + host: "0.0.0.0", }, worker: { - format: 'es' - } - } -}) + format: "es", + }, + }, +}); diff --git a/electron/lib/sdGenerator.ts b/electron/lib/sdGenerator.ts index 0da78399..20501e1d 100644 --- a/electron/lib/sdGenerator.ts +++ b/electron/lib/sdGenerator.ts @@ -8,55 +8,55 @@ * - Log streaming */ -import { spawn, ChildProcess } from 'child_process' -import { existsSync, mkdirSync } from 'fs' -import { dirname } from 'path' +import { spawn, ChildProcess } from "child_process"; +import { existsSync, mkdirSync } from "fs"; +import { dirname } from "path"; export interface GenerationOptions { - binaryPath: string - modelPath: string - llmPath?: string - vaePath?: string - clipOnCpu?: boolean - vaeTiling?: boolean - prompt: string - negativePrompt?: string - width: number - height: number - steps: number - cfgScale: number - seed?: number - samplingMethod?: string - scheduler?: string - outputPath: string - onProgress?: (progress: GenerationProgress) => void - onLog?: (log: LogMessage) => void + binaryPath: string; + modelPath: string; + llmPath?: string; + vaePath?: string; + clipOnCpu?: boolean; + vaeTiling?: boolean; + prompt: string; + negativePrompt?: string; + width: number; + height: number; + steps: number; + cfgScale: number; + seed?: number; + samplingMethod?: string; + scheduler?: string; + outputPath: string; + onProgress?: (progress: GenerationProgress) => void; + onLog?: (log: LogMessage) => void; } export interface GenerationProgress { - phase: 'generate' - progress: number // 0-100 + phase: "generate"; + progress: number; // 0-100 detail?: { - current: number - total: number - unit: 'steps' - } + current: number; + total: number; + unit: "steps"; + }; } export interface LogMessage { - type: 'stdout' | 'stderr' - message: string + type: "stdout" | "stderr"; + message: string; } export interface GenerationResult { - success: boolean - outputPath?: string - error?: string + success: boolean; + outputPath?: string; + error?: string; } export class SDGenerator { - private activeProcess: ChildProcess | null = null - private isCancelling = false + private activeProcess: ChildProcess | null = null; + private isCancelling = false; /** * Generate an image using stable-diffusion.cpp binary @@ -76,217 +76,221 @@ export class SDGenerator { seed, samplingMethod, scheduler, - outputPath, - clipOnCpu, - vaeTiling, - onProgress, - onLog - } = options + outputPath, + clipOnCpu, + vaeTiling, + onProgress, + onLog, + } = options; // Validate binary exists if (!existsSync(binaryPath)) { return { success: false, - error: `Binary not found at: ${binaryPath}` - } + error: `Binary not found at: ${binaryPath}`, + }; } // Sanitize prompt (escape dangerous characters) - const sanitizePrompt = (text: string) => text.replace(/["`$\\]/g, '\\$&').trim() - const safePrompt = sanitizePrompt(prompt) - const safeNegPrompt = negativePrompt ? sanitizePrompt(negativePrompt) : '' + const sanitizePrompt = (text: string) => + text.replace(/["`$\\]/g, "\\$&").trim(); + const safePrompt = sanitizePrompt(prompt); + const safeNegPrompt = negativePrompt ? sanitizePrompt(negativePrompt) : ""; // Ensure output directory exists - const outputDir = dirname(outputPath) + const outputDir = dirname(outputPath); if (!existsSync(outputDir)) { - mkdirSync(outputDir, { recursive: true }) + mkdirSync(outputDir, { recursive: true }); } // Build command arguments const args = [ - '--diffusion-model', + "--diffusion-model", modelPath, - '-p', + "-p", safePrompt, - '-W', + "-W", width.toString(), - '-H', + "-H", height.toString(), - '--steps', + "--steps", steps.toString(), - '--cfg-scale', + "--cfg-scale", cfgScale.toString(), - '-o', + "-o", outputPath, - '-v', // Verbose - '--offload-to-cpu', // Offload to CPU when needed - '--diffusion-fa' // Use Flash Attention - ] + "-v", // Verbose + "--offload-to-cpu", // Offload to CPU when needed + "--diffusion-fa", // Use Flash Attention + ]; if (clipOnCpu) { - args.push('--clip-on-cpu') + args.push("--clip-on-cpu"); } if (vaeTiling) { - args.push('--vae-tiling') + args.push("--vae-tiling"); } // Add LLM (text encoder) if provided if (llmPath && existsSync(llmPath)) { - args.push('--llm', llmPath) + args.push("--llm", llmPath); } // Add VAE if provided if (vaePath && existsSync(vaePath)) { - args.push('--vae', vaePath) + args.push("--vae", vaePath); } if (safeNegPrompt) { - args.push('-n', safeNegPrompt) + args.push("-n", safeNegPrompt); } if (seed !== undefined) { - args.push('--seed', seed.toString()) + args.push("--seed", seed.toString()); } if (samplingMethod) { - args.push('--sampling-method', samplingMethod) + args.push("--sampling-method", samplingMethod); } if (scheduler) { - args.push('--scheduler', scheduler) + args.push("--scheduler", scheduler); } - console.log('[SDGenerator] Spawning SD process:', binaryPath) - console.log('[SDGenerator] Arguments:', JSON.stringify(args)) + console.log("[SDGenerator] Spawning SD process:", binaryPath); + console.log("[SDGenerator] Arguments:", JSON.stringify(args)); - const binaryDir = dirname(binaryPath) + const binaryDir = dirname(binaryPath); const ldLibraryPath = process.env.LD_LIBRARY_PATH ? `${binaryDir}:${process.env.LD_LIBRARY_PATH}` - : binaryDir + : binaryDir; // Spawn child process const childProcess = spawn(binaryPath, args, { cwd: outputDir, - env: { ...process.env, LD_LIBRARY_PATH: ldLibraryPath } - }) + env: { ...process.env, LD_LIBRARY_PATH: ldLibraryPath }, + }); // Track active process for cancellation - this.activeProcess = childProcess + this.activeProcess = childProcess; - let stderrData = '' - let stdoutData = '' + let stderrData = ""; + let stdoutData = ""; // Listen to stdout and send logs + parse progress - childProcess.stdout.on('data', (data) => { - const log = data.toString() - stdoutData += log + childProcess.stdout.on("data", (data) => { + const log = data.toString(); + stdoutData += log; // Send log to caller if (onLog) { onLog({ - type: 'stdout', - message: log - }) + type: "stdout", + message: log, + }); } // Parse progress from stdout (some SD versions output here) - const progressInfo = this.parseProgress(log) + const progressInfo = this.parseProgress(log); if (progressInfo && onProgress) { - const scaledProgress = 10 + Math.min(progressInfo.progress, 100) * 0.9 + const scaledProgress = 10 + Math.min(progressInfo.progress, 100) * 0.9; onProgress({ - phase: 'generate', + phase: "generate", progress: Math.min(scaledProgress, 99), detail: { current: progressInfo.current, total: progressInfo.total, - unit: 'steps' - } - }) + unit: "steps", + }, + }); } - }) + }); // Listen to stderr and send logs + parse progress - childProcess.stderr.on('data', (data) => { - const log = data.toString() - stderrData += log + childProcess.stderr.on("data", (data) => { + const log = data.toString(); + stderrData += log; // Send log to caller if (onLog) { onLog({ - type: 'stderr', - message: log - }) + type: "stderr", + message: log, + }); } // Parse progress from stderr - const progressInfo = this.parseProgress(log) + const progressInfo = this.parseProgress(log); if (progressInfo && onProgress) { - const scaledProgress = 10 + Math.min(progressInfo.progress, 100) * 0.9 + const scaledProgress = 10 + Math.min(progressInfo.progress, 100) * 0.9; onProgress({ - phase: 'generate', + phase: "generate", progress: Math.min(scaledProgress, 99), detail: { current: progressInfo.current, total: progressInfo.total, - unit: 'steps' - } - }) + unit: "steps", + }, + }); } - }) + }); // Wait for process to end return new Promise((resolve) => { - childProcess.on('close', (code, signal) => { - this.activeProcess = null - const wasCancelled = this.isCancelling || signal === 'SIGTERM' || signal === 'SIGINT' - this.isCancelling = false + childProcess.on("close", (code, signal) => { + this.activeProcess = null; + const wasCancelled = + this.isCancelling || signal === "SIGTERM" || signal === "SIGINT"; + this.isCancelling = false; if (wasCancelled) { resolve({ success: false, - error: 'Cancelled' - }) - return + error: "Cancelled", + }); + return; } if (code === 0 && existsSync(outputPath)) { - console.log('[SDGenerator] Generation successful') + console.log("[SDGenerator] Generation successful"); if (onProgress) { onProgress({ - phase: 'generate', - progress: 100 - }) + phase: "generate", + progress: 100, + }); } resolve({ success: true, - outputPath: outputPath - }) + outputPath: outputPath, + }); } else { // Extract error information - const errorLines = stderrData.split('\n').filter((line) => line.trim()) + const errorLines = stderrData + .split("\n") + .filter((line) => line.trim()); const errorMsg = errorLines.length > 0 ? errorLines[errorLines.length - 1] - : `Process exited with code ${code ?? 'unknown'}` + : `Process exited with code ${code ?? "unknown"}`; - console.error('[SDGenerator] Generation failed:', errorMsg) + console.error("[SDGenerator] Generation failed:", errorMsg); resolve({ success: false, - error: errorMsg - }) + error: errorMsg, + }); } - }) + }); - childProcess.on('error', (err) => { - this.activeProcess = null - console.error('[SDGenerator] Process error:', err.message) + childProcess.on("error", (err) => { + this.activeProcess = null; + console.error("[SDGenerator] Process error:", err.message); resolve({ success: false, - error: err.message - }) - }) - }) + error: err.message, + }); + }); + }); } /** @@ -298,42 +302,45 @@ export class SDGenerator { * - "|==================================================| 12/12 - 7.28s/it" */ private parseProgress(log: string): { - current: number - total: number - progress: number + current: number; + total: number; + progress: number; } | null { // Strip ANSI escape codes and scan the most recent lines for progress - const cleaned = log.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '') - const lines = cleaned.split(/[\r\n]+/).filter(Boolean).reverse() + const cleaned = log.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); + const lines = cleaned + .split(/[\r\n]+/) + .filter(Boolean) + .reverse(); for (const line of lines) { // Match "step: X/Y" or "sampling: X/Y" - let stepMatch = line.match(/(?:step|sampling):\s*(\d+)\/(\d+)/) + let stepMatch = line.match(/(?:step|sampling):\s*(\d+)\/(\d+)/); if (!stepMatch) { // Match progress bar format: |===...===| 12/20 - time // Must have dash or space after the numbers to avoid matching resolution - stepMatch = line.match(/\|[=\s>-]+\|\s*(\d+)\/(\d+)\s*[-\s]/) + stepMatch = line.match(/\|[=\s>-]+\|\s*(\d+)\/(\d+)\s*[-\s]/); } if (!stepMatch) { // Match tqdm-style output: " 3/20 [00:00<00:01, 3.45it/s]" - stepMatch = line.match(/\b(\d+)\s*\/\s*(\d+)\b.*(?:it\/s|s\/it|\])/) + stepMatch = line.match(/\b(\d+)\s*\/\s*(\d+)\b.*(?:it\/s|s\/it|\])/); } if (stepMatch) { - const current = parseInt(stepMatch[1], 10) - const total = parseInt(stepMatch[2], 10) + const current = parseInt(stepMatch[1], 10); + const total = parseInt(stepMatch[2], 10); // Validate: reasonable step range and current > 0 and current <= total if (total >= 1 && total <= 512 && current > 0 && current <= total) { - const progress = Math.round((current / total) * 100) - return { current, total, progress } + const progress = Math.round((current / total) * 100); + return { current, total, progress }; } } } - return null + return null; } /** @@ -341,12 +348,12 @@ export class SDGenerator { */ cancel(): boolean { if (this.activeProcess) { - console.log('[SDGenerator] Cancelling generation') - this.isCancelling = true - this.activeProcess.kill('SIGTERM') - this.activeProcess = null - return true + console.log("[SDGenerator] Cancelling generation"); + this.isCancelling = true; + this.activeProcess.kill("SIGTERM"); + this.activeProcess = null; + return true; } - return false + return false; } } diff --git a/electron/main.ts b/electron/main.ts index 9cd7763c..d8bc81c9 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,32 +1,59 @@ -import { app, BrowserWindow, shell, ipcMain, dialog, Menu, clipboard, protocol, net } from 'electron' -import { join, dirname } from 'path' -import { existsSync, readFileSync, writeFileSync, mkdirSync, createWriteStream, unlinkSync, statSync, readdirSync, copyFileSync } from 'fs' -import { readdir, stat } from 'fs/promises' -import AdmZip from 'adm-zip' -import { electronApp, optimizer, is } from '@electron-toolkit/utils' -import { autoUpdater, UpdateInfo } from 'electron-updater' -import { spawn, execSync } from 'child_process' -import https from 'https' -import http from 'http' -import { pathToFileURL } from 'url' -import { SDGenerator } from './lib/sdGenerator' -import log from 'electron-log' -import { initWorkflowModule, closeWorkflowDatabase } from './workflow' +import { + app, + BrowserWindow, + shell, + ipcMain, + dialog, + Menu, + clipboard, + protocol, + net, +} from "electron"; +import { join, dirname } from "path"; +import { + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + createWriteStream, + unlinkSync, + statSync, + readdirSync, + copyFileSync, +} from "fs"; +import { readdir, stat } from "fs/promises"; +import AdmZip from "adm-zip"; +import { electronApp, optimizer, is } from "@electron-toolkit/utils"; +import { autoUpdater, UpdateInfo } from "electron-updater"; +import { spawn, execSync } from "child_process"; +import https from "https"; +import http from "http"; +import { pathToFileURL } from "url"; +import { SDGenerator } from "./lib/sdGenerator"; +import log from "electron-log"; +import { initWorkflowModule, closeWorkflowDatabase } from "./workflow"; // Suppress Chromium's noisy ffmpeg pixel format warnings (harmless, caused by video thumbnail decoding) // These come from GPU/renderer processes' stderr and cannot be disabled via command-line switches. // Filter them at the process level: -const originalStderrWrite = process.stderr.write.bind(process.stderr) -process.stderr.write = (chunk: string | Uint8Array, ...args: unknown[]): boolean => { - const str = typeof chunk === 'string' ? chunk : chunk.toString() - if (str.includes('Unsupported pixel format') || str.includes('ffmpeg_common.cc')) return true - return (originalStderrWrite as (...a: unknown[]) => boolean)(chunk, ...args) -} +const originalStderrWrite = process.stderr.write.bind(process.stderr); +process.stderr.write = ( + chunk: string | Uint8Array, + ...args: unknown[] +): boolean => { + const str = typeof chunk === "string" ? chunk : chunk.toString(); + if ( + str.includes("Unsupported pixel format") || + str.includes("ffmpeg_common.cc") + ) + return true; + return (originalStderrWrite as (...a: unknown[]) => boolean)(chunk, ...args); +}; // Linux-specific flags -if (process.platform === 'linux') { - app.commandLine.appendSwitch('no-sandbox') - app.commandLine.appendSwitch('disable-gpu-sandbox') +if (process.platform === "linux") { + app.commandLine.appendSwitch("no-sandbox"); + app.commandLine.appendSwitch("disable-gpu-sandbox"); } // Configure electron-log @@ -34,62 +61,67 @@ if (process.platform === 'linux') { // - Windows: %USERPROFILE%\AppData\Roaming\wavespeed-desktop\logs\main.log // - macOS: ~/Library/Logs/wavespeed-desktop/main.log // - Linux: ~/.config/wavespeed-desktop/logs/main.log -log.transports.file.level = 'info' -log.transports.console.level = is.dev ? 'debug' : 'info' -log.info('='.repeat(80)) -log.info('Application starting...') -log.info('Version:', app.getVersion()) -log.info('Platform:', process.platform, process.arch) -log.info('Electron:', process.versions.electron) -log.info('Chrome:', process.versions.chrome) -log.info('Node:', process.versions.node) -log.info('Log file:', log.transports.file.getFile().path) -log.info('='.repeat(80)) +log.transports.file.level = "info"; +log.transports.console.level = is.dev ? "debug" : "info"; +log.info("=".repeat(80)); +log.info("Application starting..."); +log.info("Version:", app.getVersion()); +log.info("Platform:", process.platform, process.arch); +log.info("Electron:", process.versions.electron); +log.info("Chrome:", process.versions.chrome); +log.info("Node:", process.versions.node); +log.info("Log file:", log.transports.file.getFile().path); +log.info("=".repeat(80)); // Override console methods to use electron-log -console.log = log.log.bind(log) -console.info = log.info.bind(log) -console.warn = log.warn.bind(log) -console.error = log.error.bind(log) -console.debug = log.debug.bind(log) +console.log = log.log.bind(log); +console.info = log.info.bind(log); +console.warn = log.warn.bind(log); +console.error = log.error.bind(log); +console.debug = log.debug.bind(log); // Settings storage -const userDataPath = app.getPath('userData') -const settingsPath = join(userDataPath, 'settings.json') +const userDataPath = app.getPath("userData"); +const settingsPath = join(userDataPath, "settings.json"); // Global instances for SD operations -const sdGenerator = new SDGenerator() +const sdGenerator = new SDGenerator(); // Global reference to active SD generation process (deprecated - using sdGenerator) -let activeSDProcess: ReturnType | null = null +let activeSDProcess: ReturnType | null = null; // Cache for system info to avoid repeated checks -let systemInfoCache: { platform: string; arch: string; acceleration: string; supported: boolean } | null = null -let metalSupportCache: boolean | null = null -let binaryPathLoggedOnce = false +let systemInfoCache: { + platform: string; + arch: string; + acceleration: string; + supported: boolean; +} | null = null; +let metalSupportCache: boolean | null = null; +let binaryPathLoggedOnce = false; function parseMaxNumberFromOutput(output: string): number | null { const values = output .split(/\r?\n/) - .map((line) => parseInt(line.replace(/[^\d]/g, '').trim(), 10)) - .filter((value) => Number.isFinite(value) && value > 0) + .map((line) => parseInt(line.replace(/[^\d]/g, "").trim(), 10)) + .filter((value) => Number.isFinite(value) && value > 0); if (values.length === 0) { - return null + return null; } - return Math.max(...values) + return Math.max(...values); } function getNvidiaVramMb(): number | null { try { const output = execSync( - 'nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits', - { encoding: 'utf8', timeout: 3000 } - ) - return parseMaxNumberFromOutput(output) + "nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits", + { encoding: "utf8", timeout: 3000 }, + ); + return parseMaxNumberFromOutput(output); } catch (error) { - return null + return null; } } @@ -97,978 +129,1054 @@ function getWindowsGpuVramMb(): number | null { try { const output = execSync( 'powershell -NoProfile -Command "Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty AdapterRAM"', - { encoding: 'utf8', timeout: 3000 } - ) - const bytes = parseMaxNumberFromOutput(output) - if (!bytes) return null - return Math.round(bytes / (1024 * 1024)) + { encoding: "utf8", timeout: 3000 }, + ); + const bytes = parseMaxNumberFromOutput(output); + if (!bytes) return null; + return Math.round(bytes / (1024 * 1024)); } catch (error) { try { const output = execSync( - 'wmic path win32_VideoController get AdapterRAM', - { encoding: 'utf8', timeout: 3000 } - ) - const bytes = parseMaxNumberFromOutput(output) - if (!bytes) return null - return Math.round(bytes / (1024 * 1024)) + "wmic path win32_VideoController get AdapterRAM", + { encoding: "utf8", timeout: 3000 }, + ); + const bytes = parseMaxNumberFromOutput(output); + if (!bytes) return null; + return Math.round(bytes / (1024 * 1024)); } catch (err) { - return null + return null; } } } function getGpuVramMb(): number | null { - if (process.platform !== 'win32') { - return null + if (process.platform !== "win32") { + return null; } - return getNvidiaVramMb() ?? getWindowsGpuVramMb() + return getNvidiaVramMb() ?? getWindowsGpuVramMb(); } interface Settings { - apiKey: string - theme: 'light' | 'dark' | 'system' - defaultPollInterval: number - defaultTimeout: number - updateChannel: 'stable' | 'nightly' - autoCheckUpdate: boolean - autoSaveAssets: boolean - assetsDirectory: string - language: string + apiKey: string; + theme: "light" | "dark" | "system"; + defaultPollInterval: number; + defaultTimeout: number; + updateChannel: "stable" | "nightly"; + autoCheckUpdate: boolean; + autoSaveAssets: boolean; + assetsDirectory: string; + language: string; } interface AssetMetadata { - id: string - filePath: string - fileName: string - type: 'image' | 'video' | 'audio' | 'text' | 'json' - modelId: string - modelName: string - createdAt: string - fileSize: number - tags: string[] - favorite: boolean - predictionId?: string - originalUrl?: string - source?: 'playground' | 'workflow' | 'free-tool' - workflowId?: string - workflowName?: string - nodeId?: string - executionId?: string + id: string; + filePath: string; + fileName: string; + type: "image" | "video" | "audio" | "text" | "json"; + modelId: string; + modelName: string; + createdAt: string; + fileSize: number; + tags: string[]; + favorite: boolean; + predictionId?: string; + originalUrl?: string; + source?: "playground" | "workflow" | "free-tool"; + workflowId?: string; + workflowName?: string; + nodeId?: string; + executionId?: string; } // ─── Persistent key-value state (survives app restarts, unlike renderer localStorage) ──── -const statePath = join(userDataPath, 'renderer-state.json') +const statePath = join(userDataPath, "renderer-state.json"); function loadState(): Record { try { if (existsSync(statePath)) { - return JSON.parse(readFileSync(statePath, 'utf-8')) + return JSON.parse(readFileSync(statePath, "utf-8")); } - } catch { /* corrupted file — start fresh */ } - return {} + } catch { + /* corrupted file — start fresh */ + } + return {}; } function saveState(state: Record): void { try { - if (!existsSync(userDataPath)) mkdirSync(userDataPath, { recursive: true }) - writeFileSync(statePath, JSON.stringify(state, null, 2)) + if (!existsSync(userDataPath)) mkdirSync(userDataPath, { recursive: true }); + writeFileSync(statePath, JSON.stringify(state, null, 2)); } catch (error) { - console.error('Failed to save renderer state:', error) + console.error("Failed to save renderer state:", error); } } -const defaultAssetsDirectory = join(app.getPath('documents'), 'WaveSpeed') -const assetsMetadataPath = join(userDataPath, 'assets-metadata.json') +const defaultAssetsDirectory = join(app.getPath("documents"), "WaveSpeed"); +const assetsMetadataPath = join(userDataPath, "assets-metadata.json"); const defaultSettings: Settings = { - apiKey: '', - theme: 'system', + apiKey: "", + theme: "system", defaultPollInterval: 1000, defaultTimeout: 36000, - updateChannel: 'stable', + updateChannel: "stable", autoCheckUpdate: true, autoSaveAssets: true, assetsDirectory: defaultAssetsDirectory, - language: 'auto' -} + language: "auto", +}; function loadSettings(): Settings { try { if (existsSync(settingsPath)) { - const data = readFileSync(settingsPath, 'utf-8') - return { ...defaultSettings, ...JSON.parse(data) } + const data = readFileSync(settingsPath, "utf-8"); + return { ...defaultSettings, ...JSON.parse(data) }; } } catch (error) { - console.error('Failed to load settings:', error) + console.error("Failed to load settings:", error); } - return { ...defaultSettings } + return { ...defaultSettings }; } function saveSettings(settings: Partial): void { try { - const currentSettings = loadSettings() - const newSettings = { ...currentSettings, ...settings } + const currentSettings = loadSettings(); + const newSettings = { ...currentSettings, ...settings }; if (!existsSync(userDataPath)) { - mkdirSync(userDataPath, { recursive: true }) + mkdirSync(userDataPath, { recursive: true }); } - writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2)) + writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2)); } catch (error) { - console.error('Failed to save settings:', error) + console.error("Failed to save settings:", error); } } function createWindow(): void { - const isMac = process.platform === 'darwin' + const isMac = process.platform === "darwin"; mainWindow = new BrowserWindow({ width: 1400, height: 900, - minWidth: 1000, - minHeight: 700, + minWidth: 520, + minHeight: 400, show: false, autoHideMenuBar: true, - icon: join(__dirname, '../../build/icon.png'), - backgroundColor: '#080c16', - titleBarStyle: isMac ? 'hiddenInset' : 'hidden', + icon: join(__dirname, "../../build/icon.png"), + backgroundColor: "#080c16", + titleBarStyle: isMac ? "hiddenInset" : "hidden", ...(isMac ? { trafficLightPosition: { x: 10, y: 8 } } : {}), - ...(process.platform !== 'darwin' ? { - titleBarOverlay: { - color: '#080c16', - symbolColor: '#6b7280', - height: 32 - } - } : {}), + ...(process.platform !== "darwin" + ? { + titleBarOverlay: { + color: "#080c16", + symbolColor: "#6b7280", + height: 32, + }, + } + : {}), webPreferences: { - preload: join(__dirname, '../preload/index.js'), + preload: join(__dirname, "../preload/index.js"), sandbox: false, contextIsolation: true, nodeIntegration: false, - webSecurity: !is.dev // Disable web security in dev mode to bypass CORS - } - }) + webSecurity: !is.dev, // Disable web security in dev mode to bypass CORS + }, + }); - mainWindow.on('ready-to-show', () => { - mainWindow?.show() - }) + mainWindow.on("ready-to-show", () => { + mainWindow?.show(); + }); // macOS: Hide window instead of closing when clicking the red button // The app will only quit when user presses Cmd+Q - if (process.platform === 'darwin') { - mainWindow.on('close', (event) => { + if (process.platform === "darwin") { + mainWindow.on("close", (event) => { if (!(app as typeof app & { isQuitting?: boolean }).isQuitting) { - event.preventDefault() + event.preventDefault(); if (mainWindow?.isFullScreen()) { - const targetWindow = mainWindow - targetWindow.once('leave-full-screen', () => { - targetWindow.hide() - }) - targetWindow.setFullScreen(false) + const targetWindow = mainWindow; + targetWindow.once("leave-full-screen", () => { + targetWindow.hide(); + }); + targetWindow.setFullScreen(false); } else { - mainWindow?.hide() + mainWindow?.hide(); } } - }) + }); } mainWindow.webContents.setWindowOpenHandler((details) => { - shell.openExternal(details.url) - return { action: 'deny' } - }) + shell.openExternal(details.url); + return { action: "deny" }; + }); // Error handling for renderer - mainWindow.webContents.on('did-fail-load', (_, errorCode, errorDescription, validatedURL) => { - console.error('Failed to load:', errorCode, errorDescription, validatedURL) - }) - - mainWindow.webContents.on('render-process-gone', (_, details) => { - console.error('Render process gone:', details) - }) + mainWindow.webContents.on( + "did-fail-load", + (_, errorCode, errorDescription, validatedURL) => { + console.error( + "Failed to load:", + errorCode, + errorDescription, + validatedURL, + ); + }, + ); + + mainWindow.webContents.on("render-process-gone", (_, details) => { + console.error("Render process gone:", details); + }); // Load the app - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) + if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { + mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]); } else { - const indexPath = join(__dirname, '../renderer/index.html') - console.log('Loading renderer from:', indexPath) - console.log('File exists:', existsSync(indexPath)) - mainWindow.loadFile(indexPath) + const indexPath = join(__dirname, "../renderer/index.html"); + console.log("Loading renderer from:", indexPath); + console.log("File exists:", existsSync(indexPath)); + mainWindow.loadFile(indexPath); } // Open DevTools with keyboard shortcut (Cmd+Opt+I on Mac, Ctrl+Shift+I on Windows/Linux) - mainWindow.webContents.on('before-input-event', (_, input) => { - if ((input.meta || input.control) && input.shift && input.key.toLowerCase() === 'i') { - mainWindow?.webContents.toggleDevTools() + mainWindow.webContents.on("before-input-event", (_, input) => { + if ( + (input.meta || input.control) && + input.shift && + input.key.toLowerCase() === "i" + ) { + mainWindow?.webContents.toggleDevTools(); } // Also allow F12 - if (input.key === 'F12') { - mainWindow?.webContents.toggleDevTools() + if (input.key === "F12") { + mainWindow?.webContents.toggleDevTools(); } - }) + }); // Enable right-click context menu - mainWindow.webContents.on('context-menu', (_, params) => { - const menuItems: Electron.MenuItemConstructorOptions[] = [] + mainWindow.webContents.on("context-menu", (_, params) => { + const menuItems: Electron.MenuItemConstructorOptions[] = []; // Add text editing options when in editable field if (params.isEditable) { menuItems.push( - { label: 'Cut', role: 'cut', enabled: params.editFlags.canCut }, - { label: 'Copy', role: 'copy', enabled: params.editFlags.canCopy }, - { label: 'Paste', role: 'paste', enabled: params.editFlags.canPaste }, - { type: 'separator' }, - { label: 'Select All', role: 'selectAll' } - ) + { label: "Cut", role: "cut", enabled: params.editFlags.canCut }, + { label: "Copy", role: "copy", enabled: params.editFlags.canCopy }, + { label: "Paste", role: "paste", enabled: params.editFlags.canPaste }, + { type: "separator" }, + { label: "Select All", role: "selectAll" }, + ); } else if (params.selectionText) { // Add copy option when text is selected - menuItems.push( - { label: 'Copy', role: 'copy' } - ) + menuItems.push({ label: "Copy", role: "copy" }); } // Add link options if (params.linkURL) { - if (menuItems.length > 0) menuItems.push({ type: 'separator' }) + if (menuItems.length > 0) menuItems.push({ type: "separator" }); menuItems.push( { - label: 'Open Link in Browser', - click: () => shell.openExternal(params.linkURL) + label: "Open Link in Browser", + click: () => shell.openExternal(params.linkURL), }, { - label: 'Copy Link', - click: () => clipboard.writeText(params.linkURL) - } - ) + label: "Copy Link", + click: () => clipboard.writeText(params.linkURL), + }, + ); } // Add image options - if (params.mediaType === 'image') { - if (menuItems.length > 0) menuItems.push({ type: 'separator' }) + if (params.mediaType === "image") { + if (menuItems.length > 0) menuItems.push({ type: "separator" }); menuItems.push( { - label: 'Copy Image', - click: () => mainWindow?.webContents.copyImageAt(params.x, params.y) + label: "Copy Image", + click: () => mainWindow?.webContents.copyImageAt(params.x, params.y), }, { - label: 'Open Image in Browser', - click: () => shell.openExternal(params.srcURL) - } - ) + label: "Open Image in Browser", + click: () => shell.openExternal(params.srcURL), + }, + ); } if (menuItems.length > 0) { - const menu = Menu.buildFromTemplate(menuItems) - menu.popup() + const menu = Menu.buildFromTemplate(menuItems); + menu.popup(); } - }) + }); } // IPC Handlers // Update title bar overlay colors when theme changes (Windows only) -ipcMain.handle('update-titlebar-theme', (_, isDark: boolean) => { - if (process.platform === 'darwin' || !mainWindow) return +ipcMain.handle("update-titlebar-theme", (_, isDark: boolean) => { + if (process.platform === "darwin" || !mainWindow) return; try { mainWindow.setTitleBarOverlay({ - color: isDark ? '#080c16' : '#f6f7f9', - symbolColor: isDark ? '#9ca3af' : '#6b7280', - height: 32 - }) + color: isDark ? "#080c16" : "#f6f7f9", + symbolColor: isDark ? "#9ca3af" : "#6b7280", + height: 32, + }); } catch { // setTitleBarOverlay may not be available on all platforms } -}) +}); -ipcMain.handle('get-api-key', () => { - const settings = loadSettings() - return settings.apiKey -}) +ipcMain.handle("get-api-key", () => { + const settings = loadSettings(); + return settings.apiKey; +}); -ipcMain.handle('set-api-key', (_, apiKey: string) => { - saveSettings({ apiKey }) - return true -}) +ipcMain.handle("set-api-key", (_, apiKey: string) => { + saveSettings({ apiKey }); + return true; +}); -ipcMain.handle('get-settings', () => { - const settings = loadSettings() +ipcMain.handle("get-settings", () => { + const settings = loadSettings(); return { theme: settings.theme, defaultPollInterval: settings.defaultPollInterval, defaultTimeout: settings.defaultTimeout, updateChannel: settings.updateChannel, autoCheckUpdate: settings.autoCheckUpdate, - language: settings.language - } -}) + language: settings.language, + }; +}); -ipcMain.handle('set-settings', (_, newSettings: Partial) => { - saveSettings(newSettings) - return true -}) +ipcMain.handle("set-settings", (_, newSettings: Partial) => { + saveSettings(newSettings); + return true; +}); -ipcMain.handle('clear-all-data', () => { - saveSettings(defaultSettings) - return true -}) +ipcMain.handle("clear-all-data", () => { + saveSettings(defaultSettings); + return true; +}); // Persistent renderer state (key-value, survives restarts) -ipcMain.handle('get-state', (_, key: string) => { - const state = loadState() - return state[key] ?? null -}) +ipcMain.handle("get-state", (_, key: string) => { + const state = loadState(); + return state[key] ?? null; +}); -ipcMain.handle('set-state', (_, key: string, value: unknown) => { - const state = loadState() +ipcMain.handle("set-state", (_, key: string, value: unknown) => { + const state = loadState(); if (value === null || value === undefined) { - delete state[key] + delete state[key]; } else { - state[key] = value + state[key] = value; } - saveState(state) - return true -}) + saveState(state); + return true; +}); -ipcMain.handle('remove-state', (_, key: string) => { - const state = loadState() - delete state[key] - saveState(state) - return true -}) +ipcMain.handle("remove-state", (_, key: string) => { + const state = loadState(); + delete state[key]; + saveState(state); + return true; +}); // Open external URL handler -ipcMain.handle('open-external', async (_, url: string) => { - await shell.openExternal(url) -}) +ipcMain.handle("open-external", async (_, url: string) => { + await shell.openExternal(url); +}); // Download file handler -ipcMain.handle('download-file', async (_, url: string, defaultFilename: string) => { - const mainWindow = BrowserWindow.getFocusedWindow() - if (!mainWindow) return { success: false, error: 'No focused window' } - - // Show save dialog - const result = await dialog.showSaveDialog(mainWindow, { - defaultPath: defaultFilename, - filters: [ - { name: 'All Files', extensions: ['*'] }, - { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] }, - { name: 'Videos', extensions: ['mp4', 'webm', 'mov'] } - ] - }) - - if (result.canceled || !result.filePath) { - return { success: false, canceled: true } - } - - // Handle local-asset:// URLs (Z-Image local outputs) - if (url.startsWith('local-asset://')) { - try { - const localPath = decodeURIComponent(url.replace('local-asset://', '')) - if (!existsSync(localPath)) { - return { success: false, error: 'Source file not found' } +ipcMain.handle( + "download-file", + async (_, url: string, defaultFilename: string) => { + const mainWindow = BrowserWindow.getFocusedWindow(); + if (!mainWindow) return { success: false, error: "No focused window" }; + + // Show save dialog + const result = await dialog.showSaveDialog(mainWindow, { + defaultPath: defaultFilename, + filters: [ + { name: "All Files", extensions: ["*"] }, + { name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp"] }, + { name: "Videos", extensions: ["mp4", "webm", "mov"] }, + ], + }); + + if (result.canceled || !result.filePath) { + return { success: false, canceled: true }; + } + + // Handle local-asset:// URLs (Z-Image local outputs) + if (url.startsWith("local-asset://")) { + try { + const localPath = decodeURIComponent(url.replace("local-asset://", "")); + if (!existsSync(localPath)) { + return { success: false, error: "Source file not found" }; + } + copyFileSync(localPath, result.filePath); + return { success: true, filePath: result.filePath }; + } catch (err) { + return { success: false, error: (err as Error).message }; } - copyFileSync(localPath, result.filePath) - return { success: true, filePath: result.filePath } - } catch (err) { - return { success: false, error: (err as Error).message } } - } - // Download the file from http/https - return new Promise((resolve) => { - const httpProtocol = url.startsWith('https') ? https : http - const file = createWriteStream(result.filePath!) - - httpProtocol.get(url, (response) => { - // Handle redirects - if (response.statusCode === 301 || response.statusCode === 302) { - const redirectUrl = response.headers.location - if (redirectUrl) { - const redirectProtocol = redirectUrl.startsWith('https') ? https : http - redirectProtocol.get(redirectUrl, (redirectResponse) => { - redirectResponse.pipe(file) - file.on('finish', () => { - file.close() - resolve({ success: true, filePath: result.filePath }) - }) - }).on('error', (err) => { - resolve({ success: false, error: err.message }) - }) - return - } - } + // Download the file from http/https + return new Promise((resolve) => { + const httpProtocol = url.startsWith("https") ? https : http; + const file = createWriteStream(result.filePath!); + + httpProtocol + .get(url, (response) => { + // Handle redirects + if (response.statusCode === 301 || response.statusCode === 302) { + const redirectUrl = response.headers.location; + if (redirectUrl) { + const redirectProtocol = redirectUrl.startsWith("https") + ? https + : http; + redirectProtocol + .get(redirectUrl, (redirectResponse) => { + redirectResponse.pipe(file); + file.on("finish", () => { + file.close(); + resolve({ success: true, filePath: result.filePath }); + }); + }) + .on("error", (err) => { + resolve({ success: false, error: err.message }); + }); + return; + } + } - response.pipe(file) - file.on('finish', () => { - file.close() - resolve({ success: true, filePath: result.filePath }) - }) - }).on('error', (err) => { - resolve({ success: false, error: err.message }) - }) - }) -}) + response.pipe(file); + file.on("finish", () => { + file.close(); + resolve({ success: true, filePath: result.filePath }); + }); + }) + .on("error", (err) => { + resolve({ success: false, error: err.message }); + }); + }); + }, +); // Silent file save handler — saves a remote URL to a local directory without dialog -ipcMain.handle('save-file-silent', async (_, url: string, dir: string, fileName: string) => { - try { - if (!fileName) return { success: false, error: 'Missing filename' } - const targetDir = dir || app.getPath('downloads') - if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true }) - const filePath = join(targetDir, fileName) - - // Handle local-asset:// URLs - if (url.startsWith('local-asset://')) { - const localPath = decodeURIComponent(url.replace('local-asset://', '')) - if (!existsSync(localPath)) return { success: false, error: 'Source file not found' } - copyFileSync(localPath, filePath) - return { success: true, filePath } - } - - // Handle data: URLs - if (url.startsWith('data:')) { - const matches = url.match(/^data:[^;]+;base64,(.+)$/) - if (matches) { - writeFileSync(filePath, Buffer.from(matches[1], 'base64')) - return { success: true, filePath } +ipcMain.handle( + "save-file-silent", + async (_, url: string, dir: string, fileName: string) => { + try { + if (!fileName) return { success: false, error: "Missing filename" }; + const targetDir = dir || app.getPath("downloads"); + if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true }); + const filePath = join(targetDir, fileName); + + // Handle local-asset:// URLs + if (url.startsWith("local-asset://")) { + const localPath = decodeURIComponent(url.replace("local-asset://", "")); + if (!existsSync(localPath)) + return { success: false, error: "Source file not found" }; + copyFileSync(localPath, filePath); + return { success: true, filePath }; } - return { success: false, error: 'Invalid data URL' } - } - // Download from http/https - return new Promise((resolve) => { - const httpProtocol = url.startsWith('https') ? https : http - const file = createWriteStream(filePath) - httpProtocol.get(url, (response) => { - if (response.statusCode === 301 || response.statusCode === 302) { - const redirectUrl = response.headers.location - if (redirectUrl) { - const rp = redirectUrl.startsWith('https') ? https : http - rp.get(redirectUrl, (rr) => { - rr.pipe(file) - file.on('finish', () => { file.close(); resolve({ success: true, filePath }) }) - }).on('error', (err) => resolve({ success: false, error: err.message })) - return - } + // Handle data: URLs + if (url.startsWith("data:")) { + const matches = url.match(/^data:[^;]+;base64,(.+)$/); + if (matches) { + writeFileSync(filePath, Buffer.from(matches[1], "base64")); + return { success: true, filePath }; } - response.pipe(file) - file.on('finish', () => { file.close(); resolve({ success: true, filePath }) }) - }).on('error', (err) => resolve({ success: false, error: err.message })) - }) - } catch (err) { - return { success: false, error: (err as Error).message } - } -}) + return { success: false, error: "Invalid data URL" }; + } + + // Download from http/https + return new Promise((resolve) => { + const httpProtocol = url.startsWith("https") ? https : http; + const file = createWriteStream(filePath); + httpProtocol + .get(url, (response) => { + if (response.statusCode === 301 || response.statusCode === 302) { + const redirectUrl = response.headers.location; + if (redirectUrl) { + const rp = redirectUrl.startsWith("https") ? https : http; + rp.get(redirectUrl, (rr) => { + rr.pipe(file); + file.on("finish", () => { + file.close(); + resolve({ success: true, filePath }); + }); + }).on("error", (err) => + resolve({ success: false, error: err.message }), + ); + return; + } + } + response.pipe(file); + file.on("finish", () => { + file.close(); + resolve({ success: true, filePath }); + }); + }) + .on("error", (err) => + resolve({ success: false, error: err.message }), + ); + }); + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }, +); // Assets metadata helpers function loadAssetsMetadata(): AssetMetadata[] { try { if (existsSync(assetsMetadataPath)) { - const data = readFileSync(assetsMetadataPath, 'utf-8') - return JSON.parse(data) + const data = readFileSync(assetsMetadataPath, "utf-8"); + return JSON.parse(data); } } catch (error) { - console.error('Failed to load assets metadata:', error) + console.error("Failed to load assets metadata:", error); } - return [] + return []; } function saveAssetsMetadata(metadata: AssetMetadata[]): void { try { if (!existsSync(userDataPath)) { - mkdirSync(userDataPath, { recursive: true }) + mkdirSync(userDataPath, { recursive: true }); } - writeFileSync(assetsMetadataPath, JSON.stringify(metadata, null, 2)) + writeFileSync(assetsMetadataPath, JSON.stringify(metadata, null, 2)); } catch (error) { - console.error('Failed to save assets metadata:', error) + console.error("Failed to save assets metadata:", error); } } // Assets IPC Handlers -ipcMain.handle('get-assets-settings', () => { - const settings = loadSettings() +ipcMain.handle("get-assets-settings", () => { + const settings = loadSettings(); return { autoSaveAssets: settings.autoSaveAssets, - assetsDirectory: settings.assetsDirectory || defaultAssetsDirectory - } -}) - -ipcMain.handle('set-assets-settings', (_, newSettings: { autoSaveAssets?: boolean; assetsDirectory?: string }) => { - saveSettings(newSettings) - return true -}) - -ipcMain.handle('get-default-assets-directory', () => { - return defaultAssetsDirectory -}) - -ipcMain.handle('get-zimage-output-path', () => { + assetsDirectory: settings.assetsDirectory || defaultAssetsDirectory, + }; +}); + +ipcMain.handle( + "set-assets-settings", + (_, newSettings: { autoSaveAssets?: boolean; assetsDirectory?: string }) => { + saveSettings(newSettings); + return true; + }, +); + +ipcMain.handle("get-default-assets-directory", () => { + return defaultAssetsDirectory; +}); + +ipcMain.handle("get-zimage-output-path", () => { // Use same ID format as other assets: base36 timestamp + random suffix - const id = Date.now().toString(36) + Math.random().toString(36).substring(2, 6) - const settings = loadSettings() - const assetsDir = settings.assetsDirectory || defaultAssetsDirectory - const imagesDir = join(assetsDir, 'images') + const id = + Date.now().toString(36) + Math.random().toString(36).substring(2, 6); + const settings = loadSettings(); + const assetsDir = settings.assetsDirectory || defaultAssetsDirectory; + const imagesDir = join(assetsDir, "images"); // Ensure images subdirectory exists if (!existsSync(imagesDir)) { - mkdirSync(imagesDir, { recursive: true }) + mkdirSync(imagesDir, { recursive: true }); } // Format: {owner}_{model}_{id}_{resultIndex}.{ext} - consistent with generateFileName in assetsStore - return join(imagesDir, `local_z-image_${id}_0.png`) -}) + return join(imagesDir, `local_z-image_${id}_0.png`); +}); -ipcMain.handle('select-directory', async () => { - const focusedWindow = BrowserWindow.getFocusedWindow() - if (!focusedWindow) return { success: false, error: 'No focused window' } +ipcMain.handle("select-directory", async () => { + const focusedWindow = BrowserWindow.getFocusedWindow(); + if (!focusedWindow) return { success: false, error: "No focused window" }; const result = await dialog.showOpenDialog(focusedWindow, { - properties: ['openDirectory', 'createDirectory'], - title: 'Select Assets Directory' - }) + properties: ["openDirectory", "createDirectory"], + title: "Select Assets Directory", + }); if (result.canceled || !result.filePaths[0]) { - return { success: false, canceled: true } + return { success: false, canceled: true }; } - return { success: true, path: result.filePaths[0] } -}) + return { success: true, path: result.filePaths[0] }; +}); -ipcMain.handle('save-asset', async (_, url: string, _type: string, fileName: string, subDir: string) => { - const settings = loadSettings() - const baseDir = settings.assetsDirectory || defaultAssetsDirectory - const targetDir = join(baseDir, subDir) +ipcMain.handle( + "save-asset", + async (_, url: string, _type: string, fileName: string, subDir: string) => { + const settings = loadSettings(); + const baseDir = settings.assetsDirectory || defaultAssetsDirectory; + const targetDir = join(baseDir, subDir); - // Ensure directory exists - if (!existsSync(targetDir)) { - mkdirSync(targetDir, { recursive: true }) - } + // Ensure directory exists + if (!existsSync(targetDir)) { + mkdirSync(targetDir, { recursive: true }); + } - const filePath = join(targetDir, fileName) + const filePath = join(targetDir, fileName); - // Handle local-asset:// URLs (Z-Image local outputs) - if (url.startsWith('local-asset://')) { - try { - const localPath = decodeURIComponent(url.replace('local-asset://', '')) - if (!existsSync(localPath)) { - return { success: false, error: 'Source file not found' } + // Handle local-asset:// URLs (Z-Image local outputs) + if (url.startsWith("local-asset://")) { + try { + const localPath = decodeURIComponent(url.replace("local-asset://", "")); + if (!existsSync(localPath)) { + return { success: false, error: "Source file not found" }; + } + copyFileSync(localPath, filePath); + const stats = statSync(filePath); + return { success: true, filePath, fileSize: stats.size }; + } catch (err) { + return { success: false, error: (err as Error).message }; } - copyFileSync(localPath, filePath) - const stats = statSync(filePath) - return { success: true, filePath, fileSize: stats.size } - } catch (err) { - return { success: false, error: (err as Error).message } } - } - // Download file from http/https - return new Promise((resolve) => { - const httpProtocol = url.startsWith('https') ? https : http - const file = createWriteStream(filePath) - - const handleResponse = (response: http.IncomingMessage) => { - // Handle redirects - if (response.statusCode === 301 || response.statusCode === 302) { - const redirectUrl = response.headers.location - if (redirectUrl) { - const redirectProtocol = redirectUrl.startsWith('https') ? https : http - redirectProtocol.get(redirectUrl, (redirectResponse) => { - handleResponse(redirectResponse) - }).on('error', (err) => { - resolve({ success: false, error: err.message }) - }) - return - } - } + // Download file from http/https + return new Promise((resolve) => { + const httpProtocol = url.startsWith("https") ? https : http; + const file = createWriteStream(filePath); - response.pipe(file) - file.on('finish', () => { - file.close() - try { - const stats = statSync(filePath) - resolve({ success: true, filePath, fileSize: stats.size }) - } catch { - resolve({ success: true, filePath, fileSize: 0 }) + const handleResponse = (response: http.IncomingMessage) => { + // Handle redirects + if (response.statusCode === 301 || response.statusCode === 302) { + const redirectUrl = response.headers.location; + if (redirectUrl) { + const redirectProtocol = redirectUrl.startsWith("https") + ? https + : http; + redirectProtocol + .get(redirectUrl, (redirectResponse) => { + handleResponse(redirectResponse); + }) + .on("error", (err) => { + resolve({ success: false, error: err.message }); + }); + return; + } } - }) - } - httpProtocol.get(url, handleResponse).on('error', (err) => { - resolve({ success: false, error: err.message }) - }) - }) -}) + response.pipe(file); + file.on("finish", () => { + file.close(); + try { + const stats = statSync(filePath); + resolve({ success: true, filePath, fileSize: stats.size }); + } catch { + resolve({ success: true, filePath, fileSize: 0 }); + } + }); + }; + + httpProtocol.get(url, handleResponse).on("error", (err) => { + resolve({ success: false, error: err.message }); + }); + }); + }, +); -ipcMain.handle('delete-asset', async (_, filePath: string) => { +ipcMain.handle("delete-asset", async (_, filePath: string) => { try { if (existsSync(filePath)) { - unlinkSync(filePath) + unlinkSync(filePath); } - return { success: true } + return { success: true }; } catch (error) { - return { success: false, error: (error as Error).message } + return { success: false, error: (error as Error).message }; } -}) +}); -ipcMain.handle('delete-assets-bulk', async (_, filePaths: string[]) => { - let deleted = 0 +ipcMain.handle("delete-assets-bulk", async (_, filePaths: string[]) => { + let deleted = 0; for (const filePath of filePaths) { try { if (existsSync(filePath)) { - unlinkSync(filePath) - deleted++ + unlinkSync(filePath); + deleted++; } } catch (error) { - console.error('Failed to delete:', filePath, error) + console.error("Failed to delete:", filePath, error); } } - return { success: true, deleted } -}) + return { success: true, deleted }; +}); -ipcMain.handle('get-assets-metadata', () => { - return loadAssetsMetadata() -}) +ipcMain.handle("get-assets-metadata", () => { + return loadAssetsMetadata(); +}); -ipcMain.handle('save-assets-metadata', (_, metadata: AssetMetadata[]) => { - saveAssetsMetadata(metadata) - return true -}) +ipcMain.handle("save-assets-metadata", (_, metadata: AssetMetadata[]) => { + saveAssetsMetadata(metadata); + return true; +}); -ipcMain.handle('open-file-location', async (_, filePath: string) => { +ipcMain.handle("open-file-location", async (_, filePath: string) => { if (existsSync(filePath)) { - shell.showItemInFolder(filePath) - return { success: true } + shell.showItemInFolder(filePath); + return { success: true }; } - return { success: false, error: 'File not found' } -}) + return { success: false, error: "File not found" }; +}); /** * File operations for chunked downloads from Worker/Renderer */ -ipcMain.handle('file-get-size', (_, filePath: string) => { +ipcMain.handle("file-get-size", (_, filePath: string) => { try { if (existsSync(filePath)) { - return { success: true, size: statSync(filePath).size } + return { success: true, size: statSync(filePath).size }; } - return { success: true, size: 0 } + return { success: true, size: 0 }; } catch (error) { - return { success: false, error: (error as Error).message } + return { success: false, error: (error as Error).message }; } -}) +}); -ipcMain.handle('file-append-chunk', (_, filePath: string, chunk: ArrayBuffer) => { - try { - const dir = dirname(filePath) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } +ipcMain.handle( + "file-append-chunk", + (_, filePath: string, chunk: ArrayBuffer) => { + try { + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } - const buffer = Buffer.from(chunk) + const buffer = Buffer.from(chunk); - // Append to file (create if not exists) - if (existsSync(filePath)) { - const fd = require('fs').openSync(filePath, 'a') - require('fs').writeSync(fd, buffer) - require('fs').closeSync(fd) - } else { - writeFileSync(filePath, buffer) - } + // Append to file (create if not exists) + if (existsSync(filePath)) { + const fd = require("fs").openSync(filePath, "a"); + require("fs").writeSync(fd, buffer); + require("fs").closeSync(fd); + } else { + writeFileSync(filePath, buffer); + } - return { success: true } - } catch (error) { - return { success: false, error: (error as Error).message } - } -}) + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }, +); -ipcMain.handle('file-rename', (_, oldPath: string, newPath: string) => { +ipcMain.handle("file-rename", (_, oldPath: string, newPath: string) => { try { if (existsSync(oldPath)) { - require('fs').renameSync(oldPath, newPath) - return { success: true } + require("fs").renameSync(oldPath, newPath); + return { success: true }; } - return { success: false, error: 'File not found' } + return { success: false, error: "File not found" }; } catch (error) { - return { success: false, error: (error as Error).message } + return { success: false, error: (error as Error).message }; } -}) +}); -ipcMain.handle('file-delete', (_, filePath: string) => { +ipcMain.handle("file-delete", (_, filePath: string) => { try { if (existsSync(filePath)) { - unlinkSync(filePath) - return { success: true } + unlinkSync(filePath); + return { success: true }; } - return { success: true } // Already deleted + return { success: true }; // Already deleted } catch (error) { - return { success: false, error: (error as Error).message } + return { success: false, error: (error as Error).message }; } -}) +}); -ipcMain.handle('check-file-exists', (_, filePath: string) => { - return existsSync(filePath) -}) +ipcMain.handle("check-file-exists", (_, filePath: string) => { + return existsSync(filePath); +}); -ipcMain.handle('open-assets-folder', async () => { - const settings = loadSettings() - const assetsDir = settings.assetsDirectory || defaultAssetsDirectory +ipcMain.handle("open-assets-folder", async () => { + const settings = loadSettings(); + const assetsDir = settings.assetsDirectory || defaultAssetsDirectory; // Ensure directory exists if (!existsSync(assetsDir)) { - mkdirSync(assetsDir, { recursive: true }) + mkdirSync(assetsDir, { recursive: true }); } - const result = await shell.openPath(assetsDir) - return { success: !result, error: result || undefined } -}) + const result = await shell.openPath(assetsDir); + return { success: !result, error: result || undefined }; +}); // Scan assets directory and return all files found (async for non-blocking) -ipcMain.handle('scan-assets-directory', async () => { - const settings = loadSettings() - const assetsDir = settings.assetsDirectory || defaultAssetsDirectory +ipcMain.handle("scan-assets-directory", async () => { + const settings = loadSettings(); + const assetsDir = settings.assetsDirectory || defaultAssetsDirectory; - const subDirs = ['images', 'videos', 'audio', 'text'] + const subDirs = ["images", "videos", "audio", "text"]; const files: Array<{ - filePath: string - fileName: string - type: 'image' | 'video' | 'audio' | 'text' - fileSize: number - createdAt: string - }> = [] - - const typeMap: Record = { - images: 'image', - videos: 'video', - audio: 'audio', - text: 'text' - } + filePath: string; + fileName: string; + type: "image" | "video" | "audio" | "text"; + fileSize: number; + createdAt: string; + }> = []; + + const typeMap: Record = { + images: "image", + videos: "video", + audio: "audio", + text: "text", + }; // Process directories in parallel for better performance - await Promise.all(subDirs.map(async (subDir) => { - const dirPath = join(assetsDir, subDir) - if (!existsSync(dirPath)) return - - try { - const entries = await readdir(dirPath) - // Process files in parallel batches - const filePromises = entries.map(async (entry) => { - const filePath = join(dirPath, entry) - try { - const stats = await stat(filePath) - if (stats.isFile()) { - return { - filePath, - fileName: entry, - type: typeMap[subDir], - fileSize: stats.size, - createdAt: stats.birthtime.toISOString() + await Promise.all( + subDirs.map(async (subDir) => { + const dirPath = join(assetsDir, subDir); + if (!existsSync(dirPath)) return; + + try { + const entries = await readdir(dirPath); + // Process files in parallel batches + const filePromises = entries.map(async (entry) => { + const filePath = join(dirPath, entry); + try { + const stats = await stat(filePath); + if (stats.isFile()) { + return { + filePath, + fileName: entry, + type: typeMap[subDir], + fileSize: stats.size, + createdAt: stats.birthtime.toISOString(), + }; } + } catch { + // Skip files we can't stat } - } catch { - // Skip files we can't stat - } - return null - }) - const results = await Promise.all(filePromises) - files.push(...results.filter((f): f is NonNullable => f !== null)) - } catch { - // Skip directories we can't read - } - })) + return null; + }); + const results = await Promise.all(filePromises); + files.push( + ...results.filter((f): f is NonNullable => f !== null), + ); + } catch { + // Skip directories we can't read + } + }), + ); - return files -}) + return files; +}); // SD download path helpers for chunked downloads -ipcMain.handle('sd-get-binary-download-path', () => { +ipcMain.handle("sd-get-binary-download-path", () => { try { - const platform = process.platform - const binaryName = platform === 'win32' ? 'sd.exe' : 'sd' + const platform = process.platform; + const binaryName = platform === "win32" ? "sd.exe" : "sd"; - const binaryDir = join(app.getPath('userData'), 'sd-bin') - const binaryPath = join(binaryDir, binaryName) + const binaryDir = join(app.getPath("userData"), "sd-bin"); + const binaryPath = join(binaryDir, binaryName); // Ensure directory exists if (!existsSync(binaryDir)) { - mkdirSync(binaryDir, { recursive: true }) + mkdirSync(binaryDir, { recursive: true }); } - return { success: true, path: binaryPath } + return { success: true, path: binaryPath }; } catch (error) { - return { success: false, error: (error as Error).message } + return { success: false, error: (error as Error).message }; } -}) +}); -ipcMain.handle('sd-get-auxiliary-model-download-path', (_, type: 'llm' | 'vae') => { - try { - const auxDir = getAuxiliaryModelsDir() +ipcMain.handle( + "sd-get-auxiliary-model-download-path", + (_, type: "llm" | "vae") => { + try { + const auxDir = getAuxiliaryModelsDir(); - // Ensure directory exists - if (!existsSync(auxDir)) { - mkdirSync(auxDir, { recursive: true }) - } + // Ensure directory exists + if (!existsSync(auxDir)) { + mkdirSync(auxDir, { recursive: true }); + } - const filename = type === 'llm' - ? 'Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf' - : 'ae.safetensors' + const filename = + type === "llm" + ? "Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf" + : "ae.safetensors"; - const filePath = join(auxDir, filename) + const filePath = join(auxDir, filename); - return { success: true, path: filePath } - } catch (error) { - return { success: false, error: (error as Error).message } - } -}) + return { success: true, path: filePath }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }, +); -ipcMain.handle('sd-get-models-dir', () => { +ipcMain.handle("sd-get-models-dir", () => { try { - const modelsDir = getModelsDir() + const modelsDir = getModelsDir(); // Ensure directory exists if (!existsSync(modelsDir)) { - mkdirSync(modelsDir, { recursive: true }) + mkdirSync(modelsDir, { recursive: true }); } - return { success: true, path: modelsDir } + return { success: true, path: modelsDir }; } catch (error) { - return { success: false, error: (error as Error).message } + return { success: false, error: (error as Error).message }; } -}) +}); /** * Extract zip file and copy all contents to destination directory * Supports both old (sd) and new (sd-cli) binary names */ -ipcMain.handle('sd-extract-binary', (_, zipPath: string, destPath: string) => { +ipcMain.handle("sd-extract-binary", (_, zipPath: string, destPath: string) => { try { - console.log('[SD Extract] Extracting:', zipPath) - console.log('[SD Extract] Destination:', destPath) + console.log("[SD Extract] Extracting:", zipPath); + console.log("[SD Extract] Destination:", destPath); if (!existsSync(zipPath)) { - throw new Error(`Zip file not found: ${zipPath}`) + throw new Error(`Zip file not found: ${zipPath}`); } - const zip = new AdmZip(zipPath) - const tempExtractDir = join(dirname(zipPath), 'temp-extract') + const zip = new AdmZip(zipPath); + const tempExtractDir = join(dirname(zipPath), "temp-extract"); // Extract to temp directory - zip.extractAllTo(tempExtractDir, true) - console.log('[SD Extract] Extracted to temp directory:', tempExtractDir) + zip.extractAllTo(tempExtractDir, true); + console.log("[SD Extract] Extracted to temp directory:", tempExtractDir); // Find the binary file (sd, sd.exe, sd-cli, or sd-cli.exe) - const possibleNames = process.platform === 'win32' - ? ['sd.exe', 'sd-cli.exe'] - : ['sd', 'sd-cli'] + const possibleNames = + process.platform === "win32" + ? ["sd.exe", "sd-cli.exe"] + : ["sd", "sd-cli"]; - let binaryPath: string | null = null - let actualBinaryName: string | null = null + let binaryPath: string | null = null; + let actualBinaryName: string | null = null; const findBinary = (dir: string): { path: string; name: string } | null => { - const files = readdirSync(dir, { withFileTypes: true }) + const files = readdirSync(dir, { withFileTypes: true }); for (const file of files) { - const fullPath = join(dir, file.name) + const fullPath = join(dir, file.name); if (file.isDirectory()) { - const found = findBinary(fullPath) - if (found) return found + const found = findBinary(fullPath); + if (found) return found; } else if (possibleNames.includes(file.name)) { - return { path: fullPath, name: file.name } + return { path: fullPath, name: file.name }; } } - return null - } + return null; + }; - const found = findBinary(tempExtractDir) + const found = findBinary(tempExtractDir); if (!found) { - throw new Error(`Binary not found in extracted files. Looked for: ${possibleNames.join(', ')}`) + throw new Error( + `Binary not found in extracted files. Looked for: ${possibleNames.join(", ")}`, + ); } - binaryPath = found.path - actualBinaryName = found.name - console.log('[SD Extract] Found binary:', binaryPath) + binaryPath = found.path; + actualBinaryName = found.name; + console.log("[SD Extract] Found binary:", binaryPath); // Get the directory containing the binary - const binaryDir = dirname(binaryPath) - console.log('[SD Extract] Binary directory:', binaryDir) + const binaryDir = dirname(binaryPath); + console.log("[SD Extract] Binary directory:", binaryDir); // Ensure destination directory exists - const destDir = dirname(destPath) + const destDir = dirname(destPath); if (!existsSync(destDir)) { - mkdirSync(destDir, { recursive: true }) + mkdirSync(destDir, { recursive: true }); } // Copy all files from binary directory to destination directory const copyDirContents = (srcDir: string, dstDir: string) => { - const files = readdirSync(srcDir, { withFileTypes: true }) + const files = readdirSync(srcDir, { withFileTypes: true }); for (const file of files) { - const srcPath = join(srcDir, file.name) - const dstPath = join(dstDir, file.name) + const srcPath = join(srcDir, file.name); + const dstPath = join(dstDir, file.name); if (file.isDirectory()) { if (!existsSync(dstPath)) { - mkdirSync(dstPath, { recursive: true }) + mkdirSync(dstPath, { recursive: true }); } - copyDirContents(srcPath, dstPath) + copyDirContents(srcPath, dstPath); } else { // Copy file if (existsSync(dstPath)) { - unlinkSync(dstPath) + unlinkSync(dstPath); } - require('fs').copyFileSync(srcPath, dstPath) - console.log('[SD Extract] Copied:', file.name) + require("fs").copyFileSync(srcPath, dstPath); + console.log("[SD Extract] Copied:", file.name); } } - } + }; - console.log('[SD Extract] Copying all files to:', destDir) - copyDirContents(binaryDir, destDir) + console.log("[SD Extract] Copying all files to:", destDir); + copyDirContents(binaryDir, destDir); // If binary is sd-cli, create sd symlink/copy for compatibility - const targetBinaryName = process.platform === 'win32' ? 'sd.exe' : 'sd' - const finalBinaryPath = join(destDir, targetBinaryName) + const targetBinaryName = process.platform === "win32" ? "sd.exe" : "sd"; + const finalBinaryPath = join(destDir, targetBinaryName); - if (actualBinaryName.startsWith('sd-cli')) { - const sdCliPath = join(destDir, actualBinaryName) + if (actualBinaryName.startsWith("sd-cli")) { + const sdCliPath = join(destDir, actualBinaryName); if (existsSync(sdCliPath) && !existsSync(finalBinaryPath)) { // Create a copy with the old name for compatibility - require('fs').copyFileSync(sdCliPath, finalBinaryPath) - console.log(`[SD Extract] Created ${targetBinaryName} copy for compatibility`) + require("fs").copyFileSync(sdCliPath, finalBinaryPath); + console.log( + `[SD Extract] Created ${targetBinaryName} copy for compatibility`, + ); } } // Make executables on Unix - if (process.platform !== 'win32') { - const files = readdirSync(destDir) + if (process.platform !== "win32") { + const files = readdirSync(destDir); for (const file of files) { - const filePath = join(destDir, file) + const filePath = join(destDir, file); if (statSync(filePath).isFile()) { try { - execSync(`chmod +x "${filePath}"`) + execSync(`chmod +x "${filePath}"`); } catch (err) { // Ignore chmod errors for non-executable files } } } - console.log('[SD Extract] Made files executable') + console.log("[SD Extract] Made files executable"); // macOS: Fix rpath and dynamic library paths - if (process.platform === 'darwin') { + if (process.platform === "darwin") { try { - console.log('[SD Extract] Fixing macOS dynamic library paths...') + console.log("[SD Extract] Fixing macOS dynamic library paths..."); // Find all dylib files - const dylibFiles = readdirSync(destDir).filter(f => f.endsWith('.dylib')) + const dylibFiles = readdirSync(destDir).filter((f) => + f.endsWith(".dylib"), + ); // Fix binary files - const binaryFiles = [targetBinaryName, actualBinaryName].filter(name => name && name !== null) + const binaryFiles = [targetBinaryName, actualBinaryName].filter( + (name) => name && name !== null, + ); for (const binaryFile of binaryFiles) { - const binaryFullPath = join(destDir, binaryFile) + const binaryFullPath = join(destDir, binaryFile); if (existsSync(binaryFullPath)) { try { // Delete existing rpaths pointing to build directories try { - execSync(`install_name_tool -delete_rpath "/Users/runner/work/stable-diffusion.cpp/stable-diffusion.cpp/build/bin" "${binaryFullPath}" 2>/dev/null || true`, { stdio: 'ignore' }) + execSync( + `install_name_tool -delete_rpath "/Users/runner/work/stable-diffusion.cpp/stable-diffusion.cpp/build/bin" "${binaryFullPath}" 2>/dev/null || true`, + { stdio: "ignore" }, + ); } catch (e) { // Ignore if rpath doesn't exist } // Add @executable_path to rpath try { - execSync(`install_name_tool -add_rpath "@executable_path" "${binaryFullPath}" 2>/dev/null || true`, { stdio: 'ignore' }) + execSync( + `install_name_tool -add_rpath "@executable_path" "${binaryFullPath}" 2>/dev/null || true`, + { stdio: "ignore" }, + ); } catch (e) { // Ignore if rpath already exists } @@ -1076,46 +1184,64 @@ ipcMain.handle('sd-extract-binary', (_, zipPath: string, destPath: string) => { // Update references to dylib files to use @executable_path for (const dylibFile of dylibFiles) { try { - execSync(`install_name_tool -change "/Users/runner/work/stable-diffusion.cpp/stable-diffusion.cpp/build/bin/${dylibFile}" "@executable_path/${dylibFile}" "${binaryFullPath}" 2>/dev/null || true`, { stdio: 'ignore' }) + execSync( + `install_name_tool -change "/Users/runner/work/stable-diffusion.cpp/stable-diffusion.cpp/build/bin/${dylibFile}" "@executable_path/${dylibFile}" "${binaryFullPath}" 2>/dev/null || true`, + { stdio: "ignore" }, + ); } catch (e) { // Ignore if reference doesn't exist } } - console.log(`[SD Extract] Fixed rpath for ${binaryFile}`) + console.log(`[SD Extract] Fixed rpath for ${binaryFile}`); } catch (err) { - console.warn(`[SD Extract] Could not fully fix rpath for ${binaryFile}:`, (err as Error).message) + console.warn( + `[SD Extract] Could not fully fix rpath for ${binaryFile}:`, + (err as Error).message, + ); } } } // Fix dylib files themselves for (const dylibFile of dylibFiles) { - const dylibFullPath = join(destDir, dylibFile) + const dylibFullPath = join(destDir, dylibFile); try { // Update the dylib's install name to use @rpath - execSync(`install_name_tool -id "@rpath/${dylibFile}" "${dylibFullPath}" 2>/dev/null || true`, { stdio: 'ignore' }) + execSync( + `install_name_tool -id "@rpath/${dylibFile}" "${dylibFullPath}" 2>/dev/null || true`, + { stdio: "ignore" }, + ); // Update references to other dylibs for (const otherDylib of dylibFiles) { if (otherDylib !== dylibFile) { try { - execSync(`install_name_tool -change "/Users/runner/work/stable-diffusion.cpp/stable-diffusion.cpp/build/bin/${otherDylib}" "@rpath/${otherDylib}" "${dylibFullPath}" 2>/dev/null || true`, { stdio: 'ignore' }) + execSync( + `install_name_tool -change "/Users/runner/work/stable-diffusion.cpp/stable-diffusion.cpp/build/bin/${otherDylib}" "@rpath/${otherDylib}" "${dylibFullPath}" 2>/dev/null || true`, + { stdio: "ignore" }, + ); } catch (e) { // Ignore } } } - console.log(`[SD Extract] Fixed install name for ${dylibFile}`) + console.log(`[SD Extract] Fixed install name for ${dylibFile}`); } catch (err) { - console.warn(`[SD Extract] Could not fix install name for ${dylibFile}:`, (err as Error).message) + console.warn( + `[SD Extract] Could not fix install name for ${dylibFile}:`, + (err as Error).message, + ); } } - console.log('[SD Extract] macOS library path fixes completed') + console.log("[SD Extract] macOS library path fixes completed"); } catch (err) { - console.warn('[SD Extract] Failed to fix macOS library paths:', (err as Error).message) + console.warn( + "[SD Extract] Failed to fix macOS library paths:", + (err as Error).message, + ); } } } @@ -1123,176 +1249,183 @@ ipcMain.handle('sd-extract-binary', (_, zipPath: string, destPath: string) => { // Clean up const cleanupDir = (dir: string) => { if (existsSync(dir)) { - const files = readdirSync(dir, { withFileTypes: true }) + const files = readdirSync(dir, { withFileTypes: true }); for (const file of files) { - const fullPath = join(dir, file.name) + const fullPath = join(dir, file.name); if (file.isDirectory()) { - cleanupDir(fullPath) + cleanupDir(fullPath); } else { - unlinkSync(fullPath) + unlinkSync(fullPath); } } - require('fs').rmdirSync(dir) + require("fs").rmdirSync(dir); } - } - cleanupDir(tempExtractDir) + }; + cleanupDir(tempExtractDir); if (existsSync(zipPath)) { - unlinkSync(zipPath) + unlinkSync(zipPath); } - console.log('[SD Extract] Cleanup completed') + console.log("[SD Extract] Cleanup completed"); - return { success: true, path: finalBinaryPath } + return { success: true, path: finalBinaryPath }; } catch (error) { - console.error('[SD Extract] Error:', error) - return { success: false, error: (error as Error).message } + console.error("[SD Extract] Error:", error); + return { success: false, error: (error as Error).message }; } -}) +}); // Auto-updater state -let mainWindow: BrowserWindow | null = null +let mainWindow: BrowserWindow | null = null; // Configure auto-updater -autoUpdater.autoDownload = true -autoUpdater.autoInstallOnAppQuit = true +autoUpdater.autoDownload = true; +autoUpdater.autoInstallOnAppQuit = true; function sendUpdateStatus(status: string, data?: Record) { if (mainWindow) { - mainWindow.webContents.send('update-status', { status, ...data }) + mainWindow.webContents.send("update-status", { status, ...data }); } } function setupAutoUpdater() { if (is.dev) { - return + return; } - const updateConfigPath = (autoUpdater as typeof autoUpdater & { appUpdateConfigPath?: string }).appUpdateConfigPath - ?? join(process.resourcesPath, 'app-update.yml') + const updateConfigPath = + (autoUpdater as typeof autoUpdater & { appUpdateConfigPath?: string }) + .appUpdateConfigPath ?? join(process.resourcesPath, "app-update.yml"); if (!existsSync(updateConfigPath)) { - console.warn('[AutoUpdater] app-update.yml not found, skipping auto-updater setup:', updateConfigPath) - return + console.warn( + "[AutoUpdater] app-update.yml not found, skipping auto-updater setup:", + updateConfigPath, + ); + return; } - const settings = loadSettings() - const channel = settings.updateChannel || 'stable' + const settings = loadSettings(); + const channel = settings.updateChannel || "stable"; // Configure update channel - if (channel === 'nightly') { - autoUpdater.allowPrerelease = true - autoUpdater.channel = 'nightly' + if (channel === "nightly") { + autoUpdater.allowPrerelease = true; + autoUpdater.channel = "nightly"; // Use generic provider pointing to nightly release assets autoUpdater.setFeedURL({ - provider: 'generic', - url: 'https://github.com/WaveSpeedAI/wavespeed-desktop/releases/download/nightly' - }) + provider: "generic", + url: "https://github.com/WaveSpeedAI/wavespeed-desktop/releases/download/nightly", + }); } else { - autoUpdater.allowPrerelease = false - autoUpdater.channel = 'latest' + autoUpdater.allowPrerelease = false; + autoUpdater.channel = "latest"; } - autoUpdater.on('checking-for-update', () => { - sendUpdateStatus('checking') - }) + autoUpdater.on("checking-for-update", () => { + sendUpdateStatus("checking"); + }); - autoUpdater.on('update-available', (info: UpdateInfo) => { - sendUpdateStatus('available', { + autoUpdater.on("update-available", (info: UpdateInfo) => { + sendUpdateStatus("available", { version: info.version, releaseNotes: info.releaseNotes, - releaseDate: info.releaseDate - }) - }) + releaseDate: info.releaseDate, + }); + }); - autoUpdater.on('update-not-available', (info: UpdateInfo) => { - sendUpdateStatus('not-available', { version: info.version }) - }) + autoUpdater.on("update-not-available", (info: UpdateInfo) => { + sendUpdateStatus("not-available", { version: info.version }); + }); - autoUpdater.on('download-progress', (progress) => { - sendUpdateStatus('downloading', { + autoUpdater.on("download-progress", (progress) => { + sendUpdateStatus("downloading", { percent: progress.percent, bytesPerSecond: progress.bytesPerSecond, transferred: progress.transferred, - total: progress.total - }) - }) + total: progress.total, + }); + }); - autoUpdater.on('update-downloaded', (info: UpdateInfo) => { - sendUpdateStatus('downloaded', { + autoUpdater.on("update-downloaded", (info: UpdateInfo) => { + sendUpdateStatus("downloaded", { version: info.version, - releaseNotes: info.releaseNotes - }) - }) + releaseNotes: info.releaseNotes, + }); + }); - autoUpdater.on('error', (error) => { - sendUpdateStatus('error', { message: error.message }) - }) + autoUpdater.on("error", (error) => { + sendUpdateStatus("error", { message: error.message }); + }); } // Auto-updater IPC handlers -ipcMain.handle('check-for-updates', async () => { +ipcMain.handle("check-for-updates", async () => { if (is.dev) { - return { status: 'dev-mode', message: 'Auto-update disabled in development' } + return { + status: "dev-mode", + message: "Auto-update disabled in development", + }; } try { - const result = await autoUpdater.checkForUpdates() - return { status: 'success', updateInfo: result?.updateInfo } + const result = await autoUpdater.checkForUpdates(); + return { status: "success", updateInfo: result?.updateInfo }; } catch (error) { - return { status: 'error', message: (error as Error).message } + return { status: "error", message: (error as Error).message }; } -}) +}); -ipcMain.handle('download-update', async () => { +ipcMain.handle("download-update", async () => { try { - await autoUpdater.downloadUpdate() - return { status: 'success' } + await autoUpdater.downloadUpdate(); + return { status: "success" }; } catch (error) { - return { status: 'error', message: (error as Error).message } + return { status: "error", message: (error as Error).message }; } -}) +}); -ipcMain.handle('install-update', () => { +ipcMain.handle("install-update", () => { // Set quitting flag before calling quitAndInstall so macOS window close handler allows quit - ;(app as typeof app & { isQuitting: boolean }).isQuitting = true - autoUpdater.quitAndInstall(false, true) -}) - -ipcMain.handle('get-app-version', () => { - return app.getVersion() -}) - -ipcMain.handle('get-log-file-path', () => { - return log.transports.file.getFile().path -}) - -ipcMain.handle('open-log-directory', () => { - const logPath = log.transports.file.getFile().path - const logDir = dirname(logPath) - shell.openPath(logDir) - return { success: true, path: logDir } -}) - -ipcMain.handle('set-update-channel', (_, channel: 'stable' | 'nightly') => { - saveSettings({ updateChannel: channel }) + (app as typeof app & { isQuitting: boolean }).isQuitting = true; + autoUpdater.quitAndInstall(false, true); +}); + +ipcMain.handle("get-app-version", () => { + return app.getVersion(); +}); + +ipcMain.handle("get-log-file-path", () => { + return log.transports.file.getFile().path; +}); + +ipcMain.handle("open-log-directory", () => { + const logPath = log.transports.file.getFile().path; + const logDir = dirname(logPath); + shell.openPath(logDir); + return { success: true, path: logDir }; +}); + +ipcMain.handle("set-update-channel", (_, channel: "stable" | "nightly") => { + saveSettings({ updateChannel: channel }); // Reconfigure updater with new channel - if (channel === 'nightly') { - autoUpdater.allowPrerelease = true - autoUpdater.channel = 'nightly' + if (channel === "nightly") { + autoUpdater.allowPrerelease = true; + autoUpdater.channel = "nightly"; // Use generic provider pointing to nightly release assets autoUpdater.setFeedURL({ - provider: 'generic', - url: 'https://github.com/WaveSpeedAI/wavespeed-desktop/releases/download/nightly' - }) + provider: "generic", + url: "https://github.com/WaveSpeedAI/wavespeed-desktop/releases/download/nightly", + }); } else { - autoUpdater.allowPrerelease = false - autoUpdater.channel = 'latest' + autoUpdater.allowPrerelease = false; + autoUpdater.channel = "latest"; autoUpdater.setFeedURL({ - provider: 'github', - owner: 'WaveSpeedAI', - repo: 'wavespeed-desktop', - releaseType: 'release' - }) + provider: "github", + owner: "WaveSpeedAI", + repo: "wavespeed-desktop", + releaseType: "release", + }); } - return true -}) + return true; +}); // ============================================================================== // Stable Diffusion IPC Handlers @@ -1304,49 +1437,49 @@ ipcMain.handle('set-update-channel', (_, channel: 'stable' | 'nightly') => { * 1. Downloaded binary in userData/sd-bin * 2. Pre-compiled binary in resources directory */ -ipcMain.handle('sd-get-binary-path', () => { +ipcMain.handle("sd-get-binary-path", () => { try { - const platform = process.platform + const platform = process.platform; // Priority 1: Check downloaded binary in userData - const userDataBinaryDir = join(app.getPath('userData'), 'sd-bin') - const binaryName = platform === 'win32' ? 'sd.exe' : 'sd' - const userDataBinaryPath = join(userDataBinaryDir, binaryName) + const userDataBinaryDir = join(app.getPath("userData"), "sd-bin"); + const binaryName = platform === "win32" ? "sd.exe" : "sd"; + const userDataBinaryPath = join(userDataBinaryDir, binaryName); if (existsSync(userDataBinaryPath)) { if (!binaryPathLoggedOnce) { - console.log('[SD] Using downloaded binary:', userDataBinaryPath) - binaryPathLoggedOnce = true + console.log("[SD] Using downloaded binary:", userDataBinaryPath); + binaryPathLoggedOnce = true; } - return { success: true, path: userDataBinaryPath } + return { success: true, path: userDataBinaryPath }; } // Priority 2: Check pre-compiled binary in resources const basePath = is.dev - ? join(__dirname, '../../resources/bin/stable-diffusion') - : join(process.resourcesPath, 'bin/stable-diffusion') + ? join(__dirname, "../../resources/bin/stable-diffusion") + : join(process.resourcesPath, "bin/stable-diffusion"); - const resourceBinaryPath = join(basePath, binaryName) + const resourceBinaryPath = join(basePath, binaryName); if (existsSync(resourceBinaryPath)) { if (!binaryPathLoggedOnce) { - console.log('[SD] Using pre-compiled binary:', resourceBinaryPath) - binaryPathLoggedOnce = true + console.log("[SD] Using pre-compiled binary:", resourceBinaryPath); + binaryPathLoggedOnce = true; } - return { success: true, path: resourceBinaryPath } + return { success: true, path: resourceBinaryPath }; } // Binary not found in any location return { success: false, - error: `Binary not found. Checked: ${userDataBinaryPath}, ${resourceBinaryPath}` - } + error: `Binary not found. Checked: ${userDataBinaryPath}, ${resourceBinaryPath}`, + }; } catch (error) { return { success: false, - error: (error as Error).message - } + error: (error as Error).message, + }; } -}) +}); /** * Check if macOS system supports Metal acceleration (cached) @@ -1354,597 +1487,627 @@ ipcMain.handle('sd-get-binary-path', () => { function checkMetalSupport(): boolean { // Return cached result if available if (metalSupportCache !== null) { - return metalSupportCache + return metalSupportCache; } try { // Check macOS version - Metal requires OS X 10.11 (El Capitan) or later - const osRelease = require('os').release() - const majorVersion = parseInt(osRelease.split('.')[0], 10) + const osRelease = require("os").release(); + const majorVersion = parseInt(osRelease.split(".")[0], 10); // Darwin kernel version 15.x = OS X 10.11 (El Capitan) // Metal was introduced in OS X 10.11 if (majorVersion < 15) { - console.log('[Metal Check] macOS version too old for Metal (Darwin kernel < 15)') - metalSupportCache = false - return false + console.log( + "[Metal Check] macOS version too old for Metal (Darwin kernel < 15)", + ); + metalSupportCache = false; + return false; } // Check GPU capabilities using system_profiler try { - const output = execSync('system_profiler SPDisplaysDataType', { - encoding: 'utf8', - timeout: 5000 - }) + const output = execSync("system_profiler SPDisplaysDataType", { + encoding: "utf8", + timeout: 5000, + }); // Check if output contains "Metal" support indication - const hasMetalSupport = output.toLowerCase().includes('metal') - console.log(`[Metal Check] Metal support detected: ${hasMetalSupport}`) - metalSupportCache = hasMetalSupport - return hasMetalSupport + const hasMetalSupport = output.toLowerCase().includes("metal"); + console.log(`[Metal Check] Metal support detected: ${hasMetalSupport}`); + metalSupportCache = hasMetalSupport; + return hasMetalSupport; } catch (error) { - console.error('[Metal Check] Failed to run system_profiler:', error) + console.error("[Metal Check] Failed to run system_profiler:", error); // If system_profiler fails but OS version is new enough, assume Metal is available - metalSupportCache = majorVersion >= 15 - return metalSupportCache + metalSupportCache = majorVersion >= 15; + return metalSupportCache; } } catch (error) { - console.error('[Metal Check] Failed to check Metal support:', error) - metalSupportCache = false - return false + console.error("[Metal Check] Failed to check Metal support:", error); + metalSupportCache = false; + return false; } } /** * Get system information (platform and acceleration type) - cached */ -ipcMain.handle('sd-get-system-info', () => { +ipcMain.handle("sd-get-system-info", () => { // Return cached result if available if (systemInfoCache !== null) { - return systemInfoCache + return systemInfoCache; } - const platform = process.platform - const arch = process.arch + const platform = process.platform; + const arch = process.arch; - let acceleration = 'CPU' + let acceleration = "CPU"; - if (platform === 'darwin') { + if (platform === "darwin") { // macOS: Check for Metal acceleration support - acceleration = checkMetalSupport() ? 'metal' : 'CPU' - } else if (platform === 'win32' || platform === 'linux') { + acceleration = checkMetalSupport() ? "metal" : "CPU"; + } else if (platform === "win32" || platform === "linux") { // Check for NVIDIA GPU (CUDA support) try { - const { execSync } = require('child_process') + const { execSync } = require("child_process"); // Try to detect NVIDIA GPU - if (platform === 'win32') { + if (platform === "win32") { // Windows: Check for nvidia-smi try { - execSync('nvidia-smi', { stdio: 'ignore', timeout: 3000 }) - acceleration = 'CUDA' + execSync("nvidia-smi", { stdio: "ignore", timeout: 3000 }); + acceleration = "CUDA"; } catch { // nvidia-smi not found or failed, use CPU } - } else if (platform === 'linux') { + } else if (platform === "linux") { // Linux: Check for NVIDIA GPU in lspci or nvidia-smi try { - const output = execSync('lspci 2>/dev/null | grep -i nvidia', { encoding: 'utf8', timeout: 3000 }) - if (output.toLowerCase().includes('nvidia')) { - acceleration = 'CUDA' + const output = execSync("lspci 2>/dev/null | grep -i nvidia", { + encoding: "utf8", + timeout: 3000, + }); + if (output.toLowerCase().includes("nvidia")) { + acceleration = "CUDA"; } } catch { // Try nvidia-smi as fallback try { - execSync('nvidia-smi', { stdio: 'ignore', timeout: 3000 }) - acceleration = 'CUDA' + execSync("nvidia-smi", { stdio: "ignore", timeout: 3000 }); + acceleration = "CUDA"; } catch { // No NVIDIA GPU detected, use CPU } } } } catch (error) { - console.error('[System Info] Failed to detect GPU:', error) + console.error("[System Info] Failed to detect GPU:", error); // Fall back to CPU on error } } - console.log(`[System Info] Platform: ${platform}, Acceleration: ${acceleration}`) + console.log( + `[System Info] Platform: ${platform}, Acceleration: ${acceleration}`, + ); // Cache the result systemInfoCache = { platform, arch, acceleration, - supported: true - } + supported: true, + }; - return systemInfoCache -}) + return systemInfoCache; +}); /** * Get GPU VRAM in MB (Windows only) */ -ipcMain.handle('sd-get-gpu-vram', () => { +ipcMain.handle("sd-get-gpu-vram", () => { try { - return { success: true, vramMb: getGpuVramMb() } + return { success: true, vramMb: getGpuVramMb() }; } catch (error) { return { success: false, vramMb: null, - error: (error as Error).message - } + error: (error as Error).message, + }; } -}) +}); /** * Generate image */ -ipcMain.handle('sd-generate-image', async (event, params: { - modelPath: string - llmPath?: string - vaePath?: string - lowVramMode?: boolean - vaeTiling?: boolean - prompt: string - negativePrompt?: string - width: number - height: number - steps: number - cfgScale: number - seed?: number - samplingMethod?: string - scheduler?: string - outputPath: string -}) => { - try { - // Get binary path using the same logic as sd-get-binary-path - const platform = process.platform - const arch = process.arch - const userDataBinaryDir = join(app.getPath('userData'), 'sd-bin') - const binaryName = platform === 'win32' ? 'sd.exe' : 'sd' - - let binaryPath: string | null = null - - // Priority 1: Check downloaded binary in userData - const userDataBinaryPath = join(userDataBinaryDir, binaryName) - if (existsSync(userDataBinaryPath)) { - binaryPath = userDataBinaryPath - console.log('[SD Generate] Using downloaded binary:', binaryPath) - } - - // Priority 2: Check pre-compiled binary in resources - if (!binaryPath) { - const basePath = is.dev - ? join(__dirname, '../../resources/bin/stable-diffusion') - : join(process.resourcesPath, 'bin/stable-diffusion') - - const resourceBinaryPath = join(basePath, `${platform}-${arch}`, binaryName) - if (existsSync(resourceBinaryPath)) { - binaryPath = resourceBinaryPath - console.log('[SD Generate] Using pre-compiled binary:', binaryPath) +ipcMain.handle( + "sd-generate-image", + async ( + event, + params: { + modelPath: string; + llmPath?: string; + vaePath?: string; + lowVramMode?: boolean; + vaeTiling?: boolean; + prompt: string; + negativePrompt?: string; + width: number; + height: number; + steps: number; + cfgScale: number; + seed?: number; + samplingMethod?: string; + scheduler?: string; + outputPath: string; + }, + ) => { + try { + // Get binary path using the same logic as sd-get-binary-path + const platform = process.platform; + const arch = process.arch; + const userDataBinaryDir = join(app.getPath("userData"), "sd-bin"); + const binaryName = platform === "win32" ? "sd.exe" : "sd"; + + let binaryPath: string | null = null; + + // Priority 1: Check downloaded binary in userData + const userDataBinaryPath = join(userDataBinaryDir, binaryName); + if (existsSync(userDataBinaryPath)) { + binaryPath = userDataBinaryPath; + console.log("[SD Generate] Using downloaded binary:", binaryPath); } - } - if (!binaryPath) { - throw new Error('SD binary not found. Please download it first.') - } + // Priority 2: Check pre-compiled binary in resources + if (!binaryPath) { + const basePath = is.dev + ? join(__dirname, "../../resources/bin/stable-diffusion") + : join(process.resourcesPath, "bin/stable-diffusion"); + + const resourceBinaryPath = join( + basePath, + `${platform}-${arch}`, + binaryName, + ); + if (existsSync(resourceBinaryPath)) { + binaryPath = resourceBinaryPath; + console.log("[SD Generate] Using pre-compiled binary:", binaryPath); + } + } - const vramMb = getGpuVramMb() - const isLowVramGpu = vramMb !== null && vramMb < 16000 - const lowVramMode = Boolean(params.lowVramMode) || isLowVramGpu - const useCpuOffload = lowVramMode - const useVaeTiling = Boolean(params.vaeTiling) || isLowVramGpu + if (!binaryPath) { + throw new Error("SD binary not found. Please download it first."); + } - if (vramMb !== null) { - console.log(`[SD Generate] Detected GPU VRAM: ${vramMb} MB`) - } else { - console.log('[SD Generate] GPU VRAM detection unavailable') - } + const vramMb = getGpuVramMb(); + const isLowVramGpu = vramMb !== null && vramMb < 16000; + const lowVramMode = Boolean(params.lowVramMode) || isLowVramGpu; + const useCpuOffload = lowVramMode; + const useVaeTiling = Boolean(params.vaeTiling) || isLowVramGpu; - if (Boolean(params.lowVramMode)) { - console.log('[SD Generate] Enabling low VRAM mode from UI setting') - } else if (isLowVramGpu) { - console.log('[SD Generate] Enabling low VRAM mode for low VRAM GPU') - } + if (vramMb !== null) { + console.log(`[SD Generate] Detected GPU VRAM: ${vramMb} MB`); + } else { + console.log("[SD Generate] GPU VRAM detection unavailable"); + } - if (Boolean(params.vaeTiling)) { - console.log('[SD Generate] Enabling VAE tiling from UI setting') - } else if (isLowVramGpu) { - console.log('[SD Generate] Enabling VAE tiling for low VRAM GPU') - } + if (Boolean(params.lowVramMode)) { + console.log("[SD Generate] Enabling low VRAM mode from UI setting"); + } else if (isLowVramGpu) { + console.log("[SD Generate] Enabling low VRAM mode for low VRAM GPU"); + } - // Use SDGenerator class for image generation - const result = await sdGenerator.generate({ - binaryPath, - modelPath: params.modelPath, - llmPath: params.llmPath, - vaePath: params.vaePath, - clipOnCpu: useCpuOffload, - vaeTiling: useVaeTiling, - prompt: params.prompt, - negativePrompt: params.negativePrompt, - width: params.width, - height: params.height, - steps: params.steps, - cfgScale: params.cfgScale, - seed: params.seed, - samplingMethod: params.samplingMethod, - scheduler: params.scheduler, - outputPath: params.outputPath, - onProgress: (progress) => { - // Send progress to frontend - event.sender.send('sd-progress', { - phase: progress.phase, - progress: progress.progress, - detail: progress.detail - }) - }, - onLog: (log) => { - // Send logs to frontend - event.sender.send('sd-log', { - type: log.type, - message: log.message - }) + if (Boolean(params.vaeTiling)) { + console.log("[SD Generate] Enabling VAE tiling from UI setting"); + } else if (isLowVramGpu) { + console.log("[SD Generate] Enabling VAE tiling for low VRAM GPU"); } - }) - // Also track via legacy activeSDProcess for backward compatibility - // (This will be set/cleared by SDGenerator internally) + // Use SDGenerator class for image generation + const result = await sdGenerator.generate({ + binaryPath, + modelPath: params.modelPath, + llmPath: params.llmPath, + vaePath: params.vaePath, + clipOnCpu: useCpuOffload, + vaeTiling: useVaeTiling, + prompt: params.prompt, + negativePrompt: params.negativePrompt, + width: params.width, + height: params.height, + steps: params.steps, + cfgScale: params.cfgScale, + seed: params.seed, + samplingMethod: params.samplingMethod, + scheduler: params.scheduler, + outputPath: params.outputPath, + onProgress: (progress) => { + // Send progress to frontend + event.sender.send("sd-progress", { + phase: progress.phase, + progress: progress.progress, + detail: progress.detail, + }); + }, + onLog: (log) => { + // Send logs to frontend + event.sender.send("sd-log", { + type: log.type, + message: log.message, + }); + }, + }); - return result - } catch (error) { - return { - success: false, - error: (error as Error).message + // Also track via legacy activeSDProcess for backward compatibility + // (This will be set/cleared by SDGenerator internally) + + return result; + } catch (error) { + return { + success: false, + error: (error as Error).message, + }; } - } -}) + }, +); /** * list models */ -ipcMain.handle('sd-list-models', () => { +ipcMain.handle("sd-list-models", () => { try { - const modelsDir = getModelsDir() + const modelsDir = getModelsDir(); if (!existsSync(modelsDir)) { - return { success: true, models: [] } + return { success: true, models: [] }; } - const files = readdirSync(modelsDir) + const files = readdirSync(modelsDir); const models = files - .filter(f => f.endsWith('.gguf') && !f.endsWith('.part')) // Exclude .part files - .map(f => { - const filePath = join(modelsDir, f) - const stats = statSync(filePath) + .filter((f) => f.endsWith(".gguf") && !f.endsWith(".part")) // Exclude .part files + .map((f) => { + const filePath = join(modelsDir, f); + const stats = statSync(filePath); return { name: f, path: filePath, size: stats.size, - createdAt: stats.birthtime.toISOString() - } - }) + createdAt: stats.birthtime.toISOString(), + }; + }); - return { success: true, models } + return { success: true, models }; } catch (error) { return { success: false, - error: (error as Error).message - } + error: (error as Error).message, + }; } -}) +}); /** * delete model */ -ipcMain.handle('sd-delete-model', (_, modelPath: string) => { +ipcMain.handle("sd-delete-model", (_, modelPath: string) => { try { if (existsSync(modelPath)) { - unlinkSync(modelPath) + unlinkSync(modelPath); } - return { success: true } + return { success: true }; } catch (error) { return { success: false, - error: (error as Error).message - } + error: (error as Error).message, + }; } -}) +}); /** * Get file size */ -ipcMain.handle('get-file-size', (_, filePath: string) => { +ipcMain.handle("get-file-size", (_, filePath: string) => { try { if (existsSync(filePath)) { - const stats = statSync(filePath) - return stats.size + const stats = statSync(filePath); + return stats.size; } - return 0 + return 0; } catch (error) { - console.error('Failed to get file size:', error) - return 0 + console.error("Failed to get file size:", error); + return 0; } -}) +}); /** * Delete SD binary */ -ipcMain.handle('sd-delete-binary', () => { +ipcMain.handle("sd-delete-binary", () => { try { - const platform = process.platform - const arch = process.arch - const binaryName = platform === 'win32' ? 'sd.exe' : 'sd' + const platform = process.platform; + const arch = process.arch; + const binaryName = platform === "win32" ? "sd.exe" : "sd"; // Delete downloaded binary in userData (cache) - const userDataBinaryDir = join(app.getPath('userData'), 'sd-bin') - const userDataBinaryPath = join(userDataBinaryDir, binaryName) + const userDataBinaryDir = join(app.getPath("userData"), "sd-bin"); + const userDataBinaryPath = join(userDataBinaryDir, binaryName); if (existsSync(userDataBinaryPath)) { - unlinkSync(userDataBinaryPath) + unlinkSync(userDataBinaryPath); } // Delete pre-compiled binary in resources (if present) const basePath = is.dev - ? join(__dirname, '../../resources/bin/stable-diffusion') - : join(process.resourcesPath, 'bin/stable-diffusion') + ? join(__dirname, "../../resources/bin/stable-diffusion") + : join(process.resourcesPath, "bin/stable-diffusion"); - const binaryPath = join(basePath, `${platform}-${arch}`, binaryName) + const binaryPath = join(basePath, `${platform}-${arch}`, binaryName); if (existsSync(binaryPath)) { - unlinkSync(binaryPath) + unlinkSync(binaryPath); } - return { success: true } + return { success: true }; } catch (error) { return { success: false, - error: (error as Error).message - } + error: (error as Error).message, + }; } -}) +}); /** * Get main models directory (for z-image-turbo models) * Uses userData directory to keep models alongside sd-bin */ function getModelsDir(): string { - return join(app.getPath('userData'), 'models', 'stable-diffusion') + return join(app.getPath("userData"), "models", "stable-diffusion"); } /** * Get auxiliary models directory */ function getAuxiliaryModelsDir(): string { - return join(getModelsDir(), 'auxiliary') + return join(getModelsDir(), "auxiliary"); } - /** * Check if auxiliary models exist */ -ipcMain.handle('sd-check-auxiliary-models', () => { +ipcMain.handle("sd-check-auxiliary-models", () => { try { - const auxDir = getAuxiliaryModelsDir() - const llmPath = join(auxDir, 'Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf') - const vaePath = join(auxDir, 'ae.safetensors') + const auxDir = getAuxiliaryModelsDir(); + const llmPath = join(auxDir, "Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf"); + const vaePath = join(auxDir, "ae.safetensors"); return { success: true, llmExists: existsSync(llmPath), vaeExists: existsSync(vaePath), llmPath, - vaePath - } + vaePath, + }; } catch (error) { return { success: false, - error: (error as Error).message - } + error: (error as Error).message, + }; } -}) +}); /** * List all auxiliary models (LLM and VAE) */ -ipcMain.handle('sd-list-auxiliary-models', () => { +ipcMain.handle("sd-list-auxiliary-models", () => { try { - const auxDir = getAuxiliaryModelsDir() - const models: Array<{ name: string; path: string; size: number; type: 'llm' | 'vae' }> = [] + const auxDir = getAuxiliaryModelsDir(); + const models: Array<{ + name: string; + path: string; + size: number; + type: "llm" | "vae"; + }> = []; if (!existsSync(auxDir)) { - return { success: true, models: [] } + return { success: true, models: [] }; } - const llmPath = join(auxDir, 'Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf') - const vaePath = join(auxDir, 'ae.safetensors') + const llmPath = join(auxDir, "Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf"); + const vaePath = join(auxDir, "ae.safetensors"); if (existsSync(llmPath)) { - const stats = statSync(llmPath) + const stats = statSync(llmPath); models.push({ - name: 'Qwen3-4B-Instruct LLM', + name: "Qwen3-4B-Instruct LLM", path: llmPath, size: stats.size, - type: 'llm' - }) + type: "llm", + }); } if (existsSync(vaePath)) { - const stats = statSync(vaePath) + const stats = statSync(vaePath); models.push({ - name: 'Z-Image VAE', + name: "Z-Image VAE", path: vaePath, size: stats.size, - type: 'vae' - }) + type: "vae", + }); } - return { success: true, models } + return { success: true, models }; } catch (error) { return { success: false, - error: (error as Error).message - } + error: (error as Error).message, + }; } -}) +}); /** * Delete an auxiliary model */ -ipcMain.handle('sd-delete-auxiliary-model', (_, type: 'llm' | 'vae') => { +ipcMain.handle("sd-delete-auxiliary-model", (_, type: "llm" | "vae") => { try { - const auxDir = getAuxiliaryModelsDir() - const fileName = type === 'llm' - ? 'Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf' - : 'ae.safetensors' - const filePath = join(auxDir, fileName) + const auxDir = getAuxiliaryModelsDir(); + const fileName = + type === "llm" + ? "Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf" + : "ae.safetensors"; + const filePath = join(auxDir, fileName); if (existsSync(filePath)) { - unlinkSync(filePath) - console.log(`[Auxiliary Models] Deleted ${type} model:`, filePath) - return { success: true } + unlinkSync(filePath); + console.log(`[Auxiliary Models] Deleted ${type} model:`, filePath); + return { success: true }; } else { - return { success: false, error: 'Model file not found' } + return { success: false, error: "Model file not found" }; } } catch (error) { return { success: false, - error: (error as Error).message - } + error: (error as Error).message, + }; } -}) - - +}); /** * Cancel SD image generation */ -ipcMain.handle('sd-cancel-generation', async () => { +ipcMain.handle("sd-cancel-generation", async () => { try { - console.log('[SD Generation] Cancelling generation') + console.log("[SD Generation] Cancelling generation"); // Cancel via SDGenerator class - const cancelled = sdGenerator.cancel() + const cancelled = sdGenerator.cancel(); // Also cancel legacy activeSDProcess if exists if (activeSDProcess) { - activeSDProcess.kill('SIGTERM') - activeSDProcess = null + activeSDProcess.kill("SIGTERM"); + activeSDProcess = null; } - return { success: true, cancelled } + return { success: true, cancelled }; } catch (error) { return { success: false, - error: (error as Error).message - } + error: (error as Error).message, + }; } -}) +}); /** * Save model from browser cache to file system */ -ipcMain.handle('sd-save-model-from-cache', async (_, fileName: string, data: Uint8Array, type: 'llm' | 'vae' | 'model') => { - try { - let destPath: string +ipcMain.handle( + "sd-save-model-from-cache", + async ( + _, + fileName: string, + data: Uint8Array, + type: "llm" | "vae" | "model", + ) => { + try { + let destPath: string; - if (type === 'model') { - // Main model goes to models directory - const modelsDir = getModelsDir() - if (!existsSync(modelsDir)) { - mkdirSync(modelsDir, { recursive: true }) - } - destPath = join(modelsDir, fileName) - } else { - // Auxiliary models (LLM, VAE) go to auxiliary directory - const auxDir = getAuxiliaryModelsDir() - if (!existsSync(auxDir)) { - mkdirSync(auxDir, { recursive: true }) + if (type === "model") { + // Main model goes to models directory + const modelsDir = getModelsDir(); + if (!existsSync(modelsDir)) { + mkdirSync(modelsDir, { recursive: true }); + } + destPath = join(modelsDir, fileName); + } else { + // Auxiliary models (LLM, VAE) go to auxiliary directory + const auxDir = getAuxiliaryModelsDir(); + if (!existsSync(auxDir)) { + mkdirSync(auxDir, { recursive: true }); + } + destPath = join(auxDir, fileName); } - destPath = join(auxDir, fileName) - } - // Write file - writeFileSync(destPath, data) + // Write file + writeFileSync(destPath, data); - return { - success: true, - filePath: destPath - } - } catch (error) { - return { - success: false, - error: (error as Error).message + return { + success: true, + filePath: destPath, + }; + } catch (error) { + return { + success: false, + error: (error as Error).message, + }; } - } -}) + }, +); // Register custom protocol for local asset files (must be before app.whenReady) protocol.registerSchemesAsPrivileged([ { - scheme: 'local-asset', + scheme: "local-asset", privileges: { secure: true, supportFetchAPI: true, stream: true, - bypassCSP: true - } - } -]) + bypassCSP: true, + }, + }, +]); // App lifecycle app.whenReady().then(() => { - electronApp.setAppUserModelId('com.wavespeed.desktop') + electronApp.setAppUserModelId("com.wavespeed.desktop"); // Handle local-asset:// protocol for loading local files (videos, images, etc.) - protocol.handle('local-asset', (request) => { - const filePath = decodeURIComponent(request.url.replace('local-asset://', '')) - return net.fetch(pathToFileURL(filePath).href) - }) + protocol.handle("local-asset", (request) => { + const filePath = decodeURIComponent( + request.url.replace("local-asset://", ""), + ); + return net.fetch(pathToFileURL(filePath).href); + }); - app.on('browser-window-created', (_, window) => { - optimizer.watchWindowShortcuts(window) - }) + app.on("browser-window-created", (_, window) => { + optimizer.watchWindowShortcuts(window); + }); - createWindow() + createWindow(); // Initialize workflow module (sql.js DB, node registry, IPC handlers) - initWorkflowModule().catch(err => { - console.error('[Workflow] Failed to initialize:', err) - }) + initWorkflowModule().catch((err) => { + console.error("[Workflow] Failed to initialize:", err); + }); // Setup auto-updater after window is created - setupAutoUpdater() + setupAutoUpdater(); // Check for updates on startup (after a short delay) if autoCheckUpdate is enabled if (!is.dev) { - const settings = loadSettings() + const settings = loadSettings(); if (settings.autoCheckUpdate !== false) { setTimeout(() => { autoUpdater.checkForUpdates().catch((err) => { - console.error('Failed to check for updates:', err) - }) - }, 3000) + console.error("Failed to check for updates:", err); + }); + }, 3000); } } - app.on('activate', function () { + app.on("activate", function () { // macOS: Show the hidden window when clicking dock icon if (mainWindow) { - mainWindow.show() + mainWindow.show(); } else { - createWindow() + createWindow(); } - }) -}) + }); +}); // macOS: Set quitting flag so window close handler allows actual quit -app.on('before-quit', () => { - (app as typeof app & { isQuitting: boolean }).isQuitting = true -}) - -app.on('window-all-closed', () => { - closeWorkflowDatabase() - if (process.platform !== 'darwin') { - app.quit() +app.on("before-quit", () => { + (app as typeof app & { isQuitting: boolean }).isQuitting = true; +}); + +app.on("window-all-closed", () => { + closeWorkflowDatabase(); + if (process.platform !== "darwin") { + app.quit(); } -}) +}); diff --git a/electron/preload.ts b/electron/preload.ts index 4f63198e..b3013a08 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,281 +1,416 @@ -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge, ipcRenderer } from "electron"; interface Settings { - theme: 'light' | 'dark' | 'system' - defaultPollInterval: number - defaultTimeout: number - updateChannel: 'stable' | 'nightly' - autoCheckUpdate: boolean - language?: string + theme: "light" | "dark" | "system"; + defaultPollInterval: number; + defaultTimeout: number; + updateChannel: "stable" | "nightly"; + autoCheckUpdate: boolean; + language?: string; } interface UpdateStatus { - status: string - version?: string - releaseNotes?: string | null - releaseDate?: string - percent?: number - bytesPerSecond?: number - transferred?: number - total?: number - message?: string + status: string; + version?: string; + releaseNotes?: string | null; + releaseDate?: string; + percent?: number; + bytesPerSecond?: number; + transferred?: number; + total?: number; + message?: string; } interface UpdateCheckResult { - status: string + status: string; updateInfo?: { - version: string - releaseNotes?: string | null - } - message?: string + version: string; + releaseNotes?: string | null; + }; + message?: string; } interface DownloadResult { - success: boolean - filePath?: string - error?: string - canceled?: boolean + success: boolean; + filePath?: string; + error?: string; + canceled?: boolean; } interface AssetsSettings { - autoSaveAssets: boolean - assetsDirectory: string + autoSaveAssets: boolean; + assetsDirectory: string; } interface SaveAssetResult { - success: boolean - filePath?: string - fileSize?: number - error?: string + success: boolean; + filePath?: string; + fileSize?: number; + error?: string; } interface DeleteAssetResult { - success: boolean - error?: string + success: boolean; + error?: string; } interface DeleteAssetsBulkResult { - success: boolean - deleted: number + success: boolean; + deleted: number; } interface SelectDirectoryResult { - success: boolean - path?: string - canceled?: boolean - error?: string + success: boolean; + path?: string; + canceled?: boolean; + error?: string; } interface AssetMetadata { - id: string - filePath: string - fileName: string - type: 'image' | 'video' | 'audio' | 'text' | 'json' - modelId: string - modelName: string - createdAt: string - fileSize: number - tags: string[] - favorite: boolean - predictionId?: string - originalUrl?: string + id: string; + filePath: string; + fileName: string; + type: "image" | "video" | "audio" | "text" | "json"; + modelId: string; + modelName: string; + createdAt: string; + fileSize: number; + tags: string[]; + favorite: boolean; + predictionId?: string; + originalUrl?: string; } const electronAPI = { - getApiKey: (): Promise => ipcRenderer.invoke('get-api-key'), - setApiKey: (apiKey: string): Promise => ipcRenderer.invoke('set-api-key', apiKey), - getSettings: (): Promise => ipcRenderer.invoke('get-settings'), - setSettings: (settings: Partial): Promise => ipcRenderer.invoke('set-settings', settings), - clearAllData: (): Promise => ipcRenderer.invoke('clear-all-data'), - downloadFile: (url: string, defaultFilename: string): Promise => - ipcRenderer.invoke('download-file', url, defaultFilename), - saveFileSilent: (url: string, dir: string, fileName: string): Promise => - ipcRenderer.invoke('save-file-silent', url, dir, fileName), - openExternal: (url: string): Promise => ipcRenderer.invoke('open-external', url), + getApiKey: (): Promise => ipcRenderer.invoke("get-api-key"), + setApiKey: (apiKey: string): Promise => + ipcRenderer.invoke("set-api-key", apiKey), + getSettings: (): Promise => ipcRenderer.invoke("get-settings"), + setSettings: (settings: Partial): Promise => + ipcRenderer.invoke("set-settings", settings), + clearAllData: (): Promise => ipcRenderer.invoke("clear-all-data"), + downloadFile: ( + url: string, + defaultFilename: string, + ): Promise => + ipcRenderer.invoke("download-file", url, defaultFilename), + saveFileSilent: ( + url: string, + dir: string, + fileName: string, + ): Promise => + ipcRenderer.invoke("save-file-silent", url, dir, fileName), + openExternal: (url: string): Promise => + ipcRenderer.invoke("open-external", url), // Title bar theme - updateTitlebarTheme: (isDark: boolean): Promise => ipcRenderer.invoke('update-titlebar-theme', isDark), + updateTitlebarTheme: (isDark: boolean): Promise => + ipcRenderer.invoke("update-titlebar-theme", isDark), // Auto-updater APIs - getAppVersion: (): Promise => ipcRenderer.invoke('get-app-version'), - getLogFilePath: (): Promise => ipcRenderer.invoke('get-log-file-path'), + getAppVersion: (): Promise => ipcRenderer.invoke("get-app-version"), + getLogFilePath: (): Promise => + ipcRenderer.invoke("get-log-file-path"), openLogDirectory: (): Promise<{ success: boolean; path: string }> => - ipcRenderer.invoke('open-log-directory'), - checkForUpdates: (): Promise => ipcRenderer.invoke('check-for-updates'), - downloadUpdate: (): Promise<{ status: string; message?: string }> => ipcRenderer.invoke('download-update'), + ipcRenderer.invoke("open-log-directory"), + checkForUpdates: (): Promise => + ipcRenderer.invoke("check-for-updates"), + downloadUpdate: (): Promise<{ status: string; message?: string }> => + ipcRenderer.invoke("download-update"), installUpdate: (): void => { - ipcRenderer.invoke('install-update') + ipcRenderer.invoke("install-update"); }, - setUpdateChannel: (channel: 'stable' | 'nightly'): Promise => - ipcRenderer.invoke('set-update-channel', channel), + setUpdateChannel: (channel: "stable" | "nightly"): Promise => + ipcRenderer.invoke("set-update-channel", channel), onUpdateStatus: (callback: (status: UpdateStatus) => void): (() => void) => { - const handler = (_: unknown, status: UpdateStatus) => callback(status) - ipcRenderer.on('update-status', handler) - return () => ipcRenderer.removeListener('update-status', handler) + const handler = (_: unknown, status: UpdateStatus) => callback(status); + ipcRenderer.on("update-status", handler); + return () => ipcRenderer.removeListener("update-status", handler); }, // Assets APIs - getAssetsSettings: (): Promise => ipcRenderer.invoke('get-assets-settings'), + getAssetsSettings: (): Promise => + ipcRenderer.invoke("get-assets-settings"), setAssetsSettings: (settings: Partial): Promise => - ipcRenderer.invoke('set-assets-settings', settings), - getDefaultAssetsDirectory: (): Promise => ipcRenderer.invoke('get-default-assets-directory'), - getZImageOutputPath: (): Promise => ipcRenderer.invoke('get-zimage-output-path'), - selectDirectory: (): Promise => ipcRenderer.invoke('select-directory'), - saveAsset: (url: string, type: string, fileName: string, subDir: string): Promise => - ipcRenderer.invoke('save-asset', url, type, fileName, subDir), - deleteAsset: (filePath: string): Promise => ipcRenderer.invoke('delete-asset', filePath), + ipcRenderer.invoke("set-assets-settings", settings), + getDefaultAssetsDirectory: (): Promise => + ipcRenderer.invoke("get-default-assets-directory"), + getZImageOutputPath: (): Promise => + ipcRenderer.invoke("get-zimage-output-path"), + selectDirectory: (): Promise => + ipcRenderer.invoke("select-directory"), + saveAsset: ( + url: string, + type: string, + fileName: string, + subDir: string, + ): Promise => + ipcRenderer.invoke("save-asset", url, type, fileName, subDir), + deleteAsset: (filePath: string): Promise => + ipcRenderer.invoke("delete-asset", filePath), deleteAssetsBulk: (filePaths: string[]): Promise => - ipcRenderer.invoke('delete-assets-bulk', filePaths), - getAssetsMetadata: (): Promise => ipcRenderer.invoke('get-assets-metadata'), + ipcRenderer.invoke("delete-assets-bulk", filePaths), + getAssetsMetadata: (): Promise => + ipcRenderer.invoke("get-assets-metadata"), saveAssetsMetadata: (metadata: AssetMetadata[]): Promise => - ipcRenderer.invoke('save-assets-metadata', metadata), + ipcRenderer.invoke("save-assets-metadata", metadata), openFileLocation: (filePath: string): Promise => - ipcRenderer.invoke('open-file-location', filePath), - checkFileExists: (filePath: string): Promise => ipcRenderer.invoke('check-file-exists', filePath), + ipcRenderer.invoke("open-file-location", filePath), + checkFileExists: (filePath: string): Promise => + ipcRenderer.invoke("check-file-exists", filePath), openAssetsFolder: (): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('open-assets-folder'), - scanAssetsDirectory: (): Promise> => ipcRenderer.invoke('scan-assets-directory'), + ipcRenderer.invoke("open-assets-folder"), + scanAssetsDirectory: (): Promise< + Array<{ + filePath: string; + fileName: string; + type: "image" | "video" | "audio" | "text"; + fileSize: number; + createdAt: string; + }> + > => ipcRenderer.invoke("scan-assets-directory"), // Stable Diffusion APIs - sdGetBinaryPath: (): Promise<{ success: boolean; path?: string; error?: string }> => - ipcRenderer.invoke('sd-get-binary-path'), - sdGetSystemInfo: (): Promise<{ platform: string; arch: string; acceleration: string; supported: boolean }> => - ipcRenderer.invoke('sd-get-system-info'), - sdGetGpuVramMb: (): Promise<{ success: boolean; vramMb: number | null; error?: string }> => - ipcRenderer.invoke('sd-get-gpu-vram'), - sdCheckAuxiliaryModels: (): Promise<{ success: boolean; llmExists: boolean; vaeExists: boolean; llmPath: string; vaePath: string; error?: string }> => - ipcRenderer.invoke('sd-check-auxiliary-models'), - sdListAuxiliaryModels: (): Promise<{ success: boolean; models?: Array<{ name: string; path: string; size: number; type: 'llm' | 'vae' }>; error?: string }> => - ipcRenderer.invoke('sd-list-auxiliary-models'), - sdDeleteAuxiliaryModel: (type: 'llm' | 'vae'): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('sd-delete-auxiliary-model', type), + sdGetBinaryPath: (): Promise<{ + success: boolean; + path?: string; + error?: string; + }> => ipcRenderer.invoke("sd-get-binary-path"), + sdGetSystemInfo: (): Promise<{ + platform: string; + arch: string; + acceleration: string; + supported: boolean; + }> => ipcRenderer.invoke("sd-get-system-info"), + sdGetGpuVramMb: (): Promise<{ + success: boolean; + vramMb: number | null; + error?: string; + }> => ipcRenderer.invoke("sd-get-gpu-vram"), + sdCheckAuxiliaryModels: (): Promise<{ + success: boolean; + llmExists: boolean; + vaeExists: boolean; + llmPath: string; + vaePath: string; + error?: string; + }> => ipcRenderer.invoke("sd-check-auxiliary-models"), + sdListAuxiliaryModels: (): Promise<{ + success: boolean; + models?: Array<{ + name: string; + path: string; + size: number; + type: "llm" | "vae"; + }>; + error?: string; + }> => ipcRenderer.invoke("sd-list-auxiliary-models"), + sdDeleteAuxiliaryModel: ( + type: "llm" | "vae", + ): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke("sd-delete-auxiliary-model", type), sdGenerateImage: (params: { - modelPath: string - llmPath?: string - vaePath?: string - lowVramMode?: boolean - vaeTiling?: boolean - prompt: string - negativePrompt?: string - width: number - height: number - steps: number - cfgScale: number - seed?: number - outputPath: string + modelPath: string; + llmPath?: string; + vaePath?: string; + lowVramMode?: boolean; + vaeTiling?: boolean; + prompt: string; + negativePrompt?: string; + width: number; + height: number; + steps: number; + cfgScale: number; + seed?: number; + outputPath: string; }): Promise<{ success: boolean; outputPath?: string; error?: string }> => - ipcRenderer.invoke('sd-generate-image', params), + ipcRenderer.invoke("sd-generate-image", params), sdCancelGeneration: (): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('sd-cancel-generation'), - sdSaveModelFromCache: (filename: string, data: Uint8Array, type: 'model' | 'llm' | 'vae'): Promise<{ success: boolean; filePath?: string; error?: string }> => - ipcRenderer.invoke('sd-save-model-from-cache', filename, data, type), + ipcRenderer.invoke("sd-cancel-generation"), + sdSaveModelFromCache: ( + filename: string, + data: Uint8Array, + type: "model" | "llm" | "vae", + ): Promise<{ success: boolean; filePath?: string; error?: string }> => + ipcRenderer.invoke("sd-save-model-from-cache", filename, data, type), sdListModels: (): Promise<{ - success: boolean - models?: Array<{ name: string; path: string; size: number; createdAt: string }> - error?: string - }> => ipcRenderer.invoke('sd-list-models'), - sdDeleteModel: (modelPath: string): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('sd-delete-model', modelPath), + success: boolean; + models?: Array<{ + name: string; + path: string; + size: number; + createdAt: string; + }>; + error?: string; + }> => ipcRenderer.invoke("sd-list-models"), + sdDeleteModel: ( + modelPath: string, + ): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke("sd-delete-model", modelPath), sdDeleteBinary: (): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('sd-delete-binary'), + ipcRenderer.invoke("sd-delete-binary"), getFileSize: (filePath: string): Promise => - ipcRenderer.invoke('get-file-size', filePath), - onSdProgress: (callback: (data: { phase: string; progress: number; detail?: unknown }) => void): (() => void) => { - const handler = (_: unknown, data: unknown) => callback(data as { phase: string; progress: number; detail?: unknown }) - ipcRenderer.on('sd-progress', handler) - return () => ipcRenderer.removeListener('sd-progress', handler) + ipcRenderer.invoke("get-file-size", filePath), + onSdProgress: ( + callback: (data: { + phase: string; + progress: number; + detail?: unknown; + }) => void, + ): (() => void) => { + const handler = (_: unknown, data: unknown) => + callback(data as { phase: string; progress: number; detail?: unknown }); + ipcRenderer.on("sd-progress", handler); + return () => ipcRenderer.removeListener("sd-progress", handler); }, - onSdLog: (callback: (data: { type: 'stdout' | 'stderr'; message: string }) => void): (() => void) => { - const handler = (_: unknown, data: unknown) => callback(data as { type: 'stdout' | 'stderr'; message: string }) - ipcRenderer.on('sd-log', handler) - return () => ipcRenderer.removeListener('sd-log', handler) + onSdLog: ( + callback: (data: { type: "stdout" | "stderr"; message: string }) => void, + ): (() => void) => { + const handler = (_: unknown, data: unknown) => + callback(data as { type: "stdout" | "stderr"; message: string }); + ipcRenderer.on("sd-log", handler); + return () => ipcRenderer.removeListener("sd-log", handler); }, - onSdDownloadProgress: (callback: (data: { phase: string; progress: number; detail?: unknown }) => void): (() => void) => { - const handler = (_: unknown, data: unknown) => callback(data as { phase: string; progress: number; detail?: unknown }) - ipcRenderer.on('sd-download-progress', handler) - return () => ipcRenderer.removeListener('sd-download-progress', handler) + onSdDownloadProgress: ( + callback: (data: { + phase: string; + progress: number; + detail?: unknown; + }) => void, + ): (() => void) => { + const handler = (_: unknown, data: unknown) => + callback(data as { phase: string; progress: number; detail?: unknown }); + ipcRenderer.on("sd-download-progress", handler); + return () => ipcRenderer.removeListener("sd-download-progress", handler); }, - onSdBinaryDownloadProgress: (callback: (data: { phase: string; progress: number; detail?: unknown }) => void): (() => void) => { - const handler = (_: unknown, data: unknown) => callback(data as { phase: string; progress: number; detail?: unknown }) - ipcRenderer.on('sd-binary-download-progress', handler) - return () => ipcRenderer.removeListener('sd-binary-download-progress', handler) + onSdBinaryDownloadProgress: ( + callback: (data: { + phase: string; + progress: number; + detail?: unknown; + }) => void, + ): (() => void) => { + const handler = (_: unknown, data: unknown) => + callback(data as { phase: string; progress: number; detail?: unknown }); + ipcRenderer.on("sd-binary-download-progress", handler); + return () => + ipcRenderer.removeListener("sd-binary-download-progress", handler); }, - onSdLlmDownloadProgress: (callback: (data: { phase: string; progress: number; detail?: unknown }) => void): (() => void) => { - const handler = (_: unknown, data: unknown) => callback(data as { phase: string; progress: number; detail?: unknown }) - ipcRenderer.on('sd-llm-download-progress', handler) - return () => ipcRenderer.removeListener('sd-llm-download-progress', handler) + onSdLlmDownloadProgress: ( + callback: (data: { + phase: string; + progress: number; + detail?: unknown; + }) => void, + ): (() => void) => { + const handler = (_: unknown, data: unknown) => + callback(data as { phase: string; progress: number; detail?: unknown }); + ipcRenderer.on("sd-llm-download-progress", handler); + return () => + ipcRenderer.removeListener("sd-llm-download-progress", handler); }, - onSdVaeDownloadProgress: (callback: (data: { phase: string; progress: number; detail?: unknown }) => void): (() => void) => { - const handler = (_: unknown, data: unknown) => callback(data as { phase: string; progress: number; detail?: unknown }) - ipcRenderer.on('sd-vae-download-progress', handler) - return () => ipcRenderer.removeListener('sd-vae-download-progress', handler) + onSdVaeDownloadProgress: ( + callback: (data: { + phase: string; + progress: number; + detail?: unknown; + }) => void, + ): (() => void) => { + const handler = (_: unknown, data: unknown) => + callback(data as { phase: string; progress: number; detail?: unknown }); + ipcRenderer.on("sd-vae-download-progress", handler); + return () => + ipcRenderer.removeListener("sd-vae-download-progress", handler); }, // File operations for chunked downloads - fileGetSize: (filePath: string): Promise<{ success: boolean; size?: number; error?: string }> => - ipcRenderer.invoke('file-get-size', filePath), - fileAppendChunk: (filePath: string, chunk: ArrayBuffer): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('file-append-chunk', filePath, chunk), - fileRename: (oldPath: string, newPath: string): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('file-rename', oldPath, newPath), - fileDelete: (filePath: string): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('file-delete', filePath), + fileGetSize: ( + filePath: string, + ): Promise<{ success: boolean; size?: number; error?: string }> => + ipcRenderer.invoke("file-get-size", filePath), + fileAppendChunk: ( + filePath: string, + chunk: ArrayBuffer, + ): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke("file-append-chunk", filePath, chunk), + fileRename: ( + oldPath: string, + newPath: string, + ): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke("file-rename", oldPath, newPath), + fileDelete: ( + filePath: string, + ): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke("file-delete", filePath), // SD download path helpers for chunked downloads - sdGetBinaryDownloadPath: (): Promise<{ success: boolean; path?: string; error?: string }> => - ipcRenderer.invoke('sd-get-binary-download-path'), - sdGetAuxiliaryModelDownloadPath: (type: 'llm' | 'vae'): Promise<{ success: boolean; path?: string; error?: string }> => - ipcRenderer.invoke('sd-get-auxiliary-model-download-path', type), - sdGetModelsDir: (): Promise<{ success: boolean; path?: string; error?: string }> => - ipcRenderer.invoke('sd-get-models-dir'), - sdExtractBinary: (zipPath: string, destPath: string): Promise<{ success: boolean; path?: string; error?: string }> => - ipcRenderer.invoke('sd-extract-binary', zipPath, destPath), + sdGetBinaryDownloadPath: (): Promise<{ + success: boolean; + path?: string; + error?: string; + }> => ipcRenderer.invoke("sd-get-binary-download-path"), + sdGetAuxiliaryModelDownloadPath: ( + type: "llm" | "vae", + ): Promise<{ success: boolean; path?: string; error?: string }> => + ipcRenderer.invoke("sd-get-auxiliary-model-download-path", type), + sdGetModelsDir: (): Promise<{ + success: boolean; + path?: string; + error?: string; + }> => ipcRenderer.invoke("sd-get-models-dir"), + sdExtractBinary: ( + zipPath: string, + destPath: string, + ): Promise<{ success: boolean; path?: string; error?: string }> => + ipcRenderer.invoke("sd-extract-binary", zipPath, destPath), // Persistent key-value state (survives app restarts, unlike renderer localStorage) - getState: (key: string): Promise => ipcRenderer.invoke('get-state', key), - setState: (key: string, value: unknown): Promise => ipcRenderer.invoke('set-state', key, value), - removeState: (key: string): Promise => ipcRenderer.invoke('remove-state', key), + getState: (key: string): Promise => + ipcRenderer.invoke("get-state", key), + setState: (key: string, value: unknown): Promise => + ipcRenderer.invoke("set-state", key, value), + removeState: (key: string): Promise => + ipcRenderer.invoke("remove-state", key), // Assets event listener (workflow executor pushes new assets) onAssetsNewAsset: (callback: (asset: unknown) => void): (() => void) => { - const handler = (_: unknown, asset: unknown) => callback(asset) - ipcRenderer.on('assets:new-asset', handler) - return () => ipcRenderer.removeListener('assets:new-asset', handler) - } -} + const handler = (_: unknown, asset: unknown) => callback(asset); + ipcRenderer.on("assets:new-asset", handler); + return () => ipcRenderer.removeListener("assets:new-asset", handler); + }, +}; // ─── Workflow API (isolated namespace to avoid collision with electronAPI) ──── const workflowAPI = { invoke: (channel: string, args?: unknown): Promise => ipcRenderer.invoke(channel, args), on: (channel: string, callback: (...args: unknown[]) => void): void => { - const handler = (_event: unknown, ...rest: unknown[]) => callback(...rest) - ipcRenderer.on(channel, handler) + const handler = (_event: unknown, ...rest: unknown[]) => callback(...rest); + ipcRenderer.on(channel, handler); // Store handler reference for removal - ;(workflowAPI as Record)[`__handler_${channel}_${callback.toString().slice(0, 50)}`] = handler + (workflowAPI as Record)[ + `__handler_${channel}_${callback.toString().slice(0, 50)}` + ] = handler; }, - removeListener: (channel: string, _callback: (...args: unknown[]) => void): void => { + removeListener: ( + channel: string, + _callback: (...args: unknown[]) => void, + ): void => { // Best-effort removal — remove all listeners for this channel - ipcRenderer.removeAllListeners(channel) - } -} + ipcRenderer.removeAllListeners(channel); + }, +}; if (process.contextIsolated) { try { - contextBridge.exposeInMainWorld('electronAPI', electronAPI) - contextBridge.exposeInMainWorld('workflowAPI', workflowAPI) + contextBridge.exposeInMainWorld("electronAPI", electronAPI); + contextBridge.exposeInMainWorld("workflowAPI", workflowAPI); } catch (error) { - console.error(error) + console.error(error); } } else { // @ts-ignore - fallback for non-isolated context - window.electronAPI = electronAPI + window.electronAPI = electronAPI; // @ts-ignore - window.workflowAPI = workflowAPI + window.workflowAPI = workflowAPI; } diff --git a/electron/workflow/db/budget.repo.ts b/electron/workflow/db/budget.repo.ts index bdcd8857..b0d52d4e 100644 --- a/electron/workflow/db/budget.repo.ts +++ b/electron/workflow/db/budget.repo.ts @@ -1,36 +1,45 @@ /** * Budget repository — get/set budget config and daily spend. */ -import { getDatabase, persistDatabase } from './connection' -import type { BudgetConfig } from '../../../src/workflow/types/ipc' +import { getDatabase, persistDatabase } from "./connection"; +import type { BudgetConfig } from "../../../src/workflow/types/ipc"; export function getBudgetConfig(): BudgetConfig { - const db = getDatabase() - const result = db.exec('SELECT per_execution_limit, daily_limit FROM budget_config WHERE id = 1') - if (!result.length || !result[0].values.length) return { perExecutionLimit: 10, dailyLimit: 100 } - const row = result[0].values[0] - return { perExecutionLimit: row[0] as number, dailyLimit: row[1] as number } + const db = getDatabase(); + const result = db.exec( + "SELECT per_execution_limit, daily_limit FROM budget_config WHERE id = 1", + ); + if (!result.length || !result[0].values.length) + return { perExecutionLimit: 10, dailyLimit: 100 }; + const row = result[0].values[0]; + return { perExecutionLimit: row[0] as number, dailyLimit: row[1] as number }; } export function setBudgetConfig(config: BudgetConfig): void { - const db = getDatabase() - db.run('UPDATE budget_config SET per_execution_limit = ?, daily_limit = ? WHERE id = 1', - [config.perExecutionLimit, config.dailyLimit]) - persistDatabase() + const db = getDatabase(); + db.run( + "UPDATE budget_config SET per_execution_limit = ?, daily_limit = ? WHERE id = 1", + [config.perExecutionLimit, config.dailyLimit], + ); + persistDatabase(); } export function getDailySpend(date?: string): number { - const db = getDatabase() - const d = date ?? new Date().toISOString().slice(0, 10) - const result = db.exec('SELECT total_cost FROM daily_spend WHERE date = ?', [d]) - if (!result.length || !result[0].values.length) return 0 - return result[0].values[0][0] as number + const db = getDatabase(); + const d = date ?? new Date().toISOString().slice(0, 10); + const result = db.exec("SELECT total_cost FROM daily_spend WHERE date = ?", [ + d, + ]); + if (!result.length || !result[0].values.length) return 0; + return result[0].values[0][0] as number; } export function addDailySpend(cost: number, date?: string): void { - const db = getDatabase() - const d = date ?? new Date().toISOString().slice(0, 10) - db.run(`INSERT INTO daily_spend (date, total_cost) VALUES (?, ?) ON CONFLICT(date) DO UPDATE SET total_cost = total_cost + ?`, - [d, cost, cost]) - persistDatabase() + const db = getDatabase(); + const d = date ?? new Date().toISOString().slice(0, 10); + db.run( + `INSERT INTO daily_spend (date, total_cost) VALUES (?, ?) ON CONFLICT(date) DO UPDATE SET total_cost = total_cost + ?`, + [d, cost, cost], + ); + persistDatabase(); } diff --git a/electron/workflow/db/connection.ts b/electron/workflow/db/connection.ts index 1f02bfc6..07015cb6 100644 --- a/electron/workflow/db/connection.ts +++ b/electron/workflow/db/connection.ts @@ -2,137 +2,154 @@ * SQLite database connection management using sql.js (WASM-based). */ -import initSqlJs, { type Database as SqlJsDatabase } from 'sql.js' -import { app } from 'electron' -import { join } from 'path' -import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs' -import { dirname } from 'path' -import { initializeSchema, runMigrations } from './schema' - -const DB_FILENAME = 'workflow.db' - -let db: SqlJsDatabase | null = null -let dbPath: string = '' +import initSqlJs, { type Database as SqlJsDatabase } from "sql.js"; +import { app } from "electron"; +import { join } from "path"; +import { + existsSync, + readFileSync, + writeFileSync, + renameSync, + mkdirSync, +} from "fs"; +import { dirname } from "path"; +import { initializeSchema, runMigrations } from "./schema"; + +const DB_FILENAME = "workflow.db"; + +let db: SqlJsDatabase | null = null; +let dbPath: string = ""; function getWorkflowDataRoot(): string { // Packaged app runs from app.asar (read-only). Persist workflow data in userData. if (app.isPackaged) { - return join(app.getPath('userData'), 'workflow-data') + return join(app.getPath("userData"), "workflow-data"); } // In dev mode keep current behavior for easier local inspection. - return join(app.getAppPath(), 'workflow-data') + return join(app.getAppPath(), "workflow-data"); } -export type { SqlJsDatabase } +export type { SqlJsDatabase }; export function getDatabasePath(): string { if (!dbPath) { try { - dbPath = join(getWorkflowDataRoot(), DB_FILENAME) + dbPath = join(getWorkflowDataRoot(), DB_FILENAME); } catch { - dbPath = join(process.cwd(), 'workflow-data', DB_FILENAME) + dbPath = join(process.cwd(), "workflow-data", DB_FILENAME); } } - return dbPath + return dbPath; } function saveToDisk(): void { - if (!db) return - const filePath = getDatabasePath() - const dir = dirname(filePath) - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) - const data = db.export() - const buffer = Buffer.from(data) - writeFileSync(filePath, buffer) + if (!db) return; + const filePath = getDatabasePath(); + const dir = dirname(filePath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const data = db.export(); + const buffer = Buffer.from(data); + writeFileSync(filePath, buffer); } export async function openDatabase(): Promise { - if (db) return db + if (db) return db; - const SQL = await initSqlJs() - const filePath = getDatabasePath() - const dbExists = existsSync(filePath) - let isCorrupt = false + const SQL = await initSqlJs(); + const filePath = getDatabasePath(); + const dbExists = existsSync(filePath); + let isCorrupt = false; if (dbExists) { try { - const fileBuffer = readFileSync(filePath) - db = new SQL.Database(fileBuffer) - const result = db.exec('PRAGMA integrity_check') - const ok = result[0]?.values?.[0]?.[0] - if (ok !== 'ok') throw new Error('integrity_check failed') + const fileBuffer = readFileSync(filePath); + db = new SQL.Database(fileBuffer); + const result = db.exec("PRAGMA integrity_check"); + const ok = result[0]?.values?.[0]?.[0]; + if (ok !== "ok") throw new Error("integrity_check failed"); } catch (error) { - console.error('[Workflow DB] Database corrupt or unreadable:', error) - isCorrupt = true - if (db) { db.close(); db = null } - const backupPath = `${filePath}.corrupt.${Date.now()}` - renameSync(filePath, backupPath) - console.warn(`[Workflow DB] Corrupt database backed up to: ${backupPath}`) + console.error("[Workflow DB] Database corrupt or unreadable:", error); + isCorrupt = true; + if (db) { + db.close(); + db = null; + } + const backupPath = `${filePath}.corrupt.${Date.now()}`; + renameSync(filePath, backupPath); + console.warn( + `[Workflow DB] Corrupt database backed up to: ${backupPath}`, + ); } } if (!db) { - db = new SQL.Database() + db = new SQL.Database(); } - db.run('PRAGMA foreign_keys = ON') + db.run("PRAGMA foreign_keys = ON"); if (!dbExists || isCorrupt) { - initializeSchema(db) - saveToDisk() + initializeSchema(db); + saveToDisk(); } else { - runMigrations(db) - saveToDisk() // Save after running migrations + runMigrations(db); + saveToDisk(); // Save after running migrations } - return db + return db; } export function getDatabase(): SqlJsDatabase { - if (!db) throw new Error('[Workflow DB] Database not initialized. Call openDatabase() first.') - return db + if (!db) + throw new Error( + "[Workflow DB] Database not initialized. Call openDatabase() first.", + ); + return db; } -let persistTimer: ReturnType | null = null +let persistTimer: ReturnType | null = null; /** Debounced persist — batches rapid writes into a single disk flush (max 500ms delay) */ export function persistDatabase(): void { - if (persistTimer) clearTimeout(persistTimer) + if (persistTimer) clearTimeout(persistTimer); persistTimer = setTimeout(() => { - persistTimer = null - saveToDisk() - }, 500) + persistTimer = null; + saveToDisk(); + }, 500); } /** Immediate persist — for critical moments like close/shutdown */ export function persistDatabaseNow(): void { - if (persistTimer) { clearTimeout(persistTimer); persistTimer = null } - saveToDisk() + if (persistTimer) { + clearTimeout(persistTimer); + persistTimer = null; + } + saveToDisk(); } export function closeDatabase(): void { if (db) { try { - persistDatabaseNow() - db.close() + persistDatabaseNow(); + db.close(); } catch (error) { - console.error('[Workflow DB] Error closing database:', error) + console.error("[Workflow DB] Error closing database:", error); } finally { - db = null + db = null; } } } export function transaction(fn: (db: SqlJsDatabase) => T): T { - const database = getDatabase() - database.run('BEGIN TRANSACTION') + const database = getDatabase(); + database.run("BEGIN TRANSACTION"); try { - const result = fn(database) - database.run('COMMIT') - saveToDisk() - return result + const result = fn(database); + database.run("COMMIT"); + saveToDisk(); + return result; } catch (error) { - database.run('ROLLBACK') - throw error + database.run("ROLLBACK"); + throw error; } } diff --git a/electron/workflow/db/edge.repo.ts b/electron/workflow/db/edge.repo.ts index a39dcaa2..3363ea5b 100644 --- a/electron/workflow/db/edge.repo.ts +++ b/electron/workflow/db/edge.repo.ts @@ -1,34 +1,45 @@ /** * Edge repository — CRUD operations for edges table. */ -import { getDatabase, persistDatabase } from './connection' -import type { WorkflowEdge } from '../../../src/workflow/types/workflow' +import { getDatabase, persistDatabase } from "./connection"; +import type { WorkflowEdge } from "../../../src/workflow/types/workflow"; function rowToEdge(row: unknown[]): WorkflowEdge { return { - id: row[0] as string, workflowId: row[1] as string, sourceNodeId: row[2] as string, - sourceOutputKey: row[3] as string, targetNodeId: row[4] as string, targetInputKey: row[5] as string - } + id: row[0] as string, + workflowId: row[1] as string, + sourceNodeId: row[2] as string, + sourceOutputKey: row[3] as string, + targetNodeId: row[4] as string, + targetInputKey: row[5] as string, + }; } -const EDGE_COLS = 'id, workflow_id, source_node_id, source_output_key, target_node_id, target_input_key' +const EDGE_COLS = + "id, workflow_id, source_node_id, source_output_key, target_node_id, target_input_key"; export function getEdgesByWorkflowId(workflowId: string): WorkflowEdge[] { - const db = getDatabase() - const result = db.exec(`SELECT ${EDGE_COLS} FROM edges WHERE workflow_id = ?`, [workflowId]) - if (!result.length) return [] - return result[0].values.map(rowToEdge) + const db = getDatabase(); + const result = db.exec( + `SELECT ${EDGE_COLS} FROM edges WHERE workflow_id = ?`, + [workflowId], + ); + if (!result.length) return []; + return result[0].values.map(rowToEdge); } export function getEdgesBySourceNode(sourceNodeId: string): WorkflowEdge[] { - const db = getDatabase() - const result = db.exec(`SELECT ${EDGE_COLS} FROM edges WHERE source_node_id = ?`, [sourceNodeId]) - if (!result.length) return [] - return result[0].values.map(rowToEdge) + const db = getDatabase(); + const result = db.exec( + `SELECT ${EDGE_COLS} FROM edges WHERE source_node_id = ?`, + [sourceNodeId], + ); + if (!result.length) return []; + return result[0].values.map(rowToEdge); } export function deleteEdge(edgeId: string): void { - const db = getDatabase() - db.run('DELETE FROM edges WHERE id = ?', [edgeId]) - persistDatabase() + const db = getDatabase(); + db.run("DELETE FROM edges WHERE id = ?", [edgeId]); + persistDatabase(); } diff --git a/electron/workflow/db/execution.repo.ts b/electron/workflow/db/execution.repo.ts index b5d898ad..10a07993 100644 --- a/electron/workflow/db/execution.repo.ts +++ b/electron/workflow/db/execution.repo.ts @@ -1,82 +1,121 @@ /** * Execution record repository — CRUD for node_executions table. */ -import { getDatabase, persistDatabase } from './connection' -import type { NodeExecutionRecord } from '../../../src/workflow/types/execution' +import { getDatabase, persistDatabase } from "./connection"; +import type { NodeExecutionRecord } from "../../../src/workflow/types/execution"; function rowToRecord(row: unknown[]): NodeExecutionRecord { return { - id: row[0] as string, nodeId: row[1] as string, workflowId: row[2] as string, - inputHash: row[3] as string, paramsHash: row[4] as string, - status: row[5] as NodeExecutionRecord['status'], + id: row[0] as string, + nodeId: row[1] as string, + workflowId: row[2] as string, + inputHash: row[3] as string, + paramsHash: row[4] as string, + status: row[5] as NodeExecutionRecord["status"], resultPath: row[6] as string | null, resultMetadata: row[7] ? JSON.parse(row[7] as string) : null, - durationMs: row[8] as number | null, cost: row[9] as number, - createdAt: row[10] as string, score: row[11] as number | null, - starred: (row[12] as number) === 1 - } + durationMs: row[8] as number | null, + cost: row[9] as number, + createdAt: row[10] as string, + score: row[11] as number | null, + starred: (row[12] as number) === 1, + }; } -const EXEC_COLS = 'id, node_id, workflow_id, input_hash, params_hash, status, result_path, result_metadata, duration_ms, cost, created_at, score, starred' +const EXEC_COLS = + "id, node_id, workflow_id, input_hash, params_hash, status, result_path, result_metadata, duration_ms, cost, created_at, score, starred"; -export function insertExecution(record: Omit): NodeExecutionRecord { - const db = getDatabase() - const now = new Date().toISOString() +export function insertExecution( + record: Omit, +): NodeExecutionRecord { + const db = getDatabase(); + const now = new Date().toISOString(); db.run( `INSERT INTO node_executions (id, node_id, workflow_id, input_hash, params_hash, status, result_path, result_metadata, duration_ms, cost, created_at, score, starred) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [record.id, record.nodeId, record.workflowId, record.inputHash, record.paramsHash, - record.status, record.resultPath, record.resultMetadata ? JSON.stringify(record.resultMetadata) : null, - record.durationMs, record.cost, now, record.score, record.starred ? 1 : 0] - ) - persistDatabase() - return { ...record, createdAt: now } + [ + record.id, + record.nodeId, + record.workflowId, + record.inputHash, + record.paramsHash, + record.status, + record.resultPath, + record.resultMetadata ? JSON.stringify(record.resultMetadata) : null, + record.durationMs, + record.cost, + now, + record.score, + record.starred ? 1 : 0, + ], + ); + persistDatabase(); + return { ...record, createdAt: now }; } export function getExecutionsByNodeId(nodeId: string): NodeExecutionRecord[] { - const db = getDatabase() - const result = db.exec(`SELECT ${EXEC_COLS} FROM node_executions WHERE node_id = ? ORDER BY created_at DESC`, [nodeId]) - if (!result.length) return [] - return result[0].values.map(rowToRecord) + const db = getDatabase(); + const result = db.exec( + `SELECT ${EXEC_COLS} FROM node_executions WHERE node_id = ? ORDER BY created_at DESC`, + [nodeId], + ); + if (!result.length) return []; + return result[0].values.map(rowToRecord); } export function getExecutionById(id: string): NodeExecutionRecord | null { - const db = getDatabase() - const result = db.exec(`SELECT ${EXEC_COLS} FROM node_executions WHERE id = ?`, [id]) - if (!result.length || !result[0].values.length) return null - return rowToRecord(result[0].values[0]) + const db = getDatabase(); + const result = db.exec( + `SELECT ${EXEC_COLS} FROM node_executions WHERE id = ?`, + [id], + ); + if (!result.length || !result[0].values.length) return null; + return rowToRecord(result[0].values[0]); } export function updateExecutionScore(executionId: string, score: number): void { - const db = getDatabase() - db.run('UPDATE node_executions SET score = ? WHERE id = ?', [score, executionId]) - persistDatabase() + const db = getDatabase(); + db.run("UPDATE node_executions SET score = ? WHERE id = ?", [ + score, + executionId, + ]); + persistDatabase(); } -export function updateExecutionStarred(executionId: string, starred: boolean): void { - const db = getDatabase() - db.run('UPDATE node_executions SET starred = ? WHERE id = ?', [starred ? 1 : 0, executionId]) - persistDatabase() +export function updateExecutionStarred( + executionId: string, + starred: boolean, +): void { + const db = getDatabase(); + db.run("UPDATE node_executions SET starred = ? WHERE id = ?", [ + starred ? 1 : 0, + executionId, + ]); + persistDatabase(); } export function deleteExecution(executionId: string): void { - const db = getDatabase() - db.run('DELETE FROM node_executions WHERE id = ?', [executionId]) - persistDatabase() + const db = getDatabase(); + db.run("DELETE FROM node_executions WHERE id = ?", [executionId]); + persistDatabase(); } export function deleteExecutionsByNodeId(nodeId: string): void { - const db = getDatabase() - db.run('DELETE FROM node_executions WHERE node_id = ?', [nodeId]) - persistDatabase() + const db = getDatabase(); + db.run("DELETE FROM node_executions WHERE node_id = ?", [nodeId]); + persistDatabase(); } -export function findByCache(nodeId: string, inputHash: string, paramsHash: string): NodeExecutionRecord | null { - const db = getDatabase() +export function findByCache( + nodeId: string, + inputHash: string, + paramsHash: string, +): NodeExecutionRecord | null { + const db = getDatabase(); const result = db.exec( `SELECT ${EXEC_COLS} FROM node_executions WHERE node_id = ? AND input_hash = ? AND params_hash = ? AND status = 'success' ORDER BY created_at DESC LIMIT 1`, - [nodeId, inputHash, paramsHash] - ) - if (!result.length || !result[0].values.length) return null - return rowToRecord(result[0].values[0]) + [nodeId, inputHash, paramsHash], + ); + if (!result.length || !result[0].values.length) return null; + return rowToRecord(result[0].values[0]); } diff --git a/electron/workflow/db/index.ts b/electron/workflow/db/index.ts index c4d66552..d053bf16 100644 --- a/electron/workflow/db/index.ts +++ b/electron/workflow/db/index.ts @@ -1,6 +1,12 @@ -export { openDatabase, getDatabase, persistDatabase, closeDatabase, transaction } from './connection' -export * from './workflow.repo' -export * from './node.repo' -export * from './edge.repo' -export * from './execution.repo' -export * from './budget.repo' +export { + openDatabase, + getDatabase, + persistDatabase, + closeDatabase, + transaction, +} from "./connection"; +export * from "./workflow.repo"; +export * from "./node.repo"; +export * from "./edge.repo"; +export * from "./execution.repo"; +export * from "./budget.repo"; diff --git a/electron/workflow/db/node.repo.ts b/electron/workflow/db/node.repo.ts index 6aa426f4..e46d8a9f 100644 --- a/electron/workflow/db/node.repo.ts +++ b/electron/workflow/db/node.repo.ts @@ -1,31 +1,46 @@ /** * Node repository — CRUD operations for nodes table. */ -import { getDatabase, persistDatabase } from './connection' -import type { WorkflowNode } from '../../../src/workflow/types/workflow' +import { getDatabase, persistDatabase } from "./connection"; +import type { WorkflowNode } from "../../../src/workflow/types/workflow"; export function getNodesByWorkflowId(workflowId: string): WorkflowNode[] { - const db = getDatabase() + const db = getDatabase(); const result = db.exec( - 'SELECT id, workflow_id, node_type, position_x, position_y, params, current_output_id FROM nodes WHERE workflow_id = ?', - [workflowId] - ) - if (!result.length) return [] - return result[0].values.map(row => ({ - id: row[0] as string, workflowId: row[1] as string, nodeType: row[2] as string, + "SELECT id, workflow_id, node_type, position_x, position_y, params, current_output_id FROM nodes WHERE workflow_id = ?", + [workflowId], + ); + if (!result.length) return []; + return result[0].values.map((row) => ({ + id: row[0] as string, + workflowId: row[1] as string, + nodeType: row[2] as string, position: { x: row[3] as number, y: row[4] as number }, - params: JSON.parse(row[5] as string), currentOutputId: row[6] as string | null - })) + params: JSON.parse(row[5] as string), + currentOutputId: row[6] as string | null, + })); } -export function updateNodeParams(nodeId: string, params: Record): void { - const db = getDatabase() - db.run('UPDATE nodes SET params = ? WHERE id = ?', [JSON.stringify(params), nodeId]) - persistDatabase() +export function updateNodeParams( + nodeId: string, + params: Record, +): void { + const db = getDatabase(); + db.run("UPDATE nodes SET params = ? WHERE id = ?", [ + JSON.stringify(params), + nodeId, + ]); + persistDatabase(); } -export function updateNodeCurrentOutputId(nodeId: string, executionId: string | null): void { - const db = getDatabase() - db.run('UPDATE nodes SET current_output_id = ? WHERE id = ?', [executionId, nodeId]) - persistDatabase() +export function updateNodeCurrentOutputId( + nodeId: string, + executionId: string | null, +): void { + const db = getDatabase(); + db.run("UPDATE nodes SET current_output_id = ? WHERE id = ?", [ + executionId, + nodeId, + ]); + persistDatabase(); } diff --git a/electron/workflow/db/schema.ts b/electron/workflow/db/schema.ts index d2395f6d..7e190fa2 100644 --- a/electron/workflow/db/schema.ts +++ b/electron/workflow/db/schema.ts @@ -2,16 +2,16 @@ * SQLite database schema definitions and migrations (sql.js version). */ -import type { Database as SqlJsDatabase } from 'sql.js' +import type { Database as SqlJsDatabase } from "sql.js"; -const DEFAULT_PER_EXECUTION_LIMIT = 10.0 -const DEFAULT_DAILY_LIMIT = 100.0 +const DEFAULT_PER_EXECUTION_LIMIT = 10.0; +const DEFAULT_DAILY_LIMIT = 100.0; export function initializeSchema(db: SqlJsDatabase): void { db.run(`CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')) - )`) + )`); db.run(`CREATE TABLE IF NOT EXISTS workflows ( id TEXT PRIMARY KEY, @@ -20,7 +20,7 @@ export function initializeSchema(db: SqlJsDatabase): void { updated_at TEXT NOT NULL DEFAULT (datetime('now')), graph_definition TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'ready', 'archived')) - )`) + )`); db.run(`CREATE TABLE IF NOT EXISTS nodes ( id TEXT PRIMARY KEY, @@ -31,7 +31,7 @@ export function initializeSchema(db: SqlJsDatabase): void { params TEXT NOT NULL DEFAULT '{}', current_output_id TEXT, FOREIGN KEY (current_output_id) REFERENCES node_executions(id) ON DELETE SET NULL - )`) + )`); db.run(`CREATE TABLE IF NOT EXISTS node_executions ( id TEXT PRIMARY KEY, @@ -47,7 +47,7 @@ export function initializeSchema(db: SqlJsDatabase): void { created_at TEXT NOT NULL DEFAULT (datetime('now')), score REAL, starred INTEGER NOT NULL DEFAULT 0 CHECK (starred IN (0, 1)) - )`) + )`); db.run(`CREATE TABLE IF NOT EXISTS edges ( id TEXT PRIMARY KEY, @@ -57,25 +57,25 @@ export function initializeSchema(db: SqlJsDatabase): void { target_node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, target_input_key TEXT NOT NULL, UNIQUE(source_node_id, source_output_key, target_node_id, target_input_key) - )`) + )`); db.run(`CREATE TABLE IF NOT EXISTS budget_config ( id INTEGER PRIMARY KEY CHECK (id = 1), per_execution_limit REAL NOT NULL DEFAULT ${DEFAULT_PER_EXECUTION_LIMIT}, daily_limit REAL NOT NULL DEFAULT ${DEFAULT_DAILY_LIMIT} - )`) + )`); db.run(`CREATE TABLE IF NOT EXISTS daily_spend ( date TEXT PRIMARY KEY, total_cost REAL NOT NULL DEFAULT 0 - )`) + )`); db.run(`CREATE TABLE IF NOT EXISTS api_keys ( id INTEGER PRIMARY KEY CHECK (id = 1), wavespeed_key TEXT, llm_key TEXT, updated_at TEXT NOT NULL DEFAULT (datetime('now')) - )`) + )`); db.run(`CREATE TABLE IF NOT EXISTS templates ( id TEXT PRIMARY KEY, @@ -92,42 +92,75 @@ export function initializeSchema(db: SqlJsDatabase): void { thumbnail TEXT, playground_data TEXT, workflow_data TEXT - )`) + )`); // Indexes - db.run('CREATE INDEX IF NOT EXISTS idx_wf_nodes_workflow ON nodes(workflow_id)') - db.run('CREATE INDEX IF NOT EXISTS idx_wf_edges_workflow ON edges(workflow_id)') - db.run('CREATE INDEX IF NOT EXISTS idx_wf_executions_node ON node_executions(node_id)') - db.run('CREATE INDEX IF NOT EXISTS idx_wf_executions_workflow ON node_executions(workflow_id)') - db.run('CREATE INDEX IF NOT EXISTS idx_wf_executions_created ON node_executions(created_at DESC)') - db.run('CREATE INDEX IF NOT EXISTS idx_wf_executions_cache ON node_executions(node_id, input_hash, params_hash, status)') - db.run('CREATE INDEX IF NOT EXISTS idx_wf_edges_source ON edges(source_node_id)') - db.run('CREATE INDEX IF NOT EXISTS idx_wf_edges_target ON edges(target_node_id)') - db.run('CREATE INDEX IF NOT EXISTS idx_wf_daily_spend_date ON daily_spend(date)') - db.run('CREATE INDEX IF NOT EXISTS idx_templates_type ON templates(type)') - db.run('CREATE INDEX IF NOT EXISTS idx_templates_template_type ON templates(template_type)') - db.run('CREATE INDEX IF NOT EXISTS idx_templates_favorite ON templates(is_favorite)') - db.run('CREATE INDEX IF NOT EXISTS idx_templates_created ON templates(created_at DESC)') - db.run('CREATE INDEX IF NOT EXISTS idx_templates_use_count ON templates(use_count DESC)') + db.run( + "CREATE INDEX IF NOT EXISTS idx_wf_nodes_workflow ON nodes(workflow_id)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_wf_edges_workflow ON edges(workflow_id)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_wf_executions_node ON node_executions(node_id)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_wf_executions_workflow ON node_executions(workflow_id)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_wf_executions_created ON node_executions(created_at DESC)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_wf_executions_cache ON node_executions(node_id, input_hash, params_hash, status)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_wf_edges_source ON edges(source_node_id)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_wf_edges_target ON edges(target_node_id)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_wf_daily_spend_date ON daily_spend(date)", + ); + db.run("CREATE INDEX IF NOT EXISTS idx_templates_type ON templates(type)"); + db.run( + "CREATE INDEX IF NOT EXISTS idx_templates_template_type ON templates(template_type)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_templates_favorite ON templates(is_favorite)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_templates_created ON templates(created_at DESC)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_templates_use_count ON templates(use_count DESC)", + ); // Default config - db.run('INSERT OR IGNORE INTO budget_config (id, per_execution_limit, daily_limit) VALUES (1, ?, ?)', - [DEFAULT_PER_EXECUTION_LIMIT, DEFAULT_DAILY_LIMIT]) - db.run('INSERT OR IGNORE INTO api_keys (id, wavespeed_key, llm_key) VALUES (1, NULL, NULL)') - db.run('INSERT OR IGNORE INTO schema_version (version) VALUES (1)') + db.run( + "INSERT OR IGNORE INTO budget_config (id, per_execution_limit, daily_limit) VALUES (1, ?, ?)", + [DEFAULT_PER_EXECUTION_LIMIT, DEFAULT_DAILY_LIMIT], + ); + db.run( + "INSERT OR IGNORE INTO api_keys (id, wavespeed_key, llm_key) VALUES (1, NULL, NULL)", + ); + db.run("INSERT OR IGNORE INTO schema_version (version) VALUES (1)"); } export function runMigrations(db: SqlJsDatabase): void { - const result = db.exec('SELECT MAX(version) as version FROM schema_version') - const currentVersion = (result[0]?.values?.[0]?.[0] as number) ?? 0 + const result = db.exec("SELECT MAX(version) as version FROM schema_version"); + const currentVersion = (result[0]?.values?.[0]?.[0] as number) ?? 0; - const migrations: Array<{ version: number; apply: (db: SqlJsDatabase) => void }> = [ + const migrations: Array<{ + version: number; + apply: (db: SqlJsDatabase) => void; + }> = [ // Migration 2: Add templates table { version: 2, apply: (db: SqlJsDatabase) => { - console.log('[Schema] Applying migration 2: Add templates table') - + console.log("[Schema] Applying migration 2: Add templates table"); + db.run(`CREATE TABLE IF NOT EXISTS templates ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -143,22 +176,32 @@ export function runMigrations(db: SqlJsDatabase): void { thumbnail TEXT, playground_data TEXT, workflow_data TEXT - )`) - - db.run('CREATE INDEX IF NOT EXISTS idx_templates_type ON templates(type)') - db.run('CREATE INDEX IF NOT EXISTS idx_templates_template_type ON templates(template_type)') - db.run('CREATE INDEX IF NOT EXISTS idx_templates_favorite ON templates(is_favorite)') - db.run('CREATE INDEX IF NOT EXISTS idx_templates_created ON templates(created_at DESC)') - db.run('CREATE INDEX IF NOT EXISTS idx_templates_use_count ON templates(use_count DESC)') - - db.run('INSERT INTO schema_version (version) VALUES (2)') - } - } - ] + )`); + + db.run( + "CREATE INDEX IF NOT EXISTS idx_templates_type ON templates(type)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_templates_template_type ON templates(template_type)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_templates_favorite ON templates(is_favorite)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_templates_created ON templates(created_at DESC)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_templates_use_count ON templates(use_count DESC)", + ); + + db.run("INSERT INTO schema_version (version) VALUES (2)"); + }, + }, + ]; for (const m of migrations) { if (m.version > currentVersion) { - m.apply(db) + m.apply(db); } } } diff --git a/electron/workflow/db/sql-js.d.ts b/electron/workflow/db/sql-js.d.ts index 22db0d48..ac470099 100644 --- a/electron/workflow/db/sql-js.d.ts +++ b/electron/workflow/db/sql-js.d.ts @@ -2,22 +2,24 @@ * Type declarations for sql.js (WASM SQLite). * sql.js doesn't ship its own .d.ts, so we provide a minimal shim. */ -declare module 'sql.js' { +declare module "sql.js" { export interface QueryExecResult { - columns: string[] - values: unknown[][] + columns: string[]; + values: unknown[][]; } export interface Database { - run(sql: string, params?: unknown[]): Database - exec(sql: string, params?: unknown[]): QueryExecResult[] - export(): Uint8Array - close(): void + run(sql: string, params?: unknown[]): Database; + exec(sql: string, params?: unknown[]): QueryExecResult[]; + export(): Uint8Array; + close(): void; } export interface SqlJsStatic { - Database: new (data?: ArrayLike | Buffer | null) => Database + Database: new (data?: ArrayLike | Buffer | null) => Database; } - export default function initSqlJs(config?: Record): Promise + export default function initSqlJs( + config?: Record, + ): Promise; } diff --git a/electron/workflow/db/template.repo.ts b/electron/workflow/db/template.repo.ts index ec17a601..694a4637 100644 --- a/electron/workflow/db/template.repo.ts +++ b/electron/workflow/db/template.repo.ts @@ -1,23 +1,29 @@ /** * Template repository — CRUD operations for templates table. */ -import { v4 as uuid } from 'uuid' -import { getDatabase, persistDatabase } from './connection' -import type { Template, TemplateFilter, CreateTemplateInput } from '../../../src/types/template' +import { v4 as uuid } from "uuid"; +import { getDatabase, persistDatabase } from "./connection"; +import type { + Template, + TemplateFilter, + CreateTemplateInput, +} from "../../../src/types/template"; export function createTemplate(input: CreateTemplateInput): Template { - const db = getDatabase() - const id = uuid() - const now = new Date().toISOString() - - const tags = input.tags ? JSON.stringify(input.tags) : null - const playgroundData = input.templateType === 'playground' && input.playgroundData - ? JSON.stringify(input.playgroundData) - : null - const workflowData = input.templateType === 'workflow' && input.workflowData - ? JSON.stringify(input.workflowData) - : null - + const db = getDatabase(); + const id = uuid(); + const now = new Date().toISOString(); + + const tags = input.tags ? JSON.stringify(input.tags) : null; + const playgroundData = + input.templateType === "playground" && input.playgroundData + ? JSON.stringify(input.playgroundData) + : null; + const workflowData = + input.templateType === "workflow" && input.workflowData + ? JSON.stringify(input.workflowData) + : null; + db.run( `INSERT INTO templates ( id, name, description, tags, type, template_type, is_favorite, @@ -25,161 +31,179 @@ export function createTemplate(input: CreateTemplateInput): Template { playground_data, workflow_data ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ - id, input.name, input.description || null, tags, input.type, - input.templateType, 0, now, now, input.author || null, 0, - input.thumbnail || null, playgroundData, workflowData - ] - ) - - persistDatabase() - return getTemplateById(id)! + id, + input.name, + input.description || null, + tags, + input.type, + input.templateType, + 0, + now, + now, + input.author || null, + 0, + input.thumbnail || null, + playgroundData, + workflowData, + ], + ); + + persistDatabase(); + return getTemplateById(id)!; } export function getTemplateById(id: string): Template | null { - const db = getDatabase() + const db = getDatabase(); const result = db.exec( `SELECT id, name, description, tags, type, template_type, is_favorite, created_at, updated_at, author, use_count, thumbnail, playground_data, workflow_data FROM templates WHERE id = ?`, - [id] - ) - - if (!result.length || !result[0].values.length) return null - return rowToTemplate(result[0].values[0]) + [id], + ); + + if (!result.length || !result[0].values.length) return null; + return rowToTemplate(result[0].values[0]); } export function queryTemplates(filter?: TemplateFilter): Template[] { - const db = getDatabase() - const conditions: string[] = [] - const params: any[] = [] - + const db = getDatabase(); + const conditions: string[] = []; + const params: any[] = []; + if (filter?.templateType) { - conditions.push('template_type = ?') - params.push(filter.templateType) + conditions.push("template_type = ?"); + params.push(filter.templateType); } - + if (filter?.type) { - conditions.push('type = ?') - params.push(filter.type) + conditions.push("type = ?"); + params.push(filter.type); } - + if (filter?.isFavorite !== undefined) { - conditions.push('is_favorite = ?') - params.push(filter.isFavorite ? 1 : 0) + conditions.push("is_favorite = ?"); + params.push(filter.isFavorite ? 1 : 0); } - - if (filter?.category && filter.templateType === 'workflow') { - conditions.push("json_extract(workflow_data, '$.category') = ?") - params.push(filter.category) + + if (filter?.category && filter.templateType === "workflow") { + conditions.push("json_extract(workflow_data, '$.category') = ?"); + params.push(filter.category); } - + if (filter?.search) { - const searchPattern = `%${filter.search}%` - conditions.push('(name LIKE ? OR description LIKE ? OR tags LIKE ?)') - params.push(searchPattern, searchPattern, searchPattern) + const searchPattern = `%${filter.search}%`; + conditions.push("(name LIKE ? OR description LIKE ? OR tags LIKE ?)"); + params.push(searchPattern, searchPattern, searchPattern); } - - const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' - const orderBy = filter?.sortBy === 'useCount' - ? 'ORDER BY use_count DESC, updated_at DESC' - : 'ORDER BY updated_at DESC' - + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const orderBy = + filter?.sortBy === "useCount" + ? "ORDER BY use_count DESC, updated_at DESC" + : "ORDER BY updated_at DESC"; + const query = `SELECT id, name, description, tags, type, template_type, is_favorite, created_at, updated_at, author, use_count, thumbnail, playground_data, workflow_data - FROM templates ${whereClause} ${orderBy}` - const result = db.exec(query, params) - - if (!result.length) return [] - return result[0].values.map(rowToTemplate) + FROM templates ${whereClause} ${orderBy}`; + const result = db.exec(query, params); + + if (!result.length) return []; + return result[0].values.map(rowToTemplate); } export function updateTemplate(id: string, updates: Partial