Compare commits

..

14 Commits

Author SHA1 Message Date
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
10 changed files with 473 additions and 77 deletions
+35
View File
@@ -5,6 +5,41 @@ 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.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
+3 -3
View File
@@ -28,7 +28,7 @@ Internet required on first launch. After that, everything works offline. [All re
## How It Works
🖥️ **Run locally.** The app sets up Open WebUI and llama.cpp on your machine. Download models, chat offline, keep everything private. Nothing leaves your computer.
🖥️ **Run locally.** The app runs Open WebUI on your machine. You can optionally enable the built-in llama.cpp engine to download and run models offline. Nothing leaves your computer.
☁️ **Connect remotely.** Point the app at any Open WebUI server. Switch between multiple connections from the sidebar.
@@ -38,8 +38,8 @@ Use both at the same time.
-**Spotlight.** Hit `Shift+Cmd+I` (macOS) or `Shift+Ctrl+I` (Windows/Linux) to summon a floating chat bar over whatever you're doing. Drag to screenshot anything on screen.
- 🎙️ **Voice input.** System-wide push-to-talk. Press the shortcut from any app to record, and your speech is transcribed and sent to your chat automatically.
- 🧠 **Local inference.** Download and run models entirely on your hardware. Your data never leaves your machine.
- 🎯 **One-click setup.** Everything installs itself. Just click "Get Started."
- 🧠 **Local inference.** Optionally run models entirely on your hardware via the built-in llama.cpp engine. Your data never leaves your machine.
- 🎯 **One-click setup.** Launch and connect to a server in seconds. Local models can be enabled from the settings.
- 🔌 **Multiple connections.** Juggle servers and switch between them instantly.
- 🔄 **Auto-updates.** New releases land in the background.
- 📡 **Offline-ready.** No internet needed after initial setup.
+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>
+3 -1
View File
@@ -15,6 +15,7 @@ extraResources:
to: CHANGELOG.md
asarUnpack:
- resources/**
- node_modules/node-pty/**
win:
executableName: open-webui
nsis:
@@ -79,7 +80,8 @@ flatpak:
- --device=dri
- --filesystem=home
- --talk-name=org.freedesktop.Notifications
npmRebuild: false
- --talk-name=org.freedesktop.portal.Desktop
npmRebuild: true
publish:
provider: github
owner: open-webui
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.0.10",
"version": "0.0.14",
"license": "AGPL-3.0",
"description": "Open WebUI Desktop",
"main": "./out/main/index.js",
+252 -67
View File
@@ -93,6 +93,26 @@ 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 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')
}
// ─── GPU Crash Recovery ─────────────────────────────────
@@ -133,81 +153,113 @@ let voiceInputRecording = false
// ─── Global Shortcuts ───────────────────────────────────
/**
* Check whether the current environment supports Electron's globalShortcut
* API. Since Chromium 134+ (Electron 33+) the GlobalShortcutsPortal
* feature is enabled by default, which lets `globalShortcut.register()`
* work transparently on Wayland via `xdg-desktop-portal`. Combined with
* `--ozone-platform-hint=auto` (set above for Linux), shortcuts should
* "just work" on most modern desktops.
*
* We only bail out when we can positively detect an environment where
* neither X11 key-grabs nor the portal will succeed (e.g. an older
* Flatpak base app that doesn't expose the portal D-Bus name).
*/
function isGlobalShortcutSupported(): boolean {
if (process.platform !== 'linux') return true
// On Wayland the portal handles registration. On X11 the classic
// key-grab path is used. Both should work, so we optimistically
// return true and let tryRegisterShortcut surface per-shortcut
// failures via notifications.
return true
}
/**
* Try to register a single global shortcut. Returns true on success.
* On failure a user-facing notification is shown (unless `silent` is set).
*/
function tryRegisterShortcut(
accel: string,
label: string,
callback: () => void,
silent = false
): boolean {
try {
const ok = globalShortcut.register(accel, callback)
if (ok) {
log.info(`${label} shortcut "${accel}" registered`)
return true
}
log.warn(`${label} shortcut "${accel}" could not be registered (returned false)`)
if (!silent) {
new Notification({
title: label,
body: `Could not register shortcut "${accel}". It may be in use by another application.`
}).show()
}
return false
} catch (error) {
log.warn(`${label} shortcut "${accel}" registration threw:`, error)
if (!silent) {
new Notification({
title: label,
body: `Failed to register shortcut "${accel}". It may conflict with another application.`
}).show()
}
return false
}
}
const registerShortcuts = (globalAccel?: string, spotlightAccel?: string, voiceInputAccel?: string, callAccel?: string): void => {
globalShortcut.unregisterAll()
// On Wayland / Flatpak global shortcuts are unsupported — skip silently.
if (!isGlobalShortcutSupported()) {
log.info(
'Global shortcut registration skipped — unsupported environment ' +
`(XDG_SESSION_TYPE=${process.env['XDG_SESSION_TYPE'] ?? '(unset)'}, ` +
`FLATPAK_ID=${process.env['FLATPAK_ID'] ?? '(unset)'})`
)
return
}
// Global shortcut bring main window to foreground
if (globalAccel) {
try {
globalShortcut.register(globalAccel, () => {
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
} else {
createMainWindow()
}
})
} catch (error) {
log.warn('Failed to register global shortcut:', globalAccel, error)
}
tryRegisterShortcut(globalAccel, 'Open WebUI', () => {
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
} else {
createMainWindow()
}
})
}
// Spotlight shortcut toggle the spotlight input bar
if (spotlightAccel) {
try {
globalShortcut.register(spotlightAccel, () => {
const text = clipboard.readText()?.trim() || ''
toggleSpotlight(text)
})
} catch (error) {
log.warn('Failed to register spotlight shortcut:', spotlightAccel, error)
}
tryRegisterShortcut(spotlightAccel, 'Spotlight', () => {
const text = CONFIG?.spotlightClipboardPaste !== false
? (clipboard.readText()?.trim() || '')
: ''
toggleSpotlight(text)
})
}
// Voice input shortcut toggle microphone recording
if (voiceInputAccel && CONFIG?.voiceInputEnabled !== false) {
try {
const ok = globalShortcut.register(voiceInputAccel, () => {
toggleVoiceInput()
})
log.info(`Voice input shortcut "${voiceInputAccel}" registered: ${ok}`)
if (!ok) {
new Notification({
title: 'Voice Input',
body: `Could not register shortcut "${voiceInputAccel}". It may be in use by another application.`
}).show()
}
} catch (error) {
log.warn('Failed to register voice input shortcut:', voiceInputAccel, error)
new Notification({
title: 'Voice Input',
body: `Failed to register shortcut "${voiceInputAccel}". It may conflict with another application.`
}).show()
}
tryRegisterShortcut(voiceInputAccel, 'Voice Input', () => {
toggleVoiceInput()
})
} else {
log.info(`Voice input shortcut skipped — accel="${voiceInputAccel}", enabled=${CONFIG?.voiceInputEnabled}`)
}
// Call shortcut open the voice/video call overlay
if (callAccel && CONFIG?.callEnabled !== false) {
try {
const ok = globalShortcut.register(callAccel, () => {
toggleCall()
})
log.info(`Call shortcut "${callAccel}" registered: ${ok}`)
if (!ok) {
new Notification({
title: 'Call',
body: `Could not register shortcut "${callAccel}". It may be in use by another application.`
}).show()
}
} catch (error) {
log.warn('Failed to register call shortcut:', callAccel, error)
new Notification({
title: 'Call',
body: `Failed to register shortcut "${callAccel}". It may conflict with another application.`
}).show()
}
tryRegisterShortcut(callAccel, 'Call', () => {
toggleCall()
})
} else {
log.info(`Call shortcut skipped — accel="${callAccel}", enabled=${CONFIG?.callEnabled}`)
}
@@ -530,12 +582,61 @@ async function toggleCall(): Promise<void> {
// ─── Windows ────────────────────────────────────────────
const DEFAULT_WINDOW_WIDTH = 1280
const DEFAULT_WINDOW_HEIGHT = 800
const MIN_WINDOW_WIDTH = 480
const MIN_WINDOW_HEIGHT = 360
const BOUNDS_SAVE_DEBOUNCE_MS = 500
const MIN_VISIBLE_OVERLAP_PX = 100
/** Last known non-maximized bounds, used to preserve restore geometry. */
let lastNormalBounds: Electron.Rectangle | null = null
/** Debounced persistence of the current window geometry to config. */
let boundsDebounceTimer: ReturnType<typeof setTimeout> | null = null
function debounceSaveWindowBounds(win: BrowserWindow): void {
if (boundsDebounceTimer) clearTimeout(boundsDebounceTimer)
boundsDebounceTimer = setTimeout(() => {
if (win.isDestroyed()) return
const maximized = win.isMaximized()
const bounds = maximized ? (lastNormalBounds ?? win.getNormalBounds()) : win.getBounds()
setConfig({ windowBounds: bounds, windowMaximized: maximized }).catch((err) =>
log.warn('Failed to save window bounds:', err)
)
}, BOUNDS_SAVE_DEBOUNCE_MS)
}
/**
* Returns true when at least `MIN_VISIBLE_OVERLAP_PX` of the saved
* rectangle would be visible on one of the connected displays.
*/
function isBoundsOnVisibleDisplay(bounds: { x: number; y: number }): boolean {
const { screen } = require('electron')
const targetPoint = { x: bounds.x + MIN_VISIBLE_OVERLAP_PX / 2, y: bounds.y + MIN_VISIBLE_OVERLAP_PX / 2 }
const display = screen.getDisplayNearestPoint(targetPoint)
const { x, y, width, height } = display.workArea
return (
bounds.x + MIN_VISIBLE_OVERLAP_PX > x &&
bounds.x < x + width &&
bounds.y + MIN_VISIBLE_OVERLAP_PX > y &&
bounds.y < y + height
)
}
function trackNormalBounds(win: BrowserWindow): void {
if (!win.isDestroyed() && !win.isMaximized()) {
lastNormalBounds = win.getBounds()
}
}
function createMainWindow(show = true): void {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 1280,
minHeight: 800,
const saved = CONFIG?.windowBounds
const windowOpts: Electron.BrowserWindowConstructorOptions = {
width: saved?.width ?? DEFAULT_WINDOW_WIDTH,
height: saved?.height ?? DEFAULT_WINDOW_HEIGHT,
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
icon: path.join(__dirname, 'assets/icon.png'),
show: false,
titleBarStyle: process.platform === 'win32' ? 'default' : 'hidden',
@@ -552,9 +653,22 @@ function createMainWindow(show = true): void {
sandbox: false,
webviewTag: true
}
})
}
// Restore position only when the saved location is still on a visible display
// (e.g. an external monitor may have been disconnected since last session).
if (saved?.x != null && saved?.y != null && isBoundsOnVisibleDisplay(saved)) {
windowOpts.x = saved.x
windowOpts.y = saved.y
}
mainWindow = new BrowserWindow(windowOpts)
mainWindow.setIcon(icon)
if (CONFIG?.windowMaximized) {
mainWindow.maximize()
}
if (!app.isPackaged) {
mainWindow.webContents.openDevTools()
}
@@ -576,6 +690,17 @@ function createMainWindow(show = true): void {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
// ── Persist window bounds on geometry changes ──
const onBoundsChanged = (): void => {
if (!mainWindow || mainWindow.isDestroyed()) return
trackNormalBounds(mainWindow)
debounceSaveWindowBounds(mainWindow)
}
mainWindow.on('resize', onBoundsChanged)
mainWindow.on('move', onBoundsChanged)
mainWindow.on('maximize', onBoundsChanged)
mainWindow.on('unmaximize', onBoundsChanged)
mainWindow.on('close', (event) => {
if (!isQuiting) {
if (CONFIG?.runInBackground === false) {
@@ -597,10 +722,10 @@ function createContentWindow(url: string, connectionId: string): BrowserWindow {
}
contentWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 1280,
minHeight: 800,
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT,
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
icon: path.join(__dirname, 'assets/icon.png'),
show: false,
titleBarStyle: process.platform === 'win32' ? 'default' : 'hidden',
@@ -620,7 +745,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))
})
@@ -1065,8 +1190,68 @@ if (!gotTheLock) {
log.info('Running with GPU sandbox disabled (marker file present)')
}
// ─── Self-Signed / Untrusted Certificate Support ─
// Allow connections to Open WebUI instances that use self-signed or
// otherwise untrusted SSL certificates (issue #108). The user
// explicitly configures the server URL, so trusting all certs is
// acceptable — this matches the behaviour of VS Code, Postman, and
// other Electron apps used in enterprise/self-hosted environments.
app.on('certificate-error', (event, _webContents, url, error, certificate, callback) => {
log.warn(
`Certificate error: ${error} for ${url} ` +
`(subject: ${certificate.subjectName}, issuer: ${certificate.issuerName})`
)
event.preventDefault()
callback(true)
})
// Trust all certs on the default session (used by net.fetch() in
// validateRemoteUrl / checkUrlAndOpen).
session.defaultSession.setCertificateVerifyProc((_request, callback) => {
callback(0) // 0 = verified/trusted
})
// Webviews use partitioned sessions (persist:connection-*). Each
// new partition's session also needs to trust all certs.
app.on('session-created', (newSession) => {
newSession.setCertificateVerifyProc((_request, callback) => {
callback(0)
})
// Grant media / notification permissions for webview partition sessions
// so that auth flows, media capture, and notifications work correctly.
newSession.setPermissionRequestHandler((_webContents, permission, callback) => {
const allowed = ['media', 'mediaKeySystem', 'notifications', 'clipboard-read', 'clipboard-sanitized-write']
callback(allowed.includes(permission))
})
})
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
// Auto-reload when the renderer process dies so the user doesn't
// see a permanent blank/grey screen.
window.webContents.on('render-process-gone', (_event, details) => {
log.error(
`Renderer process gone: reason=${details.reason}, exitCode=${details.exitCode}`
)
if (details.reason !== 'clean-exit') {
window.webContents.reload()
}
})
})
// Log webview guest renderer crashes for diagnostics — the existing
// 'crashed' listener in Content.svelte surfaces these to the user.
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 ─────────────────────────────────
+10 -4
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
@@ -829,10 +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 = {
@@ -863,10 +866,13 @@ const DEFAULT_CONFIG: AppConfig = {
envVars: {},
showSidebar: false,
spotlightPosition: null,
spotlightClipboardPaste: true,
voiceInputShortcut: 'Shift+CommandOrControl+Space',
voiceInputEnabled: true,
callShortcut: 'Shift+CommandOrControl+C',
callEnabled: true
callEnabled: true,
windowBounds: null,
windowMaximized: false
}
export const getConfig = async (): Promise<AppConfig> => {
@@ -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 }}>
@@ -111,6 +111,9 @@
let callShortcutInputEl = $state<HTMLButtonElement | null>(null)
let callEnabled = $state(true)
// Spotlight clipboard paste
let spotlightClipboardPaste = $state(true)
// Keep shortcut value in sync with config store
$effect(() => {
if ($config?.globalShortcut !== undefined) {
@@ -122,6 +125,9 @@
if ($config?.spotlightShortcut !== undefined) {
spotlightShortcutValue = $config.spotlightShortcut ?? ''
}
if ($config?.spotlightClipboardPaste !== undefined) {
spotlightClipboardPaste = $config.spotlightClipboardPaste ?? true
}
})
$effect(() => {
@@ -518,6 +524,22 @@
</div>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">Clipboard Auto-Paste</div>
<div class="text-[11px] opacity-25 mt-0.5">Automatically paste clipboard contents into Spotlight</div>
</div>
<Switch
checked={spotlightClipboardPaste}
label="Toggle clipboard auto-paste"
onchange={async (value) => {
spotlightClipboardPaste = value
await window.electronAPI.setConfig({ spotlightClipboardPaste: value })
config.set(await window.electronAPI.getConfig())
}}
/>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">Voice Input</div>
@@ -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…",