Compare commits

...

19 Commits

Author SHA1 Message Date
Timothy Jaeryang Baek 0931b46d66 refac 2026-05-06 22:57:36 +09:00
Timothy Jaeryang Baek 601137c5de fix: replace --in-process-gpu with SwiftShader to fix blank webviews on Linux
The --in-process-gpu flag broke <webview> guest compositing entirely,
leaving connection views blank on all Linux configurations. SwiftShader
(--use-gl=angle --use-angle=swiftshader) keeps the GPU process
out-of-process (required for webview compositing) while using software
rendering to avoid the driver-level /dev/shm crashes that originally
motivated the in-process flag.
2026-05-06 22:41:00 +09:00
Timothy Jaeryang Baek a097a83c46 refac 2026-05-06 22:33:38 +09:00
Timothy Jaeryang Baek f8c20275cd refactor: decouple local server from connections array
The local Open WebUI server is a singleton — it's either installed or not.
Previously it was modeled as a type:'local' entry in the connections[]
config array, which broke when installing from Settings (the entry was
only created during the Get Started flow).

Now:
- connections[] is remote-only
- Local server is implicit when open-webui package is installed
- 'local' is a virtual connection ID, synthesized on the fly
- Main process: add getDefaultConnection/buildLocalConnection/resolveConnectionUrl
  helpers, replacing ~70 lines of duplicated config lookup across 5 handlers
