This commit is contained in:
Timothy Jaeryang Baek 2026-04-08 13:49:32 -07:00
parent 08616e701d
commit 03f6abae75
10 changed files with 186 additions and 64 deletions

1
.gitignore vendored
View file

@ -6,3 +6,4 @@ out
.DS_Store
.eslintcache
*.log*
*.tsbuildinfo

View file

@ -5,7 +5,18 @@ 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).
## [Unreleased]
## [0.0.5] - 2026-04-07
### Added
- **Bidirectional Theme Sync** — Theme changes in Open WebUI are now mirrored to the desktop shell and vice versa, using a symmetric `theme:update` event protocol
- **Zero-Reload Spotlight Queries** — Spotlight prompts are injected directly into already-open webviews via event bridge instead of triggering a full page reload
### Fixed
- **Auto-Default Connection** — Selecting a connection now automatically sets it as the default for spotlight and startup
- **Connection Switch Stability** — Switching between already-open connections no longer causes unnecessary URL reloads
- **Connection Switch Race Condition** — Clicking a remote connection while the local server is still starting no longer gets overridden when the local connection finishes loading
## [0.0.3] - 2026-04-06

View file

@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.0.3",
"version": "0.0.5",
"license": "AGPL-3.0",
"description": "Open WebUI Desktop",
"main": "./out/main/index.js",

View file

