feat: switch to electron-updater for auto-updates

Replaces the custom server-poll updater (which depended on a placeholder
serverUrl 'https://your-server.com/api' and never fired in practice) with
electron-updater. Now a banner appears automatically when a new release is
published — auto-download in background, in-place install via quitAndInstall,
delta updates via blockmap.

Changes:
- Add electron-updater dep, build.publish (provider: generic, placeholder URL)
- Rewrite UpdaterManager around autoUpdater events (available/progress/
  downloaded/error), forwarded to renderer via window.webContents.send
- Drop hardcoded APP_VERSION constant; main uses app.getVersion(), renderer
  fetches via new GET_APP_VERSION IPC channel
- IPC: drop DOWNLOAD_UPDATE (autoDownload handles it), add INSTALL_UPDATE
  + GET_APP_VERSION
- VersionInfo reshaped (currentVersion field, no downloadUrl/mandatory);
  add UpdateProgress and UpdateCheckResult types
- UpdateNotification: 3-phase UI (downloading with progress bar,
  ready with restart-and-install, hidden); App.tsx tracks phase state

TODO before first real release:
- Replace build.publish.url placeholder with the actual generic host
- Bump version, run package:win, upload latest.yml + .exe + .blockmap to host

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 00:13:19 +03:00
parent ecb5e7e49f
commit 571404ca94
10 changed files with 287 additions and 149 deletions

106
package-lock.json generated
View File

