Compare commits

..

6 Commits

Author SHA1 Message Date
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
6 changed files with 250 additions and 62 deletions
+19
View File
@@ -5,6 +5,25 @@ 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.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
+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.11",
"version": "0.0.13",
"license": "AGPL-3.0",
"description": "Open WebUI Desktop",
"main": "./out/main/index.js",
+108 -59
View File
@@ -93,6 +93,18 @@ 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')
}
// ─── GPU Crash Recovery ─────────────────────────────────
@@ -133,83 +145,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 = CONFIG?.spotlightClipboardPaste !== false
? (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}`)
}
@@ -695,7 +737,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))
})
@@ -1167,6 +1209,13 @@ if (!gotTheLock) {
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) => {
@@ -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 }}>
@@ -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…",