This commit is contained in:
Timothy Jaeryang Baek
2026-03-17 23:59:33 -05:00
parent f1eb615e9a
commit 0e61600416
31 changed files with 12540 additions and 96 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 21 KiB

+15 -7
View File
@@ -1,5 +1,5 @@
appId: com.electron.app
productName: desktop
appId: com.openwebui.desktop
productName: Open WebUI
directories:
buildResources: build
files:
@@ -13,7 +13,7 @@ files:
asarUnpack:
- resources/**
win:
executableName: desktop
executableName: open-webui
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
@@ -26,15 +26,25 @@ mac:
- 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: false
notarize: true
dmg:
background: build/dmg-background.png
artifactName: ${name}-${version}.${ext}
title: ${productName}
contents:
- x: 225
y: 250
type: file
- x: 400
y: 240
type: link
path: /Applications
linux:
target:
- AppImage
- snap
- deb
maintainer: electronjs.org
maintainer: openwebui.com
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
@@ -42,5 +52,3 @@ npmRebuild: false
publish:
provider: generic
url: https://example.com/auto-updates
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
+2 -1
View File
@@ -1,10 +1,11 @@
import { defineConfig } from 'electron-vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
main: {},
preload: {},
renderer: {
plugins: [svelte()]
plugins: [tailwindcss(), svelte()]
}
})
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.debugger</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.print</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.device.usb</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
</dict>
</plist>
+9363
View File
File diff suppressed because it is too large Load Diff
+15 -6
View File
@@ -1,11 +1,11 @@
{
"name": "desktop",
"version": "1.0.0",
"name": "open-webui-desktop",
"version": "0.1.0",
"license": "AGPL-3.0",
"description": "An Electron application with Svelte and TypeScript",
"description": "Open WebUI Desktop",
"main": "./out/main/index.js",
"author": "example.com",
"homepage": "https://electron-vite.org",
"author": "Open WebUI Inc. (Timothy Jaeryang Baek)",
"homepage": "https://openwebui.com",
"scripts": {
"format": "prettier --plugin prettier-plugin-svelte --write .",
"lint": "eslint --cache .",
@@ -24,7 +24,16 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"electron-updater": "^6.3.9"
"@tailwindcss/vite": "^4.2.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"electron-log": "^5.4.3",
"electron-updater": "^6.3.9",
"node-pty": "^1.1.0",
"svelte-sonner": "^1.1.0",
"tailwindcss": "^4.2.1",
"tar": "^7.5.11",
"tippy.js": "^6.3.7"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+624 -48
View File
@@ -1,74 +1,650 @@
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { join } from 'path'
// @ts-nocheck
import {
app,
shell,
session,
clipboard,
nativeImage,
desktopCapturer,
BrowserWindow,
globalShortcut,
MessageChannelMain,
Notification,
Menu,
ipcMain,
Tray
} from 'electron'
import path, { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import {
getLogFilePath,
checkUrlAndOpen,
clearAllServerLogs,
getConfig,
getServerLog,
getServerPIDs,
getServerPty,
installPackage,
installPython,
isPackageInstalled,
isPythonInstalled,
isUvInstalled,
openUrl,
resetApp,
setConfig,
startServer,
stopAllServers,
uninstallPython,
validateRemoteUrl,
type AppConfig,
type Connection
} from './utils'
import log from 'electron-log'
log.transports.file.resolvePathFn = () => getLogFilePath('main')
import icon from '../../resources/icon.png?asset'
function createWindow(): void {
// Create the browser window.
const mainWindow = new BrowserWindow({
// ─── State ──────────────────────────────────────────────
let mainWindow: BrowserWindow | null = null
let contentWindow: 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
// ─── Windows ────────────────────────────────────────────
function createMainWindow(show = true): void {
mainWindow = new BrowserWindow({
width: 900,
height: 670,
height: 560,
minWidth: 600,
minHeight: 400,
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
sandbox: false,
webviewTag: true
}
})
mainWindow.setIcon(icon)
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
if (!app.isPackaged) {
mainWindow.webContents.openDevTools()
}
if (show) {
mainWindow.on('ready-to-show', () => {
mainWindow?.show()
})
}
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
openUrl(details.url)
return { action: 'deny' }
})
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
mainWindow.on('close', (event) => {
if (!isQuiting) {
event.preventDefault()
mainWindow?.hide()
}
})
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.electron')
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
// IPC test
ipcMain.on('ping', () => console.log('pong'))
createWindow()
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
function createContentWindow(url: string, connectionId: string): BrowserWindow {
if (contentWindow && !contentWindow.isDestroyed()) {
contentWindow.loadURL(url)
contentWindow.show()
return contentWindow
}
})
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.
contentWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 600,
minHeight: 400,
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) {
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: () => connectTo(conn)
}))
const trayMenuTemplate = [
{
label: 'Show Controls',
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
}
// Normalize URL
if (url.startsWith('http://0.0.0.0')) {
url = url.replace('http://0.0.0.0', 'http://localhost')
}
sendToRenderer('connection:open', { url, connectionId: connection.id })
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> => {
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)
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) {
// No server running — send port with a status message
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])
}
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
await new Promise((resolve) => setTimeout(resolve, 1000))
await resetApp()
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()
log.info('Config:', CONFIG)
electronApp.setAppUserModelId('com.openwebui.desktop')
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
}))
ipcMain.handle('get:config', () => getConfig())
ipcMain.handle('set:config', async (_event, config) => {
await setConfig(config)
CONFIG = await getConfig()
updateTray()
})
// Python/uv
ipcMain.handle('install:python', async () => {
try {
const res = await installPython()
sendToRenderer('status:python', res)
return res
} catch (error) {
sendToRenderer('status:python', false)
sendToRenderer('error', { message: error?.message ?? 'Python install failed' })
return false
}
})
ipcMain.handle('status:python', async () => {
return (await isPythonInstalled()) && (await isUvInstalled())
})
// Package
ipcMain.handle('install:package', async () => {
try {
const res = await installPackage('open-webui')
sendToRenderer('status:package', res)
return res
} catch (error) {
sendToRenderer('status:package', false)
sendToRenderer('error', { message: error?.message ?? 'Package install failed' })
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', () => 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: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)
})
// Misc
ipcMain.handle('app:reset', () => resetAppHandler())
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('notification', async (_event, { title, body }) => {
new Notification({ title, body }).show()
})
// ─── Startup ──────────────────────────────────────
// Create tray
const trayIcon = nativeImage.createFromPath(icon)
tray = new Tray(trayIcon.resize({ width: 16, height: 16 }))
tray.setToolTip('Open WebUI')
updateTray()
// Set up menus
const defaultMenu = Menu.getApplicationMenu()
const menuTemplate = defaultMenu ? defaultMenu.items.map((item) => item) : []
menuTemplate.push({
label: 'Action',
submenu: [
{
label: 'Reset',
click: () => resetAppHandler()
}
]
})
Menu.setApplicationMenu(Menu.buildFromTemplate(menuTemplate))
// Global shortcut
globalShortcut.register('Alt+CommandOrControl+O', () => {
if (contentWindow && !contentWindow.isDestroyed()) {
contentWindow.show()
contentWindow.focus()
} else {
mainWindow?.show()
mainWindow?.focus()
}
})
// Enable screen capture
session.defaultSession.setDisplayMediaRequestHandler(
(request, callback) => {
desktopCapturer.getSources({ types: ['screen'] }).then((sources) => {
callback({ video: sources[0], audio: 'loopback' })
})
},
{ useSystemPicker: true }
)
// 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(false)
await connectTo(defaultConn)
} else {
createMainWindow()
}
} else {
createMainWindow()
}
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 stopServerHandler()
globalShortcut.unregisterAll()
mainWindow = null
contentWindow = null
tray?.destroy()
tray = null
})
}
+747
View File
@@ -0,0 +1,747 @@
// @ts-nocheck
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import net from 'net'
import crypto from 'crypto'
import * as tar from 'tar'
import { app, shell, Notification } from 'electron'
import { execFileSync, exec, spawn, execSync, execFile } from 'child_process'
import log from 'electron-log'
log.transports.file.resolvePathFn = () => getLogFilePath('main')
const serverLogger = log.create({ logId: 'server' })
serverLogger.transports.file.resolvePath = () => getLogFilePath('server')
// ─── Paths ──────────────────────────────────────────────
export const getLogFilePath = (name: string = 'main'): string => {
const logDir = path.join(getUserDataPath(), 'logs')
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true })
}
return path.join(logDir, `${name}.log`)
}
export const getAppPath = (): string => {
let appPath = app.getAppPath()
if (app.isPackaged) {
appPath = path.dirname(appPath)
}
return path.normalize(appPath)
}
export const getUserHomePath = (): string => {
return path.normalize(app.getPath('home'))
}
export const getUserDataPath = (): string => {
const userDataDir = app.getPath('userData')
if (!fs.existsSync(userDataDir)) {
try {
fs.mkdirSync(userDataDir, { recursive: true })
} catch (error) {
log.error(error)
}
}
return path.normalize(userDataDir)
}
export const getOpenWebUIDataPath = (): string => {
const openWebUIDataDir = path.join(getUserDataPath(), 'data')
if (!fs.existsSync(openWebUIDataDir)) {
try {
fs.mkdirSync(openWebUIDataDir, { recursive: true })
} catch (error) {
log.error(error)
}
}
return path.normalize(openWebUIDataDir)
}
export const openUrl = (url: string) => {
if (!url) {
throw new Error('No URL provided to open in browser.')
}
log.info('Opening URL in browser:', url)
if (url.startsWith('http://0.0.0.0')) {
url = url.replace('http://0.0.0.0', 'http://localhost')
}
shell.openExternal(url)
}
export const getSystemInfo = () => {
return {
platform: os.platform(),
architecture: os.arch()
}
}
export const getSecretKey = (keyPath?: string, key?: string): string => {
keyPath = keyPath || path.join(getOpenWebUIDataPath(), '.key')
if (fs.existsSync(keyPath)) {
return fs.readFileSync(keyPath, 'utf-8')
}
key = key || crypto.randomBytes(64).toString('hex')
fs.writeFileSync(keyPath, key)
return key
}
// ─── Port Utils ─────────────────────────────────────────
export const portInUse = async (port: number, host: string = '0.0.0.0'): Promise<boolean> => {
return new Promise((resolve) => {
const client = new net.Socket()
client
.setTimeout(1000)
.once('connect', () => {
client.destroy()
resolve(true)
})
.once('timeout', () => {
client.destroy()
resolve(false)
})
.once('error', () => {
resolve(false)
})
.connect(port, host)
})
}
// ─── Python Download & Install ──────────────────────────
const getPlatformString = () => {
const platformMap = {
darwin: 'apple-darwin',
win32: 'pc-windows-msvc',
linux: 'unknown-linux-gnu'
}
return platformMap[os.platform()] || 'unknown-linux-gnu'
}
const getArchString = () => {
const archMap = {
x64: 'x86_64',
arm64: 'aarch64',
ia32: 'i686'
}
return archMap[os.arch()] || 'x86_64'
}
const generateDownloadUrl = () => {
const baseUrl = 'https://github.com/astral-sh/python-build-standalone/releases/download'
const releaseDate = '20250723'
const pythonVersion = '3.11.13'
const archString = getArchString()
const platformString = getPlatformString()
const filename = `cpython-${pythonVersion}+${releaseDate}-${archString}-${platformString}-install_only.tar.gz`
return `${baseUrl}/${releaseDate}/${filename}`
}
export const downloadFileWithProgress = async (url, downloadPath, onProgress) => {
try {
const response = await fetch(url)
if (!response || !response.ok) {
throw new Error(`HTTP error! status: ${response?.status}`)
}
const totalSize = parseInt(response.headers.get('content-length'), 10)
let downloadedSize = 0
const reader = response.body.getReader()
const chunks = []
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
downloadedSize += value.length
if (onProgress && totalSize) {
onProgress((downloadedSize / totalSize) * 100, downloadedSize, totalSize)
}
}
const buffer = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)))
fs.writeFileSync(downloadPath, buffer)
log.info('File downloaded successfully:', downloadPath)
return downloadPath
} catch (error) {
// Clean up partial downloads
try {
if (fs.existsSync(downloadPath)) {
fs.unlinkSync(downloadPath)
}
} catch {}
log.error('Download failed:', error)
throw error
}
}
export const getPythonDownloadPath = (): string => {
return path.join(getUserDataPath(), 'py.tar.gz')
}
export const getPythonInstallationDir = (): string => {
const installDir = path.join(app.getPath('userData'), 'python')
if (!fs.existsSync(installDir)) {
try {
fs.mkdirSync(installDir, { recursive: true })
} catch (error) {
log.error(error)
}
}
return path.normalize(installDir)
}
const downloadPython = async (onProgress = null) => {
const url = generateDownloadUrl()
const downloadPath = getPythonDownloadPath()
log.info(`Detected system: ${os.platform()} ${os.arch()}`)
log.info(`Download path: ${downloadPath}`)
log.info(`URL: ${url}`)
if (fs.existsSync(downloadPath)) {
log.info(`File already exists: ${downloadPath}`)
return downloadPath
}
try {
const result = await downloadFileWithProgress(url, downloadPath, onProgress)
log.info(`Python downloaded successfully to: ${result}`)
return result
} catch (error) {
log.error(`Download failed: ${error?.message}`)
throw error
}
}
const checkInternet = async () => {
try {
await fetch('https://api.openwebui.com', { method: 'GET' })
return true
} catch {
return false
}
}
export const installPython = async (installationDir?: string): Promise<boolean> => {
const pythonDownloadPath = getPythonDownloadPath()
if (!fs.existsSync(pythonDownloadPath)) {
if (!(await checkInternet())) {
throw new Error(
'An active internet connection is required. Please connect to the internet and try again.'
)
}
await downloadPython((progress, downloaded, total) => {
log.info(`Downloading Python: ${progress.toFixed(2)}% (${downloaded} of ${total} bytes)`)
})
}
if (!fs.existsSync(pythonDownloadPath)) {
log.error('Python download not found')
return false
}
installationDir = installationDir || getPythonInstallationDir()
log.info(installationDir, pythonDownloadPath)
try {
const userDataPath = getUserDataPath()
await tar.x({ cwd: userDataPath, file: pythonDownloadPath })
} catch (error) {
log.error(error)
return false
}
if (isPythonInstalled(installationDir)) {
const pythonPath = getPythonPath(installationDir)
execFileSync(pythonPath, ['-m', 'pip', 'install', 'uv'], {
encoding: 'utf-8',
env: {
...process.env,
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
}
})
log.info('Successfully installed uv package')
return true
} else {
log.error('Python installation failed or not found')
return false
}
}
export const getPythonExecutablePath = (envPath: string) => {
if (process.platform === 'win32') {
return path.normalize(path.join(envPath, 'python.exe'))
}
return path.normalize(path.join(envPath, 'bin', 'python'))
}
export const getPythonPath = (installationDir?: string) => {
return path.normalize(getPythonExecutablePath(installationDir || getPythonInstallationDir()))
}
export const isPythonInstalled = (installationDir?: string) => {
const pythonPath = getPythonPath(installationDir)
if (!fs.existsSync(pythonPath)) {
return false
}
try {
const pythonVersion = execFileSync(pythonPath, ['--version'], {
encoding: 'utf-8',
env: {
...process.env,
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
}
})
log.info('Installed Python Version:', pythonVersion.trim())
return true
} catch {
return false
}
}
export const isUvInstalled = (installationDir?: string) => {
const pythonPath = getPythonPath(installationDir)
try {
const result = execFileSync(pythonPath, ['-m', 'uv', '--version'], {
encoding: 'utf-8',
env: {
...process.env,
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
}
})
log.info('Installed uv Version:', result.trim())
return true
} catch {
return false
}
}
export const uninstallPython = (installationDir?: string): boolean => {
installationDir = installationDir || getPythonInstallationDir()
if (!fs.existsSync(installationDir)) {
log.error('Python installation not found')
return false
}
try {
fs.rmSync(installationDir, { recursive: true, force: true })
log.info('Python installation removed:', installationDir)
} catch (error) {
log.error('Failed to remove Python installation', error)
return false
}
try {
const pythonDownloadPath = getPythonDownloadPath()
fs.rmSync(pythonDownloadPath, { recursive: true })
} catch (error) {
log.error('Failed to remove Python download', error)
return false
}
return true
}
// ─── Package Management ─────────────────────────────────
export const installPackage = (packageName: string, version?: string): Promise<boolean> => {
return new Promise((resolve, reject) => {
if (!isPythonInstalled()) {
return reject(new Error('Python is not installed'))
}
const pythonPath = getPythonPath()
const commandProcess = execFile(
pythonPath,
[
'-m',
'uv',
'pip',
'install',
...(version ? [`${packageName}==${version}`] : [packageName, '-U'])
],
{
env: {
...process.env,
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
}
}
)
commandProcess.stdout?.on('data', (data) => log.info(data))
commandProcess.stderr?.on('data', (data) => log.info(data))
commandProcess.on('exit', (code) => {
log.info(`Package install exited with code ${code}`)
resolve(code === 0)
})
commandProcess.on('error', (error) => {
log.error(`Package install error: ${error.message}`)
reject(error)
})
})
}
export const isPackageInstalled = (packageName: string): boolean => {
const pythonPath = getPythonPath()
if (!fs.existsSync(pythonPath)) return false
try {
const info = execFileSync(pythonPath, ['-m', 'uv', 'pip', 'show', packageName], {
encoding: 'utf-8',
env: {
...process.env,
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
}
})
return info.includes(`Name: ${packageName}`)
} catch {
return false
}
}
// ─── Server Management ──────────────────────────────────
import * as pty from 'node-pty'
const serverPIDs: Set<number> = new Set()
const serverLogs: Map<number, string[]> = new Map()
let serverPtyProcesses: Map<number, pty.IPty> = new Map()
export const getServerPIDs = (): number[] => Array.from(serverPIDs)
export const getServerPty = (pid: number): pty.IPty | undefined => serverPtyProcesses.get(pid)
export const startServer = async (
expose = false,
port = null
): Promise<{ url: string; pid: number }> => {
await stopAllServers()
const host = expose ? '0.0.0.0' : '127.0.0.1'
if (!isPythonInstalled()) throw new Error('Python is not installed')
if (!isPackageInstalled('open-webui')) throw new Error('open-webui package is not installed')
const pythonPath = getPythonPath()
log.info(`Using Python at: ${pythonPath}`)
if (!fs.existsSync(pythonPath)) {
throw new Error(`Python executable not found at: ${pythonPath}`)
}
const commandArgs = ['-m', 'uv', 'run', 'open-webui', 'serve', '--host', host]
const dataDir = getOpenWebUIDataPath()
const secretKey = getSecretKey()
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true })
}
// Find available port
let desiredPort = port || 8080
let availablePort = desiredPort
while (await portInUse(availablePort, host)) {
availablePort++
if (availablePort > desiredPort + 100) {
throw new Error('No available ports found')
}
}
commandArgs.push('--port', availablePort.toString())
log.info('Starting Open-WebUI server...', pythonPath, commandArgs.join(' '))
let ptyProcess: pty.IPty
try {
ptyProcess = pty.spawn(pythonPath, commandArgs, {
name: 'xterm-256color',
cols: 200,
rows: 50,
env: {
...process.env,
DATA_DIR: dataDir,
WEBUI_SECRET_KEY: secretKey,
PYTHONUNBUFFERED: '1',
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
}
})
} catch (error) {
throw new Error(
`Failed to spawn PTY with ${pythonPath}: ${error?.message ?? error}`
)
}
const pid = ptyProcess.pid
const rawBuffer: string[] = []
serverPIDs.add(pid)
serverLogs.set(pid, rawBuffer)
serverPtyProcesses.set(pid, ptyProcess)
ptyProcess.onData((data: string) => {
rawBuffer.push(data)
serverLogger.info(`[PID:${pid}] ${data.replace(/[\r\n]+/g, ' ').trim()}`)
})
ptyProcess.onExit(({ exitCode, signal }) => {
const exitMsg = `\r\n[Process exited with code ${exitCode}${signal ? ` signal ${signal}` : ''}]\r\n`
rawBuffer.push(exitMsg)
serverLogger.info(`[PID:${pid}] Exited code=${exitCode} signal=${signal}`)
serverPIDs.delete(pid)
serverPtyProcesses.delete(pid)
})
let effectiveHost = host
if (!expose && host === '0.0.0.0') effectiveHost = '127.0.0.1'
const url = `http://${effectiveHost}:${availablePort}`
log.info(`Server started with PID: ${pid}, URL: ${url}`)
return { url, pid }
}
export async function stopAllServers(): Promise<void> {
log.info('Stopping all servers...')
const pidsToStop = Array.from(serverPIDs)
if (pidsToStop.length === 0) return
// Kill PTY processes directly — cleaner than process tree termination
for (const pid of pidsToStop) {
const ptyProc = serverPtyProcesses.get(pid)
if (ptyProc) {
try {
ptyProc.kill()
} catch (e) {
log.warn(`Failed to kill PTY process ${pid}:`, e)
}
} else {
// Fallback for any non-PTY processes
await terminateProcessTree(pid, false)
}
}
await sleep(2000)
// Force kill anything still running
for (const pid of pidsToStop) {
if (isProcessRunning(pid)) {
await terminateProcessTree(pid, true)
}
}
for (const pid of pidsToStop) {
if (!isProcessRunning(pid)) {
serverPIDs.delete(pid)
serverLogs.delete(pid)
serverPtyProcesses.delete(pid)
} else {
log.warn(`Process ${pid} may still be running after termination attempts`)
}
}
}
export const clearServerLog = (pid: number): void => {
const logs = serverLogs.get(pid)
if (logs) logs.length = 0
}
export const clearAllServerLogs = (): void => {
for (const logs of serverLogs.values()) {
logs.length = 0
}
}
async function terminateProcessTree(pid: number, forceKill: boolean = false): Promise<void> {
const maxRetries = 3
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
if (process.platform === 'win32') {
await terminateWindows(pid, forceKill)
} else {
await terminateUnix(pid, forceKill)
}
if (!isProcessRunning(pid)) {
log.info(`Successfully terminated process tree (PID: ${pid})`)
return
}
} catch (error) {
log.warn(`Attempt ${attempt}/${maxRetries} failed for PID ${pid}:`, error)
}
if (attempt < maxRetries) await sleep(1000)
}
log.error(`Failed to terminate process tree (PID: ${pid}) after ${maxRetries} attempts`)
}
async function terminateWindows(pid: number, forceKill: boolean): Promise<void> {
const commands = forceKill
? [`taskkill /PID ${pid} /T /F`]
: [`taskkill /PID ${pid} /T`, `taskkill /PID ${pid} /T /F`]
for (const cmd of commands) {
try {
execSync(cmd, { timeout: 5000, stdio: 'ignore' })
await sleep(500)
} catch {}
}
}
async function terminateUnix(pid: number, forceKill: boolean): Promise<void> {
const signals = forceKill ? ['SIGKILL'] : ['SIGTERM', 'SIGKILL']
for (const signal of signals) {
try {
process.kill(-pid, signal)
await sleep(500)
if (isProcessRunning(pid)) {
process.kill(pid, signal)
await sleep(500)
}
} catch {}
}
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function getServerLog(pid: number): string[] {
return serverLogs.get(pid) || []
}
// ─── URL Validation ─────────────────────────────────────
export const checkUrlAndOpen = async (url: string, callback: Function = async () => {}) => {
const maxAttempts = 1800
const interval = 2000
let attempts = 0
const checkUrl = async (): Promise<boolean> => {
try {
const response = await fetch(url, { method: 'HEAD' })
return response.ok
} catch {
return false
}
}
const pollUrl = async () => {
while (attempts < maxAttempts) {
attempts++
const isAvailable = await checkUrl()
if (isAvailable) {
log.info('URL is now available')
await callback()
return
}
await new Promise((resolve) => setTimeout(resolve, interval))
}
log.info('URL check timed out')
}
pollUrl().catch((error) => {
log.error('Error in URL polling:', error)
})
}
export const validateRemoteUrl = async (url: string): Promise<boolean> => {
try {
const response = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) })
return response.ok
} catch {
return false
}
}
// ─── Config ─────────────────────────────────────────────
export interface Connection {
id: string
name: string
type: 'local' | 'remote'
url: string
}
export interface AppConfig {
version: number
defaultConnectionId: string | null
connections: Connection[]
localServer: {
port: number
serveOnLocalNetwork: boolean
autoUpdate: boolean
}
}
const DEFAULT_CONFIG: AppConfig = {
version: 1,
defaultConnectionId: null,
connections: [],
localServer: {
port: 8080,
serveOnLocalNetwork: false,
autoUpdate: true
}
}
export const getConfig = async (): Promise<AppConfig> => {
const configPath = path.join(getUserDataPath(), 'config.json')
try {
if (fs.existsSync(configPath)) {
const data = await fs.promises.readFile(configPath, 'utf8')
return { ...DEFAULT_CONFIG, ...JSON.parse(data) }
}
return { ...DEFAULT_CONFIG }
} catch (error) {
log.error('Error reading config, using defaults:', error)
return { ...DEFAULT_CONFIG }
}
}
export const setConfig = async (config: Partial<AppConfig>): Promise<void> => {
const configPath = path.join(getUserDataPath(), 'config.json')
const tmpPath = configPath + '.tmp'
try {
const existing = await getConfig()
const merged = { ...existing, ...config }
await fs.promises.writeFile(tmpPath, JSON.stringify(merged, null, 2))
await fs.promises.rename(tmpPath, configPath)
} catch (error) {
log.error('Error writing config:', error)
// Clean up temp file
try {
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath)
} catch {}
throw error
}
}
export const resetApp = async (): Promise<void> => {
await uninstallPython()
log.info('Uninstalled Python environment')
const configPath = path.join(getUserDataPath(), 'config.json')
if (fs.existsSync(configPath)) {
try {
fs.unlinkSync(configPath)
} catch (error) {
log.error('Failed to remove config file:', error)
}
}
const secretKeyPath = path.join(getOpenWebUIDataPath(), '.key')
if (fs.existsSync(secretKeyPath)) {
try {
fs.unlinkSync(secretKeyPath)
} catch (error) {
log.error('Failed to remove secret key file:', error)
}
}
const dataPath = getOpenWebUIDataPath()
if (fs.existsSync(dataPath)) {
try {
fs.rmSync(dataPath, { recursive: true, force: true })
} catch (error) {
log.error('Failed to remove data directory:', error)
}
}
}
+104 -10
View File
@@ -1,22 +1,116 @@
import { contextBridge } from 'electron'
import { ipcRenderer, contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
// Custom APIs for renderer
const api = {}
window.addEventListener('DOMContentLoaded', () => {
ipcRenderer.on('main:data', (_event, data) => {
window.postMessage(
{
...data,
type: `electron:${data.type}`
},
window.location.origin
)
})
})
// ─── PTY MessagePort ────────────────────────────────────
// The MessagePort stays in the preload (cannot cross contextBridge).
// We expose simple functions so the renderer never touches the port.
let activePtyPort: MessagePort | null = null
let ptyOutputCallback: ((data: string) => void) | null = null
ipcRenderer.on('pty:port', (event, _data) => {
const [port] = event.ports
if (!port) return
// Clean up previous port
if (activePtyPort) {
activePtyPort.close()
}
activePtyPort = port
port.onmessage = (ev: MessageEvent) => {
if (ev.data?.type === 'output' && ptyOutputCallback) {
ptyOutputCallback(ev.data.data)
}
}
port.start()
})
const api = {
onData: (callback: (data: any) => void) => {
ipcRenderer.on('main:data', (_, data) => callback(data))
},
// App
getAppInfo: () => ipcRenderer.invoke('app:info'),
getVersion: () => ipcRenderer.invoke('get:version'),
resetApp: () => ipcRenderer.invoke('app:reset'),
getLaunchAtLogin: () => ipcRenderer.invoke('app:launchAtLogin:get'),
setLaunchAtLogin: (enabled: boolean) => ipcRenderer.invoke('app:launchAtLogin:set', enabled),
openInBrowser: (url: string) => ipcRenderer.invoke('open:browser', { url }),
notification: (title: string, body: string) =>
ipcRenderer.invoke('notification', { title, body }),
// Config
getConfig: () => ipcRenderer.invoke('get:config'),
setConfig: (config: Record<string, any>) => ipcRenderer.invoke('set:config', config),
// Python/uv
installPython: () => ipcRenderer.invoke('install:python'),
getPythonStatus: () => ipcRenderer.invoke('status:python'),
// Package
installPackage: () => ipcRenderer.invoke('install:package'),
getPackageStatus: () => ipcRenderer.invoke('status:package'),
// Server
startServer: () => ipcRenderer.invoke('server:start'),
stopServer: () => ipcRenderer.invoke('server:stop'),
restartServer: () => ipcRenderer.invoke('server:restart'),
getServerInfo: () => ipcRenderer.invoke('server:info'),
getServerLogs: () => ipcRenderer.invoke('server:logs'),
clearServerLogs: () => ipcRenderer.invoke('server:logs:clear'),
// PTY — MessagePort stays in preload, renderer uses these functions
listPtys: () => ipcRenderer.invoke('pty:list'),
connectPty: (onOutput: (data: string) => void, pid?: number) => {
ptyOutputCallback = onOutput
ipcRenderer.invoke('pty:connect', pid)
},
writePty: (data: string) => {
activePtyPort?.postMessage({ type: 'input', data })
},
resizePty: (cols: number, rows: number) => {
activePtyPort?.postMessage({ type: 'resize', cols, rows })
},
disconnectPty: () => {
ptyOutputCallback = null
if (activePtyPort) {
activePtyPort.close()
activePtyPort = null
}
},
// Connections
getConnections: () => ipcRenderer.invoke('connections:list'),
addConnection: (connection: any) => ipcRenderer.invoke('connections:add', connection),
removeConnection: (id: string) => ipcRenderer.invoke('connections:remove', id),
setDefaultConnection: (id: string) => ipcRenderer.invoke('connections:setDefault', id),
connectTo: (id: string) => ipcRenderer.invoke('connections:connect', id),
validateUrl: (url: string) => ipcRenderer.invoke('validate:url', url)
}
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
contextBridge.exposeInMainWorld('electronAPI', api)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore (define in dts)
// @ts-ignore
window.electron = electronAPI
// @ts-ignore (define in dts)
window.api = api
// @ts-ignore
window.electronAPI = api
}
+1 -1
View File
@@ -6,7 +6,7 @@
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; media-src 'self' https://community.s3.openwebui.com; connect-src 'self' https://community.s3.openwebui.com https://fonts.googleapis.com https://fonts.gstatic.com"
/>
</head>
+40 -22
View File
@@ -1,26 +1,44 @@
<script lang="ts">
import Versions from './components/Versions.svelte'
import electronLogo from './assets/electron.svg'
import { onMount } from 'svelte'
import { fade } from 'svelte/transition'
import { appInfo, config, connections, serverInfo, appState } from './lib/stores'
const ipcHandle = (): void => window.electron.ipcRenderer.send('ping')
import Main from './lib/components/Main.svelte'
onMount(async () => {
const api = window?.electronAPI
if (!api) return
appInfo.set(await api.getAppInfo())
config.set(await api.getConfig())
connections.set(await api.getConnections())
api.onData((data: any) => {
if (data.type === 'status:server') {
serverInfo.update((info) => ({ ...info, status: data.data }))
}
if (data.type === 'server:ready') {
serverInfo.update((info) => ({ ...info, reachable: true, url: data.data?.url }))
}
})
// Install python in the background — don't block UI
const pythonReady = await api.getPythonStatus()
if (!pythonReady) {
appState.set('initializing')
api.installPython().then(async () => {
appState.set('ready')
})
} else {
appState.set('ready')
}
setInterval(async () => {
serverInfo.set(await api.getServerInfo())
}, 3000)
})
</script>
<img alt="logo" class="logo" src={electronLogo} />
<div class="creator">Powered by electron-vite</div>
<div class="text">
Build an Electron app with
<span class="svelte">Svelte</span>
and
<span class="ts">TypeScript</span>
</div>
<p class="tip">Please try pressing <code>F12</code> to open the devTool</p>
<div class="actions">
<div class="action">
<a href="https://electron-vite.org/" target="_blank" rel="noreferrer">Documentation</a>
</div>
<div class="action">
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions a11y-missing-attribute-->
<a target="_blank" rel="noreferrer" on:click={ipcHandle}>Send IPC</a>
</div>
</div>
<Versions />
<main class="w-full h-full bg-[#0a0a0a]">
<Main />
</main>
+88
View File
@@ -0,0 +1,88 @@
@import "tailwindcss";
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@layer base {
button {
@apply cursor-pointer;
}
}
@font-face {
font-family: "Archivo";
src: url("./lib/assets/fonts/Archivo-Variable.ttf");
font-display: swap;
}
.drag-region {
-webkit-app-region: drag;
}
.drag-region a,
.drag-region button {
-webkit-app-region: no-drag;
}
.no-drag {
-webkit-app-region: no-drag;
}
@theme {
--color-white: #fff;
--color-black: #000;
--color-gray-50: #f9f9f9;
--color-gray-100: #ececec;
--color-gray-200: #e3e3e3;
--color-gray-300: #cdcdcd;
--color-gray-400: #b4b4b4;
--color-gray-500: #9b9b9b;
--color-gray-600: #676767;
--color-gray-700: #4e4e4e;
--color-gray-800: #333;
--color-gray-850: #262626;
--color-gray-900: #171717;
--color-gray-950: #0d0d0d;
}
html {
font-family: "Archivo", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
background: #0a0a0a;
color: #fafafa;
font-size: 13px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
user-select: none;
}
body {
height: 100%;
width: 100%;
overflow: hidden;
}
#app {
height: 100vh;
}
::selection {
background: rgba(255, 255, 255, 0.15);
}
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: 99px;
}
::-webkit-scrollbar-track { background: transparent; }
.xterm-screen {
height: 100% !important;
}
+25
View File
@@ -0,0 +1,25 @@
import tippy, { type Props } from 'tippy.js'
import 'tippy.js/dist/tippy.css'
import 'tippy.js/themes/translucent.css'
export function tooltip(node: HTMLElement, content: string | Partial<Props>) {
const options: Partial<Props> =
typeof content === 'string'
? { content, placement: 'bottom', theme: 'translucent', delay: [500, 0] }
: { placement: 'bottom', theme: 'translucent', delay: [500, 0], ...content }
const instance = tippy(node, options)
return {
update(newContent: string | Partial<Props>) {
const newOptions: Partial<Props> =
typeof newContent === 'string'
? { content: newContent }
: newContent
instance.setProps(newOptions)
},
destroy() {
instance.destroy()
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

@@ -0,0 +1,165 @@
<script lang="ts">
import { fly, fade } from 'svelte/transition'
import { onMount } from 'svelte'
import { connections, config, appState, appInfo } from '../stores'
import logoImage from '../assets/images/splash.png'
let view = $state('list') // list | add
let url = $state('')
let name = $state('')
let connecting = $state(false)
let error = $state('')
let visible = $state(false)
onMount(async () => {
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
setTimeout(() => { visible = true }, 50)
})
const add = async () => {
if (!url.trim()) return
let u = url.trim()
if (!u.startsWith('http')) u = 'https://' + u
error = ''
connecting = true
try {
const valid = await window.electronAPI.validateUrl(u)
if (!valid) { error = 'Unreachable'; connecting = false; return }
await window.electronAPI.addConnection({
id: crypto.randomUUID(),
name: name.trim() || new URL(u).hostname,
type: 'remote',
url: u
})
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
url = ''; name = ''; view = 'list'
} catch { error = 'Failed' }
finally { connecting = false }
}
const connect = (id: string) => window.electronAPI.connectTo(id)
const setDefault = async (id: string) => {
await window.electronAPI.setDefaultConnection(id)
config.set(await window.electronAPI.getConfig())
}
const remove = async (id: string) => {
await window.electronAPI.removeConnection(id)
const conns = await window.electronAPI.getConnections()
connections.set(conns)
config.set(await window.electronAPI.getConfig())
if (conns.length === 0) appState.set('setup')
}
</script>
{#if visible}
<div class="h-full flex flex-col bg-[#0a0a0a] text-[#fafafa]" in:fade={{ duration: 250 }}>
<!-- Header -->
<div class="flex items-center justify-between {$appInfo?.platform === 'darwin' ? 'pl-[76px]' : 'pl-5'} pr-5 pt-3 pb-2 drag-region">
<div class="text-[13px] opacity-50">Connections</div>
<img src={logoImage} class="w-5 h-5 rounded-full dark:invert opacity-40" alt="logo" />
</div>
<div class="mx-5 border-b border-white/[0.06]"></div>
<!-- Content -->
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-3">
{#if view === 'list'}
<div class="flex flex-col">
{#each $connections as conn, i (conn.id)}
<div
class="w-full py-3 cursor-pointer group flex items-center gap-3 transition-opacity hover:opacity-100 opacity-70 {i > 0 ? 'border-t border-white/[0.04]' : ''}"
role="button"
tabindex="0"
onclick={() => connect(conn.id)}
onkeydown={(e) => e.key === 'Enter' && connect(conn.id)}
in:fly={{ y: 4, duration: 150, delay: i * 30 }}
>
<div class="w-[6px] h-[6px] rounded-full shrink-0 {conn.type === 'local' ? 'bg-green-400/70' : 'bg-white/10'}"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-[13px] truncate">{conn.name}</span>
{#if $config?.defaultConnectionId === conn.id}
<span class="text-[10px] opacity-30">default</span>
{/if}
</div>
<span class="text-[11px] opacity-20 truncate block mt-px">{conn.url}</span>
</div>
<div class="shrink-0 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{#if $config?.defaultConnectionId !== conn.id}
<button
class="p-1.5 opacity-20 hover:opacity-60 text-[10px] transition bg-transparent border-none text-[#fafafa]"
onclick={(e) => { e.stopPropagation(); setDefault(conn.id) }}
>★</button>
{/if}
<button
class="p-1.5 opacity-20 hover:text-red-400 hover:opacity-80 text-[10px] transition bg-transparent border-none text-[#fafafa]"
onclick={(e) => { e.stopPropagation(); remove(conn.id) }}
>✕</button>
</div>
</div>
{:else}
<div class="flex-1 flex items-center justify-center py-16">
<span class="text-[13px] opacity-15">No connections</span>
</div>
{/each}
</div>
<!-- Add button -->
<button
class="mt-4 inline-flex items-center gap-2 text-[13px] opacity-40 hover:opacity-70 transition bg-transparent border-none text-[#fafafa]"
onclick={() => (view = 'add')}
>
+ Add connection
</button>
{:else if view === 'add'}
<div in:fade={{ duration: 150 }}>
<button
class="text-[12px] opacity-40 hover:opacity-70 transition mb-6 bg-transparent border-none text-[#fafafa]"
onclick={() => { view = 'list'; error = '' }}
>
← Back
</button>
<div class="text-2xl font-light tracking-tight mb-5">Add connection.</div>
<div class="flex flex-col gap-2.5">
<input
type="text"
bind:value={url}
placeholder="Server URL"
class="w-full px-4 py-2.5 bg-white/[0.06] text-[13px] text-[#fafafa] placeholder:opacity-20 outline-none focus:bg-white/[0.1] transition no-drag border-none"
onkeydown={(e) => e.key === 'Enter' && add()}
/>
<input
type="text"
bind:value={name}
placeholder="Label (optional)"
class="w-full px-4 py-2.5 bg-white/[0.06] text-[13px] text-[#fafafa] placeholder:opacity-20 outline-none focus:bg-white/[0.1] transition no-drag border-none"
/>
{#if error}
<span class="text-[11px] text-red-400 opacity-80">{error}</span>
{/if}
<button
class="w-fit mt-2 inline-flex items-center gap-2 bg-white px-8 py-2.5 text-black text-[13px] transition hover:bg-gray-100 disabled:opacity-30 border-none"
onclick={add}
disabled={connecting}
>
{connecting ? 'Adding…' : 'Add'}
{#if !connecting}
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
{/if}
</button>
</div>
</div>
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,55 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade } from 'svelte/transition'
import { serverInfo } from '../stores'
import logoImage from '../assets/images/splash.png'
let { phase = 'loading' } = $props()
let visible = $state(false)
let videoElement: HTMLVideoElement
onMount(() => {
setTimeout(() => { visible = true }, 100)
if (videoElement) {
videoElement.play().catch(() => {})
}
})
</script>
{#if visible}
<div class="h-full w-full relative overflow-hidden bg-[#0a0a0a]" in:fade={{ duration: 500 }}>
<!-- Video background -->
<div class="absolute inset-0 overflow-hidden">
<video
bind:this={videoElement}
autoplay
muted
loop
playsinline
class="absolute top-1/2 left-1/2 h-auto min-h-full w-auto min-w-full -translate-x-1/2 -translate-y-1/2 object-cover opacity-30"
>
<source src="https://community.s3.openwebui.com/landing.mp4" type="video/mp4" />
</video>
</div>
<div class="relative z-10 h-full flex items-center justify-center">
<div class="flex flex-col items-center gap-5">
<img src={logoImage} class="size-14 rounded-full dark:invert" alt="logo" />
{#if phase === 'initializing'}
<div class="flex flex-col items-center gap-2 text-center">
<div class="text-sm text-[#fafafa] opacity-50">
Preparing environment…
</div>
{#if $serverInfo?.status}
<div class="text-[11px] text-[#fafafa] opacity-25 max-w-[220px] leading-relaxed">
{$serverInfo.status}
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/if}
@@ -0,0 +1,104 @@
<script lang="ts">
import { fade, fly } from 'svelte/transition'
import { onMount } from 'svelte'
import { connections, config, serverInfo } from '../stores'
import logoImage from '../assets/images/splash.png'
let { onBack, onComplete } = $props()
let phase = $state('ready') // ready | working | done | error
let errorMsg = $state('')
const install = async () => {
phase = 'working'
try {
const ok = await window.electronAPI.installPackage()
if (!ok) { phase = 'error'; errorMsg = 'Install failed'; return }
await window.electronAPI.startServer()
const info = await window.electronAPI.getServerInfo()
await window.electronAPI.addConnection({
id: 'local',
name: 'Local',
type: 'local',
url: info?.url || 'http://127.0.0.1:8080'
})
await window.electronAPI.setDefaultConnection('local')
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
phase = 'done'
setTimeout(async () => {
await window.electronAPI.connectTo('local')
onComplete()
}, 800)
} catch (e) {
phase = 'error'
errorMsg = e?.message || 'Something went wrong'
}
}
</script>
<div class="flex flex-col" in:fade={{ duration: 200 }}>
<button
class="self-start text-[12px] opacity-40 hover:opacity-70 transition mb-6 bg-transparent border-none text-[#fafafa] disabled:opacity-20"
onclick={onBack}
disabled={phase === 'working'}
>
← Back
</button>
{#if phase === 'ready'}
<div class="mb-1 text-sm font-normal opacity-50">Open WebUI</div>
<h1 class="text-2xl font-light tracking-tight mb-2">Install locally.</h1>
<p class="text-[12px] opacity-30 mb-8 leading-relaxed">
Download and run Open WebUI on this machine.
</p>
<button
class="w-fit inline-flex items-center gap-2 bg-white px-8 py-2.5 text-black text-[13px] transition hover:bg-gray-100 border-none"
onclick={install}
>
Continue
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</button>
{:else if phase === 'working'}
<div class="flex flex-col items-center gap-5 py-10" in:fade={{ duration: 250 }}>
<img src={logoImage} class="size-12 rounded-full dark:invert" alt="logo" />
<div class="flex flex-col items-center gap-2 text-center">
<div class="text-sm opacity-60">Installing…</div>
{#if $serverInfo?.status}
<div class="text-[11px] opacity-30 max-w-[220px] leading-relaxed" in:fade={{ duration: 200 }}>
{$serverInfo.status}
</div>
{:else}
<div class="text-[11px] opacity-20">
This might take a few minutes
</div>
{/if}
</div>
</div>
{:else if phase === 'done'}
<div class="flex flex-col items-center gap-4 py-10" in:fade={{ duration: 250 }}>
<img src={logoImage} class="size-12 rounded-full dark:invert" alt="logo" />
<div class="text-sm text-green-400 opacity-70">Ready</div>
</div>
{:else if phase === 'error'}
<div class="flex flex-col items-center gap-4 py-10" in:fade={{ duration: 250 }}>
<div class="text-[12px] text-red-400 opacity-80">{errorMsg}</div>
<button
class="w-fit inline-flex items-center gap-2 bg-white/[0.06] px-6 py-2 text-[12px] opacity-60 hover:opacity-90 transition border-none text-[#fafafa]"
onclick={() => (phase = 'ready')}
>
Retry
</button>
</div>
{/if}
</div>
+130
View File
@@ -0,0 +1,130 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade } from 'svelte/transition'
import { connections, config, appInfo } from '../stores'
import { tooltip } from '../actions/tooltip'
import Connections from './Main/Connections.svelte'
import Settings from './Main/Settings.svelte'
let visible = $state(false)
let settingsOpen = $state(false)
let sidebarOpen = $state(true)
let activeConnectionName = $state('')
let isLocalConnection = $state(false)
let showingLogs = $state(false)
onMount(async () => {
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
setTimeout(() => {
visible = true
}, 50)
})
</script>
{#if visible}
<div
class="h-full w-full flex flex-col bg-[#0a0a0a] text-[#fafafa] relative"
in:fade={{ duration: 200 }}
>
<!-- Persistent top bar -->
<div
class="flex items-center shrink-0 drag-region {$appInfo?.platform === 'darwin'
? 'h-10'
: 'h-8'}"
>
<div
class="flex items-center {$appInfo?.platform === 'darwin'
? 'pl-25'
: 'pl-3'} pr-2 shrink-0 translate-y-[0.5px]"
>
<button
class="opacity-25 hover:opacity-50 transition bg-transparent border-none text-[#fafafa] no-drag"
onclick={() => (sidebarOpen = !sidebarOpen)}
use:tooltip={sidebarOpen ? 'Close sidebar' : 'Open sidebar'}
>
<svg
class="w-[15px] h-[15px]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 3.75h16.5v16.5H3.75V3.75zM9 3.75v16.5"
/>
</svg>
</button>
</div>
<div class="flex-1 flex items-center justify-center">
{#if activeConnectionName}
<span class="text-[11px] opacity-30">{activeConnectionName}</span>
{/if}
</div>
{#if isLocalConnection}
<div class="pr-3 shrink-0 flex items-center">
<button
class="opacity-20 hover:opacity-50 transition bg-transparent border-none text-[#fafafa] no-drag"
onclick={() => (showingLogs = !showingLogs)}
use:tooltip={showingLogs ? 'Back to Open WebUI' : 'Show logs'}
>
<svg
class="w-[14px] h-[14px]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
{#if showingLogs}
<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"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z"
/>
{/if}
</svg>
</button>
</div>
{/if}
</div>
<!-- Content area below top bar -->
<div class="flex-1 min-h-0">
<Connections
{sidebarOpen}
bind:activeConnectionName
bind:isLocalConnection
bind:showingLogs
onOpenSettings={() => (settingsOpen = true)}
/>
</div>
{#if settingsOpen}
<div
class="absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
in:fade={{ duration: 150 }}
out:fade={{ duration: 100 }}
onclick={() => (settingsOpen = false)}
role="button"
tabindex="-1"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-[calc(100%-32px)] h-[calc(100%-32px)] max-w-[900px] max-h-[600px] rounded-2xl overflow-hidden shadow-2xl border border-white/[0.08]"
in:fade={{ duration: 150 }}
onclick={(e) => e.stopPropagation()}
>
<Settings onClose={() => (settingsOpen = false)} />
</div>
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,559 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { fly, fade } from 'svelte/transition'
import { connections, config, appInfo, serverInfo, appState } from '../../stores'
import LocalInstall from '../LocalInstall.svelte'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
interface Props {
onOpenSettings: () => void
sidebarOpen: boolean
activeConnectionName?: string
isLocalConnection?: boolean
showingLogs?: boolean
}
let {
onOpenSettings,
sidebarOpen,
activeConnectionName = $bindable(''),
isLocalConnection = $bindable(false),
showingLogs = $bindable(false)
}: Props = $props()
let url = $state('')
let connecting = $state(false)
let error = $state('')
let view = $state('welcome') // welcome | add | install | logs
let settingsOpen = $state(false)
let connectedUrl = $state('')
let activeConnectionId = $state('')
let openConnections: Map<string, string> = $state(new Map())
// Terminal state
let terminalEl: HTMLDivElement | undefined = $state()
let term: Terminal | null = null
let fitAddon: FitAddon | null = null
let resizeObserver: ResizeObserver | null = null
const serverStatus = $derived($serverInfo?.status)
const serverReachable = $derived($serverInfo?.reachable)
const isInitializing = $derived($appState === 'initializing')
const hasLocal = $derived(($connections ?? []).some((c) => c.type === 'local'))
const addConnection = async () => {
if (!url.trim()) return
let u = url.trim()
if (!u.startsWith('http')) u = 'https://' + u
error = ''
connecting = true
try {
const valid = await window.electronAPI.validateUrl(u)
if (!valid) {
error = 'Could not reach this server'
connecting = false
return
}
await window.electronAPI.addConnection({
id: crypto.randomUUID(),
name: new URL(u).hostname,
type: 'remote',
url: u
})
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
url = ''
error = ''
view = 'welcome'
} catch {
error = 'Connection failed'
} finally {
connecting = false
}
}
const connect = async (id: string) => {
destroyTerminal()
// If already open, just switch to it
if (openConnections.has(id)) {
activeConnectionId = id
connectedUrl = openConnections.get(id)!
view = 'connected'
return
}
const result = await window.electronAPI.connectTo(id)
if (result?.url) {
openConnections.set(result.connectionId, result.url)
openConnections = new Map(openConnections) // trigger reactivity
connectedUrl = result.url
activeConnectionId = result.connectionId
view = 'connected'
}
}
const disconnect = () => {
activeConnectionId = ''
connectedUrl = ''
view = 'welcome'
}
const remove = async (id: string) => {
await window.electronAPI.removeConnection(id)
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
if (activeConnectionId === id) {
disconnect()
}
openConnections.delete(id)
openConnections = new Map(openConnections)
}
const showLogs = () => {
view = 'logs'
}
// Sync active connection info to parent
$effect(() => {
const conn = ($connections ?? []).find((c) => c.id === activeConnectionId)
activeConnectionName = conn?.name ?? ''
isLocalConnection = conn?.type === 'local'
})
// React to showingLogs from parent
$effect(() => {
if (showingLogs) {
view = 'logs'
initTerminal()
} else if (view === 'logs') {
destroyTerminal()
if (activeConnectionId) {
view = 'connected'
} else {
view = 'welcome'
}
}
})
const openGithub = () => {
settingsOpen = false
window.electronAPI?.openInBrowser?.('https://github.com/open-webui/desktop')
}
// ── Terminal ──────────────────────────────────────────
const initTerminal = () => {
if (!terminalEl || term) return
term = new Terminal({
cursorBlink: false,
disableStdin: false,
fontSize: 11,
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
lineHeight: 1.5,
scrollback: 10000,
theme: {
background: '#0a0a0a',
foreground: '#a0a0a0',
cursor: 'transparent',
selectionBackground: '#ffffff30'
},
convertEol: true
})
fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(terminalEl)
requestAnimationFrame(() => fitAddon?.fit())
resizeObserver = new ResizeObserver(() => {
fitAddon?.fit()
if (term) {
window.electronAPI.resizePty(term.cols, term.rows)
}
})
resizeObserver.observe(terminalEl)
// Keyboard input → PTY
term.onData((data: string) => {
window.electronAPI.writePty(data)
})
// Connect to PTY — output comes via callback
window.electronAPI.connectPty((data: string) => {
term?.write(data)
})
// Send initial resize
if (term) {
window.electronAPI.resizePty(term.cols, term.rows)
}
}
const destroyTerminal = () => {
resizeObserver?.disconnect()
resizeObserver = null
window.electronAPI.disconnectPty()
term?.dispose()
term = null
fitAddon = null
}
onDestroy(() => {
destroyTerminal()
})
// Init/destroy terminal when switching to/from logs view
$effect(() => {
if (view === 'logs' && terminalEl) {
initTerminal()
} else if (view !== 'logs') {
destroyTerminal()
}
})
// Listen for connection:open from main process (auto-connect on launch)
onMount(() => {
window.electronAPI.onData((data: any) => {
if (data.type === 'connection:open' && data.url) {
const connId = data.connectionId ?? ''
openConnections.set(connId, data.url)
openConnections = new Map(openConnections)
connectedUrl = data.url
activeConnectionId = connId
view = 'connected'
}
})
})
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="h-full w-full flex bg-[#0a0a0a] text-[#fafafa]" in:fade={{ duration: 200 }}>
<!-- Sidebar -->
{#if sidebarOpen}
<div
class="w-[200px] shrink-0 flex flex-col bg-[#0a0a0a] relative"
in:fly={{ x: -200, duration: 200 }}
>
<!-- Connections header -->
<div class="flex items-center justify-between px-4 pt-2 pb-1.5">
<span class="text-[10px] tracking-wider uppercase opacity-25">Connections</span>
<button
class="opacity-25 hover:opacity-60 transition bg-transparent border-none text-[#fafafa] leading-none"
onclick={() => {
disconnect()
view = 'add'
}}
title="Add connection"
>
<svg
class="w-[14px] h-[14px]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
</div>
<!-- Connection list -->
<div class="flex-1 min-h-0 overflow-y-auto px-2">
{#each $connections as conn (conn.id)}
<div
class="w-full px-2 py-[6px] rounded-xl cursor-pointer group flex items-center gap-2 transition-colors {activeConnectionId ===
conn.id
? 'bg-white/[0.08]'
: 'hover:bg-white/[0.05]'}"
role="button"
tabindex="0"
onclick={() => connect(conn.id)}
onkeydown={(e) => e.key === 'Enter' && connect(conn.id)}
>
<!-- Status indicator for local connections -->
{#if conn.type === 'local'}
{#if serverStatus === 'starting'}
<div class="w-[14px] h-[14px] shrink-0 flex items-center justify-center">
<div
class="w-2.5 h-2.5 rounded-full border-2 border-amber-400/60 border-t-transparent animate-spin"
></div>
</div>
{:else if serverReachable}
<div class="w-[14px] h-[14px] shrink-0 flex items-center justify-center">
<div
class="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]"
></div>
</div>
{:else}
<div class="w-[14px] h-[14px] shrink-0 flex items-center justify-center">
<div class="w-2 h-2 rounded-full bg-white/15"></div>
</div>
{/if}
{:else}
<svg
class="w-[14px] h-[14px] shrink-0 opacity-30"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
/>
</svg>
{/if}
<span
class="text-[12px] {activeConnectionId === conn.id
? 'opacity-90'
: 'opacity-50 group-hover:opacity-90'} transition-opacity truncate"
>{conn.name}</span
>
<button
class="ml-auto opacity-0 group-hover:opacity-30 hover:!opacity-70 transition bg-transparent border-none text-[#fafafa] shrink-0"
onclick={(e) => {
e.stopPropagation()
window.electronAPI?.openInBrowser?.(conn.url)
}}
title="Open in browser"
>
<svg
class="w-[12px] h-[12px]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
</button>
</div>
{/each}
</div>
<!-- Settings popover -->
{#if settingsOpen}
<div class="fixed inset-0 z-40" onclick={() => (settingsOpen = false)}></div>
<div
class="absolute bottom-12 left-2 right-2 z-50 bg-[#1a1a1a]/90 backdrop-blur-xl border border-white/[0.08] rounded-xl shadow-2xl py-1.5 overflow-hidden"
in:fly={{ y: 8, duration: 150 }}
out:fade={{ duration: 100 }}
>
<div class="px-3.5 py-2.5 border-b border-white/[0.06]">
<div class="text-[11px] opacity-40">Open WebUI Desktop</div>
<div class="text-[10px] opacity-20 mt-0.5">{$appInfo?.version ?? ''}</div>
</div>
<div class="py-1">
<button
class="w-full flex items-center gap-2.5 px-3.5 py-2 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-white/[0.05] transition bg-transparent border-none text-[#fafafa]"
onclick={() => {
settingsOpen = false
onOpenSettings()
}}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Settings
</button>
<button
class="w-full flex items-center gap-2.5 px-3.5 py-2 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-white/[0.05] transition bg-transparent border-none text-[#fafafa]"
onclick={openGithub}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
GitHub
</button>
</div>
</div>
{/if}
<!-- Settings button (bottom) -->
<div class="px-2 pb-3 pt-2">
<button
class="w-full flex items-center gap-2 px-2 py-[6px] rounded-xl text-[12px] opacity-40 hover:opacity-70 hover:bg-white/[0.05] transition bg-transparent border-none text-[#fafafa] text-left"
onclick={() => (settingsOpen = !settingsOpen)}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Settings
</button>
</div>
</div>
{/if}
<!-- Main content -->
<div
class="flex-1 flex flex-col min-w-0 overflow-clip bg-[#111] border-t {sidebarOpen
? 'border-l border-white/[0.08] rounded-tl-xl'
: 'border-white/[0.10]'}"
>
<!-- Webviews — all open connections stay alive, only active one visible -->
{#each [...openConnections] as [connId, connUrl] (connId)}
<webview
src={connUrl}
class="flex-1 min-h-0 border-none"
style="display: {view === 'connected' && activeConnectionId === connId ? 'flex' : 'none'}"
allowpopups
partition="persist:connection-{connId}"
></webview>
{/each}
{#if view === 'logs'}
<!-- Terminal / Logs -->
<div
class="flex-1 min-h-0 overflow-hidden px-3 py-2 bg-[#0a0a0a]"
bind:this={terminalEl}
></div>
{:else if view !== 'connected'}
{#if isInitializing}
<div class="px-5 py-1.5 text-[11px] opacity-25">
Setting up…{$serverInfo?.status ? ` ${$serverInfo.status}` : ''}
</div>
{/if}
<div class="flex-1 flex items-center justify-center px-6">
{#if view === 'welcome'}
<div class="text-center max-w-[320px]" in:fade={{ duration: 200 }}>
{#if ($connections ?? []).length > 0}
<div class="text-lg opacity-80 mb-1.5">Open WebUI</div>
<div class="text-[12px] opacity-30 mb-6">
Select a connection from the sidebar to get started
</div>
{:else}
<div class="text-lg opacity-80 mb-1.5">Welcome to Open WebUI</div>
<div class="text-[12px] opacity-30 mb-6">
To get started, connect to an existing server or
</div>
<button
class="inline-flex items-center gap-2 bg-white px-5 py-2 rounded-xl text-black text-[13px] transition hover:bg-gray-100 border-none"
onclick={() => (view = 'add')}
>
+ Add new connection
</button>
{#if !hasLocal}
<div class="mt-5 pt-5 border-t border-white/[0.06]">
<div class="text-[12px] opacity-40 mb-1.5">Don't have a server?</div>
<div class="text-[11px] opacity-20 mb-3 leading-relaxed">
You can install and run Open WebUI locally on this machine.
</div>
<button
class="text-[12px] opacity-40 hover:opacity-70 transition bg-transparent border-none text-[#fafafa]"
onclick={() => (view = 'install')}
>
Install locally →
</button>
</div>
{/if}
{/if}
</div>
{:else if view === 'add'}
<div class="w-full max-w-[260px]" in:fade={{ duration: 150 }}>
<div class="text-base opacity-70 mb-4">New Connection</div>
<div class="flex flex-col gap-2.5">
<input
type="text"
bind:value={url}
placeholder="e.g. https://your-server.com"
class="w-full px-4 py-2.5 rounded-xl bg-white/[0.06] text-[13px] text-[#fafafa] placeholder:opacity-20 outline-none focus:bg-white/[0.1] transition no-drag border-none"
onkeydown={(e) => e.key === 'Enter' && addConnection()}
/>
{#if error}
<p class="text-[11px] opacity-60">{error}</p>
{/if}
<div class="flex items-center gap-3 mt-1">
<button
class="inline-flex items-center gap-2 bg-white px-5 py-2 rounded-xl text-black text-[13px] transition hover:bg-gray-100 disabled:opacity-30 border-none"
onclick={addConnection}
disabled={connecting || !url.trim()}
>
{connecting ? 'Connecting…' : 'Connect'}
</button>
<button
class="text-[12px] opacity-30 hover:opacity-60 transition bg-transparent border-none text-[#fafafa]"
onclick={() => {
view = 'welcome'
error = ''
}}
>
Cancel
</button>
</div>
</div>
</div>
{:else if view === 'install'}
<div class="w-full max-w-[260px]">
<LocalInstall
onBack={() => (view = 'welcome')}
onComplete={async () => {
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
view = 'welcome'
}}
/>
</div>
{/if}
</div>
{/if}
</div>
</div>
@@ -0,0 +1,326 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade } from 'svelte/transition'
import { connections, config, appInfo } from '../../stores'
interface Props {
onClose: () => void
}
let { onClose }: Props = $props()
let settingsTab = $state('general')
let launchAtLogin = $state(false)
onMount(async () => {
launchAtLogin = await window.electronAPI.getLaunchAtLogin()
})
const setDefault = async (id: string) => {
await window.electronAPI.setDefaultConnection(id)
config.set(await window.electronAPI.getConfig())
}
const remove = async (id: string) => {
await window.electronAPI.removeConnection(id)
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
}
const updateConfig = async (key: string, value: any) => {
const current = $config ?? {}
const localServer = { ...(current.localServer ?? {}), [key]: value }
await window.electronAPI.setConfig({ localServer })
config.set(await window.electronAPI.getConfig())
}
const openGithub = () => {
window.electronAPI?.openInBrowser?.('https://github.com/open-webui/desktop')
}
const tabs = [
{
id: 'general',
label: 'General',
icon: 'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z',
extra: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z'
},
{
id: 'connections',
label: 'Connections',
icon: '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'
},
{
id: 'about',
label: 'About',
icon: 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z'
}
]
</script>
<div class="h-full w-full flex bg-[#0a0a0a] text-[#fafafa]" in:fade={{ duration: 150 }}>
<!-- Settings sidebar -->
<div class="w-[180px] shrink-0 flex flex-col border-r border-white/[0.06] bg-[#111] px-1.5">
<div class="h-4 shrink-0"></div>
<div class="px-3 pb-3">
<span class="text-[13px] opacity-60 font-medium">Settings</span>
</div>
<div class="flex flex-col gap-0.5 px-2">
{#each tabs as tab}
<button
class="flex items-center gap-2 px-2.5 py-[6px] rounded-xl text-[12px] transition bg-transparent border-none text-[#fafafa] text-left w-full {settingsTab ===
tab.id
? 'bg-white/[0.08] opacity-90'
: 'opacity-40 hover:opacity-70 hover:bg-white/[0.03]'}"
onclick={() => (settingsTab = tab.id)}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d={tab.icon} />
{#if tab.extra}
<path stroke-linecap="round" stroke-linejoin="round" d={tab.extra} />
{/if}
</svg>
{tab.label}
</button>
{/each}
</div>
</div>
<div class="flex-1 min-w-0 flex flex-col overflow-hidden">
<!-- Content header -->
<div class="flex items-center justify-between px-8 pt-5 pb-3 border-b border-white/[0.04]">
<span class="text-[15px] opacity-80 font-medium capitalize">{settingsTab}</span>
<button
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#fafafa]"
onclick={onClose}
title="Close settings"
>
<svg
class="w-[14px] h-[14px]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{#if settingsTab}
<div class="flex-1 overflow-y-auto px-8 py-4">
{#if settingsTab === 'general'}
<div class="flex flex-col divide-y divide-white/[0.04]">
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">Default connection</div>
<div class="text-[11px] opacity-25 mt-0.5">Connection used on launch</div>
</div>
<select
class="bg-white/[0.06] text-[12px] text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60"
onchange={(e) => setDefault((e.target as HTMLSelectElement).value)}
>
<option value="">None</option>
{#each $connections as conn}
<option value={conn.id} selected={$config?.defaultConnectionId === conn.id}
>{conn.name}</option
>
{/each}
</select>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">Server port</div>
<div class="text-[11px] opacity-25 mt-0.5">Port for local Open WebUI server</div>
</div>
<input
type="number"
class="bg-white/[0.06] text-[12px] text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60 w-20 text-right"
value={$config?.localServer?.port ?? 8080}
onchange={(e) =>
updateConfig('port', parseInt((e.target as HTMLInputElement).value) || 8080)}
/>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">Serve on local network</div>
<div class="text-[11px] opacity-25 mt-0.5">
Allow other devices on your network to connect
</div>
</div>
<button
class="w-9 h-5 rounded-full transition-colors {$config?.localServer
?.serveOnLocalNetwork
? 'bg-white/30'
: 'bg-white/[0.08]'} border-none relative"
aria-label="Toggle serve on local network"
onclick={() =>
updateConfig('serveOnLocalNetwork', !$config?.localServer?.serveOnLocalNetwork)}
>
<div
class="w-3.5 h-3.5 rounded-full bg-white absolute top-[3px] transition-all {$config
?.localServer?.serveOnLocalNetwork
? 'left-[18px]'
: 'left-[3px]'}"
></div>
</button>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">Auto-update</div>
<div class="text-[11px] opacity-25 mt-0.5">
Automatically update Open WebUI package
</div>
</div>
<button
class="w-9 h-5 rounded-full transition-colors {$config?.localServer?.autoUpdate !==
false
? 'bg-white/30'
: 'bg-white/[0.08]'} border-none relative"
aria-label="Toggle auto-update"
onclick={() =>
updateConfig('autoUpdate', $config?.localServer?.autoUpdate === false)}
>
<div
class="w-3.5 h-3.5 rounded-full bg-white absolute top-[3px] transition-all {$config
?.localServer?.autoUpdate !== false
? 'left-[18px]'
: 'left-[3px]'}"
></div>
</button>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">Launch at login</div>
<div class="text-[11px] opacity-25 mt-0.5">Open app when you log in</div>
</div>
<button
class="w-9 h-5 rounded-full transition-colors {launchAtLogin
? 'bg-white/30'
: 'bg-white/[0.08]'} border-none relative"
aria-label="Toggle launch at login"
onclick={async () => {
launchAtLogin = !launchAtLogin
await window.electronAPI.setLaunchAtLogin(launchAtLogin)
}}
>
<div
class="w-3.5 h-3.5 rounded-full bg-white absolute top-[3px] transition-all {launchAtLogin
? 'left-[18px]'
: 'left-[3px]'}"
></div>
</button>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">Factory reset</div>
<div class="text-[11px] opacity-25 mt-0.5">
Remove Python, packages, data &amp; connections
</div>
</div>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-white/[0.06] transition border-none text-[#fafafa] rounded-xl"
onclick={() => {
if (
confirm(
'This will remove all installed components, data, and connections. The app will restart. Continue?'
)
) {
window.electronAPI.resetApp()
}
}}
>
Reset
</button>
</div>
</div>
{:else if settingsTab === 'connections'}
<div class="flex flex-col divide-y divide-white/[0.04]">
{#each $connections as conn}
<div class="py-3 flex items-center justify-between">
<div class="flex items-center gap-2.5 min-w-0">
<svg
class="w-[14px] h-[14px] shrink-0 opacity-30"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
{#if conn.type === 'local'}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z"
/>
{:else}
<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 3"
/>
{/if}
</svg>
<div class="min-w-0">
<div class="text-[13px] opacity-70 truncate">{conn.name}</div>
<div class="text-[11px] opacity-25 truncate mt-0.5">{conn.url}</div>
</div>
</div>
<button
class="text-[11px] opacity-30 hover:opacity-60 px-2 py-1 bg-transparent transition border-none text-[#fafafa] shrink-0"
onclick={() => remove(conn.id)}
>
Remove
</button>
</div>
{/each}
{#if ($connections ?? []).length === 0}
<div class="py-6 text-[12px] opacity-20 text-center">No connections</div>
{/if}
</div>
{:else if settingsTab === 'about'}
<div class="flex flex-col divide-y divide-white/[0.04]">
<div class="py-4 flex items-center justify-between">
<div class="text-[13px] opacity-70">Version</div>
<div class="text-[12px] opacity-30">{$appInfo?.version ?? 'Unknown'}</div>
</div>
<div class="py-4 flex items-center justify-between">
<div class="text-[13px] opacity-70">Platform</div>
<div class="text-[12px] opacity-30">{$appInfo?.platform ?? 'Unknown'}</div>
</div>
<div class="py-4">
<button
class="text-[12px] opacity-40 hover:opacity-70 transition bg-transparent border-none text-[#fafafa]"
onclick={openGithub}
>
View on GitHub →
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
@@ -0,0 +1,129 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fly, fade } from 'svelte/transition'
import { appState, connections, config } from '../stores'
import LocalInstall from './LocalInstall.svelte'
import logoImage from '../assets/images/splash.png'
let view = $state('main') // main | install
let url = $state('')
let connecting = $state(false)
let error = $state('')
let mounted = $state(false)
let videoElement: HTMLVideoElement
onMount(() => {
setTimeout(() => { mounted = true }, 100)
if (videoElement) {
videoElement.play().catch(() => {})
}
})
const connect = async () => {
if (!url.trim()) return
let u = url.trim()
if (!u.startsWith('http')) u = 'https://' + u
error = ''
connecting = true
try {
const valid = await window.electronAPI.validateUrl(u)
if (!valid) { error = 'Could not reach this server'; connecting = false; return }
await window.electronAPI.addConnection({
id: crypto.randomUUID(),
name: new URL(u).hostname,
type: 'remote',
url: u
})
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
const conns = await window.electronAPI.getConnections()
await window.electronAPI.connectTo(conns[conns.length - 1].id)
appState.set('ready')
} catch {
error = 'Connection failed'
} finally {
connecting = false
}
}
</script>
<div class="h-full w-full relative overflow-hidden bg-[#0a0a0a] text-[#fafafa]">
<!-- Video background -->
<div class="absolute inset-0 overflow-hidden">
<video
bind:this={videoElement}
autoplay
muted
loop
playsinline
class="absolute top-1/2 left-1/2 h-auto min-h-full w-auto min-w-full -translate-x-1/2 -translate-y-1/2 object-cover opacity-30"
>
<source src="https://community.s3.openwebui.com/landing.mp4" type="video/mp4" />
</video>
</div>
<!-- Drag region -->
<div class="absolute top-0 left-0 right-0 h-8 drag-region z-10"></div>
<!-- Content -->
{#if mounted}
<div class="relative z-10 h-full flex flex-col justify-end px-8 pb-10">
{#if view === 'main'}
<div class="max-w-sm" in:fly={{ duration: 500, y: 10 }}>
<div class="mb-2 text-sm font-normal opacity-50">Open WebUI</div>
<h1 class="text-3xl leading-tight font-light tracking-tight mb-6">
New Connection
</h1>
<div class="flex flex-col gap-2.5">
<div class="flex gap-2">
<input
type="text"
bind:value={url}
placeholder="e.g. https://your-server.com"
class="flex-1 px-4 py-2.5 bg-white/[0.06] text-[13px] text-[#fafafa] placeholder:opacity-20 outline-none focus:bg-white/[0.1] transition no-drag border-none"
onkeydown={(e) => e.key === 'Enter' && connect()}
/>
<button
class="inline-flex items-center gap-2 bg-white px-6 py-2.5 text-black text-[13px] transition hover:bg-gray-100 disabled:opacity-30 border-none shrink-0"
onclick={connect}
disabled={connecting || !url.trim()}
>
{connecting ? 'Connecting…' : 'Connect'}
{#if !connecting}
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
{/if}
</button>
</div>
{#if error}
<p class="text-[11px] text-red-400 opacity-80">{error}</p>
{/if}
</div>
<div class="mt-6">
<button
class="text-sm opacity-40 hover:opacity-70 transition bg-transparent border-none text-[#fafafa]"
onclick={() => (view = 'install')}
>
Or install locally →
</button>
</div>
</div>
{:else if view === 'install'}
<div class="max-w-sm">
<LocalInstall
onBack={() => (view = 'main')}
onComplete={() => appState.set('ready')}
/>
</div>
{/if}
</div>
{/if}
</div>
+7
View File
@@ -0,0 +1,7 @@
import { writable } from 'svelte/store'
export const appInfo = writable(null)
export const config = writable(null)
export const connections = writable([])
export const serverInfo = writable(null)
export const appState = writable('loading') // loading | initializing | setup | ready
+1 -1
View File
@@ -1,6 +1,6 @@
import { mount } from 'svelte'
import './assets/main.css'
import './app.css'
import App from './App.svelte'