- Renderer: localConn derived from localInstalled state, not connections store
- Push-based reactivity: main process emits connections:changed events
- Tray: virtual local entry when package is installed
- Migration: strip legacy type:'local' entries from config on startup
2026-05-06 22:09:20 +09:00
Timothy Jaeryang Baek fa64bc02b5 chore: complete v0.0.19 changelog 2026-05-06 21:18:27 +09:00
Timothy Jaeryang Baek 20f0aaf40c fix: prevent Spotlight from pulling to first desktop on macOS Spaces (#179)
On macOS, app.focus({ steal: true }) activates the entire application,
causing the window manager to switch back to whichever Space the app was
originally launched on. Replace it with targeted window-level focus and
add visibleOnAllWorkspaces to ensure Spotlight appears on the current
Space without triggering a desktop switch.
2026-05-06 21:17:15 +09:00
Timothy Jaeryang Baek c64e946b38 Update release.yml 2026-05-06 21:05:52 +09:00
Timothy Jaeryang Baek 1902f791cb fix: auto-install Python when Open Terminal starts without it
Previously, startOpenTerminal threw 'Python is not installed' which was
caught by the IPC handler but only produced a generic error toast with no
actionable guidance. On a fresh install where the user hasn't started a
Local Server first, this made Open Terminal appear completely broken.

Now startOpenTerminal automatically triggers installPython() when Python
is missing, reusing the same download/extract/uv-install flow that the
Local Server setup uses. Install progress (download %, extraction) is
forwarded to the renderer via a new status:open-terminal-setup event and
displayed in the log panel status bar.

Fixes #168
2026-05-05 01:35:52 +09:00
Timothy Jaeryang Baek c2f128aec0 fix: replace --disable-gpu with --in-process-gpu on Linux (#178)
Both --disable-gpu and --disable-gpu-compositing kill the display
compositor, preventing <webview> guest surfaces from painting (gray
rectangle).  --in-process-gpu moves the GPU thread into the browser
process, sidestepping the cross-process shared-memory IPC crashes
while keeping the compositor alive so webview content renders.
2026-05-05 00:36:27 +09:00
Timothy Jaeryang Baek 64c399738e fix: store HF models directly under models/ for llama-server discovery
llama-server's --models-dir scanner only checks one level of
subdirectories. Downloaded models were stored two levels deep at
models/huggingface/<repo-slug>/<filename>, making them invisible.

Changed the cache directory from models/huggingface/ to models/ so
models land at models/<repo-slug>/<filename> — exactly one level deep.

Includes automatic migration of existing models from the legacy
huggingface/ subdirectory on first run.

Fixes #177
2026-05-05 00:21:59 +09:00
Timothy Jaeryang Baek 842c1481f6 fix: allow webview popups so links open in default browser on Windows (#165, #170) 2026-05-03 00:42:27 +09:00
Timothy Jaeryang Baek e7eef2da86 chore: bump version to 0.0.17 2026-05-03 00:09:33 +09:00
Timothy Jaeryang Baek aab2a687cc refac 2026-05-03 00:06:03 +09:00
Timothy Jaeryang Baek 9e204e78b7 doc 2026-05-02 22:37:04 +09:00
Timothy Jaeryang Baek ef53d6fb21 fix: open links in default browser instead of within the app (#165) 2026-05-02 19:04:15 +09:00
Timothy Jaeryang Baek 21e8a36e0d chore: bump version to 0.0.15 2026-04-28 14:32:36 +09:00
Timothy Jaeryang Baek fcb32f93ab feat: add ARM64 support for Linux and Windows
- Add ubuntu-24.04-arm and windows-11-arm to CI matrix
- Linux ARM64 builds AppImage + deb (Raspberry Pi, DGX Spark)
- Windows ARM64 builds NSIS installer (Snapdragon devices)
- Add arch to NSIS and AppImage artifact names to prevent collisions
- Add latest-linux.yml and latest.yml merge steps for auto-updater
- Update README with ARM64 download links

Closes #140
2026-04-28 14:31:23 +09:00
Timothy Jaeryang Baek 48be8d0386 fix(linux): disable GPU entirely to prevent shared memory crash and grey screen
Escalate from --disable-gpu-compositing to --disable-gpu on Linux.
The GPU process crashes during shared memory allocation in /tmp even
with --disable-dev-shm-usage, and disabling compositing alone still
leaves the GPU process running. --disable-gpu prevents the process
from spawning entirely, resolving both the crash and grey screen.

Closes #157, Closes #119
2026-04-28 14:23:36 +09:00
Timothy Jaeryang Baek 4cab91de4e fix: spotlight dismiss no longer brings main window to foreground (#158)
Escape, click-away, hotkey toggle, and blur now hide the spotlight
overlay silently.  Only spotlight:submit restores the main window
so the user sees their query delivered.
2026-04-28 14:08:58 +09:00
16 changed files with 599 additions and 237 deletions
+151 -21
View File
@@ -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
+50
View File
@@ -5,6 +5,56 @@ 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
+9 -6
View File
@@ -18,11 +18,14 @@ 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)
@@ -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.
+2 -2
View File
@@ -19,7 +19,7 @@ asarUnpack:
win:
executableName: open-webui
nsis:
artifactName: ${name}-setup.${ext}
artifactName: ${name}-${arch}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
@@ -63,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'
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.0.14",
"version": "0.0.20",
"license": "AGPL-3.0",
"description": "Open WebUI Desktop",
"main": "./out/main/index.js",
+249 -120
View File
@@ -106,13 +106,25 @@ if (process.platform === 'linux') {
// to work (the portal is enabled by default in Chromium 134+ / Electron 33+).
app.commandLine.appendSwitch('ozone-platform-hint', 'auto')
// Disable GPU compositing to prevent grey/blank webview rendering on
// Linux systems with problematic Intel/NVIDIA drivers or certain Wayland
// compositors. The GPU process may not crash (so the crash-recovery
// marker never fires), but the compositor can fail silently — producing
// a grey rectangle instead of rendered content. This is the standard
// workaround used by VS Code and other Electron apps (#119).
app.commandLine.appendSwitch('disable-gpu-compositing')
// 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 ─────────────────────────────────
@@ -294,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'),
@@ -319,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
})
@@ -338,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
@@ -495,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',
@@ -504,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)
}
@@ -542,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',
@@ -551,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()) {
@@ -784,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 () => {
@@ -793,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',
@@ -802,10 +803,10 @@ const updateTray = () => {
}
},
{ type: 'separator' },
...(connectionItems.length > 0
...(allItems.length > 0
? [
{ label: 'Connections', enabled: false },
...connectionItems,
...allItems,
{ type: 'separator' }
]
: []),
@@ -837,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
@@ -890,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
@@ -1243,6 +1297,10 @@ if (!gotTheLock) {
// 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') {
@@ -1252,6 +1310,85 @@ if (!gotTheLock) {
)
}
})
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 ─────────────────────────────────
@@ -1335,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)
@@ -1365,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
@@ -1380,6 +1529,7 @@ if (!gotTheLock) {
await setConfig(config)
CONFIG = config
updateTray()
sendToRenderer('connections:changed', config.connections)
return config.connections
})
@@ -1387,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
})
@@ -1403,6 +1554,7 @@ if (!gotTheLock) {
await setConfig(config)
CONFIG = config
updateTray()
sendToRenderer('connections:changed', config.connections)
}
return config.connections
})
@@ -1416,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) {
@@ -1456,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) => ({
@@ -1486,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()
@@ -1496,7 +1639,6 @@ if (!gotTheLock) {
})
ipcMain.handle('spotlight:close', () => {
spotlightWindow?.hide()
// blur handler restores main window
})
// Persist bar offset within the fullscreen spotlight window
@@ -1604,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 || ''
@@ -1702,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()) {
@@ -1754,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
@@ -1763,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)
@@ -1787,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)
@@ -1829,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)
@@ -1859,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)
@@ -1934,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 () => {
@@ -2025,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) {
@@ -2049,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()
}
+29 -2
View File
@@ -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
}
+42 -33
View File
@@ -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(
+22 -2
View File
@@ -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'
@@ -80,7 +80,7 @@
// Server is starting up (local)
const serverStarting = $derived(
localConn && localInstalled && (
localInstalled && (
$serverInfo?.status === 'starting' ||
($serverInfo?.status === 'running' && !$serverInfo?.reachable)
)
@@ -94,7 +94,7 @@
const isLoading = $derived(
connectingId !== '' ||
(serverStarting && activeConnectionId === localConn?.id) ||
(serverStarting && activeConnectionId === 'local') ||
(view === 'connected' && !activeWebviewError && webviewLoading.get(activeConnectionId) === true)
)
@@ -268,9 +268,9 @@
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}
@@ -389,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">
@@ -487,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>
@@ -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')