Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ jobs:
- run: npm run build
- name: Package portable exe
run: npx electron-builder --win portable --publish never
# GetBox checks the downloaded update against this file before replacing itself.
- name: Checksum
shell: bash
run: |
cd release
for f in GetBox-*-portable.exe; do sha256sum "$f" > "$f.sha256"; done
- name: Release notes
shell: bash
run: node scripts/release-notes.cjs "${{ steps.meta.outputs.tag }}" > release-notes.md
Expand All @@ -57,5 +63,7 @@ jobs:
target_commitish: ${{ github.sha }}
name: ${{ steps.meta.outputs.name }}
body_path: release-notes.md
files: release/GetBox-*-portable.exe
files: |
release/GetBox-*-portable.exe
release/GetBox-*-portable.exe.sha256
fail_on_unmatched_files: true
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Changelog

## Не выпущено
## 1.3.0

### GetBox обновляется сам
- Новая версия ищется на GitHub при запуске и раз в 6 часов, скачивается в фоне и сверяется по SHA256 с файлом из релиза.
- В заголовке окна появляется кнопка «Обновить до X»: перезапустить сейчас или обновиться при выходе.
- После обновления — экран «Что нового» с переходом к новым возможностям; повторно открывается из настроек.
- Настройки → GetBox: версия, проверка вручную, фоновая загрузка, канал «Стабильный» / «Бета».

### Обновления программ
- Новая вкладка «Обновления»: что можно обновить через winget, Scoop и Chocolatey, с кнопками «Обновить» и «Обновить все». Обновления программ из каталога показываются под их названием и иконкой.
Expand Down
12 changes: 12 additions & 0 deletions data/whats-new.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"1.3.0": [
{ "icon": "refresh", "title": "Обновления программ", "text": "Всё, что можно обновить, — на одной вкладке. История и откат в один клик.", "action": "updates", "actionLabel": "Открыть обновления",
"titleEn": "Program updates", "textEn": "Everything that can be updated in one tab, with history and one-click rollback.", "actionLabelEn": "Open updates" },
{ "icon": "palette", "title": "Пять тем и свой акцент", "text": "Светлая, OLED, высокий контраст, Windows 11, свой цвет и масштаб.", "action": "settings", "actionLabel": "Выбрать тему",
"titleEn": "Five themes and your accent", "textEn": "Light, OLED, high contrast, Windows 11, your own colour and scale.", "actionLabelEn": "Pick a theme" },
{ "icon": "grid", "title": "Четыре вида списка", "text": "Карточки, список с описанием, таблица и плитки — переключатель над любым списком.", "action": "catalog", "actionLabel": "Попробовать",
"titleEn": "Four list views", "textEn": "Cards, a detailed list, a table and tiles — switch above any list.", "actionLabelEn": "Try it" },
{ "icon": "shield", "title": "GetBox обновляется сам", "text": "Новая версия скачивается в фоне и проверяется по SHA256 — кнопка появится в заголовке окна.",
"titleEn": "GetBox updates itself", "textEn": "New versions download in the background and are checked by SHA256 — a button appears in the title bar." }
]
}
19 changes: 19 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getCatalog, invalidateCatalog, setInstalledSet, findApp } from './catal
import { searchPackages } from './search-cache.js';
import { installQueue } from './queue/install-queue.js';
import { updates } from './updates/updates.js';
import { selfUpdater } from './self-update/self-update.js';
import { buildIndex, isIndexEmptyOrSmall } from './index-builder.js';
import { getSettings, updateSettings } from './store/settings.js';
import { toggleFavorite, isFavorite } from './store/favorites.js';
Expand Down Expand Up @@ -275,6 +276,21 @@ function setupIpc(): void {
ipcMain.handle('queue:dismiss', (_e, jobId: string) => installQueue.dismiss(jobId));
ipcMain.handle('queue:retryJob', (_e, jobId: string) => installQueue.retryJob(jobId));

ipcMain.handle('app:version', () => app.getVersion());
ipcMain.handle('selfUpdate:get', () => selfUpdater.getState());
ipcMain.handle('selfUpdate:check', () => selfUpdater.check(true));
ipcMain.handle('selfUpdate:restart', () => {
if (!selfUpdater.apply(true)) return false;
app.quit();
return true;
});
ipcMain.handle('selfUpdate:onQuit', (_e, on: boolean) => selfUpdater.setApplyOnQuit(Boolean(on)));
// Opens the release page of a version (or of the pending update); only this repo's releases.
ipcMain.handle('selfUpdate:openRelease', (_e, version?: string) => {
const v = typeof version === 'string' && /^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version) ? version : selfUpdater.getState().latest;
if (v) void shell.openExternal(`https://github.com/IncludeSpecial/GetBox/releases/tag/v${v}`);
});

