refac
|
After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 21 KiB |
@@ -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/
|
||||
|
||||
@@ -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()]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
|
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>
|
||||
@@ -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 & 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>
|
||||
@@ -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,6 +1,6 @@
|
||||
import { mount } from 'svelte'
|
||||
|
||||
import './assets/main.css'
|
||||
import './app.css'
|
||||
|
||||
import App from './App.svelte'
|
||||
|
||||
|
||||