mirror of
https://github.com/open-webui/desktop.git
synced 2026-07-15 12:45:42 -04:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b83f790e75 | |||
| 67c83f328d | |||
| 2a644345d4 | |||
| 3f8fe6cfe1 | |||
| f2db94ed13 | |||
| db2e170291 | |||
| 32224983cf | |||
| cf011d4642 | |||
| 96388c45c6 | |||
| db1b59a813 | |||
| 6d4ffea851 | |||
| 238cce6dc8 | |||
| b255f22fe7 | |||
| d205c176ef | |||
| 2a223e62b6 | |||
| eb2ebd2c1d | |||
| a6a944c847 | |||
| 03fea4a7cc | |||
| 6b159246d1 | |||
| 398dc3128e | |||
| d991908205 | |||
| 2528096e07 | |||
| aeba0e7c22 | |||
| ffbbb8f5b2 | |||
| 119409a2dc | |||
| cb564555dc | |||
| 00d739d0d0 |
@@ -3,7 +3,7 @@ name: Build and Release Electron App (electron-builder)
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- release
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -13,8 +13,8 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
# - os: ubuntu-latest
|
||||
# arch: x64
|
||||
- os: ubuntu-latest
|
||||
arch: x64
|
||||
# - os: ubuntu-latest
|
||||
# arch: arm64
|
||||
- os: windows-latest
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/release'
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -1,53 +1,38 @@
|
||||
# Open WebUI Desktop 🌐
|
||||
|
||||

