Add update check and auto-update functionality

This commit is contained in:
2026-03-20 08:46:55 +01:00
parent 674a01b90a
commit bc3532ce2c
4 changed files with 284 additions and 3 deletions

View File

@@ -2,14 +2,23 @@ const { app, BrowserWindow, Menu, dialog, ipcMain, shell } = require('electron')
const path = require('path') const path = require('path')
const { is } = require('@electron-toolkit/utils') const { is } = require('@electron-toolkit/utils')
const fs = require('fs') const fs = require('fs')
const { execFile } = require('child_process')
const { promisify } = require('util')
let mainWindow let mainWindow
let settingsWindow = null let settingsWindow = null
const execFileAsync = promisify(execFile)
const DEFAULT_BACKEND_API_URL = 'http://127.0.0.1:8000' const DEFAULT_BACKEND_API_URL = 'http://127.0.0.1:8000'
const DEFAULT_OLLAMA_API_URL = 'http://127.0.0.1:11434' 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') const settingsFilePath = process.env.HEIMGEIST_SETTINGS_FILE || path.join(app.getPath('userData'), 'settings.json')
let appSettings = {} let appSettings = {}
let lastUpdateCheckResult = null
let activeUpdateCheck = null
const DEFAULT_UI_SCALE = 1 const DEFAULT_UI_SCALE = 1
const MIN_UI_SCALE = 0.7 const MIN_UI_SCALE = 0.7
const MAX_UI_SCALE = 1.3 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() { async function createMainWindow() {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1000, width: 1000,
@@ -194,9 +353,14 @@ async function createSettingsWindow() {
} }
} }
app.whenReady().then(() => { app.whenReady().then(async () => {
loadSettings() loadSettings()
createMainWindow() const startupUpdateResult = await checkForUpdates('startup')
if (startupUpdateResult?.restartScheduled) {
return
}
await createMainWindow()
const menuTemplate = [ const menuTemplate = [
{ {
@@ -250,6 +414,8 @@ app.whenReady().then(() => {
}) })
ipcMain.handle('get-settings', () => appSettings) 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) => { ipcMain.handle('set-setting', (event, key, value) => {
appSettings[key] = key === 'uiScale' ? normalizeUiScale(value) : value appSettings[key] = key === 'uiScale' ? normalizeUiScale(value) : value

View File

@@ -4,6 +4,8 @@ const { contextBridge, ipcRenderer } = require('electron')
// Expose a secure API to the renderer process // Expose a secure API to the renderer process
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
getSettings: () => ipcRenderer.invoke('get-settings'), 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), setSetting: (key, value) => ipcRenderer.invoke('set-setting', key, value),
updateSettings: (settings) => ipcRenderer.invoke('update-settings', settings), updateSettings: (settings) => ipcRenderer.invoke('update-settings', settings),
pickPaths: () => ipcRenderer.invoke('pick-paths'), pickPaths: () => ipcRenderer.invoke('pick-paths'),

View File

@@ -6,25 +6,59 @@ const MODEL_KEY = 'chatModel';
const STREAM_KEY = 'streamOutput'; const STREAM_KEY = 'streamOutput';
const DEFAULT_BACKEND_API_URL = 'http://127.0.0.1:8000'; const DEFAULT_BACKEND_API_URL = 'http://127.0.0.1:8000';
const DEFAULT_OLLAMA_API_URL = 'http://127.0.0.1:11434'; 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) { function resolveBackendApiUrl(settings) {
return settings.backendApiUrl || settings.ollamaApiUrl || DEFAULT_BACKEND_API_URL; 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 }) { export default function GeneralSettings({ onModelChange, onStreamOutputChange }) {
const [backendApiUrl, setBackendApiUrl] = useState(''); const [backendApiUrl, setBackendApiUrl] = useState('');
const [ollamaApiUrl, setOllamaApiUrl] = useState(''); const [ollamaApiUrl, setOllamaApiUrl] = useState('');
const [models, setModels] = useState([]); const [models, setModels] = useState([]);
const [selectedModel, setSelectedModel] = useState(''); const [selectedModel, setSelectedModel] = useState('');
const [streamOutput, setStreamOutput] = useState(false); const [streamOutput, setStreamOutput] = useState(false);
const [updateStatus, setUpdateStatus] = useState(DEFAULT_UPDATE_STATUS);
const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false);
useEffect(() => { 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)); setBackendApiUrl(resolveBackendApiUrl(settings));
setOllamaApiUrl(settings.ollamaApiUrl || DEFAULT_OLLAMA_API_URL); setOllamaApiUrl(settings.ollamaApiUrl || DEFAULT_OLLAMA_API_URL);
setSelectedModel(settings.chatModel || ''); setSelectedModel(settings.chatModel || '');
setStreamOutput(settings.streamOutput || false); setStreamOutput(settings.streamOutput || false);
setUpdateStatus(status || DEFAULT_UPDATE_STATUS);
}); });
return () => {
cancelled = true;
};
}, []); }, []);
useEffect(() => { 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 ( return (
<div className="settings-content-panel"> <div className="settings-content-panel">
<div className="setting-section"> <div className="setting-section">
@@ -121,6 +177,34 @@ export default function GeneralSettings({ onModelChange, onStreamOutputChange })
<span className="slider"></span> <span className="slider"></span>
</label> </label>
</div> </div>
<div className="setting-section">
<h3>Updates</h3>
<div className="setting-control-row">
<button
type="button"
className="button"
onClick={handleCheckForUpdates}
disabled={isCheckingForUpdates}
>
{isCheckingForUpdates ? 'Checking...' : 'Check for Update'}
</button>
</div>
<p className="setting-description">
Compares the local Git commit with remote <code>master</code>, pulls changes when needed, and restarts Heimgeist automatically. The same check also runs on every startup.
</p>
{updateStatus.message && (
<p className={`setting-status ${getStatusTone(updateStatus.state)}`}>
{updateStatus.message}
</p>
)}
{(updateStatus.localCommit || updateStatus.remoteCommit || updateCheckedAtLabel) && (
<div className="setting-meta">
{updateStatus.localCommit && <div>Local: <code>{shortCommit(updateStatus.localCommit)}</code></div>}
{updateStatus.remoteCommit && <div>Remote: <code>{shortCommit(updateStatus.remoteCommit)}</code></div>}
{updateCheckedAtLabel && <div>Last checked: {updateCheckedAtLabel}</div>}
</div>
)}
</div>
</div> </div>
); );
} }

View File

@@ -563,6 +563,35 @@ textarea.input {
line-height: 1.5; 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 */ /* Markdown Styles */
.msg h1, .msg h2, .msg h3, .msg h4 { .msg h1, .msg h2, .msg h3, .msg h4 {
margin: 10px 0; margin: 10px 0;