diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 49c25f7..3875cba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -133,13 +133,16 @@ jobs: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ run: npx electron-builder --mac --${{ matrix.arch }} -c.mac.identity=auto -c.mac.notarize=true --publish never - name: Package for macOS (unsigned fallback) if: matrix.os == 'macos-latest' && (steps.apple_cert.outcome != 'success' || steps.mac_build_signed.outcome == 'failure') + env: + ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ run: | rm -rf dist/ - npx electron-builder --mac --${{ matrix.arch }} --publish never + npx electron-builder --mac --${{ matrix.arch }} -c.mac.notarize=false --publish never - name: Package for Linux if: matrix.os == 'ubuntu-latest' diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a12837..d567432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ 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.2] - 2026-04-06 + +### Added + +- **Spotlight Input Bar** — Lightweight quick-chat bar (⇧⌘I) for submitting queries without opening the full app +- **Spotlight Shortcut** — Dedicated configurable shortcut for the spotlight, independent from the global app shortcut +- **Draggable Spotlight** — Spotlight bar can be dragged to any position on screen +- **Persistent Spotlight Position** — Spotlight position is saved to config and restored across app restarts +- **Spotlight Settings** — Shortcut recorder in Settings → General for the spotlight shortcut + + +### Fixed + +- **System Theme Sync** — App now listens for OS dark/light mode changes in real-time when set to "Auto" (previously only checked once at startup) + ## [0.0.1] - 2026-03-20 ### Added diff --git a/electron.vite.config.ts b/electron.vite.config.ts index c0a66ef..2c2b32e 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -14,12 +14,21 @@ export default defineConfig({ rollupOptions: { input: { index: resolve(__dirname, 'src/preload/index.ts'), - 'content-preload': resolve(__dirname, 'src/preload/content-preload.ts') + 'content-preload': resolve(__dirname, 'src/preload/content-preload.ts'), + 'spotlight-preload': resolve(__dirname, 'src/preload/spotlight-preload.ts') } } } }, renderer: { + build: { + rollupOptions: { + input: { + index: resolve(__dirname, 'src/renderer/index.html'), + spotlight: resolve(__dirname, 'src/renderer/spotlight.html') + } + } + }, plugins: [tailwindcss(), svelte()] } }) diff --git a/package.json b/package.json index 8123860..48356bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.0.1", + "version": "0.0.2", "license": "AGPL-3.0", "description": "Open WebUI Desktop", "main": "./out/main/index.js", diff --git a/src/main/index.ts b/src/main/index.ts index a9cf487..001f261 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -94,6 +94,7 @@ if (process.platform === 'linux') { let mainWindow: BrowserWindow | null = null let contentWindow: BrowserWindow | null = null +let spotlightWindow: BrowserWindow | null = null let tray: Tray | null = null let isQuiting = false @@ -103,23 +104,140 @@ let SERVER_STATUS: string | null = null let SERVER_REACHABLE = false let SERVER_PID: number | null = null -// ─── Global Shortcut ──────────────────────────────────── +// ─── Global Shortcuts ─────────────────────────────────── -const registerGlobalShortcut = (accelerator?: string): void => { +const registerShortcuts = (globalAccel?: string, spotlightAccel?: string): void => { globalShortcut.unregisterAll() - if (!accelerator) return - try { - globalShortcut.register(accelerator, () => { - if (contentWindow && !contentWindow.isDestroyed()) { - contentWindow.show() - contentWindow.focus() + + // 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) + } + } + + // Spotlight shortcut – toggle the spotlight input bar + if (spotlightAccel) { + try { + globalShortcut.register(spotlightAccel, () => { + toggleSpotlight() + }) + } catch (error) { + log.warn('Failed to register spotlight shortcut:', spotlightAccel, error) + } + } +} + +// ─── Spotlight Window ─────────────────────────────────── + +// Remember where the user dragged the spotlight +let spotlightPosition: { x: number; y: number } | null = null + +// Load persisted spotlight position from config (call after CONFIG is loaded) +function loadSpotlightPosition(): void { + if (CONFIG?.spotlightPosition) { + spotlightPosition = { ...CONFIG.spotlightPosition } + } +} + +function getDefaultSpotlightPosition(): { x: number; y: number } { + const { screen } = require('electron') + const cursorPoint = screen.getCursorScreenPoint() + const activeDisplay = screen.getDisplayNearestPoint(cursorPoint) + const { width: screenW } = activeDisplay.workAreaSize + const { x: screenX, y: screenY } = activeDisplay.workArea + const winW = 748 + return { + x: Math.round(screenX + (screenW - winW) / 2), + y: Math.round(screenY + 160) + } +} + +function createSpotlightWindow(): BrowserWindow { + const pos = spotlightPosition || getDefaultSpotlightPosition() + + const winW = 748 + const winH = 86 + + spotlightWindow = new BrowserWindow({ + width: winW, + height: winH, + x: pos.x, + y: pos.y, + frame: false, + transparent: true, + alwaysOnTop: true, + skipTaskbar: true, + resizable: false, + hasShadow: false, + show: false, + icon: path.join(__dirname, 'assets/icon.png'), + webPreferences: { + preload: join(__dirname, '../preload/spotlight-preload.js'), + sandbox: false, + webviewTag: false + } + }) + + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + spotlightWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/spotlight.html`) + } else { + spotlightWindow.loadFile(join(__dirname, '../renderer/spotlight.html')) + } + + // Save position when user drags to a new spot + spotlightWindow.on('moved', () => { + if (spotlightWindow && !spotlightWindow.isDestroyed()) { + const [x, y] = spotlightWindow.getPosition() + spotlightPosition = { x, y } + // Persist to config for cross-session recall + setConfig({ spotlightPosition: { x, y } }).catch((err) => + log.warn('Failed to persist spotlight position:', err) + ) + } + }) + + spotlightWindow.on('blur', () => { + spotlightWindow?.hide() + }) + + spotlightWindow.on('closed', () => { + spotlightWindow = null + }) + + return spotlightWindow +} + +function toggleSpotlight(): void { + if (spotlightWindow && !spotlightWindow.isDestroyed()) { + if (spotlightWindow.isVisible()) { + spotlightWindow.hide() + } else { + // Restore to saved position, or default if none saved + if (spotlightPosition) { + spotlightWindow.setPosition(spotlightPosition.x, spotlightPosition.y) } else { - mainWindow?.show() - mainWindow?.focus() + const pos = getDefaultSpotlightPosition() + spotlightWindow.setPosition(pos.x, pos.y) } + spotlightWindow.show() + spotlightWindow.focus() + } + } else { + const win = createSpotlightWindow() + win.once('ready-to-show', () => { + win.show() + win.focus() }) - } catch (error) { - log.warn('Failed to register global shortcut:', accelerator, error) } } @@ -127,10 +245,10 @@ const registerGlobalShortcut = (accelerator?: string): void => { function createMainWindow(show = true): void { mainWindow = new BrowserWindow({ - width: 1100, - height: 700, - minWidth: 900, - minHeight: 560, + width: 1280, + height: 800, + minWidth: 1280, + minHeight: 800, icon: path.join(__dirname, 'assets/icon.png'), show: false, titleBarStyle: process.platform === 'win32' ? 'default' : 'hidden', @@ -192,10 +310,10 @@ function createContentWindow(url: string, connectionId: string): BrowserWindow { } contentWindow = new BrowserWindow({ - width: 1200, + width: 1280, height: 800, - minWidth: 900, - minHeight: 560, + minWidth: 1280, + minHeight: 800, icon: path.join(__dirname, 'assets/icon.png'), show: false, titleBarStyle: process.platform === 'win32' ? 'default' : 'hidden', @@ -603,6 +721,7 @@ if (!gotTheLock) { app.whenReady().then(async () => { CONFIG = await getConfig() + loadSpotlightPosition() log.info('Config:', CONFIG) app.name = 'Open WebUI' @@ -649,7 +768,7 @@ if (!gotTheLock) { await setConfig(config) CONFIG = await getConfig() updateTray() - registerGlobalShortcut(CONFIG.globalShortcut) + registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut) }) // Python/uv @@ -803,6 +922,43 @@ if (!gotTheLock) { // Misc ipcMain.handle('app:reset', () => resetAppHandler()) + // Spotlight + ipcMain.handle('spotlight:submit', async (_event, query: string) => { + const config = await getConfig() + if (!config.defaultConnectionId || config.connections.length === 0) { + // No default connection — just show main window + mainWindow?.show() + mainWindow?.focus() + return + } + const conn = config.connections.find((c) => c.id === config.defaultConnectionId) + if (!conn) { + mainWindow?.show() + mainWindow?.focus() + return + } + + let url = conn.url + if (conn.type === 'local' && SERVER_URL) { + url = SERVER_URL + } + if (url.startsWith('http://0.0.0.0')) { + 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 }) + + // Show main window and hide spotlight + mainWindow?.show() + mainWindow?.focus() + spotlightWindow?.hide() + }) + ipcMain.handle('spotlight:close', () => { + spotlightWindow?.hide() + }) + // Open Terminal ipcMain.handle('open-terminal:start', async () => { try { @@ -1058,7 +1214,7 @@ if (!gotTheLock) { // Global shortcut - registerGlobalShortcut(CONFIG.globalShortcut) + registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut) // Enable screen capture session.defaultSession.setDisplayMediaRequestHandler( @@ -1145,6 +1301,10 @@ if (!gotTheLock) { globalShortcut.unregisterAll() mainWindow = null contentWindow = null + if (spotlightWindow && !spotlightWindow.isDestroyed()) { + spotlightWindow.destroy() + } + spotlightWindow = null tray?.destroy() tray = null }) diff --git a/src/main/utils/index.ts b/src/main/utils/index.ts index bbbfcfa..f3ea169 100644 --- a/src/main/utils/index.ts +++ b/src/main/utils/index.ts @@ -780,6 +780,7 @@ export interface AppConfig { connections: Connection[] runInBackground: boolean globalShortcut: string + spotlightShortcut: string dataDir: string localServer: { port: number @@ -800,6 +801,7 @@ export interface AppConfig { } envVars: Record showSidebar: boolean + spotlightPosition: { x: number; y: number } | null } const DEFAULT_CONFIG: AppConfig = { @@ -808,6 +810,7 @@ const DEFAULT_CONFIG: AppConfig = { connections: [], runInBackground: true, globalShortcut: 'Alt+CommandOrControl+O', + spotlightShortcut: 'Shift+CommandOrControl+I', dataDir: '', localServer: { port: 8080, @@ -825,7 +828,8 @@ const DEFAULT_CONFIG: AppConfig = { extraArgs: [] }, envVars: {}, - showSidebar: true + showSidebar: true, + spotlightPosition: null } export const getConfig = async (): Promise => { diff --git a/src/preload/spotlight-preload.ts b/src/preload/spotlight-preload.ts new file mode 100644 index 0000000..e4263fc --- /dev/null +++ b/src/preload/spotlight-preload.ts @@ -0,0 +1,21 @@ +import { ipcRenderer, contextBridge } from 'electron' + +const api = { + submitQuery: (query: string): void => { + ipcRenderer.invoke('spotlight:submit', query) + }, + closeSpotlight: (): void => { + ipcRenderer.invoke('spotlight:close') + } +} + +if (process.contextIsolated) { + try { + contextBridge.exposeInMainWorld('spotlightAPI', api) + } catch (error) { + console.error(error) + } +} else { + // @ts-ignore + window.spotlightAPI = api +} diff --git a/src/renderer/spotlight.html b/src/renderer/spotlight.html new file mode 100644 index 0000000..c543b92 --- /dev/null +++ b/src/renderer/spotlight.html @@ -0,0 +1,15 @@ + + + + + Open WebUI – Spotlight + + + +
+ + + diff --git a/src/renderer/src/App.svelte b/src/renderer/src/App.svelte index 1f979fd..8515cc4 100644 --- a/src/renderer/src/App.svelte +++ b/src/renderer/src/App.svelte @@ -1,10 +1,22 @@
diff --git a/src/renderer/src/components/Spotlight.svelte b/src/renderer/src/components/Spotlight.svelte new file mode 100644 index 0000000..2e637c3 --- /dev/null +++ b/src/renderer/src/components/Spotlight.svelte @@ -0,0 +1,182 @@ + + + e.key === 'Escape' && api?.closeSpotlight()} /> + +
+
+ + + { + if (e.key === 'Enter' && !e.isComposing) { + e.preventDefault() + submit() + } + }} + /> + + +
+
+ + diff --git a/src/renderer/src/lib/components/Main/Settings/General.svelte b/src/renderer/src/lib/components/Main/Settings/General.svelte index fa6b6c3..9a7670a 100644 --- a/src/renderer/src/lib/components/Main/Settings/General.svelte +++ b/src/renderer/src/lib/components/Main/Settings/General.svelte @@ -76,6 +76,11 @@ let recording = $state(false) let shortcutInputEl = $state(null) + // Spotlight shortcut recorder + let spotlightShortcutValue = $state('') + let spotlightRecording = $state(false) + let spotlightShortcutInputEl = $state(null) + // Keep shortcut value in sync with config store $effect(() => { if ($config?.globalShortcut !== undefined) { @@ -83,6 +88,12 @@ } }) + $effect(() => { + if ($config?.spotlightShortcut !== undefined) { + spotlightShortcutValue = $config.spotlightShortcut ?? '' + } + }) + const keyToElectron = (e: KeyboardEvent): string | null => { const parts: string[] = [] if (e.metaKey || e.ctrlKey) parts.push('CommandOrControl') @@ -142,6 +153,32 @@ config.set(await window.electronAPI.getConfig()) } } + + const handleSpotlightShortcutKeydown = async (e: KeyboardEvent) => { + e.preventDefault() + e.stopPropagation() + + if (e.key === 'Escape') { + spotlightRecording = false + return + } + + if (e.key === 'Backspace' || e.key === 'Delete') { + spotlightShortcutValue = '' + spotlightRecording = false + await window.electronAPI.setConfig({ spotlightShortcut: '' }) + config.set(await window.electronAPI.getConfig()) + return + } + + const accel = keyToElectron(e) + if (accel) { + spotlightShortcutValue = accel + spotlightRecording = false + await window.electronAPI.setConfig({ spotlightShortcut: accel }) + config.set(await window.electronAPI.getConfig()) + } + }
@@ -303,6 +340,60 @@
+
+
+
{$i18n.t('settings.general.spotlightShortcut')}
+
+ {#if spotlightRecording} + {$i18n.t('settings.general.globalShortcutRecording')} + {:else} + {$i18n.t('settings.general.spotlightShortcutDesc')} + {/if} +
+
+
+ + {#if spotlightShortcutValue && !spotlightRecording} + + {/if} +
+
+