This commit is contained in:
Timothy Jaeryang Baek 2025-05-26 20:00:49 +04:00
parent c36ab799ef
commit d48ed8abeb
52 changed files with 2 additions and 15605 deletions

View File

@ -1,16 +0,0 @@
{
"env": {
"browser": true,
"es6": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/electron",
"plugin:import/typescript"
],
"parser": "@typescript-eslint/parser"
}

3
.gitignore vendored
View File

@ -96,4 +96,5 @@ resources/python.tar.gz
.webui_secret_key
.webui_secret_key
_old

View File

@ -1,10 +0,0 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }],
"tabWidth": 4
}

27
LICENSE
View File

@ -1,27 +0,0 @@
Copyright (c) 2025 Timothy Jaeryang Baek
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -1,26 +0,0 @@
# Open WebUI App (Experimental) 🚀
![App Demo](./demo.png)
**Open WebUI App** is the upcoming cross-platform desktop application for [Open WebUI](https://github.com/open-webui/open-webui). It brings the *full-featured Open WebUI experience* directly to your device, effectively transforming it into a powerful server—without the complexities of manual setup.
This project is still in an **experimental phase** and under active development. 🛠️ Expect frequent updates and potential changes as we refine the application.
---
## Features (Planned & Implemented)
- **One-Click Installation (Implemented)**: Quickly and effortlessly install and set up Open WebUI with all its dependencies. This feature is fully functional and ready to make your setup a breeze.
- **Remote Server Integration**: Easily connect to and manage remote Open WebUI instances.
- **Cross-Platform Support**: Compatible with Windows, macOS, and Linux to ensure broad accessibility.
---
## License 📜
This project is licensed under the [BSD-3-Clause License](LICENSE).
---
## Stay Tuned! 🌟
We're actively developing Open WebUI App. Follow [Open WebUI](https://github.com/open-webui/open-webui) for updates, and join the [community on Discord](https://discord.gg/5rJgQTnV4s) to stay involved.
Lets build something amazing together! 💪

BIN
demo.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 946 KiB

56
dev.md
View File

@ -1,56 +0,0 @@
# Developer Documentation
## Prerequisites
To work on this Electron project, you must have the following installed:
- **Conda**: A package, dependency, and environment management tool.
- **conda-pack**: To pack Conda environments into tarballs.
- **conda-lock**: To generate lockfiles for Conda environments.
- **Node.js 22+**: Ensure that your Node.js version is at least 22 (tested with v22.10).
### Installation Notes
- Install **conda-pack** and **conda-lock** globally or ensure they are available in the project's Python environment (e.g., a virtual environment).
- Double-check your tool versions to avoid compatibility issues.
---
## Getting Started
1. **Clone the repository**:
```bash
git clone https://github.com/open-webui/app
cd app
```
2. **Install Node.js dependencies**:
```bash
npm i
```
3. **Generate the Python environment tarball**:
```bash
npm run create:python-tar
```
> Note: This requires **conda-lock** to be installed and properly configured.
4. **Start the development environment**:
```bash
npm run start
```
This will launch the project in development mode.
---
## Building Distributables
To generate production-ready distributables (e.g., installers or app packages), run:
```bash
npm run make
```
This will create the necessary files for distribution in the `out` directory.
---
## Notes
- Ensure **conda**, **conda-pack**, and **conda-lock** are installed and working within your environment (global or virtual).
- Use Node.js **version 22+** to avoid runtime and compatibility issues (verified with v22.10).
- If you encounter issues, examine the project-specific scripts in the `package.json` file for troubleshooting.
- Always review logs carefully if commands produce errors to identify dependencies or configuration steps you might need to address.
Enjoy developing! 🚀

View File

@ -1,40 +0,0 @@
<?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>

View File

@ -1,99 +0,0 @@
import type { ForgeConfig } from '@electron-forge/shared-types';
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
import { MakerDMG } from '@electron-forge/maker-dmg';
import { MakerZIP } from '@electron-forge/maker-zip';
import { MakerDeb } from '@electron-forge/maker-deb';
import { MakerRpm } from '@electron-forge/maker-rpm';
import { VitePlugin } from '@electron-forge/plugin-vite';
import { FusesPlugin } from '@electron-forge/plugin-fuses';
import { FuseV1Options, FuseVersion } from '@electron/fuses';
import os from 'os';
const config: ForgeConfig = {
packagerConfig: {
executableName: 'open-webui',
asar: true,
icon: 'public/assets/icon.png',
extraResource: ['public/assets', 'resources'],
osxSign: {
optionsForFile: (filePath) => {
return {
entitlements: 'entitlements.plist'
};
}
}
// osxNotarize: {
// appleId: process.env.APPLE_ID,
// appleIdPassword: process.env.APPLE_PASSWORD,
// teamId: process.env.APPLE_TEAM_ID
// }
},
rebuildConfig: {},
makers: [
new MakerSquirrel({}),
// new MakerZIP({}, ['darwin']),
new MakerDMG(
// @ts-expect-error Incorrect TS typings (https://github.com/electron/forge/issues/3712)
{
icon: 'public/assets/icon.icns',
background: 'public/assets/dmg-background.png',
format: 'ULFO',
contents: [
{
x: 225,
y: 250,
type: 'file',
path: `${process.cwd()}/out/Open WebUI-darwin-${os.arch()}/Open WebUI.app`
},
{
x: 400,
y: 240,
type: 'link',
path: '/Applications'
}
]
}
),
new MakerRpm({}),
new MakerDeb({})
],
plugins: [
new VitePlugin({
// `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc.
// If you are familiar with Vite configuration, it will look really familiar.
build: [
{
// `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`.
entry: 'src/main.ts',
config: 'vite.main.config.ts',
target: 'main'
},
{
entry: 'src/preload.ts',
config: 'vite.preload.config.ts',
target: 'preload'
}
],
renderer: [
{
name: 'main_window',
config: 'vite.renderer.config.mts'
}
]
}),
// Fuses are used to enable/disable various Electron functionality
// at package time, before code signing the application
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true
})
]
};
export default config;

1
forge.env.d.ts vendored
View File

@ -1 +0,0 @@
/// <reference types="@electron-forge/plugin-vite/forge-vite-env" />

View File

