diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1de1e64..517f67e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 30936cc..fee5288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog -## Не выпущено +## 1.3.0 + +### GetBox обновляется сам +- Новая версия ищется на GitHub при запуске и раз в 6 часов, скачивается в фоне и сверяется по SHA256 с файлом из релиза. +- В заголовке окна появляется кнопка «Обновить до X»: перезапустить сейчас или обновиться при выходе. +- После обновления — экран «Что нового» с переходом к новым возможностям; повторно открывается из настроек. +- Настройки → GetBox: версия, проверка вручную, фоновая загрузка, канал «Стабильный» / «Бета». ### Обновления программ - Новая вкладка «Обновления»: что можно обновить через winget, Scoop и Chocolatey, с кнопками «Обновить» и «Обновить все». Обновления программ из каталога показываются под их названием и иконкой. diff --git a/data/whats-new.json b/data/whats-new.json new file mode 100644 index 0000000..7f5d44d --- /dev/null +++ b/data/whats-new.json @@ -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." } + ] +} diff --git a/electron/main.ts b/electron/main.ts index 6de28a3..ac6890e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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'; @@ -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()); @@ -443,6 +459,8 @@ 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(); @@ -450,6 +468,7 @@ app.whenReady().then(async () => { createWindow(); updates.schedule(true); + selfUpdater.schedule(true); if (shouldRetryBootstrap()) { startBootstrap(); diff --git a/electron/preload.ts b/electron/preload.ts index 6fb0809..4c53a02 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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; @@ -66,6 +66,13 @@ export interface GetBoxAPI { rollbackUpdate: (historyId: string) => Promise; clearUpdateHistory: () => Promise; onUpdatesChanged: (cb: (state: UpdatesState) => void) => () => void; + getAppVersion: () => Promise; + getSelfUpdate: () => Promise; + checkSelfUpdate: () => Promise; + restartToUpdate: () => Promise; + updateOnQuit: (on: boolean) => Promise; + openReleasePage: (version?: string) => Promise; + 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; @@ -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); @@ -225,4 +243,4 @@ declare global { } } -export type { MergedApp, Package, QueueJob, SearchResult, AppSettings, UpdatesState }; +export type { MergedApp, Package, QueueJob, SearchResult, AppSettings, UpdatesState, SelfUpdateState }; diff --git a/electron/providers/types.ts b/electron/providers/types.ts index b0717cb..d389c96 100644 --- a/electron/providers/types.ts +++ b/electron/providers/types.ts @@ -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; diff --git a/electron/self-update/self-update.ts b/electron/self-update/self-update.ts new file mode 100644 index 0000000..3fa5e8f --- /dev/null +++ b/electron/self-update/self-update.ts @@ -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(url: string): Promise { + 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 { + 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): 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 { + 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(`${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 { + 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((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((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(); diff --git a/electron/store/settings.ts b/electron/store/settings.ts index 5dae8f4..c902527 100644 --- a/electron/store/settings.ts +++ b/electron/store/settings.ts @@ -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; @@ -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, }; } diff --git a/package-lock.json b/package-lock.json index 5921837..f112427 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "getbox", - "version": "1.2.5", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "getbox", - "version": "1.2.5", + "version": "1.3.0", "dependencies": { "electron-store": "^10.0.0" }, diff --git a/package.json b/package.json index e1affd5..babcd62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "getbox", - "version": "1.2.5", + "version": "1.3.0", "description": "GetBox — локальный магазин программ для Windows", "main": "dist-electron/main.js", "type": "module", diff --git a/promo/director.js b/promo/director.js index 5f7ba52..2dd6055 100644 --- a/promo/director.js +++ b/promo/director.js @@ -1,3 +1,4 @@ +/* global window, XMLHttpRequest */ // Shared camera and highlight logic for promo13.html and corp13.html. // // Every zoom targets a real element: capture13.cjs measures element rects on diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index 3f0707b..e676110 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -700,5 +700,34 @@ console.log('\nupdate checks:'); }); } + +console.log('\nself-update:'); +{ + const { compareVersions, pickRelease } = loadTsModule( + path.join(__dirname, '..', 'electron', 'self-update', 'self-update.ts'), + { + electron: { ...electronStub, app: { ...electronStub.app, getVersion: () => '1.2.5' } }, + 'electron-store': class { get() { return {}; } set() {} }, + }, + ); + test('versions compare numerically, a release beats its prerelease', () => { + assert.equal(compareVersions('1.10.0', '1.9.9'), 1); + assert.equal(compareVersions('v1.3.0', '1.3.0'), 0); + assert.equal(compareVersions('1.3.0', '1.3.0-beta.2'), 1); + assert.equal(compareVersions('1.3.0-beta.1', '1.3.0-beta.2'), -1); + }); + const rel = (tag, prerelease = false, asset = true) => ({ + tag_name: tag, name: tag, body: '', html_url: '', prerelease, draft: false, + assets: asset ? [{ name: `GetBox-${tag.slice(1)}-portable.exe`, browser_download_url: 'https://github.com/x', size: 1 }] : [], + }); + test('stable channel skips prereleases and releases without the exe', () => { + const r = pickRelease([rel('v1.4.0-beta.1', true), rel('v1.3.1', false, false), rel('v1.3.0'), rel('v1.2.5')], 'stable'); + assert.equal(r.version, '1.3.0'); + }); + test('beta channel takes the newest prerelease', () => { + assert.equal(pickRelease([rel('v1.3.0'), rel('v1.4.0-beta.1', true)], 'beta').version, '1.4.0-beta.1'); + }); +} + console.log(`\n${passed} passed, ${failed} failed\n`); process.exit(failed > 0 ? 1 : 0); diff --git a/src/App.tsx b/src/App.tsx index 1cce99d..10ee56b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { MergedApp, ProfileEntry, QueueJob, SearchResult, ViewMode, AppSettings, ListView, UpdatesState } from './types'; +import { createPortal } from 'react-dom'; +import type { MergedApp, ProfileEntry, QueueJob, SearchResult, ViewMode, AppSettings, ListView, UpdatesState, SelfUpdateState } from './types'; import { packageKey } from './types'; import { I18nProvider } from './i18n/I18nContext'; import { translations, type Locale } from './i18n/translations'; @@ -20,6 +21,16 @@ import { MetaLoadingBar } from './components/MetaLoadingBar'; import { ToastContainer } from './components/Toast'; import { ViewSwitcher } from './components/ViewSwitcher'; import { UpdatesView } from './components/UpdatesView'; +import { SelfUpdateButton } from './components/SelfUpdateButton'; +import { WhatsNew, notesFor } from './components/WhatsNew'; +import { AppUpdateSettings } from './components/AppUpdateSettings'; + +/** -1 / 0 / 1 for dotted versions; enough for «is this a newer GetBox». */ +function compareVersions(a: string, b: string): number { + const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number); + for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return Math.sign((pa[i] || 0) - (pb[i] || 0)); + return 0; +} import { AppearanceSettings } from './components/AppearanceSettings'; import { applyAppearance } from './appearance'; import type { MetaProgress } from './types'; @@ -41,6 +52,10 @@ export default function App() { const [providers, setProviders] = useState>([]); const [settings, setSettings] = useState(null); const [nativeMica, setNativeMica] = useState(false); + const [selfUpdate, setSelfUpdate] = useState(null); + const [appVersion, setAppVersion] = useState(null); + const [whatsNew, setWhatsNew] = useState<{ version: string; previous?: string } | null>(null); + const [titleSlot, setTitleSlot] = useState(null); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState(null); @@ -127,8 +142,33 @@ export default function App() { useEffect(() => { window.getbox.windowNativeMica().then(setNativeMica, () => setNativeMica(false)); + window.getbox.getAppVersion().then(setAppVersion, () => {}); + window.getbox.getSelfUpdate().then(setSelfUpdate, () => {}); + setTitleSlot(document.getElementById('titlebar-slot')); + return window.getbox.onSelfUpdateChanged(setSelfUpdate); }, []); + // «Что нового»: once per version after an update; a fresh install just remembers its version. + const seenChecked = useRef(false); + useEffect(() => { + if (!settings || !appVersion || seenChecked.current) return; + seenChecked.current = true; + const last = settings.lastSeenVersion; + if (last && compareVersions(appVersion, last) > 0 && notesFor(appVersion)) { + setWhatsNew({ version: appVersion, previous: last }); + } else if (last !== appVersion) { + void window.getbox.updateSettings({ lastSeenVersion: appVersion }).then(setSettings); + } + }, [settings, appVersion]); + + const closeWhatsNew = useCallback(() => { + setWhatsNew(null); + if (appVersion) void window.getbox.updateSettings({ lastSeenVersion: appVersion }).then(setSettings); + }, [appVersion]); + const showWhatsNew = useCallback(() => { + if (appVersion && notesFor(appVersion)) setWhatsNew({ version: appVersion }); + }, [appVersion]); + useEffect(() => { if (settings) applyAppearance(settings, nativeMica); }, [settings, nativeMica]); @@ -633,6 +673,7 @@ export default function App() { {view === 'settings' && settings && (
+

