mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat(updater): electron-updater wiring + IPC bridge
electron-updater integration so users get fixes automatically:
- new dependency electron-updater@^6.6.2 in package.json
- electron-builder.config.js publishes to GitHub releases
(owner: concreteinfo, repo: markdownconverter)
- new src/main/auto-updater.js wraps autoUpdater: wires the six
lifecycle events to a 'updates:status' IPC channel, exposes
check() and install() helpers, dev-gates via isDev(), and
swallows the 'missing app-update.yml' error that dev runs hit
- main.js: ensureAutoUpdater() forwards status to the renderer
via mainWindow.webContents.send; ipcMain handlers for
'updates:check' (invoke) and 'updates:install' (invoke)
- preload.js: 'updates:check' / 'updates:install' added to
ALLOWED_SEND_CHANNELS; 'updates:status' added to receive
channels; electronAPI.updates.{check, install, onStatus}
exposed
7 new tests cover event registration, dev-gate behaviour,
'not-available' fallback for missing app-update.yml, typed event
payloads, error forwarding, and install() quitAndInstall.
Amit Haridas
This commit is contained in:
@@ -36,6 +36,16 @@ module.exports = {
|
|||||||
copyright: 'Copyright (C) 2024-2025 ConcreteInfo',
|
copyright: 'Copyright (C) 2024-2025 ConcreteInfo',
|
||||||
directories: { output: 'dist' },
|
directories: { output: 'dist' },
|
||||||
icon: 'assets/icon',
|
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: [
|
files: [
|
||||||
'src/**/*',
|
'src/**/*',
|
||||||
'assets/**/*',
|
'assets/**/*',
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
"docx": "^9.6.0",
|
"docx": "^9.6.0",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"electron-store": "^10.1.0",
|
"electron-store": "^10.1.0",
|
||||||
|
"electron-updater": "^6.6.2",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
"figlet": "^1.8.0",
|
"figlet": "^1.8.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
|||||||
+24
@@ -11,6 +11,7 @@ const VideoOperations = require('./main/VideoOperations');
|
|||||||
const { collectFilesByExtension } = require('./main/collectFilesByExtension');
|
const { collectFilesByExtension } = require('./main/collectFilesByExtension');
|
||||||
const { listWorkspaceFiles } = require('./quick-switcher/workspace-file-lister');
|
const { listWorkspaceFiles } = require('./quick-switcher/workspace-file-lister');
|
||||||
const { completeStream } = require('./main/AiProviders');
|
const { completeStream } = require('./main/AiProviders');
|
||||||
|
const { setupAutoUpdater } = require('./main/auto-updater');
|
||||||
const { runPDFBatchOperation } = require('./main/PDFBatchOperations');
|
const { runPDFBatchOperation } = require('./main/PDFBatchOperations');
|
||||||
const GitOperations = require('./main/GitOperations');
|
const GitOperations = require('./main/GitOperations');
|
||||||
const PandocArgs = require('./main/PandocArgs');
|
const PandocArgs = require('./main/PandocArgs');
|
||||||
@@ -4948,6 +4949,29 @@ ipcMain.on('ai-assist-stream:cancel', (_event, { requestId } = {}) => {
|
|||||||
if (entry) entry.abort();
|
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
|
// Plugins (loaded in the renderer) report the export formats they've
|
||||||
// registered; rebuild the Export menu so they show up as entries.
|
// registered; rebuild the Export menu so they show up as entries.
|
||||||
// createMenu() is idempotent and already re-invoked elsewhere (e.g. after
|
// createMenu() is idempotent and already re-invoked elsewhere (e.g. after
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -48,6 +48,10 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'ai-assist-stream:start',
|
'ai-assist-stream:start',
|
||||||
'ai-assist-stream:cancel',
|
'ai-assist-stream:cancel',
|
||||||
|
|
||||||
|
// v4.13.0 — auto-update (electron-updater)
|
||||||
|
'updates:check',
|
||||||
|
'updates:install',
|
||||||
|
|
||||||
// Batch conversion
|
// Batch conversion
|
||||||
'batch-convert',
|
'batch-convert',
|
||||||
'select-folder',
|
'select-folder',
|
||||||
@@ -338,6 +342,9 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
// v4.13.0 — quick-switcher overlay trigger
|
// v4.13.0 — quick-switcher overlay trigger
|
||||||
'show-quick-switcher',
|
'show-quick-switcher',
|
||||||
|
|
||||||
|
// v4.13.0 — auto-update status events from main
|
||||||
|
'updates:status',
|
||||||
|
|
||||||
// Plugin export formats
|
// Plugin export formats
|
||||||
'run-plugin-export-format',
|
'run-plugin-export-format',
|
||||||
];
|
];
|
||||||
@@ -581,6 +588,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
onError: (cb) => ipcRenderer.on('ai-assist-stream:error', (_e, p) => cb(p)),
|
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'),
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user