@ -1,12 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Open WebUI</title>
<link rel="preload" href="/assets/fonts/InstrumentSerif-Regular.ttf" as="font" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/renderer.ts"></script>
</body>
</html>

12171
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,61 +0,0 @@
{
"name": "open-webui",
"productName": "Open WebUI",
"version": "0.0.1",
"description": "Open WebUI",
"main": ".vite/build/main.js",
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish",
"lint": "eslint --ext .ts,.tsx .",
"format": "prettier --plugin-search-dir --write \"**/*.{js,ts,svelte,css,md,html,json}\"",
"create:conda-lock": "cd resources && rimraf conda-lock.yml && conda-lock -f environment.yml && cd -",
"create:python-tar": "rimraf ./resources/python.tar.gz && conda-lock install --prefix ./resources/python ./resources/conda-lock.yml && conda pack -p ./resources/python -o ./resources/python.tar.gz && rimraf ./resources/python"
},
"devDependencies": {
"@electron-forge/cli": "^7.6.0",
"@electron-forge/maker-deb": "^7.6.0",
"@electron-forge/maker-dmg": "^7.6.0",
"@electron-forge/maker-rpm": "^7.6.0",
"@electron-forge/maker-squirrel": "^7.6.0",
"@electron-forge/maker-zip": "^7.6.0",
"@electron-forge/plugin-auto-unpack-natives": "^7.6.0",
"@electron-forge/plugin-fuses": "^7.6.0",
"@electron-forge/plugin-vite": "^7.6.0",
"@electron/fuses": "^1.8.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.0.0-beta.8",
"@tsconfig/svelte": "^5.0.4",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"dompurify": "^3.2.3",
"electron": "33.3.1",
"eslint": "^8.57.1",
"eslint-plugin-import": "^2.31.0",
"marked": "^15.0.6",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"svelte": "^5.17.1",
"svelte-check": "^4.1.3",
"svelte-sonner": "^0.3.28",
"tailwindcss": "^4.0.0-beta.8",
"tippy.js": "^6.3.7",
"ts-node": "^10.9.2",
"typescript": "^5.7.3",
"vite": "^6.0.7"
},
"keywords": [],
"author": {
"name": "Timothy Jaeryang Baek",
"email": "tim@openwebui.com"
},
"license": "MIT",
"dependencies": {
"electron-log": "^5.2.4",
"electron-squirrel-startup": "^1.0.1",
"tar": "^7.4.3",
"update-electron-app": "^3.1.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +0,0 @@
# environment.yml
channels:
- conda-forge
dependencies:
- python=3.11
- pip
platforms:
- linux-64
- linux-aarch64 # aka arm64, use for Docker on Apple Silicon
- osx-64
- osx-arm64 # For Apple Silicon, e.g. M1/M2
- win-64
# TODO: Add win-arm64 when available

View File

@ -1,441 +0,0 @@
import {
app,
nativeImage,
desktopCapturer,
session,
clipboard,
shell,
Tray,
Menu,
MenuItem,
BrowserWindow,
globalShortcut,
Notification,
ipcMain,
ipcRenderer
} from 'electron';
import path from 'path';
import started from 'electron-squirrel-startup';
import {
installPackage,
removePackage,
logEmitter,
startServer,
stopAllServers,
validateInstallation
} from './utils';
// Restrict app to a single instance
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit(); // Quit if another instance is already running
} else {
// Handle second-instance logic
app.on('second-instance', (event, argv, workingDirectory) => {
// This event happens if a second instance is launched
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore(); // Restore if minimized
mainWindow.show(); // Show existing window
mainWindow.focus(); // Focus the existing window
}
});
// Handle creating/removing shortcuts on Windows during installation/uninstallation
if (started) {
app.quit();
}
app.setAboutPanelOptions({
applicationName: 'Open WebUI',
iconPath: path.join(__dirname, 'assets/icon.png'),
applicationVersion: app.getVersion(),
version: app.getVersion(),
website: 'https://openwebui.com',
copyright: `© ${new Date().getFullYear()} Open WebUI (Timothy Jaeryang Baek)`
});
// Main application logic
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let SERVER_URL = null;
let SERVER_STATUS = 'stopped';
logEmitter.on('log', (message) => {
mainWindow?.webContents.send('main:log', message);
});
const loadDefaultView = () => {
// Load index.html or dev server URL
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
} else {
mainWindow.loadFile(
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`)
);
}
};
const updateTrayMenu = (status: string, url: string | null) => {
const trayMenuTemplate = [
{
label: 'Show Open WebUI',
accelerator: 'CommandOrControl+Alt+O',
click: () => {
mainWindow?.show(); // Show the main window when clicked
}
},
{
type: 'separator'
},
{
label: status, // Dynamic status message
enabled: !!url,
click: () => {
if (url) {
shell.openExternal(url); // Open the URL in the default browser
}
}
},
...(SERVER_STATUS === 'started'
? [
{
label: 'Stop Server',
click: async () => {
await stopAllServers();
SERVER_STATUS = 'stopped';
mainWindow.webContents.send('main:data', {
type: 'server:status',
data: SERVER_STATUS
});
updateTrayMenu('Open WebUI: Stopped', null); // Update tray menu with stopped status
}
}
]
: SERVER_STATUS === 'starting'
? [
{
label: 'Starting Server...',
enabled: false
}
]
: [
{
label: 'Start Server',
click: async () => {
await startServerHandler();
}
}
]),
{
type: 'separator'
},
{
label: 'Copy Server URL',
enabled: !!url, // Enable if URL exists
click: () => {
if (url) {
clipboard.writeText(url); // Copy the URL to clipboard
}
}
},
{
type: 'separator'
},
{
label: 'Quit Open WebUI',
accelerator: 'CommandOrControl+Q',
click: () => {
app.isQuiting = true; // Mark as quitting
app.quit(); // Quit the application
}
}
];
const trayMenu = Menu.buildFromTemplate(trayMenuTemplate);
tray?.setContextMenu(trayMenu);
};
const startServerHandler = async () => {
SERVER_STATUS = 'starting';
mainWindow.webContents.send('main:data', {
type: 'server:status',
data: SERVER_STATUS
});
updateTrayMenu('Open WebUI: Starting...', null);
try {
SERVER_URL = await startServer();
// SERVER_URL = 'http://localhost:5050';
SERVER_STATUS = 'started';
mainWindow.webContents.send('main:data', {
type: 'server:status',
data: SERVER_STATUS
});
if (SERVER_URL.startsWith('http://0.0.0.0')) {
SERVER_URL = SERVER_URL.replace('http://0.0.0.0', 'http://localhost');
}
mainWindow.loadURL(SERVER_URL);
mainWindow;
const urlObj = new URL(SERVER_URL);
const port = urlObj.port || '8080'; // Fallback to port 8080 if not provided
updateTrayMenu(`Open WebUI: Running on port ${port}`, SERVER_URL); // Update tray menu with running status
} catch (error) {
console.error('Failed to start server:', error);
SERVER_STATUS = 'failed';
mainWindow.webContents.send('main:data', {
type: 'server:status',
data: SERVER_STATUS
});
mainWindow.webContents.send('main:log', `Failed to start server: ${error}`);
updateTrayMenu('Open WebUI: Failed to Start', null); // Update tray menu with failure status
}
};
const onReady = async () => {
console.log(process.resourcesPath);
console.log(app.getName());
console.log(app.getPath('userData'));
console.log(app.getPath('appData'));
mainWindow = new BrowserWindow({
width: 1000,
height: 600,
minWidth: 425,
minHeight: 600,
icon: path.join(__dirname, 'assets/icon.png'),
webPreferences: {
preload: path.join(__dirname, 'preload.js')
},
...(process.platform === 'win32'
? {
frame: false
}
: {}),
titleBarStyle: process.platform === 'win32' ? 'default' : 'hidden',
trafficLightPosition: { x: 10, y: 10 },
// expose window controlls in Windows/Linux
...(process.platform !== 'darwin' ? { titleBarOverlay: true } : {})
});
mainWindow.setIcon(path.join(__dirname, 'assets/icon.png'));
// Enables navigator.mediaDevices.getUserMedia API. See https://www.electronjs.org/docs/latest/api/desktop-capturer
session.defaultSession.setDisplayMediaRequestHandler(
(request, callback) => {
desktopCapturer.getSources({ types: ['screen'] }).then((sources) => {
// Grant access to the first screen found.
callback({ video: sources[0], audio: 'loopback' });
});
},
{ useSystemPicker: true }
);
loadDefaultView();
if (!app.isPackaged) {
mainWindow.webContents.openDevTools();
}
// Wait for the renderer to finish loading
mainWindow.webContents.once('did-finish-load', async () => {
console.log('Renderer finished loading');
// Check installation and start the server
if (await validateInstallation()) {
mainWindow.webContents.send('main:data', {
type: 'install:status',
data: true
});
} else {
mainWindow.webContents.send('main:data', {
type: 'install:status',
data: false
});
}
});
globalShortcut.register('Alt+CommandOrControl+O', () => {
mainWindow?.show();
if (mainWindow?.isMinimized()) mainWindow?.restore();
mainWindow?.focus();
});
const defaultMenu = Menu.getApplicationMenu();
let menuTemplate = defaultMenu ? defaultMenu.items.map((item) => item) : [];
menuTemplate.push({
label: 'Action',
submenu: [
// {
// label: 'Home',
// accelerator: process.platform === 'darwin' ? 'Cmd+H' : 'Ctrl+H',
// click: () => {
// loadDefaultView();
// }
// },
{
label: 'Uninstall',
click: () => {
loadDefaultView();
removePackage();
}
}
]
});
const updatedMenu = Menu.buildFromTemplate(menuTemplate);
Menu.setApplicationMenu(updatedMenu);
// Create a system tray icon
const image = nativeImage.createFromPath(path.join(__dirname, 'assets/tray.png'));
tray = new Tray(image.resize({ width: 16, height: 16 }));
const trayMenu = Menu.buildFromTemplate([
{
label: 'Show Open WebUI',
accelerator: 'CommandOrControl+Alt+O',
click: () => {
mainWindow.show(); // Show the main window when clicked
}
},
{
type: 'separator'
},
{
label: 'Quit Open WebUI',
accelerator: 'CommandOrControl+Q',
click: async () => {
await stopAllServers();
app.isQuiting = true; // Mark as quitting
app.quit(); // Quit the application
}
}
]);
tray.setToolTip('Open WebUI');
tray.setContextMenu(trayMenu);
// Handle the close event
mainWindow.on('close', (event) => {
if (!app.isQuiting) {
event.preventDefault(); // Prevent the default close behavior
mainWindow.hide(); // Hide the window instead of closing it
}
});
};
ipcMain.handle('install', async (event) => {
console.log('Installing package...');
try {
const res = await installPackage();
if (res) {
mainWindow.webContents.send('main:data', {
type: 'install:status',
data: true
});
await startServerHandler();
}
} catch (error) {
mainWindow.webContents.send('main:data', {
type: 'install:status',
data: false
});
}
});
ipcMain.handle('install:status', async (event) => {
return await validateInstallation();
});
ipcMain.handle('remove', async (event) => {
console.log('Resetting package...');
removePackage();
});
ipcMain.handle('server:status', async (event) => {
return SERVER_STATUS;
});
ipcMain.handle('server:start', async (event) => {
console.log('Starting server...');
await startServerHandler();
});
ipcMain.handle('server:stop', async (event) => {
console.log('Stopping server...');
await stopAllServers();
SERVER_STATUS = 'stopped';
mainWindow.webContents.send('main:data', {
type: 'server:status',
data: SERVER_STATUS
});
updateTrayMenu('Open WebUI: Stopped', null); // Update tray menu with stopped status
});
ipcMain.handle('server:url', async (event) => {
return SERVER_URL;
});
ipcMain.handle('renderer:data', async (event, { type, data }) => {
console.log('Received data from renderer:', type, data);
if (type === 'info') {
return {
platform: process.platform,
version: app.getVersion()
};
}
if (type === 'window:isFocused') {
return {
isFocused: mainWindow?.isFocused()
};
}
return { type, data };
});
ipcMain.handle('notification', async (event, { title, body }) => {
console.log('Received notification:', title, body);
const notification = new Notification({
title: title,
body: body
});
notification.show();
});
app.on('before-quit', async () => {
await stopAllServers();
app.isQuiting = true; // Ensure quit flag is set
});
// Quit when all windows are closed, except on macOS
app.on('window-all-closed', async () => {
if (process.platform !== 'darwin') {
await stopAllServers();
app.isQuitting = true;
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
onReady();
} else {
mainWindow?.show();
}
});
app.on('ready', onReady);
}

View File

@ -1,94 +0,0 @@
import { ipcRenderer, contextBridge } from 'electron';
const isLocalSource = () => {
// Check if the execution environment is local
const origin = window.location.origin;
// Allow local sources: file protocol, localhost, or 0.0.0.0
return (
origin.startsWith('file://') ||
origin.includes('localhost') ||
origin.includes('127.0.0.1') ||
origin.includes('0.0.0.0')
);
};
window.addEventListener('DOMContentLoaded', () => {
// Listen for messages from the main process
ipcRenderer.on('main:data', (event, data) => {
// Forward the message to the renderer using window.postMessage
window.postMessage(
{
...data,
type: `electron:${data.type}`
},
window.location.origin
);
});
});
contextBridge.exposeInMainWorld('electronAPI', {
onLog: (callback: (message: string) => void) => {
if (!isLocalSource()) {
throw new Error('Access restricted: This operation is only allowed in a local environment.');
}
ipcRenderer.on('main:log', (_, message: string) => callback(message));
},
send: async ({ type, data }: { type: string; data?: any }) => {
return await ipcRenderer.invoke('renderer:data', { type, data });
},
installPackage: async () => {
if (!isLocalSource()) {
throw new Error('Access restricted: This operation is only allowed in a local environment.');
}
await ipcRenderer.invoke('install');
},
getInstallStatus: async () => {
return await ipcRenderer.invoke('install:status');
},
removePackage: async () => {
if (!isLocalSource()) {
throw new Error('Access restricted: This operation is only allowed in a local environment.');
}
await ipcRenderer.invoke('remove');
},
getServerStatus: async () => {
if (!isLocalSource()) {
throw new Error('Access restricted: This operation is only allowed in a local environment.');
}
return await ipcRenderer.invoke('server:status');
},
startServer: async () => {
if (!isLocalSource()) {
throw new Error('Access restricted: This operation is only allowed in a local environment.');
}
await ipcRenderer.invoke('server:start');
},
stopServer: async () => {
if (!isLocalSource()) {
throw new Error('Access restricted: This operation is only allowed in a local environment.');
}
await ipcRenderer.invoke('server:stop');
},
getServerUrl: async () => {
return await ipcRenderer.invoke('server:url');
},
notification: async (title: string, body: string) => {
await ipcRenderer.invoke('notification', { title, body });
}
});

View File

@ -1,71 +0,0 @@
<script lang="ts">
import { Toaster, toast } from 'svelte-sonner';
import { onMount } from 'svelte';
import { installStatus, serverStatus, serverStartedAt, serverLogs } from './lib/stores';
import Main from './lib/components/Main.svelte';
let logs = [];
onMount(async () => {
window.addEventListener('message', (event) => {
// Ensure the message is coming from a trusted origin
if (event.origin !== window.location.origin) {
console.warn('Received message from untrusted origin:', event.origin);
return;
}
// Check the type of the message
if (event.data && event.data.type && event.data.type.startsWith('electron:')) {
console.log('Received message:', event.data);
// Perform actions based on the `type` or the `data`
switch (event.data.type) {
case 'electron:install:status':
console.log('Install status:', event.data.data);
installStatus.set(event.data.data);
break;
case 'electron:server:status':
console.log('Server status:', event.data.data);
serverStatus.set(event.data.data);
if ($serverStatus) {
serverStartedAt.set(Date.now());
}
break;
default:
console.warn('Unhandled message type:', event.data.type);
}
}
});
if (window.electronAPI) {
installStatus.set(await window.electronAPI.getInstallStatus());
serverStatus.set(await window.electronAPI.getServerStatus());
if ($installStatus && $serverStatus === 'stopped') {
window.electronAPI.startServer();
}
window.electronAPI.onLog((log) => {
console.log('Electron log:', log);
logs.push(log);
serverLogs.set(logs);
});
}
});
</script>
<main class="w-screen h-screen bg-gray-900">
<Main />
</main>
<Toaster
theme={window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'}
richColors
position="top-center"
/>

View File

@ -1,88 +0,0 @@
@import 'tailwindcss';
@font-face {
font-family: 'Archivo';
src: url('/assets/fonts/Archivo-Variable.ttf');
font-display: swap;
}
@font-face {
font-family: 'InstrumentSerif';
src: url('/assets/fonts/InstrumentSerif-Regular.ttf');
font-display: swap;
}
.drag-region {
-webkit-app-region: drag;
}
.drag-region a,
.drag-region button {
-webkit-app-region: no-drag;
}
.no-drag-region {
-webkit-app-region: no-drag;
}
.font-secondary {
font-family: 'InstrumentSerif', sans-serif;
}
.font-system {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
'Noto Sans',
sans-serif,
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol',
'Noto Color Emoji';
}
html {
font-family: 'Archivo';
}
@theme {
--color-*: initial;
--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;
}
.tippy-box[data-theme~='dark'] {
@apply rounded-lg bg-gray-950 text-xs border border-gray-900 shadow-xl;
}
.scrollbar-hidden:active::-webkit-scrollbar-thumb,
.scrollbar-hidden:focus::-webkit-scrollbar-thumb,
.scrollbar-hidden:hover::-webkit-scrollbar-thumb {
visibility: visible;
}
.scrollbar-hidden::-webkit-scrollbar-thumb {
visibility: hidden;
}
.scrollbar-hidden::-webkit-scrollbar-corner {
display: none;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -1,196 +0,0 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { fly } from 'svelte/transition';
import { installStatus, serverStatus, serverStartedAt, serverLogs } from '../stores';
import Logs from './setup/Logs.svelte';
import Spinner from './common/Spinner.svelte';
import ArrowRightCircle from './icons/ArrowRightCircle.svelte';
import backgroundImage from '../assets/images/green.jpg';
let mounted = false;
let currentTime = Date.now();
let showLogs = false;
let installing = false;
const continueHandler = async () => {
if (window?.electronAPI) {
window.electronAPI.installPackage();
installing = true;
}
};
onMount(() => {
installStatus.subscribe(async (value) => {
if (value !== null) {
await tick();
mounted = true;
}
});
const interval = setInterval(() => {
currentTime = Date.now();
}, 1000); // Update every second
return () => {
clearInterval(interval); // Cleanup interval on destroy
};
});
</script>
{#if $installStatus === null}
<div class="flex flex-row w-full h-full relative text-gray-850 dark:text-gray-100 drag-region">
<div class="flex-1 w-full flex justify-center relative">
<div class="m-auto">
<img
src="./assets/images/splash.png"
class="size-18 rounded-full dark:invert"
alt="logo"
/>
</div>
</div>
</div>
{:else}
<div class="flex flex-row w-full h-full relative text-gray-850 dark:text-gray-100 p-1">
<div class="fixed right-0 m-10 z-50">
<div class="flex space-x-2">
<button
class=" self-center cursor-pointer outline-none"
onclick={() => (showLogs = !showLogs)}
>
<img
src="./assets/images/splash.png"
class=" w-6 rounded-full dark:invert"
alt="logo"
/>
</button>
</div>
</div>
<div
class="image w-full h-full absolute top-0 left-0 bg-cover bg-center transition-opacity duration-1000"
style="opacity: 1; background-image: url({backgroundImage})"
></div>
<div
class="w-full h-full absolute top-0 left-0 bg-gradient-to-t from-20% from-white dark:from-black to-transparent"
></div>
<div
class="w-full h-full absolute top-0 left-0 backdrop-blur-sm bg-white/50 dark:bg-black/50"
></div>
<div class=" absolute w-full top-0 left-0 right-0 z-10">
<div class="h-6 drag-region"></div>
</div>
<div class="flex-1 w-full flex justify-center relative">
{#if $installStatus === false}
<div class="m-auto flex flex-col justify-center text-center max-w-2xl w-full">
{#if mounted}
<div
class=" font-medium text-5xl xl:text-7xl text-center mb-4 xl:mb-5 font-secondary"
in:fly={{ duration: 750, y: 20 }}
>
Open WebUI
</div>
<div
class=" text-sm xl:text-lg text-center mb-3"
in:fly={{ delay: 250, duration: 750, y: 10 }}
>
To install Open WebUI, click Continue.
</div>
{/if}
<Logs show={showLogs} logs={$serverLogs} />
</div>
<div class="absolute bottom-0 pb-10">
<div class="flex justify-center mt-8">
<div class="flex flex-col justify-center items-center">
{#if installing}
<div class="flex flex-col gap-3 text-center">
<Spinner className="size-5" />
<div class=" font-secondary xl:text-lg -mt-0.5">
Installing...
</div>
<div
class=" font-default text-xs"
in:fly={{ delay: 100, duration: 500, y: 10 }}
>
This might take a few minutes, Well notify you when its
ready.
</div>
{#if $serverLogs.length > 0}
<div
class="text-[0.5rem] text-gray-500 font-mono text-center line-clamp-1 px-10"
>
{$serverLogs.at(-1)}
</div>
{/if}
</div>
{:else if mounted}
<button
class="relative z-20 flex p-1 rounded-full bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 transition font-medium text-sm cursor-pointer"
onclick={() => {
continueHandler();
}}
in:fly={{ delay: 500, duration: 750, y: 10 }}
>
<ArrowRightCircle className="size-6" />
</button>
<div
class="mt-1.5 font-primary text-base font-medium"
in:fly={{ delay: 500, duration: 750, y: 10 }}
>
{`Continue`}
</div>
<button
class="text-xs mt-3 text-gray-500 cursor-pointer"
in:fly={{ delay: 500, duration: 750, y: 10 }}
onclick={() => {
console.log('hi');
}}
>
To connect to an existing server, click here.
</button>
{/if}
</div>
</div>
</div>
{:else if $installStatus === true}
<div class="flex-1 w-full flex justify-center relative">
<div class="m-auto max-w-2xl w-full">
<div class="flex flex-col gap-3 text-center">
<Spinner className="size-5" />
<div class=" font-secondary xl:text-lg">Launching Open WebUI...</div>
{#if $serverStartedAt}
{#if currentTime - $serverStartedAt > 10000}
<div
class=" font-default text-xs"
in:fly={{ duration: 500, y: 10 }}
>
If it's your first time, it might take a few minutes to
start.
</div>
{/if}
{/if}
<Logs show={showLogs} logs={$serverLogs} />
</div>
</div>
</div>
{/if}
</div>
</div>
{/if}

View File

@ -1,39 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
export let imageUrls = [
'./assets/images/adam.jpg',
'./assets/images/galaxy.jpg',
'./assets/images/earth.jpg',
'./assets/images/space.jpg'
];
export let duration = 5000;
let selectedImageIdx = 0;
onMount(() => {
setInterval(() => {
selectedImageIdx = (selectedImageIdx + 1) % (imageUrls.length - 1);
}, duration);
});
</script>
{#each imageUrls as imageUrl, idx (idx)}
<div
class="image w-full h-full absolute top-0 left-0 bg-cover bg-center transition-opacity duration-1000"
style="opacity: {selectedImageIdx === idx ? 1 : 0}; background-image: url('{imageUrl}')"
></div>
{/each}
<style>
.image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center; /* Center the background images */
transition: opacity 1s ease-in-out; /* Smooth fade effect */
opacity: 0; /* Make images initially not visible */
}
</style>

View File

@ -1,29 +0,0 @@
<script lang="ts">
export let className: string = 'size-5';
</script>
<div class="flex justify-center text-center">
<svg
class={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div>

View File

@ -1,52 +0,0 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { onDestroy } from 'svelte';
import { marked } from 'marked';
import tippy from 'tippy.js';
import { roundArrow } from 'tippy.js';
export let placement = 'top';
export let content = `I'm a tooltip!`;
export let touch = true;
export let className = 'flex';
export let theme = '';
export let offset = [0, 4];
export let allowHTML = true;
export let tippyOptions = {};
let tooltipElement;
let tooltipInstance;
$: if (tooltipElement && content) {
if (tooltipInstance) {
tooltipInstance.setContent(DOMPurify.sanitize(content));
} else {
tooltipInstance = tippy(tooltipElement, {
content: DOMPurify.sanitize(content),
placement: placement,
allowHTML: allowHTML,
touch: touch,
...(theme !== '' ? { theme } : { theme: 'dark' }),
arrow: false,
offset: offset,
...tippyOptions
});
}
} else if (tooltipInstance && content === '') {
if (tooltipInstance) {
tooltipInstance.destroy();
}
}
onDestroy(() => {
if (tooltipInstance) {
tooltipInstance.destroy();
}
});
</script>
<div bind:this={tooltipElement} aria-label={DOMPurify.sanitize(content)} class={className}>
<slot />
</div>

View File

@ -1,19 +0,0 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m12.75 15 3-3m0 0-3-3m3 3h-7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>

View File

@ -1,15 +0,0 @@
<script lang="ts">
export let className = 'w-4 h-4';
export let strokeWidth = '2';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>

View File

@ -1,53 +0,0 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import Tooltip from '../common/Tooltip.svelte';
import { copyToClipboard } from '../../utils';
export let show;
export let logs = [];
</script>
{#if show}
<div class="relative max-w-full w-full px-3">
{#if logs.length > 0}
<div class="absolute top-0 right-0 p-1 bg-transparent text-xs font-mono">
<Tooltip content="Copy">
<button
class="text-xs cursor-pointer"
type="button"
on:click={async () => {
await copyToClipboard(logs.join('\n'));
toast.success('Logs copied to clipboard');
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
</button>
</Tooltip>
</div>
{/if}
<div
class="text-xs font-mono text-left max-h-40 overflow-auto max-w-full w-full flex flex-col-reverse scrollbar-hidden no-drag-region"
>
{#each logs.reverse() as log, idx}
<div class="text-xs font-mono whitespace-pre-wrap text-wrap max-w-full w-full">
{log}
</div>
{/each}
</div>
</div>
{/if}

View File

@ -1,8 +0,0 @@
import { writable } from 'svelte/store';
export const installStatus = writable(null);
export const serverStatus = writable(null);
export const serverStartedAt = writable(null);
export const serverLogs = writable([]);

View File

@ -1,43 +0,0 @@
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const copyToClipboard = async (text) => {
let result = false;
if (!navigator.clipboard) {
const textArea = document.createElement('textarea');
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = '0';
textArea.style.left = '0';
textArea.style.position = 'fixed';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
const msg = successful ? 'successful' : 'unsuccessful';
console.log('Fallback: Copying text command was ' + msg);
result = true;
} catch (err) {
console.error('Fallback: Oops, unable to copy', err);
}
document.body.removeChild(textArea);
return result;
}
result = await navigator.clipboard
.writeText(text)
.then(() => {
console.log('Async: Copying to clipboard was successful!');
return true;
})
.catch((error) => {
console.error('Async: Could not copy text: ', error);
return false;
});
return result;
};

View File

@ -1,11 +0,0 @@
import { mount } from 'svelte';
import './render/app.css';
import 'tippy.js/dist/tippy.css';
import App from './render/App.svelte';
const app = mount(App, {
target: document.getElementById('app')
});
export default app;

View File

View File

@ -1,560 +0,0 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import net from 'net';
import crypto from 'crypto';
import {
exec,
execFile,
ExecFileOptions,
execFileSync,
execSync,
spawn,
ChildProcess
} from 'child_process';
import { EventEmitter } from 'events';
import * as tar from 'tar';
import log from 'electron-log';
import { app } from 'electron';
// Create and export a global event emitter specifically for logs
export const logEmitter = new EventEmitter();
////////////////////////////////////////////////
//
// General Utils
//
////////////////////////////////////////////////
export function getAppPath(): string {
let appPath = app.getAppPath();
if (app.isPackaged) {
appPath = path.dirname(appPath);
}
return path.normalize(appPath);
}
export function getUserHomePath(): string {
return path.normalize(app.getPath('home'));
}
export function 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 function 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 function 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;
}
export async function portInUse(port: number, host: string = '0.0.0.0'): Promise<boolean> {
return new Promise((resolve) => {
const client = new net.Socket();
// Attempt to connect to the port
client
.setTimeout(1000) // Timeout for the connection attempt
.once('connect', () => {
// If connection succeeds, port is in use
client.destroy();
resolve(true);
})
.once('timeout', () => {
// If no connection after the timeout, port is not in use
client.destroy();
resolve(false);
})
.once('error', (err: any) => {
if (err.code === 'ECONNREFUSED') {
// Port is not in use or no listener is accepting connections
resolve(false);
} else {
// Unexpected error
resolve(false);
}
})
.connect(port, host);
});
}
////////////////////////////////////////////////
//
// Python Utils
//
////////////////////////////////////////////////
export function getBundledPythonTarPath(): string {
const appPath = getAppPath();
return path.normalize(path.join(appPath, 'resources', 'python.tar.gz'));
}
export function getBundledPythonInstallationPath(): 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);
}
export function isCondaEnv(envPath: string): boolean {
return fs.existsSync(path.join(envPath, 'conda-meta'));
}
export function getPythonPath(envPath: string, isConda?: boolean) {
if (process.platform === 'win32') {
return path.normalize(
(isConda ?? isCondaEnv(envPath))
? path.join(envPath, 'python.exe')
: path.join(envPath, 'Scripts', 'python.exe')
);
} else {
return path.normalize(path.join(envPath, 'bin', 'python'));
}
}
export function getBundledPythonPath() {
return path.normalize(getPythonPath(getBundledPythonInstallationPath()));
}
export function isBundledPythonInstalled() {
return fs.existsSync(getBundledPythonPath());
}
////////////////////////////////////////////////
//
// Fixes code-signing issues in macOS by applying ad-hoc signatures to extracted environment files.
//
// Unpacking a Conda environment on macOS may break the signatures of binaries, causing macOS
// Gatekeeper to block them. This script assigns an ad-hoc signature (`-s -`), making the binaries
// executable while bypassing macOS's strict validation without requiring trusted certificates.
//
// It reads an architecture-specific file (`sign-osx-arm64.txt` or `sign-osx-64.txt`), which lists
// files requiring re-signing, and generates a `codesign` command to fix them all within the `envPath`.
//
////////////////////////////////////////////////
export function createAdHocSignCommand(envPath: string): string {
const appPath = getAppPath();
const signListFile = path.join(
appPath,
'resources',
`sign-osx-${process.arch === 'arm64' ? 'arm64' : '64'}.txt`
);
const fileContents = fs.readFileSync(signListFile, 'utf-8');
const signList: string[] = [];
fileContents.split(/\r?\n/).forEach((line) => {
if (line) {
signList.push(`"${line}"`);
}
});
// sign all binaries with ad-hoc signature
return `cd ${envPath} && codesign -s - -o 0x2 -f ${signList.join(' ')} && cd -`;
}
export async function installOpenWebUI(
installationPath: string,
version?: string
): Promise<boolean> {
console.log(installationPath);
// Build the appropriate unpack command based on the platform
let unpackCommand =
process.platform === 'win32'
? `"${installationPath}\\Scripts\\activate.bat" && pip install open-webui${version ? `==${version}` : ' -U'}`
: `source "${installationPath}/bin/activate" && pip install open-webui${version ? `==${version}` : ' -U'}`;
// only unsign when installing from bundled installer
// if (platform === "darwin") {
// unpackCommand = `${createAdHocSignCommand(installationPath)}\n${unpackCommand}`;
// }
// Wrap the logic in a Promise to properly handle async execution and return a boolean
return new Promise((resolve, reject) => {
const commandProcess = exec(unpackCommand, {
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash'
});
// Function to handle logging output
const onLog = (data: any) => {
console.log(data);
logEmitter.emit('log', data);
};
// Listen to stdout and stderr for logging
commandProcess.stdout?.on('data', onLog);
commandProcess.stderr?.on('data', onLog);
// Handle the exit event
commandProcess.on('exit', (code) => {
console.log(`Child exited with code ${code}`);
logEmitter.emit('log', `Child exited with code ${code}`);
if (code !== 0) {
log.error(`Failed to install open-webui: ${code}`);
logEmitter.emit('log', `Failed to install open-webui: ${code}`);
resolve(false); // Resolve the Promise with `false` if the command fails
} else {
logEmitter.emit('log', 'open-webui installed successfully');
resolve(true); // Resolve the Promise with `true` if the command succeeds
}
});
// Handle errors during execution
commandProcess.on('error', (error) => {
log.error(`Error occurred while installing open-webui: ${error.message}`);
logEmitter.emit('log', `Error occurred while installing open-webui: ${error.message}`);
reject(error); // Reject the Promise if an unexpected error occurs
});
});
}
export async function installBundledPython(installationPath?: string): Promise<boolean> {
installationPath = installationPath || getBundledPythonInstallationPath();
const pythonTarPath = getBundledPythonTarPath();
console.log(installationPath, pythonTarPath);
logEmitter.emit('log', `Installing bundled Python to: ${installationPath}`); // Emit log
logEmitter.emit('log', `Python tarball path: ${pythonTarPath}`); // Emit log
if (!fs.existsSync(pythonTarPath)) {
log.error('Python tarball not found');
logEmitter.emit('log', 'Python tarball not found'); // Emit log
return false;
}
try {
fs.mkdirSync(installationPath, { recursive: true });
await tar.x({
cwd: installationPath,
file: pythonTarPath
});
} catch (error) {
log.error(error);
logEmitter.emit('log', error); // Emit log
return false; // Return false to indicate failure
}
// Get the path to the installed Python binary
const bundledPythonPath = getBundledPythonPath();
if (!fs.existsSync(bundledPythonPath)) {
log.error('Python binary not found in install path');
logEmitter.emit('log', 'Python binary not found in install path'); // Emit log
return false; // Return false to indicate failure
}
try {
// Execute the Python binary to print the version
const pythonVersion = execFileSync(bundledPythonPath, ['--version'], {
encoding: 'utf-8'
});
console.log('Installed Python Version:', pythonVersion.trim());
logEmitter.emit('log', `Installed Python Version: ${pythonVersion.trim()}`); // Emit log
return true; // Return true to indicate success
} catch (error) {
log.error('Failed to execute Python binary', error);
return false; // Return false to indicate failure
}
}
export async function installPackage(installationPath?: string): Promise<boolean> {
// Resolve the installation path or use the default bundled Python installation path
installationPath = installationPath || getBundledPythonInstallationPath();
// if (!isBundledPythonInstalled()) {
// try {
// await installBundledPython(installationPath);
// } catch (error) {
// log.error("Failed to install bundled Python", error);
// return Promise.reject("Failed to install bundled Python");
// }
// }
// Log the status for installation steps
console.log('Installing Python...');
try {
// Install the bundled Python
const res = await installBundledPython(installationPath);
if (!res) {
throw new Error('Failed to install bundled Python');
}
} catch (error) {
throw new Error('Failed to install bundled Python');
}
console.log('Installing open-webui...');
try {
// Install the Open-WebUI package
const success = await installOpenWebUI(installationPath);
if (!success) {
// Handle a scenario where `installOpenWebUI` returns `false`
log.error('Failed to install open-webui');
throw new Error('Failed to install open-webui');
}
} catch (error) {
// Log and throw an error if the Open-WebUI installation fails
log.error('Failed to install open-webui', error);
throw new Error('Failed to install open-webui');
}
// Return true if all installations are successful
return true;
}
export async function removePackage(installationPath?: string) {
await stopAllServers();
installationPath = installationPath || getBundledPythonInstallationPath();
// remove the python env entirely
if (fs.existsSync(installationPath)) {
fs.rmSync(installationPath, { recursive: true });
}
}
////////////////////////////////////////////////
//
// Server Manager
//
////////////////////////////////////////////////
/**
* Validates that Python is installed and the `open-webui` package is present
* within the specified virtual environment.
*/
export async function validateInstallation(installationPath?: string): Promise<boolean> {
installationPath = installationPath || getBundledPythonInstallationPath();
const pythonPath = getPythonPath(installationPath);
if (!fs.existsSync(pythonPath)) {
return false;
}
try {
const checkCommand =
process.platform === 'win32'
? `"${installationPath}\\Scripts\\activate.bat" && pip show open-webui`
: `source "${installationPath}/bin/activate" && pip show open-webui`;
execSync(checkCommand, { stdio: 'ignore' });
} catch (error) {
return false;
}
return true;
}
// Tracks all spawned server process PIDs
const serverPIDs: Set<number> = new Set();
/**
* Spawn the Open-WebUI server process.
*/
export async function startServer(
installationPath?: string,
expose = false,
port = 8080
): Promise<string> {
installationPath = path.normalize(installationPath || getBundledPythonInstallationPath());
if (!(await validateInstallation(installationPath))) {
console.error('Failed to validate installation');
logEmitter.emit('log', 'Failed to validate installation'); // Emit log
return;
}
try {
const bundledPythonPath = getBundledPythonPath();
// Execute the Python binary to print the version
const pythonVersion = execFileSync(bundledPythonPath, ['--version'], {
encoding: 'utf-8'
});
console.log('Installed Python Version:', pythonVersion.trim());
logEmitter.emit('log', `Installed Python Version: ${pythonVersion.trim()}`); // Emit log
} catch (error) {
log.error('Failed to execute Python binary', error);
}
const host = expose ? '0.0.0.0' : '127.0.0.1';
// Windows HATES Typer-CLI used to create the CLI for Open-WebUI
// So we have to manually create the command to start the server
let startCommand =
process.platform === 'win32'
? `"${installationPath}\\Scripts\\activate.bat" && uvicorn open_webui.main:app --host "${host}" --forwarded-allow-ips '*'`
: `source "${installationPath}/bin/activate" && open-webui serve --host "${host}"`;
if (process.platform === 'win32') {
process.env.FROM_INIT_PY = 'true';
}
// Set environment variables in a platform-agnostic way
process.env.DATA_DIR = path.join(app.getPath('userData'), 'data');
process.env.WEBUI_SECRET_KEY = getSecretKey();
port = port || 8080;
while (await portInUse(port)) {
port++;
}
startCommand += ` --port ${port}`;
console.log('Starting Open-WebUI server...', startCommand);
logEmitter.emit('log', `${startCommand}`); // Emit log
logEmitter.emit('log', 'Starting Open-WebUI server...'); // Emit log
const childProcess = spawn(startCommand, {
shell: true,
detached: process.platform !== 'win32', // Detach the child process on Unix-like platforms
stdio: ['ignore', 'pipe', 'pipe'] // Let us capture logs via stdout/stderr
});
let serverCrashed = false;
let detectedURL: string | null = null;
// Wait for log output to confirm the server has started
async function monitorServerLogs(): Promise<void> {
return new Promise<void>((resolve, reject) => {
const handleLog = (data: Buffer) => {
const logLine = data.toString().trim();
console.log(`[Open-WebUI Log]: ${logLine}`);
logEmitter.emit('log', logLine);
// Look for "Uvicorn running on http://<hostname>:<port>"
const match = logLine.match(
/Uvicorn running on (http:\/\/[^\s]+) \(Press CTRL\+C to quit\)/
);
if (match) {
detectedURL = match[1]; // e.g., "http://0.0.0.0:8081"
resolve();
}
};
// Combine stdout and stderr streams as a unified log source
childProcess.stdout?.on('data', handleLog);
childProcess.stderr?.on('data', handleLog);
childProcess.on('close', (code) => {
serverCrashed = true;
if (!detectedURL) {
reject(
new Error(
`Process exited unexpectedly with code ${code}. No server URL detected.`
)
);
}
});
});
}
// Track the child process PID
if (childProcess.pid) {
serverPIDs.add(childProcess.pid);
console.log(`Server started with PID: ${childProcess.pid}`);
logEmitter.emit('log', `Server started with PID: ${childProcess.pid}`); // Emit PID log
} else {
throw new Error('Failed to start server: No PID available');
}
// Wait until the server log confirms it's started
try {
await monitorServerLogs();
} catch (error) {
if (serverCrashed) {
throw new Error('Server crashed unexpectedly.');
}
throw error;
}
if (!detectedURL) {
throw new Error('Failed to detect server URL from logs.');
}
console.log(`Server is now running at ${detectedURL}`);
logEmitter.emit('log', `Server is now running at ${detectedURL}`); // Emit server URL log
return detectedURL; // Return the detected URL
}
/**
* Terminates all server processes.
*/
export async function stopAllServers(): Promise<void> {
console.log('Stopping all servers...');
for (const pid of serverPIDs) {
try {
terminateProcessTree(pid);
serverPIDs.delete(pid); // Remove from tracking set after termination
} catch (error) {
console.error(`Error stopping server with PID ${pid}:`, error);
}
}
console.log('All servers stopped successfully.');
}
/**
* Kills a process tree by PID.
*/
function terminateProcessTree(pid: number): void {
if (process.platform === 'win32') {
// Use `taskkill` on Windows to recursively kill the process and its children
try {
execSync(`taskkill /PID ${pid} /T /F`); // /T -> terminate child processes, /F -> force termination
console.log(`Terminated server process tree (PID: ${pid}) on Windows.`);
} catch (error) {
log.error(`Failed to terminate process tree (PID: ${pid}):`, error);
}
} else {
// Use `kill` on Unix-like platforms to terminate the process group (-pid)
try {
process.kill(-pid, 'SIGKILL'); // Negative PID (-pid) kills the process group
console.log(`Terminated server process tree (PID: ${pid}) on Unix-like OS.`);
} catch (error) {
log.error(`Failed to terminate process tree (PID: ${pid}):`, error);
}
}
}

View File

@ -1,7 +0,0 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess()
};

View File

@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "commonjs",
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"noImplicitAny": true,
"sourceMap": true,
"baseUrl": ".",
"outDir": "dist",
"moduleResolution": "node",
"resolveJsonModule": true
}
}

View File

@ -1,4 +0,0 @@
import { defineConfig } from 'vite';
// https://vitejs.dev/config
export default defineConfig({});

View File

@ -1,4 +0,0 @@
import { defineConfig } from 'vite';
// https://vitejs.dev/config
export default defineConfig({});

View File

@ -1,8 +0,0 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite";
// https://vitejs.dev/config
export default defineConfig({
plugins: [tailwindcss(), svelte()],
});