Compare commits

...

10 Commits

Author SHA1 Message Date
Timothy Jaeryang Baek 5aa13c813e v0.0.3: fix spotlight focus after page interaction, fix ?q= query passthrough 2026-04-06 11:40:25 -05:00
Timothy Jaeryang Baek 14258c4b36 v0.0.2: spotlight input bar, system theme sync, persistent spotlight position 2026-04-06 11:32:15 -05:00
Timothy Jaeryang Baek a2f6de45f5 refac 2026-04-02 20:36:40 -05:00
Timothy Jaeryang Baek 3b3349d3b5 refac 2026-04-02 20:31:48 -05:00
Timothy Jaeryang Baek b099a4a6fa refac 2026-04-02 20:28:21 -05:00
Timothy Jaeryang Baek 4920e90bef refac 2026-04-02 20:24:01 -05:00
Timothy Jaeryang Baek 6852b4f83e refac 2026-04-02 19:24:34 -05:00
Timothy Jaeryang Baek a29f1fe1f1 Update .npmrc 2026-04-02 18:53:57 -05:00
Timothy Jaeryang Baek 4076e12976 refac 2026-03-31 17:48:05 -05:00
Timothy Jaeryang Baek e9f8c89cc9 refac 2026-03-22 22:38:10 -05:00
20 changed files with 802 additions and 114 deletions
+98 -40
View File
@@ -6,8 +6,35 @@ on:
- release
jobs:
build:
name: Build and Package
compile:
name: Compile (Typecheck + Vite Build)
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Typecheck and Build
run: npm run build
- name: Upload Compiled Output
uses: actions/upload-artifact@v4
with:
name: compiled-output
path: out/
retention-days: 1
package:
name: Package (${{ matrix.os }}-${{ matrix.arch }})
needs: compile
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -29,14 +56,28 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Download Compiled Output
uses: actions/download-artifact@v4
with:
name: compiled-output
path: out/
# ── Flatpak setup (Linux only) ──
- name: Cache Flatpak SDKs
if: matrix.os == 'ubuntu-latest'
uses: actions/cache@v4
with:
path: ~/.local/share/flatpak
key: flatpak-sdk-23.08-electron2-${{ runner.os }}
- name: Install Flatpak build tools
id: flatpak
if: ${{ matrix.os == 'ubuntu-latest' }}
if: matrix.os == 'ubuntu-latest'
continue-on-error: true
run: |
sudo apt-get update
@@ -45,9 +86,10 @@ jobs:
flatpak install --user -y flathub org.freedesktop.Platform//23.08 org.freedesktop.Sdk//23.08
flatpak install --user -y flathub org.electronjs.Electron2.BaseApp//23.08
# ── Apple codesigning (macOS only) ──
- name: Install Apple codesigning certificate
id: apple_cert
if: ${{ matrix.os == 'macos-latest' }}
if: matrix.os == 'macos-latest'
continue-on-error: true
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
@@ -67,39 +109,54 @@ jobs:
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# Build commands — use electron-builder directly to pass arch flags
- name: Create Windows Builds
if: ${{ matrix.os == 'windows-latest' }}
run: npm run build && npx electron-builder --win --${{ matrix.arch }}
# ── Platform packaging ──
- name: Package for Windows
id: win_build
if: matrix.os == 'windows-latest'
continue-on-error: true
run: npx electron-builder --win --${{ matrix.arch }} --publish never
- name: Create macOS Builds (signed)
if: ${{ matrix.os == 'macos-latest' && steps.apple_cert.outcome == 'success' }}
- name: Package for Windows (unsigned fallback)
if: matrix.os == 'windows-latest' && steps.win_build.outcome == 'failure'
env:
WIN_CSC_LINK: ''
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: |
rm -rf dist/
npx electron-builder --win --${{ matrix.arch }} --publish never
- name: Package for macOS (signed + notarized)
id: mac_build_signed
if: matrix.os == 'macos-latest' && steps.apple_cert.outcome == 'success'
continue-on-error: true
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: npm run build && npx electron-builder --mac --${{ matrix.arch }}
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: Create macOS Builds (unsigned fallback)
if: ${{ matrix.os == 'macos-latest' && steps.apple_cert.outcome != 'success' }}
- name: Package for macOS (unsigned fallback)
if: matrix.os == 'macos-latest' && (steps.apple_cert.outcome != 'success' || steps.mac_build_signed.outcome == 'failure')
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: npm run build && npx electron-builder --mac --${{ matrix.arch }}
- name: Create Linux Builds
if: ${{ matrix.os == 'ubuntu-latest' }}
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
run: |
rm -rf dist/
npx electron-builder --mac --${{ matrix.arch }} -c.mac.notarize=false --publish never
- name: Package for Linux
if: matrix.os == 'ubuntu-latest'
run: |
npm run build
if [ "${{ steps.flatpak.outcome }}" == "success" ]; then
npx electron-builder --linux --${{ matrix.arch }}
npx electron-builder --linux --${{ matrix.arch }} --publish never
else
echo "Flatpak not available, building without flatpak"
npx electron-builder --linux AppImage deb snap --${{ matrix.arch }}
npx electron-builder --linux AppImage deb snap --${{ matrix.arch }} --publish never
fi
# Sign Windows executable after build
# ── Windows code signing ──
- name: Azure Trusted Signing (Windows Only)
if: ${{ matrix.os == 'windows-latest' }}
if: matrix.os == 'windows-latest'
continue-on-error: true
uses: azure/trusted-signing-action@v0.5.1
with:
@@ -112,12 +169,7 @@ jobs:
files-folder: dist
files-folder-filter: exe
- name: List files for debugging
shell: bash
run: |
echo "Files in dist directory:"
ls -la dist/ || echo "dist directory not found"
# ── Upload release artifacts ──
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
@@ -138,14 +190,24 @@ jobs:
if-no-files-found: warn
release:
needs: build
if: always() && github.event_name == 'push' && github.ref == 'refs/heads/release'
name: Create GitHub Release
needs: package
if: >-
github.event_name == 'push' &&
github.ref == 'refs/heads/release' &&
!cancelled() &&
needs.package.result != 'failure'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
sparse-checkout: |
package.json
CHANGELOG.md
sparse-checkout-cone-mode: false
- name: Get version from package.json
id: version
@@ -171,16 +233,12 @@ jobs:
- name: Download Artifacts
uses: actions/download-artifact@v4
- name: List downloaded artifacts
run: |
echo "Downloaded artifacts:"
find . -type f | grep -E "\.(exe|zip|dmg|pkg|deb|rpm|AppImage|flatpak|tar\.gz|yml|blockmap)$" || echo "No package files found"
ls -laR
with:
pattern: '*-*'
merge-multiple: false
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
+1 -2
View File
@@ -1,2 +1 @@
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
+24
View File
@@ -5,6 +5,30 @@ 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.3] - 2026-04-06
### Fixed
- **Spotlight Focus** — Spotlight now reliably appears after interacting with the main window or webview (fixed blur-during-show race condition on macOS)
- **Spotlight Query Passthrough** — The `?q=` search parameter from spotlight now correctly navigates already-open webviews instead of being silently ignored
## [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
+1 -1
View File
@@ -26,13 +26,13 @@ mac:
target:
- dmg
- zip
identity: null
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: true
dmg:
background: build/dmg-background.png
artifactName: ${name}-${version}.${ext}
+10 -1
View File
@@ -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()]
}
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.0.1",
"version": "0.0.3",
"license": "AGPL-3.0",
"description": "Open WebUI Desktop",
"main": "./out/main/index.js",
+213 -23
View File
@@ -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,170 @@ 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()
} else {
mainWindow?.show()
mainWindow?.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,
focusable: true,
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)
)
}
})
// Hide on blur — but only when the window was truly visible and settled.
// Without the guard the blur fires during the show→focus transition and
// the window disappears immediately (especially when the user had been
// interacting with the main window / webview).
let blurArmed = false
spotlightWindow.on('focus', () => {
blurArmed = false
setTimeout(() => {
blurArmed = true
}, 200)
})
spotlightWindow.on('blur', () => {
if (blurArmed) {
spotlightWindow?.hide()
}
})
spotlightWindow.on('closed', () => {
spotlightWindow = null
})
return spotlightWindow
}
function showAndFocusSpotlight(win: BrowserWindow): void {
// On macOS the app may not be the "active" application when the global
// shortcut fires (e.g. user clicked into a webview which is a separate
// render process). Calling app.focus() first ensures macOS brings the
// app to the foreground so the subsequent window.focus() actually works.
if (process.platform === 'darwin') {
app.focus({ steal: true })
}
// Restore to saved position, or default if none saved
if (spotlightPosition) {
win.setPosition(spotlightPosition.x, spotlightPosition.y)
} else {
const pos = getDefaultSpotlightPosition()
win.setPosition(pos.x, pos.y)
}
win.show()
win.focus()
// Tell the renderer to focus the input field (belt-and-suspenders —
// the renderer also listens for the 'focus' window event)
win.webContents.focus()
}
function toggleSpotlight(): void {
if (spotlightWindow && !spotlightWindow.isDestroyed()) {
if (spotlightWindow.isVisible()) {
spotlightWindow.hide()
} else {
showAndFocusSpotlight(spotlightWindow)
}
} else {
const win = createSpotlightWindow()
win.once('ready-to-show', () => {
showAndFocusSpotlight(win)
})
} catch (error) {
log.warn('Failed to register global shortcut:', accelerator, error)
}
}
@@ -127,10 +275,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 +340,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 +751,7 @@ if (!gotTheLock) {
app.whenReady().then(async () => {
CONFIG = await getConfig()
loadSpotlightPosition()
log.info('Config:', CONFIG)
app.name = 'Open WebUI'
@@ -649,7 +798,7 @@ if (!gotTheLock) {
await setConfig(config)
CONFIG = await getConfig()
updateTray()
registerGlobalShortcut(CONFIG.globalShortcut)
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut)
})
// Python/uv
@@ -803,6 +952,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 +1244,7 @@ if (!gotTheLock) {
// Global shortcut
registerGlobalShortcut(CONFIG.globalShortcut)
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut)
// Enable screen capture
session.defaultSession.setDisplayMediaRequestHandler(
@@ -1145,6 +1331,10 @@ if (!gotTheLock) {
globalShortcut.unregisterAll()
mainWindow = null
contentWindow = null
if (spotlightWindow && !spotlightWindow.isDestroyed()) {
spotlightWindow.destroy()
}
spotlightWindow = null
tray?.destroy()
tray = null
})
+7 -1
View File
@@ -780,6 +780,7 @@ export interface AppConfig {
connections: Connection[]
runInBackground: boolean
globalShortcut: string
spotlightShortcut: string
dataDir: string
localServer: {
port: number
@@ -799,6 +800,8 @@ export interface AppConfig {
extraArgs: string[]
}
envVars: Record<string, string>
showSidebar: boolean
spotlightPosition: { x: number; y: number } | null
}
const DEFAULT_CONFIG: AppConfig = {
@@ -807,6 +810,7 @@ const DEFAULT_CONFIG: AppConfig = {
connections: [],
runInBackground: true,
globalShortcut: 'Alt+CommandOrControl+O',
spotlightShortcut: 'Shift+CommandOrControl+I',
dataDir: '',
localServer: {
port: 8080,
@@ -823,7 +827,9 @@ const DEFAULT_CONFIG: AppConfig = {
variant: 'cpu',
extraArgs: []
},
envVars: {}
envVars: {},
showSidebar: true,
spotlightPosition: null
}
export const getConfig = async (): Promise<AppConfig> => {
+21
View File
@@ -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
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Open WebUI Spotlight</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/spotlight-main.ts"></script>
</body>
</html>
+32 -26
View File
@@ -1,10 +1,22 @@
<script lang="ts">
import { onMount } from 'svelte'
import { onMount, onDestroy } from 'svelte'
import { fade } from 'svelte/transition'
import { appInfo, config, connections, serverInfo, appState } from './lib/stores'
import Main from './lib/components/Main.svelte'
let themeMediaQuery: MediaQueryList
let themeChangeHandler: ((e: MediaQueryListEvent) => void) | null = null
const applyResolvedTheme = (theme: string) => {
let resolved = theme
if (theme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(resolved)
}
onMount(async () => {
const api = window?.electronAPI
if (!api) return
@@ -15,11 +27,17 @@
// Apply saved theme
const savedTheme = (await api.getConfig())?.theme ?? 'system'
let resolved = savedTheme
if (savedTheme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
applyResolvedTheme(savedTheme)
// Listen for OS theme changes so "system" mode reacts in real-time
themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
themeChangeHandler = () => {
const currentTheme = $config?.theme ?? 'system'
if (currentTheme === 'system') {
applyResolvedTheme('system')
}
}
document.documentElement.classList.add(resolved)
themeMediaQuery.addEventListener('change', themeChangeHandler)
api.onData((data: any) => {
if (data.type === 'status:server') {
@@ -30,32 +48,20 @@
}
})
// Install python in the background — don't block UI
const pythonReady = await api.getPythonStatus()
if (!pythonReady) {
// Check disk space before installing
const MINIMUM_DISK_BYTES = 5 * 1024 * 1024 * 1024 // 5 GB
const disk = await api.getDiskSpace()
if (disk?.free >= 0 && disk.free < MINIMUM_DISK_BYTES) {
const availableGB = (disk.free / (1024 * 1024 * 1024)).toFixed(1)
appState.set(`insufficient-storage:${availableGB}`)
return
}
appState.set('initializing')
api.installPython().then(async () => {
appState.set('ready')
}).catch((e: any) => {
appState.set(`install-failed:${e?.message || 'Python installation failed. Please try again.'}`)
})
} else {
appState.set('ready')
}
// Don't auto-install anything — the user must explicitly choose
// "Get Started" (local install) which handles Python/uv as a prerequisite.
appState.set('ready')
setInterval(async () => {
serverInfo.set(await api.getServerInfo())
}, 3000)
})
onDestroy(() => {
if (themeMediaQuery && themeChangeHandler) {
themeMediaQuery.removeEventListener('change', themeChangeHandler)
}
})
</script>
<main class="w-full h-full bg-[#f5f5f7] dark:bg-[#0a0a0a]">
@@ -0,0 +1,182 @@
<script lang="ts">
import { onMount } from 'svelte'
import logoImage from '../lib/assets/images/splash.png'
let inputEl = $state<HTMLInputElement | null>(null)
let query = $state('')
const api = window.spotlightAPI
const submit = () => {
const q = query.trim()
if (!q || !api) return
api.submitQuery(q)
query = ''
}
onMount(() => {
inputEl?.focus()
window.addEventListener('focus', () => {
query = ''
inputEl?.focus()
})
})
</script>
<svelte:window on:keydown={(e) => e.key === 'Escape' && api?.closeSpotlight()} />
<div class="wrapper">
<div class="bar">
<img class="logo" src={logoImage} alt="" />
<input
bind:this={inputEl}
bind:value={query}
type="text"
placeholder="What can I help you with today?"
autocomplete="off"
spellcheck="false"
onkeydown={(e) => {
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault()
submit()
}
}}
/>
<button class="send" class:active={query.trim().length > 0} aria-label="Send" onclick={submit}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 19V5" />
<path d="M5 12l7-7 7 7" />
</svg>
</button>
</div>
</div>
<style>
@font-face {
font-family: 'Archivo';
src: url('../lib/assets/fonts/Archivo-Variable.ttf');
font-display: swap;
}
:global(*) { margin: 0; padding: 0; box-sizing: border-box; }
:global(html), :global(body), :global(#app) {
height: 100%;
width: 100%;
background: transparent;
overflow: hidden;
user-select: none;
-webkit-font-smoothing: antialiased;
}
.wrapper {
height: 100%;
width: 100%;
padding: 10px 14px 24px 14px;
display: flex;
align-items: center;
}
.bar {
width: 100%;
height: 52px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 9px 0 15px;
border-radius: 14px;
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
-webkit-app-region: drag;
background: #f5f5f7;
color: #1d1d1f;
border: 0.5px solid rgba(0, 0, 0, 0.08);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.06),
0 8px 24px rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
.bar {
background: #1a1a1c;
color: #fafafa;
border-color: rgba(255, 255, 255, 0.08);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.2),
0 8px 24px rgba(0, 0, 0, 0.35);
}
}
.logo {
width: 25px;
height: 25px;
flex-shrink: 0;
object-fit: contain;
opacity: 0.8;
}
@media (prefers-color-scheme: dark) {
.logo { filter: invert(1); }
}
input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 17px;
font-weight: 450;
line-height: 1;
letter-spacing: -0.01em;
color: #1d1d1f;
-webkit-app-region: no-drag;
}
input::placeholder {
color: rgba(0, 0, 0, 0.22);
}
@media (prefers-color-scheme: dark) {
input { color: #fafafa; }
input::placeholder { color: rgba(255, 255, 255, 0.18); }
}
.send {
width: 30px;
height: 30px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border: none;
outline: none;
border-radius: 10px;
cursor: pointer;
transition: all 0.15s ease;
-webkit-app-region: no-drag;
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.18);
}
.send.active {
background: #1d1d1f;
color: #fff;
}
.send.active:hover { opacity: 0.8; }
.send.active:active { transform: scale(0.92); }
@media (prefers-color-scheme: dark) {
.send {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.15);
}
.send.active {
background: #fafafa;
color: #1d1d1f;
}
}
</style>
+35 -3
View File
@@ -14,11 +14,18 @@
onMount(async () => {
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
const cfg = await window.electronAPI.getConfig()
config.set(cfg)
sidebarOpen = cfg?.showSidebar ?? true
setTimeout(() => {
visible = true
}, 50)
})
const toggleSidebar = () => {
sidebarOpen = !sidebarOpen
window.electronAPI.setConfig({ showSidebar: sidebarOpen })
}
</script>
{#if visible}
@@ -33,13 +40,13 @@
: 'h-8'}"
>
<div
class="flex items-center {$appInfo?.platform === 'darwin'
class="flex items-center gap-3 {$appInfo?.platform === 'darwin'
? 'pl-25'
: 'pl-3'} pr-2 shrink-0 translate-y-[0.5px]"
>
<button
class="opacity-70 hover:opacity-100 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] no-drag"
onclick={() => (sidebarOpen = !sidebarOpen)}
onclick={toggleSidebar}
use:tooltip={sidebarOpen ? $i18n.t('sidebar.tooltip.closeSidebar') : $i18n.t('sidebar.tooltip.openSidebar')}
>
<svg
@@ -56,6 +63,31 @@
/>
</svg>
</button>
{#if activeConnectionName}
<button
class="opacity-40 hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] no-drag cursor-pointer"
onclick={() => {
const wv = document.querySelector('webview[style*="display: flex"]') as any
if (wv?.goBack) wv.goBack()
}}
>
<svg class="w-[13px] h-[13px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<button
class="opacity-40 hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] no-drag cursor-pointer"
onclick={() => {
const wv = document.querySelector('webview[style*="display: flex"]') as any
if (wv?.goForward) wv.goForward()
}}
>
<svg class="w-[13px] h-[13px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
{/if}
</div>
<div class="flex-1 flex items-center justify-center">
<span class="text-[11px] opacity-80">{activeConnectionName || $i18n.t('app.name')}</span>
@@ -306,10 +306,32 @@
window.electronAPI.onData((data: any) => {
if (data.type === 'connection:open' && data.data?.url) {
const connId = data.data.connectionId ?? ''
openConnections.set(connId, data.data.url)
const incomingUrl = data.data.url
const alreadyOpen = openConnections.has(connId)
openConnections.set(connId, incomingUrl)
openConnections = new Map(openConnections)
connectedUrl = data.data.url
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)
}
})
}
// Don't switch to connected view during active install — the install
// flow handles its own transition after confirming reachability.
if (installPhase !== 'working') {
@@ -37,12 +37,24 @@
<div
class="relative flex h-36 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-900 via-gray-800 to-black dark:from-white dark:via-gray-100 dark:to-gray-200"
>
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent"></div>
<div
class="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent"
></div>
<div class="relative z-10 text-center">
<div class="mb-2.5 flex justify-center">
<div class="rounded-full bg-white/10 p-3 dark:bg-black/10">
<svg class="w-6 h-6 text-white dark:text-gray-900" 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
class="w-6 h-6 text-white dark:text-gray-900"
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>
</div>
</div>
@@ -57,11 +69,14 @@
<!-- Body -->
<div class="px-6 py-5">
<label class="block text-[11px] text-gray-400 dark:text-gray-500"
>{$i18n.t('setup.connectionManager.serverUrl')}</label
>
<input
type="text"
bind:value={url}
placeholder={$i18n.t('setup.urlPlaceholder')}
class="w-full px-4 py-2.5 rounded-xl bg-black/[0.015] dark:bg-white/[0.02] text-[13px] text-[#1d1d1f] dark:text-[#fafafa] placeholder:opacity-25 outline-none focus:bg-black/[0.03] dark:focus:bg-white/[0.04] transition border border-black/[0.06] dark:border-white/[0.06]"
placeholder="https://"
class="w-full py-2 text-[14px] text-[#1d1d1f] dark:text-[#fafafa] placeholder:opacity-20 outline-none bg-transparent border-none border-b border-black/[0.08] dark:border-white/[0.08]"
onkeydown={(e) => e.key === 'Enter' && onConnect()}
/>
@@ -79,7 +94,9 @@
>
{#if connecting}
<span class="inline-flex items-center gap-2">
<span class="w-3.5 h-3.5 rounded-full border-2 border-white/30 dark:border-black/30 border-t-white dark:border-t-black animate-spin inline-block"></span>
<span
class="w-3.5 h-3.5 rounded-full border-2 border-white/30 dark:border-black/30 border-t-white dark:border-t-black animate-spin inline-block"
></span>
{$i18n.t('common.connecting')}
</span>
{:else}
@@ -24,7 +24,7 @@
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
<div
class="relative mx-4 w-full max-w-lg overflow-hidden rounded-3xl bg-white shadow-2xl dark:bg-gray-950"
class="relative mx-4 w-full max-w-xl overflow-hidden rounded-3xl bg-white shadow-2xl dark:bg-gray-950"
transition:scale={{ start: 0.97, duration: 180 }}
onmousedown={(e) => e.stopPropagation()}
>
@@ -51,7 +51,7 @@
</div>
<!-- Options -->
<div class="px-6 py-4 flex flex-col divide-y divide-gray-100 dark:divide-gray-800/60">
<div class="px-6 py-4 flex flex-col divide-y divide-gray-100/30 dark:divide-gray-800/15">
<div class="py-3.5 flex items-center justify-between gap-4">
<div>
<div class="text-[13px] font-medium text-gray-700 dark:text-gray-300">{$i18n.t('main.getStarted.openTerminal')}</div>
@@ -95,3 +95,4 @@
</div>
</div>
</div>
@@ -69,7 +69,9 @@
>
<!-- Connections header -->
<div class="flex items-center justify-between px-4 pt-2 pb-1.5">
<span class="text-[10px] tracking-wider uppercase opacity-60">{$i18n.t('sidebar.connections')}</span>
<span class="text-[10px] tracking-wider uppercase opacity-60"
>{$i18n.t('sidebar.connections')}</span
>
<button
class="opacity-25 hover:opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] leading-none"
onclick={() => {
@@ -93,13 +95,16 @@
<div class="flex-1 min-h-0 overflow-y-auto px-2">
<!-- Pinned: Open WebUI (local) -->
{#if localConn && localInstalled}
{@const isServerLoading = connectingId === localConn.id || serverStatus === 'starting' || (serverStatus === 'running' && !serverReachable)}
{@const isServerLoading =
connectingId === localConn.id ||
serverStatus === 'starting' ||
(serverStatus === 'running' && !serverReachable)}
{@const isLocalDisabled = !serverReachable && !isServerLoading}
<div
class="w-full px-2 py-[6px] rounded-xl group flex items-center gap-2 transition-colors {isLocalDisabled
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.08]'
? 'bg-black/[0.06] dark:bg-white/[0.06]'
: isLocalDisabled
? ''
: 'hover:bg-black/[0.03] dark:bg-white/[0.05]'}"
@@ -238,9 +243,9 @@
{#each remoteConnections as conn (conn.id)}
<div
class="w-full px-2 py-[6px] rounded-xl group flex items-center gap-2 transition-colors cursor-pointer {activeConnectionId ===
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.08]'
? 'bg-black/[0.06] dark:bg-white/[0.06]'
: 'hover:bg-black/[0.03] dark:bg-white/[0.05]'}"
role="button"
tabindex="0"
@@ -76,6 +76,11 @@
let recording = $state(false)
let shortcutInputEl = $state<HTMLButtonElement | null>(null)
// Spotlight shortcut recorder
let spotlightShortcutValue = $state('')
let spotlightRecording = $state(false)
let spotlightShortcutInputEl = $state<HTMLButtonElement | null>(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())
}
}
</script>
<div class="flex flex-col divide-y divide-white/[0.04]">
@@ -303,6 +340,60 @@
</div>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.spotlightShortcut')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{#if spotlightRecording}
{$i18n.t('settings.general.globalShortcutRecording')}
{:else}
{$i18n.t('settings.general.spotlightShortcutDesc')}
{/if}
</div>
</div>
<div class="flex items-center gap-1.5">
<button
bind:this={spotlightShortcutInputEl}
class="text-[12px] px-3 py-1.5 border-none outline-none rounded-xl transition min-w-[80px] text-center
{spotlightRecording
? 'bg-black/[0.08] dark:bg-white/[0.10] text-[#1d1d1f] dark:text-[#fafafa] opacity-80 animate-pulse'
: 'bg-black/[0.04] dark:bg-white/[0.06] text-[#1d1d1f] dark:text-[#fafafa] opacity-60 hover:opacity-80'}"
onclick={() => {
spotlightRecording = true
spotlightShortcutInputEl?.focus()
}}
onkeydown={(e) => {
if (spotlightRecording) handleSpotlightShortcutKeydown(e)
}}
onblur={() => {
spotlightRecording = false
}}
>
{#if spotlightRecording}
<span class="text-[11px]">{$i18n.t('settings.general.pressShortcut')}</span>
{:else if spotlightShortcutValue}
{displayShortcut(spotlightShortcutValue)}
{:else}
<span class="opacity-40">{$i18n.t('common.disabled')}</span>
{/if}
</button>
{#if spotlightShortcutValue && !spotlightRecording}
<button
class="opacity-20 hover:opacity-50 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 shrink-0"
onclick={async () => {
spotlightShortcutValue = ''
await window.electronAPI.setConfig({ spotlightShortcut: '' })
config.set(await window.electronAPI.getConfig())
}}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
</div>
<!-- Advanced (collapsed by default) -->
<div class="py-4">
<button
@@ -152,6 +152,8 @@
"settings.general.globalShortcutDesc": "Bring the app to the foreground from anywhere",
"settings.general.globalShortcutRecording": "Press a key combination… (Esc to cancel, Del to clear)",
"settings.general.pressShortcut": "Press shortcut…",
"settings.general.spotlightShortcut": "Spotlight shortcut",
"settings.general.spotlightShortcutDesc": "Open the quick-chat input bar from anywhere",
"settings.general.appearance": "Appearance",
"settings.general.appearanceDesc": "Choose light, dark, or system default",
"settings.general.language": "Language",
+8
View File
@@ -0,0 +1,8 @@
import { mount } from 'svelte'
import Spotlight from './components/Spotlight.svelte'
const app = mount(Spotlight, {
target: document.getElementById('app')!
})
export default app