Add update check and auto-update functionality
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user