This commit is contained in:
Timothy Jaeryang Baek 2026-03-18 03:56:16 -05:00
parent 86cf4834cd
commit 146f75f979
14 changed files with 591 additions and 71 deletions

41
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,41 @@
name: Build & Publish Release
on:
push:
branches:
- release
permissions:
contents: write
jobs:
build:
strategy:
matrix:
include:
- os: macos-latest
platform: mac
- os: ubuntu-latest
platform: linux
- os: windows-latest
platform: win
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build & Publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run build && npx electron-builder --${{ matrix.platform }} --publish always

View file

@ -1,3 +1,4 @@
provider: generic
url: https://example.com/auto-updates
provider: github
owner: open-webui
repo: desktop
updaterCacheDirName: desktop-updater

View file

@ -50,5 +50,6 @@ appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
publish:
provider: generic
url: https://example.com/auto-updates
provider: github
owner: open-webui
repo: desktop

View file

@ -13,7 +13,8 @@ import {
Notification,
Menu,
ipcMain,
Tray
Tray,
dialog
} from 'electron'
import path, { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
@ -52,6 +53,8 @@ import {
getOpenTerminalLog
} from './utils/open-terminal'
import { initUpdater, checkForUpdates, downloadUpdate, installUpdate } from './updater'
import log from 'electron-log'
log.transports.file.resolvePathFn = () => getLogFilePath('main')
@ -590,6 +593,18 @@ if (!gotTheLock) {
return config.connections
})
ipcMain.handle('connections:update', async (_event, id: string, updates: Partial<Connection>) => {
const config = await getConfig()
const idx = config.connections.findIndex((c) => c.id === id)
if (idx !== -1) {
config.connections[idx] = { ...config.connections[idx], ...updates }
await setConfig(config)
CONFIG = config
updateTray()
}
return config.connections
})
ipcMain.handle('connections:setDefault', async (_event, id: string) => {
const config = await getConfig()
config.defaultConnectionId = id
@ -611,6 +626,11 @@ if (!gotTheLock) {
return await validateRemoteUrl(url)
})
// Updater
ipcMain.handle('updater:check', () => checkForUpdates())
ipcMain.handle('updater:download', () => downloadUpdate())
ipcMain.handle('updater:install', () => installUpdate())
// Misc
ipcMain.handle('app:reset', () => resetAppHandler())
@ -655,6 +675,13 @@ if (!gotTheLock) {
return uninstallPackage(packageName)
})
ipcMain.handle('dialog:selectFolder', async () => {
const result = await dialog.showOpenDialog(mainWindow!, {
properties: ['openDirectory']
})
return result.canceled ? null : result.filePaths[0] ?? null
})
ipcMain.handle('app:launchAtLogin:get', () => {
return app.getLoginItemSettings().openAtLogin
})
@ -746,6 +773,11 @@ if (!gotTheLock) {
createMainWindow()
}
// Initialize auto-updater
if (mainWindow) {
initUpdater(mainWindow)
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
else {

66
src/main/updater.ts Normal file
View file

@ -0,0 +1,66 @@
import { autoUpdater, type UpdateInfo } from 'electron-updater'
import log from 'electron-log'
import { BrowserWindow } from 'electron'
let mainWin: BrowserWindow | null = null
const send = (type: string, data?: any): void => {
mainWin?.webContents.send('main:data', { type, data })
}
export function initUpdater(window: BrowserWindow): void {
mainWin = window
autoUpdater.logger = log
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.on('checking-for-update', () => {
send('update:checking')
})
autoUpdater.on('update-available', (info: UpdateInfo) => {
send('update:available', {
version: info.version,
releaseDate: info.releaseDate
})
})
autoUpdater.on('update-not-available', (_info: UpdateInfo) => {
send('update:not-available')
})
autoUpdater.on('download-progress', (progress) => {
send('update:download-progress', {
percent: progress.percent,
bytesPerSecond: progress.bytesPerSecond,
transferred: progress.transferred,
total: progress.total
})
})
autoUpdater.on('update-downloaded', (_info: UpdateInfo) => {
send('update:downloaded')
})
autoUpdater.on('error', (error: Error) => {
send('update:error', { message: error?.message ?? 'Update error' })
})
// Auto-check on launch (silently)
autoUpdater.checkForUpdates().catch((err) => {
log.warn('Auto update check failed:', err)
})
}
export async function checkForUpdates(): Promise<void> {
await autoUpdater.checkForUpdates()
}
export async function downloadUpdate(): Promise<void> {
await autoUpdater.downloadUpdate()
}
export function installUpdate(): void {
autoUpdater.quitAndInstall(false, true)
}

View file

@ -738,6 +738,7 @@ export interface AppConfig {
openTerminal: {
enabled: boolean
port: number
cwd: string
}
envVars: Record<string, string>
}
@ -753,7 +754,8 @@ const DEFAULT_CONFIG: AppConfig = {
},
openTerminal: {
enabled: false,
port: 8000
port: 8000,
cwd: ''
},
envVars: {}
}

View file

@ -63,11 +63,14 @@ export const startOpenTerminal = async (
}
}
const cwd = config.openTerminal?.cwd || require('os').homedir()
const commandArgs = [
'-m', 'uv', 'run', 'open-terminal', 'run',
'--host', host,
'--port', availablePort.toString(),
'--api-key', generatedKey
'--api-key', generatedKey,
'--cwd', cwd
]
log.info('Starting Open Terminal...', pythonPath, commandArgs.join(' '))

