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" | ||||
| } | ||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @ -97,3 +97,4 @@ resources/python.tar.gz | ||||
| 
 | ||||
| 
 | ||||
| .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()], | ||||
| }); | ||||