mirror of
https://github.com/open-webui/desktop.git
synced 2026-07-16 02:28:22 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61db9dc10f | |||
| 27a3075c3a | |||
| 8c990befbe | |||
| da84e49970 | |||
| e0af7f3d32 | |||
| 4f653f5fcd |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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…",
|
||||
|
||||
Reference in New Issue
Block a user