{t.settings.title}

diff --git a/src/components/AppUpdateSettings.tsx b/src/components/AppUpdateSettings.tsx new file mode 100644 index 0000000..cc6c994 --- /dev/null +++ b/src/components/AppUpdateSettings.tsx @@ -0,0 +1,70 @@ +import { useI18n } from '../i18n/I18nContext'; +import type { AppSettings, SelfUpdateState } from '../types'; + +interface AppUpdateSettingsProps { + settings: AppSettings; + state: SelfUpdateState | null; + onChange: (partial: Partial) => void | Promise; + onShowWhatsNew: () => void; +} + +/** Settings → GetBox: version, background self-update and the release channel. */ +export function AppUpdateSettings({ settings, state, onChange, onShowWhatsNew }: AppUpdateSettingsProps) { + const { t } = useI18n(); + const status = !state + ? '' + : state.status === 'checking' + ? t.selfUpdate.checking + : state.status === 'ready' + ? `${t.selfUpdate.ready} ${state.latest}` + : state.status === 'downloading' + ? `${t.selfUpdate.downloading} ${state.progress ?? 0}%` + : state.status === 'available' + ? `${t.selfUpdate.available} ${state.latest}` + : state.status === 'error' + ? `${t.selfUpdate.failed}: ${state.error ?? ''}` + : state.checkedAt + ? t.selfUpdate.upToDate + : ''; + + return ( +
+

GetBox

+
+ {t.selfUpdate.version} {state?.current ?? ''} + {status} + + +
+
+ +
+
+ {t.selfUpdate.channel} +
+ {(['stable', 'beta'] as const).map((c) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/SelfUpdateButton.tsx b/src/components/SelfUpdateButton.tsx new file mode 100644 index 0000000..45ba5f1 --- /dev/null +++ b/src/components/SelfUpdateButton.tsx @@ -0,0 +1,118 @@ +import { useEffect, useRef, useState } from 'react'; +import { useI18n } from '../i18n/I18nContext'; +import type { SelfUpdateState } from '../types'; + +/** First few bullet lines of the release notes, without Markdown markers. */ +function summary(notes = ''): string[] { + return notes + .split('\n') + .map((l) => l.trim()) + .filter((l) => /^[-*] /.test(l)) + .map((l) => l.replace(/^[-*] /, '').replace(/\*\*|`/g, '')) + .slice(0, 3); +} + +interface SelfUpdateButtonProps { + state: SelfUpdateState; + onShowWhatsNew: () => void; +} + +/** «Обновить до X» in the title bar; a popover offers restart now or on exit. */ +export function SelfUpdateButton({ state, onShowWhatsNew }: SelfUpdateButtonProps) { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const close = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + const esc = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false); + document.addEventListener('mousedown', close); + document.addEventListener('keydown', esc); + return () => { + document.removeEventListener('mousedown', close); + document.removeEventListener('keydown', esc); + }; + }, [open]); + + const visible = state.latest && (state.status === 'available' || state.status === 'downloading' || state.status === 'ready'); + if (!visible) return null; + const ready = state.status === 'ready'; + const label = ready + ? `${t.selfUpdate.updateTo} ${state.latest}` + : state.status === 'downloading' + ? `${t.selfUpdate.downloading} ${state.progress ?? 0}%` + : `${t.selfUpdate.available} ${state.latest}`; + + return ( +
+ + {open && ( +
+
+

+ {ready ? `GetBox ${state.latest} ${t.selfUpdate.readyTitle}` : `GetBox ${state.latest}`} +

+ {summary(state.notes).length > 0 && ( +
    {summary(state.notes).map((l) =>
  • {l}
  • )}
+ )} + {ready &&

{t.selfUpdate.verified}

} + {state.status === 'downloading' && ( +
+ +
+ )} + {!state.canApply &&

{t.selfUpdate.manual}

} + {state.error &&

{state.error}

} +
+
+ {ready && state.canApply ? ( + <> + + + + ) : state.status === 'available' && state.canApply ? ( + + ) : !state.canApply ? ( + + ) : null} +
+ + +
+ )} +
+ ); +} diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 5b1d0d6..195c951 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -45,6 +45,8 @@ export function TitleBar() { GetBox
+ {/* App portals the GetBox update button here, inside its i18n provider. */} +
+ )} + + ))} +
+
+ + +
+
+
+ ); +} diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 00690eb..722d257 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -126,6 +126,37 @@ export const translations = { nothingFound: 'Ничего не найдено по фильтру', count: 'пакетов', }, + selfUpdate: { + available: 'Доступна', + downloading: 'Скачивается', + updateTo: 'Обновить до', + readyTitle: 'готов к установке', + ready: 'Готова к установке', + verified: 'Скачано в фоне · SHA256 совпадает с релизом', + manual: 'Это не портативная сборка — скачайте новую версию со страницы релиза.', + restart: 'Перезапустить', + onQuit: 'При выходе', + onQuitSet: 'Обновится при выходе', + download: 'Скачать', + openRelease: 'Страница релиза', + whatsNewIn: 'Что нового в', + currentWhatsNew: 'Что нового в этой версии', + checking: 'Проверяем…', + failed: 'Не удалось проверить', + upToDate: 'Установлена последняя версия', + version: 'Версия', + check: 'Проверить обновления', + auto: 'Скачивать новые версии GetBox в фоне', + channel: 'Канал обновлений', + stable: 'Стабильный', + beta: 'Бета', + }, + whatsNew: { + title: 'GetBox обновился', + kept: 'Очередь установок, избранное и настройки сохранены.', + continue: 'Продолжить', + fullChangelog: 'Полный список изменений', + }, updates: { check: 'Проверить', checking: 'Проверяем…', @@ -357,6 +388,37 @@ export const translations = { nothingFound: 'No packages match the filter', count: 'packages', }, + selfUpdate: { + available: 'Available:', + downloading: 'Downloading', + updateTo: 'Update to', + readyTitle: 'is ready to install', + ready: 'Ready to install:', + verified: 'Downloaded in the background · SHA256 matches the release', + manual: 'This is not the portable build — download the new version from the release page.', + restart: 'Restart', + onQuit: 'On exit', + onQuitSet: 'Updates on exit', + download: 'Download', + openRelease: 'Release page', + whatsNewIn: 'What’s new in', + currentWhatsNew: 'What’s new in this version', + checking: 'Checking…', + failed: 'Check failed', + upToDate: 'You have the latest version', + version: 'Version', + check: 'Check for updates', + auto: 'Download new GetBox versions in the background', + channel: 'Update channel', + stable: 'Stable', + beta: 'Beta', + }, + whatsNew: { + title: 'GetBox has been updated', + kept: 'Your install queue, favourites and settings are kept.', + continue: 'Continue', + fullChangelog: 'Full changelog', + }, updates: { check: 'Check', checking: 'Checking…', diff --git a/src/index.css b/src/index.css index d7f1c73..4157f17 100644 --- a/src/index.css +++ b/src/index.css @@ -2237,3 +2237,154 @@ td.app-table-name span { overflow: hidden; text-overflow: ellipsis; } .upd-check { display: flex; align-items: flex-start; gap: 9px; cursor: pointer; } .upd-check input { width: 15px; height: 15px; margin-top: 2px; accent-color: var(--accent); } .upd-aside-note { color: var(--text-muted); font-size: 12px; line-height: 1.5; } + +/* ---------- GetBox self-update ---------- */ + +.titlebar-slot { display: flex; align-items: center; height: 100%; padding-right: 8px; -webkit-app-region: no-drag; } +.self-update { position: relative; } + +.self-update-btn { + display: inline-flex; + align-items: center; + gap: 7px; + height: 24px; + padding: 0 11px 0 9px; + border: 1px solid var(--accent-line); + border-radius: 12px; + background: var(--accent-wash); + color: var(--accent-hover); + font-size: 11.5px; + font-weight: 600; + cursor: pointer; +} + +.self-update-btn.ready { + border-color: color-mix(in srgb, var(--ok) 60%, transparent); + background: color-mix(in srgb, var(--ok) 16%, transparent); + color: var(--text-primary); +} + +.self-update-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); } +.self-update-btn.ready .self-update-dot { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 30%, transparent); } + +.self-update-pop { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 80; + width: 340px; + border: var(--border-width) solid var(--border-strong); + border-radius: var(--r-lg); + background: var(--bg-raised); + box-shadow: var(--shadow-modal); + font-size: 12.5px; +} + +.self-update-pop-body { display: flex; flex-direction: column; gap: 9px; padding: 14px 16px 6px; } +.self-update-pop h2 { font-size: 14px; } +.self-update-pop ul { margin-left: 16px; color: var(--text-secondary); line-height: 1.5; } +.self-update-ok { color: var(--text-secondary); } +.self-update-ok::before { content: '✓ '; color: var(--ok); font-weight: 700; } +.self-update-note { color: var(--text-muted); } +.self-update-err { color: var(--danger); } +.self-update-progress { height: 5px; border-radius: 3px; background: var(--bg-inset); overflow: hidden; } +.self-update-progress i { display: block; height: 100%; background: var(--accent); } +.self-update-actions { display: flex; gap: 8px; padding: 10px 16px 12px; } +.self-update-actions .btn { flex: 1; justify-content: center; } +.self-update-actions .btn.active { border-color: var(--accent-line); color: var(--accent-hover); } + +.self-update-link { + display: block; + width: 100%; + padding: 9px 16px; + border: 0; + border-top: 1px solid var(--border); + background: none; + color: var(--accent-hover); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.self-update-link--muted { color: var(--text-secondary); } +.self-update-link:hover { background: var(--overlay-hover); } + +.app-update { margin: 8px 0 26px; } +.app-update-row { flex-wrap: wrap; } +.app-update-status { flex: 1; color: var(--text-secondary); } + +/* ---------- What's new ---------- */ + +.whats-new { + position: fixed; + inset: 0; + z-index: 90; + overflow: auto; + background: radial-gradient(900px 600px at 50% 0%, color-mix(in srgb, var(--accent) 22%, var(--bg-base)) 0%, var(--bg-base) 70%); + animation: fade-up 0.25s ease; +} + +.whats-new-inner { + max-width: 1100px; + margin: 0 auto; + padding: 56px 32px 40px; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + text-align: center; +} + +.whats-new-logo { width: 72px; height: 72px; } +.whats-new-version { padding: 3px 12px; border-radius: 12px; background: var(--accent-dim); color: var(--accent-hover); } +.whats-new h1 { font-size: 34px; letter-spacing: -0.01em; } +.whats-new-sub { color: var(--text-secondary); font-size: 14px; } + +.whats-new-cards { + width: 100%; + margin-top: 18px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; + text-align: left; +} + +.whats-new-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 18px; + border: var(--border-width) solid var(--border); + border-radius: var(--r-lg); + background: var(--bg-surface); +} + +.whats-new-card h2 { font-size: 15px; } +.whats-new-card p { color: var(--text-secondary); font-size: 13px; line-height: 1.5; } +.whats-new-icon { width: 46px; height: 46px; border-radius: 12px; display: grid; place-items: center; background: var(--accent-dim); } +.whats-new-icon svg { fill: none; stroke: var(--accent-hover); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } + +.whats-new-link { + margin-top: auto; + align-self: flex-start; + padding: 0; + border: 0; + background: none; + color: var(--accent-hover); + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.whats-new-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 18px; margin-top: 18px; } +.whats-new-actions .whats-new-link { margin: 0; align-self: center; font-weight: 400; color: var(--text-secondary); } + +.btn-accent-solid { + padding: 10px 26px; + background: var(--accent); + color: var(--on-accent); + font-size: 13px; + font-weight: 700; +} + +.btn-accent-solid:hover { filter: brightness(1.08); } diff --git a/src/types.ts b/src/types.ts index 9c364c7..c6b850a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -109,6 +109,23 @@ export interface AppSettings { listView: ListView; autoCheckUpdates: boolean; autoInstallUpdates: boolean; + autoUpdateApp: boolean; + appUpdateChannel: 'stable' | 'beta'; + lastSeenVersion?: string; +} + +export interface SelfUpdateState { + status: 'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'error'; + current: string; + latest?: string; + notes?: string; + releaseUrl?: string; + checkedAt?: number; + progress?: number; + downloadedPath?: string; + canApply: boolean; + applyOnQuit?: boolean; + error?: string; } export type ThemeId = 'classic' | 'light' | 'oled' | 'contrast' | 'mica'; diff --git a/tsconfig.json b/tsconfig.json index f3638e3..9fcee9d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, + "resolveJsonModule": true, "paths": { "@/*": ["./src/*"] }