diff --git a/electron-builder.config.js b/electron-builder.config.js index 7797753..5d9f55e 100644 --- a/electron-builder.config.js +++ b/electron-builder.config.js @@ -36,6 +36,16 @@ module.exports = { copyright: 'Copyright (C) 2024-2025 ConcreteInfo', directories: { output: 'dist' }, icon: 'assets/icon', + // v4.13.0 — auto-update wiring via electron-updater. `provider: 'github'` + // is the no-config option: it reads releases from this repo's Releases + // page and matches by the tag's semver against package.json. Replace + // with `generic` + `url:` if you ever move off GitHub releases. + publish: { + provider: 'github', + owner: 'concreteinfo', + repo: 'markdownconverter', + releaseType: 'release', + }, files: [ 'src/**/*', 'assets/**/*', diff --git a/package.json b/package.json index c896014..13c3ac5 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "docx": "^9.6.0", "dompurify": "^3.3.1", "electron-store": "^10.1.0", + "electron-updater": "^6.6.2", "ffmpeg-static": "^5.3.0", "figlet": "^1.8.0", "highlight.js": "^11.11.1", diff --git a/src/main.js b/src/main.js index 4673221..a2c5c43 100644 --- a/src/main.js +++ b/src/main.js @@ -11,6 +11,7 @@ const VideoOperations = require('./main/VideoOperations'); const { collectFilesByExtension } = require('./main/collectFilesByExtension'); const { listWorkspaceFiles } = require('./quick-switcher/workspace-file-lister'); const { completeStream } = require('./main/AiProviders'); +const { setupAutoUpdater } = require('./main/auto-updater'); const { runPDFBatchOperation } = require('./main/PDFBatchOperations'); const GitOperations = require('./main/GitOperations'); const PandocArgs = require('./main/PandocArgs'); @@ -4948,6 +4949,29 @@ ipcMain.on('ai-assist-stream:cancel', (_event, { requestId } = {}) => { if (entry) entry.abort(); }); +// Auto-update wiring (v4.13.0). Surfaces status to the renderer and +// exposes check-now / install-now IPC channels. Lazy-instantiated so a +// missing app-update.yml in dev doesn't blow up startup. +let autoUpdaterCtl = null; +function ensureAutoUpdater() { + if (autoUpdaterCtl) return autoUpdaterCtl; + autoUpdaterCtl = setupAutoUpdater({ + send: (channel, payload) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(channel, payload); + } + }, + isDev: () => process.env.NODE_ENV === 'development' || !!process.env.ELECTRON_DEV, + }); + return autoUpdaterCtl; +} +ipcMain.handle('updates:check', async () => { + return ensureAutoUpdater().check(); +}); +ipcMain.handle('updates:install', () => { + ensureAutoUpdater().install(); +}); + // Plugins (loaded in the renderer) report the export formats they've // registered; rebuild the Export menu so they show up as entries. // createMenu() is idempotent and already re-invoked elsewhere (e.g. after diff --git a/src/main/auto-updater.js b/src/main/auto-updater.js new file mode 100644 index 0000000..6b45764 --- /dev/null +++ b/src/main/auto-updater.js @@ -0,0 +1,75 @@ +/** + * Auto-updater wiring for electron-updater. + * + * Wraps electron-updater's autoUpdater so the main process has one place + * to: + * - start a check on app launch + * - forward events to the renderer over 'updates:status' IPC + * - accept "install now" / "check now" requests from the renderer + * + * Safe to call in dev: when no app-update.yml is packaged (which is the + * case for `npm start` / electron .), electron-updater surfaces an + * "app-update.yml not found" error which we swallow and log. The renderer + * still gets a `not-available` event so the UI can show "you're up to + * date" instead of a spinning indicator that never resolves. + * + * @module auto-updater + */ + +/** + * @param {object} deps + * @param {(channel:string, payload?:any) => void} deps.send - forwards + * status events to the BrowserWindow's webContents + * @param {() => boolean} [deps.isDev] - dev gate (skip check in dev) + */ +function setupAutoUpdater({ send, isDev }) { + // Lazy-load — electron-updater touches `app` at import time, which fails + // in test environments where the app isn't bootstrapped. + const { autoUpdater } = require('electron-updater'); + + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = true; + + // Forward every event to the renderer with a typed payload. + autoUpdater.on('checking-for-update', () => send('updates:status', { state: 'checking' })); + autoUpdater.on('update-available', (info) => + send('updates:status', { state: 'available', version: info?.version }) + ); + autoUpdater.on('update-not-available', (info) => + send('updates:status', { state: 'not-available', version: info?.version }) + ); + autoUpdater.on('download-progress', (p) => + send('updates:status', { state: 'downloading', percent: Math.round(p.percent || 0) }) + ); + autoUpdater.on('update-downloaded', (info) => + send('updates:status', { state: 'downloaded', version: info?.version }) + ); + autoUpdater.on('error', (err) => + send('updates:status', { + state: 'error', + message: err && err.message ? err.message : 'Update check failed.', + }) + ); + + function check() { + if (typeof isDev === 'function' && isDev()) return Promise.resolve(null); + return autoUpdater.checkForUpdates().catch((err) => { + // Dev runs surface "app-update.yml not found" — treat that as benign. + const msg = err && err.message ? err.message : String(err); + if (msg.includes('app-update.yml') || msg.includes('Cannot find')) { + send('updates:status', { state: 'not-available' }); + return null; + } + send('updates:status', { state: 'error', message: msg }); + return null; + }); + } + + function install() { + autoUpdater.quitAndInstall(); + } + + return { check, install }; +} + +module.exports = { setupAutoUpdater }; diff --git a/src/preload.js b/src/preload.js index 730f9bd..2ee3bff 100644 --- a/src/preload.js +++ b/src/preload.js @@ -48,6 +48,10 @@ const ALLOWED_SEND_CHANNELS = [ 'ai-assist-stream:start', 'ai-assist-stream:cancel', + // v4.13.0 — auto-update (electron-updater) + 'updates:check', + 'updates:install', + // Batch conversion 'batch-convert', 'select-folder', @@ -338,6 +342,9 @@ const ALLOWED_RECEIVE_CHANNELS = [ // v4.13.0 — quick-switcher overlay trigger 'show-quick-switcher', + // v4.13.0 — auto-update status events from main + 'updates:status', + // Plugin export formats 'run-plugin-export-format', ]; @@ -581,6 +588,15 @@ contextBridge.exposeInMainWorld('electronAPI', { onError: (cb) => ipcRenderer.on('ai-assist-stream:error', (_e, p) => cb(p)), }, + // v4.13.0 — auto-update controls. Check-now asks main to poll + // GitHub releases; install-now triggers quitAndInstall. Status events + // arrive via the generic on('updates:status', cb). + updates: { + check: () => ipcRenderer.invoke('updates:check'), + install: () => ipcRenderer.invoke('updates:install'), + onStatus: (cb) => ipcRenderer.on('updates:status', (_e, p) => cb(p)), + }, + getAppVersion: () => ipcRenderer.invoke('get-app-version'), }); diff --git a/tests/auto-updater.test.js b/tests/auto-updater.test.js new file mode 100644 index 0000000..d135878 --- /dev/null +++ b/tests/auto-updater.test.js @@ -0,0 +1,114 @@ +/** + * @jest-environment node + * + * Auto-updater — unit test the controller without bringing up electron. + * The electron-updater module is mocked because the real one requires the + * Electron app to be initialised. + */ + +const mockAutoUpdater = { + on: jest.fn(), + autoDownload: false, + autoInstallOnAppQuit: false, + checkForUpdates: jest.fn().mockResolvedValue(null), + quitAndInstall: jest.fn(), +}; + +jest.mock('electron-updater', () => ({ autoUpdater: mockAutoUpdater }), { virtual: true }); + +const { setupAutoUpdater } = require('../src/main/auto-updater'); + +describe('setupAutoUpdater', () => { + let send; + let updater; + + beforeEach(() => { + jest.clearAllMocks(); + mockAutoUpdater.on.mockReset(); + send = jest.fn(); + updater = setupAutoUpdater({ send }); + }); + + test('registers all six event handlers', () => { + const events = mockAutoUpdater.on.mock.calls.map((c) => c[0]); + expect(events).toEqual( + expect.arrayContaining([ + 'checking-for-update', + 'update-available', + 'update-not-available', + 'download-progress', + 'update-downloaded', + 'error', + ]) + ); + }); + + test('enables autoDownload and autoInstallOnAppQuit', () => { + expect(mockAutoUpdater.autoDownload).toBe(true); + expect(mockAutoUpdater.autoInstallOnAppQuit).toBe(true); + }); + + test('check() forwards "not-available" when app-update.yml is missing (dev)', async () => { + mockAutoUpdater.checkForUpdates.mockRejectedValueOnce( + new Error('Cannot find any update manifest (app-update.yml) in resources') + ); + await updater.check(); + expect(send).toHaveBeenCalledWith('updates:status', { state: 'not-available' }); + }); + + test('check() forwards "error" for non-benign failures', async () => { + mockAutoUpdater.checkForUpdates.mockRejectedValueOnce(new Error('network down')); + await updater.check(); + expect(send).toHaveBeenCalledWith('updates:status', { + state: 'error', + message: 'network down', + }); + }); + + test('check() respects isDev() and does not invoke autoUpdater', async () => { + const isDev = jest.fn(() => true); + const u2 = setupAutoUpdater({ send, isDev }); + await u2.check(); + expect(isDev).toHaveBeenCalled(); + expect(mockAutoUpdater.checkForUpdates).not.toHaveBeenCalled(); + }); + + test('install() calls quitAndInstall', () => { + updater.install(); + expect(mockAutoUpdater.quitAndInstall).toHaveBeenCalledTimes(1); + }); + + test('forwarded events carry typed payloads', () => { + // Find each handler and invoke it; assert what got sent. + const map = new Map(); + for (const [event, cb] of mockAutoUpdater.on.mock.calls) map.set(event, cb); + + send.mockClear(); + map.get('checking-for-update')(); + expect(send).toHaveBeenLastCalledWith('updates:status', { state: 'checking' }); + + map.get('update-available')({ version: '4.13.0' }); + expect(send).toHaveBeenLastCalledWith('updates:status', { + state: 'available', + version: '4.13.0', + }); + + map.get('download-progress')({ percent: 42.7 }); + expect(send).toHaveBeenLastCalledWith('updates:status', { + state: 'downloading', + percent: 43, + }); + + map.get('update-downloaded')({ version: '4.13.0' }); + expect(send).toHaveBeenLastCalledWith('updates:status', { + state: 'downloaded', + version: '4.13.0', + }); + + map.get('error')(new Error('boom')); + expect(send).toHaveBeenLastCalledWith('updates:status', { + state: 'error', + message: 'boom', + }); + }); +});