@ -410,7 +410,10 @@ const updateTray = () => {
const connectionItems = (CONFIG.connections || []).map((conn) => ({
label: `${conn.id === CONFIG.defaultConnectionId ? '★ ' : ''}${conn.name}`,
sublabel: conn.url,
click: () => connectTo(conn)
click: async () => {
const result = await connectTo(conn)
if (result) sendToRenderer('connection:open', result)
}
}))
const trayMenuTemplate = [
@ -490,7 +493,6 @@ const connectTo = async (connection: Connection) => {
url = url.replace('http://0.0.0.0', 'http://localhost')
}
sendToRenderer('connection:open', { url, connectionId: connection.id })
return { url, connectionId: connection.id }
}
@ -981,9 +983,10 @@ if (!gotTheLock) {
url = url.replace('http://0.0.0.0', 'http://localhost')
}
// Navigate to the connection URL with query
const targetUrl = `${url}/?q=${encodeURIComponent(query)}`
sendToRenderer('connection:open', { url: targetUrl, connectionId: conn.id })
// Send query event — the renderer decides whether to open a new
// connection (with ?q= baked into the URL) or inject the prompt
// into an already-open webview via postMessage (zero navigation).
sendToRenderer('query', { query, connectionId: conn.id, url })
// Show main window and hide spotlight
mainWindow?.show()
@ -1300,7 +1303,8 @@ if (!gotTheLock) {
)
if (defaultConn) {
createMainWindow()
await connectTo(defaultConn)
const result = await connectTo(defaultConn)
if (result) sendToRenderer('connection:open', result)
} else {
createMainWindow()
}

View file

@ -15,6 +15,14 @@ ipcRenderer.on('desktop:event', (_event, data) => {
eventCallbacks.forEach((cb) => cb(data))
})
// ─── Theme Sync: Open WebUI → Desktop ───────────────────
// Open WebUI calls window.applyTheme() after every theme change.
// We inject this hook so the desktop shell can mirror the theme.
contextBridge.exposeInMainWorld('applyTheme', () => {
const theme = localStorage.getItem('theme') ?? 'system'
ipcRenderer.sendToHost('webview:event', { type: 'theme:update', data: { theme } })
})
// Expose to the Open WebUI page via contextBridge (secure, unforgeable)
contextBridge.exposeInMainWorld('electronAPI', {
// Push events: desktop → Open WebUI

View file

@ -133,7 +133,7 @@
// Now connect — the server is ready
installStatus = ''
localInstalled = true
await connect('local')
connect('local')
installPhase = 'idle'
} catch (e: any) {
installPhase = 'error'
@ -182,33 +182,61 @@
}
}
const connect = async (id: string) => {
const connect = (id: string) => {
showingLogs = false
// Toggle: clicking the active connection unselects it
if (activeConnectionId === id && view === 'connected') {
connectingId = ''
activeConnectionId = ''
connectedUrl = ''
view = 'welcome'
return
}
// Persist as default so spotlight/startup always use the last-selected connection
window.electronAPI.setDefaultConnection(id)
// Already-open connection — just switch to it
if (openConnections.has(id)) {
connectingId = ''
activeConnectionId = id
connectedUrl = openConnections.get(id)!
view = 'connected'
return
}
connectingId = id
try {
const result = await window.electronAPI.connectTo(id)
if (result?.url) {
openConnections.set(result.connectionId, result.url)
openConnections = new Map(openConnections) // trigger reactivity
connectedUrl = result.url
activeConnectionId = result.connectionId
view = 'connected'
}
} finally {
const conn = ($connections ?? []).find((c) => c.id === id)
if (!conn) return
activeConnectionId = id
if (conn.type === 'local') {
// Local needs server start — use IPC
connectingId = id
view = 'welcome'
window.electronAPI.connectTo(id).then((result: any) => {
if (!result?.url) {
if (connectingId === id) connectingId = ''
return
}
if (!openConnections.has(result.connectionId)) {
openConnections.set(result.connectionId, result.url)
openConnections = new Map(openConnections)
}
if (connectingId === id) {
connectedUrl = result.url
activeConnectionId = result.connectionId
connectingId = ''
if (installPhase !== 'working') {
view = 'connected'
}
}
})
} else {
// Remote — open immediately, no IPC needed
connectingId = ''
openConnections.set(id, conn.url)
openConnections = new Map(openConnections)
connectedUrl = conn.url
view = 'connected'
}
}
@ -307,36 +335,72 @@
if (data.type === 'connection:open' && data.data?.url) {
const connId = data.data.connectionId ?? ''
const incomingUrl = data.data.url
const alreadyOpen = openConnections.has(connId)
openConnections.set(connId, incomingUrl)
openConnections = new Map(openConnections)
connectedUrl = incomingUrl
activeConnectionId = connId
// If the webview for this connection already exists in the DOM,
// updating the map value won't cause it to re-navigate (Electron
// webview `src` changes on an existing element are ignored). We
// need to explicitly call loadURL so that e.g. the spotlight ?q=
// parameter actually reaches the Open WebUI instance.
if (alreadyOpen) {
requestAnimationFrame(() => {
const container = document.querySelector('.content-webview-container')
if (!container) return
const wv = container.querySelector(
`webview[partition="persist:connection-${connId}"]`
) as any
if (wv?.loadURL) {
wv.loadURL(incomingUrl)
}
})
if (!openConnections.has(connId)) {
openConnections.set(connId, incomingUrl)
openConnections = new Map(openConnections)
}
// Don't switch to connected view during active install — the install
// flow handles its own transition after confirming reachability.
// Only auto-switch if no connection is currently active.
// This handles startup auto-connect and tray clicks when on the welcome screen,
// without overriding a connection the user is already viewing.
if (view !== 'connected') {
connectedUrl = openConnections.get(connId) ?? incomingUrl
activeConnectionId = connId
if (installPhase !== 'working') {
view = 'connected'
}
}
}
// Desktop query — for new connections, bake ?q= into the initial URL.
// For already-open connections, the event flows through to the webview
// where Open WebUI's Chat.svelte handles it via electronAPI.onEvent.
// Fall back to loadURL for old Open WebUI versions without the handler.
if (data.type === 'query' && data.data?.query) {
const connId = data.data.connectionId ?? ''
const query = data.data.query
const baseUrl = data.data.url ?? ''
if (!openConnections.has(connId)) {
const initialUrl = `${baseUrl}/?q=${encodeURIComponent(query)}`
openConnections.set(connId, initialUrl)
openConnections = new Map(openConnections)
connectedUrl = initialUrl
activeConnectionId = connId
if (installPhase !== 'working') {
view = 'connected'
}
return
}
activeConnectionId = connId
connectedUrl = openConnections.get(connId)!
if (installPhase !== 'working') {
view = 'connected'
}
// Check if Open WebUI supports the query event (version >= 0.6.6).
// If not, fall back to full URL navigation.
requestAnimationFrame(async () => {
const container = document.querySelector('.content-webview-container')
if (!container) return
const wv = container.querySelector(
`webview[partition="persist:connection-${connId}"]`
) as any
if (!wv) return
try {
const ver = await wv.executeJavaScript('window.WEBUI_VERSION || ""')
if (!ver) {
// Old Open WebUI — fall back to ?q= navigation
wv.loadURL(`${baseUrl}/?q=${encodeURIComponent(query)}`)
}
// New Open WebUI — event already forwarded by Content.svelte
} catch {
wv.loadURL(`${baseUrl}/?q=${encodeURIComponent(query)}`)
}
})
}
if (data.type === 'status:open-terminal') {
openTerminalStatus = data.data

View file

@ -85,8 +85,7 @@
const isLoading = $derived(
connectingId !== '' ||
serverStarting ||
(view === 'connected' && activeConnectionId && (webviewLoading.get(activeConnectionId) ?? true))
(serverStarting && activeConnectionId === localConn?.id)
)
// Attach load event listeners and IPC forwarding to webviews
@ -149,6 +148,35 @@
} else if (event.channel === 'webview:load') {
const page = event.args?.[0]
if (page) onSetView(page === 'home' ? 'welcome' : page)
} else if (event.channel === 'webview:event') {
const payload = event.args?.[0]
if (!payload?.type) return
if (payload.type === 'theme:update') {
const webuiTheme = payload.data?.theme ?? 'system'
// Map Open WebUI theme names to desktop-compatible values
let desktopTheme: string
if (webuiTheme === 'system') {
desktopTheme = 'system'
} else if (webuiTheme.includes('dark')) {
desktopTheme = 'dark'
} else {
desktopTheme = 'light'
}
// Resolve and apply CSS class
let resolved = desktopTheme
if (desktopTheme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(resolved)
// Persist to desktop config
await window.electronAPI.setConfig({ theme: desktopTheme })
config.set(await window.electronAPI.getConfig())
}
}
})
})

View file

@ -99,19 +99,14 @@
connectingId === localConn.id ||
serverStatus === 'starting' ||
(serverStatus === 'running' && !serverReachable)}
{@const isLocalDisabled = !serverReachable && !isServerLoading}
<div
class="w-full px-2.5 py-1.5 rounded-xl group flex items-center gap-2 transition-colors {isLocalDisabled
? 'opacity-40 cursor-default'
: 'cursor-pointer'} {activeConnectionId === localConn.id || isServerLoading
? 'bg-black/[0.06] dark:bg-white/[0.06]'
: isLocalDisabled
? ''
: 'hover:bg-black/[0.03] dark:bg-white/[0.05]'}"
class="w-full px-2.5 py-1.5 rounded-xl group flex items-center gap-2 transition-colors cursor-pointer {activeConnectionId === localConn.id
? 'bg-black/[0.08] dark:bg-white/[0.08]'
: 'hover:bg-black/[0.04] dark:hover:bg-white/[0.06]'}"
role="button"
tabindex="0"
onclick={() => !isLocalDisabled && onConnect(localConn.id)}
onkeydown={(e) => e.key === 'Enter' && !isLocalDisabled && onConnect(localConn.id)}
onclick={() => onConnect(localConn.id)}
onkeydown={(e) => e.key === 'Enter' && onConnect(localConn.id)}
>
{#if connectingId === localConn.id || serverStatus === 'starting' || (serverStatus === 'running' && !serverReachable)}
<div class="w-[14px] h-[14px] shrink-0 flex items-center justify-center">
@ -149,8 +144,8 @@
{:else}
<span
class="text-[12px] {activeConnectionId === localConn.id
? 'opacity-90'
: 'opacity-100'} transition-opacity truncate flex-1 min-w-0"
? 'font-medium opacity-100'
: 'opacity-70'} transition-opacity truncate flex-1 min-w-0"
>{localConn.name ?? 'Open WebUI'}</span
>
{/if}
@ -245,8 +240,8 @@
<div
class="w-full px-2.5 py-1.5 rounded-xl group flex items-center gap-2 transition-colors cursor-pointer {activeConnectionId ===
conn.id
? 'bg-black/[0.06] dark:bg-white/[0.06]'
: 'hover:bg-black/[0.03] dark:bg-white/[0.05]'}"
? 'bg-black/[0.08] dark:bg-white/[0.08]'
: 'hover:bg-black/[0.04] dark:hover:bg-white/[0.06]'}"
role="button"
tabindex="0"
onclick={() => editingId !== conn.id && onConnect(conn.id)}
@ -293,8 +288,8 @@
{:else}
<span
class="text-[12px] {activeConnectionId === conn.id
? 'opacity-90'
: 'opacity-100'} transition-opacity truncate flex-1 min-w-0">{conn.name}</span
? 'font-medium opacity-100'
: 'opacity-70'} transition-opacity truncate flex-1 min-w-0">{conn.name}</span
>
{/if}

View file

@ -45,6 +45,18 @@
applyThemeClass(newTheme)
await window.electronAPI.setConfig({ theme: newTheme })
config.set(await window.electronAPI.getConfig())
// Push theme to all active Open WebUI webviews
const container = document.querySelector('.content-webview-container')
if (container) {
container.querySelectorAll('webview').forEach((wv: any) => {
try {
wv.send('desktop:event', { type: 'theme:update', data: { theme: newTheme } })
} catch (_) {
// webview may not be ready yet
}
})
}
}
const setDefault = async (id: string) => {

File diff suppressed because one or more lines are too long