View file

@ -138,9 +138,16 @@ const api = {
getConnections: () => ipcRenderer.invoke('connections:list'),
addConnection: (connection: any) => ipcRenderer.invoke('connections:add', connection),
removeConnection: (id: string) => ipcRenderer.invoke('connections:remove', id),
updateConnection: (id: string, updates: any) => ipcRenderer.invoke('connections:update', id, updates),
setDefaultConnection: (id: string) => ipcRenderer.invoke('connections:setDefault', id),
connectTo: (id: string) => ipcRenderer.invoke('connections:connect', id),
validateUrl: (url: string) => ipcRenderer.invoke('validate:url', url)
validateUrl: (url: string) => ipcRenderer.invoke('validate:url', url),
selectFolder: () => ipcRenderer.invoke('dialog:selectFolder'),
// Updater
checkForUpdates: () => ipcRenderer.invoke('updater:check'),
downloadUpdate: () => ipcRenderer.invoke('updater:download'),
installUpdate: () => ipcRenderer.invoke('updater:install')
}
if (process.contextIsolated) {

View file

@ -116,7 +116,7 @@
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-[calc(100%-32px)] h-[calc(100%-32px)] max-w-[900px] max-h-[600px] rounded-2xl overflow-hidden shadow-2xl border border-white/[0.08]"
class="w-[calc(100%-32px)] h-[calc(100%-32px)] max-w-[900px] max-h-[600px] rounded-3xl overflow-hidden shadow-2xl border border-white/[0.08]"
in:fade={{ duration: 150 }}
onclick={(e) => e.stopPropagation()}
>

View file

@ -148,6 +148,13 @@
const connect = async (id: string) => {
destroyTerminal()
showingLogs = false
// Toggle: clicking the active connection unselects it
if (activeConnectionId === id && view === 'connected') {
activeConnectionId = ''
connectedUrl = ''
view = 'welcome'
return
}
if (openConnections.has(id)) {
activeConnectionId = id
connectedUrl = openConnections.get(id)!
@ -412,6 +419,11 @@
{onOpenSettings}
onToggleOpenTerminal={toggleOpenTerminal}
onToggleOtLogs={toggleOtLogs}
onRename={async (id, name) => {
await window.electronAPI.updateConnection(id, { name })
connections.set(await window.electronAPI.getConnections())
}}
onRemove={remove}
{openGithub}
/>
{/if}

View file

@ -19,6 +19,8 @@
onOpenSettings: () => void
onToggleOpenTerminal: () => void
onToggleOtLogs: () => void
onRename: (id: string, name: string) => void
onRemove: (id: string) => void
openGithub: () => void
}
@ -38,8 +40,33 @@
onOpenSettings,
onToggleOpenTerminal,
onToggleOtLogs,
onRename,
onRemove,
openGithub
}: Props = $props()
// Inline rename state
let editingId = $state<string | null>(null)
let editValue = $state('')
let menuOpenId = $state<string | null>(null)
const startRename = (id: string, currentName: string) => {
editingId = id
editValue = currentName
}
const commitRename = () => {
if (editingId && editValue.trim()) {
onRename(editingId, editValue.trim())
}
editingId = null
editValue = ''
}
const cancelRename = () => {
editingId = null
editValue = ''
}
</script>
<div
@ -84,11 +111,12 @@
onclick={() => !isLocalDisabled && onConnect(localConn.id)}
onkeydown={(e) => e.key === 'Enter' && !isLocalDisabled && onConnect(localConn.id)}
>
{#if serverStatus === 'starting'}
{#if serverStatus === 'starting' || (serverStatus === 'running' && !serverReachable)}
<div class="w-[14px] h-[14px] shrink-0 flex items-center justify-center">
<div
class="w-2.5 h-2.5 rounded-full border-2 border-amber-400/60 border-t-transparent animate-spin"
></div>
<svg class="w-3 h-3 animate-spin text-white/40" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
{:else if serverReachable}
<div class="w-[14px] h-[14px] shrink-0 flex items-center justify-center">
@ -101,35 +129,82 @@
<div class="w-2 h-2 rounded-full bg-white/15"></div>
</div>
{/if}
<span
class="text-[12px] {activeConnectionId === localConn.id
? 'opacity-90'
: 'opacity-100'} transition-opacity truncate"
>Open WebUI</span
>
<button
class="ml-auto opacity-0 group-hover:opacity-30 hover:!opacity-70 transition bg-transparent border-none text-[#fafafa] shrink-0"
onclick={(e) => {
e.stopPropagation()
window.electronAPI?.openInBrowser?.(localConn.url)
}}
title="Open in browser"
>
<svg
class="w-[12px] h-[12px]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
{#if editingId === localConn.id}
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
class="text-[12px] bg-transparent text-[#fafafa] px-0 py-0 border-none outline-none rounded-md flex-1 min-w-0"
bind:value={editValue}
autofocus
onfocus={(e) => e.currentTarget.select()}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') commitRename()
if (e.key === 'Escape') cancelRename()
}}
onblur={commitRename}
/>
{:else}
<span
class="text-[12px] {activeConnectionId === localConn.id
? 'opacity-90'
: 'opacity-100'} transition-opacity truncate flex-1 min-w-0"
>{localConn.name ?? 'Open WebUI'}</span
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
</button>
{/if}
<div class="ml-auto relative shrink-0">
<button
class="opacity-20 hover:opacity-70 transition bg-transparent border-none text-[#fafafa] p-0.5 leading-none"
onclick={(e) => {
e.stopPropagation()
menuOpenId = menuOpenId === 'local' ? null : 'local'
}}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM18 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</button>
{#if menuOpenId === 'local'}
<div class="fixed inset-0 z-40" onclick={(e) => { e.stopPropagation(); menuOpenId = null }}></div>
<div
class="absolute right-0 top-6 z-50 w-[160px] bg-[#1a1a1a]/90 backdrop-blur-xl border border-white/[0.08] rounded-2xl shadow-2xl py-0.5 overflow-hidden"
in:fly={{ y: -4, duration: 150 }}
out:fade={{ duration: 100 }}
>
<div class="py-1 px-1.5">
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-white/[0.06] transition bg-transparent border-none text-[#fafafa] rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
startRename(localConn.id, localConn.name ?? 'Open WebUI')
}}
>
<svg class="w-[14px] h-[14px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487z" />
</svg>
Rename
</button>
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-white/[0.06] transition bg-transparent border-none text-[#fafafa] rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
window.electronAPI?.openInBrowser?.(localConn.url)
}}
>
<svg class="w-[14px] h-[14px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
Open in Browser
</button>
</div>
</div>
{/if}
</div>
</div>
{/if}
@ -145,8 +220,8 @@
: 'hover:bg-white/[0.05]'}"
role="button"
tabindex="0"
onclick={() => onConnect(conn.id)}
onkeydown={(e) => e.key === 'Enter' && onConnect(conn.id)}
onclick={() => editingId !== conn.id && onConnect(conn.id)}
onkeydown={(e) => e.key === 'Enter' && editingId !== conn.id && onConnect(conn.id)}
>
<svg
class="w-[14px] h-[14px] shrink-0 opacity-30"
@ -161,35 +236,100 @@
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
/>
</svg>
<span
class="text-[12px] {activeConnectionId === conn.id
? 'opacity-90'
: 'opacity-100'} transition-opacity truncate"
>{conn.name}</span
>
<button
class="ml-auto opacity-0 group-hover:opacity-30 hover:!opacity-70 transition bg-transparent border-none text-[#fafafa] shrink-0"
onclick={(e) => {
e.stopPropagation()
window.electronAPI?.openInBrowser?.(conn.url)
}}
title="Open in browser"
>
<svg
class="w-[12px] h-[12px]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
{#if editingId === conn.id}
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
class="text-[12px] bg-transparent text-[#fafafa] px-0 py-0 border-none outline-none rounded-md flex-1 min-w-0"
bind:value={editValue}
autofocus
onfocus={(e) => e.currentTarget.select()}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') commitRename()
if (e.key === 'Escape') cancelRename()
}}
onblur={commitRename}
/>
{:else}
<span
class="text-[12px] {activeConnectionId === conn.id
? 'opacity-90'
: 'opacity-100'} transition-opacity truncate flex-1 min-w-0"
>{conn.name}</span
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
</button>
{/if}
<!-- Three-dots menu -->
<div class="ml-auto relative shrink-0">
<button
class="opacity-20 hover:opacity-70 transition bg-transparent border-none text-[#fafafa] p-0.5 leading-none"
onclick={(e) => {
e.stopPropagation()
menuOpenId = menuOpenId === conn.id ? null : conn.id
}}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM18 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</button>
{#if menuOpenId === conn.id}
<div class="fixed inset-0 z-40" onclick={(e) => { e.stopPropagation(); menuOpenId = null }}></div>
<div
class="absolute right-0 top-6 z-50 w-[160px] bg-[#1a1a1a]/90 backdrop-blur-xl border border-white/[0.08] rounded-2xl shadow-2xl py-0.5 overflow-hidden"
in:fly={{ y: -4, duration: 150 }}
out:fade={{ duration: 100 }}
>
<div class="py-1 px-1.5">
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-white/[0.06] transition bg-transparent border-none text-[#fafafa] rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
startRename(conn.id, conn.name)
}}
>
<svg class="w-[14px] h-[14px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487z" />
</svg>
Rename
</button>
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-white/[0.06] transition bg-transparent border-none text-[#fafafa] rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
window.electronAPI?.openInBrowser?.(conn.url)
}}
>
<svg class="w-[14px] h-[14px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
Open in Browser
</button>
</div>
<div class="mx-3 border-t border-white/[0.06]"></div>
<div class="py-1 px-1.5">
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-red-500/10 transition bg-transparent border-none text-red-400 rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
onRemove(conn.id)
}}
>
<svg class="w-[14px] h-[14px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
Delete
</button>
</div>
</div>
{/if}
</div>
</div>
{/each}
</div>

