This commit is contained in:
Timothy Jaeryang Baek 2025-01-11 16:42:27 -08:00
parent f33baf219f
commit 82c68c4bb0
5 changed files with 240 additions and 51 deletions

View File

@ -1,16 +1,23 @@
import {
app,
protocol,
nativeImage,
Tray,
Menu,
MenuItem,
BrowserWindow,
globalShortcut,
ipcMain,
} from "electron";
import path from "path";
import started from "electron-squirrel-startup";
import { installPackage, startServer, stopAllServers } from "./utils";
import {
installPackage,
removePackage,
startServer,
stopAllServers,
validateInstallation,
} from "./utils";
// Restrict app to a single instance
const gotTheLock = app.requestSingleInstanceLock();
@ -45,11 +52,23 @@ if (!gotTheLock) {
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
const onReady = () => {
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 onReady = async () => {
console.log(process.resourcesPath);
console.log(app.getName());
console.log(app.getPath("userData"));
console.log(app.getPath("appData"));
mainWindow = new BrowserWindow({
width: 800,
height: 600,
@ -59,22 +78,49 @@ if (!gotTheLock) {
},
titleBarStyle: "hidden",
});
mainWindow.setIcon(path.join(__dirname, "assets/icon.png"));
// 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`)
);
}
loadDefaultView();
if (!app.isPackaged) {
mainWindow.webContents.openDevTools();
}
if (validateInstallation()) {
const serverUrl = await startServer();
mainWindow.loadURL(serverUrl);
}
globalShortcut.register("Alt+CommandOrControl+O", () => {
mainWindow?.show();
if (mainWindow?.isMinimized()) mainWindow?.restore();
mainWindow?.focus();
});
const defaultMenu = Menu.getApplicationMenu();
console.log(defaultMenu);
// Convert the default menu to a template we can modify
let menuTemplate = defaultMenu ? defaultMenu.items.map((item) => item) : [];
// Add your own custom menu items
menuTemplate.push({
label: "Action",
submenu: [
{
label: "Home",
accelerator: process.platform === "darwin" ? "Cmd+H" : "Ctrl+H",
click: () => {
loadDefaultView();
},
},
],
});
// Build the updated menu and set it as the application menu
const updatedMenu = Menu.buildFromTemplate(menuTemplate);
Menu.setApplicationMenu(updatedMenu);
// Create a system tray icon
const image = nativeImage.createFromPath(
path.join(__dirname, "assets/tray.png")
@ -89,7 +135,8 @@ if (!gotTheLock) {
},
},
{
label: "Quit",
label: "Quit Open WebUI",
accelerator: "CommandOrControl+Q",
click: () => {
app.isQuiting = true; // Mark as quitting
app.quit(); // Quit the application
@ -114,6 +161,11 @@ if (!gotTheLock) {
installPackage();
});
ipcMain.handle("remove", async (event) => {
console.log("Resetting package...");
removePackage();
});
ipcMain.handle("server:start", async (event) => {
console.log("Starting server...");

View File

@ -1,5 +1,17 @@
import { ipcRenderer, contextBridge } from "electron";
import { start } from "repl";
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
@ -22,14 +34,42 @@ contextBridge.exposeInMainWorld("electronAPI", {
},
installPackage: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
await ipcRenderer.invoke("install");
},
removePackage: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
await ipcRenderer.invoke("remove");
},
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");
},
});

View File

