Compare commits

...

30 Commits

Author SHA1 Message Date
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
Timothy Jaeryang Baek eb3c569078 chore: bump version to 0.0.10 2026-04-24 22:17:45 +09:00
Timothy Jaeryang Baek 3350da65ec feat: auto-recover from GPU process crashes by disabling GPU sandbox
When the GPU process crashes fatally (common with certain NVIDIA/Intel
driver versions on Windows), automatically write a marker file and
relaunch with --disable-gpu-sandbox so users don't have to manually
edit shortcut targets.

- Detect GPU crashes via child-process-gone event
- Persist .gpu-sandbox-disabled marker across restarts
- Apply --disable-gpu-sandbox preemptively on subsequent launches
- Call disableDomainBlockingFor3DAPIs() to prevent WebGL blacklisting
- Clean up marker on app reset so users can re-test after driver updates
- Expose gpuSandboxDisabled in app:info for diagnostics

Fixes #110
2026-04-24 22:15:21 +09:00
Timothy Jaeryang Baek 953327b9ef refac: styling 2026-04-20 16:21:59 +09:00
Timothy Jaeryang Baek 1a56df0c6e refac 2026-04-20 16:14:03 +09:00
Timothy Jaeryang Baek 44c40eabd6 fix: persist Open Terminal API key across restarts
The API key is now saved in config.json and reused on subsequent
startups instead of being regenerated every time, which was breaking
existing integrations that relied on a stable key.

Closes #102
2026-04-20 15:57:42 +09:00
Tim Baek d475bde04a Merge pull request #95 from NN708/main
feat: add AppStream MetaInfo file
2026-04-12 11:21:42 -06:00
NN708 5cbe7553d3 feat: add appstream metainfo file 2026-04-12 08:52:17 +08:00
Timothy Jaeryang Baek 7160392959 refac 2026-04-11 16:44:45 -06:00
Timothy Jaeryang Baek a889d0e607 feat: add voice input troubleshooting notifications and mic permission checks
- Wire up dead voiceInput:micPermission IPC handler via preload API
- Add pre-flight mic permission check before starting recording (macOS)
- Add pre-flight connection/auth validation in toggleVoiceInput
- Show OS notifications for all voice input failure points:
  shortcut registration, mic denied, no connection, auth missing,
  transcription HTTP errors, and generic voice input errors
