mirror of
https://github.com/open-webui/desktop.git
synced 2026-07-15 04:35:40 -04:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa64bc02b5 | |||
| 20f0aaf40c | |||
| c64e946b38 | |||
| 1902f791cb | |||
| c2f128aec0 | |||
| 64c399738e | |||
| 842c1481f6 | |||
| e7eef2da86 | |||
| aab2a687cc | |||
| 9e204e78b7 | |||
| ef53d6fb21 | |||
| 21e8a36e0d | |||
| fcb32f93ab | |||
| 48be8d0386 | |||
| 4cab91de4e |
+150
-21
@@ -42,8 +42,12 @@ jobs:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
arch: x64
|
||||
- os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
- os: windows-latest
|
||||
arch: x64
|
||||
- os: windows-11-arm
|
||||
arch: arm64
|
||||
- os: macos-latest
|
||||
arch: x64
|
||||
- os: macos-latest
|
||||
@@ -67,9 +71,9 @@ jobs:
|
||||
name: compiled-output
|
||||
path: out/
|
||||
|
||||
# ── Flatpak setup (Linux only) ──
|
||||
# ── Flatpak setup (Linux x64 only) ──
|
||||
- name: Cache Flatpak SDKs
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: runner.os == 'Linux' && matrix.arch == 'x64'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.local/share/flatpak
|
||||
@@ -77,7 +81,7 @@ jobs:
|
||||
|
||||
- name: Install Flatpak build tools
|
||||
id: flatpak
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: runner.os == 'Linux' && matrix.arch == 'x64'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -113,12 +117,12 @@ jobs:
|
||||
# ── Platform packaging ──
|
||||
- name: Package for Windows
|
||||
id: win_build
|
||||
if: matrix.os == 'windows-latest'
|
||||
if: runner.os == 'Windows'
|
||||
continue-on-error: true
|
||||
run: npx electron-builder --win --${{ matrix.arch }} --publish never
|
||||
|
||||
- name: Package for Windows (unsigned fallback)
|
||||
if: matrix.os == 'windows-latest' && steps.win_build.outcome == 'failure'
|
||||
if: runner.os == 'Windows' && steps.win_build.outcome == 'failure'
|
||||
env:
|
||||
WIN_CSC_LINK: ''
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
@@ -147,9 +151,12 @@ jobs:
|
||||
npx electron-builder --mac --${{ matrix.arch }} --publish never
|
||||
|
||||
- name: Package for Linux
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
if [ "${{ steps.flatpak.outcome }}" == "success" ]; then
|
||||
if [ "${{ matrix.arch }}" == "arm64" ]; then
|
||||
# ARM64: deb + AppImage only (flatpak BaseApp not available for arm64)
|
||||
npx electron-builder --linux AppImage deb --${{ matrix.arch }} --publish never
|
||||
elif [ "${{ steps.flatpak.outcome }}" == "success" ]; then
|
||||
npx electron-builder --linux --${{ matrix.arch }} --publish never
|
||||
else
|
||||
echo "Flatpak not available, building without flatpak"
|
||||
@@ -158,7 +165,7 @@ jobs:
|
||||
|
||||
# ── Windows code signing ──
|
||||
- name: Azure Trusted Signing (Windows Only)
|
||||
if: matrix.os == 'windows-latest'
|
||||
if: runner.os == 'Windows'
|
||||
continue-on-error: true
|
||||
uses: azure/trusted-signing-action@v0.5.1
|
||||
with:
|
||||
@@ -239,6 +246,9 @@ jobs:
|
||||
pattern: '*-*'
|
||||
merge-multiple: false
|
||||
|
||||
- name: Install js-yaml for manifest merging
|
||||
run: npm install --no-save js-yaml
|
||||
|
||||
- name: Merge macOS latest-mac.yml (x64 + arm64)
|
||||
run: |
|
||||
# Each macOS arch build produces its own latest-mac.yml with only
|
||||
@@ -248,24 +258,143 @@ 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"
|
||||
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'));
|
||||
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 +406,18 @@ jobs:
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: |
|
||||
windows-latest-*/*.exe
|
||||
windows-latest-*/*.blockmap
|
||||
windows-latest-*/latest*.yml
|
||||
windows-*-*/*.exe
|
||||
windows-*-*/*.blockmap
|
||||
windows-*-*/latest*.yml
|
||||
macos-latest-*/*.dmg
|
||||
macos-latest-*/*.zip
|
||||
macos-latest-*/*.pkg
|
||||
macos-latest-*/*.blockmap
|
||||
macos-latest-*/latest*.yml
|
||||
ubuntu-latest-*/*.deb
|
||||
ubuntu-latest-*/*.rpm
|
||||
ubuntu-latest-*/*.AppImage
|
||||
ubuntu-latest-*/*.snap
|
||||
ubuntu-latest-*/*.flatpak
|
||||
ubuntu-latest-*/*.tar.gz
|
||||
ubuntu-latest-*/latest*.yml
|
||||
ubuntu-*-*/*.deb
|
||||
ubuntu-*-*/*.rpm
|
||||
ubuntu-*-*/*.AppImage
|
||||
ubuntu-*-*/*.snap
|
||||
ubuntu-*-*/*.flatpak
|
||||
ubuntu-*-*/*.tar.gz
|
||||
ubuntu-*-*/latest*.yml
|
||||
|
||||
@@ -5,6 +5,50 @@ 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.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
|
||||
|
||||
@@ -18,11 +18,14 @@ Your AI, right on your desktop. [Open WebUI](https://github.com/open-webui/open-
|
||||
|----------|-----------|
|
||||
| macOS (Apple Silicon) | [**Download .dmg**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-arm64.dmg) |
|
||||
| macOS (Intel) | [**Download .dmg**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-x64.dmg) |
|
||||
| Windows x64 | [**Download .exe**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-setup.exe) |
|
||||
| Linux (AppImage) | [**Download .AppImage**](https://github.com/open-webui/desktop/releases/latest/download/open-webui.AppImage) |
|
||||
| Linux (Debian/Ubuntu) | [**Download .deb**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.deb) |
|
||||
| Linux (Snap) | [**Download .snap**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.snap) |
|
||||
| Linux (Flatpak) | [**Download .flatpak**](https://github.com/open-webui/desktop/releases/latest/download/open-webui.flatpak) |
|
||||
| Windows x64 | [**Download .exe**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-x64-setup.exe) |
|
||||
| Windows ARM64 | [**Download .exe**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-arm64-setup.exe) |
|
||||
| Linux x64 (AppImage) | [**Download .AppImage**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_x64.AppImage) |
|
||||
| Linux x64 (Debian/Ubuntu) | [**Download .deb**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.deb) |
|
||||
| Linux x64 (Snap) | [**Download .snap**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.snap) |
|
||||
| Linux x64 (Flatpak) | [**Download .flatpak**](https://github.com/open-webui/desktop/releases/latest/download/open-webui.flatpak) |
|
||||
| Linux ARM64 (AppImage) | [**Download .AppImage**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_arm64.AppImage) |
|
||||
| Linux ARM64 (Debian/Ubuntu) | [**Download .deb**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_arm64.deb) |
|
||||
|
||||
Internet required on first launch. After that, everything works offline. [All releases →](https://github.com/open-webui/desktop/releases)
|
||||
|
||||
@@ -51,7 +54,7 @@ Use both at the same time.
|
||||
|--|-------------|-------------|
|
||||
| **Disk** | 5 GB+ | ~500 MB |
|
||||
| **RAM** | 16 GB+ | 4 GB |
|
||||
| **OS** | macOS 12+, Windows 10+, modern Linux | Same |
|
||||
| **OS** | macOS 12+, Windows 10+, modern Linux (glibc 2.28+) | Same |
|
||||
|
||||
> [!NOTE]
|
||||
> Local models need serious RAM (7B ≈ 8 GB, 13B ≈ 16 GB). Lighter machine? Connect to a remote server instead.
|
||||
|
||||
@@ -19,7 +19,7 @@ asarUnpack:
|
||||
win:
|
||||
executableName: open-webui
|
||||
nsis:
|
||||
artifactName: ${name}-setup.${ext}
|
||||
artifactName: ${name}-${arch}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
@@ -63,7 +63,7 @@ deb:
|
||||
snap:
|
||||
artifactName: ${name}_${arch}.${ext}
|
||||
appImage:
|
||||
artifactName: ${name}.${ext}
|
||||
artifactName: ${name}_${arch}.${ext}
|
||||
flatpak:
|
||||
base: org.electronjs.Electron2.BaseApp
|
||||
baseVersion: '23.08'
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.0.14",
|
||||
"version": "0.0.19",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Open WebUI Desktop",
|
||||
"main": "./out/main/index.js",
|
||||
|
||||
+118
-21
@@ -106,13 +106,26 @@ if (process.platform === 'linux') {
|
||||
// to work (the portal is enabled by default in Chromium 134+ / Electron 33+).
|
||||
app.commandLine.appendSwitch('ozone-platform-hint', 'auto')
|
||||
|
||||
// Disable GPU compositing to prevent grey/blank webview rendering on
|
||||
// Linux systems with problematic Intel/NVIDIA drivers or certain Wayland
|
||||
// compositors. The GPU process may not crash (so the crash-recovery
|
||||
// marker never fires), but the compositor can fail silently — producing
|
||||
// a grey rectangle instead of rendered content. This is the standard
|
||||
// workaround used by VS Code and other Electron apps (#119).
|
||||
app.commandLine.appendSwitch('disable-gpu-compositing')
|
||||
// Run the GPU service in-process instead of in a separate sandboxed
|
||||
// process. The out-of-process GPU crashes on Ubuntu 24.04+, certain
|
||||
// Wayland compositors, and AppArmor-restricted environments because of
|
||||
// shared-memory allocation failures in /dev/shm or /tmp (#119, #157).
|
||||
//
|
||||
// Previous attempts:
|
||||
// --disable-gpu-compositing → GPU process still spawns & crashes.
|
||||
// --disable-gpu → fixes crashes but kills the display
|
||||
// compositor, so <webview> guest surfaces
|
||||
// are never painted (gray rectangle #178).
|
||||
//
|
||||
// --in-process-gpu moves the GPU thread into the browser process, which
|
||||
// sidesteps the cross-process shared-memory IPC entirely while keeping
|
||||
// the display compositor alive so webview content renders normally.
|
||||
app.commandLine.appendSwitch('in-process-gpu')
|
||||
|
||||
// Disable the GPU sandbox — it is the sandbox setup that triggers the
|
||||
// shared-memory failures. With --in-process-gpu the GPU thread lives
|
||||
// in the browser process which is already un-sandboxed (--no-sandbox).
|
||||
app.commandLine.appendSwitch('disable-gpu-sandbox')
|
||||
}
|
||||
|
||||
// ─── GPU Crash Recovery ─────────────────────────────────
|
||||
@@ -294,6 +307,9 @@ function createSpotlightWindow(): BrowserWindow {
|
||||
hasShadow: false,
|
||||
show: false,
|
||||
focusable: true,
|
||||
// Ensure the window appears on whichever Space/desktop the user is
|
||||
// currently on, rather than pulling them back to the primary Space.
|
||||
visibleOnAllWorkspaces: true,
|
||||
icon: path.join(__dirname, 'assets/icon.png'),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/spotlight-preload.js'),
|
||||
@@ -319,18 +335,10 @@ function createSpotlightWindow(): BrowserWindow {
|
||||
spotlightWindow.on('blur', () => {
|
||||
if (blurArmed) {
|
||||
spotlightWindow?.hide()
|
||||
// Restore main window when spotlight dismisses
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.show()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
spotlightWindow.on('closed', () => {
|
||||
// Restore main window if spotlight is closed
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.show()
|
||||
}
|
||||
spotlightWindow = null
|
||||
})
|
||||
|
||||
@@ -338,8 +346,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
|
||||
@@ -1243,6 +1255,10 @@ if (!gotTheLock) {
|
||||
|
||||
// Log webview guest renderer crashes for diagnostics — the existing
|
||||
// 'crashed' listener in Content.svelte surfaces these to the user.
|
||||
//
|
||||
// For webview guests we also intercept navigation and popup events
|
||||
// so that external links open in the user's default browser instead
|
||||
// of navigating the webview or spawning a new Electron window (#165).
|
||||
app.on('web-contents-created', (_event, contents) => {
|
||||
contents.on('render-process-gone', (_e, details) => {
|
||||
if (details.reason !== 'clean-exit') {
|
||||
@@ -1252,6 +1268,85 @@ if (!gotTheLock) {
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if (contents.getType() === 'webview') {
|
||||
// ── Popups (target="_blank" links) → open in default browser ──
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
openUrl(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// ── In-page navigation to a different origin → open externally ──
|
||||
// This catches regular link clicks (no target) that would navigate
|
||||
// the webview away from the Open WebUI instance.
|
||||
contents.on('will-navigate', (event, url) => {
|
||||
try {
|
||||
const currentOrigin = new URL(contents.getURL()).origin
|
||||
const targetOrigin = new URL(url).origin
|
||||
if (targetOrigin !== currentOrigin) {
|
||||
event.preventDefault()
|
||||
openUrl(url)
|
||||
}
|
||||
} catch {
|
||||
// Malformed URL — let it through so Chromium can handle/reject it
|
||||
}
|
||||
})
|
||||
|
||||
// ── Native right-click context menu (#161) ──────────────────
|
||||
// Electron <webview> guests don't show a context menu by default,
|
||||
// which blocks right-click → Paste / Autofill / password-manager
|
||||
// integration on login pages. Build a native menu with standard
|
||||
// editing actions, spell-check suggestions, and link handling.
|
||||
contents.on('context-menu', (_event, params) => {
|
||||
const menuItems: Electron.MenuItemConstructorOptions[] = []
|
||||
|
||||
// Spell-check suggestions (if any)
|
||||
if (params.misspelledWord && params.dictionarySuggestions?.length) {
|
||||
for (const suggestion of params.dictionarySuggestions) {
|
||||
menuItems.push({
|
||||
label: suggestion,
|
||||
click: () => contents.replaceMisspelling(suggestion)
|
||||
})
|
||||
}
|
||||
menuItems.push({ type: 'separator' })
|
||||
}
|
||||
|
||||
// Link handling
|
||||
if (params.linkURL) {
|
||||
menuItems.push({
|
||||
label: 'Open Link in Browser',
|
||||
click: () => openUrl(params.linkURL)
|
||||
})
|
||||
menuItems.push({
|
||||
label: 'Copy Link',
|
||||
click: () => clipboard.writeText(params.linkURL)
|
||||
})
|
||||
menuItems.push({ type: 'separator' })
|
||||
}
|
||||
|
||||
// Editable field actions (input, textarea, contenteditable)
|
||||
if (params.isEditable) {
|
||||
menuItems.push(
|
||||
{ label: 'Undo', role: 'undo', enabled: params.editFlags.canUndo },
|
||||
{ label: 'Redo', role: 'redo', enabled: params.editFlags.canRedo },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Cut', role: 'cut', enabled: params.editFlags.canCut },
|
||||
{ label: 'Copy', role: 'copy', enabled: params.editFlags.canCopy },
|
||||
{ label: 'Paste', role: 'paste', enabled: params.editFlags.canPaste },
|
||||
{ label: 'Select All', role: 'selectAll', enabled: params.editFlags.canSelectAll }
|
||||
)
|
||||
} else if (params.selectionText) {
|
||||
// Non-editable text selection
|
||||
menuItems.push(
|
||||
{ label: 'Copy', role: 'copy', enabled: params.editFlags.canCopy }
|
||||
)
|
||||
}
|
||||
|
||||
if (menuItems.length > 0) {
|
||||
Menu.buildFromTemplate(menuItems).popup()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ─── IPC Handlers ─────────────────────────────────
|
||||
@@ -1486,9 +1581,8 @@ if (!gotTheLock) {
|
||||
|
||||
sendToRenderer('query', { query, connectionId: conn.id, url, files })
|
||||
|
||||
// Hide spotlight first (blur handler will restore main window)
|
||||
spotlightWindow?.hide()
|
||||
// Ensure main window is focused to receive the query
|
||||
// Show main window so it can receive and display the submitted query
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
@@ -1496,7 +1590,6 @@ if (!gotTheLock) {
|
||||
})
|
||||
ipcMain.handle('spotlight:close', () => {
|
||||
spotlightWindow?.hide()
|
||||
// blur handler restores main window
|
||||
})
|
||||
|
||||
// Persist bar offset within the fullscreen spotlight window
|
||||
@@ -1754,7 +1847,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
|
||||
@@ -2025,7 +2120,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) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Downloads files from HF repos, manages a local model cache,
|
||||
* and provides listing/deletion of cached models.
|
||||
*
|
||||
* Cache dir: <userData>/models/huggingface/<repo-slug>/<filename>
|
||||
* Cache dir: <userData>/models/<repo-slug>/<filename>
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
@@ -33,10 +33,37 @@ export interface HfDownloadProgress {
|
||||
// ─── Paths ──────────────────────────────────────────────
|
||||
|
||||
const getHfCacheDir = (): string => {
|
||||
const dir = path.join(getInstallDir(), 'models', 'huggingface')
|
||||
const dir = path.join(getInstallDir(), 'models')
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
// Migrate models from legacy models/huggingface/<slug>/ to models/<slug>/
|
||||
const legacyDir = path.join(dir, 'huggingface')
|
||||
if (fs.existsSync(legacyDir)) {
|
||||
try {
|
||||
const entries = fs.readdirSync(legacyDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const src = path.join(legacyDir, entry.name)
|
||||
const dest = path.join(dir, entry.name)
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.renameSync(src, dest)
|
||||
log.info(`[huggingface] Migrated ${entry.name} from legacy cache`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove legacy dir if empty (manifest.json may remain)
|
||||
const remaining = fs.readdirSync(legacyDir)
|
||||
if (remaining.length === 0) {
|
||||
fs.rmdirSync(legacyDir)
|
||||
log.info('[huggingface] Removed empty legacy huggingface/ directory')
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('[huggingface] Failed to migrate legacy cache:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
|
||||
+42
-33
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -62,6 +62,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'
|
||||
@@ -432,7 +433,8 @@
|
||||
|
||||
// ── 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 }
|
||||
@@ -486,8 +488,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 +499,7 @@
|
||||
} else {
|
||||
openTerminalStatus = 'failed'
|
||||
}
|
||||
openTerminalSetupStatus = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,7 +585,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)}
|
||||
|
||||
@@ -268,9 +268,9 @@
|
||||
src={connUrl}
|
||||
class="flex-1 min-h-0 border-none"
|
||||
style="display: {view === 'connected' && activeConnectionId === connId ? 'flex' : 'none'}"
|
||||
allowpopups
|
||||
partition="persist:connection-{connId}"
|
||||
preload={contentPreloadPath}
|
||||
allowpopups
|
||||
></webview>
|
||||
{/each}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user