ipcMain.handle('updates:get', () => updates.getState());
ipcMain.handle('updates:check', () => updates.check());
ipcMain.handle('updates:clearHistory', () => updates.clearHistory());
Expand Down Expand Up @@ -443,13 +459,16 @@ app.whenReady().then(async () => {
installQueue.on('updated', (jobs) => sendToRenderer('queue:updated', jobs));
installQueue.on('job-finished', (job) => updates.record(job));
updates.on('changed', (state) => sendToRenderer('updates:changed', state));
selfUpdater.on('changed', (state) => sendToRenderer('selfUpdate:changed', state));
app.on('will-quit', () => selfUpdater.onQuit());
updates.on('auto-install', (items: UpdateInfo[]) => queueUpgrades(items));
installQueue.on('installed-changed', () => {
refreshInstalledInBackground();
});

createWindow();
updates.schedule(true);
selfUpdater.schedule(true);

if (shouldRetryBootstrap()) {
startBootstrap();
Expand Down
22 changes: 20 additions & 2 deletions electron/preload.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from 'electron';
import type { MergedApp, Package, QueueJob, SearchResult, AppSettings, UpdatesState } from './providers/types.js';
import type { MergedApp, Package, QueueJob, SearchResult, AppSettings, UpdatesState, SelfUpdateState } from './providers/types.js';

export interface RuntimeToolInfo {
status: string;
Expand Down Expand Up @@ -66,6 +66,13 @@ export interface GetBoxAPI {
rollbackUpdate: (historyId: string) => Promise<boolean>;
clearUpdateHistory: () => Promise<void>;
onUpdatesChanged: (cb: (state: UpdatesState) => void) => () => void;
getAppVersion: () => Promise<string>;
getSelfUpdate: () => Promise<SelfUpdateState>;
checkSelfUpdate: () => Promise<SelfUpdateState>;
restartToUpdate: () => Promise<boolean>;
updateOnQuit: (on: boolean) => Promise<void>;
openReleasePage: (version?: string) => Promise<void>;
onSelfUpdateChanged: (cb: (state: SelfUpdateState) => void) => () => void;
rebuildIndex: () => Promise<{ ok: boolean; count: number; message?: string }>;
onIndexProgress: (cb: (msg: string) => void) => () => void;
onIndexDone: (cb: (count: number) => void) => () => void;
Expand Down Expand Up @@ -144,6 +151,17 @@ const api: GetBoxAPI = {
upgradePackages: (keys) => ipcRenderer.invoke('updates:upgrade', keys),
rollbackUpdate: (historyId) => ipcRenderer.invoke('updates:rollback', historyId),
clearUpdateHistory: () => ipcRenderer.invoke('updates:clearHistory'),
getAppVersion: () => ipcRenderer.invoke('app:version'),
getSelfUpdate: () => ipcRenderer.invoke('selfUpdate:get'),
checkSelfUpdate: () => ipcRenderer.invoke('selfUpdate:check'),
restartToUpdate: () => ipcRenderer.invoke('selfUpdate:restart'),
updateOnQuit: (on) => ipcRenderer.invoke('selfUpdate:onQuit', on),
openReleasePage: (version) => ipcRenderer.invoke('selfUpdate:openRelease', version),
onSelfUpdateChanged: (cb) => {
const handler = (_: unknown, state: SelfUpdateState) => cb(state);
ipcRenderer.on('selfUpdate:changed', handler);
return () => ipcRenderer.removeListener('selfUpdate:changed', handler);
},
onUpdatesChanged: (cb) => {
const handler = (_: unknown, state: UpdatesState) => cb(state);
ipcRenderer.on('updates:changed', handler);
Expand Down Expand Up @@ -225,4 +243,4 @@ declare global {
}
}

export type { MergedApp, Package, QueueJob, SearchResult, AppSettings, UpdatesState };
export type { MergedApp, Package, QueueJob, SearchResult, AppSettings, UpdatesState, SelfUpdateState };
21 changes: 21 additions & 0 deletions electron/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,27 @@ export interface AppSettings {
autoCheckUpdates: boolean;
/** Queue every found update without asking. */
autoInstallUpdates: boolean;
/** Download new GetBox versions in the background. */
autoUpdateApp: boolean;
appUpdateChannel: 'stable' | 'beta';
/** Last version whose «Что нового» screen was shown. */
lastSeenVersion?: string;
}

export interface SelfUpdateState {
status: 'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'error';
current: string;
latest?: string;
/** Release notes (Markdown) of `latest`. */
notes?: string;
releaseUrl?: string;
checkedAt?: number;
progress?: number;
downloadedPath?: string;
/** False when not running as the portable exe (dev builds): updates are shown but not installed. */
canApply: boolean;
applyOnQuit?: boolean;
error?: string;
}

export const THEME_IDS = ['classic', 'light', 'oled', 'contrast', 'mica'] as const;
Expand Down
204 changes: 204 additions & 0 deletions electron/self-update/self-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { EventEmitter } from 'node:events';
import { spawn } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { app } from 'electron';
import { getAppDataDir } from '../paths.js';
import { getSettings } from '../store/settings.js';
import type { SelfUpdateState } from '../providers/types.js';

const REPO = 'IncludeSpecial/GetBox';
const API = `https://api.github.com/repos/${REPO}/releases`;
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
const FIRST_CHECK_DELAY_MS = 20 * 1000;
const ASSET = /^GetBox-(.+)-portable\.exe$/;
/** Release assets are served from github.com and redirect to GitHub's object storage. */
const ALLOWED_HOSTS = new Set(['github.com', 'objects.githubusercontent.com', 'release-assets.githubusercontent.com']);

interface GhAsset { name: string; browser_download_url: string; size: number; digest?: string | null }
interface GhRelease { tag_name: string; name: string; body: string; html_url: string; prerelease: boolean; draft: boolean; assets: GhAsset[] }

/** Compare dotted versions with an optional -prerelease suffix; a release beats its prereleases. */
export function compareVersions(a: string, b: string): number {
const [ma, pa = ''] = a.replace(/^v/, '').split('-', 2);
const [mb, pb = ''] = b.replace(/^v/, '').split('-', 2);
const na = ma.split('.').map(Number), nb = mb.split('.').map(Number);
for (let i = 0; i < Math.max(na.length, nb.length); i++) {
const d = (na[i] || 0) - (nb[i] || 0);
if (d) return Math.sign(d);
}
if (pa === pb) return 0;
if (!pa) return 1;
if (!pb) return -1;
return pa < pb ? -1 : 1;
}

/** Picks the newest release for the channel that ships a portable exe. */
export function pickRelease(releases: GhRelease[], channel: 'stable' | 'beta'): { release: GhRelease; asset: GhAsset; version: string } | null {
let best: { release: GhRelease; asset: GhAsset; version: string } | null = null;
for (const release of releases) {
if (release.draft || (release.prerelease && channel !== 'beta')) continue;
const asset = release.assets.find((a) => ASSET.test(a.name));
if (!asset) continue;
const version = release.tag_name.replace(/^v/, '');
if (!best || compareVersions(version, best.version) > 0) best = { release, asset, version };
}
return best;
}

/** The exe this app was started from; only set for electron-builder portable builds. */
function portableExe(): string | undefined {
return process.env.PORTABLE_EXECUTABLE_FILE || undefined;
}

function updatesDir(): string {
return path.join(getAppDataDir(), 'self-update');
}

async function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url, { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'GetBox' } });
if (!res.ok) throw new Error(`GitHub ${res.status}`);
return (await res.json()) as T;
}

async function fetchText(url: string): Promise<string> {
const res = await fetch(url, { headers: { 'User-Agent': 'GetBox' }, redirect: 'follow' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}

function assertAllowed(url: string): void {
const u = new URL(url);
if (u.protocol !== 'https:' || !ALLOWED_HOSTS.has(u.hostname)) throw new Error(`Недопустимый адрес загрузки: ${u.hostname}`);
}

class SelfUpdater extends EventEmitter {
private state: SelfUpdateState = { status: 'idle', current: app.getVersion(), canApply: Boolean(portableExe()) };
private timer: NodeJS.Timeout | null = null;
private busy = false;

getState(): SelfUpdateState {
return { ...this.state };
}

private set(patch: Partial<SelfUpdateState>): void {
this.state = { ...this.state, ...patch };
this.emit('changed', this.getState());
}

/** Looks for a newer release and, when allowed, downloads and verifies it in the background. */
async check(manual = false): Promise<SelfUpdateState> {
if (this.busy || this.state.status === 'ready') return this.getState();
this.busy = true;
this.set({ status: 'checking', error: undefined });
try {
const { appUpdateChannel } = getSettings();
const releases = await fetchJson<GhRelease[]>(`${API}?per_page=15`);
const found = pickRelease(releases, appUpdateChannel);
if (!found || compareVersions(found.version, this.state.current) <= 0) {
this.set({ status: 'idle', latest: found?.version, checkedAt: Date.now() });
return this.getState();
}
this.set({
status: 'available', latest: found.version, notes: found.release.body, releaseUrl: found.release.html_url, checkedAt: Date.now(),
});
if (this.state.canApply && (manual || getSettings().autoUpdateApp)) await this.download(found.release, found.asset);
} catch (err) {
// A failed download keeps the update visible as «available» so it can be retried.
const status = this.state.status === 'downloading' || this.state.status === 'available' ? 'available' : 'error';
this.set({ status, progress: undefined, error: err instanceof Error ? err.message : String(err) });
} finally {
this.busy = false;
}
return this.getState();
}

/** Downloads the portable exe and checks it against the .sha256 file (or GitHub's digest) of the release. */
private async download(release: GhRelease, asset: GhAsset): Promise<void> {
const shaAsset = release.assets.find((a) => a.name === `${asset.name}.sha256`);
let expected = shaAsset ? (await fetchText(shaAsset.browser_download_url)).trim().split(/\s+/)[0].toLowerCase() : '';
if (!expected && asset.digest?.startsWith('sha256:')) expected = asset.digest.slice(7).toLowerCase();
if (!/^[0-9a-f]{64}$/.test(expected)) throw new Error('У релиза нет контрольной суммы SHA256 — обновление не скачано');

assertAllowed(asset.browser_download_url);
fs.mkdirSync(updatesDir(), { recursive: true });
const target = path.join(updatesDir(), asset.name);
const part = target + '.part';
this.set({ status: 'downloading', progress: 0 });

const res = await fetch(asset.browser_download_url, { headers: { 'User-Agent': 'GetBox' }, redirect: 'follow' });
if (!res.ok || !res.body) throw new Error(`Загрузка: HTTP ${res.status}`);
assertAllowed(res.url);
const total = Number(res.headers.get('content-length')) || asset.size;
const hash = crypto.createHash('sha256');
const out = fs.createWriteStream(part);
let done = 0, lastEmit = 0;
const reader = res.body.getReader();
for (;;) {
const { done: end, value } = await reader.read();
if (end) break;
hash.update(value);
done += value.length;
if (!out.write(value)) await new Promise<void>((r) => out.once('drain', () => r()));
if (Date.now() - lastEmit > 300) {
lastEmit = Date.now();
this.set({ progress: total ? Math.min(99, Math.round((done / total) * 100)) : undefined });
}
}
await new Promise<void>((resolve, reject) => out.end((err?: Error | null) => (err ? reject(err) : resolve())));
const actual = hash.digest('hex');
if (actual !== expected) {
fs.rmSync(part, { force: true });
throw new Error(`SHA256 не совпал: ${actual}`);
}
fs.renameSync(part, target);
this.set({ status: 'ready', progress: 100, downloadedPath: target });
}

/**
* Swaps the running portable exe for the downloaded one. The exe is locked while
* this process runs, so a detached script waits for it to exit, copies the new
* file over and (optionally) starts it again.
*/
apply(restart: boolean): boolean {
const exe = portableExe();
const next = this.state.downloadedPath;
if (!exe || !next || this.state.status !== 'ready' || !fs.existsSync(next)) return false;
const script = path.join(os.tmpdir(), `getbox-update-${process.pid}.cmd`);
const q = (p: string) => `"${p.replace(/"/g, '')}"`;
fs.writeFileSync(script, [
'@echo off',
':wait',
`tasklist /FI "PID eq ${process.pid}" | find "${process.pid}" >nul && (timeout /t 1 /nobreak >nul & goto wait)`,
// No write access to the exe's folder: bring the old version back instead of leaving GetBox closed.
`copy /Y ${q(next)} ${q(exe)} >nul || (${restart ? `start "" ${q(exe)} & ` : ''}goto end)`,
`del ${q(next)} >nul 2>&1`,
restart ? `start "" ${q(exe)}` : 'rem no restart',
':end',
'del "%~f0"',
].join('\r\n'));
spawn('cmd.exe', ['/c', script], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
return true;
}

/** «При выходе»: remember the choice and swap the exe when the app closes. */
setApplyOnQuit(on: boolean): void {
this.set({ applyOnQuit: on });
}

onQuit(): void {
if (this.state.applyOnQuit) this.apply(false);
}

schedule(atLaunch = false): void {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(async () => {
await this.check().catch(() => {});
this.schedule();
}, atLaunch ? FIRST_CHECK_DELAY_MS : CHECK_INTERVAL_MS);
}
}

export const selfUpdater = new SelfUpdater();
6 changes: 6 additions & 0 deletions electron/store/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const DEFAULTS: AppSettings = {
listView: 'cards',
autoCheckUpdates: true,
autoInstallUpdates: false,
autoUpdateApp: true,
appUpdateChannel: 'stable',
lastSeenVersion: undefined,
};

export const UI_SCALE_MIN = 0.9;
Expand Down Expand Up @@ -41,6 +44,9 @@ function sanitize(s: AppSettings): AppSettings {
uiScale,
autoCheckUpdates: typeof s.autoCheckUpdates === 'boolean' ? s.autoCheckUpdates : DEFAULTS.autoCheckUpdates,
autoInstallUpdates: typeof s.autoInstallUpdates === 'boolean' ? s.autoInstallUpdates : DEFAULTS.autoInstallUpdates,
autoUpdateApp: typeof s.autoUpdateApp === 'boolean' ? s.autoUpdateApp : DEFAULTS.autoUpdateApp,
appUpdateChannel: s.appUpdateChannel === 'beta' ? 'beta' : 'stable',
lastSeenVersion: typeof s.lastSeenVersion === 'string' && /^\d+\.\d+\.\d+/.test(s.lastSeenVersion) ? s.lastSeenVersion : undefined,
};
}

Expand Down
Loading
Loading