@@ -1,17 +1,18 @@
{ {
"name": "media-center", "name": "media-center",
"version": "1.0.0", "version": "1.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "media-center", "name": "media-center",
"version": "1.0.0", "version": "1.0.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.2", "axios": "^1.6.2",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"electron-updater": "^6.8.3",
"nedb": "^1.8.0", "nedb": "^1.8.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"xml2js": "^0.6.2" "xml2js": "^0.6.2"
@@ -2197,7 +2198,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/assert-plus": { "node_modules/assert-plus": {
@@ -3135,7 +3135,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -3698,6 +3697,82 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/electron-updater": {
"version": "6.8.3",
"resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz",
"integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==",
"license": "MIT",
"dependencies": {
"builder-util-runtime": "9.5.1",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"lazy-val": "^1.0.5",
"lodash.escaperegexp": "^4.1.2",
"lodash.isequal": "^4.5.0",
"semver": "~7.7.3",
"tiny-typed-emitter": "^2.1.0"
}
},
"node_modules/electron-updater/node_modules/builder-util-runtime": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz",
"integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4",
"sax": "^1.2.4"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/electron-updater/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/electron-updater/node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/electron-updater/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/electron-updater/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/electron/node_modules/@types/node": { "node_modules/electron/node_modules/@types/node": {
"version": "18.19.130", "version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
@@ -4334,7 +4409,6 @@
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/has-flag": { "node_modules/has-flag": {
@@ -4709,7 +4783,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@@ -4796,7 +4869,6 @@
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lazystream": { "node_modules/lazystream": {
@@ -4903,6 +4975,12 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/lodash.escaperegexp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
"license": "MIT"
},
"node_modules/lodash.flatten": { "node_modules/lodash.flatten": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
@@ -4911,6 +4989,13 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.isplainobject": { "node_modules/lodash.isplainobject": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
@@ -5118,7 +5203,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
@@ -6251,6 +6335,12 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/tiny-typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
"license": "MIT"
},
"node_modules/tmp": { "node_modules/tmp": {
"version": "0.2.5", "version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",

View File

@@ -23,6 +23,7 @@
"axios": "^1.6.2", "axios": "^1.6.2",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"electron-updater": "^6.8.3",
"nedb": "^1.8.0", "nedb": "^1.8.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"xml2js": "^0.6.2" "xml2js": "^0.6.2"
@@ -53,6 +54,10 @@
"directories": { "directories": {
"output": "release" "output": "release"
}, },
"publish": {
"provider": "generic",
"url": "https://your-update-server.example.com/media-center/"
},
"files": [ "files": [
"dist/**/*", "dist/**/*",
"node_modules/**/*", "node_modules/**/*",

View File

@@ -51,7 +51,7 @@ async function initializeApp() {
proxyManager = new ProxyManager(); proxyManager = new ProxyManager();
configManager = new ConfigManager(userDataPath); configManager = new ConfigManager(userDataPath);
databaseManager = new DatabaseManager(userDataPath); databaseManager = new DatabaseManager(userDataPath);
updaterManager = new UpdaterManager(configManager); updaterManager = new UpdaterManager(() => mainWindow);
tabManager = new TabManager(); tabManager = new TabManager();
// Load initial configuration // Load initial configuration
@@ -69,11 +69,7 @@ async function initializeApp() {
// Check for updates // Check for updates
setTimeout(() => { setTimeout(() => {
updaterManager.checkForUpdates().then((versionInfo) => { updaterManager.checkForUpdates();
if (versionInfo.updateAvailable && mainWindow) {
mainWindow.webContents.send('update-available', versionInfo);
}
});
}, 5000); }, 5000);
} }
@@ -169,8 +165,12 @@ function setupIpcHandlers() {
return await updaterManager.checkForUpdates(); return await updaterManager.checkForUpdates();
}); });
ipcMain.handle(IPC_CHANNELS.DOWNLOAD_UPDATE, async (_, downloadUrl: string) => { ipcMain.handle(IPC_CHANNELS.INSTALL_UPDATE, () => {
return await updaterManager.downloadUpdate(downloadUrl); updaterManager.installUpdate();
});
ipcMain.handle(IPC_CHANNELS.GET_APP_VERSION, () => {
return app.getVersion();
}); });
// Settings handlers // Settings handlers

View File

@@ -17,7 +17,8 @@ const IPC_CHANNELS = {
START_PROXY: 'proxy:start', START_PROXY: 'proxy:start',
STOP_PROXY: 'proxy:stop', STOP_PROXY: 'proxy:stop',
CHECK_VERSION: 'version:check', CHECK_VERSION: 'version:check',
DOWNLOAD_UPDATE: 'version:download', INSTALL_UPDATE: 'version:install',
GET_APP_VERSION: 'app:version',
GET_SETTINGS: 'settings:get', GET_SETTINGS: 'settings:get',
SAVE_SETTINGS: 'settings:save', SAVE_SETTINGS: 'settings:save',
}; };
@@ -53,20 +54,29 @@ contextBridge.exposeInMainWorld('electronAPI', {
startProxy: () => ipcRenderer.invoke(IPC_CHANNELS.START_PROXY), startProxy: () => ipcRenderer.invoke(IPC_CHANNELS.START_PROXY),
stopProxy: () => ipcRenderer.invoke(IPC_CHANNELS.STOP_PROXY), stopProxy: () => ipcRenderer.invoke(IPC_CHANNELS.STOP_PROXY),
// Version // Version / Updates
checkVersion: () => ipcRenderer.invoke(IPC_CHANNELS.CHECK_VERSION), checkVersion: () => ipcRenderer.invoke(IPC_CHANNELS.CHECK_VERSION),
downloadUpdate: (downloadUrl: string) => installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE),
ipcRenderer.invoke(IPC_CHANNELS.DOWNLOAD_UPDATE, downloadUrl), getAppVersion: () => ipcRenderer.invoke(IPC_CHANNELS.GET_APP_VERSION),
// Settings // Settings
getSettings: () => ipcRenderer.invoke(IPC_CHANNELS.GET_SETTINGS), getSettings: () => ipcRenderer.invoke(IPC_CHANNELS.GET_SETTINGS),
saveSettings: (settings: any) => saveSettings: (settings: any) =>
ipcRenderer.invoke(IPC_CHANNELS.SAVE_SETTINGS, settings), ipcRenderer.invoke(IPC_CHANNELS.SAVE_SETTINGS, settings),
// Event listeners // Update event listeners
onUpdateAvailable: (callback: (versionInfo: any) => void) => { onUpdateAvailable: (callback: (versionInfo: any) => void) => {
ipcRenderer.on('update-available', (_, versionInfo) => callback(versionInfo)); ipcRenderer.on('update-available', (_, versionInfo) => callback(versionInfo));
}, },
onDownloadProgress: (callback: (progress: any) => void) => {
ipcRenderer.on('update-download-progress', (_, progress) => callback(progress));
},
onUpdateDownloaded: (callback: (info: any) => void) => {
ipcRenderer.on('update-downloaded', (_, info) => callback(info));
},
onUpdateError: (callback: (error: any) => void) => {
ipcRenderer.on('update-error', (_, error) => callback(error));
},
}); });
// Type declaration for TypeScript // Type declaration for TypeScript
@@ -87,11 +97,15 @@ declare global {
getProxyStatus: () => Promise<any>; getProxyStatus: () => Promise<any>;
startProxy: () => Promise<any>; startProxy: () => Promise<any>;
stopProxy: () => Promise<any>; stopProxy: () => Promise<any>;
checkVersion: () => Promise<any>; checkVersion: () => Promise<{ updateAvailable: boolean; latestVersion: string }>;
downloadUpdate: (downloadUrl: string) => Promise<string>; installUpdate: () => Promise<void>;
getAppVersion: () => Promise<string>;
getSettings: () => Promise<any>; getSettings: () => Promise<any>;
saveSettings: (settings: any) => Promise<void>; saveSettings: (settings: any) => Promise<void>;
onUpdateAvailable: (callback: (versionInfo: any) => void) => void; onUpdateAvailable: (callback: (versionInfo: any) => void) => void;
onDownloadProgress: (callback: (progress: any) => void) => void;
onUpdateDownloaded: (callback: (info: any) => void) => void;
onUpdateError: (callback: (error: any) => void) => void;
}; };
} }
} }

