mirror of
https://github.com/open-webui/desktop.git
synced 2026-04-28 01:49:29 +00:00
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
2075 lines
68 KiB
TypeScript
2075 lines
68 KiB
TypeScript
// @ts-nocheck
|
||
|
||
import {
|
||
app,
|
||
shell,
|
||
session,
|
||
clipboard,
|
||
nativeImage,
|
||
desktopCapturer,
|
||
systemPreferences,
|
||
BrowserWindow,
|
||
globalShortcut,
|
||
MessageChannelMain,
|
||
Notification,
|
||
Menu,
|
||
ipcMain,
|
||
Tray,
|
||
dialog
|
||
} from 'electron'
|
||
import path, { join } from 'path'
|
||
import { readFile, statfs } from 'fs/promises'
|
||
|
||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||
|
||
import {
|
||
getLogFilePath,
|
||
checkUrlAndOpen,
|
||
clearAllServerLogs,
|
||
getConfig,
|
||
getUserDataPath,
|
||
getInstallDir,
|
||
getServerLog,
|
||
getServerPIDs,
|
||
getServerPty,
|
||
installPackage,
|
||
installPython,
|
||
isPackageInstalled,
|
||
isPythonInstalled,
|
||
getPackageVersion,
|
||
uninstallPackage,
|
||
isUvInstalled,
|
||
openUrl,
|
||
resetApp,
|
||
setConfig,
|
||
startServer,
|
||
stopAllServers,
|
||
uninstallPython,
|
||
validateRemoteUrl,
|
||
type AppConfig,
|
||
type Connection
|
||
} from './utils'
|
||
|
||
import {
|
||
startOpenTerminal,
|
||
stopOpenTerminal,
|
||
getOpenTerminalInfo,
|
||
getOpenTerminalPty,
|
||
getOpenTerminalLog,
|
||
validateOpenTerminalProcess
|
||
} from './utils/open-terminal'
|
||
|
||
import {
|
||
setupLlamaCpp,
|
||
startLlamaCpp,
|
||
stopLlamaCpp,
|
||
getLlamaCppInfo,
|
||
getLlamaCppLog,
|
||
getLlamaCppPty,
|
||
validateLlamaCppProcess,
|
||
checkLlamaCppUpdate,
|
||
updateLlamaCpp,
|
||
uninstallLlamaCpp
|
||
} from './utils/llamacpp'
|
||
|
||
import {
|
||
listModels,
|
||
downloadModel,
|
||
deleteModel,
|
||
cancelDownload,
|
||
getModelsDir,
|
||
searchModels,
|
||
getRepoFiles
|
||
} from './utils/huggingface'
|
||
|
||
import { initUpdater, checkForUpdates, downloadUpdate, installUpdate } from './updater'
|
||
|
||
import log from 'electron-log'
|
||
log.transports.file.resolvePathFn = () => getLogFilePath('main')
|
||
|
||
import icon from '../../resources/icon.png?asset'
|
||
|
||
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 ─────────────────────────────────
|
||
// When the GPU process crashes fatally (common on certain NVIDIA/Intel
|
||
// driver + Windows combos), we write a marker file and relaunch with
|
||
// --disable-gpu-sandbox so the user doesn't have to manually edit
|
||
// shortcut properties. On the next launch the marker is detected and
|
||
// the switch is applied preemptively.
|
||
|
||
const gpuCrashMarkerPath = join(app.getPath('userData'), '.gpu-sandbox-disabled')
|
||
const gpuSandboxDisabled = existsSync(gpuCrashMarkerPath)
|
||
|
||
if (gpuSandboxDisabled) {
|
||
log.info('GPU sandbox disabled due to previous GPU process crash')
|
||
app.commandLine.appendSwitch('disable-gpu-sandbox')
|
||
}
|
||
|
||
// Prevent Chromium from permanently blocking WebGL / 3-D APIs after
|
||
// repeated GPU process crashes within the same session.
|
||
app.disableDomainBlockingFor3DAPIs()
|
||
|
||
// ─── State ──────────────────────────────────────────────
|
||
|
||
let mainWindow: BrowserWindow | null = null
|
||
let contentWindow: BrowserWindow | null = null
|
||
let spotlightWindow: BrowserWindow | null = null
|
||
let voiceInputWindow: BrowserWindow | null = null
|
||
let tray: Tray | null = null
|
||
let isQuiting = false
|
||
|
||
let CONFIG: AppConfig | null = null
|
||
let SERVER_URL: string | null = null
|
||
let SERVER_STATUS: string | null = null
|
||
let SERVER_REACHABLE = false
|
||
let SERVER_PID: number | null = null
|
||
let AUTH_TOKEN: string | null = null
|
||
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) {
|
||
tryRegisterShortcut(globalAccel, 'Open WebUI', () => {
|
||
if (mainWindow) {
|
||
mainWindow.show()
|
||
mainWindow.focus()
|
||
} else {
|
||
createMainWindow()
|
||
}
|
||
})
|
||
}
|
||
|
||
// Spotlight shortcut – toggle the spotlight input bar
|
||
if (spotlightAccel) {
|
||
tryRegisterShortcut(spotlightAccel, 'Spotlight', () => {
|
||
const text = CONFIG?.spotlightClipboardPaste !== false
|
||
? (clipboard.readText()?.trim() || '')
|
||
: ''
|
||
toggleSpotlight(text)
|
||
})
|
||
}
|
||
|
||
// Voice input shortcut – toggle microphone recording
|
||
if (voiceInputAccel && CONFIG?.voiceInputEnabled !== false) {
|
||
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) {
|
||
tryRegisterShortcut(callAccel, 'Call', () => {
|
||
toggleCall()
|
||
})
|
||
} else {
|
||
log.info(`Call shortcut skipped — accel="${callAccel}", enabled=${CONFIG?.callEnabled}`)
|
||
}
|
||
}
|
||
|
||
// ─── Spotlight Window ───────────────────────────────────
|
||
// Bar position within the fullscreen window (persisted to config).
|
||
let spotlightBarOffset: { x: number; y: number } | null = null
|
||
|
||
function loadSpotlightPosition(): void {
|
||
if (CONFIG?.spotlightPosition) {
|
||
spotlightBarOffset = { ...CONFIG.spotlightPosition }
|
||
}
|
||
}
|
||
|
||
function createSpotlightWindow(): BrowserWindow {
|
||
const { screen } = require('electron')
|
||
const cursorPoint = screen.getCursorScreenPoint()
|
||
const activeDisplay = screen.getDisplayNearestPoint(cursorPoint)
|
||
const { x: sx, y: sy, width: sw, height: sh } = activeDisplay.bounds
|
||
|
||
spotlightWindow = new BrowserWindow({
|
||
x: sx,
|
||
y: sy,
|
||
width: sw,
|
||
height: sh,
|
||
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'))
|
||
}
|
||
|
||
// Hide on blur — but only when the window was truly visible and settled.
|
||
let blurArmed = false
|
||
spotlightWindow.on('focus', () => {
|
||
blurArmed = false
|
||
setTimeout(() => {
|
||
blurArmed = true
|
||
}, 200)
|
||
})
|
||
spotlightWindow.on('blur', () => {
|
||
if (blurArmed) {
|
||
spotlightWindow?.hide()
|
||
// Restore main window when spotlight dismisses
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
mainWindow.show()
|
||
}
|
||
}
|
||
})
|
||
|
||
spotlightWindow.on('closed', () => {
|
||
// Restore main window if spotlight is closed
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
mainWindow.show()
|
||
}
|
||
spotlightWindow = null
|
||
})
|
||
|
||
return spotlightWindow
|
||
}
|
||
|
||
function showAndFocusSpotlight(win: BrowserWindow, initialQuery?: string): void {
|
||
if (process.platform === 'darwin') {
|
||
app.focus({ steal: true })
|
||
}
|
||
|
||
// Reposition fullscreen window to the active display
|
||
const { screen } = require('electron')
|
||
const cursorPoint = screen.getCursorScreenPoint()
|
||
const activeDisplay = screen.getDisplayNearestPoint(cursorPoint)
|
||
const { x: sx, y: sy, width: sw, height: sh } = activeDisplay.bounds
|
||
win.setBounds({ x: sx, y: sy, width: sw, height: sh })
|
||
|
||
// Hide main window so it doesn't appear behind the transparent overlay
|
||
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) {
|
||
mainWindow.hide()
|
||
}
|
||
|
||
win.show()
|
||
win.focus()
|
||
win.webContents.focus()
|
||
|
||
// Send initial data to the renderer (bar offset + optional query)
|
||
win.webContents.send('spotlight:init', {
|
||
barOffset: spotlightBarOffset,
|
||
screenSize: { width: sw, height: sh },
|
||
query: initialQuery || ''
|
||
})
|
||
}
|
||
|
||
function toggleSpotlight(selectedText?: string): void {
|
||
if (spotlightWindow && !spotlightWindow.isDestroyed()) {
|
||
if (spotlightWindow.isVisible()) {
|
||
spotlightWindow.hide()
|
||
} else {
|
||
showAndFocusSpotlight(spotlightWindow, selectedText)
|
||
}
|
||
} else {
|
||
const win = createSpotlightWindow()
|
||
win.once('ready-to-show', () => {
|
||
showAndFocusSpotlight(win, selectedText)
|
||
})
|
||
}
|
||
}
|
||
|
||
// ─── Voice Input Window ─────────────────────────────────
|
||
|
||
function createVoiceInputWindow(): BrowserWindow {
|
||
const { screen } = require('electron')
|
||
const cursorPoint = screen.getCursorScreenPoint()
|
||
const activeDisplay = screen.getDisplayNearestPoint(cursorPoint)
|
||
const { x: sx, y: sy, width: sw } = activeDisplay.bounds
|
||
|
||
const winW = 340
|
||
const winH = 72
|
||
|
||
voiceInputWindow = new BrowserWindow({
|
||
x: sx + Math.round((sw - winW) / 2),
|
||
y: sy + 120,
|
||
width: winW,
|
||
height: winH,
|
||
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/voice-input-preload.js'),
|
||
sandbox: false,
|
||
webviewTag: false,
|
||
autoplayPolicy: 'no-user-gesture-required'
|
||
}
|
||
})
|
||
|
||
// Grant microphone permission for the voice input window
|
||
voiceInputWindow.webContents.session.setPermissionRequestHandler(
|
||
(_webContents, permission, callback) => {
|
||
callback(permission === 'media')
|
||
}
|
||
)
|
||
|
||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||
voiceInputWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/voice-input.html`)
|
||
} else {
|
||
voiceInputWindow.loadFile(join(__dirname, '../renderer/voice-input.html'))
|
||
}
|
||
|
||
voiceInputWindow.on('closed', () => {
|
||
voiceInputWindow = null
|
||
voiceInputRecording = false
|
||
})
|
||
|
||
return voiceInputWindow
|
||
}
|
||
|
||
function playChime(ascending: boolean): Promise<void> {
|
||
return new Promise((resolve) => {
|
||
const { execFile } = require('child_process')
|
||
const fs = require('fs')
|
||
const file = ascending ? 'chime-start.wav' : 'chime-stop.wav'
|
||
const soundPath = app.isPackaged
|
||
? join(process.resourcesPath, 'app.asar.unpacked', 'resources', 'sounds', file)
|
||
: join(app.getAppPath(), 'resources', 'sounds', file)
|
||
|
||
const exists = fs.existsSync(soundPath)
|
||
log.info(`playChime: ${ascending ? 'start' : 'stop'}, path=${soundPath}, exists=${exists}`)
|
||
|
||
if (!exists) { resolve(); return }
|
||
|
||
if (process.platform === 'darwin') {
|
||
execFile('afplay', [soundPath], (err, stdout, stderr) => {
|
||
if (err) log.warn('afplay error:', err.message, stderr)
|
||
resolve()
|
||
})
|
||
} else if (process.platform === 'win32') {
|
||
execFile('powershell', ['-NoProfile', '-Command',
|
||
`(New-Object Media.SoundPlayer '${soundPath}').PlaySync()`
|
||
], () => resolve())
|
||
} else {
|
||
execFile('paplay', [soundPath], (err) => {
|
||
if (err) execFile('aplay', [soundPath], () => resolve())
|
||
else resolve()
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
async function toggleVoiceInput(): Promise<void> {
|
||
if (voiceInputRecording) {
|
||
// Stop recording — chime plays in done/close handler after mic is released
|
||
voiceInputRecording = false
|
||
if (voiceInputWindow && !voiceInputWindow.isDestroyed()) {
|
||
voiceInputWindow.webContents.send('voiceInput:state', { recording: false })
|
||
}
|
||
return
|
||
}
|
||
|
||
// Pre-flight: check microphone permission on macOS
|
||
if (process.platform === 'darwin') {
|
||
const micStatus = systemPreferences.getMediaAccessStatus('microphone')
|
||
if (micStatus !== 'granted') {
|
||
const granted = await systemPreferences.askForMediaAccess('microphone')
|
||
if (!granted) {
|
||
log.warn('Voice input: microphone permission denied')
|
||
new Notification({
|
||
title: 'Voice Input',
|
||
body: 'Microphone access denied. Enable it in System Settings → Privacy & Security → Microphone, then restart the app.'
|
||
}).show()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pre-flight: check a connection is configured
|
||
try {
|
||
const config = await getConfig()
|
||
if (!config.defaultConnectionId || config.connections.length === 0) {
|
||
log.warn('Voice input: no connection configured')
|
||
new Notification({
|
||
title: 'Voice Input',
|
||
body: 'No connection configured. Set up a connection in Settings before using voice input.'
|
||
}).show()
|
||
return
|
||
}
|
||
const conn = config.connections.find((c) => c.id === config.defaultConnectionId)
|
||
if (!conn) {
|
||
log.warn('Voice input: default connection not found')
|
||
new Notification({
|
||
title: 'Voice Input',
|
||
body: 'Default connection not found. Check your connection settings.'
|
||
}).show()
|
||
return
|
||
}
|
||
} catch (err: any) {
|
||
log.warn('Voice input: config check failed:', err)
|
||
}
|
||
|
||
// Start recording — chime plays concurrently (separate audio output path from mic input)
|
||
voiceInputRecording = true
|
||
playChime(true)
|
||
|
||
if (voiceInputWindow && !voiceInputWindow.isDestroyed()) {
|
||
voiceInputWindow.show()
|
||
voiceInputWindow.focus()
|
||
voiceInputWindow.webContents.send('voiceInput:state', { recording: true })
|
||
} else {
|
||
const win = createVoiceInputWindow()
|
||
win.once('ready-to-show', () => {
|
||
win.show()
|
||
win.focus()
|
||
setTimeout(() => {
|
||
win.webContents.send('voiceInput:state', { recording: true })
|
||
}, 100)
|
||
})
|
||
}
|
||
}
|
||
|
||
// ─── Call Shortcut ──────────────────────────────────────
|
||
|
||
async function toggleCall(): Promise<void> {
|
||
// Pre-flight: check a connection is configured
|
||
try {
|
||
const config = await getConfig()
|
||
if (!config.defaultConnectionId || config.connections.length === 0) {
|
||
log.warn('Call: no connection configured')
|
||
new Notification({
|
||
title: 'Call',
|
||
body: 'No connection configured. Set up a connection in Settings before using the call shortcut.'
|
||
}).show()
|
||
return
|
||
}
|
||
const conn = config.connections.find((c) => c.id === config.defaultConnectionId)
|
||
if (!conn) {
|
||
log.warn('Call: default connection not found')
|
||
new Notification({
|
||
title: 'Call',
|
||
body: 'Default connection not found. Check your connection settings.'
|
||
}).show()
|
||
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')
|
||
}
|
||
|
||
sendToRenderer('call', { connectionId: conn.id, url })
|
||
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
mainWindow.show()
|
||
mainWindow.focus()
|
||
}
|
||
} catch (err: any) {
|
||
log.warn('Call: config check failed:', err)
|
||
}
|
||
}
|
||
|
||
// ─── Windows ────────────────────────────────────────────
|
||
|
||
const DEFAULT_WINDOW_WIDTH = 1280
|
||
const DEFAULT_WINDOW_HEIGHT = 800
|
||
const MIN_WINDOW_WIDTH = 480
|
||
const MIN_WINDOW_HEIGHT = 360
|
||
const BOUNDS_SAVE_DEBOUNCE_MS = 500
|
||
const MIN_VISIBLE_OVERLAP_PX = 100
|
||
|
||
/** Last known non-maximized bounds, used to preserve restore geometry. */
|
||
let lastNormalBounds: Electron.Rectangle | null = null
|
||
|
||
/** Debounced persistence of the current window geometry to config. */
|
||
let boundsDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
function debounceSaveWindowBounds(win: BrowserWindow): void {
|
||
if (boundsDebounceTimer) clearTimeout(boundsDebounceTimer)
|
||
boundsDebounceTimer = setTimeout(() => {
|
||
if (win.isDestroyed()) return
|
||
const maximized = win.isMaximized()
|
||
const bounds = maximized ? (lastNormalBounds ?? win.getNormalBounds()) : win.getBounds()
|
||
setConfig({ windowBounds: bounds, windowMaximized: maximized }).catch((err) =>
|
||
log.warn('Failed to save window bounds:', err)
|
||
)
|
||
}, BOUNDS_SAVE_DEBOUNCE_MS)
|
||
}
|
||
|
||
/**
|
||
* Returns true when at least `MIN_VISIBLE_OVERLAP_PX` of the saved
|
||
* rectangle would be visible on one of the connected displays.
|
||
*/
|
||
function isBoundsOnVisibleDisplay(bounds: { x: number; y: number }): boolean {
|
||
const { screen } = require('electron')
|
||
const targetPoint = { x: bounds.x + MIN_VISIBLE_OVERLAP_PX / 2, y: bounds.y + MIN_VISIBLE_OVERLAP_PX / 2 }
|
||
const display = screen.getDisplayNearestPoint(targetPoint)
|
||
const { x, y, width, height } = display.workArea
|
||
return (
|
||
bounds.x + MIN_VISIBLE_OVERLAP_PX > x &&
|
||
bounds.x < x + width &&
|
||
bounds.y + MIN_VISIBLE_OVERLAP_PX > y &&
|
||
bounds.y < y + height
|
||
)
|
||
}
|
||
|
||
function trackNormalBounds(win: BrowserWindow): void {
|
||
if (!win.isDestroyed() && !win.isMaximized()) {
|
||
lastNormalBounds = win.getBounds()
|
||
}
|
||
}
|
||
|
||
function createMainWindow(show = true): void {
|
||
const saved = CONFIG?.windowBounds
|
||
const windowOpts: Electron.BrowserWindowConstructorOptions = {
|
||
width: saved?.width ?? DEFAULT_WINDOW_WIDTH,
|
||
height: saved?.height ?? DEFAULT_WINDOW_HEIGHT,
|
||
minWidth: MIN_WINDOW_WIDTH,
|
||
minHeight: MIN_WINDOW_HEIGHT,
|
||
icon: path.join(__dirname, 'assets/icon.png'),
|
||
show: false,
|
||
titleBarStyle: process.platform === 'win32' ? 'default' : 'hidden',
|
||
trafficLightPosition: { x: 10, y: 10 },
|
||
autoHideMenuBar: true,
|
||
vibrancy: 'under-window',
|
||
visualEffectState: 'active',
|
||
backgroundColor: '#00000000',
|
||
...(process.platform === 'win32' ? { frame: true } : {}),
|
||
...(process.platform === 'linux' ? { icon } : {}),
|
||
...(process.platform !== 'darwin' ? { titleBarOverlay: true } : {}),
|
||
webPreferences: {
|
||
preload: join(__dirname, '../preload/index.js'),
|
||
sandbox: false,
|
||
webviewTag: true
|
||
}
|
||
}
|
||
|
||
// Restore position only when the saved location is still on a visible display
|
||
// (e.g. an external monitor may have been disconnected since last session).
|
||
if (saved?.x != null && saved?.y != null && isBoundsOnVisibleDisplay(saved)) {
|
||
windowOpts.x = saved.x
|
||
windowOpts.y = saved.y
|
||
}
|
||
|
||
mainWindow = new BrowserWindow(windowOpts)
|
||
mainWindow.setIcon(icon)
|
||
|
||
if (CONFIG?.windowMaximized) {
|
||
mainWindow.maximize()
|
||
}
|
||
|
||
if (!app.isPackaged) {
|
||
mainWindow.webContents.openDevTools()
|
||
}
|
||
|
||
if (show) {
|
||
mainWindow.on('ready-to-show', () => {
|
||
mainWindow?.show()
|
||
})
|
||
}
|
||
|
||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||
openUrl(details.url)
|
||
return { action: 'deny' }
|
||
})
|
||
|
||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||
} else {
|
||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||
}
|
||
|
||
// ── Persist window bounds on geometry changes ──
|
||
const onBoundsChanged = (): void => {
|
||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||
trackNormalBounds(mainWindow)
|
||
debounceSaveWindowBounds(mainWindow)
|
||
}
|
||
mainWindow.on('resize', onBoundsChanged)
|
||
mainWindow.on('move', onBoundsChanged)
|
||
mainWindow.on('maximize', onBoundsChanged)
|
||
mainWindow.on('unmaximize', onBoundsChanged)
|
||
|
||
mainWindow.on('close', (event) => {
|
||
if (!isQuiting) {
|
||
if (CONFIG?.runInBackground === false) {
|
||
isQuiting = true
|
||
app.quit()
|
||
} else {
|
||
event.preventDefault()
|
||
mainWindow?.hide()
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
function createContentWindow(url: string, connectionId: string): BrowserWindow {
|
||
if (contentWindow && !contentWindow.isDestroyed()) {
|
||
contentWindow.loadURL(url)
|
||
contentWindow.show()
|
||
return contentWindow
|
||
}
|
||
|
||
contentWindow = new BrowserWindow({
|
||
width: DEFAULT_WINDOW_WIDTH,
|
||
height: DEFAULT_WINDOW_HEIGHT,
|
||
minWidth: MIN_WINDOW_WIDTH,
|
||
minHeight: MIN_WINDOW_HEIGHT,
|
||
icon: path.join(__dirname, 'assets/icon.png'),
|
||
show: false,
|
||
titleBarStyle: process.platform === 'win32' ? 'default' : 'hidden',
|
||
trafficLightPosition: { x: 16, y: 16 },
|
||
autoHideMenuBar: true,
|
||
...(process.platform === 'win32' ? { frame: true } : {}),
|
||
...(process.platform === 'linux' ? { icon } : {}),
|
||
webPreferences: {
|
||
sandbox: true,
|
||
nodeIntegration: false,
|
||
contextIsolation: true,
|
||
partition: `persist:connection-${connectionId}`
|
||
}
|
||
})
|
||
|
||
// Enable media capture
|
||
session
|
||
.fromPartition(`persist:connection-${connectionId}`)
|
||
.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||
const allowedPermissions = ['media', 'mediaKeySystem', 'notifications']
|
||
callback(allowedPermissions.includes(permission))
|
||
})
|
||
|
||
contentWindow.on('ready-to-show', () => {
|
||
contentWindow?.show()
|
||
})
|
||
|
||
contentWindow.webContents.setWindowOpenHandler((details) => {
|
||
openUrl(details.url)
|
||
return { action: 'deny' }
|
||
})
|
||
|
||
contentWindow.loadURL(url)
|
||
|
||
contentWindow.on('close', (event) => {
|
||
if (!isQuiting) {
|
||
if (CONFIG?.runInBackground === false) {
|
||
isQuiting = true
|
||
app.quit()
|
||
} else {
|
||
event.preventDefault()
|
||
contentWindow?.hide()
|
||
}
|
||
}
|
||
})
|
||
|
||
contentWindow.on('closed', () => {
|
||
contentWindow = null
|
||
})
|
||
|
||
return contentWindow
|
||
}
|
||
|
||
// ─── Tray ───────────────────────────────────────────────
|
||
|
||
const updateTray = () => {
|
||
if (!tray || !CONFIG) return
|
||
|
||
const connectionItems = (CONFIG.connections || []).map((conn) => ({
|
||
label: `${conn.id === CONFIG.defaultConnectionId ? '★ ' : ''}${conn.name}`,
|
||
sublabel: conn.url,
|
||
click: async () => {
|
||
const result = await connectTo(conn)
|
||
if (result) sendToRenderer('connection:open', result)
|
||
}
|
||
}))
|
||
|
||
const trayMenuTemplate = [
|
||
{
|
||
label: 'Show Open WebUI',
|
||
click: () => {
|
||
mainWindow?.show()
|
||
mainWindow?.focus()
|
||
}
|
||
},
|
||
{ type: 'separator' },
|
||
...(connectionItems.length > 0
|
||
? [
|
||
{ label: 'Connections', enabled: false },
|
||
...connectionItems,
|
||
{ type: 'separator' }
|
||
]
|
||
: []),
|
||
...(SERVER_STATUS === 'started' && SERVER_URL
|
||
? [
|
||
{
|
||
label: `Local: ${SERVER_URL}`,
|
||
click: () => {
|
||
if (SERVER_URL) clipboard.writeText(SERVER_URL)
|
||
}
|
||
},
|
||
{ type: 'separator' }
|
||
]
|
||
: []),
|
||
{
|
||
label: 'Quit Open WebUI',
|
||
accelerator: 'CommandOrControl+Q',
|
||
click: async () => {
|
||
await stopServerHandler()
|
||
isQuiting = true
|
||
app.quit()
|
||
}
|
||
}
|
||
]
|
||
|
||
const trayMenu = Menu.buildFromTemplate(trayMenuTemplate)
|
||
tray?.setContextMenu(trayMenu)
|
||
}
|
||
|
||
// ─── Connection Management ──────────────────────────────
|
||
|
||
const connectTo = async (connection: Connection) => {
|
||
let url = connection.url
|
||
|
||
if (connection.type === 'local') {
|
||
// Start local server if needed
|
||
if (SERVER_STATUS !== 'started') {
|
||
const started = await startServerHandler()
|
||
if (!started) return null
|
||
}
|
||
url = SERVER_URL || connection.url
|
||
|
||
// Wait for the server to actually be reachable before opening the view.
|
||
// startServerHandler returns as soon as the process spawns, but the HTTP
|
||
// endpoint might not be ready yet (especially on first launch).
|
||
if (!SERVER_REACHABLE) {
|
||
const maxWait = 120_000
|
||
const poll = 2_000
|
||
const t0 = Date.now()
|
||
while (!SERVER_REACHABLE && Date.now() - t0 < maxWait) {
|
||
await new Promise((r) => setTimeout(r, poll))
|
||
}
|
||
if (!SERVER_REACHABLE) {
|
||
log.warn('connectTo: server did not become reachable within timeout')
|
||
return null
|
||
}
|
||
}
|
||
}
|
||
|
||
// Normalize URL
|
||
if (url.startsWith('http://0.0.0.0')) {
|
||
url = url.replace('http://0.0.0.0', 'http://localhost')
|
||
}
|
||
|
||
return { url, connectionId: connection.id }
|
||
}
|
||
|
||
// ─── Server Lifecycle ───────────────────────────────────
|
||
|
||
// Active PTY data listener — when a MessagePort is connected, PTY data
|
||
// flows to the port. This disposable gets replaced on each pty:connect.
|
||
let activePtyDataDisposable: { dispose: () => void } | null = null
|
||
|
||
const startServerHandler = async (): Promise<boolean> => {
|
||
if (SERVER_STATUS === 'starting' || SERVER_STATUS === 'started') {
|
||
log.info('[server] Already running or starting, skipping duplicate start')
|
||
return true
|
||
}
|
||
await stopServerHandler()
|
||
SERVER_STATUS = 'starting'
|
||
sendToRenderer('status:server', SERVER_STATUS)
|
||
|
||
try {
|
||
CONFIG = await getConfig()
|
||
const { url, pid } = await startServer(
|
||
CONFIG?.localServer?.serveOnLocalNetwork ?? false,
|
||
CONFIG?.localServer?.port ?? null
|
||
)
|
||
SERVER_URL = url
|
||
SERVER_PID = pid
|
||
SERVER_STATUS = 'started'
|
||
log.info('Server started:', SERVER_URL, SERVER_PID)
|
||
sendToRenderer('status:server', SERVER_STATUS)
|
||
|
||
// Auto-push PTY port so an already-open log panel picks up live output
|
||
connectPtyPort(pid)
|
||
updateTray()
|
||
|
||
checkUrlAndOpen(SERVER_URL, async () => {
|
||
SERVER_REACHABLE = true
|
||
sendToRenderer('server:ready', { url: SERVER_URL })
|
||
updateTray()
|
||
})
|
||
|
||
return true
|
||
} catch (error) {
|
||
log.error('Failed to start server:', error)
|
||
SERVER_STATUS = 'failed'
|
||
sendToRenderer('status:server', SERVER_STATUS)
|
||
sendToRenderer('error', { message: `Failed to start server: ${error?.message}` })
|
||
updateTray()
|
||
return false
|
||
}
|
||
}
|
||
|
||
// Active PTY data listeners — one per PID, replaced on each pty:connect for that PID
|
||
const activePtyDisposables: Map<number, { dispose: () => void }> = new Map()
|
||
|
||
/**
|
||
* Creates a MessagePort-based channel between a PTY process and the renderer.
|
||
* Supports multiple concurrent PTYs — each identified by PID.
|
||
*
|
||
* Flow:
|
||
* PTY stdout → port1.postMessage → [transfer] → port2 (renderer) → xterm.write
|
||
* xterm.onData → port2.postMessage → [transfer] → port1 (main) → PTY.write
|
||
*/
|
||
const connectPtyPort = (pid?: number): void => {
|
||
const targetPid = pid ?? SERVER_PID
|
||
if (!mainWindow) return
|
||
|
||
const { port1, port2 } = new MessageChannelMain()
|
||
|
||
if (!targetPid) {
|
||
if (SERVER_STATUS === 'starting') {
|
||
log.info('pty:connect — server is starting, no PID yet')
|
||
} else {
|
||
log.info('pty:connect — no active server')
|
||
port1.postMessage({ type: 'output', data: '[No active server process]\r\n' })
|
||
}
|
||
mainWindow.webContents.postMessage('pty:port', { pid: 0 }, [port2])
|
||
return
|
||
}
|
||
|
||
// Clean up previous connection for this PID
|
||
activePtyDisposables.get(targetPid)?.dispose()
|
||
activePtyDisposables.delete(targetPid)
|
||
|
||
const ptyProcess = getServerPty(targetPid)
|
||
log.info(`pty:connect — PID ${targetPid}, pty exists: ${!!ptyProcess}`)
|
||
|
||
// Replay buffered output so renderer sees full history
|
||
const buffer = getServerLog(targetPid)
|
||
if (buffer?.length) {
|
||
for (const chunk of buffer) {
|
||
port1.postMessage({ type: 'output', data: chunk })
|
||
}
|
||
}
|
||
|
||
// PTY → port1 → renderer
|
||
if (ptyProcess) {
|
||
const disposable = ptyProcess.onData((data: string) => {
|
||
port1.postMessage({ type: 'output', data })
|
||
})
|
||
activePtyDisposables.set(targetPid, disposable)
|
||
|
||
// Renderer → port1 → PTY (interactive input)
|
||
port1.on('message', (event) => {
|
||
const msg = event.data
|
||
if (msg.type === 'input') {
|
||
ptyProcess.write(msg.data)
|
||
} else if (msg.type === 'resize') {
|
||
ptyProcess.resize(msg.cols, msg.rows)
|
||
}
|
||
})
|
||
port1.start()
|
||
}
|
||
|
||
// Transfer port2 to the renderer
|
||
mainWindow.webContents.postMessage('pty:port', { pid: targetPid }, [port2])
|
||
}
|
||
|
||
/**
|
||
* MessagePort channel for the Open Terminal PTY — read-only log viewer.
|
||
*/
|
||
let activeOpenTerminalDisposable: { dispose: () => void } | null = null
|
||
|
||
const connectOpenTerminalPtyPort = (): void => {
|
||
if (!mainWindow) return
|
||
|
||
const { port1, port2 } = new MessageChannelMain()
|
||
|
||
const otPty = getOpenTerminalPty()
|
||
if (!otPty) {
|
||
port1.postMessage({ type: 'output', data: '[Open Terminal is not running]\r\n' })
|
||
mainWindow.webContents.postMessage('open-terminal:pty:port', null, [port2])
|
||
return
|
||
}
|
||
|
||
// Clean up previous
|
||
activeOpenTerminalDisposable?.dispose()
|
||
|
||
// Replay log buffer
|
||
const buffer = getOpenTerminalLog()
|
||
for (const chunk of buffer) {
|
||
port1.postMessage({ type: 'output', data: chunk })
|
||
}
|
||
|
||
// Live data
|
||
const disposable = otPty.onData((data: string) => {
|
||
port1.postMessage({ type: 'output', data })
|
||
})
|
||
activeOpenTerminalDisposable = disposable
|
||
|
||
port1.start()
|
||
mainWindow.webContents.postMessage('open-terminal:pty:port', null, [port2])
|
||
}
|
||
|
||
/**
|
||
* MessagePort channel for the llamacpp PTY — log viewer.
|
||
*/
|
||
let activeLlamaCppDisposable: { dispose: () => void } | null = null
|
||
|
||
const connectLlamaCppPtyPort = (): void => {
|
||
if (!mainWindow) return
|
||
|
||
const { port1, port2 } = new MessageChannelMain()
|
||
|
||
const lsPty = getLlamaCppPty()
|
||
if (!lsPty) {
|
||
port1.postMessage({ type: 'output', data: '[llamacpp is not running]\r\n' })
|
||
mainWindow.webContents.postMessage('llamacpp:pty:port', null, [port2])
|
||
return
|
||
}
|
||
|
||
// Clean up previous
|
||
activeLlamaCppDisposable?.dispose()
|
||
|
||
// Replay log buffer
|
||
const buffer = getLlamaCppLog()
|
||
for (const chunk of buffer) {
|
||
port1.postMessage({ type: 'output', data: chunk })
|
||
}
|
||
|
||
// Live data
|
||
const disposable = lsPty.onData((data: string) => {
|
||
port1.postMessage({ type: 'output', data })
|
||
})
|
||
activeLlamaCppDisposable = disposable
|
||
|
||
port1.start()
|
||
mainWindow.webContents.postMessage('llamacpp:pty:port', null, [port2])
|
||
}
|
||
|
||
const stopServerHandler = async (): Promise<boolean> => {
|
||
try {
|
||
await stopAllServers()
|
||
if (SERVER_STATUS) {
|
||
SERVER_STATUS = 'stopped'
|
||
updateTray()
|
||
}
|
||
SERVER_REACHABLE = false
|
||
SERVER_URL = null
|
||
sendToRenderer('status:server', SERVER_STATUS)
|
||
return true
|
||
} catch (error) {
|
||
log.error('Failed to stop server:', error)
|
||
return false
|
||
}
|
||
}
|
||
|
||
const resetAppHandler = async () => {
|
||
try {
|
||
await stopServerHandler()
|
||
SERVER_STATUS = null
|
||
// Stop Open Terminal if running
|
||
try {
|
||
await stopOpenTerminal()
|
||
sendToRenderer('status:open-terminal', null)
|
||
} catch (e) {
|
||
log.warn('Failed to stop Open Terminal during reset:', e)
|
||
}
|
||
// Stop and uninstall llama.cpp if running
|
||
try {
|
||
await uninstallLlamaCpp()
|
||
sendToRenderer('status:llamacpp', null)
|
||
} catch (e) {
|
||
log.warn('Failed to uninstall llama.cpp during reset:', e)
|
||
}
|
||
// Remove GPU crash marker so sandbox is re-tested on next launch
|
||
try {
|
||
if (existsSync(gpuCrashMarkerPath)) {
|
||
unlinkSync(gpuCrashMarkerPath)
|
||
log.info('GPU crash marker removed during reset')
|
||
}
|
||
} catch (e) {
|
||
log.warn('Failed to remove GPU crash marker during reset:', e)
|
||
}
|
||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||
await resetApp()
|
||
CONFIG = await getConfig() // reload from defaults since config.json was deleted
|
||
new Notification({ title: 'Open WebUI', body: 'Application has been reset.' }).show()
|
||
} catch (error) {
|
||
log.error('Failed to reset:', error)
|
||
new Notification({ title: 'Open WebUI', body: `Reset failed: ${error.message}` }).show()
|
||
}
|
||
}
|
||
|
||
// ─── Helpers ────────────────────────────────────────────
|
||
|
||
const sendToRenderer = (type: string, data?: any) => {
|
||
mainWindow?.webContents.send('main:data', { type, data })
|
||
}
|
||
|
||
// ─── App Lifecycle ──────────────────────────────────────
|
||
|
||
const gotTheLock = app.requestSingleInstanceLock()
|
||
if (!gotTheLock) {
|
||
app.quit()
|
||
} else {
|
||
app.on('second-instance', () => {
|
||
if (mainWindow) {
|
||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||
mainWindow.show()
|
||
mainWindow.focus()
|
||
}
|
||
})
|
||
|
||
app.setAboutPanelOptions({
|
||
applicationName: 'Open WebUI',
|
||
iconPath: icon,
|
||
applicationVersion: app.getVersion(),
|
||
version: app.getVersion(),
|
||
website: 'https://openwebui.com',
|
||
copyright: `© ${new Date().getFullYear()} Open WebUI`
|
||
})
|
||
|
||
app.whenReady().then(async () => {
|
||
CONFIG = await getConfig()
|
||
loadSpotlightPosition()
|
||
log.info('Config:', CONFIG)
|
||
|
||
app.name = 'Open WebUI'
|
||
if (process.platform === 'darwin' && app.dock) {
|
||
app.dock.setIcon(icon)
|
||
}
|
||
electronApp.setAppUserModelId('com.openwebui.desktop')
|
||
|
||
// ─── GPU Process Crash Recovery ──────────────────
|
||
// If the GPU process exits fatally (e.g. sandbox init failure on
|
||
// certain NVIDIA/Intel drivers), write a marker and relaunch with
|
||
// --disable-gpu-sandbox so the user doesn't have to manually edit
|
||
// shortcut targets (see issue #110).
|
||
app.on('child-process-gone', (_event, details) => {
|
||
if (details.type === 'GPU') {
|
||
log.error(
|
||
`GPU process gone: reason=${details.reason}, exitCode=${details.exitCode}`
|
||
)
|
||
|
||
// Only auto-recover from fatal crashes, not normal/clean exits
|
||
if (
|
||
details.reason === 'crashed' ||
|
||
details.reason === 'launch-failed' ||
|
||
details.reason === 'abnormal-exit'
|
||
) {
|
||
if (!gpuSandboxDisabled) {
|
||
log.info('Writing GPU crash marker and relaunching with --disable-gpu-sandbox')
|
||
try {
|
||
writeFileSync(gpuCrashMarkerPath, new Date().toISOString(), 'utf-8')
|
||
} catch (e) {
|
||
log.warn('Failed to write GPU crash marker:', e)
|
||
}
|
||
app.relaunch({ args: [...process.argv.slice(1), '--disable-gpu-sandbox'] })
|
||
app.exit(0)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
// If we previously set the GPU sandbox marker and this session
|
||
// started successfully, log it so it's visible in diagnostics.
|
||
if (gpuSandboxDisabled) {
|
||
log.info('Running with GPU sandbox disabled (marker file present)')
|
||
}
|
||
|
||
// ─── Self-Signed / Untrusted Certificate Support ─
|
||
// Allow connections to Open WebUI instances that use self-signed or
|
||
// otherwise untrusted SSL certificates (issue #108). The user
|
||
// explicitly configures the server URL, so trusting all certs is
|
||
// acceptable — this matches the behaviour of VS Code, Postman, and
|
||
// other Electron apps used in enterprise/self-hosted environments.
|
||
app.on('certificate-error', (event, _webContents, url, error, certificate, callback) => {
|
||
log.warn(
|
||
`Certificate error: ${error} for ${url} ` +
|
||
`(subject: ${certificate.subjectName}, issuer: ${certificate.issuerName})`
|
||
)
|
||
event.preventDefault()
|
||
callback(true)
|
||
})
|
||
|
||
// Trust all certs on the default session (used by net.fetch() in
|
||
// validateRemoteUrl / checkUrlAndOpen).
|
||
session.defaultSession.setCertificateVerifyProc((_request, callback) => {
|
||
callback(0) // 0 = verified/trusted
|
||
})
|
||
|
||
// Webviews use partitioned sessions (persist:connection-*). Each
|
||
// new partition's session also needs to trust all certs.
|
||
app.on('session-created', (newSession) => {
|
||
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']
|
||
callback(allowed.includes(permission))
|
||
})
|
||
})
|
||
|
||
app.on('browser-window-created', (_, window) => {
|
||
optimizer.watchWindowShortcuts(window)
|
||
})
|
||
|
||
// ─── IPC Handlers ─────────────────────────────────
|
||
|
||
ipcMain.handle('get:version', () => app.getVersion())
|
||
|
||
ipcMain.handle('app:info', () => ({
|
||
version: app.getVersion(),
|
||
platform: process.platform,
|
||
arch: process.arch,
|
||
username: require('os').userInfo().username,
|
||
gpuSandboxDisabled
|
||
}))
|
||
|
||
ipcMain.handle('app:contentPreloadPath', () => {
|
||
return `file://${join(__dirname, '../preload/content-preload.js')}`
|
||
})
|
||
|
||
ipcMain.handle('app:defaultDataPath', () => {
|
||
return join(getUserDataPath(), 'data')
|
||
})
|
||
|
||
ipcMain.handle('app:installDir', () => {
|
||
return getInstallDir()
|
||
})
|
||
|
||
ipcMain.handle('system:diskSpace', async () => {
|
||
try {
|
||
const stats = await statfs(getUserDataPath())
|
||
return { free: stats.bavail * stats.bsize }
|
||
} catch (error) {
|
||
log.error('Failed to check disk space:', error)
|
||
return { free: -1 }
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('get:config', () => getConfig())
|
||
ipcMain.handle('set:config', async (_event, config) => {
|
||
await setConfig(config)
|
||
CONFIG = await getConfig()
|
||
updateTray()
|
||
voiceInputRecording = false
|
||
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut, CONFIG.callShortcut)
|
||
})
|
||
|
||
// Python/uv
|
||
ipcMain.handle('install:python', async () => {
|
||
try {
|
||
sendToRenderer('status:install', 'Downloading Python…')
|
||
const res = await installPython(undefined, (status: string) => {
|
||
sendToRenderer('status:install', status)
|
||
})
|
||
sendToRenderer('status:python', res)
|
||
return res
|
||
} catch (error) {
|
||
sendToRenderer('status:python', false)
|
||
sendToRenderer('error', { message: error?.message ?? 'Python installation failed. Please check your internet connection and try again.' })
|
||
return false
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('status:python', async () => {
|
||
return (await isPythonInstalled()) && (await isUvInstalled())
|
||
})
|
||
|
||
// Package
|
||
ipcMain.handle('install:package', async () => {
|
||
try {
|
||
CONFIG = await getConfig()
|
||
const owuiVersion = CONFIG?.localServer?.version || undefined
|
||
const otVersion = CONFIG?.openTerminal?.version || undefined
|
||
|
||
sendToRenderer('status:install', 'Installing Open WebUI…')
|
||
await installPackage('open-webui', owuiVersion, (status: string) => {
|
||
sendToRenderer('status:install', status)
|
||
})
|
||
sendToRenderer('status:install', 'Installing Open Terminal…')
|
||
await installPackage('open-terminal', otVersion, (status: string) => {
|
||
sendToRenderer('status:install', status)
|
||
}).catch((e) =>
|
||
log.warn('open-terminal install failed (non-fatal):', e)
|
||
)
|
||
sendToRenderer('status:package', true)
|
||
return true
|
||
} catch (error) {
|
||
sendToRenderer('status:package', false)
|
||
sendToRenderer('error', { message: error?.message ?? 'Package installation failed. Please check your internet connection and try again.' })
|
||
return false
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('status:package', async () => isPackageInstalled('open-webui'))
|
||
|
||
// Server
|
||
ipcMain.handle('server:start', () => startServerHandler())
|
||
ipcMain.handle('server:stop', () => stopServerHandler())
|
||
ipcMain.handle('server:restart', async () => {
|
||
await stopServerHandler()
|
||
return startServerHandler()
|
||
})
|
||
ipcMain.handle('server:logs', () => (SERVER_PID ? getServerLog(SERVER_PID) : []))
|
||
ipcMain.handle('server:logs:clear', () => clearAllServerLogs())
|
||
|
||
// PTY MessagePort channel
|
||
ipcMain.handle('pty:list', () => getServerPIDs())
|
||
ipcMain.handle('pty:connect', (_event, pid?: number) => connectPtyPort(pid))
|
||
ipcMain.handle('server:info', () => ({
|
||
url: SERVER_URL,
|
||
status: SERVER_STATUS,
|
||
pid: SERVER_PID,
|
||
reachable: SERVER_REACHABLE
|
||
}))
|
||
|
||
// Connections
|
||
ipcMain.handle('connections:list', async () => {
|
||
const config = await getConfig()
|
||
return config.connections
|
||
})
|
||
|
||
ipcMain.handle('connections:add', async (_event, connection: Connection) => {
|
||
const config = await getConfig()
|
||
config.connections.push(connection)
|
||
if (!config.defaultConnectionId) {
|
||
config.defaultConnectionId = connection.id
|
||
}
|
||
await setConfig(config)
|
||
CONFIG = config
|
||
updateTray()
|
||
return config.connections
|
||
})
|
||
|
||
ipcMain.handle('connections:remove', async (_event, id: string) => {
|
||
const config = await getConfig()
|
||
config.connections = config.connections.filter((c) => c.id !== id)
|
||
if (config.defaultConnectionId === id) {
|
||
config.defaultConnectionId = config.connections[0]?.id || null
|
||
}
|
||
await setConfig(config)
|
||
CONFIG = config
|
||
updateTray()
|
||
return config.connections
|
||
})
|
||
|
||
ipcMain.handle('connections:update', async (_event, id: string, updates: Partial<Connection>) => {
|
||
const config = await getConfig()
|
||
const idx = config.connections.findIndex((c) => c.id === id)
|
||
if (idx !== -1) {
|
||
config.connections[idx] = { ...config.connections[idx], ...updates }
|
||
await setConfig(config)
|
||
CONFIG = config
|
||
updateTray()
|
||
}
|
||
return config.connections
|
||
})
|
||
|
||
ipcMain.handle('connections:setDefault', async (_event, id: string) => {
|
||
const config = await getConfig()
|
||
config.defaultConnectionId = id
|
||
await setConfig(config)
|
||
CONFIG = config
|
||
updateTray()
|
||
})
|
||
|
||
ipcMain.handle('connections:connect', async (_event, id: string) => {
|
||
const config = await getConfig()
|
||
const conn = config.connections.find((c) => c.id === id)
|
||
if (conn) {
|
||
return await connectTo(conn)
|
||
}
|
||
return null
|
||
})
|
||
|
||
ipcMain.handle('validate:url', async (_event, url: string) => {
|
||
return await validateRemoteUrl(url)
|
||
})
|
||
|
||
// Updater
|
||
ipcMain.handle('updater:check', () => checkForUpdates())
|
||
ipcMain.handle('updater:download', () => downloadUpdate())
|
||
ipcMain.handle('updater:install', () => installUpdate())
|
||
|
||
// Changelog
|
||
ipcMain.handle('app:changelog', async () => {
|
||
try {
|
||
const changelogPath = app.isPackaged
|
||
? join(process.resourcesPath, 'CHANGELOG.md')
|
||
: join(app.getAppPath(), 'CHANGELOG.md')
|
||
return await readFile(changelogPath, 'utf-8')
|
||
} catch {
|
||
return null
|
||
}
|
||
})
|
||
|
||
// Auth token relay from webview
|
||
ipcMain.handle('app:setAuthToken', (_event, token: string) => {
|
||
AUTH_TOKEN = token || null
|
||
log.info('Auth token updated from webview')
|
||
})
|
||
|
||
// Misc
|
||
ipcMain.handle('app:reset', () => resetAppHandler())
|
||
|
||
// Spotlight
|
||
ipcMain.handle('spotlight:submit', async (_event, query: string, images?: string[]) => {
|
||
const config = await getConfig()
|
||
if (!config.defaultConnectionId || config.connections.length === 0) {
|
||
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')
|
||
}
|
||
|
||
// Build files payload from screenshot images
|
||
const files = images?.map((dataUrl, i) => ({
|
||
name: `screenshot-${Date.now()}-${i + 1}.png`,
|
||
mimeType: 'image/png',
|
||
dataUrl
|
||
}))
|
||
|
||
sendToRenderer('query', { query, connectionId: conn.id, url, files })
|
||
|
||
// Hide spotlight first (blur handler will restore main window)
|
||
spotlightWindow?.hide()
|
||
// Ensure main window is focused to receive the query
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
mainWindow.show()
|
||
mainWindow.focus()
|
||
}
|
||
})
|
||
ipcMain.handle('spotlight:close', () => {
|
||
spotlightWindow?.hide()
|
||
// blur handler restores main window
|
||
})
|
||
|
||
// Persist bar offset within the fullscreen spotlight window
|
||
ipcMain.handle('spotlight:savePosition', async (_event, offset: { x: number; y: number }) => {
|
||
spotlightBarOffset = offset
|
||
setConfig({ spotlightPosition: offset }).catch((err) =>
|
||
log.warn('Failed to persist spotlight bar position:', err)
|
||
)
|
||
})
|
||
|
||
// Capture a region of the screen (called from Spotlight renderer after drag)
|
||
ipcMain.handle(
|
||
'spotlight:captureRegion',
|
||
async (_event, rect: { x: number; y: number; width: number; height: number }) => {
|
||
try {
|
||
// ── Permission check (macOS) ──
|
||
if (process.platform === 'darwin') {
|
||
const status = systemPreferences.getMediaAccessStatus('screen')
|
||
if (status !== 'granted') {
|
||
log.warn(`spotlight:captureRegion — screen recording permission: ${status}`)
|
||
new Notification({
|
||
title: 'Screen Recording Permission Required',
|
||
body: 'Open WebUI needs Screen Recording access to capture screenshots. Please enable it in System Settings → Privacy & Security → Screen Recording, then restart the app.'
|
||
}).show()
|
||
// Open the correct System Preferences pane
|
||
shell.openExternal(
|
||
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'
|
||
).catch(() => {})
|
||
return 'no-permission'
|
||
}
|
||
}
|
||
|
||
// Make spotlight invisible (but don't hide it — hiding triggers macOS
|
||
// window activation which brings up the main window behind it)
|
||
spotlightWindow?.setOpacity(0)
|
||
// Small delay to let the window fully disappear before capture
|
||
await new Promise((r) => setTimeout(r, 150))
|
||
|
||
const { screen } = require('electron')
|
||
const cursorPoint = screen.getCursorScreenPoint()
|
||
const display = screen.getDisplayNearestPoint(cursorPoint)
|
||
const scaleFactor = display.scaleFactor || 1
|
||
|
||
const sources = await desktopCapturer.getSources({
|
||
types: ['screen'],
|
||
thumbnailSize: {
|
||
width: Math.round(display.bounds.width * scaleFactor),
|
||
height: Math.round(display.bounds.height * scaleFactor)
|
||
}
|
||
})
|
||
|
||
// Find the source matching this display
|
||
const source =
|
||
sources.find((s) => s.display_id === String(display.id)) || sources[0]
|
||
if (!source) {
|
||
spotlightWindow?.setOpacity(1)
|
||
return null
|
||
}
|
||
|
||
const fullImage = source.thumbnail
|
||
// Validate thumbnail is not empty (can happen without permission)
|
||
if (fullImage.isEmpty()) {
|
||
log.warn('spotlight:captureRegion — captured thumbnail is empty (likely no permission)')
|
||
spotlightWindow?.setOpacity(1)
|
||
return null
|
||
}
|
||
|
||
const cropped = fullImage.crop({
|
||
x: Math.round(rect.x * scaleFactor),
|
||
y: Math.round(rect.y * scaleFactor),
|
||
width: Math.round(rect.width * scaleFactor),
|
||
height: Math.round(rect.height * scaleFactor)
|
||
})
|
||
|
||
// Restore spotlight visibility
|
||
if (spotlightWindow && !spotlightWindow.isDestroyed()) {
|
||
spotlightWindow.setOpacity(1)
|
||
}
|
||
|
||
return cropped.toDataURL()
|
||
} catch (err) {
|
||
log.error('spotlight:captureRegion failed:', err)
|
||
// Restore spotlight on error
|
||
spotlightWindow?.setOpacity(1)
|
||
return null
|
||
}
|
||
}
|
||
)
|
||
|
||
// ── Voice Input ─────────────────────────────────────
|
||
|
||
// Check microphone permission (macOS)
|
||
ipcMain.handle('voiceInput:micPermission', async () => {
|
||
if (process.platform === 'darwin') {
|
||
const status = systemPreferences.getMediaAccessStatus('microphone')
|
||
if (status !== 'granted') {
|
||
const granted = await systemPreferences.askForMediaAccess('microphone')
|
||
return granted ? 'granted' : 'denied'
|
||
}
|
||
return 'granted'
|
||
}
|
||
return 'granted' // Windows/Linux don't need explicit permission
|
||
})
|
||
|
||
// Transcribe audio via the connected server's STT endpoint
|
||
ipcMain.handle('voiceInput:transcribe', async (_event, audioBuffer: ArrayBuffer, rendererToken?: string) => {
|
||
try {
|
||
const config = await getConfig()
|
||
if (!config.defaultConnectionId || config.connections.length === 0) {
|
||
throw new Error('No connection configured. Set up a connection in Settings first.')
|
||
}
|
||
const conn = config.connections.find((c) => c.id === config.defaultConnectionId)
|
||
if (!conn) throw new Error('Default connection not found. Check your connection settings.')
|
||
|
||
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')
|
||
}
|
||
|
||
// Use stored auth token (relayed from webview), fall back to renderer-provided or contentWindow
|
||
let token = AUTH_TOKEN || rendererToken || ''
|
||
if (!token) {
|
||
// Scan all webContents to find the Open WebUI webview and read its token
|
||
try {
|
||
const { webContents: wc } = require('electron')
|
||
const allContents = wc.getAllWebContents()
|
||
for (const contents of allContents) {
|
||
try {
|
||
if (contents.getType() === 'webview' && !contents.isDestroyed()) {
|
||
const t = await contents.executeJavaScript(
|
||
`localStorage.getItem('token') || ''`
|
||
)
|
||
if (t) { token = t; break }
|
||
}
|
||
} catch {
|
||
// Skip inaccessible webContents
|
||
}
|
||
}
|
||
} catch {
|
||
log.warn('voiceInput:transcribe — could not extract token from webviews')
|
||
}
|
||
}
|
||
|
||
if (!token) {
|
||
throw new Error('Not authenticated. Open a connection and sign in before using voice input.')
|
||
}
|
||
|
||
// Build multipart form data manually using Node.js
|
||
const boundary = '----VoiceInput' + Date.now()
|
||
const buffer = Buffer.from(audioBuffer)
|
||
const filename = `recording-${Date.now()}.wav`
|
||
|
||
const header = [
|
||
`--${boundary}`,
|
||
`Content-Disposition: form-data; name="file"; filename="${filename}"`,
|
||
`Content-Type: audio/wav`,
|
||
'',
|
||
''
|
||
].join('\r\n')
|
||
|
||
const footer = `\r\n--${boundary}--\r\n`
|
||
const headerBuf = Buffer.from(header, 'utf-8')
|
||
const footerBuf = Buffer.from(footer, 'utf-8')
|
||
const body = Buffer.concat([headerBuf, buffer, footerBuf])
|
||
|
||
const response = await fetch(`${url}/api/v1/audio/transcriptions`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': `multipart/form-data; boundary=${boundary}`
|
||
},
|
||
body
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const text = await response.text().catch(() => '')
|
||
throw new Error(`Transcription failed (HTTP ${response.status}). ${text || 'Check that your server has Speech-to-Text configured.'}`)
|
||
}
|
||
|
||
const result = await response.json()
|
||
return result
|
||
} catch (error: any) {
|
||
log.error('voiceInput:transcribe failed:', error)
|
||
new Notification({
|
||
title: 'Voice Input Failed',
|
||
body: error?.message || 'Transcription failed. Check logs for details.'
|
||
}).show()
|
||
throw error
|
||
}
|
||
})
|
||
|
||
// Voice input completed — deliver text to chat
|
||
ipcMain.handle('voiceInput:done', async (_event, text: string) => {
|
||
voiceInputRecording = false
|
||
playChime(false)
|
||
if (voiceInputWindow && !voiceInputWindow.isDestroyed()) {
|
||
voiceInputWindow.hide()
|
||
}
|
||
|
||
if (!text?.trim()) return
|
||
|
||
// Deliver text through the same path as Spotlight
|
||
const config = await getConfig()
|
||
if (!config.defaultConnectionId || config.connections.length === 0) {
|
||
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')
|
||
}
|
||
|
||
sendToRenderer('query', { query: text.trim(), connectionId: conn.id, url })
|
||
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
mainWindow.show()
|
||
mainWindow.focus()
|
||
}
|
||
})
|
||
|
||
// Voice input window requests close
|
||
ipcMain.handle('voiceInput:close', () => {
|
||
voiceInputRecording = false
|
||
playChime(false)
|
||
if (voiceInputWindow && !voiceInputWindow.isDestroyed()) {
|
||
voiceInputWindow.hide()
|
||
}
|
||
})
|
||
|
||
// Voice input error
|
||
ipcMain.handle('voiceInput:error', (_event, message: string) => {
|
||
log.warn('Voice input error:', message)
|
||
voiceInputRecording = false
|
||
new Notification({
|
||
title: 'Voice Input Error',
|
||
body: message || 'An unknown error occurred with voice input.'
|
||
}).show()
|
||
})
|
||
|
||
// Open Terminal
|
||
ipcMain.handle('open-terminal:start', async () => {
|
||
try {
|
||
sendToRenderer('status:open-terminal', 'starting')
|
||
const result = await startOpenTerminal(CONFIG?.openTerminal?.port ?? null)
|
||
sendToRenderer('status:open-terminal', 'started')
|
||
sendToRenderer('open-terminal:ready', result)
|
||
// Notify webview to register terminal server at system level
|
||
sendToRenderer('connections:terminal', {
|
||
action: 'add',
|
||
url: result.url,
|
||
key: result.apiKey
|
||
})
|
||
// Save enabled state
|
||
await setConfig({ openTerminal: { ...CONFIG?.openTerminal, enabled: true } })
|
||
CONFIG = await getConfig()
|
||
return result
|
||
} catch (error) {
|
||
log.error('Failed to start Open Terminal:', error)
|
||
sendToRenderer('status:open-terminal', 'failed')
|
||
sendToRenderer('error', { message: `Open Terminal failed: ${error?.message}` })
|
||
return null
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('open-terminal:stop', async () => {
|
||
try {
|
||
const info = getOpenTerminalInfo()
|
||
await stopOpenTerminal()
|
||
sendToRenderer('status:open-terminal', 'stopped')
|
||
// Notify webview to unregister terminal server
|
||
if (info.url) {
|
||
sendToRenderer('connections:terminal', {
|
||
action: 'remove',
|
||
url: info.url
|
||
})
|
||
}
|
||
await setConfig({ openTerminal: { ...CONFIG?.openTerminal, enabled: false } })
|
||
CONFIG = await getConfig()
|
||
return true
|
||
} catch (error) {
|
||
log.error('Failed to stop Open Terminal:', error)
|
||
return false
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('open-terminal:info', () => getOpenTerminalInfo())
|
||
ipcMain.handle('open-terminal:status', () => isPackageInstalled('open-terminal'))
|
||
ipcMain.handle('open-terminal:pty:connect', () => connectOpenTerminalPtyPort())
|
||
|
||
// llama.cpp
|
||
ipcMain.handle('llamacpp:setup', async () => {
|
||
try {
|
||
sendToRenderer('status:llamacpp', 'setting-up')
|
||
const binary = await setupLlamaCpp((status) => {
|
||
sendToRenderer('status:llamacpp-setup', status)
|
||
})
|
||
sendToRenderer('status:llamacpp', 'ready')
|
||
return binary
|
||
} catch (error) {
|
||
log.error('Failed to setup llamacpp:', error)
|
||
sendToRenderer('status:llamacpp', 'failed')
|
||
sendToRenderer('error', { message: `llamacpp setup failed: ${error?.message}` })
|
||
return null
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('llamacpp:start', async () => {
|
||
try {
|
||
sendToRenderer('status:llamacpp', 'starting')
|
||
const result = await startLlamaCpp((status) => {
|
||
sendToRenderer('status:llamacpp-setup', status)
|
||
})
|
||
sendToRenderer('status:llamacpp', 'started')
|
||
sendToRenderer('llamacpp:ready', result)
|
||
// Notify webview to register llama-server as OpenAI endpoint
|
||
if (result.url) {
|
||
sendToRenderer('connections:openai', {
|
||
action: 'add',
|
||
url: `${result.url}/v1`
|
||
})
|
||
// Refresh model list after backend registers the endpoint
|
||
setTimeout(() => sendToRenderer('models:refresh'), 1000)
|
||
}
|
||
await setConfig({ llamaCpp: { ...CONFIG?.llamaCpp, enabled: true } })
|
||
CONFIG = await getConfig()
|
||
return result
|
||
} catch (error) {
|
||
log.error('Failed to start llamacpp:', error)
|
||
sendToRenderer('status:llamacpp', 'failed')
|
||
sendToRenderer('error', { message: `llamacpp failed: ${error?.message}` })
|
||
return null
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('llamacpp:stop', async () => {
|
||
try {
|
||
const info = getLlamaCppInfo()
|
||
await stopLlamaCpp()
|
||
sendToRenderer('status:llamacpp', 'stopped')
|
||
// Notify webview to unregister llama-server
|
||
if (info.url) {
|
||
sendToRenderer('connections:openai', {
|
||
action: 'remove',
|
||
url: `${info.url}/v1`
|
||
})
|
||
// Refresh model list after removing endpoint
|
||
setTimeout(() => sendToRenderer('models:refresh'), 500)
|
||
}
|
||
await setConfig({ llamaCpp: { ...CONFIG?.llamaCpp, enabled: false } })
|
||
CONFIG = await getConfig()
|
||
return true
|
||
} catch (error) {
|
||
log.error('Failed to stop llamacpp:', error)
|
||
return false
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('llamacpp:info', () => getLlamaCppInfo())
|
||
ipcMain.handle('llamacpp:logs', () => getLlamaCppLog())
|
||
ipcMain.handle('llamacpp:pty:connect', () => connectLlamaCppPtyPort())
|
||
|
||
ipcMain.handle('llamacpp:uninstall', async () => {
|
||
try {
|
||
const info = getLlamaCppInfo()
|
||
await uninstallLlamaCpp()
|
||
sendToRenderer('status:llamacpp', null)
|
||
// Unregister OpenAI endpoint if it was running
|
||
if (info.url) {
|
||
sendToRenderer('connections:openai', {
|
||
action: 'remove',
|
||
url: `${info.url}/v1`
|
||
})
|
||
setTimeout(() => sendToRenderer('models:refresh'), 500)
|
||
}
|
||
await setConfig({ llamaCpp: { ...CONFIG?.llamaCpp, enabled: false } })
|
||
CONFIG = await getConfig()
|
||
return true
|
||
} catch (error) {
|
||
log.error('Failed to uninstall llamacpp:', error)
|
||
return false
|
||
}
|
||
})
|
||
|
||
// Hugging Face models
|
||
ipcMain.handle('huggingface:models:list', () => listModels())
|
||
ipcMain.handle('huggingface:models:dir', () => getModelsDir())
|
||
ipcMain.handle('huggingface:models:delete', (_event, repo: string, filename: string) => {
|
||
return deleteModel(repo, filename)
|
||
})
|
||
ipcMain.handle('huggingface:models:cancel', (_event, repo?: string, filename?: string) => {
|
||
cancelDownload(repo, filename)
|
||
return true
|
||
})
|
||
ipcMain.handle('huggingface:search', async (_event, query: string, token?: string) => {
|
||
return searchModels(query, token)
|
||
})
|
||
ipcMain.handle('huggingface:repo:files', async (_event, repo: string, token?: string) => {
|
||
return getRepoFiles(repo, token)
|
||
})
|
||
ipcMain.handle('huggingface:models:download', async (_event, repo: string, filename: string, token?: string, expectedSize?: number) => {
|
||
try {
|
||
sendToRenderer('status:huggingface-download', { repo, filename, status: 'downloading', percent: 0 })
|
||
const filepath = await downloadModel(repo, filename, (progress) => {
|
||
sendToRenderer('status:huggingface-download', {
|
||
repo, filename,
|
||
status: 'downloading',
|
||
percent: progress.percent,
|
||
downloadedBytes: progress.downloadedBytes,
|
||
totalBytes: progress.totalBytes
|
||
})
|
||
}, token, expectedSize)
|
||
sendToRenderer('status:huggingface-download', { repo, filename, status: 'done', filepath })
|
||
return filepath
|
||
} catch (error) {
|
||
log.error('Failed to download model:', error)
|
||
sendToRenderer('status:huggingface-download', { repo, filename, status: 'failed', error: error?.message })
|
||
sendToRenderer('error', { message: `Model download failed: ${error?.message}` })
|
||
return null
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('package:version', (_event, packageName: string) => getPackageVersion(packageName))
|
||
ipcMain.handle('package:uninstall', async (_event, packageName: string) => {
|
||
return uninstallPackage(packageName)
|
||
})
|
||
|
||
ipcMain.handle('dialog:selectFolder', async () => {
|
||
const result = await dialog.showOpenDialog(mainWindow!, {
|
||
properties: ['openDirectory']
|
||
})
|
||
return result.canceled ? null : result.filePaths[0] ?? null
|
||
})
|
||
|
||
ipcMain.handle('app:launchAtLogin:get', () => {
|
||
return app.getLoginItemSettings().openAtLogin
|
||
})
|
||
ipcMain.handle('app:launchAtLogin:set', (_event, enabled: boolean) => {
|
||
app.setLoginItemSettings({ openAtLogin: enabled })
|
||
})
|
||
|
||
ipcMain.handle('open:browser', async (_event, { url }) => {
|
||
if (!url) throw new Error('No URL provided')
|
||
let normalizedUrl = url
|
||
if (normalizedUrl.startsWith('http://0.0.0.0')) {
|
||
normalizedUrl = normalizedUrl.replace('http://0.0.0.0', 'http://localhost')
|
||
}
|
||
await openUrl(normalizedUrl)
|
||
})
|
||
|
||
ipcMain.handle('open:path', async (_event, folderPath: string) => {
|
||
if (!folderPath) throw new Error('No path provided')
|
||
await shell.openPath(folderPath)
|
||
})
|
||
|
||
ipcMain.handle('notification', async (_event, { title, body }) => {
|
||
new Notification({ title, body }).show()
|
||
})
|
||
|
||
ipcMain.handle('llamacpp:check-update', async () => {
|
||
try {
|
||
return await checkLlamaCppUpdate()
|
||
} catch (error) {
|
||
log.error('Failed to check llamacpp update:', error)
|
||
throw error
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('llamacpp:update', async () => {
|
||
try {
|
||
sendToRenderer('status:llamacpp', 'setting-up')
|
||
const result = await updateLlamaCpp((status) => {
|
||
sendToRenderer('status:llamacpp-setup', status)
|
||
})
|
||
sendToRenderer('status:llamacpp', 'ready')
|
||
return result
|
||
} catch (error) {
|
||
log.error('Failed to update llamacpp:', error)
|
||
sendToRenderer('status:llamacpp', 'failed')
|
||
sendToRenderer('error', { message: `llamacpp update failed: ${error?.message}` })
|
||
throw error
|
||
}
|
||
})
|
||
|
||
// ─── Startup ──────────────────────────────────────
|
||
|
||
// Create tray
|
||
const trayIcon = nativeImage.createFromPath(icon)
|
||
tray = new Tray(trayIcon.resize({ width: 16, height: 16 }))
|
||
tray.setToolTip('Open WebUI')
|
||
updateTray()
|
||
|
||
|
||
|
||
// Global shortcut
|
||
registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut, CONFIG.callShortcut)
|
||
|
||
// Enable screen capture
|
||
session.defaultSession.setDisplayMediaRequestHandler(
|
||
(request, callback) => {
|
||
desktopCapturer.getSources({ types: ['screen'] }).then((sources) => {
|
||
callback({ video: sources[0], audio: 'loopback' })
|
||
})
|
||
},
|
||
{ useSystemPicker: true }
|
||
)
|
||
|
||
// Validate stale PIDs from previous crash
|
||
validateOpenTerminalProcess()
|
||
validateLlamaCppProcess()
|
||
|
||
// Auto-start Open Terminal if previously enabled
|
||
if (CONFIG?.openTerminal?.enabled) {
|
||
try {
|
||
sendToRenderer('status:open-terminal', 'starting')
|
||
const result = await startOpenTerminal(CONFIG?.openTerminal?.port ?? null)
|
||
sendToRenderer('status:open-terminal', 'started')
|
||
sendToRenderer('open-terminal:ready', result)
|
||
} catch (error) {
|
||
log.error('Auto-start Open Terminal failed:', error)
|
||
sendToRenderer('status:open-terminal', 'failed')
|
||
}
|
||
}
|
||
|
||
// Auto-start llama.cpp if previously enabled
|
||
if (CONFIG?.llamaCpp?.enabled) {
|
||
try {
|
||
sendToRenderer('status:llamacpp', 'starting')
|
||
const result = await startLlamaCpp((status) => {
|
||
sendToRenderer('status:llamacpp-setup', status)
|
||
})
|
||
sendToRenderer('status:llamacpp', 'started')
|
||
sendToRenderer('llamacpp:ready', result)
|
||
} catch (error) {
|
||
log.error('Auto-start llama.cpp failed:', error)
|
||
sendToRenderer('status:llamacpp', 'failed')
|
||
}
|
||
}
|
||
|
||
// Check if already configured, auto-connect to default
|
||
if (CONFIG.defaultConnectionId && CONFIG.connections.length > 0) {
|
||
const defaultConn = CONFIG.connections.find(
|
||
(c) => c.id === CONFIG.defaultConnectionId
|
||
)
|
||
if (defaultConn) {
|
||
createMainWindow()
|
||
const result = await connectTo(defaultConn)
|
||
if (result) sendToRenderer('connection:open', result)
|
||
} else {
|
||
createMainWindow()
|
||
}
|
||
} else {
|
||
createMainWindow()
|
||
}
|
||
|
||
// Initialize auto-updater
|
||
if (mainWindow) {
|
||
initUpdater(mainWindow)
|
||
}
|
||
|
||
app.on('activate', () => {
|
||
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
|
||
else {
|
||
mainWindow?.show()
|
||
mainWindow?.focus()
|
||
}
|
||
})
|
||
})
|
||
|
||
app.on('window-all-closed', () => {
|
||
if (process.platform !== 'darwin') {
|
||
app.quit()
|
||
}
|
||
})
|
||
|
||
app.on('before-quit', async () => {
|
||
isQuiting = true
|
||
await stopLlamaCpp()
|
||
await stopOpenTerminal()
|
||
await stopServerHandler()
|
||
globalShortcut.unregisterAll()
|
||
mainWindow = null
|
||
contentWindow = null
|
||
if (spotlightWindow && !spotlightWindow.isDestroyed()) {
|
||
spotlightWindow.destroy()
|
||
}
|
||
spotlightWindow = null
|
||
if (voiceInputWindow && !voiceInputWindow.isDestroyed()) {
|
||
voiceInputWindow.destroy()
|
||
}
|
||
voiceInputWindow = null
|
||
tray?.destroy()
|
||
tray = null
|
||
})
|
||
}
|