Compare commits

...

33 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
Timothy Jaeryang Baek 564a89baa8 chore: bump version to 0.0.14 and update changelog 2026-04-28 13:20:20 +09:00
Timothy Jaeryang Baek 25b9e195c2 fix(linux): disable GPU compositing to prevent grey/blank webview screen
On many Linux setups (Intel iGPU, mixed NVIDIA/Intel, Wayland
compositors), the GPU compositor fails silently without crashing
the GPU process — producing a grey rectangle where the webview
content should be.  The existing GPU crash recovery marker never
fires because the process stays alive.

Adding --disable-gpu-compositing on Linux is the standard Electron
workaround (used by VS Code, Brave, etc.) for this class of issue.
It disables only the compositor, not all hardware acceleration,
so video decode and basic GPU ops still work.

Also adds render-process-gone listeners to auto-reload the main
window on renderer crashes and log webview guest crashes for
diagnostics.

Closes #119
2026-04-27 15:15:44 +09:00
Timothy Jaeryang Baek 61db9dc10f fix: grant clipboard-write permission to webview sessions (fixes #154)
The Copy button in the Open WebUI interface silently failed on
GNOME/Wayland/Flatpak because the webview session permission handlers
did not include 'clipboard-sanitized-write'. Electron denied the
navigator.clipboard.writeText() call, but the web app saw no error
and briefly showed 'Copied' without actually writing to the clipboard.

Added 'clipboard-sanitized-write' to both permission handler whitelists:
- Per-connection content window handler
- session-created handler for webview partition sessions
2026-04-27 14:32:35 +09:00
Timothy Jaeryang Baek 27a3075c3a fix(linux): use /tmp for shared memory to prevent AppImage /dev/shm crashes
Add --disable-dev-shm-usage flag on Linux so Chromium writes shared
memory to /tmp instead of /dev/shm.  AppImage's FUSE mount restricts
child-process access to /dev/shm, causing FATAL zygote/renderer crashes
with 'Unable to access(W_OK|X_OK) /dev/shm' — resulting in a blank/grey
screen.  Also affects .deb and Snap packages on some distros.

Fixes #136
2026-04-25 00:32:27 +09:00
Timothy Jaeryang Baek 8c990befbe chore: bump version to 0.0.12 and update changelog 2026-04-25 00:29:27 +09:00
Timothy Jaeryang Baek da84e49970 fix: enable npmRebuild and unpack node-pty to fix Linux deb crash (#125)
- Set npmRebuild: true so native modules are compiled against
  Electron's Node ABI on each platform during packaging
- Add node_modules/node-pty/** to asarUnpack so the native pty.node
  binary is extracted to the real filesystem instead of being
  trapped inside app.asar where require() cannot load it
2026-04-25 00:27:10 +09:00
Timothy Jaeryang Baek e0af7f3d32 fix: show error overlay instead of grey screen on webview load failure
When connecting to remote or auth-enabled instances, the webview could
fail to load silently — leaving users with a blank grey screen and no
feedback (#119, #124).

Changes:
- Add did-fail-load, crashed, and did-navigate event listeners to
  webviews so load failures are captured per-connection
- Surface the existing webviewLoading state in the loading overlay
  (was tracked but never rendered)
- Show an error overlay with the failure description, Retry button,
  and Open in Browser fallback when the webview fails to load
- Fix preload race condition: webviews created before the async
  contentPreloadPath resolves now get the correct preload reapplied
- Add console-message listener (warnings/errors) for diagnosing
  blank-page issues where the page loads but JS fails silently
2026-04-25 00:23:33 +09:00
Timothy Jaeryang Baek 4f653f5fcd fix: support global shortcuts on Wayland/Flatpak via xdg-desktop-portal
- Enable native Wayland backend with ozone-platform-hint=auto on Linux,
  allowing Chromium's built-in GlobalShortcutsPortal (default since Cr134)
  to route globalShortcut.register() through xdg-desktop-portal
- Add org.freedesktop.portal.Desktop talk-name to Flatpak finishArgs so
  the sandbox permits portal D-Bus calls
- Refactor shortcut registration into tryRegisterShortcut() helper with
  consistent error handling and user-facing notifications for all four
  shortcuts (global, spotlight, voice input, call)

Fixes #126
2026-04-25 00:22:02 +09:00
Timothy Jaeryang Baek 3a76f985ab feat: persist window size and position across restarts
- Save window bounds (size + position) and maximized state to config on
  resize, move, maximize, and unmaximize with debounced writes
- Restore saved geometry on launch with display visibility validation
  to handle disconnected monitors gracefully
- Lower minimum window size from 1280x800 to 480x360 to support
  smaller screens and compact layouts
- Extract magic numbers into named constants and consolidate event
  handlers for cleaner maintainability

Closes #145, Closes #109
2026-04-25 00:10:25 +09:00
Timothy Jaeryang Baek 06808cb284 feat: add toggleable clipboard auto-paste for Spotlight
Add a 'spotlightClipboardPaste' setting (default: true) that controls
whether clipboard contents are automatically pasted into the Spotlight
prompt window. Users can disable this in Settings > General to prevent
sensitive data (e.g. passwords) from being exposed.

Closes #123
2026-04-25 00:06:05 +09:00
Timothy Jaeryang Baek ed26423f90 chore: bump version to 0.0.11 2026-04-25 00:00:54 +09:00
Timothy Jaeryang Baek 5e95e918c7 refac 2026-04-25 00:00:16 +09:00
Timothy Jaeryang Baek 84c93aaeb6 fix: add disable-library-validation entitlement to fix macOS launch crash
The build/entitlements.mac.plist was missing the critical
com.apple.security.cs.disable-library-validation entitlement, causing
macOS to reject loading the Electron Framework due to Team ID mismatch.

Also syncs all other entitlements (network, camera, microphone, etc.)
from the root entitlements.plist to ensure consistency.
2026-04-24 23:59:57 +09:00
Timothy Jaeryang Baek 37f0891840 fix(#108): trust all SSL certificates for HTTPS connections
Bypass certificate verification globally so the desktop app can connect
to Open WebUI instances using self-signed, private-CA, ZeroSSL, or any
other non-publicly-trusted certificates.

Three layers:
- session.defaultSession.setCertificateVerifyProc: covers net.fetch()
  used by validateRemoteUrl / checkUrlAndOpen
- app.on('session-created'): covers partitioned webview sessions
  (persist:connection-*) including in-page API calls
- app.on('certificate-error'): fallback for BrowserWindow navigations

Also switches validateRemoteUrl and checkUrlAndOpen from Node fetch()
to Electron net.fetch() so they route through Chromium's network stack
and respect the session certificate overrides.
2026-04-24 23:59:25 +09:00
19 changed files with 1063 additions and 305 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
+85
View File
@@ -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
+12 -9
View File
@@ -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.
+28
View File
@@ -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>
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}
+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
}
+52 -37
View File
@@ -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> => {
+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'
@@ -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…",