View File

@@ -1,99 +1,71 @@
import axios from 'axios'; import { app, BrowserWindow } from 'electron';
import * as fs from 'fs'; import { autoUpdater } from 'electron-updater';
import * as path from 'path'; import type { UpdateCheckResult } from '../shared/types';
import { app, shell } from 'electron';
import { VersionInfo } from '../shared/types';
import { APP_VERSION } from '../shared/constants';
import { ConfigManager } from './config';
export class UpdaterManager { export class UpdaterManager {
private configManager: ConfigManager; private getWindow: () => BrowserWindow | null;
constructor(configManager: ConfigManager) { constructor(getWindow: () => BrowserWindow | null) {
this.configManager = configManager; this.getWindow = getWindow;
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
this.wireEvents();
} }
async checkForUpdates(): Promise<VersionInfo> { private send(channel: string, payload?: unknown) {
try { const win = this.getWindow();
const settings = await this.configManager.getConfig(); if (win && !win.isDestroyed()) {
const serverUrl = (settings as any).serverUrl || 'https://your-server.com/api'; win.webContents.send(channel, payload);
const endpoint = `${serverUrl}/version/check`; }
}
const response = await axios.get<VersionInfo>(endpoint, { private wireEvents() {
params: { autoUpdater.on('update-available', (info) => {
currentVersion: APP_VERSION, this.send('update-available', {
platform: process.platform, latestVersion: info.version,
}, currentVersion: app.getVersion(),
timeout: 10000, releaseDate: info.releaseDate ?? '',
changelog: typeof info.releaseNotes === 'string' ? info.releaseNotes : '',
}); });
});
return response.data; autoUpdater.on('download-progress', (progress) => {
} catch (error: any) { this.send('update-download-progress', {
console.error('Error checking for updates:', error.message); percent: progress.percent,
// Return no update available if check fails bytesPerSecond: progress.bytesPerSecond,
return { transferred: progress.transferred,
latestVersion: APP_VERSION, total: progress.total,
updateAvailable: false,
downloadUrl: '',
changelog: '',
releaseDate: '',
mandatory: false,
};
}
}
async downloadUpdate(downloadUrl: string): Promise<string> {
try {
const downloadsPath = app.getPath('downloads');
const fileName = path.basename(new URL(downloadUrl).pathname);
const filePath = path.join(downloadsPath, fileName);
const response = await axios.get(downloadUrl, {
responseType: 'stream',
timeout: 300000, // 5 minutes
}); });
});
const writer = fs.createWriteStream(filePath); autoUpdater.on('update-downloaded', (info) => {
this.send('update-downloaded', { latestVersion: info.version });
});
response.data.pipe(writer); autoUpdater.on('error', (err) => {
console.error('[updater] error:', err.message);
return new Promise((resolve, reject) => { this.send('update-error', { message: err.message });
writer.on('finish', () => resolve(filePath)); });
writer.on('error', reject);
});
} catch (error: any) {
console.error('Error downloading update:', error.message);
throw new Error(`Failed to download update: ${error.message}`);
}
} }
async installUpdate(installerPath: string): Promise<void> { async checkForUpdates(): Promise<UpdateCheckResult> {
const currentVersion = app.getVersion();
if (!app.isPackaged) {
return { updateAvailable: false, latestVersion: currentVersion };
}
try { try {
// Open the installer const result = await autoUpdater.checkForUpdates();
await shell.openPath(installerPath); const latestVersion = result?.updateInfo?.version ?? currentVersion;
return { updateAvailable: latestVersion !== currentVersion, latestVersion };
// Close the app after a short delay } catch (err: any) {
setTimeout(() => { console.error('[updater] check failed:', err.message);
app.quit(); return { updateAvailable: false, latestVersion: currentVersion };
}, 1000);
} catch (error: any) {
console.error('Error installing update:', error.message);
throw new Error(`Failed to install update: ${error.message}`);
} }
} }
compareVersions(v1: string, v2: string): number { installUpdate(): void {
const parts1 = v1.split('.').map(Number); autoUpdater.quitAndInstall();
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const part1 = parts1[i] || 0;
const part2 = parts2[i] || 0;
if (part1 > part2) return 1;
if (part1 < part2) return -1;
}
return 0;
} }
} }