- Improve renderer error messages for NotAllowedError/NotFoundError
- Forward renderer errors to main process for OS-level notifications
2026-04-11 16:30:10 -06:00
Timothy Jaeryang Baek ef66b1b21a refac 2026-04-11 15:47:39 -06:00
Timothy Jaeryang Baek 6c669f1389 doc: readme 2026-04-11 15:44:59 -06:00
Timothy Jaeryang Baek fe398bc65d doc: readme 2026-04-11 15:42:03 -06:00
20 changed files with 1154 additions and 228 deletions
+74 -20
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,7 +258,6 @@ 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');
@@ -259,13 +268,58 @@ jobs:
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"
ARM_YML="ubuntu-24.04-arm-arm64/latest-linux.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'));
x64.files = [...(x64.files || []), ...(arm.files || [])];
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'));
x64.files = [...(x64.files || []), ...(arm.files || [])];
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: Create Release
uses: softprops/action-gh-release@v2
env:
@@ -277,18 +331,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
+66
View File
@@ -5,6 +5,72 @@ 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.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
- **Concurrent Model Downloads.** Multiple Hugging Face models can now be downloaded simultaneously, each with independent progress tracking and per-file cancel buttons.
### Changed
- **Models Settings UI.** Cleaner layout with inline progress bars, hover-reveal download buttons, and breadcrumb-style repo navigation.
### Fixed
- **GPU Process Crash Recovery.** The app now automatically detects GPU process crashes (common with certain NVIDIA/Intel drivers on Windows) and relaunches with the GPU sandbox disabled, instead of closing immediately. No manual shortcut edits required.
## [0.0.9] - 2026-04-20
### Fixed
- **Open Terminal API Key Persistence.** The Open Terminal API key is now saved in config.json and reused across restarts instead of being regenerated on every startup, which was breaking existing integrations.
## [0.0.8] - 2026-04-11
### Added
+55 -62
View File
@@ -1,87 +1,80 @@
# Open WebUI Desktop
[![Version](https://img.shields.io/github/v/release/open-webui/desktop?label=version&color=%2331c48d)](https://github.com/open-webui/desktop/releases)
[![Downloads](https://img.shields.io/github/downloads/open-webui/desktop/total?color=%23764abc)](https://github.com/open-webui/desktop/releases)
[![Discord](https://img.shields.io/discord/1170866489302188073?label=discord&color=%235865F2)](https://discord.gg/open-webui)
[![License](https://img.shields.io/badge/license-AGPL--3.0-blue)](LICENSE)
![Open WebUI Desktop](./demo.png)
Your AI, right on your desktop. **Open WebUI Desktop** wraps the full [Open WebUI](https://github.com/open-webui/open-webui) experience into a native app you can install in seconds, with no Docker, terminal, or manual setup required. Just download, launch, and start chatting with local or remote models.
Your AI, right on your desktop. [Open WebUI](https://github.com/open-webui/open-webui) as a native app. Run models locally or connect to any server. No Docker, no terminal, no setup. Download, launch, chat.
> [!NOTE]
> Open WebUI Desktop is in **early alpha**. Things are moving fast and we'd love your feedback! Drop into the [Discord](https://discord.gg/5rJgQTnV4s) or open an issue if you hit a snag.
> [!WARNING]
> **Early Alpha.** Things move fast and stuff might break. [Report bugs](https://github.com/open-webui/desktop/issues) or [come hang out on Discord](https://discord.gg/open-webui).
## Download
Grab the installer for your platform and you're good to go. An internet connection is needed the first time you launch; after that the app works fully offline.
| Platform | Installer |
|----------|-----------|
| 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-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) |
### macOS
Internet required on first launch. After that, everything works offline. [All releases →](https://github.com/open-webui/desktop/releases)
| Chip | Installer |
|------|-----------|
| Apple Silicon | [**Download .dmg**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-arm64.dmg) |
| Intel | [**Download .dmg**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-x64.dmg) |
## How It Works
### Windows
🖥️ **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.
| Architecture | Installer |
|--------------|-----------|
| x64 | [**Download .exe**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-setup.exe) |
☁️ **Connect remotely.** Point the app at any Open WebUI server. Switch between multiple connections from the sidebar.
### Linux
| Format | Installer |
|--------|-----------|
| AppImage | [**Download .AppImage**](https://github.com/open-webui/desktop/releases/latest/download/open-webui.AppImage) |
| Debian / Ubuntu | [**Download .deb**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.deb) |
| Snap | [**Download .snap**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.snap) |
| Flatpak | [**Download .flatpak**](https://github.com/open-webui/desktop/releases/latest/download/open-webui.flatpak) |
> Looking for an older version? Check the [releases page](https://github.com/open-webui/desktop/releases).
Use both at the same time.
## Highlights
- **One-click setup.** Open WebUI and all its dependencies install automatically, no terminal required.
- **Spotlight quick-chat.** Press `⇧⌘I` to pop up a lightweight input bar and fire off a prompt from anywhere on your desktop.
- **Local inference.** The built-in llama.cpp integration lets you download and run models directly on your machine.
- **Connect anywhere.** Point the app at any remote Open WebUI server, or run one locally and switch between connections in the sidebar.
- **Offline-ready.** After the first launch, everything runs without an internet connection.
- **Cross-platform.** Available for macOS (Apple Silicon + Intel), Windows, and Linux.
- **Auto-updates.** New releases are detected and installed automatically.
- **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.** 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.
- 💻 **Cross-platform.** macOS, Windows, and Linux.
## System Requirements
| | Minimum |
|--|---------|
| **Disk** | 5 GB free space |
| **OS** | macOS 12+, Windows 10+, or a modern Linux distro |
| **RAM** | 8 GB recommended (more is better for local models) |
| | Local Models | Remote Only |
|--|-------------|-------------|
| **Disk** | 5 GB+ | ~500 MB |
| **RAM** | 16 GB+ | 4 GB |
| **OS** | macOS 12+, Windows 10+, modern Linux | Same |
## Contributing
> [!NOTE]
> Local models need serious RAM (7B ≈ 8 GB, 13B ≈ 16 GB). Lighter machine? Connect to a remote server instead.
Want to hack on the desktop app? Here's how to get a dev build running:
## Privacy
```bash
# Install dependencies
npm install
# Start the dev server with hot-reload
npm run dev
```
### Building for production
```bash
# macOS
npm run build:mac
# Windows
npm run build:win
# Linux
npm run build:linux
```
## License
This project is licensed under **AGPL-3.0**. See [LICENSE](LICENSE) for details.
No telemetry. No tracking. No phone-home. Your conversations stay on your machine. Period.
## Community
We'd love to have you around! Star the repo, join the [Discord](https://discord.gg/5rJgQTnV4s), or follow [Open WebUI on GitHub](https://github.com/open-webui/open-webui) for the latest updates.
- 💬 [Discord](https://discord.gg/open-webui) - Come hang out
- 🐛 [Issues](https://github.com/open-webui/desktop/issues) - Report bugs or request features
- 🌐 [Open WebUI](https://github.com/open-webui/open-webui) - The main project
- 📖 [Docs](https://docs.openwebui.com) - Full documentation
## Contributing
```bash
npm install
npm run dev
```
See [CHANGELOG.md](CHANGELOG.md) for release history. Licensed under [AGPL-3.0](LICENSE).
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.openwebui.open-webui</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>AGPL-3.0-or-later</project_license>
<name>Open WebUI</name>
<summary>The freedom AI stack</summary>
<developer id="com.timbaek">
<name>Timothy J. Baek</name>
</developer>
<description>
<p>
Open WebUI is an extensible, feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.
</p>
</description>
<launchable type="desktop-id">com.openwebui.open-webui.desktop</launchable>
<content_rating type="oars-1.1">
<content_attribute id="social-chat">intense</content_attribute>
</content_rating>
<url type="bugtracker">https://github.com/open-webui/desktop/issues</url>
<url type="homepage">https://openwebui.com</url>
<url type="donation">https://github.com/sponsors/tjbck</url>
<url type="vcs-browser">https://github.com/open-webui/desktop</url>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/open-webui/desktop/61f55e6fd6814b959b16a4704b03262e02186f48/demo.png</image>
</screenshot>
</screenshots>
<releases>
<release version="0.0.8" date="2026-04-11">
<url type="details">https://github.com/open-webui/desktop/releases/tag/v0.0.8</url>
</release>
<release version="0.0.6" date="2026-04-10">
<url type="details">https://github.com/open-webui/desktop/releases/tag/v0.0.6</url>
</release>
</releases>
</component>
+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.8",
"version": "0.0.15",
"license": "AGPL-3.0",
"description": "Open WebUI Desktop",
"main": "./out/main/index.js",
+429 -60
View File
@@ -89,10 +89,52 @@ log.transports.file.resolvePathFn = () => getLogFilePath('main')
import icon from '../../resources/icon.png?asset'
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')
// Disable GPU acceleration entirely on Linux. This prevents the GPU
// process from spawning, which avoids shared-memory allocation failures
// in /dev/shm or /tmp that crash the renderer on Ubuntu 24.04+, certain
// Wayland compositors, and AppArmor-restricted environments. The lighter
// --disable-gpu-compositing flag is insufficient because the GPU process
// still starts and attempts shared-memory IPC. Users confirmed that
// --disable-gpu resolves both the crash and grey/blank screen (#119, #157).
app.commandLine.appendSwitch('disable-gpu')
}
// ─── GPU Crash Recovery ─────────────────────────────────
// When the GPU process crashes fatally (common on certain NVIDIA/Intel
// driver + Windows combos), we write a marker file and relaunch with
// --disable-gpu-sandbox so the user doesn't have to manually edit
// shortcut properties. On the next launch the marker is detected and
// the switch is applied preemptively.
const gpuCrashMarkerPath = join(app.getPath('userData'), '.gpu-sandbox-disabled')
const gpuSandboxDisabled = existsSync(gpuCrashMarkerPath)
if (gpuSandboxDisabled) {
log.info('GPU sandbox disabled due to previous GPU process crash')
app.commandLine.appendSwitch('disable-gpu-sandbox')
}
// Prevent Chromium from permanently blocking WebGL / 3-D APIs after
// repeated GPU process crashes within the same session.
app.disableDomainBlockingFor3DAPIs()
// ─── State ──────────────────────────────────────────────
let mainWindow: BrowserWindow | null = null
@@ -112,50 +154,116 @@ let voiceInputRecording = false
// ─── Global Shortcuts ───────────────────────────────────
const registerShortcuts = (globalAccel?: string, spotlightAccel?: string, voiceInputAccel?: string): void => {
/**
* 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}`)
} catch (error) {
log.warn('Failed to register voice input shortcut:', voiceInputAccel, error)
}
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) {
tryRegisterShortcut(callAccel, 'Call', () => {
toggleCall()
})
} else {
log.info(`Call shortcut skipped — accel="${callAccel}", enabled=${CONFIG?.callEnabled}`)
}
}
// ─── Spotlight Window ───────────────────────────────────
@@ -212,18 +320,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
})
@@ -370,6 +470,46 @@ async function toggleVoiceInput(): Promise<void> {
return
}
// Pre-flight: check microphone permission on macOS
if (process.platform === 'darwin') {
const micStatus = systemPreferences.getMediaAccessStatus('microphone')
if (micStatus !== 'granted') {
const granted = await systemPreferences.askForMediaAccess('microphone')
if (!granted) {
log.warn('Voice input: microphone permission denied')
new Notification({
title: 'Voice Input',
body: 'Microphone access denied. Enable it in System Settings → Privacy & Security → Microphone, then restart the app.'
}).show()
return
}
}
}
// Pre-flight: check a connection is configured
try {
const config = await getConfig()
if (!config.defaultConnectionId || config.connections.length === 0) {
log.warn('Voice input: no connection configured')
new Notification({
title: 'Voice Input',
body: 'No connection configured. Set up a connection in Settings before using voice input.'
}).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)
}
// Start recording — chime plays concurrently (separate audio output path from mic input)
voiceInputRecording = true
playChime(true)
@@ -390,14 +530,106 @@ async function toggleVoiceInput(): Promise<void> {
}
}
// ─── Call Shortcut ──────────────────────────────────────
async function toggleCall(): Promise<void> {
// Pre-flight: check a connection is configured
try {
const config = await getConfig()
if (!config.defaultConnectionId || config.connections.length === 0) {
log.warn('Call: no connection configured')
new Notification({
title: 'Call',
body: 'No connection configured. Set up a connection in Settings before using the call shortcut.'
}).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')
}
sendToRenderer('call', { connectionId: conn.id, url })
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.show()
mainWindow.focus()
}
} catch (err: any) {
log.warn('Call: config check failed:', err)
}
}
// ─── 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',
@@ -414,9 +646,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()
}
@@ -438,6 +683,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) {
@@ -459,10 +715,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',
@@ -482,7 +738,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))
})
@@ -831,6 +1087,15 @@ const resetAppHandler = async () => {
} catch (e) {
log.warn('Failed to uninstall llama.cpp during reset:', e)
}
// Remove GPU crash marker so sandbox is re-tested on next launch
try {
if (existsSync(gpuCrashMarkerPath)) {
unlinkSync(gpuCrashMarkerPath)
log.info('GPU crash marker removed during reset')
}
} catch (e) {
log.warn('Failed to remove GPU crash marker during reset:', e)
}
await new Promise((resolve) => setTimeout(resolve, 1000))
await resetApp()
CONFIG = await getConfig() // reload from defaults since config.json was deleted
@@ -881,8 +1146,105 @@ if (!gotTheLock) {
}
electronApp.setAppUserModelId('com.openwebui.desktop')
// ─── GPU Process Crash Recovery ──────────────────
// If the GPU process exits fatally (e.g. sandbox init failure on
// certain NVIDIA/Intel drivers), write a marker and relaunch with
// --disable-gpu-sandbox so the user doesn't have to manually edit
// shortcut targets (see issue #110).
app.on('child-process-gone', (_event, details) => {
if (details.type === 'GPU') {
log.error(
`GPU process gone: reason=${details.reason}, exitCode=${details.exitCode}`
)
// Only auto-recover from fatal crashes, not normal/clean exits
if (
details.reason === 'crashed' ||
details.reason === 'launch-failed' ||
details.reason === 'abnormal-exit'
) {
if (!gpuSandboxDisabled) {
log.info('Writing GPU crash marker and relaunching with --disable-gpu-sandbox')
try {
writeFileSync(gpuCrashMarkerPath, new Date().toISOString(), 'utf-8')
} catch (e) {
log.warn('Failed to write GPU crash marker:', e)
}
app.relaunch({ args: [...process.argv.slice(1), '--disable-gpu-sandbox'] })
app.exit(0)
}
}
}
})
// If we previously set the GPU sandbox marker and this session
// started successfully, log it so it's visible in diagnostics.
if (gpuSandboxDisabled) {
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.
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}`
)
}
})
})
// ─── IPC Handlers ─────────────────────────────────
@@ -893,7 +1255,8 @@ if (!gotTheLock) {
version: app.getVersion(),
platform: process.platform,
arch: process.arch,
username: require('os').userInfo().username
username: require('os').userInfo().username,
gpuSandboxDisabled
}))
ipcMain.handle('app:contentPreloadPath', () => {
@@ -924,7 +1287,7 @@ if (!gotTheLock) {
CONFIG = await getConfig()
updateTray()
voiceInputRecording = false
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut)
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut, CONFIG.callShortcut)
})
// Python/uv
@@ -1116,9 +1479,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()
@@ -1126,7 +1488,6 @@ if (!gotTheLock) {
})
ipcMain.handle('spotlight:close', () => {
spotlightWindow?.hide()
// blur handler restores main window
})
// Persist bar offset within the fullscreen spotlight window
@@ -1236,10 +1597,10 @@ if (!gotTheLock) {
try {
const config = await getConfig()
if (!config.defaultConnectionId || config.connections.length === 0) {
throw new Error('No connection configured')
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')
if (!conn) throw new Error('Default connection not found. Check your connection settings.')
let url = conn.url
if (conn.type === 'local' && SERVER_URL) {
@@ -1274,7 +1635,7 @@ if (!gotTheLock) {
}
if (!token) {
throw new Error('Not authenticated — open a connection first')
throw new Error('Not authenticated. Open a connection and sign in before using voice input.')
}
// Build multipart form data manually using Node.js
@@ -1306,13 +1667,17 @@ if (!gotTheLock) {
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(`Transcription failed (${response.status}): ${text}`)
throw new Error(`Transcription failed (HTTP ${response.status}). ${text || 'Check that your server has Speech-to-Text configured.'}`)
}
const result = await response.json()
return result
} catch (error: any) {
log.error('voiceInput:transcribe failed:', error)
new Notification({
title: 'Voice Input Failed',
body: error?.message || 'Transcription failed. Check logs for details.'
}).show()
throw error
}
})
@@ -1370,6 +1735,10 @@ if (!gotTheLock) {
ipcMain.handle('voiceInput:error', (_event, message: string) => {
log.warn('Voice input error:', message)
voiceInputRecording = false
new Notification({
title: 'Voice Input Error',
body: message || 'An unknown error occurred with voice input.'
}).show()
})
// Open Terminal
@@ -1522,8 +1891,8 @@ if (!gotTheLock) {
ipcMain.handle('huggingface:models:delete', (_event, repo: string, filename: string) => {
return deleteModel(repo, filename)
})
ipcMain.handle('huggingface:models:cancel', () => {
cancelDownload()
ipcMain.handle('huggingface:models:cancel', (_event, repo?: string, filename?: string) => {
cancelDownload(repo, filename)
return true
})
ipcMain.handle('huggingface:search', async (_event, query: string, token?: string) => {
@@ -1627,7 +1996,7 @@ if (!gotTheLock) {
// Global shortcut
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut)
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut, CONFIG.callShortcut)
// Enable screen capture
session.defaultSession.setDisplayMediaRequestHandler(
+28 -10
View File
@@ -62,15 +62,28 @@ const writeManifest = (models: HfModel[]): void => {
// ─── Public API ─────────────────────────────────────────
let activeDownloadAbort: AbortController | null = null
const activeDownloads = new Map<string, AbortController>()
const downloadKey = (repo: string, filename: string): string => `${repo}/${filename}`
/**
* Cancel the current download in progress.
* Cancel a specific download in progress.
* If no repo/filename given, cancels ALL active downloads.
*/
export const cancelDownload = (): void => {
if (activeDownloadAbort) {
activeDownloadAbort.abort()
activeDownloadAbort = null
export const cancelDownload = (repo?: string, filename?: string): void => {
if (repo && filename) {
const key = downloadKey(repo, filename)
const ctrl = activeDownloads.get(key)
if (ctrl) {
ctrl.abort()
activeDownloads.delete(key)
}
} else {
// Cancel all
for (const ctrl of activeDownloads.values()) {
ctrl.abort()
}
activeDownloads.clear()
}
}
@@ -136,8 +149,13 @@ export const downloadModel = async (
headers['Authorization'] = `Bearer ${token}`
}
activeDownloadAbort = new AbortController()
const { signal } = activeDownloadAbort
const key = downloadKey(repo, filename)
// Cancel any existing download for the same file
activeDownloads.get(key)?.abort()
const abortController = new AbortController()
activeDownloads.set(key, abortController)
const { signal } = abortController
// Use fetch for streaming download with progress
const response = await fetch(downloadUrl, {
@@ -183,7 +201,7 @@ export const downloadModel = async (
writeStream.end()
// Clean up partial download
try { fs.unlinkSync(tmpPath) } catch {}
activeDownloadAbort = null
activeDownloads.delete(downloadKey(repo, filename))
throw err
} finally {
writeStream.end()
@@ -192,7 +210,7 @@ export const downloadModel = async (
// Rename tmp to final
fs.renameSync(tmpPath, destPath)
activeDownloadAbort = null
activeDownloads.delete(downloadKey(repo, filename))
// Update manifest
const manifest = readManifest()
+17 -5
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'
@@ -755,7 +755,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 +783,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
@@ -817,6 +817,7 @@ export interface AppConfig {
enabled: boolean
port: number
cwd: string
apiKey: string
}
llamaCpp: {
enabled: boolean
@@ -828,8 +829,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 = {
@@ -848,7 +854,8 @@ const DEFAULT_CONFIG: AppConfig = {
},
openTerminal: {
enabled: false,
cwd: ''
cwd: '',
apiKey: ''
},
llamaCpp: {
enabled: false,
@@ -859,8 +866,13 @@ const DEFAULT_CONFIG: AppConfig = {
envVars: {},
showSidebar: false,
spotlightPosition: null,
spotlightClipboardPaste: true,
voiceInputShortcut: 'Shift+CommandOrControl+Space',
voiceInputEnabled: true
voiceInputEnabled: true,
callShortcut: 'Shift+CommandOrControl+C',
callEnabled: true,
windowBounds: null,
windowMaximized: false
}
export const getConfig = async (): Promise<AppConfig> => {
+9 -2
View File
@@ -6,6 +6,7 @@ import * as pty from 'node-pty'
import {
getPythonPath,
getConfig,
setConfig,
installPackage,
isPackageInstalled,
isPythonInstalled,
@@ -63,8 +64,14 @@ export const startOpenTerminal = async (
const config = await getConfig()
const configEnvVars = config.envVars ?? {}
// Auto-generate API key
const generatedKey = crypto.randomBytes(24).toString('base64url')
// Use persisted API key or generate and save a new one
let generatedKey = config.openTerminal?.apiKey
if (!generatedKey) {
generatedKey = crypto.randomBytes(24).toString('base64url')
await setConfig({
openTerminal: { ...config.openTerminal, apiKey: generatedKey }
})
}
// Find available port
let desiredPort = port || 39284
+2 -1
View File
@@ -155,7 +155,8 @@ const api = {
ipcRenderer.invoke('huggingface:models:download', repo, filename, token, expectedSize),
deleteHfModel: (repo: string, filename: string) =>
ipcRenderer.invoke('huggingface:models:delete', repo, filename),
cancelHfDownload: () => ipcRenderer.invoke('huggingface:models:cancel'),
cancelHfDownload: (repo?: string, filename?: string) =>
ipcRenderer.invoke('huggingface:models:cancel', repo, filename),
searchHfModels: (query: string, token?: string) =>
ipcRenderer.invoke('huggingface:search', query, token),
getHfRepoFiles: (repo: string, token?: string) =>
+5
View File
@@ -10,6 +10,11 @@ const api = {
})
},
// Request microphone permission (macOS system-level)
checkMicPermission: (): Promise<string> => {
return ipcRenderer.invoke('voiceInput:micPermission')
},
// Send recorded audio to main process for transcription
transcribe: (audioBuffer: ArrayBuffer, token?: string): Promise<any> => {
return ipcRenderer.invoke('voiceInput:transcribe', audioBuffer, token)
+19 -2
View File
@@ -72,6 +72,15 @@
await new Promise((r) => setTimeout(r, 500))
try {
// Request system-level mic permission (macOS) before activating the mic
const permStatus = await api?.checkMicPermission()
if (permStatus === 'denied') {
const msg = 'Microphone access denied. Enable it in System Settings → Privacy & Security → Microphone, then restart the app.'
showError(msg)
api?.error(msg)
return
}
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
audioChunks = []
@@ -96,7 +105,13 @@
mediaRecorder.start(250)
timer = setInterval(() => { duration++ }, 1000)
} catch (err: any) {
showError(err?.message || 'Mic access failed')
const msg = err?.name === 'NotAllowedError'
? 'Microphone access denied. Check system permissions.'
: err?.name === 'NotFoundError'
? 'No microphone found. Connect a microphone and try again.'
: err?.message || 'Mic access failed'
showError(msg)
api?.error(msg)
}
}
@@ -176,7 +191,9 @@
api?.close()
}
} catch (err: any) {
showError(err?.message || 'Transcription failed')
const msg = err?.message || 'Transcription failed'
showError(msg)
api?.error(msg)
}
}
@@ -408,6 +408,28 @@
return
}
// ── Call shortcut ─────────────────────────────────
if (data.type === 'call' && data.data?.connectionId) {
const connId = data.data.connectionId ?? ''
const baseUrl = data.data.url ?? ''
if (!openConnections.has(connId)) {
openConnections.set(connId, baseUrl)
openConnections = new Map(openConnections)
connectedUrl = baseUrl
} else {
connectedUrl = openConnections.get(connId)!
}
activeConnectionId = connId
if (installPhase !== 'working') view = 'connected'
// Targeted delivery — wait a frame for the webview DOM to exist
requestAnimationFrame(() => {
sendToWebview({ type: 'call' }, connId)
})
return
}
// ── 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 }
@@ -72,6 +72,9 @@
// 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('')
@@ -83,11 +86,36 @@
)
)
const activeWebviewError = $derived(
view === 'connected' && activeConnectionId
? webviewErrors.get(activeConnectionId) ?? null
: null
)
const isLoading = $derived(
connectingId !== '' ||
(serverStarting && activeConnectionId === localConn?.id)
(serverStarting && activeConnectionId === localConn?.id) ||
(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') {
@@ -201,6 +274,48 @@
></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 }}>
@@ -30,7 +30,7 @@
<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 3"
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"
/>
{/if}
</svg>
@@ -105,6 +105,15 @@
let voiceInputShortcutInputEl = $state<HTMLButtonElement | null>(null)
let voiceInputEnabled = $state(true)
// Call shortcut recorder
let callShortcutValue = $state('')
let callRecording = $state(false)
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) {
@@ -116,6 +125,9 @@
if ($config?.spotlightShortcut !== undefined) {
spotlightShortcutValue = $config.spotlightShortcut ?? ''
}
if ($config?.spotlightClipboardPaste !== undefined) {
spotlightClipboardPaste = $config.spotlightClipboardPaste ?? true
}
})
$effect(() => {
@@ -127,6 +139,15 @@
}
})
$effect(() => {
if ($config?.callShortcut !== undefined) {
callShortcutValue = $config.callShortcut ?? ''
}
if ($config?.callEnabled !== undefined) {
callEnabled = $config.callEnabled ?? true
}
})
const keyToElectron = (e: KeyboardEvent): string | null => {
const parts: string[] = []
if (e.metaKey || e.ctrlKey) parts.push('CommandOrControl')
@@ -262,6 +283,32 @@
config.set(await window.electronAPI.getConfig())
}
}
const handleCallShortcutKeydown = async (e: KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.key === 'Escape') {
callRecording = false
return
}
if (e.key === 'Backspace' || e.key === 'Delete') {
callShortcutValue = ''
callRecording = false
await window.electronAPI.setConfig({ callShortcut: '' })
config.set(await window.electronAPI.getConfig())
return
}
const accel = keyToElectron(e)
if (accel) {
callShortcutValue = accel
callRecording = false
await window.electronAPI.setConfig({ callShortcut: accel })
config.set(await window.electronAPI.getConfig())
}
}
</script>
<div class="flex flex-col divide-y divide-white/[0.04]">
@@ -477,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>
@@ -549,6 +612,78 @@
</div>
{/if}
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">Call</div>
<div class="text-[11px] opacity-25 mt-0.5">Enable global shortcut to start a voice/video call</div>
</div>
<Switch
checked={callEnabled}
label="Toggle call shortcut"
onchange={async (value) => {
callEnabled = value
await window.electronAPI.setConfig({ callEnabled: value })
config.set(await window.electronAPI.getConfig())
}}
/>
</div>
{#if callEnabled}
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">Call Shortcut</div>
<div class="text-[11px] opacity-25 mt-0.5">
{#if callRecording}
Press a key combination…
{:else}
Start a call from anywhere
{/if}
</div>
</div>
<div class="flex items-center gap-1.5">
<button
bind:this={callShortcutInputEl}
class="text-[12px] px-3 py-1.5 border-none outline-none rounded-xl transition min-w-[80px] text-center
{callRecording
? 'bg-black/[0.08] dark:bg-white/[0.10] text-[#1d1d1f] dark:text-[#fafafa] opacity-80 animate-pulse'
: 'bg-black/[0.04] dark:bg-white/[0.06] text-[#1d1d1f] dark:text-[#fafafa] opacity-60 hover:opacity-80'}"
onclick={() => {
callRecording = true
callShortcutInputEl?.focus()
}}
onkeydown={(e) => {
if (callRecording) handleCallShortcutKeydown(e)
}}
onblur={() => {
callRecording = false
}}
>
{#if callRecording}
<span class="text-[11px]">Press keys…</span>
{:else if callShortcutValue}
{displayShortcut(callShortcutValue)}
{:else}
<span class="opacity-40">Disabled</span>
{/if}
</button>
{#if callShortcutValue && !callRecording}
<button
class="opacity-20 hover:opacity-50 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 shrink-0"
onclick={async () => {
callShortcutValue = ''
await window.electronAPI.setConfig({ callShortcut: '' })
config.set(await window.electronAPI.getConfig())
}}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
</div>
{/if}
<!-- Advanced (collapsed by default) -->
<div class="py-4">
<button
@@ -41,8 +41,10 @@
let repoFiles = $state<HfFileInfo[]>([])
let loadingFiles = $state(false)
// Download state — track active download in the "Downloaded" section
let activeDownload = $state<{ repo: string; filename: string; percent: number } | null>(null)
// Download state — track active downloads in the "Downloaded" section
let activeDownloads = $state<Map<string, { repo: string; filename: string; percent: number }>>(new Map())
const dlKey = (repo: string, filename: string): string => `${repo}/${filename}`
onMount(async () => {
models = await window.electronAPI.listHfModels()
@@ -52,15 +54,22 @@
window.electronAPI.onData((data: any) => {
if (data.type === 'status:huggingface-download') {
const d = data.data
const key = dlKey(d.repo, d.filename)
if (d?.status === 'downloading') {
activeDownload = { repo: d.repo, filename: d.filename, percent: d.percent ?? 0 }
const updated = new Map(activeDownloads)
updated.set(key, { repo: d.repo, filename: d.filename, percent: d.percent ?? 0 })
activeDownloads = updated
}
if (d?.status === 'done') {
activeDownload = null
const updated = new Map(activeDownloads)
updated.delete(key)
activeDownloads = updated
window.electronAPI.listHfModels().then((m: HfModel[]) => { models = m })
}
if (d?.status === 'failed') {
activeDownload = null
const updated = new Map(activeDownloads)
updated.delete(key)
activeDownloads = updated
}
}
})
@@ -109,22 +118,29 @@
}
const startDownload = async (repo: string, filename: string, size?: number) => {
activeDownload = { repo, filename, percent: 0 }
const key = dlKey(repo, filename)
const updated = new Map(activeDownloads)
updated.set(key, { repo, filename, percent: 0 })
activeDownloads = updated
try {
await window.electronAPI.downloadHfModel(repo, filename, undefined, size)
} catch (e) {
console.error('Failed to download model:', e)
activeDownload = null
const cleaned = new Map(activeDownloads)
cleaned.delete(key)
activeDownloads = cleaned
}
}
const cancelDownload = async () => {
const cancelDownload = async (repo: string, filename: string) => {
try {
await window.electronAPI.cancelHfDownload()
await window.electronAPI.cancelHfDownload(repo, filename)
} catch (e) {
console.error('Failed to cancel download:', e)
}
activeDownload = null
const updated = new Map(activeDownloads)
updated.delete(dlKey(repo, filename))
activeDownloads = updated
}
const removeModel = async (repo: string, filename: string) => {
@@ -143,9 +159,15 @@
}
const isDownloading = (repo: string, filename: string): boolean => {
return activeDownload?.repo === repo && activeDownload?.filename === filename
return activeDownloads.has(dlKey(repo, filename))
}
const getDownloadPercent = (repo: string, filename: string): number => {
return activeDownloads.get(dlKey(repo, filename))?.percent ?? 0
}
const hasActiveDownloads = $derived(activeDownloads.size > 0)
const formatSize = (bytes: number): string => {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
@@ -180,50 +202,50 @@
</button>
</div>
<!-- Downloaded models + active download -->
<!-- Downloaded models + active downloads -->
<div class="py-4">
<div class="text-[12px] opacity-50 mb-2">{$i18n.t('settings.models.downloadedModels')}</div>
{#if models.length > 0 || activeDownload}
<div class="flex flex-col gap-1.5">
{#if models.length > 0 || hasActiveDownloads}
<div class="flex flex-col">
<!-- Active download in progress -->
{#if activeDownload}
<div class="px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] rounded-xl">
<div class="flex items-center justify-between gap-2 mb-1.5">
<div class="min-w-0 flex-1">
<div class="text-[12px] opacity-60 truncate font-mono">{activeDownload.filename}</div>
<div class="text-[10px] opacity-25 truncate">{activeDownload.repo} · {$i18n.t('common.downloading')}</div>
<!-- Active downloads -->
{#each [...activeDownloads.values()] as dl (dlKey(dl.repo, dl.filename))}
<div class="flex items-center gap-3 py-2 group">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-[12px] opacity-60 truncate font-mono">{dl.filename}</span>
<span class="text-[10px] opacity-30 font-mono shrink-0">{dl.percent.toFixed(1)}%</span>
</div>
<button
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0"
onclick={cancelDownload}
title={$i18n.t('settings.models.cancelDownload')}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div class="mt-1.5 w-full h-[3px] bg-black/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
<div
class="h-full bg-emerald-400/70 rounded-full transition-[width] duration-300"
style="width: {dl.percent}%"
></div>
</div>
<div class="text-[10px] opacity-20 mt-1 truncate">{dl.repo}</div>
</div>
<div class="w-full h-1 bg-black/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
<div
class="h-full bg-emerald-400/80 rounded-full"
style="width: {activeDownload.percent}%"
></div>
</div>
<div class="text-[10px] opacity-25 mt-1 text-right font-mono">{activeDownload.percent.toFixed(1)}%</div>
<button
class="opacity-0 group-hover:opacity-40 hover:!opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0"
onclick={() => cancelDownload(dl.repo, dl.filename)}
title={$i18n.t('settings.models.cancelDownload')}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/if}
{/each}
<!-- Completed downloads -->
{#each models as model}
<div class="flex items-center justify-between gap-2 px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] rounded-xl">
<div class="flex items-center gap-3 py-2 group">
<div class="min-w-0 flex-1">
<div class="text-[12px] opacity-60 truncate font-mono">{model.filename}</div>
<div class="text-[10px] opacity-25 truncate">{model.repo} · {formatSize(model.size)}</div>
<div class="text-[10px] opacity-20 truncate mt-0.5">{model.repo} · {formatSize(model.size)}</div>
</div>
<button
class="opacity-20 hover:opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0 {deleting === `${model.repo}/${model.filename}` ? 'pointer-events-none' : ''}"
class="opacity-0 group-hover:opacity-30 hover:!opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0 {deleting === `${model.repo}/${model.filename}` ? '!opacity-30 pointer-events-none' : ''}"
onclick={() => removeModel(model.repo, model.filename)}
title={$i18n.t('settings.models.deleteModel')}
>
@@ -239,7 +261,7 @@
{/each}
</div>
{:else}
<div class="text-[11px] opacity-40 py-3">{$i18n.t('settings.models.noModels')}</div>
<div class="text-[11px] opacity-20 py-3">{$i18n.t('settings.models.noModels')}</div>
{/if}
</div>
@@ -248,13 +270,13 @@
<div class="text-[12px] opacity-50 mb-2">
{#if selectedRepo}
<button
class="opacity-50 hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 text-[12px] flex items-center gap-1"
class="opacity-70 hover:opacity-100 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 text-[12px] flex items-center gap-1 font-mono truncate"
onclick={backToSearch}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<svg class="w-3 h-3 shrink-0 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
{$i18n.t('common.back')}
<span class="truncate">{selectedRepo}</span>
</button>
{:else}
{$i18n.t('settings.models.downloadFromHF')}
@@ -263,10 +285,6 @@
{#if selectedRepo}
<!-- Repo file browser -->
<div class="mb-2">
<div class="text-[12px] opacity-60 font-mono truncate mb-2">{selectedRepo}</div>
</div>
{#if loadingFiles}
<div class="flex items-center gap-2 py-3 justify-center">
<div class="w-3 h-3 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
@@ -275,27 +293,42 @@
{:else if repoFiles.length === 0}
<div class="text-[11px] opacity-20 text-center py-3">{$i18n.t('settings.models.noGgufFiles')}</div>
{:else}
<div class="flex flex-col gap-1">
<div class="flex flex-col">
{#each repoFiles as file}
{@const downloaded = isDownloaded(selectedRepo, file.filename)}
{@const dlActive = isDownloading(selectedRepo, file.filename)}
<div class="flex items-center justify-between gap-2 px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] rounded-xl">
<div class="flex items-center gap-3 py-2 group">
<div class="min-w-0 flex-1">
<div class="text-[12px] opacity-50 truncate font-mono">{file.filename}</div>
<div class="text-[10px] opacity-25">{formatSize(file.size)}</div>
<div class="text-[10px] opacity-20 mt-0.5">{formatSize(file.size)}</div>
{#if dlActive}
<div class="mt-1.5 w-full h-[3px] bg-black/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
<div
class="h-full bg-emerald-400/70 rounded-full transition-[width] duration-300"
style="width: {getDownloadPercent(selectedRepo, file.filename)}%"
></div>
</div>
{/if}
</div>
{#if downloaded}
<span class="text-[10px] opacity-30 shrink-0 px-2">{$i18n.t('settings.models.downloaded')}</span>
<span class="text-[10px] opacity-25 shrink-0">{$i18n.t('settings.models.downloaded')}</span>
{:else if dlActive}
<div class="flex items-center gap-1.5 shrink-0">
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
<span class="text-[10px] opacity-40 font-mono">{activeDownload?.percent?.toFixed(0) ?? 0}%</span>
<span class="text-[10px] opacity-40 font-mono">{getDownloadPercent(selectedRepo, file.filename).toFixed(0)}%</span>
<button
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5"
onclick={() => cancelDownload(selectedRepo, file.filename)}
title={$i18n.t('settings.models.cancelDownload')}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{:else}
<button
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0 {activeDownload ? 'pointer-events-none opacity-10' : ''}"
class="opacity-0 group-hover:opacity-40 hover:!opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0"
onclick={() => startDownload(selectedRepo, file.filename, file.size)}
disabled={!!activeDownload}
title={$i18n.t('common.download')}
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
@@ -329,10 +362,10 @@
{#if searchError}
<div class="text-[11px] text-red-400/70 text-center py-2">{searchError}</div>
{:else if searchResults.length > 0}
<div class="flex flex-col gap-1 max-h-[300px] overflow-y-auto">
<div class="flex flex-col max-h-[300px] overflow-y-auto">
{#each searchResults as repo}
<button
class="flex items-center justify-between gap-2 px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] hover:bg-black/[0.06] dark:hover:bg-white/[0.08] rounded-xl transition border-none text-left w-full text-[#1d1d1f] dark:text-[#fafafa]"
class="flex items-center justify-between gap-2 py-2 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] rounded-lg transition border-none text-left w-full text-[#1d1d1f] dark:text-[#fafafa] bg-transparent px-1"
onclick={() => selectRepo(repo.id)}
>
<div class="min-w-0 flex-1">
@@ -352,7 +385,7 @@
</span>
</div>
</div>
<svg class="w-3 h-3 opacity-20 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<svg class="w-3 h-3 opacity-15 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
@@ -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…",