@ -1,31 +1,51 @@
<script lang="ts">
import { onMount } from "svelte";
import Spinner from "./common/Spinner.svelte";
</script>
<div class="flex flex-row w-full h-full relative">
<div class="flex flex-row w-full h-full relative dark:text-gray-100">
<div
class="absolute top-0 left-0 w-full h-6 bg-gray-900 bg-opacity-50 draggable"
></div>
<div class="m-auto flex flex-col">
<div class="m-auto flex flex-col max-w-xs w-full text-center">
<div class=" flex justify-center mb-3">
<img
src="./assets/images/splash.png"
class="size-16 dark:invert"
class=" size-24 dark:invert"
alt="hero"
/>
</div>
<!-- <div class=" text-2xl text-gray-50 font-secondary">Install Open WebUI</div> -->
<button
class=" text-gray-100 hover:text-white transition font-medium cursor-pointer"
<div class=" text-gray-500 hover:text-white transition">
<div class="flex justify-center items-center gap-2">
<div>Loading...</div>
<div>
<Spinner className="size-4" />
</div>
</div>
</div>
<!-- <button
class=" hover:text-white transition font-medium cursor-pointer"
onclick={() => {
console.log("install clicked");
if (window?.electronAPI) {
window.electronAPI.installPackage();
}
}}>Install Open WebUI</button
}}
>
<div class="flex justify-center items-center gap-2">
<div>Install</div>
<div>
<Spinner className="size-4" />
</div>
</div>
</button> -->
<!--
<button
class=" text-gray-100 hover:text-white transition font-medium cursor-pointer"
@ -45,7 +65,7 @@
window.electronAPI.stopServer();
}
}}>Stop Open WebUI</button
>
> -->
</div>
</div>

View File

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

View File

@ -272,25 +272,33 @@ export async function installPackage(installationPath?: string) {
return Promise.reject("Failed to install open-webui");
}
}
export async function removePackage(installationPath?: string) {
installationPath = installationPath || getBundledPythonInstallationPath();
// remove the python env entirely
if (fs.existsSync(installationPath)) {
fs.rmdirSync(installationPath, { recursive: true });
}
}
////////////////////////////////////////////////
//
// Server Manager
//
////////////////////////////////////////////////
// Set to keep track of all spawned child processes from the app
const childProcesses: Set<ChildProcess> = new Set();
/**
* Validates that Python is installed and the `open-webui` package is present
* within the specified virtual environment.
*/
export async function validateInstallation(
installationPath: string
): Promise<void> {
installationPath?: string
): Promise<boolean> {
installationPath = installationPath || getBundledPythonInstallationPath();
const pythonPath = getPythonPath(installationPath);
if (!fs.existsSync(pythonPath)) {
throw new Error(`Python binary not found in environment: ${pythonPath}`);
return false;
}
try {
const checkCommand =
@ -299,17 +307,11 @@ export async function validateInstallation(
: `source "${installationPath}/bin/activate" && pip show open-webui`;
execSync(checkCommand, { stdio: "ignore" });
} catch (error) {
throw new Error(
`The 'open-webui' package is not installed in the environment at ${installationPath}.`
);
return false;
}
}
////////////////////////////////////////////////
//
// Process Management
//
////////////////////////////////////////////////
return true;
}
// Tracks all spawned server process PIDs
const serverPIDs: Set<number> = new Set();
@ -320,11 +322,16 @@ const serverPIDs: Set<number> = new Set();
export async function startServer(
installationPath?: string,
port?: number
): Promise<void> {
): Promise<string> {
installationPath = path.normalize(
installationPath || getBundledPythonInstallationPath()
);
if (!validateInstallation(installationPath)) {
console.error("Failed to validate installation");
return;
}
let startCommand =
process.platform === "win32"
? `${installationPath}\\Scripts\\activate.bat && set DATA_DIR="${path.join(
@ -347,29 +354,70 @@ export async function startServer(
const childProcess = spawn(startCommand, {
shell: true,
detached: true,
stdio: "pipe",
stdio: ["ignore", "pipe", "pipe"], // Let us capture logs via stdout/stderr
});
// Log any output (optional)
childProcess.stdout?.on("data", (data) => {
console.log(`[Open-WebUI]: ${data.toString().trim()}`);
});
let serverCrashed = false;
let detectedURL: string | null = null;
childProcess.stderr?.on("data", (data) => {
console.error(`[Open-WebUI Error]: ${data.toString().trim()}`);
});
// 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}`);
childProcess.on("exit", (code) => {
console.log(`Open-WebUI server exited with code ${code}`);
});
// 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();
}
};
// Track server PID
// 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}`);
} else {
console.error("Failed to start the server: PID not found");
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}`);
return detectedURL; // Return the detected URL
}
/**