View file

@ -1,18 +1,87 @@
<script lang="ts">
import { onMount } from 'svelte'
import { onMount, onDestroy } from 'svelte'
import { appInfo } from '../../../stores'
let openWebuiVersion = $state<string | null>(null)
let openTerminalVersion = $state<string | null>(null)
// Update state
type UpdateStatus = 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'up-to-date' | 'error'
let updateStatus = $state<UpdateStatus>('idle')
let updateVersion = $state<string | null>(null)
let downloadPercent = $state(0)
let updateError = $state<string | null>(null)
let removeListener: (() => void) | null = null
onMount(async () => {
openWebuiVersion = await window.electronAPI.getPackageVersion('open-webui')
openTerminalVersion = await window.electronAPI.getPackageVersion('open-terminal')
// Listen for update events from main process
const handler = (data: any) => {
switch (data.type) {
case 'update:checking':
updateStatus = 'checking'
updateError = null
break
case 'update:available':
updateStatus = 'available'
updateVersion = data.data?.version ?? null
break
case 'update:not-available':
updateStatus = 'up-to-date'
break
case 'update:download-progress':
updateStatus = 'downloading'
downloadPercent = Math.round(data.data?.percent ?? 0)
break
case 'update:downloaded':
updateStatus = 'downloaded'
downloadPercent = 100
break
case 'update:error':
updateStatus = 'error'
updateError = data.data?.message ?? 'Unknown error'
break
}
}
window.electronAPI.onData(handler)
removeListener = () => {
// electron onData doesn't return an unsubscribe, so we just null out
}
})
const openGithub = () => {
window.electronAPI?.openInBrowser?.('https://github.com/open-webui/desktop')
}
const handleCheck = async () => {
updateStatus = 'checking'
updateError = null
try {
await window.electronAPI.checkForUpdates()
} catch (e: any) {
updateStatus = 'error'
updateError = e?.message ?? 'Check failed'
}
}
const handleDownload = async () => {
updateStatus = 'downloading'
downloadPercent = 0
try {
await window.electronAPI.downloadUpdate()
} catch (e: any) {
updateStatus = 'error'
updateError = e?.message ?? 'Download failed'
}
}
const handleInstall = () => {
window.electronAPI.installUpdate()
}
</script>
<div class="flex flex-col divide-y divide-white/[0.04]">
@ -40,6 +109,71 @@
<div class="text-[12px] opacity-30">{$appInfo?.platform ?? 'Unknown'}</div>
</div>
<!-- Update section -->
<div class="py-4">
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">Software Update</div>
{#if updateStatus === 'up-to-date'}
<div class="text-[11px] opacity-25 mt-0.5">You're on the latest version</div>
{:else if updateStatus === 'available' && updateVersion}
<div class="text-[11px] opacity-40 mt-0.5">Version {updateVersion} is available</div>
{:else if updateStatus === 'downloading'}
<div class="text-[11px] opacity-25 mt-0.5">Downloading… {downloadPercent}%</div>
{:else if updateStatus === 'downloaded'}
<div class="text-[11px] opacity-40 mt-0.5">Update ready — restart to apply</div>
{:else if updateStatus === 'error'}
<div class="text-[11px] text-red-400/60 mt-0.5">{updateError}</div>
{/if}
</div>
<div>
{#if updateStatus === 'idle' || updateStatus === 'up-to-date' || updateStatus === 'error'}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-white/[0.06] transition border-none text-[#fafafa] rounded-xl"
onclick={handleCheck}
>
Check for Updates
</button>
{:else if updateStatus === 'checking'}
<button
class="text-[12px] opacity-30 px-3 py-1.5 bg-white/[0.06] border-none text-[#fafafa] rounded-xl pointer-events-none flex items-center gap-1.5"
disabled
>
<svg class="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-dasharray="31.4 31.4" stroke-linecap="round" />
</svg>
Checking…
</button>
{:else if updateStatus === 'available'}
<button
class="text-[12px] opacity-50 hover:opacity-80 px-3 py-1.5 bg-white/[0.08] transition border-none text-[#fafafa] rounded-xl"
onclick={handleDownload}
>
Download Update
</button>
{:else if updateStatus === 'downloading'}
<div class="flex items-center gap-2">
<div class="w-24 h-1.5 bg-white/[0.06] rounded-full overflow-hidden">
<div
class="h-full bg-white/30 rounded-full transition-all duration-300"
style="width: {downloadPercent}%"
></div>
</div>
<span class="text-[11px] opacity-30">{downloadPercent}%</span>
</div>
{:else if updateStatus === 'downloaded'}
<button
class="text-[12px] opacity-50 hover:opacity-80 px-3 py-1.5 bg-white/[0.08] transition border-none text-[#fafafa] rounded-xl"
onclick={handleInstall}
>
Restart to Update
</button>
{/if}
</div>
</div>
</div>
<div class="py-4">
<button
class="text-[12px] opacity-40 hover:opacity-70 transition bg-transparent border-none text-[#fafafa]"

View file

@ -7,6 +7,7 @@
let version = $state<string | null>(null)
let stopping = $state(false)
let starting = $state(false)
let restarting = $state(false)
let updating = $state(false)
let loaded = $state(false)
let uninstalling = $state(false)
@ -51,6 +52,18 @@
starting = false
}
const restartTerminal = async () => {
restarting = true
try {
await window.electronAPI.stopOpenTerminal()
await window.electronAPI.startOpenTerminal()
otInfo = await window.electronAPI.getOpenTerminalInfo()
} catch (e) {
console.error('Failed to restart Open Terminal:', e)
}
restarting = false
}
const updatePackage = async () => {
updating = true
try {
@ -149,6 +162,21 @@
Stop
{/if}
</button>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-white/[0.06] transition border-none text-[#fafafa] rounded-xl flex items-center gap-1.5 {restarting ? 'pointer-events-none opacity-20' : ''}"
disabled={restarting}
onclick={restartTerminal}
>
{#if restarting}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-white/30 border-t-transparent animate-spin"></div>
Restarting…
{:else}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M20.015 4.356v4.992m0 0h-4.992m4.993 0l-3.181-3.183a8.25 8.25 0 00-13.803 3.7" />
</svg>
Restart
{/if}
</button>
{:else}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-white/[0.06] transition border-none text-[#fafafa] rounded-xl flex items-center gap-1.5 {starting ? 'pointer-events-none opacity-20' : ''}"
@ -221,6 +249,32 @@
/>
</div>
<div class="py-4 flex items-center justify-between gap-4">
<div class="shrink-0">
<div class="text-[13px] opacity-70">Working Directory</div>
<div class="text-[11px] opacity-25 mt-0.5">Starting directory for terminal sessions</div>
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1 max-w-[280px] justify-end">
<input
type="text"
class="bg-white/[0.06] text-[12px] text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60 min-w-0 flex-1 text-right font-mono"
placeholder="~ (default)"
value={$config?.openTerminal?.cwd ?? ''}
onchange={(e) =>
updateOtConfig('cwd', (e.target as HTMLInputElement).value.trim())}
/>
<button
class="shrink-0 text-[12px] opacity-40 hover:opacity-70 px-2.5 py-1.5 bg-white/[0.06] transition border-none text-[#fafafa] rounded-xl"
onclick={async () => {
const folder = await window.electronAPI.selectFolder()
if (folder) updateOtConfig('cwd', folder)
}}
>
Browse
</button>
</div>
</div>
{#if isRunning && otInfo}
<div class="py-4">
<div class="text-[13px] opacity-70 mb-3">Running Instance</div>

View file

@ -6,6 +6,7 @@
let updating = $state(false)
let stopping = $state(false)
let starting = $state(false)
let restarting = $state(false)
let version = $state<string | null>(null)
let loaded = $state(false)
@ -54,6 +55,17 @@
starting = false
}
const restartServer = async () => {
restarting = true
try {
await window.electronAPI.restartServer()
serverStatus = 'running'
} catch (e) {
console.error('Failed to restart server:', e)
}
restarting = false
}
const updatePackage = async () => {
updating = true
try {
@ -144,6 +156,21 @@
Stop
{/if}
</button>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-white/[0.06] transition border-none text-[#fafafa] rounded-xl flex items-center gap-1.5 {restarting ? 'pointer-events-none opacity-20' : ''}"
disabled={restarting}
onclick={restartServer}
>
{#if restarting}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-white/30 border-t-transparent animate-spin"></div>
Restarting…
{:else}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M20.015 4.356v4.992m0 0h-4.992m4.993 0l-3.181-3.183a8.25 8.25 0 00-13.803 3.7" />
</svg>
Restart
{/if}
</button>
{:else}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-white/[0.06] transition border-none text-[#fafafa] rounded-xl flex items-center gap-1.5 {starting ? 'pointer-events-none opacity-20' : ''}"