reboot
@ -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
@ -96,4 +96,5 @@ resources/python.tar.gz
|
||||
|
||||
|
||||
|
||||
.webui_secret_key
|
||||
.webui_secret_key
|
||||
_old
|
10
.prettierrc
@ -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
@ -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.
|
26
README.md
@ -1,26 +0,0 @@
|
||||
# Open WebUI App (Experimental) 🚀
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
|
||||
Let’s build something amazing together! 💪
|
56
dev.md
@ -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! 🚀
|
@ -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>
|
@ -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
@ -1 +0,0 @@
|
||||
/// <reference types="@electron-forge/plugin-vite/forge-vite-env" />
|
12
index.html
@ -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
61
package.json
@ -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"
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 258 KiB |
Before Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 782 KiB |
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 2.8 MiB |
Before Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 432 KiB |
Before Width: | Height: | Size: 5.3 KiB |
Before Width: | Height: | Size: 5.1 KiB |
Before Width: | Height: | Size: 5.3 KiB |
@ -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
|
441
src/main.ts
@ -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);
|
||||
}
|
@ -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 });
|
||||
}
|
||||
});
|
@ -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"
|
||||
/>
|
@ -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;
|
||||
}
|
Before Width: | Height: | Size: 1.3 MiB |
@ -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, We’ll notify you when it’s
|
||||
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}
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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}
|
@ -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([]);
|
@ -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;
|
||||
};
|
@ -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;
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
};
|
@ -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
|
||||
}
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// https://vitejs.dev/config
|
||||
export default defineConfig({});
|
@ -1,4 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// https://vitejs.dev/config
|
||||
export default defineConfig({});
|
@ -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()],
|
||||
});
|