|
||||
**Open WebUI App** is the 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.
|
||||
|
||||
**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 currently in **alpha** and under active development. 🛠️ Expect frequent updates and potential changes as we refine the application.
|
||||
|
||||
This project is still in an **experimental phase** and under active development. 🛠️ Expect frequent updates and potential changes as we refine the application.
|
||||
## Download 📥
|
||||
Get the latest alpha release from our [releases page](https://github.com/open-webui/desktop/releases).
|
||||
|
||||
---
|
||||
**Note**: An internet connection is required for initial setup, but afterwards the application can be used completely offline.
|
||||
|
||||
## Features
|
||||
- **One-Click Installation**: 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.
|
||||
- **Cross-Platform Support**: Compatible with Windows, macOS, and Linux to ensure broad accessibility.
|
||||
|
||||
---
|
||||
- **Offline Capability**: After initial setup, use the application completely offline for enhanced privacy and reliability.
|
||||
|
||||
## Project Setup
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
$ npm run dev
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# For windows
|
||||
$ npm run build:win
|
||||
|
||||
npm run build:win
|
||||
# For macOS
|
||||
$ npm run build:mac
|
||||
|
||||
npm run build:mac
|
||||
# For Linux
|
||||
$ npm run build:linux
|
||||
npm run build:linux
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License 📜
|
||||
This project is licensed under the **Open WebUI Sustainable Use License**. For details, see [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.
|
||||
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.
|
||||
+60
-13
@@ -16,12 +16,9 @@ import {
|
||||
} from "electron";
|
||||
import path, { join } from "path";
|
||||
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
import log from "electron-log";
|
||||
|
||||
import icon from "../../resources/icon.png?asset";
|
||||
import trayIconImage from "../../resources/assets/tray.png?asset";
|
||||
|
||||
import {
|
||||
getLogFilePath,
|
||||
checkUrlAndOpen,
|
||||
getConfig,
|
||||
getServerLog,
|
||||
@@ -38,6 +35,12 @@ import {
|
||||
uninstallPython,
|
||||
} from "./utils";
|
||||
|
||||
import log from "electron-log";
|
||||
log.transports.file.resolvePathFn = () => getLogFilePath("main");
|
||||
|
||||
import icon from "../../resources/icon.png?asset";
|
||||
import trayIconImage from "../../resources/assets/tray.png?asset";
|
||||
|
||||
// Main application logic
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
@@ -58,14 +61,16 @@ function createWindow(show = true): void {
|
||||
minHeight: 400,
|
||||
icon: path.join(__dirname, "assets/icon.png"),
|
||||
show: false,
|
||||
titleBarStyle: process.platform === "win32" ? "default" : "hidden",
|
||||
trafficLightPosition: { x: 16, y: 16 },
|
||||
autoHideMenuBar: true,
|
||||
...(process.platform === "win32"
|
||||
? {
|
||||
frame: false,
|
||||
frame: true,
|
||||
}
|
||||
: {}),
|
||||
...(process.platform === "linux" ? { icon } : {}),
|
||||
titleBarStyle: process.platform === "win32" ? "default" : "hidden",
|
||||
trafficLightPosition: { x: 16, y: 16 },
|
||||
...(process.platform !== "darwin" ? { titleBarOverlay: true } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
sandbox: false,
|
||||
@@ -122,6 +127,13 @@ function createWindow(show = true): void {
|
||||
uninstallHandler();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
label: "Reset",
|
||||
click: async () => {
|
||||
await resetAppHandler();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const updatedMenu = Menu.buildFromTemplate(menuTemplate);
|
||||
@@ -241,7 +253,7 @@ const uninstallHandler = async () => {
|
||||
});
|
||||
notification.show();
|
||||
} catch (error) {
|
||||
console.error("Uninstallation failed:", error);
|
||||
log.error("Uninstallation failed:", error);
|
||||
// Show error notification
|
||||
const notification = new Notification({
|
||||
title: "Open WebUI",
|
||||
@@ -295,7 +307,7 @@ const startServerHandler = async () => {
|
||||
// Show system notification
|
||||
const notification = new Notification({
|
||||
title: "Open WebUI",
|
||||
body: "Server is now available and opened in your browser",
|
||||
body: "Open WebUI is now available and opened in your browser",
|
||||
});
|
||||
notification.show();
|
||||
|
||||
@@ -307,7 +319,7 @@ const startServerHandler = async () => {
|
||||
|
||||
return true; // Indicate success
|
||||
} catch (error) {
|
||||
console.error("Failed to start server:", error);
|
||||
log.error("Failed to start server:", error);
|
||||
SERVER_STATUS = "failed";
|
||||
mainWindow?.webContents.send("main:data", {
|
||||
type: "status:server",
|
||||
@@ -343,11 +355,38 @@ const stopServerHandler = async () => {
|
||||
|
||||
return true; // Indicate success
|
||||
} catch (error) {
|
||||
console.error("Failed to stop server:", error);
|
||||
log.error("Failed to stop server:", error);
|
||||
return false; // Indicate failure
|
||||
}
|
||||
};
|
||||
|
||||
const resetAppHandler = async () => {
|
||||
try {
|
||||
await stopServerHandler(); // Stop the server if running
|
||||
SERVER_STATUS = null;
|
||||
|
||||
// wait a moment to ensure all processes are stopped
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
await resetApp(); // Reset the application state
|
||||
|
||||
// Show success notification
|
||||
const notification = new Notification({
|
||||
title: "Open WebUI",
|
||||
body: "Application has been reset successfully.",
|
||||
});
|
||||
notification.show();
|
||||
} catch (error) {
|
||||
log.error("Failed to reset application:", error);
|
||||
// Show error notification
|
||||
const notification = new Notification({
|
||||
title: "Open WebUI",
|
||||
body: `Failed to reset application: ${error.message}`,
|
||||
});
|
||||
notification.show();
|
||||
}
|
||||
};
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
app.quit(); // Quit if another instance is already running
|
||||
@@ -484,8 +523,16 @@ if (!gotTheLock) {
|
||||
return SERVER_STATUS;
|
||||
});
|
||||
|
||||
ipcMain.handle("app:info", async (event) => {
|
||||
return {
|
||||
version: app.getVersion(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle("app:reset", async (event) => {
|
||||
return await resetApp();
|
||||
return await resetAppHandler();
|
||||
});
|
||||
|
||||
ipcMain.handle("get:config", async (event) => {
|
||||
@@ -528,7 +575,7 @@ if (!gotTheLock) {
|
||||
);
|
||||
await installPackage("open-webui");
|
||||
} catch (error) {
|
||||
console.error("Failed to update package:", error);
|
||||
log.error("Failed to update package:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+226
-67
@@ -12,6 +12,18 @@ import { app, shell, Notification } from "electron";
|
||||
import { execFileSync, exec, spawn, execSync, execFile } from "child_process";
|
||||
|
||||
import log from "electron-log";
|
||||
log.transports.file.resolvePathFn = () => getLogFilePath("main");
|
||||
|
||||
const serverLogger = log.create({ logId: "server" });
|
||||
serverLogger.transports.file.resolvePath = () => getLogFilePath(`server`);
|
||||
|
||||
export const getLogFilePath = (name: string = "main"): string => {
|
||||
const logDir = path.join(getUserDataPath(), "logs");
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
return path.join(logDir, `${name}.log`);
|
||||
};
|
||||
|
||||
export const getAppPath = (): string => {
|
||||
let appPath = app.getAppPath();
|
||||
@@ -219,7 +231,7 @@ export const downloadFileWithProgress = async (
|
||||
return downloadPath;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
log.error("Download failed:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -237,7 +249,7 @@ export const getPythonDownloadPath = (): string => {
|
||||
return downloadPath;
|
||||
};
|
||||
|
||||
export const getPythonInstallationPath = (): string => {
|
||||
export const getPythonInstallationDir = (): string => {
|
||||
const installDir = path.join(app.getPath("userData"), "python");
|
||||
|
||||
if (!fs.existsSync(installDir)) {
|
||||
@@ -276,7 +288,7 @@ const downloadPython = async (onProgress = null) => {
|
||||
log.info(`✅ Python downloaded successfully to: ${result}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`❌ Download failed: ${error?.message}`);
|
||||
log.error(`❌ Download failed: ${error?.message}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -299,7 +311,7 @@ const checkInternet = async () => {
|
||||
};
|
||||
|
||||
export const installPython = async (
|
||||
installationPath?: string
|
||||
installationDir?: string
|
||||
): Promise<boolean> => {
|
||||
let pythonDownloadPath = getPythonDownloadPath();
|
||||
if (!isPythonDownloaded()) {
|
||||
@@ -314,9 +326,6 @@ export const installPython = async (
|
||||
log.info(
|
||||
`Downloading Python: ${progress.toFixed(2)}% (${downloaded} of ${total} bytes)`
|
||||
);
|
||||
log.info(
|
||||
`Downloading Python: ${progress.toFixed(2)}% (${downloaded} of ${total} bytes)`
|
||||
);
|
||||
});
|
||||
}
|
||||
if (!fs.existsSync(pythonDownloadPath)) {
|
||||
@@ -324,8 +333,8 @@ export const installPython = async (
|
||||
return false;
|
||||
}
|
||||
|
||||
installationPath = installationPath || getPythonInstallationPath();
|
||||
log.info(installationPath, pythonDownloadPath);
|
||||
installationDir = installationDir || getPythonInstallationDir();
|
||||
log.info(installationDir, pythonDownloadPath);
|
||||
|
||||
try {
|
||||
const userDataPath = getUserDataPath();
|
||||
@@ -339,13 +348,16 @@ export const installPython = async (
|
||||
}
|
||||
|
||||
// Get the path to the installed Python binary
|
||||
if (isPythonInstalled(installationPath)) {
|
||||
const pythonPath = getPythonPath(installationPath);
|
||||
if (isPythonInstalled(installationDir)) {
|
||||
const pythonPath = getPythonPath(installationDir);
|
||||
|
||||
execFileSync(pythonPath, ["-m", "pip", "install", "uv"], {
|
||||
encoding: "utf-8",
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === "win32"
|
||||
? { PYTHONIOENCODING: "utf-8" }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
log.info("Successfully installed uv package");
|
||||
@@ -367,14 +379,14 @@ export const getPythonExecutablePath = (envPath: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getPythonPath = (installationPath?: string) => {
|
||||
export const getPythonPath = (installationDir?: string) => {
|
||||
return path.normalize(
|
||||
getPythonExecutablePath(installationPath || getPythonInstallationPath())
|
||||
getPythonExecutablePath(installationDir || getPythonInstallationDir())
|
||||
);
|
||||
};
|
||||
|
||||
export const isPythonInstalled = (installationPath?: string) => {
|
||||
const pythonPath = getPythonPath(installationPath);
|
||||
export const isPythonInstalled = (installationDir?: string) => {
|
||||
const pythonPath = getPythonPath(installationDir);
|
||||
|
||||
if (!fs.existsSync(pythonPath)) {
|
||||
log.error("Python binary not found in install path");
|
||||
@@ -387,6 +399,9 @@ export const isPythonInstalled = (installationPath?: string) => {
|
||||
encoding: "utf-8",
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === "win32"
|
||||
? { PYTHONIOENCODING: "utf-8" }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
log.info("Installed Python Version:", pythonVersion.trim());
|
||||
@@ -398,14 +413,17 @@ export const isPythonInstalled = (installationPath?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const isUvInstalled = (installationPath?: string) => {
|
||||
const pythonPath = getPythonPath(installationPath);
|
||||
export const isUvInstalled = (installationDir?: string) => {
|
||||
const pythonPath = getPythonPath(installationDir);
|
||||
try {
|
||||
// Check if uv is installed by running the command
|
||||
const result = execFileSync(pythonPath, ["-m", "uv", "--version"], {
|
||||
encoding: "utf-8",
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === "win32"
|
||||
? { PYTHONIOENCODING: "utf-8" }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -420,16 +438,17 @@ export const isUvInstalled = (installationPath?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const uninstallPython = (installationPath?: string): boolean => {
|
||||
installationPath = installationPath || getPythonInstallationPath();
|
||||
export const uninstallPython = (installationDir?: string): boolean => {
|
||||
installationDir = installationDir || getPythonInstallationDir();
|
||||
|
||||
if (!fs.existsSync(installationPath)) {
|
||||
if (!fs.existsSync(installationDir)) {
|
||||
log.error("Python installation not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmSync(installationPath, { recursive: true });
|
||||
fs.rmSync(installationDir, { recursive: true, force: true });
|
||||
log.info("Python installation removed successfully:", installationDir);
|
||||
} catch (error) {
|
||||
log.error("Failed to remove Python installation", error);
|
||||
return false;
|
||||
@@ -449,6 +468,39 @@ export const uninstallPython = (installationPath?: string): boolean => {
|
||||
export const resetApp = async (): Promise<void> => {
|
||||
await uninstallPython();
|
||||
log.info("Uninstalled Python environment");
|
||||
|
||||
// remove config file
|
||||
const configPath = path.join(getUserDataPath(), "config.json");
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
fs.unlinkSync(configPath);
|
||||
log.info("Removed config file:", configPath);
|
||||
} catch (error) {
|
||||
log.error("Failed to remove config file:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// remove secret key file
|
||||
const secretKeyPath = path.join(getOpenWebUIDataPath(), ".key");
|
||||
if (fs.existsSync(secretKeyPath)) {
|
||||
try {
|
||||
fs.unlinkSync(secretKeyPath);
|
||||
log.info("Removed secret key file:", secretKeyPath);
|
||||
} catch (error) {
|
||||
log.error("Failed to remove secret key file:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// remove /data folder
|
||||
const dataPath = getOpenWebUIDataPath();
|
||||
if (fs.existsSync(dataPath)) {
|
||||
try {
|
||||
fs.rmSync(dataPath, { recursive: true, force: true });
|
||||
log.info("Removed data directory:", dataPath);
|
||||
} catch (error) {
|
||||
log.error("Failed to remove data directory:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
////////////////////////////////////////////////
|
||||
@@ -518,6 +570,9 @@ export const installPackage = (
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === "win32"
|
||||
? { PYTHONIOENCODING: "utf-8" }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -568,6 +623,9 @@ export const isPackageInstalled = (packageName: string): boolean => {
|
||||
encoding: "utf-8",
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === "win32"
|
||||
? { PYTHONIOENCODING: "utf-8" }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -610,16 +668,15 @@ export const startServer = async (
|
||||
const pythonPath = getPythonPath();
|
||||
log.info(`Using Python at: ${pythonPath}`);
|
||||
|
||||
const openWebUIPath = path.join(path.dirname(pythonPath), "open-webui");
|
||||
|
||||
let commandArgs: string[];
|
||||
commandArgs = ["-m", "uv", "run", "open-webui", "serve", "--host", host];
|
||||
|
||||
const dataDir = path.join(app.getPath("userData"), "data");
|
||||
const dataDir = getOpenWebUIDataPath();
|
||||
const secretKey = getSecretKey();
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.WEBUI_SECRET_KEY = secretKey;
|
||||
|
||||
@@ -645,7 +702,12 @@ export const startServer = async (
|
||||
const childProcess = spawn(pythonPath, commandArgs, {
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env },
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === "win32"
|
||||
? { PYTHONIOENCODING: "utf-8" }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!childProcess.pid) {
|
||||
@@ -658,24 +720,23 @@ export const startServer = async (
|
||||
|
||||
const appendLog = (source: string) => (data: Buffer) => {
|
||||
const logLine = data.toString().trim();
|
||||
const tag = `[${source}][PID:${childProcess.pid}]:`;
|
||||
logLines.push(`${tag} ${logLine}`);
|
||||
// (Optional) also log to main process console
|
||||
// log.info(`${tag} ${logLine}`);
|
||||
const line = `[${source}][PID:${childProcess.pid}]: ${logLine}`;
|
||||
logLines.push(line);
|
||||
serverLogger.info(line); // Log to console
|
||||
};
|
||||
childProcess.stdout?.on("data", appendLog("stdout"));
|
||||
childProcess.stderr?.on("data", appendLog("stderr"));
|
||||
childProcess.on("close", (code, signal) => {
|
||||
logLines.push(
|
||||
`[process][PID:${childProcess.pid}] Exited with code ${code} signal ${signal}`
|
||||
);
|
||||
const line = `[process][PID:${childProcess.pid}] Exited with code ${code} signal ${signal}`;
|
||||
serverLogger.info(line);
|
||||
logLines.push(line);
|
||||
serverPIDs.delete(childProcess.pid);
|
||||
// Note: we keep the logs available until manually cleared
|
||||
});
|
||||
childProcess.on("error", (err) => {
|
||||
logLines.push(
|
||||
`[process][PID:${childProcess.pid}] Error: ${err.message}`
|
||||
);
|
||||
const line = `[process][PID:${childProcess.pid}] Error: ${err.message}`;
|
||||
serverLogger.error(line);
|
||||
logLines.push(line);
|
||||
});
|
||||
|
||||
// Compute URL directly, do not try to parse logs
|
||||
@@ -688,50 +749,148 @@ export const startServer = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Terminates all server processes.
|
||||
* Terminates all server processes with maximum reliability.
|
||||
*/
|
||||
export async function stopAllServers(): Promise<void> {
|
||||
log.info("Stopping all servers...");
|
||||
for (const pid of Array.from(serverPIDs)) {
|
||||
try {
|
||||
terminateProcessTree(pid);
|
||||
serverPIDs.delete(pid); // Remove from tracking set after termination
|
||||
|
||||
const pidsToStop = Array.from(serverPIDs);
|
||||
if (pidsToStop.length === 0) {
|
||||
log.info("No servers to stop.");
|
||||
return;
|
||||
}
|
||||
|
||||
// First pass: attempt graceful termination
|
||||
for (const pid of pidsToStop) {
|
||||
await terminateProcessTree(pid, false);
|
||||
}
|
||||
|
||||
// Wait a moment for graceful shutdown
|
||||
await sleep(2000);
|
||||
|
||||
// Second pass: force kill any remaining processes
|
||||
for (const pid of pidsToStop) {
|
||||
await terminateProcessTree(pid, true);
|
||||
}
|
||||
|
||||
// Final verification and cleanup
|
||||
for (const pid of pidsToStop) {
|
||||
if (!isProcessRunning(pid)) {
|
||||
serverPIDs.delete(pid);
|
||||
serverLogs.delete(pid);
|
||||
} catch (error) {
|
||||
console.error(`Error stopping server with PID ${pid}:`, error);
|
||||
} else {
|
||||
log.warn(
|
||||
`Process ${pid} may still be running after termination attempts`
|
||||
);
|
||||
}
|
||||
}
|
||||
log.info("All servers stopped successfully.");
|
||||
|
||||
log.info(
|
||||
`Stopped ${pidsToStop.length - serverPIDs.size}/${pidsToStop.length} servers successfully.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kills a process tree by PID.
|
||||
* Kills a process tree by PID with retry logic.
|
||||
*/
|
||||
function terminateProcessTree(pid: number): void {
|
||||
if (process.platform === "win32") {
|
||||
async function terminateProcessTree(
|
||||
pid: number,
|
||||
forceKill: boolean = false
|
||||
): Promise<void> {
|
||||
const maxRetries = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
execSync(`taskkill /PID ${pid} /T /F`);
|
||||
log.info(
|
||||
`Terminated server process tree (PID: ${pid}) on Windows.`
|
||||
);
|
||||
if (process.platform === "win32") {
|
||||
await terminateWindows(pid, forceKill);
|
||||
} else {
|
||||
await terminateUnix(pid, forceKill);
|
||||
}
|
||||
|
||||
// Verify termination
|
||||
if (!isProcessRunning(pid)) {
|
||||
log.info(`Successfully terminated process tree (PID: ${pid})`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to terminate process tree (PID: ${pid}):`,
|
||||
log.warn(
|
||||
`Attempt ${attempt}/${maxRetries} failed for PID ${pid}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL");
|
||||
log.info(
|
||||
`Terminated server process tree (PID: ${pid}) on Unix-like OS.`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to terminate process tree (PID: ${pid}):`,
|
||||
error
|
||||
);
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
await sleep(1000); // Wait before retry
|
||||
}
|
||||
}
|
||||
|
||||
log.error(
|
||||
`Failed to terminate process tree (PID: ${pid}) after ${maxRetries} attempts`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate process on Windows.
|
||||
*/
|
||||
async function terminateWindows(
|
||||
pid: number,
|
||||
forceKill: boolean
|
||||
): Promise<void> {
|
||||
const commands = forceKill
|
||||
? [`taskkill /PID ${pid} /T /F`]
|
||||
: [`taskkill /PID ${pid} /T`, `taskkill /PID ${pid} /T /F`];
|
||||
|
||||
for (const cmd of commands) {
|
||||
try {
|
||||
execSync(cmd, { timeout: 5000, stdio: "ignore" });
|
||||
await sleep(500); // Brief pause between commands
|
||||
} catch (error) {
|
||||
log.error(`Failed to terminate process (PID: ${pid}):`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate process on Unix-like systems.
|
||||
*/
|
||||
async function terminateUnix(pid: number, forceKill: boolean): Promise<void> {
|
||||
const signals = forceKill ? ["SIGKILL"] : ["SIGTERM", "SIGKILL"];
|
||||
|
||||
for (const signal of signals) {
|
||||
try {
|
||||
// Kill process group (negative PID)
|
||||
process.kill(-pid, signal);
|
||||
await sleep(500);
|
||||
|
||||
// Also try individual process if group kill fails
|
||||
if (isProcessRunning(pid)) {
|
||||
process.kill(pid, signal);
|
||||
await sleep(500);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Failed to terminate process (PID: ${pid}):`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a process is still running.
|
||||
*/
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
// Sending signal 0 checks if process exists without killing it
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple sleep utility.
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -746,7 +905,7 @@ export const checkUrlAndOpen = async (
|
||||
url: string,
|
||||
callback: Function = async () => {}
|
||||
) => {
|
||||
const maxAttempts = 150; // 5 minutes with 2-second intervals
|
||||
const maxAttempts = 1800; // 60 minutes with 2-second intervals
|
||||
const interval = 2000; // 2 seconds
|
||||
let attempts = 0;
|
||||
|
||||
@@ -793,7 +952,7 @@ export const checkUrlAndOpen = async (
|
||||
|
||||
// Start polling in the background (don't await)
|
||||
pollUrl().catch((error) => {
|
||||
console.error("Error in URL polling:", error);
|
||||
log.error("Error in URL polling:", error);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -806,7 +965,7 @@ export const getConfig = async (): Promise<Record<string, any>> => {
|
||||
}
|
||||
return {};
|
||||
} catch (error) {
|
||||
console.error("Error reading config:", error);
|
||||
log.error("Error reading config:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -819,7 +978,7 @@ export const setConfig = async (config: Record<string, any>): Promise<void> => {
|
||||
JSON.stringify(config, null, 2)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error writing config:", error);
|
||||
log.error("Error writing config:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -54,6 +54,16 @@ const api = {
|
||||
await ipcRenderer.invoke("open:browser", { url });
|
||||
},
|
||||
|
||||
getAppInfo: async () => {
|
||||
if (!isLocalSource()) {
|
||||
throw new Error(
|
||||
"Access restricted: This operation is only allowed in a local environment."
|
||||
);
|
||||
}
|
||||
|
||||
return await ipcRenderer.invoke("app:info");
|
||||
},
|
||||
|
||||
getVersion: async () => {
|
||||
if (!isLocalSource()) {
|
||||
throw new Error(
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
import Installation from "./lib/components/Installation.svelte";
|
||||
import Loading from "./lib/components/Loading.svelte";
|
||||
|
||||
import { info, config } from "./lib/stores";
|
||||
import { info, config, appInfo } from "./lib/stores";
|
||||
|
||||
let installed = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
appInfo.set(await window?.electronAPI?.getAppInfo());
|
||||
config.set(await window?.electronAPI?.getConfig());
|
||||
|
||||
const pythonStatus = await window?.electronAPI?.getPythonStatus();
|
||||
@@ -57,7 +58,7 @@
|
||||
{:else if installed === false}
|
||||
<Installation bind:installed />
|
||||
{:else}
|
||||
<Controls />
|
||||
<Controls bind:installed />
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -154,7 +154,6 @@ html {
|
||||
.scrollbar-hidden:hover::-webkit-scrollbar-thumb {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.scrollbar-hidden::-webkit-scrollbar-thumb {
|
||||
visibility: hidden;
|
||||
}
|
||||
@@ -162,3 +161,40 @@ html {
|
||||
.scrollbar-hidden::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none; /* for Chrome, Safari and Opera */
|
||||
}
|
||||
|
||||
.scrollbar-none::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
--tw-border-opacity: 1;
|
||||
background-color: rgba(215, 215, 215, 0.8);
|
||||
border-color: rgba(255, 255, 255, var(--tw-border-opacity));
|
||||
border-radius: 9999px;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
/* Dark theme scrollbar styles */
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(67, 67, 67, 0.8); /* Darker color for dark theme */
|
||||
border-color: rgba(0, 0, 0, var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
height: 0.6rem;
|
||||
width: 0.4rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { appInfo, info } from "../stores";
|
||||
|
||||
import Launching from "./Launching.svelte";
|
||||
import logoImage from "../assets/images/splash.png";
|
||||
import General from "./Controls/General.svelte";
|
||||
import About from "./Controls/About.svelte";
|
||||
import { info } from "../stores";
|
||||
|
||||
let { installed = $bindable(false) } = $props();
|
||||
|
||||
let selectedTab = $state("general");
|
||||
|
||||
@@ -17,7 +19,9 @@
|
||||
class="flex flex-col w-full h-full relative text-gray-850 dark:text-gray-100"
|
||||
>
|
||||
<div
|
||||
class="pt-3 pb-1.5 pl-22 pr-4 w-full drag-region flex flex-row gap-3 items-center justify-between"
|
||||
class="pt-3 pb-1.5 {$appInfo?.platform === 'darwin'
|
||||
? 'pl-22 pr-4'
|
||||
: 'px-4'} w-full drag-region flex flex-row gap-3 items-center justify-between"
|
||||
>
|
||||
<div class=" font-medium">Controls</div>
|
||||
|
||||
@@ -104,7 +108,7 @@
|
||||
class="flex-1 pt-1 sm:mt-0 overflow-y-scroll pr-1 scrollbar-hidden"
|
||||
>
|
||||
{#if selectedTab === "general"}
|
||||
<General info={$info} />
|
||||
<General bind:installed info={$info} />
|
||||
{:else if selectedTab === "about"}
|
||||
<About />
|
||||
{/if}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import Spinner from "../common/Spinner.svelte";
|
||||
import ConfirmDialog from "../common/ConfirmDialog.svelte";
|
||||
|
||||
let { info } = $props();
|
||||
let { info, installed = $bindable(false) } = $props();
|
||||
|
||||
let config = $state(null);
|
||||
|
||||
@@ -54,12 +54,14 @@
|
||||
confirmLabel="Reset"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
installed = null;
|
||||
config = null;
|
||||
await window.electronAPI.resetApp();
|
||||
toast.success("App has been reset successfully.");
|
||||
|
||||
// refresh the page to apply changes
|
||||
window.location.reload();
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
toast.error("Failed to reset the app");
|
||||
}
|
||||
|
||||
@@ -11,20 +11,12 @@
|
||||
import galaxyImage from "../assets/images/galaxy.jpg";
|
||||
import greenImage from "../assets/images/green.jpg";
|
||||
import adamImage from "../assets/images/adam.jpg";
|
||||
import earthImage from "../assets/images/earth.jpg";
|
||||
import nasaImage from "../assets/images/nasa.jpg";
|
||||
import neomImage from "../assets/images/neom.jpg";
|
||||
|
||||
let { installed = $bindable() } = $props();
|
||||
|
||||
let images = [
|
||||
galaxyImage,
|
||||
greenImage,
|
||||
adamImage,
|
||||
earthImage,
|
||||
nasaImage,
|
||||
neomImage,
|
||||
];
|
||||
let images = [galaxyImage, greenImage, adamImage, nasaImage, neomImage];
|
||||
|
||||
let mounted = $state(false);
|
||||
let currentTime = Date.now();
|
||||
@@ -57,10 +49,12 @@
|
||||
(await window.electronAPI.getPythonStatus()) &&
|
||||
(await window.electronAPI.getPackageStatus())
|
||||
) {
|
||||
// Notify the user that the installation is complete
|
||||
// Start the server if it's not already running
|
||||
if (!(await window.electronAPI.getServerStatus())) {
|
||||
await window.electronAPI.startServer();
|
||||
}
|
||||
|
||||
// Notify the user that the installation is complete
|
||||
await window.electronAPI.notification(
|
||||
"Installation Complete",
|
||||
"Open WebUI is now ready to use."
|
||||
@@ -218,7 +212,7 @@
|
||||
{`Continue`}
|
||||
</div>
|
||||
|
||||
<!-- <button
|
||||
<div
|
||||
class="text-xs mt-3 text-gray-500 cursor-pointer"
|
||||
in:fly={{
|
||||
delay: 500,
|
||||
@@ -226,8 +220,16 @@
|
||||
y: 10,
|
||||
}}
|
||||
>
|
||||
To connect to an existing server, click here.
|
||||
</button> -->
|
||||
By continuing, you agree to our
|
||||
<button
|
||||
class="underline"
|
||||
onclick={() => {
|
||||
window.electronAPI.openInBrowser(
|
||||
"https://github.com/open-webui/desktop/blob/main/LICENSE"
|
||||
);
|
||||
}}>license agreement</button
|
||||
>.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,19 +7,11 @@
|
||||
import galaxyImage from "../assets/images/galaxy.jpg";
|
||||
import greenImage from "../assets/images/green.jpg";
|
||||
import adamImage from "../assets/images/adam.jpg";
|
||||
import earthImage from "../assets/images/earth.jpg";
|
||||
import nasaImage from "../assets/images/nasa.jpg";
|
||||
import neomImage from "../assets/images/neom.jpg";
|
||||
import { fly } from "svelte/transition";
|
||||
|
||||
let images = [
|
||||
galaxyImage,
|
||||
greenImage,
|
||||
adamImage,
|
||||
earthImage,
|
||||
nasaImage,
|
||||
neomImage,
|
||||
];
|
||||
let images = [galaxyImage, greenImage, adamImage, nasaImage, neomImage];
|
||||
|
||||
let startTime = $state(null);
|
||||
let currentTime = $state(null);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export const appInfo = writable(null);
|
||||
|
||||
export const info = writable(null);
|
||||
export const config = writable(null);
|
||||
|
||||
Reference in New Issue
Block a user