From 368642433a43a069093feae69054e6c2579c7e8b Mon Sep 17 00:00:00 2001 From: Wesley Stewart Date: Mon, 25 May 2026 15:18:47 -0400 Subject: [PATCH 1/2] Add trusted header SSO connection auth --- src/main/index.ts | 486 ++++++++++++------ src/main/utils/index.ts | 59 ++- src/preload/index.ts | 7 +- .../lib/components/Main/Connections.svelte | 216 ++++++-- .../Connections/AddConnectionModal.svelte | 113 +++- .../Main/Connections/Content.svelte | 233 +++++++-- 6 files changed, 847 insertions(+), 267 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index ca8e43792..16212cf87 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -162,6 +162,7 @@ let SERVER_REACHABLE = false let SERVER_PID: number | null = null let AUTH_TOKEN: string | null = null let voiceInputRecording = false +const trustedHeaderSessions = new Set() // ─── Global Shortcuts ─────────────────────────────────── @@ -223,15 +224,20 @@ function tryRegisterShortcut( } } -const registerShortcuts = (globalAccel?: string, spotlightAccel?: string, voiceInputAccel?: string, callAccel?: string): void => { +const registerShortcuts = ( + globalAccel?: string, + spotlightAccel?: string, + voiceInputAccel?: string, + callAccel?: string +): void => { globalShortcut.unregisterAll() // On Wayland / Flatpak global shortcuts are unsupported — skip silently. if (!isGlobalShortcutSupported()) { log.info( 'Global shortcut registration skipped — unsupported environment ' + - `(XDG_SESSION_TYPE=${process.env['XDG_SESSION_TYPE'] ?? '(unset)'}, ` + - `FLATPAK_ID=${process.env['FLATPAK_ID'] ?? '(unset)'})` + `(XDG_SESSION_TYPE=${process.env['XDG_SESSION_TYPE'] ?? '(unset)'}, ` + + `FLATPAK_ID=${process.env['FLATPAK_ID'] ?? '(unset)'})` ) return } @@ -251,9 +257,8 @@ const registerShortcuts = (globalAccel?: string, spotlightAccel?: string, voiceI // Spotlight shortcut – toggle the spotlight input bar if (spotlightAccel) { tryRegisterShortcut(spotlightAccel, 'Spotlight', () => { - const text = CONFIG?.spotlightClipboardPaste !== false - ? (clipboard.readText()?.trim() || '') - : '' + const text = + CONFIG?.spotlightClipboardPaste !== false ? clipboard.readText()?.trim() || '' : '' toggleSpotlight(text) }) } @@ -264,7 +269,9 @@ const registerShortcuts = (globalAccel?: string, spotlightAccel?: string, voiceI toggleVoiceInput() }) } else { - log.info(`Voice input shortcut skipped — accel="${voiceInputAccel}", enabled=${CONFIG?.voiceInputEnabled}`) + log.info( + `Voice input shortcut skipped — accel="${voiceInputAccel}", enabled=${CONFIG?.voiceInputEnabled}` + ) } // Call shortcut – open the voice/video call overlay @@ -458,7 +465,10 @@ function playChime(ascending: boolean): Promise { const exists = fs.existsSync(soundPath) log.info(`playChime: ${ascending ? 'start' : 'stop'}, path=${soundPath}, exists=${exists}`) - if (!exists) { resolve(); return } + if (!exists) { + resolve() + return + } if (process.platform === 'darwin') { execFile('afplay', [soundPath], (err, stdout, stderr) => { @@ -466,9 +476,11 @@ function playChime(ascending: boolean): Promise { resolve() }) } else if (process.platform === 'win32') { - execFile('powershell', ['-NoProfile', '-Command', - `(New-Object Media.SoundPlayer '${soundPath}').PlaySync()` - ], () => resolve()) + execFile( + 'powershell', + ['-NoProfile', '-Command', `(New-Object Media.SoundPlayer '${soundPath}').PlaySync()`], + () => resolve() + ) } else { execFile('paplay', [soundPath], (err) => { if (err) execFile('aplay', [soundPath], () => resolve()) @@ -599,7 +611,10 @@ function debounceSaveWindowBounds(win: BrowserWindow): void { */ function isBoundsOnVisibleDisplay(bounds: { x: number; y: number }): boolean { const { screen } = require('electron') - const targetPoint = { x: bounds.x + MIN_VISIBLE_OVERLAP_PX / 2, y: bounds.y + MIN_VISIBLE_OVERLAP_PX / 2 } + const targetPoint = { + x: bounds.x + MIN_VISIBLE_OVERLAP_PX / 2, + y: bounds.y + MIN_VISIBLE_OVERLAP_PX / 2 + } const display = screen.getDisplayNearestPoint(targetPoint) const { x, y, width, height } = display.workArea return ( @@ -731,7 +746,12 @@ function createContentWindow(url: string, connectionId: string): BrowserWindow { session .fromPartition(`persist:connection-${connectionId}`) .setPermissionRequestHandler((_webContents, permission, callback) => { - const allowedPermissions = ['media', 'mediaKeySystem', 'notifications', 'clipboard-sanitized-write'] + const allowedPermissions = [ + 'media', + 'mediaKeySystem', + 'notifications', + 'clipboard-sanitized-write' + ] callback(allowedPermissions.includes(permission)) }) @@ -782,14 +802,16 @@ const updateTray = () => { // Virtual local connection (when package is installed) const localItem = isPackageInstalled('open-webui') - ? [{ - label: `${CONFIG.defaultConnectionId === 'local' ? '★ ' : ''}Open WebUI (Local)`, - sublabel: SERVER_URL || `http://127.0.0.1:${CONFIG.localServer?.port ?? 8080}`, - click: async () => { - const result = await connectTo(buildLocalConnection()) - if (result) sendToRenderer('connection:open', result) + ? [ + { + label: `${CONFIG.defaultConnectionId === 'local' ? '★ ' : ''}Open WebUI (Local)`, + sublabel: SERVER_URL || `http://127.0.0.1:${CONFIG.localServer?.port ?? 8080}`, + click: async () => { + const result = await connectTo(buildLocalConnection()) + if (result) sendToRenderer('connection:open', result) + } } - }] + ] : [] const allItems = [...localItem, ...remoteItems] @@ -804,11 +826,7 @@ const updateTray = () => { }, { type: 'separator' }, ...(allItems.length > 0 - ? [ - { label: 'Connections', enabled: false }, - ...allItems, - { type: 'separator' } - ] + ? [{ label: 'Connections', enabled: false }, ...allItems, { type: 'separator' }] : []), ...(SERVER_STATUS === 'started' && SERVER_URL ? [ @@ -870,7 +888,86 @@ const resolveConnectionUrl = (conn: Connection): string => { return url } -const connectTo = async (connection: Connection) => { +const getConnectionPartition = (connectionId: string): string => + `persist:connection-${connectionId}` + +const getTrustedHeaderEntries = ( + connection: Connection +): Array<{ name: string; value: string }> => { + if (connection.auth?.type !== 'trustedHeader') return [] + return (connection.auth.trustedHeaders ?? []) + .map((header) => ({ + name: String(header?.name ?? '').trim(), + value: String(header?.value ?? '').trim() + })) + .filter((header) => header.name && header.value) +} + +const configureTrustedHeaderSession = (connection: Connection, url: string): void => { + const partition = getConnectionPartition(connection.id) + const ses = session.fromPartition(partition) + const headers = getTrustedHeaderEntries(connection) + + if (trustedHeaderSessions.has(partition)) { + ses.webRequest.onBeforeSendHeaders(null) + trustedHeaderSessions.delete(partition) + } + + if (!headers.length) return + + let origin: string + try { + origin = new URL(url).origin + } catch { + return + } + + ses.webRequest.onBeforeSendHeaders({ urls: [`${origin}/*`] }, (details, callback) => { + const requestHeaders = { ...details.requestHeaders } + for (const header of headers) { + requestHeaders[header.name] = header.value + } + callback({ requestHeaders }) + }) + trustedHeaderSessions.add(partition) +} + +const openBrowserAuthWindow = async (connection: Connection): Promise => { + const authUrl = resolveConnectionUrl(connection) + if (!authUrl) return false + + return await new Promise((resolve) => { + const authWindow = new BrowserWindow({ + width: 980, + height: 760, + show: false, + title: `${connection.name || 'Open WebUI'} Authentication`, + autoHideMenuBar: true, + parent: mainWindow ?? undefined, + webPreferences: { + partition: getConnectionPartition(connection.id), + sandbox: false + } + }) + + authWindow.webContents.setWindowOpenHandler((details) => { + authWindow.loadURL(details.url) + return { action: 'deny' } + }) + + authWindow.once('ready-to-show', () => authWindow.show()) + authWindow.once('closed', () => resolve(true)) + authWindow.loadURL(authUrl).catch((error) => { + log.warn('Browser auth window failed to load:', error) + if (!authWindow.isDestroyed()) authWindow.close() + resolve(false) + }) + }) +} + +const connectTo = async ( + connection: Connection +): Promise<{ url: string; connectionId: string } | null> => { let url = connection.url if (connection.type === 'local') { @@ -903,6 +1000,8 @@ const connectTo = async (connection: Connection) => { url = url.replace('http://0.0.0.0', 'http://localhost') } + configureTrustedHeaderSession(connection, url) + return { url, connectionId: connection.id } } @@ -1214,9 +1313,7 @@ if (!gotTheLock) { // shortcut targets (see issue #110). app.on('child-process-gone', (_event, details) => { if (details.type === 'GPU') { - log.error( - `GPU process gone: reason=${details.reason}, exitCode=${details.exitCode}` - ) + log.error(`GPU process gone: reason=${details.reason}, exitCode=${details.exitCode}`) // Only auto-recover from fatal crashes, not normal/clean exits if ( @@ -1253,7 +1350,7 @@ if (!gotTheLock) { app.on('certificate-error', (event, _webContents, url, error, certificate, callback) => { log.warn( `Certificate error: ${error} for ${url} ` + - `(subject: ${certificate.subjectName}, issuer: ${certificate.issuerName})` + `(subject: ${certificate.subjectName}, issuer: ${certificate.issuerName})` ) event.preventDefault() callback(true) @@ -1275,7 +1372,13 @@ if (!gotTheLock) { // Grant media / notification permissions for webview partition sessions // so that auth flows, media capture, and notifications work correctly. newSession.setPermissionRequestHandler((_webContents, permission, callback) => { - const allowed = ['media', 'mediaKeySystem', 'notifications', 'clipboard-read', 'clipboard-sanitized-write'] + const allowed = [ + 'media', + 'mediaKeySystem', + 'notifications', + 'clipboard-read', + 'clipboard-sanitized-write' + ] callback(allowed.includes(permission)) }) }) @@ -1286,9 +1389,7 @@ if (!gotTheLock) { // Auto-reload when the renderer process dies so the user doesn't // see a permanent blank/grey screen. window.webContents.on('render-process-gone', (_event, details) => { - log.error( - `Renderer process gone: reason=${details.reason}, exitCode=${details.exitCode}` - ) + log.error(`Renderer process gone: reason=${details.reason}, exitCode=${details.exitCode}`) if (details.reason !== 'clean-exit') { window.webContents.reload() } @@ -1306,7 +1407,7 @@ if (!gotTheLock) { if (details.reason !== 'clean-exit') { log.error( `WebContents render-process-gone: type=${contents.getType()}, ` + - `reason=${details.reason}, exitCode=${details.exitCode}` + `reason=${details.reason}, exitCode=${details.exitCode}` ) } }) @@ -1379,9 +1480,7 @@ if (!gotTheLock) { ) } else if (params.selectionText) { // Non-editable text selection - menuItems.push( - { label: 'Copy', role: 'copy', enabled: params.editFlags.canCopy } - ) + menuItems.push({ label: 'Copy', role: 'copy', enabled: params.editFlags.canCopy }) } if (menuItems.length > 0) { @@ -1431,7 +1530,12 @@ if (!gotTheLock) { CONFIG = await getConfig() updateTray() voiceInputRecording = false - registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut, CONFIG.callShortcut) + registerShortcuts( + CONFIG.globalShortcut, + CONFIG.spotlightShortcut, + CONFIG.voiceInputShortcut, + CONFIG.callShortcut + ) }) // Python/uv @@ -1445,7 +1549,11 @@ if (!gotTheLock) { return res } catch (error) { sendToRenderer('status:python', false) - sendToRenderer('error', { message: error?.message ?? 'Python installation failed. Please check your internet connection and try again.' }) + sendToRenderer('error', { + message: + error?.message ?? + 'Python installation failed. Please check your internet connection and try again.' + }) return false } }) @@ -1468,9 +1576,7 @@ if (!gotTheLock) { sendToRenderer('status:install', 'Installing Open Terminal…') await installPackage('open-terminal', otVersion, (status: string) => { sendToRenderer('status:install', status) - }).catch((e) => - log.warn('open-terminal install failed (non-fatal):', e) - ) + }).catch((e) => log.warn('open-terminal install failed (non-fatal):', e)) sendToRenderer('status:package', true) // Notify renderer of install state change sendToRenderer('packages:changed', { @@ -1487,7 +1593,11 @@ if (!gotTheLock) { return true } catch (error) { sendToRenderer('status:package', false) - sendToRenderer('error', { message: error?.message ?? 'Package installation failed. Please check your internet connection and try again.' }) + sendToRenderer('error', { + message: + error?.message ?? + 'Package installation failed. Please check your internet connection and try again.' + }) return false } }) @@ -1546,18 +1656,21 @@ if (!gotTheLock) { return config.connections }) - ipcMain.handle('connections:update', async (_event, id: string, updates: Partial) => { - 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() - sendToRenderer('connections:changed', config.connections) + ipcMain.handle( + 'connections:update', + async (_event, id: string, updates: Partial) => { + 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() + sendToRenderer('connections:changed', config.connections) + } + return config.connections } - return config.connections - }) + ) ipcMain.handle('connections:setDefault', async (_event, id: string) => { const config = await getConfig() @@ -1580,10 +1693,39 @@ if (!gotTheLock) { return null }) - ipcMain.handle('validate:url', async (_event, url: string) => { - return await validateRemoteUrl(url) + ipcMain.handle('connections:authenticate', async (_event, id: string) => { + const config = await getConfig() + const conn = config.connections.find((c) => c.id === id) + if (!conn) return false + configureTrustedHeaderSession(conn, resolveConnectionUrl(conn)) + return await openBrowserAuthWindow(conn) }) + ipcMain.handle( + 'validate:url', + async (_event, url: string, headers?: Array<{ name: string; value: string }>) => { + if (!headers?.length) return await validateRemoteUrl(url) + try { + const requestHeaders = Object.fromEntries( + headers + .map((header) => [ + String(header?.name ?? '').trim(), + String(header?.value ?? '').trim() + ]) + .filter(([name, value]) => name && value) + ) + const response = await session.defaultSession.fetch(url, { + method: 'HEAD', + headers: requestHeaders, + signal: AbortSignal.timeout(5000) + }) + return response.ok + } catch { + return false + } + } + ) + // Updater ipcMain.handle('updater:check', () => checkForUpdates()) ipcMain.handle('updater:download', () => downloadUpdate()) @@ -1664,9 +1806,11 @@ if (!gotTheLock) { body: 'Open WebUI needs Screen Recording access to capture screenshots. Please enable it in System Settings → Privacy & Security → Screen Recording, then restart the app.' }).show() // Open the correct System Preferences pane - shell.openExternal( - 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture' - ).catch(() => {}) + shell + .openExternal( + 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture' + ) + .catch(() => {}) return 'no-permission' } } @@ -1691,8 +1835,7 @@ if (!gotTheLock) { }) // Find the source matching this display - const source = - sources.find((s) => s.display_id === String(display.id)) || sources[0] + const source = sources.find((s) => s.display_id === String(display.id)) || sources[0] if (!source) { spotlightWindow?.setOpacity(1) return null @@ -1744,84 +1887,95 @@ if (!gotTheLock) { }) // Transcribe audio via the connected server's STT endpoint - ipcMain.handle('voiceInput:transcribe', async (_event, audioBuffer: ArrayBuffer, rendererToken?: string) => { - try { - const conn = await getDefaultConnection() - if (!conn) throw new Error('No connection configured. Set up a connection in Settings first.') + ipcMain.handle( + 'voiceInput:transcribe', + async (_event, audioBuffer: ArrayBuffer, rendererToken?: string) => { + try { + const conn = await getDefaultConnection() + if (!conn) + throw new Error('No connection configured. Set up a connection in Settings first.') - const url = resolveConnectionUrl(conn) + const url = resolveConnectionUrl(conn) - // Use stored auth token (relayed from webview), fall back to renderer-provided or contentWindow - let token = AUTH_TOKEN || rendererToken || '' - if (!token) { - // Scan all webContents to find the Open WebUI webview and read its token - try { - const { webContents: wc } = require('electron') - const allContents = wc.getAllWebContents() - for (const contents of allContents) { - try { - if (contents.getType() === 'webview' && !contents.isDestroyed()) { - const t = await contents.executeJavaScript( - `localStorage.getItem('token') || ''` - ) - if (t) { token = t; break } + // Use stored auth token (relayed from webview), fall back to renderer-provided or contentWindow + let token = AUTH_TOKEN || rendererToken || '' + if (!token) { + // Scan all webContents to find the Open WebUI webview and read its token + try { + const { webContents: wc } = require('electron') + const allContents = wc.getAllWebContents() + for (const contents of allContents) { + try { + if (contents.getType() === 'webview' && !contents.isDestroyed()) { + const t = await contents.executeJavaScript( + `localStorage.getItem('token') || ''` + ) + if (t) { + token = t + break + } + } + } catch { + // Skip inaccessible webContents } - } catch { - // Skip inaccessible webContents } + } catch { + log.warn('voiceInput:transcribe — could not extract token from webviews') } - } catch { - log.warn('voiceInput:transcribe — could not extract token from webviews') } - } - if (!token) { - throw new Error('Not authenticated. Open a connection and sign in before using voice input.') - } + if (!token) { + throw new Error( + 'Not authenticated. Open a connection and sign in before using voice input.' + ) + } - // Build multipart form data manually using Node.js - const boundary = '----VoiceInput' + Date.now() - const buffer = Buffer.from(audioBuffer) - const filename = `recording-${Date.now()}.wav` - - const header = [ - `--${boundary}`, - `Content-Disposition: form-data; name="file"; filename="${filename}"`, - `Content-Type: audio/wav`, - '', - '' - ].join('\r\n') - - const footer = `\r\n--${boundary}--\r\n` - const headerBuf = Buffer.from(header, 'utf-8') - const footerBuf = Buffer.from(footer, 'utf-8') - const body = Buffer.concat([headerBuf, buffer, footerBuf]) - - const response = await fetch(`${url}/api/v1/audio/transcriptions`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': `multipart/form-data; boundary=${boundary}` - }, - body - }) + // Build multipart form data manually using Node.js + const boundary = '----VoiceInput' + Date.now() + const buffer = Buffer.from(audioBuffer) + const filename = `recording-${Date.now()}.wav` + + const header = [ + `--${boundary}`, + `Content-Disposition: form-data; name="file"; filename="${filename}"`, + `Content-Type: audio/wav`, + '', + '' + ].join('\r\n') + + const footer = `\r\n--${boundary}--\r\n` + const headerBuf = Buffer.from(header, 'utf-8') + const footerBuf = Buffer.from(footer, 'utf-8') + const body = Buffer.concat([headerBuf, buffer, footerBuf]) + + const response = await fetch(`${url}/api/v1/audio/transcriptions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': `multipart/form-data; boundary=${boundary}` + }, + body + }) - if (!response.ok) { - const text = await response.text().catch(() => '') - throw new Error(`Transcription failed (HTTP ${response.status}). ${text || 'Check that your server has Speech-to-Text configured.'}`) - } + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new Error( + `Transcription failed (HTTP ${response.status}). ${text || 'Check that your server has Speech-to-Text configured.'}` + ) + } - const result = await response.json() - return result - } catch (error: any) { - log.error('voiceInput:transcribe failed:', error) - new Notification({ - title: 'Voice Input Failed', - body: error?.message || 'Transcription failed. Check logs for details.' - }).show() - throw error + const result = await response.json() + return result + } catch (error: any) { + log.error('voiceInput:transcribe failed:', error) + new Notification({ + title: 'Voice Input Failed', + body: error?.message || 'Transcription failed. Check logs for details.' + }).show() + throw error + } } - }) + ) // Voice input completed — deliver text to chat ipcMain.handle('voiceInput:done', async (_event, text: string) => { @@ -2026,29 +2180,56 @@ if (!gotTheLock) { ipcMain.handle('huggingface:repo:files', async (_event, repo: string, token?: string) => { return getRepoFiles(repo, token) }) - ipcMain.handle('huggingface:models:download', async (_event, repo: string, filename: string, token?: string, expectedSize?: number) => { - try { - sendToRenderer('status:huggingface-download', { repo, filename, status: 'downloading', percent: 0 }) - const filepath = await downloadModel(repo, filename, (progress) => { + ipcMain.handle( + 'huggingface:models:download', + async (_event, repo: string, filename: string, token?: string, expectedSize?: number) => { + try { sendToRenderer('status:huggingface-download', { - repo, filename, + repo, + filename, status: 'downloading', - percent: progress.percent, - downloadedBytes: progress.downloadedBytes, - totalBytes: progress.totalBytes + percent: 0 }) - }, token, expectedSize) - sendToRenderer('status:huggingface-download', { repo, filename, status: 'done', filepath }) - return filepath - } catch (error) { - log.error('Failed to download model:', error) - sendToRenderer('status:huggingface-download', { repo, filename, status: 'failed', error: error?.message }) - sendToRenderer('error', { message: `Model download failed: ${error?.message}` }) - return null + const filepath = await downloadModel( + repo, + filename, + (progress) => { + sendToRenderer('status:huggingface-download', { + repo, + filename, + status: 'downloading', + percent: progress.percent, + downloadedBytes: progress.downloadedBytes, + totalBytes: progress.totalBytes + }) + }, + token, + expectedSize + ) + sendToRenderer('status:huggingface-download', { + repo, + filename, + status: 'done', + filepath + }) + return filepath + } catch (error) { + log.error('Failed to download model:', error) + sendToRenderer('status:huggingface-download', { + repo, + filename, + status: 'failed', + error: error?.message + }) + sendToRenderer('error', { message: `Model download failed: ${error?.message}` }) + return null + } } - }) + ) - ipcMain.handle('package:version', (_event, packageName: string) => getPackageVersion(packageName)) + ipcMain.handle('package:version', (_event, packageName: string) => + getPackageVersion(packageName) + ) ipcMain.handle('package:uninstall', async (_event, packageName: string) => { const result = uninstallPackage(packageName) // Notify renderer of install state change @@ -2063,7 +2244,7 @@ if (!gotTheLock) { const result = await dialog.showOpenDialog(mainWindow!, { properties: ['openDirectory'] }) - return result.canceled ? null : result.filePaths[0] ?? null + return result.canceled ? null : (result.filePaths[0] ?? null) }) ipcMain.handle('app:launchAtLogin:get', () => { @@ -2124,10 +2305,13 @@ if (!gotTheLock) { tray.setToolTip('Open WebUI') updateTray() - - // Global shortcut - registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut, CONFIG.callShortcut) + registerShortcuts( + CONFIG.globalShortcut, + CONFIG.spotlightShortcut, + CONFIG.voiceInputShortcut, + CONFIG.callShortcut + ) // Enable screen capture session.defaultSession.setDisplayMediaRequestHandler( diff --git a/src/main/utils/index.ts b/src/main/utils/index.ts index ad57292b1..5df23f7c3 100644 --- a/src/main/utils/index.ts +++ b/src/main/utils/index.ts @@ -262,7 +262,10 @@ const checkInternet = async () => { } } -export const installPython = async (installationDir?: string, onStatus?: (status: string) => void): Promise => { +export const installPython = async ( + installationDir?: string, + onStatus?: (status: string) => void +): Promise => { const pythonDownloadPath = getPythonDownloadPath() if (!fs.existsSync(pythonDownloadPath)) { if (!(await checkInternet())) { @@ -296,10 +299,10 @@ export const installPython = async (installationDir?: string, onStatus?: (status } catch (error) { log.error(error) // Remove possibly-corrupted download so next retry re-downloads - try { fs.unlinkSync(pythonDownloadPath) } catch {} - throw new Error( - 'Failed to extract Python. The download may be corrupted. Please try again.' - ) + try { + fs.unlinkSync(pythonDownloadPath) + } catch {} + throw new Error('Failed to extract Python. The download may be corrupted. Please try again.') } if (!isPythonInstalled(installationDir)) { @@ -435,10 +438,16 @@ export const uninstallPython = (installationDir?: string): boolean => { // ─── Package Management ───────────────────────────────── -export const installPackage = (packageName: string, version?: string, onStatus?: (status: string) => void): Promise => { +export const installPackage = ( + packageName: string, + version?: string, + onStatus?: (status: string) => void +): Promise => { return new Promise((resolve, reject) => { if (!isPythonInstalled()) { - return reject(new Error('Python is not installed. Please reinstall the app or run setup again.')) + return reject( + new Error('Python is not installed. Please reinstall the app or run setup again.') + ) } const pythonPath = getPythonPath() const commandProcess = execFile( @@ -477,9 +486,12 @@ export const installPackage = (packageName: string, version?: string, onStatus?: if (code === 0) { resolve(true) } else { - reject(new Error( - lastLine || `Package installation failed (exit code ${code}). Please check your internet connection and try again.` - )) + reject( + new Error( + lastLine || + `Package installation failed (exit code ${code}). Please check your internet connection and try again.` + ) + ) } }) commandProcess.on('error', (error) => { @@ -489,10 +501,7 @@ export const installPackage = (packageName: string, version?: string, onStatus?: }) } -export const installPackages = async ( - packages: string[], - version?: string -): Promise => { +export const installPackages = async (packages: string[], version?: string): Promise => { for (const pkg of packages) { const ok = await installPackage(pkg, version) if (!ok) return false @@ -607,9 +616,7 @@ export const startServer = async ( }) }) } catch (error) { - throw new Error( - `Failed to spawn PTY with ${pythonPath}: ${error?.message ?? error}` - ) + throw new Error(`Failed to spawn PTY with ${pythonPath}: ${error?.message ?? error}`) } const pid = ptyProcess.pid @@ -639,7 +646,6 @@ export const startServer = async ( return { url, pid } } - export async function stopAllServers(): Promise { log.info('Stopping all servers...') const pidsToStop = Array.from(serverPIDs) @@ -792,7 +798,10 @@ export const checkUrlAndOpen = async (url: string, callback: Function = async () export const validateRemoteUrl = async (url: string): Promise => { try { - const response = await electronNet.fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) }) + const response = await electronNet.fetch(url, { + method: 'HEAD', + signal: AbortSignal.timeout(5000) + }) return response.ok } catch { return false @@ -806,6 +815,14 @@ export interface Connection { name: string type: 'local' | 'remote' url: string + auth?: { + type: 'none' | 'trustedHeader' + browserAuth?: boolean + trustedHeaders?: Array<{ + name: string + value: string + }> + } } export interface AppConfig { @@ -904,7 +921,9 @@ export const setConfig = async (config: Partial): Promise => { // Serialize writes so concurrent callers don't race on the tmp file const previous = configWriteLock let resolve: () => void - configWriteLock = new Promise((r) => { resolve = r }) + configWriteLock = new Promise((r) => { + resolve = r + }) await previous const configPath = path.join(getUserDataPath(), 'config.json') diff --git a/src/preload/index.ts b/src/preload/index.ts index 1a3486898..2c77b2804 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -170,10 +170,13 @@ 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), + 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), + authenticateConnection: (id: string) => ipcRenderer.invoke('connections:authenticate', id), + validateUrl: (url: string, headers?: Array<{ name: string; value: string }>) => + ipcRenderer.invoke('validate:url', url, headers), selectFolder: () => ipcRenderer.invoke('dialog:selectFolder'), // Updater diff --git a/src/renderer/src/lib/components/Main/Connections.svelte b/src/renderer/src/lib/components/Main/Connections.svelte index 242c68017..331b3bd43 100644 --- a/src/renderer/src/lib/components/Main/Connections.svelte +++ b/src/renderer/src/lib/components/Main/Connections.svelte @@ -15,11 +15,7 @@ activeConnectionName?: string } - let { - onOpenSettings, - sidebarOpen, - activeConnectionName = $bindable('') - }: Props = $props() + let { onOpenSettings, sidebarOpen, activeConnectionName = $bindable('') }: Props = $props() let isLocalConnection = $state(false) let showingLogs = $state(false) @@ -27,6 +23,16 @@ let url = $state('') let connecting = $state(false) let error = $state('') + let trustedHeaderAuth = $state(false) + let browserAuth = $state(false) + let trustedEmailHeaderName = $state('X-User-Email') + let trustedEmailHeaderValue = $state('') + let trustedNameHeaderName = $state('X-User-Name') + let trustedNameHeaderValue = $state('') + let trustedGroupsHeaderName = $state('X-User-Groups') + let trustedGroupsHeaderValue = $state('') + let trustedRoleHeaderName = $state('X-User-Role') + let trustedRoleHeaderValue = $state('') let view = $state('welcome') // welcome | install | connected let autoInstall = $state(false) let installPhase = $state('idle') // idle | working | error @@ -50,9 +56,15 @@ const serverReachable = $derived($serverInfo?.reachable) const isInitializing = $derived($appState === 'initializing') - const localConn = $derived(localInstalled - ? { id: 'local', name: 'Open WebUI', type: 'local' as const, url: `http://127.0.0.1:${$config?.localServer?.port ?? 8080}` } - : null + const localConn = $derived( + localInstalled + ? { + id: 'local', + name: 'Open WebUI', + type: 'local' as const, + url: `http://127.0.0.1:${$config?.localServer?.port ?? 8080}` + } + : null ) const remoteConnections = $derived($connections ?? []) @@ -66,7 +78,11 @@ let llamaCppSetupStatus = $state('') let openTerminalSetupStatus = $state('') - const startInstall = async (options?: { installOpenTerminal?: boolean; installLlamaCpp?: boolean; installDir?: string }) => { + const startInstall = async (options?: { + installOpenTerminal?: boolean + installLlamaCpp?: boolean + installDir?: string + }) => { installPhase = 'working' installError = '' installStatus = '' @@ -85,7 +101,9 @@ const disk = await window.electronAPI.getDiskSpace() if (disk?.free >= 0 && disk.free < MINIMUM_DISK_BYTES) { const availableGB = (disk.free / (1024 * 1024 * 1024)).toFixed(1) - throw new Error(`Not enough disk space. At least 5 GB is required (${availableGB} GB available).`) + throw new Error( + `Not enough disk space. At least 5 GB is required (${availableGB} GB available).` + ) } // Ensure Python and uv are installed before attempting package install @@ -143,7 +161,9 @@ installError = e?.message || $i18n.t('error.somethingWentWrong') toastVisible = true if (toastTimeout) clearTimeout(toastTimeout) - toastTimeout = setTimeout(() => { toastVisible = false }, 5000) + toastTimeout = setTimeout(() => { + toastVisible = false + }, 5000) } } @@ -158,23 +178,66 @@ error = $i18n.t('setup.invalidUrl') return } + const trustedHeaders = trustedHeaderAuth + ? [ + { name: trustedEmailHeaderName, value: trustedEmailHeaderValue }, + { name: trustedNameHeaderName, value: trustedNameHeaderValue }, + { name: trustedGroupsHeaderName, value: trustedGroupsHeaderValue }, + { name: trustedRoleHeaderName, value: trustedRoleHeaderValue } + ] + .map((header) => ({ + name: header.name.trim(), + value: header.value.trim() + })) + .filter((header) => header.name && header.value) + : [] + + if (trustedHeaderAuth && trustedHeaders.length === 0 && !browserAuth) { + error = 'Add at least one trusted header or enable browser auth' + return + } + connecting = true try { - const valid = await window.electronAPI.validateUrl(u) + const valid = + trustedHeaderAuth && browserAuth + ? true + : await window.electronAPI.validateUrl(u, trustedHeaders) if (!valid) { error = $i18n.t('setup.couldNotReachServer') connecting = false return } + const connectionId = crypto.randomUUID() await window.electronAPI.addConnection({ - id: crypto.randomUUID(), + id: connectionId, name: new URL(u).hostname, type: 'remote', - url: u + url: u, + auth: trustedHeaderAuth + ? { + type: 'trustedHeader', + browserAuth, + trustedHeaders + } + : { type: 'none' } }) + if (trustedHeaderAuth && browserAuth) { + await window.electronAPI.authenticateConnection?.(connectionId) + } config.set(await window.electronAPI.getConfig()) url = '' error = '' + trustedHeaderAuth = false + browserAuth = false + trustedEmailHeaderName = 'X-User-Email' + trustedEmailHeaderValue = '' + trustedNameHeaderName = 'X-User-Name' + trustedNameHeaderValue = '' + trustedGroupsHeaderName = 'X-User-Groups' + trustedGroupsHeaderValue = '' + trustedRoleHeaderName = 'X-User-Role' + trustedRoleHeaderValue = '' showAddConnectionModal = false view = 'welcome' } catch { @@ -232,12 +295,24 @@ } else { const conn = ($connections ?? []).find((c) => c.id === id) if (!conn) return - // Remote — open immediately, no IPC needed - connectingId = '' - openConnections.set(id, conn.url) - openConnections = new Map(openConnections) - connectedUrl = conn.url - view = 'connected' + connectingId = id + view = 'welcome' + window.electronAPI.connectTo(id).then((result: any) => { + if (!result?.url) { + if (connectingId === id) connectingId = '' + return + } + if (!openConnections.has(result.connectionId)) { + openConnections.set(result.connectionId, result.url) + openConnections = new Map(openConnections) + } + if (connectingId === id) { + connectedUrl = result.url + activeConnectionId = result.connectionId + connectingId = '' + view = 'connected' + } + }) } } @@ -341,7 +416,9 @@ if (!container) return const webviews = connId - ? [container.querySelector(`webview[partition="persist:connection-${connId}"]`) as any].filter(Boolean) + ? [ + container.querySelector(`webview[partition="persist:connection-${connId}"]`) as any + ].filter(Boolean) : Array.from(container.querySelectorAll('webview')) for (const wv of webviews) { @@ -352,7 +429,9 @@ // Webview not ready — queue delivery until dom-ready const onReady = () => { wv.removeEventListener('dom-ready', onReady) - try { wv.send('desktop:event', event) } catch (_) {} + try { + wv.send('desktop:event', event) + } catch (_) {} } wv.addEventListener('dom-ready', onReady) } @@ -427,13 +506,38 @@ } // ── Desktop-only state (not forwarded to webviews) ─ - if (data.type === 'status:open-terminal') { openTerminalStatus = data.data; return } - if (data.type === 'status:open-terminal-setup') { openTerminalSetupStatus = data.data ?? ''; return } - if (data.type === 'open-terminal:ready') { openTerminalInfo = data.data; openTerminalStatus = 'started'; openTerminalSetupStatus = ''; return } - if (data.type === 'status:llamacpp') { llamaCppStatus = data.data; return } - if (data.type === 'status:llamacpp-setup') { llamaCppSetupStatus = data.data ?? ''; return } - if (data.type === 'llamacpp:ready') { llamaCppInfo = data.data; llamaCppStatus = 'started'; llamaCppSetupStatus = ''; return } - if (data.type === 'status:install') { installStatus = data.data ?? ''; return } + if (data.type === 'status:open-terminal') { + openTerminalStatus = data.data + return + } + if (data.type === 'status:open-terminal-setup') { + openTerminalSetupStatus = data.data ?? '' + return + } + if (data.type === 'open-terminal:ready') { + openTerminalInfo = data.data + openTerminalStatus = 'started' + openTerminalSetupStatus = '' + return + } + if (data.type === 'status:llamacpp') { + llamaCppStatus = data.data + return + } + if (data.type === 'status:llamacpp-setup') { + llamaCppSetupStatus = data.data ?? '' + return + } + if (data.type === 'llamacpp:ready') { + llamaCppInfo = data.data + llamaCppStatus = 'started' + llamaCppSetupStatus = '' + return + } + if (data.type === 'status:install') { + installStatus = data.data ?? '' + return + } if (data.type === 'packages:changed') { localInstalled = !!data.data?.['open-webui'] return @@ -528,7 +632,10 @@ -
+
{#if sidebarOpen} { showAddConnectionModal = true }} + onAddView={() => { + showAddConnectionModal = true + }} {onOpenSettings} onRename={async (id, name) => { await window.electronAPI.updateConnection(id, { name }) @@ -568,11 +677,23 @@ bind:url bind:connecting bind:error + bind:trustedHeaderAuth + bind:browserAuth + bind:trustedEmailHeaderName + bind:trustedEmailHeaderValue + bind:trustedNameHeaderName + bind:trustedNameHeaderValue + bind:trustedGroupsHeaderName + bind:trustedGroupsHeaderValue + bind:trustedRoleHeaderName + bind:trustedRoleHeaderValue bind:showAddConnectionModal bind:autoInstall onStartInstall={startInstall} onAddConnection={addConnection} - onSetView={(v) => { view = v }} + onSetView={(v) => { + view = v + }} />
@@ -585,17 +706,38 @@ ? openTerminalStatus === 'started' : llamaCppStatus === 'started'} statusText={activeLog === 'server' - ? (serverStatus === 'starting' ? 'Starting Open WebUI…' : serverStatus === 'running' && !serverReachable ? 'Waiting for server…' : installStatus || '') + ? serverStatus === 'starting' + ? 'Starting Open WebUI…' + : serverStatus === 'running' && !serverReachable + ? 'Waiting for server…' + : installStatus || '' : activeLog === 'open-terminal' - ? (openTerminalStatus === 'stopping' ? 'Stopping Open Terminal…' : openTerminalSetupStatus || (openTerminalStatus === 'starting' ? 'Starting Open Terminal…' : '')) - : (llamaCppStatus === 'stopping' ? 'Stopping llama-server…' : llamaCppSetupStatus || (llamaCppStatus === 'starting' ? 'Starting llama-server…' : llamaCppStatus === 'setting-up' ? 'Setting up llama.cpp…' : ''))} + ? openTerminalStatus === 'stopping' + ? 'Stopping Open Terminal…' + : openTerminalSetupStatus || + (openTerminalStatus === 'starting' ? 'Starting Open Terminal…' : '') + : llamaCppStatus === 'stopping' + ? 'Stopping llama-server…' + : llamaCppSetupStatus || + (llamaCppStatus === 'starting' + ? 'Starting llama-server…' + : llamaCppStatus === 'setting-up' + ? 'Setting up llama.cpp…' + : '')} connectPty={getConnectPty(activeLog)} disconnectPty={getDisconnectPty(activeLog)} readonly={activeLog !== 'server'} onWrite={getOnWrite(activeLog)} onResize={getOnResize(activeLog)} - onStop={activeLog === 'open-terminal' ? toggleOpenTerminal : activeLog === 'llama-server' ? toggleLlamaCpp : undefined} - onClose={() => { activeLog = null; showingLogs = false }} + onStop={activeLog === 'open-terminal' + ? toggleOpenTerminal + : activeLog === 'llama-server' + ? toggleLlamaCpp + : undefined} + onClose={() => { + activeLog = null + showingLogs = false + }} /> {/if} diff --git a/src/renderer/src/lib/components/Main/Connections/AddConnectionModal.svelte b/src/renderer/src/lib/components/Main/Connections/AddConnectionModal.svelte index 225c99015..6d2be45a8 100644 --- a/src/renderer/src/lib/components/Main/Connections/AddConnectionModal.svelte +++ b/src/renderer/src/lib/components/Main/Connections/AddConnectionModal.svelte @@ -6,6 +6,16 @@ url: string connecting: boolean error: string + trustedHeaderAuth: boolean + browserAuth: boolean + trustedEmailHeaderName: string + trustedEmailHeaderValue: string + trustedNameHeaderName: string + trustedNameHeaderValue: string + trustedGroupsHeaderName: string + trustedGroupsHeaderValue: string + trustedRoleHeaderName: string + trustedRoleHeaderValue: string onConnect: () => void onCancel: () => void } @@ -14,6 +24,16 @@ url = $bindable(''), connecting = $bindable(false), error = $bindable(''), + trustedHeaderAuth = $bindable(false), + browserAuth = $bindable(false), + trustedEmailHeaderName = $bindable('X-User-Email'), + trustedEmailHeaderValue = $bindable(''), + trustedNameHeaderName = $bindable('X-User-Name'), + trustedNameHeaderValue = $bindable(''), + trustedGroupsHeaderName = $bindable('X-User-Groups'), + trustedGroupsHeaderValue = $bindable(''), + trustedRoleHeaderName = $bindable('X-User-Role'), + trustedRoleHeaderValue = $bindable(''), onConnect, onCancel }: Props = $props() @@ -29,7 +49,7 @@
e.stopPropagation()} > @@ -68,7 +88,7 @@
-
+
@@ -83,6 +103,95 @@ {#if error}

{error}

{/if} + +
+ + + {#if trustedHeaderAuth} +
+ + +
+ + + + + + + + +
+
+ {/if} +
diff --git a/src/renderer/src/lib/components/Main/Connections/Content.svelte b/src/renderer/src/lib/components/Main/Connections/Content.svelte index 93d6d4fc5..586169fb4 100644 --- a/src/renderer/src/lib/components/Main/Connections/Content.svelte +++ b/src/renderer/src/lib/components/Main/Connections/Content.svelte @@ -24,8 +24,22 @@ url: string connecting: boolean error: string + trustedHeaderAuth: boolean + browserAuth: boolean + trustedEmailHeaderName: string + trustedEmailHeaderValue: string + trustedNameHeaderName: string + trustedNameHeaderValue: string + trustedGroupsHeaderName: string + trustedGroupsHeaderValue: string + trustedRoleHeaderName: string + trustedRoleHeaderValue: string autoInstall: boolean - onStartInstall: (options?: { installOpenTerminal?: boolean; installLlamaCpp?: boolean; installDir?: string }) => void + onStartInstall: (options?: { + installOpenTerminal?: boolean + installLlamaCpp?: boolean + installDir?: string + }) => void onAddConnection: () => void onSetView: (v: string) => void showAddConnectionModal: boolean @@ -47,6 +61,16 @@ url = $bindable(''), connecting = $bindable(false), error = $bindable(''), + trustedHeaderAuth = $bindable(false), + browserAuth = $bindable(false), + trustedEmailHeaderName = $bindable('X-User-Email'), + trustedEmailHeaderValue = $bindable(''), + trustedNameHeaderName = $bindable('X-User-Name'), + trustedNameHeaderValue = $bindable(''), + trustedGroupsHeaderName = $bindable('X-User-Groups'), + trustedGroupsHeaderValue = $bindable(''), + trustedRoleHeaderName = $bindable('X-User-Role'), + trustedRoleHeaderValue = $bindable(''), autoInstall = $bindable(false), onStartInstall, onAddConnection, @@ -58,44 +82,42 @@ const isInitializing = $derived($appState === 'initializing') const insufficientStorage = $derived( - $appState?.startsWith('insufficient-storage:') - ? $appState.split(':')[1] - : null + $appState?.startsWith('insufficient-storage:') ? $appState.split(':')[1] : null ) const installFailed = $derived( - $appState?.startsWith('install-failed:') - ? $appState.substring('install-failed:'.length) - : null + $appState?.startsWith('install-failed:') ? $appState.substring('install-failed:'.length) : null ) - // Track webview loading per connection let webviewLoading: Map = $state(new Map()) // Track webview load errors per connection - let webviewErrors: Map = $state(new Map()) + let webviewErrors: Map = $state( + new Map() + ) // Content preload path for webview bridge let contentPreloadPath: string = $state('') // Server is starting up (local) const serverStarting = $derived( - localInstalled && ( - $serverInfo?.status === 'starting' || - ($serverInfo?.status === 'running' && !$serverInfo?.reachable) - ) + localInstalled && + ($serverInfo?.status === 'starting' || + ($serverInfo?.status === 'running' && !$serverInfo?.reachable)) ) const activeWebviewError = $derived( view === 'connected' && activeConnectionId - ? webviewErrors.get(activeConnectionId) ?? null + ? (webviewErrors.get(activeConnectionId) ?? null) : null ) const isLoading = $derived( connectingId !== '' || - (serverStarting && activeConnectionId === 'local') || - (view === 'connected' && !activeWebviewError && webviewLoading.get(activeConnectionId) === true) + (serverStarting && activeConnectionId === 'local') || + (view === 'connected' && + !activeWebviewError && + webviewLoading.get(activeConnectionId) === true) ) const retryActiveWebview = () => { @@ -175,7 +197,8 @@ // Log guest page console messages for debugging blank-page issues (#124) wv.addEventListener('console-message', (event: any) => { - if (event.level >= 2) { // warnings and errors only + if (event.level >= 2) { + // warnings and errors only console.warn(`[webview:${connId}]`, event.message) } }) @@ -234,7 +257,9 @@ // Resolve and apply CSS class let resolved = desktopTheme if (desktopTheme === 'system') { - resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + resolved = window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' } document.documentElement.classList.remove('light', 'dark') document.documentElement.classList.add(resolved) @@ -276,25 +301,54 @@ {#if activeWebviewError} -
+
-
+
{#if activeWebviewError.code === -1} - - + + {:else} - - + + {/if}
- {activeWebviewError.code === -1 ? $i18n.t('setup.pageCrashed') : $i18n.t('setup.couldNotLoadPage')} + {activeWebviewError.code === -1 + ? $i18n.t('setup.pageCrashed') + : $i18n.t('setup.couldNotLoadPage')}
{activeWebviewError.description}
{#if activeWebviewError.url} -
{activeWebviewError.url}
+
+ {activeWebviewError.url} +
{:else}
{/if} @@ -318,9 +372,14 @@ {#if isLoading} -
+
-
+
{$i18n.t('common.loading')}
@@ -330,7 +389,9 @@ {#if insufficientStorage}
-
{$i18n.t('main.notEnoughDiskSpace')}
+
+ {$i18n.t('main.notEnoughDiskSpace')} +
{$i18n.t('main.diskSpaceRequired', { available: insufficientStorage })}
@@ -346,9 +407,14 @@ return } appState.set('initializing') - window.electronAPI.installPython().then(() => appState.set('ready')).catch((e: any) => { - appState.set(`install-failed:${e?.message || 'Python installation failed. Please try again.'}`) - }) + window.electronAPI + .installPython() + .then(() => appState.set('ready')) + .catch((e: any) => { + appState.set( + `install-failed:${e?.message || 'Python installation failed. Please try again.'}` + ) + }) }} > {$i18n.t('common.retry')} @@ -357,7 +423,9 @@ {:else if installFailed}
-
{$i18n.t('error.installFailedGeneric')}
+
+ {$i18n.t('error.installFailedGeneric')} +
{installFailed}
@@ -373,9 +441,14 @@ return } appState.set('initializing') - window.electronAPI.installPython().then(() => appState.set('ready')).catch((e: any) => { - appState.set(`install-failed:${e?.message || 'Python installation failed. Please try again.'}`) - }) + window.electronAPI + .installPython() + .then(() => appState.set('ready')) + .catch((e: any) => { + appState.set( + `install-failed:${e?.message || 'Python installation failed. Please try again.'}` + ) + }) }} > {$i18n.t('common.retry')} @@ -411,13 +484,21 @@ -
+
-
{$i18n.t('app.name')}
-
+
+ {$i18n.t('app.name')} +
+
{$i18n.t('main.heroDescription')}
@@ -434,37 +515,64 @@ disabled={installPhase === 'working'} > {#if installPhase === 'working'} -
+
{$i18n.t('common.installing')} {:else if installPhase === 'error'} {$i18n.t('common.retry')} - - + + {:else} {$i18n.t('main.getStarted')} - - + + {/if} {#if installPhase === 'working' && installStatus} -
+
{installStatus}
{/if} {/if} {#if installPhase !== 'working'} -
- -
+
+ +
{/if}
@@ -485,7 +593,10 @@
{ autoInstall = false; onSetView('welcome') }} + onBack={() => { + autoInstall = false + onSetView('welcome') + }} onComplete={async () => { config.set(await window.electronAPI.getConfig()) onSetView('welcome') @@ -502,7 +613,9 @@ showGetStartedModal = false onStartInstall(options) }} - onCancel={() => { showGetStartedModal = false }} + onCancel={() => { + showGetStartedModal = false + }} /> {/if} @@ -511,6 +624,16 @@ bind:url bind:connecting bind:error + bind:trustedHeaderAuth + bind:browserAuth + bind:trustedEmailHeaderName + bind:trustedEmailHeaderValue + bind:trustedNameHeaderName + bind:trustedNameHeaderValue + bind:trustedGroupsHeaderName + bind:trustedGroupsHeaderValue + bind:trustedRoleHeaderName + bind:trustedRoleHeaderValue onConnect={() => { onAddConnection() }} From 159076294b30968843ce7950761ad14805c7e054 Mon Sep 17 00:00:00 2001 From: Wesley Stewart Date: Mon, 25 May 2026 15:46:04 -0400 Subject: [PATCH 2/2] Decouple browser auth from custom headers --- src/main/utils/index.ts | 2 +- .../lib/components/Main/Connections.svelte | 15 ++++---- .../Connections/AddConnectionModal.svelte | 36 +++++++++---------- 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/src/main/utils/index.ts b/src/main/utils/index.ts index 5df23f7c3..0f9faec8d 100644 --- a/src/main/utils/index.ts +++ b/src/main/utils/index.ts @@ -816,7 +816,7 @@ export interface Connection { type: 'local' | 'remote' url: string auth?: { - type: 'none' | 'trustedHeader' + type: 'none' | 'browser' | 'trustedHeader' browserAuth?: boolean trustedHeaders?: Array<{ name: string diff --git a/src/renderer/src/lib/components/Main/Connections.svelte b/src/renderer/src/lib/components/Main/Connections.svelte index 331b3bd43..299a9ae30 100644 --- a/src/renderer/src/lib/components/Main/Connections.svelte +++ b/src/renderer/src/lib/components/Main/Connections.svelte @@ -192,17 +192,14 @@ .filter((header) => header.name && header.value) : [] - if (trustedHeaderAuth && trustedHeaders.length === 0 && !browserAuth) { - error = 'Add at least one trusted header or enable browser auth' + if (trustedHeaderAuth && trustedHeaders.length === 0) { + error = 'Add at least one custom HTTP header' return } connecting = true try { - const valid = - trustedHeaderAuth && browserAuth - ? true - : await window.electronAPI.validateUrl(u, trustedHeaders) + const valid = browserAuth ? true : await window.electronAPI.validateUrl(u, trustedHeaders) if (!valid) { error = $i18n.t('setup.couldNotReachServer') connecting = false @@ -220,9 +217,11 @@ browserAuth, trustedHeaders } - : { type: 'none' } + : browserAuth + ? { type: 'browser', browserAuth: true } + : { type: 'none' } }) - if (trustedHeaderAuth && browserAuth) { + if (browserAuth) { await window.electronAPI.authenticateConnection?.(connectionId) } config.set(await window.electronAPI.getConfig()) diff --git a/src/renderer/src/lib/components/Main/Connections/AddConnectionModal.svelte b/src/renderer/src/lib/components/Main/Connections/AddConnectionModal.svelte index 6d2be45a8..f293d6718 100644 --- a/src/renderer/src/lib/components/Main/Connections/AddConnectionModal.svelte +++ b/src/renderer/src/lib/components/Main/Connections/AddConnectionModal.svelte @@ -108,10 +108,26 @@ + +