diff --git a/electron/main.cjs b/electron/main.cjs index 2f7369d..39fcf30 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -2,14 +2,23 @@ const { app, BrowserWindow, Menu, dialog, ipcMain, shell } = require('electron') const path = require('path') const { is } = require('@electron-toolkit/utils') const fs = require('fs') +const { execFile } = require('child_process') +const { promisify } = require('util') let mainWindow let settingsWindow = null +const execFileAsync = promisify(execFile) const DEFAULT_BACKEND_API_URL = 'http://127.0.0.1:8000' const DEFAULT_OLLAMA_API_URL = 'http://127.0.0.1:11434' +const REPO_ROOT = path.resolve(__dirname, '..') +const UPDATE_REMOTE_URL = 'https://giers10.uber.space/giers10/Heimgeist.git' +const UPDATE_BRANCH = 'master' +const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: '0' } const settingsFilePath = process.env.HEIMGEIST_SETTINGS_FILE || path.join(app.getPath('userData'), 'settings.json') let appSettings = {} +let lastUpdateCheckResult = null +let activeUpdateCheck = null const DEFAULT_UI_SCALE = 1 const MIN_UI_SCALE = 0.7 const MAX_UI_SCALE = 1.3 @@ -111,6 +120,156 @@ function saveSettings() { } } +function setUpdateStatus(status) { + lastUpdateCheckResult = { + state: 'idle', + message: '', + checkedAt: new Date().toISOString(), + localCommit: null, + remoteCommit: null, + branch: null, + restartScheduled: false, + ...status, + } + + return lastUpdateCheckResult +} + +async function runGitCommand(args, options = {}) { + return execFileAsync('git', ['-C', REPO_ROOT, ...args], { + env: GIT_ENV, + maxBuffer: 1024 * 1024, + timeout: options.timeout ?? 15000, + }) +} + +function scheduleAppRestart() { + setTimeout(() => { + app.relaunch() + app.exit(0) + }, 300) +} + +async function performUpdateCheck(trigger = 'manual') { + if (!fs.existsSync(path.join(REPO_ROOT, '.git'))) { + return setUpdateStatus({ + state: 'unavailable', + trigger, + message: 'Update check unavailable: no Git checkout found.', + }) + } + + setUpdateStatus({ + state: 'checking', + trigger, + message: 'Checking remote repository for updates...', + }) + + try { + const [ + { stdout: localStdout }, + { stdout: branchStdout }, + { stdout: remoteStdout }, + { stdout: worktreeStdout }, + ] = await Promise.all([ + runGitCommand(['rev-parse', 'HEAD']), + runGitCommand(['branch', '--show-current']), + runGitCommand(['ls-remote', UPDATE_REMOTE_URL, `refs/heads/${UPDATE_BRANCH}`]), + runGitCommand(['status', '--porcelain']), + ]) + + const localCommit = localStdout.trim() + const branch = branchStdout.trim() || null + const remoteCommit = remoteStdout.trim().split(/\s+/)[0] || null + const worktreeDirty = Boolean(worktreeStdout.trim()) + + if (!localCommit) { + throw new Error('Could not resolve the current local commit hash.') + } + + if (!remoteCommit) { + throw new Error(`Could not resolve the remote ${UPDATE_BRANCH} commit hash.`) + } + + if (localCommit === remoteCommit) { + return setUpdateStatus({ + state: 'up-to-date', + trigger, + branch, + localCommit, + remoteCommit, + message: 'Heimgeist is already up to date.', + }) + } + + if (branch && branch !== UPDATE_BRANCH) { + return setUpdateStatus({ + state: 'skipped', + trigger, + branch, + localCommit, + remoteCommit, + message: `Update skipped: current branch is "${branch}", expected "${UPDATE_BRANCH}".`, + }) + } + + if (worktreeDirty) { + return setUpdateStatus({ + state: 'skipped', + trigger, + branch: branch || UPDATE_BRANCH, + localCommit, + remoteCommit, + message: 'Update skipped: uncommitted local changes detected.', + }) + } + + setUpdateStatus({ + state: 'updating', + trigger, + branch: branch || UPDATE_BRANCH, + localCommit, + remoteCommit, + message: 'Update found. Pulling latest changes and restarting Heimgeist...', + }) + + await runGitCommand(['pull', '--ff-only', UPDATE_REMOTE_URL, UPDATE_BRANCH], { timeout: 120000 }) + + const { stdout: updatedStdout } = await runGitCommand(['rev-parse', 'HEAD']) + const updatedLocalCommit = updatedStdout.trim() + const result = setUpdateStatus({ + state: 'updated', + trigger, + branch: branch || UPDATE_BRANCH, + localCommit: updatedLocalCommit || localCommit, + remoteCommit, + message: 'Update installed. Heimgeist restarts now.', + restartScheduled: true, + }) + + scheduleAppRestart() + return result + } catch (error) { + console.error('Failed to check for updates:', error) + + return setUpdateStatus({ + state: 'error', + trigger, + message: `Update check failed: ${error.message || String(error)}`, + }) + } +} + +function checkForUpdates(trigger = 'manual') { + if (!activeUpdateCheck) { + activeUpdateCheck = performUpdateCheck(trigger).finally(() => { + activeUpdateCheck = null + }) + } + + return activeUpdateCheck +} + async function createMainWindow() { mainWindow = new BrowserWindow({ width: 1000, @@ -194,9 +353,14 @@ async function createSettingsWindow() { } } -app.whenReady().then(() => { +app.whenReady().then(async () => { loadSettings() - createMainWindow() + const startupUpdateResult = await checkForUpdates('startup') + if (startupUpdateResult?.restartScheduled) { + return + } + + await createMainWindow() const menuTemplate = [ { @@ -250,6 +414,8 @@ app.whenReady().then(() => { }) ipcMain.handle('get-settings', () => appSettings) +ipcMain.handle('get-update-status', () => lastUpdateCheckResult) +ipcMain.handle('check-for-updates', () => checkForUpdates('manual')) ipcMain.handle('set-setting', (event, key, value) => { appSettings[key] = key === 'uiScale' ? normalizeUiScale(value) : value diff --git a/electron/preload.cjs b/electron/preload.cjs index 6ad4ce3..17fb860 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -4,6 +4,8 @@ const { contextBridge, ipcRenderer } = require('electron') // Expose a secure API to the renderer process contextBridge.exposeInMainWorld('electronAPI', { getSettings: () => ipcRenderer.invoke('get-settings'), + getUpdateStatus: () => ipcRenderer.invoke('get-update-status'), + checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), setSetting: (key, value) => ipcRenderer.invoke('set-setting', key, value), updateSettings: (settings) => ipcRenderer.invoke('update-settings', settings), pickPaths: () => ipcRenderer.invoke('pick-paths'), diff --git a/src/GeneralSettings.jsx b/src/GeneralSettings.jsx index e28cb66..99018a5 100644 --- a/src/GeneralSettings.jsx +++ b/src/GeneralSettings.jsx @@ -6,25 +6,59 @@ const MODEL_KEY = 'chatModel'; const STREAM_KEY = 'streamOutput'; const DEFAULT_BACKEND_API_URL = 'http://127.0.0.1:8000'; const DEFAULT_OLLAMA_API_URL = 'http://127.0.0.1:11434'; +const DEFAULT_UPDATE_STATUS = { + state: 'idle', + message: '', + checkedAt: null, + localCommit: null, + remoteCommit: null, +}; function resolveBackendApiUrl(settings) { return settings.backendApiUrl || settings.ollamaApiUrl || DEFAULT_BACKEND_API_URL; } +function shortCommit(commit) { + return typeof commit === 'string' && commit.length > 7 ? commit.slice(0, 7) : commit || '—'; +} + +function getStatusTone(state) { + if (state === 'error') return 'error'; + if (state === 'updated' || state === 'up-to-date') return 'success'; + if (state === 'skipped' || state === 'unavailable') return 'warning'; + return 'neutral'; +} + export default function GeneralSettings({ onModelChange, onStreamOutputChange }) { const [backendApiUrl, setBackendApiUrl] = useState(''); const [ollamaApiUrl, setOllamaApiUrl] = useState(''); const [models, setModels] = useState([]); const [selectedModel, setSelectedModel] = useState(''); const [streamOutput, setStreamOutput] = useState(false); + const [updateStatus, setUpdateStatus] = useState(DEFAULT_UPDATE_STATUS); + const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false); useEffect(() => { - window.electronAPI.getSettings().then(settings => { + let cancelled = false; + + Promise.all([ + window.electronAPI.getSettings(), + window.electronAPI.getUpdateStatus(), + ]).then(([settings, status]) => { + if (cancelled) { + return; + } + setBackendApiUrl(resolveBackendApiUrl(settings)); setOllamaApiUrl(settings.ollamaApiUrl || DEFAULT_OLLAMA_API_URL); setSelectedModel(settings.chatModel || ''); setStreamOutput(settings.streamOutput || false); + setUpdateStatus(status || DEFAULT_UPDATE_STATUS); }); + + return () => { + cancelled = true; + }; }, []); useEffect(() => { @@ -75,6 +109,28 @@ export default function GeneralSettings({ onModelChange, onStreamOutputChange }) } }; + const handleCheckForUpdates = async () => { + setIsCheckingForUpdates(true); + try { + const status = await window.electronAPI.checkForUpdates(); + setUpdateStatus(status || DEFAULT_UPDATE_STATUS); + } catch (error) { + setUpdateStatus({ + state: 'error', + message: `Update check failed: ${error.message || String(error)}`, + checkedAt: new Date().toISOString(), + localCommit: null, + remoteCommit: null, + }); + } finally { + setIsCheckingForUpdates(false); + } + }; + + const updateCheckedAtLabel = updateStatus.checkedAt + ? new Date(updateStatus.checkedAt).toLocaleString() + : null; + return (
@@ -121,6 +177,34 @@ export default function GeneralSettings({ onModelChange, onStreamOutputChange })
+
+

Updates

+
+ +
+

+ Compares the local Git commit with remote master, pulls changes when needed, and restarts Heimgeist automatically. The same check also runs on every startup. +

+ {updateStatus.message && ( +

+ {updateStatus.message} +

+ )} + {(updateStatus.localCommit || updateStatus.remoteCommit || updateCheckedAtLabel) && ( +
+ {updateStatus.localCommit &&
Local: {shortCommit(updateStatus.localCommit)}
} + {updateStatus.remoteCommit &&
Remote: {shortCommit(updateStatus.remoteCommit)}
} + {updateCheckedAtLabel &&
Last checked: {updateCheckedAtLabel}
} +
+ )} +
); } diff --git a/src/styles.css b/src/styles.css index c3329dc..9abb13c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -563,6 +563,35 @@ textarea.input { line-height: 1.5; } +.setting-status { + margin: 12px 0 0; + line-height: 1.5; +} + +.setting-status.success { + color: #8fd6a3; +} + +.setting-status.warning { + color: #f1c97a; +} + +.setting-status.error { + color: #ff9aa8; +} + +.setting-status.neutral { + color: var(--text); +} + +.setting-meta { + margin-top: 10px; + display: grid; + gap: 4px; + color: var(--muted); + font-size: 0.95em; +} + /* Markdown Styles */ .msg h1, .msg h2, .msg h3, .msg h4 { margin: 10px 0;