mirror of
https://github.com/open-webui/desktop.git
synced 2026-07-01 20:54:03 -04:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0931b46d66 | |||
| 601137c5de | |||
| a097a83c46 | |||
| f8c20275cd | |||
| fa64bc02b5 | |||
| 20f0aaf40c | |||
| c64e946b38 | |||
| 1902f791cb | |||
| c2f128aec0 | |||
| 64c399738e | |||
| 842c1481f6 | |||
| e7eef2da86 | |||
| aab2a687cc | |||
| 9e204e78b7 | |||
| ef53d6fb21 | |||
| 21e8a36e0d | |||
| fcb32f93ab | |||
| 48be8d0386 | |||
| 4cab91de4e | |||
| 564a89baa8 | |||
| 25b9e195c2 | |||
| 61db9dc10f | |||
| 27a3075c3a | |||
| 8c990befbe | |||
| da84e49970 | |||
| e0af7f3d32 | |||
| 4f653f5fcd | |||
| 3a76f985ab | |||
| 06808cb284 | |||
| ed26423f90 | |||
| 5e95e918c7 | |||
| 84c93aaeb6 | |||
| 37f0891840 |
+151
-21
@@ -42,8 +42,12 @@ jobs:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
arch: x64
|
||||
- os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
- os: windows-latest
|
||||
arch: x64
|
||||
- os: windows-11-arm
|
||||
arch: arm64
|
||||
- os: macos-latest
|
||||
arch: x64
|
||||
- os: macos-latest
|
||||
@@ -67,9 +71,9 @@ jobs:
|
||||
name: compiled-output
|
||||
path: out/
|
||||
|
||||
# ── Flatpak setup (Linux only) ──
|
||||
# ── Flatpak setup (Linux x64 only) ──
|
||||
- name: Cache Flatpak SDKs
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: runner.os == 'Linux' && matrix.arch == 'x64'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.local/share/flatpak
|
||||
@@ -77,7 +81,7 @@ jobs:
|
||||
|
||||
- name: Install Flatpak build tools
|
||||
id: flatpak
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: runner.os == 'Linux' && matrix.arch == 'x64'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -113,12 +117,12 @@ jobs:
|
||||
# ── Platform packaging ──
|
||||
- name: Package for Windows
|
||||
id: win_build
|
||||
if: matrix.os == 'windows-latest'
|
||||
if: runner.os == 'Windows'
|
||||
continue-on-error: true
|
||||
run: npx electron-builder --win --${{ matrix.arch }} --publish never
|
||||
|
||||
- name: Package for Windows (unsigned fallback)
|
||||
if: matrix.os == 'windows-latest' && steps.win_build.outcome == 'failure'
|
||||
if: runner.os == 'Windows' && steps.win_build.outcome == 'failure'
|
||||
env:
|
||||
WIN_CSC_LINK: ''
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
@@ -147,9 +151,12 @@ jobs:
|
||||
npx electron-builder --mac --${{ matrix.arch }} --publish never
|
||||
|
||||
- name: Package for Linux
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
if [ "${{ steps.flatpak.outcome }}" == "success" ]; then
|
||||
if [ "${{ matrix.arch }}" == "arm64" ]; then
|
||||
# ARM64: deb + AppImage only (flatpak BaseApp not available for arm64)
|
||||
npx electron-builder --linux AppImage deb --${{ matrix.arch }} --publish never
|
||||
elif [ "${{ steps.flatpak.outcome }}" == "success" ]; then
|
||||
npx electron-builder --linux --${{ matrix.arch }} --publish never
|
||||
else
|
||||
echo "Flatpak not available, building without flatpak"
|
||||
@@ -158,7 +165,7 @@ jobs:
|
||||
|
||||
# ── Windows code signing ──
|
||||
- name: Azure Trusted Signing (Windows Only)
|
||||
if: matrix.os == 'windows-latest'
|
||||
if: runner.os == 'Windows'
|
||||
continue-on-error: true
|
||||
uses: azure/trusted-signing-action@v0.5.1
|
||||
with:
|
||||
@@ -239,6 +246,9 @@ jobs:
|
||||
pattern: '*-*'
|
||||
merge-multiple: false
|
||||
|
||||
- name: Install js-yaml for manifest merging
|
||||
run: npm install --no-save js-yaml
|
||||
|
||||
- name: Merge macOS latest-mac.yml (x64 + arm64)
|
||||
run: |
|
||||
# Each macOS arch build produces its own latest-mac.yml with only
|
||||
@@ -248,24 +258,144 @@ jobs:
|
||||
|
||||
if [ -f "$X64_YML" ] && [ -f "$ARM_YML" ]; then
|
||||
echo "Merging latest-mac.yml from both architectures"
|
||||
npm install --no-save js-yaml
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
const x64 = yaml.load(fs.readFileSync('$X64_YML', 'utf8'));
|
||||
const arm = yaml.load(fs.readFileSync('$ARM_YML', 'utf8'));
|
||||
x64.files = [...(x64.files || []), ...(arm.files || [])];
|
||||
const all = [...(x64.files || []), ...(arm.files || [])];
|
||||
const map = new Map();
|
||||
for (const f of all) map.set(f.url, f);
|
||||
x64.files = [...map.values()];
|
||||
const out = yaml.dump(x64, { lineWidth: -1 });
|
||||
fs.writeFileSync('$X64_YML', out);
|
||||
console.log(out);
|
||||
"
|
||||
# Remove duplicate so only one latest-mac.yml is uploaded
|
||||
rm -f "$ARM_YML"
|
||||
else
|
||||
echo "Skipping merge — need both $X64_YML and $ARM_YML"
|
||||
ls -la macos-latest-*/latest-mac.yml 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Merge Linux latest-linux.yml (x64 + arm64)
|
||||
run: |
|
||||
X64_YML="ubuntu-latest-x64/latest-linux.yml"
|
||||
# electron-builder names the arm64 Linux manifest "latest-linux-arm64.yml"
|
||||
ARM_YML="ubuntu-24.04-arm-arm64/latest-linux-arm64.yml"
|
||||
|
||||
if [ -f "$X64_YML" ] && [ -f "$ARM_YML" ]; then
|
||||
echo "Merging latest-linux.yml from both architectures"
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
const x64 = yaml.load(fs.readFileSync('$X64_YML', 'utf8'));
|
||||
const arm = yaml.load(fs.readFileSync('$ARM_YML', 'utf8'));
|
||||
const all = [...(x64.files || []), ...(arm.files || [])];
|
||||
const map = new Map();
|
||||
for (const f of all) map.set(f.url, f);
|
||||
x64.files = [...map.values()];
|
||||
const out = yaml.dump(x64, { lineWidth: -1 });
|
||||
fs.writeFileSync('$X64_YML', out);
|
||||
console.log(out);
|
||||
"
|
||||
rm -f "$ARM_YML"
|
||||
else
|
||||
echo "Skipping merge — need both $X64_YML and $ARM_YML"
|
||||
ls -la ubuntu-*-*/latest-linux*.yml 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Merge Windows latest.yml (x64 + arm64)
|
||||
run: |
|
||||
X64_YML="windows-latest-x64/latest.yml"
|
||||
ARM_YML="windows-11-arm-arm64/latest.yml"
|
||||
|
||||
if [ -f "$X64_YML" ] && [ -f "$ARM_YML" ]; then
|
||||
echo "Merging latest.yml from both architectures"
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
const x64 = yaml.load(fs.readFileSync('$X64_YML', 'utf8'));
|
||||
const arm = yaml.load(fs.readFileSync('$ARM_YML', 'utf8'));
|
||||
const all = [...(x64.files || []), ...(arm.files || [])];
|
||||
const map = new Map();
|
||||
for (const f of all) map.set(f.url, f);
|
||||
x64.files = [...map.values()];
|
||||
const out = yaml.dump(x64, { lineWidth: -1 });
|
||||
fs.writeFileSync('$X64_YML', out);
|
||||
console.log(out);
|
||||
"
|
||||
rm -f "$ARM_YML"
|
||||
else
|
||||
echo "Skipping merge — need both $X64_YML and $ARM_YML"
|
||||
ls -la windows-*-*/latest.yml 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Reconcile update manifest hashes
|
||||
run: |
|
||||
# Recompute SHA512 from actual artifact files to ensure manifests
|
||||
# match the real binaries (fixes signed/unsigned hash mismatches)
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const yaml = require('js-yaml');
|
||||
const path = require('path');
|
||||
|
||||
const manifests = [
|
||||
{ prefix: 'macos-latest-', file: 'latest-mac.yml' },
|
||||
{ prefix: 'ubuntu-', file: 'latest-linux.yml' },
|
||||
{ prefix: 'windows-', file: 'latest.yml' },
|
||||
];
|
||||
|
||||
const allDirs = fs.readdirSync('.').filter(d => {
|
||||
try { return fs.statSync(d).isDirectory(); } catch { return false; }
|
||||
});
|
||||
|
||||
for (const { prefix, file } of manifests) {
|
||||
const dirs = allDirs.filter(d => d.startsWith(prefix));
|
||||
let ymlPath = null;
|
||||
for (const dir of dirs) {
|
||||
const p = path.join(dir, file);
|
||||
if (fs.existsSync(p)) { ymlPath = p; break; }
|
||||
}
|
||||
if (!ymlPath) { console.log('No ' + file + ' found, skipping'); continue; }
|
||||
|
||||
const manifest = yaml.load(fs.readFileSync(ymlPath, 'utf8'));
|
||||
let fixed = 0;
|
||||
|
||||
for (const entry of manifest.files) {
|
||||
for (const dir of dirs) {
|
||||
const filePath = path.join(dir, entry.url);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const buf = fs.readFileSync(filePath);
|
||||
const hash = crypto.createHash('sha512').update(buf).digest('base64');
|
||||
if (hash !== entry.sha512) {
|
||||
console.log('[' + file + '] Fix: ' + entry.url);
|
||||
entry.sha512 = hash;
|
||||
entry.size = buf.length;
|
||||
fixed++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.path) {
|
||||
for (const dir of dirs) {
|
||||
const filePath = path.join(dir, manifest.path);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const buf = fs.readFileSync(filePath);
|
||||
manifest.sha512 = crypto.createHash('sha512').update(buf).digest('base64');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out = yaml.dump(manifest, { lineWidth: -1 });
|
||||
fs.writeFileSync(ymlPath, out);
|
||||
console.log('[' + file + '] ' + (fixed ? 'Fixed ' + fixed + ' entries' : 'All hashes OK'));
|
||||
}
|
||||
"
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
env:
|
||||
@@ -277,18 +407,18 @@ jobs:
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: |
|
||||
windows-latest-*/*.exe
|
||||
windows-latest-*/*.blockmap
|
||||
windows-latest-*/latest*.yml
|
||||
windows-*-*/*.exe
|
||||
windows-*-*/*.blockmap
|
||||
windows-*-*/latest*.yml
|
||||
macos-latest-*/*.dmg
|
||||
macos-latest-*/*.zip
|
||||
macos-latest-*/*.pkg
|
||||
macos-latest-*/*.blockmap
|
||||
macos-latest-*/latest*.yml
|
||||
ubuntu-latest-*/*.deb
|
||||
ubuntu-latest-*/*.rpm
|
||||
ubuntu-latest-*/*.AppImage
|
||||
ubuntu-latest-*/*.snap
|
||||
ubuntu-latest-*/*.flatpak
|
||||
ubuntu-latest-*/*.tar.gz
|
||||
ubuntu-latest-*/latest*.yml
|
||||
ubuntu-*-*/*.deb
|
||||
ubuntu-*-*/*.rpm
|
||||
ubuntu-*-*/*.AppImage
|
||||
ubuntu-*-*/*.snap
|
||||
ubuntu-*-*/*.flatpak
|
||||
ubuntu-*-*/*.tar.gz
|
||||
ubuntu-*-*/latest*.yml
|
||||
|
||||
@@ -5,6 +5,91 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.0.20] - 2026-05-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Blank Webview on Linux.** Replaced the `--in-process-gpu` Chromium flag with SwiftShader software rendering (`--use-gl=angle --use-angle=swiftshader`). The in-process GPU flag broke `<webview>` guest compositing entirely, leaving connection views blank on all Linux configurations. SwiftShader keeps the GPU process out-of-process (required for webview compositing) while avoiding driver-level crashes (#178).
|
||||
|
||||
## [0.0.19] - 2026-05-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Spotlight Pulls to First Desktop on macOS.** Spotlight no longer switches Spaces when triggered from a non-primary desktop. The window is now visible on all workspaces, and `app.focus({ steal: true })` — which activated the entire app and caused the Space switch — has been replaced with targeted window-level focus (#179).
|
||||
- **Gray Screen When Connecting to Server on Linux.** Replaced the `--disable-gpu` Chromium flag with `--in-process-gpu`, which keeps the display compositor alive so `<webview>` guest surfaces actually paint instead of showing a gray rectangle. The previous flag fixed GPU process crashes but broke webview rendering on Debian and Ubuntu (#178).
|
||||
- **Open Terminal Fails Silently Without Python.** Open Terminal now automatically installs Python when it's missing instead of throwing an opaque error. Progress status is surfaced in the UI during installation.
|
||||
- **Corrupt Auto-Update Manifests.** Fixed the release workflow to deduplicate artifact entries during manifest merging, preventing SHA512 checksum mismatches that caused updates to fail silently.
|
||||
|
||||
## [0.0.18] - 2026-05-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Downloaded Models Not Recognized by llama.cpp.** Models downloaded from Hugging Face are now stored directly under the `models/` directory instead of a nested `models/huggingface/` subdirectory, so llama-server's model scanner discovers them without manual symlinks. Existing models in the old location are automatically migrated on startup (#177).
|
||||
|
||||
## [0.0.17] - 2026-05-03
|
||||
|
||||
### Added
|
||||
|
||||
- **Webview Context Menu.** Right-clicking inside the webview now shows a native context menu with Cut, Copy, Paste, Undo/Redo, spell-check suggestions, and "Open Link in Browser" — enabling system autofill and password manager integration on login pages (#161).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Windows OpenSSL Compatibility.** The bundled Python's directory is now prepended to `PATH` on Windows so its own OpenSSL DLLs are loaded before any conflicting system-wide installations (Git for Windows, Anaconda, Strawberry Perl, etc.), preventing the `OPENSSL_Uplink: no OPENSSL_Applink` crash on startup (#167).
|
||||
- **Links Open in Default Browser on Windows.** Added `allowpopups` to the webview so that `target="_blank"` link clicks correctly propagate to the main process handler and open in the default browser instead of being silently blocked (#165, #170).
|
||||
- **Linux System Requirements.** Documentation now specifies glibc 2.28+ as a minimum requirement for Linux installations.
|
||||
|
||||
## [0.0.16] - 2026-05-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Links Open in Default Browser.** Clicking links in chat responses now opens them in the user's default browser instead of navigating within the app or spawning a new Electron window (#165).
|
||||
|
||||
## [0.0.15] - 2026-04-28
|
||||
|
||||
### Added
|
||||
|
||||
- **ARM64 Support for Linux and Windows.** Native ARM64 builds are now produced for Linux (.deb, AppImage) and Windows (NSIS installer), enabling support for Raspberry Pi, NVIDIA DGX Spark, Snapdragon laptops, and other ARM64 devices (#140).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Grey/Blank Screen on Linux.** Disabled GPU compositing entirely on Linux to prevent shared memory allocation crashes that caused a grey or blank screen on systems with restricted `/dev/shm` or `/tmp` permissions.
|
||||
- **Spotlight Dismiss Behavior.** Pressing Escape or the toggle shortcut to dismiss Spotlight no longer erroneously brings the main application window to the foreground (#158).
|
||||
|
||||
## [0.0.14] - 2026-04-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Grey/Blank Webview on Linux.** Disabled GPU compositing on Linux to prevent silent compositor failures that produce a grey rectangle instead of rendered content on systems with problematic Intel/NVIDIA drivers or certain Wayland compositors (#119).
|
||||
- **Renderer Crash Recovery.** The main window now automatically reloads when the renderer process dies unexpectedly, preventing a permanent blank/grey screen.
|
||||
- **Webview Crash Diagnostics.** Added logging for guest webview renderer crashes to aid debugging connectivity and rendering issues.
|
||||
- **macOS Notarization.** Resolved Apple notarization failure caused by an expired Developer Program agreement, restoring signed and notarized macOS builds.
|
||||
|
||||
## [0.0.13] - 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Copy Button on Linux (GNOME/Wayland/Flatpak).** Fixed the "Copy" button in the Open WebUI interface not actually writing to the system clipboard on Linux. The webview session was missing the `clipboard-sanitized-write` permission required by Electron for `navigator.clipboard.writeText()` to work.
|
||||
|
||||
## [0.0.12] - 2026-04-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Toggleable Clipboard Auto-Paste for Spotlight.** Spotlight's automatic clipboard pasting is now optional and can be toggled in Settings, so the input bar starts empty when preferred.
|
||||
- **Persistent Window Size and Position.** The app now remembers your window dimensions, position, and maximized state across restarts, with safe fallback when a saved display is disconnected.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Linux .deb Crash.** Fixed app failing to launch on Linux with `Failed to load native module: pty.node` by enabling native module rebuilds and unpacking node-pty from the asar archive during packaging.
|
||||
- **Grey Screen on Connection Failure.** The webview now shows an error overlay with retry and open-in-browser options instead of a blank grey screen when a connection fails to load or crashes.
|
||||
- **Global Shortcuts on Wayland/Flatpak.** Global shortcuts now work on Wayland desktops via `xdg-desktop-portal`, with clear user-facing notifications when a shortcut cannot be registered.
|
||||
|
||||
## [0.0.11] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **macOS Launch Crash.** Fixed app failing to launch with "different Team IDs" error by adding the missing `disable-library-validation` entitlement to the build signing configuration.
|
||||
- **Self-Signed SSL Connections.** The app now trusts all SSL certificates, allowing connections to Open WebUI instances behind self-signed or untrusted certificates without errors.
|
||||
|
||||
## [0.0.10] - 2026-04-24
|
||||
|
||||
### Added
|
||||
|
||||
@@ -18,17 +18,20 @@ Your AI, right on your desktop. [Open WebUI](https://github.com/open-webui/open-
|
||||
|----------|-----------|
|
||||
| macOS (Apple Silicon) | [**Download .dmg**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-arm64.dmg) |
|
||||
| macOS (Intel) | [**Download .dmg**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-x64.dmg) |
|
||||
| Windows x64 | [**Download .exe**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-setup.exe) |
|
||||
| Linux (AppImage) | [**Download .AppImage**](https://github.com/open-webui/desktop/releases/latest/download/open-webui.AppImage) |
|
||||
| Linux (Debian/Ubuntu) | [**Download .deb**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.deb) |
|
||||
| Linux (Snap) | [**Download .snap**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.snap) |
|
||||
| Linux (Flatpak) | [**Download .flatpak**](https://github.com/open-webui/desktop/releases/latest/download/open-webui.flatpak) |
|
||||
| Windows x64 | [**Download .exe**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-x64-setup.exe) |
|
||||
| Windows ARM64 | [**Download .exe**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-arm64-setup.exe) |
|
||||
| Linux x64 (AppImage) | [**Download .AppImage**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_x64.AppImage) |
|
||||
| Linux x64 (Debian/Ubuntu) | [**Download .deb**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.deb) |
|
||||
| Linux x64 (Snap) | [**Download .snap**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.snap) |
|
||||
| Linux x64 (Flatpak) | [**Download .flatpak**](https://github.com/open-webui/desktop/releases/latest/download/open-webui.flatpak) |
|
||||
| Linux ARM64 (AppImage) | [**Download .AppImage**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_arm64.AppImage) |
|
||||
| Linux ARM64 (Debian/Ubuntu) | [**Download .deb**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_arm64.deb) |
|
||||
|
||||
Internet required on first launch. After that, everything works offline. [All releases →](https://github.com/open-webui/desktop/releases)
|
||||
|
||||
## How It Works
|
||||
|
||||
🖥️ **Run locally.** The app sets up Open WebUI and llama.cpp on your machine. Download models, chat offline, keep everything private. Nothing leaves your computer.
|
||||
🖥️ **Run locally.** The app runs Open WebUI on your machine. You can optionally enable the built-in llama.cpp engine to download and run models offline. Nothing leaves your computer.
|
||||
|
||||
☁️ **Connect remotely.** Point the app at any Open WebUI server. Switch between multiple connections from the sidebar.
|
||||
|
||||
@@ -38,8 +41,8 @@ Use both at the same time.
|
||||
|
||||
- ⚡ **Spotlight.** Hit `Shift+Cmd+I` (macOS) or `Shift+Ctrl+I` (Windows/Linux) to summon a floating chat bar over whatever you're doing. Drag to screenshot anything on screen.
|
||||
- 🎙️ **Voice input.** System-wide push-to-talk. Press the shortcut from any app to record, and your speech is transcribed and sent to your chat automatically.
|
||||
- 🧠 **Local inference.** Download and run models entirely on your hardware. Your data never leaves your machine.
|
||||
- 🎯 **One-click setup.** Everything installs itself. Just click "Get Started."
|
||||
- 🧠 **Local inference.** Optionally run models entirely on your hardware via the built-in llama.cpp engine. Your data never leaves your machine.
|
||||
- 🎯 **One-click setup.** Launch and connect to a server in seconds. Local models can be enabled from the settings.
|
||||
- 🔌 **Multiple connections.** Juggle servers and switch between them instantly.
|
||||
- 🔄 **Auto-updates.** New releases land in the background.
|
||||
- 📡 **Offline-ready.** No internet needed after initial setup.
|
||||
@@ -51,7 +54,7 @@ Use both at the same time.
|
||||
|--|-------------|-------------|
|
||||
| **Disk** | 5 GB+ | ~500 MB |
|
||||
| **RAM** | 16 GB+ | 4 GB |
|
||||
| **OS** | macOS 12+, Windows 10+, modern Linux | Same |
|
||||
| **OS** | macOS 12+, Windows 10+, modern Linux (glibc 2.28+) | Same |
|
||||
|
||||
> [!NOTE]
|
||||
> Local models need serious RAM (7B ≈ 8 GB, 13B ≈ 16 GB). Lighter machine? Connect to a remote server instead.
|
||||
|
||||
@@ -8,5 +8,33 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.debugger</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.inherit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.print</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -15,10 +15,11 @@ extraResources:
|
||||
to: CHANGELOG.md
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- node_modules/node-pty/**
|
||||
win:
|
||||
executableName: open-webui
|
||||
nsis:
|
||||
artifactName: ${name}-setup.${ext}
|
||||
artifactName: ${name}-${arch}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
@@ -62,7 +63,7 @@ deb:
|
||||
snap:
|
||||
artifactName: ${name}_${arch}.${ext}
|
||||
appImage:
|
||||
artifactName: ${name}.${ext}
|
||||
artifactName: ${name}_${arch}.${ext}
|
||||
flatpak:
|
||||
base: org.electronjs.Electron2.BaseApp
|
||||
baseVersion: '23.08'
|
||||
@@ -79,7 +80,8 @@ flatpak:
|
||||
- --device=dri
|
||||
- --filesystem=home
|
||||
- --talk-name=org.freedesktop.Notifications
|
||||
npmRebuild: false
|
||||
- --talk-name=org.freedesktop.portal.Desktop
|
||||
npmRebuild: true
|
||||
publish:
|
||||
provider: github
|
||||
owner: open-webui
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.20",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Open WebUI Desktop",
|
||||
"main": "./out/main/index.js",
|
||||
|
||||
+494
-180
@@ -93,6 +93,38 @@ import { existsSync, writeFileSync, unlinkSync } from 'fs'
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('no-sandbox')
|
||||
|
||||
// Work around /dev/shm access failures in AppImage and other containerised
|
||||
// environments. AppImage's FUSE mount can restrict child-process access to
|
||||
// /dev/shm even when --no-sandbox is set, causing FATAL crashes in the
|
||||
// Chromium zygote/renderer with "Unable to access(W_OK|X_OK) /dev/shm".
|
||||
// This flag tells Chromium to use /tmp for shared memory instead (#136).
|
||||
app.commandLine.appendSwitch('disable-dev-shm-usage')
|
||||
|
||||
// Use the native Wayland backend when available instead of XWayland.
|
||||
// This is required for xdg-desktop-portal features like GlobalShortcuts
|
||||
// to work (the portal is enabled by default in Chromium 134+ / Electron 33+).
|
||||
app.commandLine.appendSwitch('ozone-platform-hint', 'auto')
|
||||
|
||||
// Force software GL rendering via SwiftShader. The out-of-process GPU
|
||||
// crashes on Ubuntu 24.04+, certain Wayland compositors, and AppArmor-
|
||||
// restricted environments due to shared-memory allocation failures in
|
||||
// /dev/shm or /tmp (#119, #157).
|
||||
//
|
||||
// --disable-gpu kills the display compositor so <webview> guest surfaces
|
||||
// are never painted (#178). --in-process-gpu breaks <webview> guest
|
||||
// compositing entirely (blank webviews on all Linux).
|
||||
//
|
||||
// SwiftShader keeps the GPU process out-of-process (required for
|
||||
// <webview> compositing) while using software rendering to avoid
|
||||
// driver-level crashes.
|
||||
app.commandLine.appendSwitch('use-gl', 'angle')
|
||||
app.commandLine.appendSwitch('use-angle', 'swiftshader')
|
||||
|
||||
// Disable the GPU sandbox — the sandbox setup triggers shared-memory
|
||||
// allocation failures in /dev/shm. The browser process is already
|
||||
// un-sandboxed (--no-sandbox above).
|
||||
app.commandLine.appendSwitch('disable-gpu-sandbox')
|
||||
}
|
||||
|
||||
// ─── GPU Crash Recovery ─────────────────────────────────
|
||||
@@ -133,81 +165,113 @@ let voiceInputRecording = false
|
||||
|
||||
// ─── Global Shortcuts ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check whether the current environment supports Electron's globalShortcut
|
||||
* API. Since Chromium 134+ (Electron 33+) the GlobalShortcutsPortal
|
||||
* feature is enabled by default, which lets `globalShortcut.register()`
|
||||
* work transparently on Wayland via `xdg-desktop-portal`. Combined with
|
||||
* `--ozone-platform-hint=auto` (set above for Linux), shortcuts should
|
||||
* "just work" on most modern desktops.
|
||||
*
|
||||
* We only bail out when we can positively detect an environment where
|
||||
* neither X11 key-grabs nor the portal will succeed (e.g. an older
|
||||
* Flatpak base app that doesn't expose the portal D-Bus name).
|
||||
*/
|
||||
function isGlobalShortcutSupported(): boolean {
|
||||
if (process.platform !== 'linux') return true
|
||||
|
||||
// On Wayland the portal handles registration. On X11 the classic
|
||||
// key-grab path is used. Both should work, so we optimistically
|
||||
// return true and let tryRegisterShortcut surface per-shortcut
|
||||
// failures via notifications.
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to register a single global shortcut. Returns true on success.
|
||||
* On failure a user-facing notification is shown (unless `silent` is set).
|
||||
*/
|
||||
function tryRegisterShortcut(
|
||||
accel: string,
|
||||
label: string,
|
||||
callback: () => void,
|
||||
silent = false
|
||||
): boolean {
|
||||
try {
|
||||
const ok = globalShortcut.register(accel, callback)
|
||||
if (ok) {
|
||||
log.info(`${label} shortcut "${accel}" registered`)
|
||||
return true
|
||||
}
|
||||
log.warn(`${label} shortcut "${accel}" could not be registered (returned false)`)
|
||||
if (!silent) {
|
||||
new Notification({
|
||||
title: label,
|
||||
body: `Could not register shortcut "${accel}". It may be in use by another application.`
|
||||
}).show()
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
log.warn(`${label} shortcut "${accel}" registration threw:`, error)
|
||||
if (!silent) {
|
||||
new Notification({
|
||||
title: label,
|
||||
body: `Failed to register shortcut "${accel}". It may conflict with another application.`
|
||||
}).show()
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const registerShortcuts = (globalAccel?: string, spotlightAccel?: string, voiceInputAccel?: string, callAccel?: string): void => {
|
||||
globalShortcut.unregisterAll()
|
||||
|
||||
// On Wayland / Flatpak global shortcuts are unsupported — skip silently.
|
||||
if (!isGlobalShortcutSupported()) {
|
||||
log.info(
|
||||
'Global shortcut registration skipped — unsupported environment ' +
|
||||
`(XDG_SESSION_TYPE=${process.env['XDG_SESSION_TYPE'] ?? '(unset)'}, ` +
|
||||
`FLATPAK_ID=${process.env['FLATPAK_ID'] ?? '(unset)'})`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Global shortcut – bring main window to foreground
|
||||
if (globalAccel) {
|
||||
try {
|
||||
globalShortcut.register(globalAccel, () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
} else {
|
||||
createMainWindow()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
log.warn('Failed to register global shortcut:', globalAccel, error)
|
||||
}
|
||||
tryRegisterShortcut(globalAccel, 'Open WebUI', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
} else {
|
||||
createMainWindow()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Spotlight shortcut – toggle the spotlight input bar
|
||||
if (spotlightAccel) {
|
||||
try {
|
||||
globalShortcut.register(spotlightAccel, () => {
|
||||
const text = clipboard.readText()?.trim() || ''
|
||||
toggleSpotlight(text)
|
||||
})
|
||||
} catch (error) {
|
||||
log.warn('Failed to register spotlight shortcut:', spotlightAccel, error)
|
||||
}
|
||||
tryRegisterShortcut(spotlightAccel, 'Spotlight', () => {
|
||||
const text = CONFIG?.spotlightClipboardPaste !== false
|
||||
? (clipboard.readText()?.trim() || '')
|
||||
: ''
|
||||
toggleSpotlight(text)
|
||||
})
|
||||
}
|
||||
|
||||
// Voice input shortcut – toggle microphone recording
|
||||
if (voiceInputAccel && CONFIG?.voiceInputEnabled !== false) {
|
||||
try {
|
||||
const ok = globalShortcut.register(voiceInputAccel, () => {
|
||||
toggleVoiceInput()
|
||||
})
|
||||
log.info(`Voice input shortcut "${voiceInputAccel}" registered: ${ok}`)
|
||||
if (!ok) {
|
||||
new Notification({
|
||||
title: 'Voice Input',
|
||||
body: `Could not register shortcut "${voiceInputAccel}". It may be in use by another application.`
|
||||
}).show()
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('Failed to register voice input shortcut:', voiceInputAccel, error)
|
||||
new Notification({
|
||||
title: 'Voice Input',
|
||||
body: `Failed to register shortcut "${voiceInputAccel}". It may conflict with another application.`
|
||||
}).show()
|
||||
}
|
||||
tryRegisterShortcut(voiceInputAccel, 'Voice Input', () => {
|
||||
toggleVoiceInput()
|
||||
})
|
||||
} else {
|
||||
log.info(`Voice input shortcut skipped — accel="${voiceInputAccel}", enabled=${CONFIG?.voiceInputEnabled}`)
|
||||
}
|
||||
|
||||
// Call shortcut – open the voice/video call overlay
|
||||
if (callAccel && CONFIG?.callEnabled !== false) {
|
||||
try {
|
||||
const ok = globalShortcut.register(callAccel, () => {
|
||||
toggleCall()
|
||||
})
|
||||
log.info(`Call shortcut "${callAccel}" registered: ${ok}`)
|
||||
if (!ok) {
|
||||
new Notification({
|
||||
title: 'Call',
|
||||
body: `Could not register shortcut "${callAccel}". It may be in use by another application.`
|
||||
}).show()
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('Failed to register call shortcut:', callAccel, error)
|
||||
new Notification({
|
||||
title: 'Call',
|
||||
body: `Failed to register shortcut "${callAccel}". It may conflict with another application.`
|
||||
}).show()
|
||||
}
|
||||
tryRegisterShortcut(callAccel, 'Call', () => {
|
||||
toggleCall()
|
||||
})
|
||||
} else {
|
||||
log.info(`Call shortcut skipped — accel="${callAccel}", enabled=${CONFIG?.callEnabled}`)
|
||||
}
|
||||
@@ -242,6 +306,9 @@ function createSpotlightWindow(): BrowserWindow {
|
||||
hasShadow: false,
|
||||
show: false,
|
||||
focusable: true,
|
||||
// Ensure the window appears on whichever Space/desktop the user is
|
||||
// currently on, rather than pulling them back to the primary Space.
|
||||
visibleOnAllWorkspaces: true,
|
||||
icon: path.join(__dirname, 'assets/icon.png'),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/spotlight-preload.js'),
|
||||
@@ -267,18 +334,10 @@ function createSpotlightWindow(): BrowserWindow {
|
||||
spotlightWindow.on('blur', () => {
|
||||
if (blurArmed) {
|
||||
spotlightWindow?.hide()
|
||||
// Restore main window when spotlight dismisses
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.show()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
spotlightWindow.on('closed', () => {
|
||||
// Restore main window if spotlight is closed
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.show()
|
||||
}
|
||||
spotlightWindow = null
|
||||
})
|
||||
|
||||
@@ -286,8 +345,12 @@ function createSpotlightWindow(): BrowserWindow {
|
||||
}
|
||||
|
||||
function showAndFocusSpotlight(win: BrowserWindow, initialQuery?: string): void {
|
||||
// On macOS, avoid `app.focus({ steal: true })` — it activates the whole
|
||||
// application and causes the window manager to switch back to whichever
|
||||
// Space the app was originally launched on (#179). Instead, ensure the
|
||||
// window is visible on all workspaces and focus it directly.
|
||||
if (process.platform === 'darwin') {
|
||||
app.focus({ steal: true })
|
||||
win.setVisibleOnAllWorkspaces(true, { skipTransformProcessType: true })
|
||||
}
|
||||
|
||||
// Reposition fullscreen window to the active display
|
||||
@@ -443,8 +506,8 @@ async function toggleVoiceInput(): Promise<void> {
|
||||
|
||||
// Pre-flight: check a connection is configured
|
||||
try {
|
||||
const config = await getConfig()
|
||||
if (!config.defaultConnectionId || config.connections.length === 0) {
|
||||
const conn = await getDefaultConnection()
|
||||
if (!conn) {
|
||||
log.warn('Voice input: no connection configured')
|
||||
new Notification({
|
||||
title: 'Voice Input',
|
||||
@@ -452,15 +515,6 @@ async function toggleVoiceInput(): Promise<void> {
|
||||
}).show()
|
||||
return
|
||||
}
|
||||
const conn = config.connections.find((c) => c.id === config.defaultConnectionId)
|
||||
if (!conn) {
|
||||
log.warn('Voice input: default connection not found')
|
||||
new Notification({
|
||||
title: 'Voice Input',
|
||||
body: 'Default connection not found. Check your connection settings.'
|
||||
}).show()
|
||||
return
|
||||
}
|
||||
} catch (err: any) {
|
||||
log.warn('Voice input: config check failed:', err)
|
||||
}
|
||||
@@ -490,8 +544,8 @@ async function toggleVoiceInput(): Promise<void> {
|
||||
async function toggleCall(): Promise<void> {
|
||||
// Pre-flight: check a connection is configured
|
||||
try {
|
||||
const config = await getConfig()
|
||||
if (!config.defaultConnectionId || config.connections.length === 0) {
|
||||
const conn = await getDefaultConnection()
|
||||
if (!conn) {
|
||||
log.warn('Call: no connection configured')
|
||||
new Notification({
|
||||
title: 'Call',
|
||||
@@ -499,24 +553,8 @@ async function toggleCall(): Promise<void> {
|
||||
}).show()
|
||||
return
|
||||
}
|
||||
const conn = config.connections.find((c) => c.id === config.defaultConnectionId)
|
||||
if (!conn) {
|
||||
log.warn('Call: default connection not found')
|
||||
new Notification({
|
||||
title: 'Call',
|
||||
body: 'Default connection not found. Check your connection settings.'
|
||||
}).show()
|
||||
return
|
||||
}
|
||||
|
||||
let url = conn.url
|
||||
if (conn.type === 'local' && SERVER_URL) {
|
||||
url = SERVER_URL
|
||||
}
|
||||
if (url.startsWith('http://0.0.0.0')) {
|
||||
url = url.replace('http://0.0.0.0', 'http://localhost')
|
||||
}
|
||||
|
||||
const url = resolveConnectionUrl(conn)
|
||||
sendToRenderer('call', { connectionId: conn.id, url })
|
||||
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
@@ -530,12 +568,61 @@ async function toggleCall(): Promise<void> {
|
||||
|
||||
// ─── Windows ────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_WINDOW_WIDTH = 1280
|
||||
const DEFAULT_WINDOW_HEIGHT = 800
|
||||
const MIN_WINDOW_WIDTH = 480
|
||||
const MIN_WINDOW_HEIGHT = 360
|
||||
const BOUNDS_SAVE_DEBOUNCE_MS = 500
|
||||
const MIN_VISIBLE_OVERLAP_PX = 100
|
||||
|
||||
/** Last known non-maximized bounds, used to preserve restore geometry. */
|
||||
let lastNormalBounds: Electron.Rectangle | null = null
|
||||
|
||||
/** Debounced persistence of the current window geometry to config. */
|
||||
let boundsDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function debounceSaveWindowBounds(win: BrowserWindow): void {
|
||||
if (boundsDebounceTimer) clearTimeout(boundsDebounceTimer)
|
||||
boundsDebounceTimer = setTimeout(() => {
|
||||
if (win.isDestroyed()) return
|
||||
const maximized = win.isMaximized()
|
||||
const bounds = maximized ? (lastNormalBounds ?? win.getNormalBounds()) : win.getBounds()
|
||||
setConfig({ windowBounds: bounds, windowMaximized: maximized }).catch((err) =>
|
||||
log.warn('Failed to save window bounds:', err)
|
||||
)
|
||||
}, BOUNDS_SAVE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when at least `MIN_VISIBLE_OVERLAP_PX` of the saved
|
||||
* rectangle would be visible on one of the connected displays.
|
||||
*/
|
||||
function isBoundsOnVisibleDisplay(bounds: { x: number; y: number }): boolean {
|
||||
const { screen } = require('electron')
|
||||
const targetPoint = { x: bounds.x + MIN_VISIBLE_OVERLAP_PX / 2, y: bounds.y + MIN_VISIBLE_OVERLAP_PX / 2 }
|
||||
const display = screen.getDisplayNearestPoint(targetPoint)
|
||||
const { x, y, width, height } = display.workArea
|
||||
return (
|
||||
bounds.x + MIN_VISIBLE_OVERLAP_PX > x &&
|
||||
bounds.x < x + width &&
|
||||
bounds.y + MIN_VISIBLE_OVERLAP_PX > y &&
|
||||
bounds.y < y + height
|
||||
)
|
||||
}
|
||||
|
||||
function trackNormalBounds(win: BrowserWindow): void {
|
||||
if (!win.isDestroyed() && !win.isMaximized()) {
|
||||
lastNormalBounds = win.getBounds()
|
||||
}
|
||||
}
|
||||
|
||||
function createMainWindow(show = true): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 1280,
|
||||
minHeight: 800,
|
||||
const saved = CONFIG?.windowBounds
|
||||
const windowOpts: Electron.BrowserWindowConstructorOptions = {
|
||||
width: saved?.width ?? DEFAULT_WINDOW_WIDTH,
|
||||
height: saved?.height ?? DEFAULT_WINDOW_HEIGHT,
|
||||
minWidth: MIN_WINDOW_WIDTH,
|
||||
minHeight: MIN_WINDOW_HEIGHT,
|
||||
icon: path.join(__dirname, 'assets/icon.png'),
|
||||
show: false,
|
||||
titleBarStyle: process.platform === 'win32' ? 'default' : 'hidden',
|
||||
@@ -552,9 +639,22 @@ function createMainWindow(show = true): void {
|
||||
sandbox: false,
|
||||
webviewTag: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Restore position only when the saved location is still on a visible display
|
||||
// (e.g. an external monitor may have been disconnected since last session).
|
||||
if (saved?.x != null && saved?.y != null && isBoundsOnVisibleDisplay(saved)) {
|
||||
windowOpts.x = saved.x
|
||||
windowOpts.y = saved.y
|
||||
}
|
||||
|
||||
mainWindow = new BrowserWindow(windowOpts)
|
||||
mainWindow.setIcon(icon)
|
||||
|
||||
if (CONFIG?.windowMaximized) {
|
||||
mainWindow.maximize()
|
||||
}
|
||||
|
||||
if (!app.isPackaged) {
|
||||
mainWindow.webContents.openDevTools()
|
||||
}
|
||||
@@ -576,6 +676,17 @@ function createMainWindow(show = true): void {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
// ── Persist window bounds on geometry changes ──
|
||||
const onBoundsChanged = (): void => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
trackNormalBounds(mainWindow)
|
||||
debounceSaveWindowBounds(mainWindow)
|
||||
}
|
||||
mainWindow.on('resize', onBoundsChanged)
|
||||
mainWindow.on('move', onBoundsChanged)
|
||||
mainWindow.on('maximize', onBoundsChanged)
|
||||
mainWindow.on('unmaximize', onBoundsChanged)
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!isQuiting) {
|
||||
if (CONFIG?.runInBackground === false) {
|
||||
@@ -597,10 +708,10 @@ function createContentWindow(url: string, connectionId: string): BrowserWindow {
|
||||
}
|
||||
|
||||
contentWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 1280,
|
||||
minHeight: 800,
|
||||
width: DEFAULT_WINDOW_WIDTH,
|
||||
height: DEFAULT_WINDOW_HEIGHT,
|
||||
minWidth: MIN_WINDOW_WIDTH,
|
||||
minHeight: MIN_WINDOW_HEIGHT,
|
||||
icon: path.join(__dirname, 'assets/icon.png'),
|
||||
show: false,
|
||||
titleBarStyle: process.platform === 'win32' ? 'default' : 'hidden',
|
||||
@@ -620,7 +731,7 @@ function createContentWindow(url: string, connectionId: string): BrowserWindow {
|
||||
session
|
||||
.fromPartition(`persist:connection-${connectionId}`)
|
||||
.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||
const allowedPermissions = ['media', 'mediaKeySystem', 'notifications']
|
||||
const allowedPermissions = ['media', 'mediaKeySystem', 'notifications', 'clipboard-sanitized-write']
|
||||
callback(allowedPermissions.includes(permission))
|
||||
})
|
||||
|
||||
@@ -659,7 +770,8 @@ function createContentWindow(url: string, connectionId: string): BrowserWindow {
|
||||
const updateTray = () => {
|
||||
if (!tray || !CONFIG) return
|
||||
|
||||
const connectionItems = (CONFIG.connections || []).map((conn) => ({
|
||||
// Remote connections from config
|
||||
const remoteItems = (CONFIG.connections || []).map((conn) => ({
|
||||
label: `${conn.id === CONFIG.defaultConnectionId ? '★ ' : ''}${conn.name}`,
|
||||
sublabel: conn.url,
|
||||
click: async () => {
|
||||
@@ -668,6 +780,20 @@ const updateTray = () => {
|
||||
}
|
||||
}))
|
||||
|
||||
// Virtual local connection (when package is installed)
|
||||
const localItem = isPackageInstalled('open-webui')
|
||||
? [{
|
||||
label: `${CONFIG.defaultConnectionId === 'local' ? '★ ' : ''}Open WebUI (Local)`,
|
||||
sublabel: SERVER_URL || `http://127.0.0.1:${CONFIG.localServer?.port ?? 8080}`,
|
||||
click: async () => {
|
||||
const result = await connectTo(buildLocalConnection())
|
||||
if (result) sendToRenderer('connection:open', result)
|
||||
}
|
||||
}]
|
||||
: []
|
||||
|
||||
const allItems = [...localItem, ...remoteItems]
|
||||
|
||||
const trayMenuTemplate = [
|
||||
{
|
||||
label: 'Show Open WebUI',
|
||||
@@ -677,10 +803,10 @@ const updateTray = () => {
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
...(connectionItems.length > 0
|
||||
...(allItems.length > 0
|
||||
? [
|
||||
{ label: 'Connections', enabled: false },
|
||||
...connectionItems,
|
||||
...allItems,
|
||||
{ type: 'separator' }
|
||||
]
|
||||
: []),
|
||||
@@ -712,6 +838,38 @@ const updateTray = () => {
|
||||
|
||||
// ─── Connection Management ──────────────────────────────
|
||||
|
||||
// Build a virtual local connection object from current config.
|
||||
// The local server is never stored in the connections array — it's
|
||||
// implicit when the open-webui package is installed.
|
||||
const buildLocalConnection = (): Connection => {
|
||||
const port = CONFIG?.localServer?.port ?? 8080
|
||||
return {
|
||||
id: 'local',
|
||||
name: 'Open WebUI',
|
||||
type: 'local',
|
||||
url: SERVER_URL || `http://127.0.0.1:${port}`
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the default connection. 'local' is a virtual ID that
|
||||
// synthesises a Connection on the fly; everything else is looked
|
||||
// up in the persisted connections array (remote only).
|
||||
const getDefaultConnection = async (): Promise<Connection | null> => {
|
||||
const config = await getConfig()
|
||||
if (!config.defaultConnectionId) return null
|
||||
if (config.defaultConnectionId === 'local') return buildLocalConnection()
|
||||
return config.connections.find((c) => c.id === config.defaultConnectionId) ?? null
|
||||
}
|
||||
|
||||
// Resolve the URL for a connection, preferring the live SERVER_URL
|
||||
// for local connections and normalising 0.0.0.0 to localhost.
|
||||
const resolveConnectionUrl = (conn: Connection): string => {
|
||||
let url = conn.url
|
||||
if (conn.type === 'local' && SERVER_URL) url = SERVER_URL
|
||||
if (url.startsWith('http://0.0.0.0')) url = url.replace('http://0.0.0.0', 'http://localhost')
|
||||
return url
|
||||
}
|
||||
|
||||
const connectTo = async (connection: Connection) => {
|
||||
let url = connection.url
|
||||
|
||||
@@ -765,6 +923,27 @@ const startServerHandler = async (): Promise<boolean> => {
|
||||
|
||||
try {
|
||||
CONFIG = await getConfig()
|
||||
|
||||
// Auto-update the open-webui pip package to latest before starting.
|
||||
// Only when autoUpdate is enabled (default) and no version pin is set.
|
||||
const autoUpdate = CONFIG?.localServer?.autoUpdate !== false
|
||||
const versionPin = CONFIG?.localServer?.version
|
||||
if (autoUpdate && !versionPin && isPackageInstalled('open-webui')) {
|
||||
try {
|
||||
log.info('[server] Auto-updating open-webui package to latest…')
|
||||
sendToRenderer('status:install', 'Updating Open WebUI…')
|
||||
await installPackage('open-webui', undefined, (status: string) => {
|
||||
sendToRenderer('status:install', status)
|
||||
})
|
||||
sendToRenderer('status:install', '')
|
||||
log.info('[server] Auto-update complete')
|
||||
} catch (e) {
|
||||
// Non-fatal — start the existing version if upgrade fails
|
||||
log.warn('[server] Auto-update failed, starting existing version:', e)
|
||||
sendToRenderer('status:install', '')
|
||||
}
|
||||
}
|
||||
|
||||
const { url, pid } = await startServer(
|
||||
CONFIG?.localServer?.serveOnLocalNetwork ?? false,
|
||||
CONFIG?.localServer?.port ?? null
|
||||
@@ -1065,8 +1244,151 @@ if (!gotTheLock) {
|
||||
log.info('Running with GPU sandbox disabled (marker file present)')
|
||||
}
|
||||
|
||||
// ─── Self-Signed / Untrusted Certificate Support ─
|
||||
// Allow connections to Open WebUI instances that use self-signed or
|
||||
// otherwise untrusted SSL certificates (issue #108). The user
|
||||
// explicitly configures the server URL, so trusting all certs is
|
||||
// acceptable — this matches the behaviour of VS Code, Postman, and
|
||||
// other Electron apps used in enterprise/self-hosted environments.
|
||||
app.on('certificate-error', (event, _webContents, url, error, certificate, callback) => {
|
||||
log.warn(
|
||||
`Certificate error: ${error} for ${url} ` +
|
||||
`(subject: ${certificate.subjectName}, issuer: ${certificate.issuerName})`
|
||||
)
|
||||
event.preventDefault()
|
||||
callback(true)
|
||||
})
|
||||
|
||||
// Trust all certs on the default session (used by net.fetch() in
|
||||
// validateRemoteUrl / checkUrlAndOpen).
|
||||
session.defaultSession.setCertificateVerifyProc((_request, callback) => {
|
||||
callback(0) // 0 = verified/trusted
|
||||
})
|
||||
|
||||
// Webviews use partitioned sessions (persist:connection-*). Each
|
||||
// new partition's session also needs to trust all certs.
|
||||
app.on('session-created', (newSession) => {
|
||||
newSession.setCertificateVerifyProc((_request, callback) => {
|
||||
callback(0)
|
||||
})
|
||||
|
||||
// Grant media / notification permissions for webview partition sessions
|
||||
// so that auth flows, media capture, and notifications work correctly.
|
||||
newSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||
const allowed = ['media', 'mediaKeySystem', 'notifications', 'clipboard-read', 'clipboard-sanitized-write']
|
||||
callback(allowed.includes(permission))
|
||||
})
|
||||
})
|
||||
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
|
||||
// Auto-reload when the renderer process dies so the user doesn't
|
||||
// see a permanent blank/grey screen.
|
||||
window.webContents.on('render-process-gone', (_event, details) => {
|
||||
log.error(
|
||||
`Renderer process gone: reason=${details.reason}, exitCode=${details.exitCode}`
|
||||
)
|
||||
if (details.reason !== 'clean-exit') {
|
||||
window.webContents.reload()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Log webview guest renderer crashes for diagnostics — the existing
|
||||
// 'crashed' listener in Content.svelte surfaces these to the user.
|
||||
//
|
||||
// For webview guests we also intercept navigation and popup events
|
||||
// so that external links open in the user's default browser instead
|
||||
// of navigating the webview or spawning a new Electron window (#165).
|
||||
app.on('web-contents-created', (_event, contents) => {
|
||||
contents.on('render-process-gone', (_e, details) => {
|
||||
if (details.reason !== 'clean-exit') {
|
||||
log.error(
|
||||
`WebContents render-process-gone: type=${contents.getType()}, ` +
|
||||
`reason=${details.reason}, exitCode=${details.exitCode}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if (contents.getType() === 'webview') {
|
||||
// ── Popups (target="_blank" links) → open in default browser ──
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
openUrl(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// ── In-page navigation to a different origin → open externally ──
|
||||
// This catches regular link clicks (no target) that would navigate
|
||||
// the webview away from the Open WebUI instance.
|
||||
contents.on('will-navigate', (event, url) => {
|
||||
try {
|
||||
const currentOrigin = new URL(contents.getURL()).origin
|
||||
const targetOrigin = new URL(url).origin
|
||||
if (targetOrigin !== currentOrigin) {
|
||||
event.preventDefault()
|
||||
openUrl(url)
|
||||
}
|
||||
} catch {
|
||||
// Malformed URL — let it through so Chromium can handle/reject it
|
||||
}
|
||||
})
|
||||
|
||||
// ── Native right-click context menu (#161) ──────────────────
|
||||
// Electron <webview> guests don't show a context menu by default,
|
||||
// which blocks right-click → Paste / Autofill / password-manager
|
||||
// integration on login pages. Build a native menu with standard
|
||||
// editing actions, spell-check suggestions, and link handling.
|
||||
contents.on('context-menu', (_event, params) => {
|
||||
const menuItems: Electron.MenuItemConstructorOptions[] = []
|
||||
|
||||
// Spell-check suggestions (if any)
|
||||
if (params.misspelledWord && params.dictionarySuggestions?.length) {
|
||||
for (const suggestion of params.dictionarySuggestions) {
|
||||
menuItems.push({
|
||||
label: suggestion,
|
||||
click: () => contents.replaceMisspelling(suggestion)
|
||||
})
|
||||
}
|
||||
menuItems.push({ type: 'separator' })
|
||||
}
|
||||
|
||||
// Link handling
|
||||
if (params.linkURL) {
|
||||
menuItems.push({
|
||||
label: 'Open Link in Browser',
|
||||
click: () => openUrl(params.linkURL)
|
||||
})
|
||||
menuItems.push({
|
||||
label: 'Copy Link',
|
||||
click: () => clipboard.writeText(params.linkURL)
|
||||
})
|
||||
menuItems.push({ type: 'separator' })
|
||||
}
|
||||
|
||||
// Editable field actions (input, textarea, contenteditable)
|
||||
if (params.isEditable) {
|
||||
menuItems.push(
|
||||
{ label: 'Undo', role: 'undo', enabled: params.editFlags.canUndo },
|
||||
{ label: 'Redo', role: 'redo', enabled: params.editFlags.canRedo },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Cut', role: 'cut', enabled: params.editFlags.canCut },
|
||||
{ label: 'Copy', role: 'copy', enabled: params.editFlags.canCopy },
|
||||
{ label: 'Paste', role: 'paste', enabled: params.editFlags.canPaste },
|
||||
{ label: 'Select All', role: 'selectAll', enabled: params.editFlags.canSelectAll }
|
||||
)
|
||||
} else if (params.selectionText) {
|
||||
// Non-editable text selection
|
||||
menuItems.push(
|
||||
{ label: 'Copy', role: 'copy', enabled: params.editFlags.canCopy }
|
||||
)
|
||||
}
|
||||
|
||||
if (menuItems.length > 0) {
|
||||
Menu.buildFromTemplate(menuItems).popup()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ─── IPC Handlers ─────────────────────────────────
|
||||
@@ -1150,6 +1472,18 @@ if (!gotTheLock) {
|
||||
log.warn('open-terminal install failed (non-fatal):', e)
|
||||
)
|
||||
sendToRenderer('status:package', true)
|
||||
// Notify renderer of install state change
|
||||
sendToRenderer('packages:changed', {
|
||||
'open-webui': isPackageInstalled('open-webui')
|
||||
})
|
||||
// Auto-set local as default if no default configured
|
||||
const cfg = await getConfig()
|
||||
if (!cfg.defaultConnectionId) {
|
||||
cfg.defaultConnectionId = 'local'
|
||||
await setConfig(cfg)
|
||||
CONFIG = cfg
|
||||
}
|
||||
updateTray()
|
||||
return true
|
||||
} catch (error) {
|
||||
sendToRenderer('status:package', false)
|
||||
@@ -1180,7 +1514,7 @@ if (!gotTheLock) {
|
||||
reachable: SERVER_REACHABLE
|
||||
}))
|
||||
|
||||
// Connections
|
||||
// Connections (remote only — local is virtual)
|
||||
ipcMain.handle('connections:list', async () => {
|
||||
const config = await getConfig()
|
||||
return config.connections
|
||||
@@ -1195,6 +1529,7 @@ if (!gotTheLock) {
|
||||
await setConfig(config)
|
||||
CONFIG = config
|
||||
updateTray()
|
||||
sendToRenderer('connections:changed', config.connections)
|
||||
return config.connections
|
||||
})
|
||||
|
||||
@@ -1202,11 +1537,12 @@ if (!gotTheLock) {
|
||||
const config = await getConfig()
|
||||
config.connections = config.connections.filter((c) => c.id !== id)
|
||||
if (config.defaultConnectionId === id) {
|
||||
config.defaultConnectionId = config.connections[0]?.id || null
|
||||
config.defaultConnectionId = config.connections[0]?.id || 'local'
|
||||
}
|
||||
await setConfig(config)
|
||||
CONFIG = config
|
||||
updateTray()
|
||||
sendToRenderer('connections:changed', config.connections)
|
||||
return config.connections
|
||||
})
|
||||
|
||||
@@ -1218,6 +1554,7 @@ if (!gotTheLock) {
|
||||
await setConfig(config)
|
||||
CONFIG = config
|
||||
updateTray()
|
||||
sendToRenderer('connections:changed', config.connections)
|
||||
}
|
||||
return config.connections
|
||||
})
|
||||
@@ -1231,6 +1568,10 @@ if (!gotTheLock) {
|
||||
})
|
||||
|
||||
ipcMain.handle('connections:connect', async (_event, id: string) => {
|
||||
// 'local' is a virtual connection — synthesize it
|
||||
if (id === 'local') {
|
||||
return await connectTo(buildLocalConnection())
|
||||
}
|
||||
const config = await getConfig()
|
||||
const conn = config.connections.find((c) => c.id === id)
|
||||
if (conn) {
|
||||
@@ -1271,26 +1612,14 @@ if (!gotTheLock) {
|
||||
|
||||
// Spotlight
|
||||
ipcMain.handle('spotlight:submit', async (_event, query: string, images?: string[]) => {
|
||||
const config = await getConfig()
|
||||
if (!config.defaultConnectionId || config.connections.length === 0) {
|
||||
mainWindow?.show()
|
||||
mainWindow?.focus()
|
||||
return
|
||||
}
|
||||
const conn = config.connections.find((c) => c.id === config.defaultConnectionId)
|
||||
const conn = await getDefaultConnection()
|
||||
if (!conn) {
|
||||
mainWindow?.show()
|
||||
mainWindow?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
let url = conn.url
|
||||
if (conn.type === 'local' && SERVER_URL) {
|
||||
url = SERVER_URL
|
||||
}
|
||||
if (url.startsWith('http://0.0.0.0')) {
|
||||
url = url.replace('http://0.0.0.0', 'http://localhost')
|
||||
}
|
||||
const url = resolveConnectionUrl(conn)
|
||||
|
||||
// Build files payload from screenshot images
|
||||
const files = images?.map((dataUrl, i) => ({
|
||||
@@ -1301,9 +1630,8 @@ if (!gotTheLock) {
|
||||
|
||||
sendToRenderer('query', { query, connectionId: conn.id, url, files })
|
||||
|
||||
// Hide spotlight first (blur handler will restore main window)
|
||||
spotlightWindow?.hide()
|
||||
// Ensure main window is focused to receive the query
|
||||
// Show main window so it can receive and display the submitted query
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
@@ -1311,7 +1639,6 @@ if (!gotTheLock) {
|
||||
})
|
||||
ipcMain.handle('spotlight:close', () => {
|
||||
spotlightWindow?.hide()
|
||||
// blur handler restores main window
|
||||
})
|
||||
|
||||
// Persist bar offset within the fullscreen spotlight window
|
||||
@@ -1419,20 +1746,10 @@ if (!gotTheLock) {
|
||||
// Transcribe audio via the connected server's STT endpoint
|
||||
ipcMain.handle('voiceInput:transcribe', async (_event, audioBuffer: ArrayBuffer, rendererToken?: string) => {
|
||||
try {
|
||||
const config = await getConfig()
|
||||
if (!config.defaultConnectionId || config.connections.length === 0) {
|
||||
throw new Error('No connection configured. Set up a connection in Settings first.')
|
||||
}
|
||||
const conn = config.connections.find((c) => c.id === config.defaultConnectionId)
|
||||
if (!conn) throw new Error('Default connection not found. Check your connection settings.')
|
||||
const conn = await getDefaultConnection()
|
||||
if (!conn) throw new Error('No connection configured. Set up a connection in Settings first.')
|
||||
|
||||
let url = conn.url
|
||||
if (conn.type === 'local' && SERVER_URL) {
|
||||
url = SERVER_URL
|
||||
}
|
||||
if (url.startsWith('http://0.0.0.0')) {
|
||||
url = url.replace('http://0.0.0.0', 'http://localhost')
|
||||
}
|
||||
const url = resolveConnectionUrl(conn)
|
||||
|
||||
// Use stored auth token (relayed from webview), fall back to renderer-provided or contentWindow
|
||||
let token = AUTH_TOKEN || rendererToken || ''
|
||||
@@ -1517,27 +1834,14 @@ if (!gotTheLock) {
|
||||
if (!text?.trim()) return
|
||||
|
||||
// Deliver text through the same path as Spotlight
|
||||
const config = await getConfig()
|
||||
if (!config.defaultConnectionId || config.connections.length === 0) {
|
||||
mainWindow?.show()
|
||||
mainWindow?.focus()
|
||||
return
|
||||
}
|
||||
const conn = config.connections.find((c) => c.id === config.defaultConnectionId)
|
||||
const conn = await getDefaultConnection()
|
||||
if (!conn) {
|
||||
mainWindow?.show()
|
||||
mainWindow?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
let url = conn.url
|
||||
if (conn.type === 'local' && SERVER_URL) {
|
||||
url = SERVER_URL
|
||||
}
|
||||
if (url.startsWith('http://0.0.0.0')) {
|
||||
url = url.replace('http://0.0.0.0', 'http://localhost')
|
||||
}
|
||||
|
||||
const url = resolveConnectionUrl(conn)
|
||||
sendToRenderer('query', { query: text.trim(), connectionId: conn.id, url })
|
||||
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
@@ -1569,7 +1873,9 @@ if (!gotTheLock) {
|
||||
ipcMain.handle('open-terminal:start', async () => {
|
||||
try {
|
||||
sendToRenderer('status:open-terminal', 'starting')
|
||||
const result = await startOpenTerminal(CONFIG?.openTerminal?.port ?? null)
|
||||
const result = await startOpenTerminal(CONFIG?.openTerminal?.port ?? null, (status) => {
|
||||
sendToRenderer('status:open-terminal-setup', status)
|
||||
})
|
||||
sendToRenderer('status:open-terminal', 'started')
|
||||
sendToRenderer('open-terminal:ready', result)
|
||||
// Notify webview to register terminal server at system level
|
||||
@@ -1578,9 +1884,6 @@ if (!gotTheLock) {
|
||||
url: result.url,
|
||||
key: result.apiKey
|
||||
})
|
||||
// Save enabled state
|
||||
await setConfig({ openTerminal: { ...CONFIG?.openTerminal, enabled: true } })
|
||||
CONFIG = await getConfig()
|
||||
return result
|
||||
} catch (error) {
|
||||
log.error('Failed to start Open Terminal:', error)
|
||||
@@ -1602,8 +1905,7 @@ if (!gotTheLock) {
|
||||
url: info.url
|
||||
})
|
||||
}
|
||||
await setConfig({ openTerminal: { ...CONFIG?.openTerminal, enabled: false } })
|
||||
CONFIG = await getConfig()
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
log.error('Failed to stop Open Terminal:', error)
|
||||
@@ -1644,13 +1946,13 @@ if (!gotTheLock) {
|
||||
if (result.url) {
|
||||
sendToRenderer('connections:openai', {
|
||||
action: 'add',
|
||||
url: `${result.url}/v1`
|
||||
url: `${result.url}/v1`,
|
||||
config: { provider: 'llama.cpp', connection_type: 'local' }
|
||||
})
|
||||
// Refresh model list after backend registers the endpoint
|
||||
setTimeout(() => sendToRenderer('models:refresh'), 1000)
|
||||
}
|
||||
await setConfig({ llamaCpp: { ...CONFIG?.llamaCpp, enabled: true } })
|
||||
CONFIG = await getConfig()
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
log.error('Failed to start llamacpp:', error)
|
||||
@@ -1674,8 +1976,7 @@ if (!gotTheLock) {
|
||||
// Refresh model list after removing endpoint
|
||||
setTimeout(() => sendToRenderer('models:refresh'), 500)
|
||||
}
|
||||
await setConfig({ llamaCpp: { ...CONFIG?.llamaCpp, enabled: false } })
|
||||
CONFIG = await getConfig()
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
log.error('Failed to stop llamacpp:', error)
|
||||
@@ -1749,7 +2050,13 @@ if (!gotTheLock) {
|
||||
|
||||
ipcMain.handle('package:version', (_event, packageName: string) => getPackageVersion(packageName))
|
||||
ipcMain.handle('package:uninstall', async (_event, packageName: string) => {
|
||||
return uninstallPackage(packageName)
|
||||
const result = uninstallPackage(packageName)
|
||||
// Notify renderer of install state change
|
||||
sendToRenderer('packages:changed', {
|
||||
'open-webui': isPackageInstalled('open-webui')
|
||||
})
|
||||
updateTray()
|
||||
return result
|
||||
})
|
||||
|
||||
ipcMain.handle('dialog:selectFolder', async () => {
|
||||
@@ -1840,7 +2147,9 @@ if (!gotTheLock) {
|
||||
if (CONFIG?.openTerminal?.enabled) {
|
||||
try {
|
||||
sendToRenderer('status:open-terminal', 'starting')
|
||||
const result = await startOpenTerminal(CONFIG?.openTerminal?.port ?? null)
|
||||
const result = await startOpenTerminal(CONFIG?.openTerminal?.port ?? null, (status) => {
|
||||
sendToRenderer('status:open-terminal-setup', status)
|
||||
})
|
||||
sendToRenderer('status:open-terminal', 'started')
|
||||
sendToRenderer('open-terminal:ready', result)
|
||||
} catch (error) {
|
||||
@@ -1864,18 +2173,23 @@ if (!gotTheLock) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already configured, auto-connect to default
|
||||
if (CONFIG.defaultConnectionId && CONFIG.connections.length > 0) {
|
||||
const defaultConn = CONFIG.connections.find(
|
||||
(c) => c.id === CONFIG.defaultConnectionId
|
||||
)
|
||||
if (defaultConn) {
|
||||
createMainWindow()
|
||||
const result = await connectTo(defaultConn)
|
||||
if (result) sendToRenderer('connection:open', result)
|
||||
} else {
|
||||
createMainWindow()
|
||||
// Migrate legacy local connection entries out of the connections array
|
||||
if (CONFIG.connections.some((c) => c.type === 'local')) {
|
||||
CONFIG.connections = CONFIG.connections.filter((c) => c.type !== 'local')
|
||||
// Preserve 'local' as default if it was the default
|
||||
if (!CONFIG.defaultConnectionId || CONFIG.defaultConnectionId === 'local') {
|
||||
CONFIG.defaultConnectionId = 'local'
|
||||
}
|
||||
await setConfig(CONFIG)
|
||||
log.info('Migrated legacy local connection entry from connections array')
|
||||
}
|
||||
|
||||
// Check if already configured, auto-connect to default
|
||||
const defaultConn = await getDefaultConnection()
|
||||
if (defaultConn) {
|
||||
createMainWindow()
|
||||
const result = await connectTo(defaultConn)
|
||||
if (result) sendToRenderer('connection:open', result)
|
||||
} else {
|
||||
createMainWindow()
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Downloads files from HF repos, manages a local model cache,
|
||||
* and provides listing/deletion of cached models.
|
||||
*
|
||||
* Cache dir: <userData>/models/huggingface/<repo-slug>/<filename>
|
||||
* Cache dir: <userData>/models/<repo-slug>/<filename>
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
@@ -33,10 +33,37 @@ export interface HfDownloadProgress {
|
||||
// ─── Paths ──────────────────────────────────────────────
|
||||
|
||||
const getHfCacheDir = (): string => {
|
||||
const dir = path.join(getInstallDir(), 'models', 'huggingface')
|
||||
const dir = path.join(getInstallDir(), 'models')
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
// Migrate models from legacy models/huggingface/<slug>/ to models/<slug>/
|
||||
const legacyDir = path.join(dir, 'huggingface')
|
||||
if (fs.existsSync(legacyDir)) {
|
||||
try {
|
||||
const entries = fs.readdirSync(legacyDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const src = path.join(legacyDir, entry.name)
|
||||
const dest = path.join(dir, entry.name)
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.renameSync(src, dest)
|
||||
log.info(`[huggingface] Migrated ${entry.name} from legacy cache`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove legacy dir if empty (manifest.json may remain)
|
||||
const remaining = fs.readdirSync(legacyDir)
|
||||
if (remaining.length === 0) {
|
||||
fs.rmdirSync(legacyDir)
|
||||
log.info('[huggingface] Removed empty legacy huggingface/ directory')
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('[huggingface] Failed to migrate legacy cache:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
|
||||
+52
-37
@@ -8,7 +8,7 @@ import crypto from 'crypto'
|
||||
|
||||
import * as tar from 'tar'
|
||||
|
||||
import { app, shell, Notification } from 'electron'
|
||||
import { app, shell, Notification, net as electronNet } from 'electron'
|
||||
import { execFileSync, exec, spawn, execSync, execFile } from 'child_process'
|
||||
|
||||
import log from 'electron-log'
|
||||
@@ -318,10 +318,7 @@ export const installPython = async (installationDir?: string, onStatus?: (status
|
||||
['-m', 'pip', 'install', 'uv'],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
|
||||
}
|
||||
env: pythonEnv()
|
||||
},
|
||||
(error) => {
|
||||
if (error) reject(error)
|
||||
@@ -350,6 +347,38 @@ export const getPythonPath = (installationDir?: string) => {
|
||||
return path.normalize(getPythonExecutablePath(installationDir || getPythonInstallationDir()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a process environment suitable for running the bundled Python.
|
||||
*
|
||||
* On Windows the standalone Python distribution ships its own OpenSSL DLLs
|
||||
* (`libssl-3-x64.dll`, `libcrypto-3-x64.dll`) next to `python.exe`. If a
|
||||
* different OpenSSL installation (Git for Windows, Anaconda, Strawberry Perl,
|
||||
* etc.) appears earlier on the system `PATH`, Python picks up those mismatched
|
||||
* DLLs at load-time, which causes the fatal error:
|
||||
*
|
||||
* OPENSSL_Uplink(..., 08): no OPENSSL_Applink
|
||||
*
|
||||
* To prevent this we prepend the Python installation directory to `PATH` so
|
||||
* Windows finds the correct DLLs first. On non-Windows platforms this is a
|
||||
* harmless no-op.
|
||||
*
|
||||
* Any additional env overrides (e.g. `configEnvVars`) can be spread after
|
||||
* calling this helper.
|
||||
*/
|
||||
const pythonEnv = (extra: Record<string, string> = {}): Record<string, string> => {
|
||||
const base: Record<string, string> = { ...process.env }
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// python.exe lives at the root of the installation directory on Windows
|
||||
const pythonDir = getPythonInstallationDir()
|
||||
const currentPath = process.env['PATH'] || process.env['Path'] || ''
|
||||
base['PATH'] = `${pythonDir};${currentPath}`
|
||||
base['PYTHONIOENCODING'] = 'utf-8'
|
||||
}
|
||||
|
||||
return { ...base, ...extra }
|
||||
}
|
||||
|
||||
export const isPythonInstalled = (installationDir?: string) => {
|
||||
const pythonPath = getPythonPath(installationDir)
|
||||
if (!fs.existsSync(pythonPath)) {
|
||||
@@ -358,10 +387,7 @@ export const isPythonInstalled = (installationDir?: string) => {
|
||||
try {
|
||||
const pythonVersion = execFileSync(pythonPath, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
|
||||
}
|
||||
env: pythonEnv()
|
||||
})
|
||||
log.info('Installed Python Version:', pythonVersion.trim())
|
||||
return true
|
||||
@@ -375,10 +401,7 @@ export const isUvInstalled = (installationDir?: string) => {
|
||||
try {
|
||||
const result = execFileSync(pythonPath, ['-m', 'uv', '--version'], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
|
||||
}
|
||||
env: pythonEnv()
|
||||
})
|
||||
log.info('Installed uv Version:', result.trim())
|
||||
return true
|
||||
@@ -428,10 +451,7 @@ export const installPackage = (packageName: string, version?: string, onStatus?:
|
||||
...(version ? [`${packageName}==${version}`] : [packageName, '-U'])
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
|
||||
}
|
||||
env: pythonEnv()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -486,10 +506,7 @@ export const isPackageInstalled = (packageName: string): boolean => {
|
||||
try {
|
||||
const info = execFileSync(pythonPath, ['-m', 'uv', 'pip', 'show', packageName], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
|
||||
}
|
||||
env: pythonEnv()
|
||||
})
|
||||
return info.includes(`Name: ${packageName}`)
|
||||
} catch {
|
||||
@@ -503,10 +520,7 @@ export const getPackageVersion = (packageName: string): string | null => {
|
||||
try {
|
||||
const info = execFileSync(pythonPath, ['-m', 'uv', 'pip', 'show', packageName], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
|
||||
}
|
||||
env: pythonEnv()
|
||||
})
|
||||
const match = info.match(/^Version:\s*(.+)$/m)
|
||||
return match ? match[1].trim() : null
|
||||
@@ -521,10 +535,7 @@ export const uninstallPackage = (packageName: string): boolean => {
|
||||
try {
|
||||
execFileSync(pythonPath, ['-m', 'uv', 'pip', 'uninstall', packageName], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
|
||||
}
|
||||
env: pythonEnv()
|
||||
})
|
||||
log.info(`Uninstalled package: ${packageName}`)
|
||||
return true
|
||||
@@ -588,14 +599,12 @@ export const startServer = async (
|
||||
name: 'xterm-256color',
|
||||
cols: 200,
|
||||
rows: 50,
|
||||
env: {
|
||||
...process.env,
|
||||
env: pythonEnv({
|
||||
...(configEnvVars ?? {}),
|
||||
DATA_DIR: dataDir,
|
||||
WEBUI_SECRET_KEY: secretKey,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
|
||||
}
|
||||
PYTHONUNBUFFERED: '1'
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
@@ -755,7 +764,7 @@ export const checkUrlAndOpen = async (url: string, callback: Function = async ()
|
||||
|
||||
const checkUrl = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
const response = await electronNet.fetch(url, { method: 'HEAD' })
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
@@ -783,7 +792,7 @@ export const checkUrlAndOpen = async (url: string, callback: Function = async ()
|
||||
|
||||
export const validateRemoteUrl = async (url: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) })
|
||||
const response = await electronNet.fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) })
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
@@ -829,10 +838,13 @@ export interface AppConfig {
|
||||
envVars: Record<string, string>
|
||||
showSidebar: boolean
|
||||
spotlightPosition: { x: number; y: number } | null
|
||||
spotlightClipboardPaste: boolean
|
||||
voiceInputShortcut: string
|
||||
voiceInputEnabled: boolean
|
||||
callShortcut: string
|
||||
callEnabled: boolean
|
||||
windowBounds: { x: number; y: number; width: number; height: number } | null
|
||||
windowMaximized: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: AppConfig = {
|
||||
@@ -863,10 +875,13 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||
envVars: {},
|
||||
showSidebar: false,
|
||||
spotlightPosition: null,
|
||||
spotlightClipboardPaste: true,
|
||||
voiceInputShortcut: 'Shift+CommandOrControl+Space',
|
||||
voiceInputEnabled: true,
|
||||
callShortcut: 'Shift+CommandOrControl+C',
|
||||
callEnabled: true
|
||||
callEnabled: true,
|
||||
windowBounds: null,
|
||||
windowMaximized: false
|
||||
}
|
||||
|
||||
export const getConfig = async (): Promise<AppConfig> => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
installPackage,
|
||||
isPackageInstalled,
|
||||
isPythonInstalled,
|
||||
installPython,
|
||||
portInUse
|
||||
} from './index'
|
||||
import { ServiceLock, isProcessAlive } from './service-lock'
|
||||
@@ -38,7 +39,8 @@ export const getOpenTerminalPty = (): pty.IPty | null => ptyProcess
|
||||
export const getOpenTerminalLog = (): string[] => logBuffer
|
||||
|
||||
export const startOpenTerminal = async (
|
||||
port: number | null = null
|
||||
port: number | null = null,
|
||||
onStatus?: (status: string) => void
|
||||
): Promise<{ url: string; apiKey: string; pid: number }> => {
|
||||
if (!lock.acquire()) {
|
||||
return { url, apiKey, pid }
|
||||
@@ -46,9 +48,27 @@ export const startOpenTerminal = async (
|
||||
|
||||
await stopOpenTerminal()
|
||||
|
||||
if (!isPythonInstalled()) throw new Error('Python is not installed')
|
||||
if (!isPythonInstalled()) {
|
||||
log.info('Python not installed — installing automatically for Open Terminal…')
|
||||
onStatus?.('Installing Python…')
|
||||
try {
|
||||
const ok = await installPython(undefined, onStatus)
|
||||
if (!ok) throw new Error('Python installation returned false')
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Python is required for Open Terminal but installation failed: ${err?.message ?? err}`
|
||||
)
|
||||
}
|
||||
if (!isPythonInstalled()) {
|
||||
throw new Error(
|
||||
'Python was installed but could not be verified. Please restart the app and try again.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPackageInstalled('open-terminal')) {
|
||||
log.info('open-terminal not installed, attempting install...')
|
||||
onStatus?.('Installing Open Terminal package…')
|
||||
try {
|
||||
await installPackage('open-terminal')
|
||||
} catch (err) {
|
||||
|
||||
@@ -50,9 +50,11 @@
|
||||
const serverReachable = $derived($serverInfo?.reachable)
|
||||
|
||||
const isInitializing = $derived($appState === 'initializing')
|
||||
const hasLocal = $derived(($connections ?? []).some((c) => c.type === 'local'))
|
||||
const localConn = $derived(($connections ?? []).find((c) => c.type === 'local'))
|
||||
const remoteConnections = $derived(($connections ?? []).filter((c) => c.type !== 'local'))
|
||||
const localConn = $derived(localInstalled
|
||||
? { id: 'local', name: 'Open WebUI', type: 'local' as const, url: `http://127.0.0.1:${$config?.localServer?.port ?? 8080}` }
|
||||
: null
|
||||
)
|
||||
const remoteConnections = $derived($connections ?? [])
|
||||
|
||||
// Open Terminal state
|
||||
let openTerminalStatus = $state<string | null>(null)
|
||||
@@ -62,6 +64,7 @@
|
||||
let llamaCppStatus = $state<string | null>(null)
|
||||
let llamaCppInfo = $state<{ url?: string; pid?: number } | null>(null)
|
||||
let llamaCppSetupStatus = $state('')
|
||||
let openTerminalSetupStatus = $state('')
|
||||
|
||||
const startInstall = async (options?: { installOpenTerminal?: boolean; installLlamaCpp?: boolean; installDir?: string }) => {
|
||||
installPhase = 'working'
|
||||
@@ -109,14 +112,7 @@
|
||||
const info = await window.electronAPI.getServerInfo()
|
||||
|
||||
installStatus = $i18n.t('main.install.settingUpConnection')
|
||||
await window.electronAPI.addConnection({
|
||||
id: 'local',
|
||||
name: 'Local',
|
||||
type: 'local',
|
||||
url: info?.url || 'http://127.0.0.1:8080'
|
||||
})
|
||||
await window.electronAPI.setDefaultConnection('local')
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
|
||||
// Wait for server to actually be reachable before showing connected view
|
||||
@@ -140,7 +136,6 @@
|
||||
|
||||
// Now connect — the server is ready
|
||||
installStatus = ''
|
||||
localInstalled = true
|
||||
connect('local')
|
||||
installPhase = 'idle'
|
||||
} catch (e: any) {
|
||||
@@ -177,7 +172,6 @@
|
||||
type: 'remote',
|
||||
url: u
|
||||
})
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
url = ''
|
||||
error = ''
|
||||
@@ -211,13 +205,10 @@
|
||||
return
|
||||
}
|
||||
|
||||
const conn = ($connections ?? []).find((c) => c.id === id)
|
||||
if (!conn) return
|
||||
|
||||
activeConnectionId = id
|
||||
|
||||
if (conn.type === 'local') {
|
||||
// Local needs server start — use IPC
|
||||
if (id === 'local') {
|
||||
// Local needs server start — use IPC (no renderer-side conn needed)
|
||||
connectingId = id
|
||||
view = 'welcome'
|
||||
window.electronAPI.connectTo(id).then((result: any) => {
|
||||
@@ -239,6 +230,8 @@
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const conn = ($connections ?? []).find((c) => c.id === id)
|
||||
if (!conn) return
|
||||
// Remote — open immediately, no IPC needed
|
||||
connectingId = ''
|
||||
openConnections.set(id, conn.url)
|
||||
@@ -256,7 +249,6 @@
|
||||
|
||||
const remove = async (id: string) => {
|
||||
await window.electronAPI.removeConnection(id)
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
if (activeConnectionId === id) {
|
||||
disconnect()
|
||||
@@ -265,11 +257,15 @@
|
||||
openConnections = new Map(openConnections)
|
||||
}
|
||||
|
||||
// Sync active connection info to parent
|
||||
$effect(() => {
|
||||
const conn = ($connections ?? []).find((c) => c.id === activeConnectionId)
|
||||
activeConnectionName = conn?.name ?? ''
|
||||
isLocalConnection = conn?.type === 'local'
|
||||
if (activeConnectionId === 'local') {
|
||||
activeConnectionName = localConn?.name ?? 'Open WebUI'
|
||||
isLocalConnection = true
|
||||
} else {
|
||||
const conn = ($connections ?? []).find((c) => c.id === activeConnectionId)
|
||||
activeConnectionName = conn?.name ?? ''
|
||||
isLocalConnection = false
|
||||
}
|
||||
})
|
||||
|
||||
// React to showingLogs from parent — open the server log panel
|
||||
@@ -432,11 +428,20 @@
|
||||
|
||||
// ── Desktop-only state (not forwarded to webviews) ─
|
||||
if (data.type === 'status:open-terminal') { openTerminalStatus = data.data; return }
|
||||
if (data.type === 'open-terminal:ready') { openTerminalInfo = data.data; openTerminalStatus = 'started'; return }
|
||||
if (data.type === 'status:open-terminal-setup') { openTerminalSetupStatus = data.data ?? ''; return }
|
||||
if (data.type === 'open-terminal:ready') { openTerminalInfo = data.data; openTerminalStatus = 'started'; openTerminalSetupStatus = ''; return }
|
||||
if (data.type === 'status:llamacpp') { llamaCppStatus = data.data; return }
|
||||
if (data.type === 'status:llamacpp-setup') { llamaCppSetupStatus = data.data ?? ''; return }
|
||||
if (data.type === 'llamacpp:ready') { llamaCppInfo = data.data; llamaCppStatus = 'started'; llamaCppSetupStatus = ''; return }
|
||||
if (data.type === 'status:install') { installStatus = data.data ?? ''; return }
|
||||
if (data.type === 'packages:changed') {
|
||||
localInstalled = !!data.data?.['open-webui']
|
||||
return
|
||||
}
|
||||
if (data.type === 'connections:changed') {
|
||||
connections.set(data.data ?? [])
|
||||
return
|
||||
}
|
||||
|
||||
// ── Everything else → broadcast to all webviews ───
|
||||
sendToWebview(data)
|
||||
@@ -486,8 +491,10 @@
|
||||
await window.electronAPI.stopOpenTerminal()
|
||||
openTerminalStatus = null
|
||||
openTerminalInfo = null
|
||||
openTerminalSetupStatus = ''
|
||||
} else {
|
||||
openTerminalStatus = 'starting'
|
||||
openTerminalSetupStatus = ''
|
||||
const result = await window.electronAPI.startOpenTerminal()
|
||||
if (result) {
|
||||
openTerminalInfo = result
|
||||
@@ -495,6 +502,7 @@
|
||||
} else {
|
||||
openTerminalStatus = 'failed'
|
||||
}
|
||||
openTerminalSetupStatus = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,7 +546,6 @@
|
||||
{onOpenSettings}
|
||||
onRename={async (id, name) => {
|
||||
await window.electronAPI.updateConnection(id, { name })
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
}}
|
||||
onRemove={remove}
|
||||
{openGithub}
|
||||
@@ -580,7 +587,7 @@
|
||||
statusText={activeLog === 'server'
|
||||
? (serverStatus === 'starting' ? 'Starting Open WebUI…' : serverStatus === 'running' && !serverReachable ? 'Waiting for server…' : installStatus || '')
|
||||
: activeLog === 'open-terminal'
|
||||
? (openTerminalStatus === 'stopping' ? 'Stopping Open Terminal…' : openTerminalStatus === 'starting' ? 'Starting Open Terminal…' : '')
|
||||
? (openTerminalStatus === 'stopping' ? 'Stopping Open Terminal…' : openTerminalSetupStatus || (openTerminalStatus === 'starting' ? 'Starting Open Terminal…' : ''))
|
||||
: (llamaCppStatus === 'stopping' ? 'Stopping llama-server…' : llamaCppSetupStatus || (llamaCppStatus === 'starting' ? 'Starting llama-server…' : llamaCppStatus === 'setting-up' ? 'Setting up llama.cpp…' : ''))}
|
||||
connectPty={getConnectPty(activeLog)}
|
||||
disconnectPty={getDisconnectPty(activeLog)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { fade, fly } from 'svelte/transition'
|
||||
import { connections, config, serverInfo, appState } from '../../../stores'
|
||||
import { config, serverInfo, appState } from '../../../stores'
|
||||
import i18n from '../../../i18n'
|
||||
import LocalInstall from '../../Setup/LocalInstall.svelte'
|
||||
import GetStartedModal from './GetStartedModal.svelte'
|
||||
@@ -72,22 +72,50 @@
|
||||
// Track webview loading per connection
|
||||
let webviewLoading: Map<string, boolean> = $state(new Map())
|
||||
|
||||
// Track webview load errors per connection
|
||||
let webviewErrors: Map<string, { code: number; description: string; url: string }> = $state(new Map())
|
||||
|
||||
// Content preload path for webview bridge
|
||||
let contentPreloadPath: string = $state('')
|
||||
|
||||
// Server is starting up (local)
|
||||
const serverStarting = $derived(
|
||||
localConn && localInstalled && (
|
||||
localInstalled && (
|
||||
$serverInfo?.status === 'starting' ||
|
||||
($serverInfo?.status === 'running' && !$serverInfo?.reachable)
|
||||
)
|
||||
)
|
||||
|
||||
const activeWebviewError = $derived(
|
||||
view === 'connected' && activeConnectionId
|
||||
? webviewErrors.get(activeConnectionId) ?? null
|
||||
: null
|
||||
)
|
||||
|
||||
const isLoading = $derived(
|
||||
connectingId !== '' ||
|
||||
(serverStarting && activeConnectionId === localConn?.id)
|
||||
(serverStarting && activeConnectionId === 'local') ||
|
||||
(view === 'connected' && !activeWebviewError && webviewLoading.get(activeConnectionId) === true)
|
||||
)
|
||||
|
||||
const retryActiveWebview = () => {
|
||||
const wv = document.querySelector(
|
||||
`webview[partition="persist:connection-${activeConnectionId}"]`
|
||||
) as any
|
||||
if (wv?.reload) {
|
||||
webviewErrors.delete(activeConnectionId)
|
||||
webviewErrors = new Map(webviewErrors)
|
||||
wv.reload()
|
||||
}
|
||||
}
|
||||
|
||||
const openActiveInBrowser = () => {
|
||||
const connUrl = openConnections.get(activeConnectionId)
|
||||
if (connUrl) {
|
||||
window.electronAPI.openInBrowser(connUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// Attach load event listeners and IPC forwarding to webviews
|
||||
onMount(async () => {
|
||||
// Fetch the content preload path once
|
||||
@@ -115,6 +143,51 @@
|
||||
webviewLoading = new Map(webviewLoading)
|
||||
})
|
||||
|
||||
// Track load failures so we can show an error overlay
|
||||
wv.addEventListener('did-fail-load', (event: any) => {
|
||||
// Ignore sub-resource failures and aborted navigations (-3)
|
||||
if (event.errorCode === -3 || event.isMainFrame === false) return
|
||||
webviewErrors.set(connId, {
|
||||
code: event.errorCode,
|
||||
description: event.errorDescription || 'Unknown error',
|
||||
url: event.validatedURL || ''
|
||||
})
|
||||
webviewErrors = new Map(webviewErrors)
|
||||
})
|
||||
|
||||
// Clear error when a navigation succeeds (retry, redirect, etc.)
|
||||
wv.addEventListener('did-navigate', () => {
|
||||
if (webviewErrors.has(connId)) {
|
||||
webviewErrors.delete(connId)
|
||||
webviewErrors = new Map(webviewErrors)
|
||||
}
|
||||
})
|
||||
|
||||
// Renderer process crash
|
||||
wv.addEventListener('crashed', () => {
|
||||
webviewErrors.set(connId, {
|
||||
code: -1,
|
||||
description: 'crashed',
|
||||
url: ''
|
||||
})
|
||||
webviewErrors = new Map(webviewErrors)
|
||||
})
|
||||
|
||||
// Log guest page console messages for debugging blank-page issues (#124)
|
||||
wv.addEventListener('console-message', (event: any) => {
|
||||
if (event.level >= 2) { // warnings and errors only
|
||||
console.warn(`[webview:${connId}]`, event.message)
|
||||
}
|
||||
})
|
||||
|
||||
// If this webview was created before the preload path resolved
|
||||
// (race between auto-connect and async IPC), the preload didn't
|
||||
// attach. Force a reload now so it picks up the correct preload.
|
||||
if (contentPreloadPath && wv.getAttribute('preload') !== contentPreloadPath) {
|
||||
wv.setAttribute('preload', contentPreloadPath)
|
||||
wv.reload()
|
||||
}
|
||||
|
||||
// Handle IPC messages from the webview guest (Open WebUI → desktop)
|
||||
wv.addEventListener('ipc-message', async (event: any) => {
|
||||
if (event.channel === 'webview:send') {
|
||||
@@ -195,12 +268,54 @@
|
||||
src={connUrl}
|
||||
class="flex-1 min-h-0 border-none"
|
||||
style="display: {view === 'connected' && activeConnectionId === connId ? 'flex' : 'none'}"
|
||||
allowpopups
|
||||
partition="persist:connection-{connId}"
|
||||
preload={contentPreloadPath}
|
||||
allowpopups
|
||||
></webview>
|
||||
{/each}
|
||||
|
||||
<!-- Error overlay when webview fails to load -->
|
||||
{#if activeWebviewError}
|
||||
<div class="absolute inset-0 z-20 flex items-center justify-center bg-[#eee] dark:bg-[#111]" transition:fade={{ duration: 200 }}>
|
||||
<div class="text-center max-w-sm px-6">
|
||||
<div class="mx-auto mb-4 w-10 h-10 rounded-full bg-black/[0.04] dark:bg-white/[0.06] flex items-center justify-center">
|
||||
{#if activeWebviewError.code === -1}
|
||||
<svg class="w-5 h-5 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-[14px] font-medium mb-1 opacity-80">
|
||||
{activeWebviewError.code === -1 ? $i18n.t('setup.pageCrashed') : $i18n.t('setup.couldNotLoadPage')}
|
||||
</div>
|
||||
<div class="text-[12px] opacity-30 mb-1">{activeWebviewError.description}</div>
|
||||
{#if activeWebviewError.url}
|
||||
<div class="text-[11px] opacity-20 mb-6 break-all font-mono">{activeWebviewError.url}</div>
|
||||
{:else}
|
||||
<div class="mb-6"></div>
|
||||
{/if}
|
||||
<div class="flex gap-2 justify-center">
|
||||
<button
|
||||
class="px-4 py-2 rounded-xl text-[13px] font-medium bg-black dark:bg-white text-white dark:text-black border-none cursor-pointer transition hover:bg-gray-800 dark:hover:bg-gray-100 active:scale-[0.98]"
|
||||
onclick={retryActiveWebview}
|
||||
>
|
||||
{$i18n.t('common.retry')}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-xl text-[13px] bg-black/[0.04] dark:bg-white/[0.06] text-[#1d1d1f] dark:text-[#fafafa] border-none cursor-pointer opacity-60 hover:opacity-90 transition active:scale-[0.98]"
|
||||
onclick={openActiveInBrowser}
|
||||
>
|
||||
{$i18n.t('setup.openInBrowser')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading overlay for webview -->
|
||||
{#if isLoading}
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-[#eee] dark:bg-[#111]" transition:fade={{ duration: 200 }}>
|
||||
@@ -274,7 +389,7 @@
|
||||
|
||||
<div class="flex-1 flex items-center justify-center px-6 relative overflow-hidden">
|
||||
{#if view === 'welcome'}
|
||||
{#if remoteConnections.length > 0 || (localConn && localInstalled)}
|
||||
{#if remoteConnections.length > 0 || localInstalled}
|
||||
<div class="text-center max-w-[320px]" in:fade={{ duration: 200 }}>
|
||||
<div class="text-lg opacity-80 mb-1.5">{$i18n.t('app.name')}</div>
|
||||
<div class="text-[12px] opacity-30 mb-6">
|
||||
@@ -372,7 +487,6 @@
|
||||
autoStart={autoInstall}
|
||||
onBack={() => { autoInstall = false; onSetView('welcome') }}
|
||||
onComplete={async () => {
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
onSetView('welcome')
|
||||
}}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
const remove = async (id: string) => {
|
||||
await window.electronAPI.removeConnection(id)
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -111,6 +111,9 @@
|
||||
let callShortcutInputEl = $state<HTMLButtonElement | null>(null)
|
||||
let callEnabled = $state(true)
|
||||
|
||||
// Spotlight clipboard paste
|
||||
let spotlightClipboardPaste = $state(true)
|
||||
|
||||
// Keep shortcut value in sync with config store
|
||||
$effect(() => {
|
||||
if ($config?.globalShortcut !== undefined) {
|
||||
@@ -122,6 +125,9 @@
|
||||
if ($config?.spotlightShortcut !== undefined) {
|
||||
spotlightShortcutValue = $config.spotlightShortcut ?? ''
|
||||
}
|
||||
if ($config?.spotlightClipboardPaste !== undefined) {
|
||||
spotlightClipboardPaste = $config.spotlightClipboardPaste ?? true
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
@@ -518,6 +524,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] opacity-70">Clipboard Auto-Paste</div>
|
||||
<div class="text-[11px] opacity-25 mt-0.5">Automatically paste clipboard contents into Spotlight</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={spotlightClipboardPaste}
|
||||
label="Toggle clipboard auto-paste"
|
||||
onchange={async (value) => {
|
||||
spotlightClipboardPaste = value
|
||||
await window.electronAPI.setConfig({ spotlightClipboardPaste: value })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] opacity-70">Voice Input</div>
|
||||
|
||||
@@ -429,7 +429,7 @@
|
||||
</div>
|
||||
<Switch
|
||||
checked={$config?.llamaCpp?.enabled ?? false}
|
||||
onToggle={() => updateConfig('enabled', !($config?.llamaCpp?.enabled ?? false))}
|
||||
onchange={(value) => updateConfig('enabled', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
type: 'remote',
|
||||
url: u
|
||||
})
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
url = ''; name = ''; view = 'list'
|
||||
} catch { error = $i18n.t('setup.connectionManager.failed') }
|
||||
@@ -48,10 +47,8 @@
|
||||
}
|
||||
const remove = async (id: string) => {
|
||||
await window.electronAPI.removeConnection(id)
|
||||
const conns = await window.electronAPI.getConnections()
|
||||
connections.set(conns)
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
if (conns.length === 0) appState.set('setup')
|
||||
if (($connections ?? []).length === 0) appState.set('setup')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { fade, fly } from 'svelte/transition'
|
||||
import { onMount } from 'svelte'
|
||||
import { connections, config, serverInfo } from '../../stores'
|
||||
import { config, serverInfo } from '../../stores'
|
||||
import i18n from '../../i18n'
|
||||
|
||||
import logoImage from '../../assets/images/splash.png'
|
||||
@@ -33,14 +33,7 @@
|
||||
await window.electronAPI.startServer()
|
||||
const info = await window.electronAPI.getServerInfo()
|
||||
|
||||
await window.electronAPI.addConnection({
|
||||
id: 'local',
|
||||
name: 'Local',
|
||||
type: 'local',
|
||||
url: info?.url || 'http://127.0.0.1:8080'
|
||||
})
|
||||
await window.electronAPI.setDefaultConnection('local')
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
|
||||
phase = 'done'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { fly, fade } from 'svelte/transition'
|
||||
import { appState, connections, config } from '../../stores'
|
||||
import { appState, config } from '../../stores'
|
||||
import i18n from '../../i18n'
|
||||
import LocalInstall from './LocalInstall.svelte'
|
||||
|
||||
@@ -30,16 +30,15 @@
|
||||
try {
|
||||
const valid = await window.electronAPI.validateUrl(u)
|
||||
if (!valid) { error = $i18n.t('setup.couldNotReachServer'); connecting = false; return }
|
||||
const connId = crypto.randomUUID()
|
||||
await window.electronAPI.addConnection({
|
||||
id: crypto.randomUUID(),
|
||||
id: connId,
|
||||
name: new URL(u).hostname,
|
||||
type: 'remote',
|
||||
url: u
|
||||
})
|
||||
connections.set(await window.electronAPI.getConnections())
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
const conns = await window.electronAPI.getConnections()
|
||||
await window.electronAPI.connectTo(conns[conns.length - 1].id)
|
||||
await window.electronAPI.connectTo(connId)
|
||||
appState.set('ready')
|
||||
} catch {
|
||||
error = $i18n.t('setup.connectionFailed')
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
"setup.couldNotReachServer": "Could not reach this server",
|
||||
"setup.invalidUrl": "Please enter a valid URL",
|
||||
"setup.connectionFailed": "Connection failed",
|
||||
"setup.couldNotLoadPage": "Could not load this page",
|
||||
"setup.pageCrashed": "This page crashed unexpectedly",
|
||||
"setup.openInBrowser": "Open in Browser",
|
||||
"setup.urlPlaceholder": "e.g. https://your-server.com",
|
||||
"setup.preparingEnvironment": "Preparing environment…",
|
||||
"setup.settingUp": "Setting up…",
|
||||
|
||||
Reference in New Issue
Block a user