View File

@@ -6,25 +6,38 @@ import Bookmarks from './pages/Bookmarks';
import ActiveTabs from './pages/ActiveTabs'; import ActiveTabs from './pages/ActiveTabs';
import Settings from './pages/Settings'; import Settings from './pages/Settings';
import UpdateNotification from './components/UpdateNotification'; import UpdateNotification from './components/UpdateNotification';
import { VersionInfo } from '../shared/types'; import { VersionInfo, UpdateProgress } from '../shared/types';
type UpdatePhase = 'idle' | 'downloading' | 'ready';
function App() { function App() {
const [updateInfo, setUpdateInfo] = useState<VersionInfo | null>(null); const [phase, setPhase] = useState<UpdatePhase>('idle');
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
const [progress, setProgress] = useState<UpdateProgress | null>(null);
useEffect(() => { useEffect(() => {
// Listen for update notifications window.electronAPI.onUpdateAvailable((info) => {
window.electronAPI.onUpdateAvailable((versionInfo) => { setVersionInfo(info);
setUpdateInfo(versionInfo); setPhase('downloading');
});
window.electronAPI.onDownloadProgress((p) => {
setProgress(p);
});
window.electronAPI.onUpdateDownloaded(() => {
setPhase('ready');
setProgress(null);
}); });
}, []); }, []);
return ( return (
<BrowserRouter> <BrowserRouter>
<Layout> <Layout>
{updateInfo && updateInfo.updateAvailable && ( {phase !== 'idle' && versionInfo && (
<UpdateNotification <UpdateNotification
versionInfo={updateInfo} phase={phase}
onClose={() => setUpdateInfo(null)} versionInfo={versionInfo}
progress={progress}
onClose={() => setPhase('idle')}
/> />
)} )}
<Routes> <Routes>

View File

@@ -1,55 +1,66 @@
import React, { useState } from 'react'; import React from 'react';
import { VersionInfo } from '../../shared/types'; import { VersionInfo, UpdateProgress } from '../../shared/types';
import '../styles/UpdateNotification.css'; import '../styles/UpdateNotification.css';
interface UpdateNotificationProps { interface UpdateNotificationProps {
phase: 'downloading' | 'ready';
versionInfo: VersionInfo; versionInfo: VersionInfo;
progress: UpdateProgress | null;
onClose: () => void; onClose: () => void;
} }
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
}
const UpdateNotification: React.FC<UpdateNotificationProps> = ({ const UpdateNotification: React.FC<UpdateNotificationProps> = ({
phase,
versionInfo, versionInfo,
progress,
onClose, onClose,
}) => { }) => {
const [isDownloading, setIsDownloading] = useState(false); const handleInstall = () => {
window.electronAPI.installUpdate();
const handleUpdate = async () => {
setIsDownloading(true);
try {
const installerPath = await window.electronAPI.downloadUpdate(
versionInfo.downloadUrl
);
alert(`Обновление загружено: ${installerPath}\риложение будет закрыто для установки.`);
// App will close automatically after opening installer
} catch (error) {
console.error('Error downloading update:', error);
alert('Ошибка при загрузке обновления');
setIsDownloading(false);
}
}; };
return ( return (
<div className="update-notification"> <div className="update-notification">
<div className="update-content"> <div className="update-content">
<h3>Доступно обновление!</h3> <h3>{phase === 'ready' ? 'Обновление готово' : 'Доступно обновление'}</h3>
<p className="version"> <p className="version">
Версия <strong>{versionInfo.latestVersion}</strong> Версия <strong>{versionInfo.latestVersion}</strong>
</p> </p>
<div className="changelog"> {versionInfo.changelog && (
<h4>Что нового:</h4> <div className="changelog">
<pre>{versionInfo.changelog}</pre> <h4>Что нового:</h4>
</div> <pre>{versionInfo.changelog}</pre>
</div>
)}
{phase === 'downloading' && (
<div className="update-progress">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${progress?.percent ?? 0}%` }}
/>
</div>
<p className="progress-label">
{progress
? `${Math.round(progress.percent)}% — ${formatBytes(progress.transferred)} / ${formatBytes(progress.total)}`
: 'Подготовка загрузки…'}
</p>
</div>
)}
<div className="update-actions"> <div className="update-actions">
<button {phase === 'ready' && (
className="button-primary" <button className="button-primary" onClick={handleInstall}>
onClick={handleUpdate} Перезапустить и установить
disabled={isDownloading} </button>
> )}
{isDownloading ? 'Загрузка...' : 'Обновить сейчас'}
</button>
<button className="button-secondary" onClick={onClose}> <button className="button-secondary" onClick={onClose}>
Напомнить позже {phase === 'ready' ? 'Позже' : 'Скрыть'}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -59,6 +59,30 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.update-progress {
margin-bottom: 1rem;
}
.progress-bar {
width: 100%;
height: 6px;
background-color: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: var(--accent);
transition: width 0.2s ease-out;
}
.progress-label {
margin: 0.5rem 0 0 0;
font-size: 0.85rem;
color: var(--text-secondary);
}
.update-actions { .update-actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;

View File

@@ -1,5 +1,3 @@
export const APP_VERSION = '1.0.1';
export const DEFAULT_CONFIG_SERVER_URL = 'https://your-server.com/api'; export const DEFAULT_CONFIG_SERVER_URL = 'https://your-server.com/api';
export const DEFAULT_PROXY_PORT = 10808; export const DEFAULT_PROXY_PORT = 10808;

View File

@@ -59,11 +59,21 @@ export interface ActiveTab {
// Version Check Types // Version Check Types
export interface VersionInfo { export interface VersionInfo {
latestVersion: string; latestVersion: string;
updateAvailable: boolean; currentVersion: string;
downloadUrl: string;
changelog: string;
releaseDate: string; releaseDate: string;
mandatory: boolean; changelog: string;
}
export interface UpdateProgress {
percent: number;
bytesPerSecond: number;
transferred: number;
total: number;
}
export interface UpdateCheckResult {
updateAvailable: boolean;
latestVersion: string;
} }
// Proxy Types // Proxy Types
@@ -113,7 +123,8 @@ export enum IPC_CHANNELS {
// Version // Version
CHECK_VERSION = 'version:check', CHECK_VERSION = 'version:check',
DOWNLOAD_UPDATE = 'version:download', INSTALL_UPDATE = 'version:install',
GET_APP_VERSION = 'app:version',
// Settings // Settings
GET_SETTINGS = 'settings:get', GET_SETTINGS = 'settings:get',