diff --git a/CHANGELOG.md b/CHANGELOG.md index 36bd67f..30936cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Не выпущено + +### Обновления программ +- Новая вкладка «Обновления»: что можно обновить через winget, Scoop и Chocolatey, с кнопками «Обновить» и «Обновить все». Обновления программ из каталога показываются под их названием и иконкой. +- История обновлений по дням. Успешное обновление можно откатить до предыдущей версии: `winget install --version`, `scoop reset app@версия` или `choco install --version --allow-downgrade`. +- Проверка при запуске и раз в 6 часов; по желанию обновления ставятся в фоне без вопросов. Количество доступных обновлений видно на вкладке. +- Обновления и откаты идут через общую очередь установок с прогрессом и отменой. + +### Внешний вид +- Пять тем в «Расширенных → Внешний вид»: Классика, Светлая, OLED, Высокий контраст и Windows 11. На Windows 11 22H2+ тема Windows 11 включает системный фон Mica, на других системах рисует полупрозрачную подложку. +- Свой акцентный цвет (7 готовых или любой через палитру); цвет текста на кнопках подбирается по яркости акцента. +- Масштаб интерфейса: 90 / 100 / 110 / 125 %. +- Четыре вида списков: карточки, список с описанием, компактная таблица и плитки. Переключатель стоит над каждым списком, выбор запоминается. +- Значения внешнего вида проверяются в main-процессе: повреждённые настройки не ломают интерфейс. + ## 1.2.5 ### Установка diff --git a/electron/main.ts b/electron/main.ts index 80cab8d..6de28a3 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow, ipcMain, shell, protocol, Menu } from 'electron'; import path from 'node:path'; +import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { initProviders, getAllProviders, setCatalogRemoteUrl } from './providers/registry.js'; import { warmMetaCacheFromDisk, enrichMeta, prewarmMeta, maybeAutoPrewarm, getMetaCompleteness, getMetaProgress } from './enrich/meta-cache.js'; @@ -7,6 +8,7 @@ import { enqueuePackageMeta, onPackageMetaUpdate, type PackageMetaRequest } from import { getCatalog, invalidateCatalog, setInstalledSet, findApp } from './catalog/merge.js'; import { searchPackages } from './search-cache.js'; import { installQueue } from './queue/install-queue.js'; +import { updates } from './updates/updates.js'; import { buildIndex, isIndexEmptyOrSmall } from './index-builder.js'; import { getSettings, updateSettings } from './store/settings.js'; import { toggleFavorite, isFavorite } from './store/favorites.js'; @@ -14,7 +16,7 @@ import { registerMediaProtocol } from './protocol.js'; import { loadCuratedCatalog } from './providers/catalog.js'; import fs from 'node:fs'; import { getDataDir } from './paths.js'; -import type { ProfileEntry } from './providers/types.js'; +import type { ProfileEntry, UpdateInfo } from './providers/types.js'; import { bootstrapRuntime, isBootstrapComplete, @@ -153,10 +155,44 @@ function createWindow(): void { mainWindow.loadFile(path.join(__dirname, '../dist/index.html')); } + mainWindow.webContents.on('did-finish-load', applyAppearance); mainWindow.once('ready-to-show', () => mainWindow?.show()); mainWindow.on('closed', () => { mainWindow = null; }); } +/** Mica needs Windows 11 22H2+ (build 22621); elsewhere the theme falls back to a painted backdrop. */ +function supportsMica(): boolean { + if (process.platform !== 'win32') return false; + const build = Number(os.release().split('.')[2]); + return Number.isFinite(build) && build >= 22621; +} + +function applyAppearance(): void { + if (!mainWindow) return; + const { uiScale, theme } = getSettings(); + mainWindow.webContents.setZoomFactor(uiScale); + if (!supportsMica()) return; + const mica = theme === 'mica'; + mainWindow.setBackgroundColor(mica ? '#00000000' : '#0a1118'); + mainWindow.setBackgroundMaterial(mica ? 'mica' : 'none'); +} + +function queueUpgrades(items: UpdateInfo[]): void { + installQueue.addMany( + items + .filter((u) => !installQueue.isQueued(u.provider, u.packageId)) + .map((u) => ({ + provider: u.provider, + packageId: u.packageId, + name: u.name, + kind: 'upgrade' as const, + fromVersion: u.installed, + toVersion: u.available, + catalogId: u.catalogId, + })), + ); +} + function sendToRenderer(channel: string, ...args: unknown[]): void { mainWindow?.webContents.send(channel, ...args); } @@ -239,6 +275,30 @@ 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('updates:get', () => updates.getState()); + ipcMain.handle('updates:check', () => updates.check()); + ipcMain.handle('updates:clearHistory', () => updates.clearHistory()); + // The renderer names packages; versions come from our own state, never from IPC. + ipcMain.handle('updates:upgrade', (_e, keys: Array<{ provider: string; packageId: string }>) => { + const items = updates.getState().items.filter((u) => + keys.some((k) => k.provider === u.provider && k.packageId === u.packageId), + ); + queueUpgrades(items); + return items.length; + }); + ipcMain.handle('updates:rollback', (_e, historyId: string) => { + const entry = updates.getState().history.find((h) => h.id === historyId); + if (!entry || !entry.ok || entry.kind !== 'upgrade' || !entry.from) return false; + if (installQueue.isQueued(entry.provider, entry.packageId)) return false; + installQueue.add(entry.provider, entry.packageId, entry.name, { + kind: 'rollback', + fromVersion: entry.to, + toVersion: entry.from, + catalogId: entry.catalogId, + }); + return true; + }); + ipcMain.handle('index:rebuild', () => runIndexBuild()); ipcMain.handle('providers:status', async () => { @@ -253,10 +313,13 @@ function setupIpc(): void { }); ipcMain.handle('settings:get', () => getSettings()); + ipcMain.handle('window:nativeMica', () => supportsMica()); ipcMain.handle('settings:update', (_e, partial) => { const prev = getSettings(); const next = updateSettings(partial); setCatalogRemoteUrl(next.remoteCatalogUrl); + if (next.uiScale !== prev.uiScale || next.theme !== prev.theme) applyAppearance(); + if (next.autoCheckUpdates !== prev.autoCheckUpdates) updates.schedule(); if (partial.language && partial.language !== prev.language) { invalidateCatalog('index'); sendToRenderer('catalog:onDirty'); @@ -378,11 +441,15 @@ 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)); + updates.on('auto-install', (items: UpdateInfo[]) => queueUpgrades(items)); installQueue.on('installed-changed', () => { refreshInstalledInBackground(); }); createWindow(); + updates.schedule(true); if (shouldRetryBootstrap()) { startBootstrap(); diff --git a/electron/preload.ts b/electron/preload.ts index a9a86bc..6fb0809 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 } from './providers/types.js'; +import type { MergedApp, Package, QueueJob, SearchResult, AppSettings, UpdatesState } from './providers/types.js'; export interface RuntimeToolInfo { status: string; @@ -60,6 +60,12 @@ export interface GetBoxAPI { dismissQueueJob: (jobId: string) => Promise; retryQueueJob: (jobId: string) => Promise; onQueueUpdated: (cb: (jobs: QueueJob[]) => void) => () => void; + getUpdates: () => Promise; + checkUpdates: () => Promise; + upgradePackages: (keys: Array<{ provider: string; packageId: string }>) => Promise; + rollbackUpdate: (historyId: string) => Promise; + clearUpdateHistory: () => Promise; + onUpdatesChanged: (cb: (state: UpdatesState) => void) => () => void; rebuildIndex: () => Promise<{ ok: boolean; count: number; message?: string }>; onIndexProgress: (cb: (msg: string) => void) => () => void; onIndexDone: (cb: (count: number) => void) => () => void; @@ -83,6 +89,7 @@ export interface GetBoxAPI { windowMaximize: () => Promise; windowClose: () => Promise; windowIsMaximized: () => Promise; + windowNativeMica: () => Promise; onWindowState: (cb: (state: { maximized: boolean }) => void) => () => void; } @@ -132,6 +139,16 @@ const api: GetBoxAPI = { cancelQueueJob: (jobId) => ipcRenderer.invoke('queue:cancel', jobId), dismissQueueJob: (jobId) => ipcRenderer.invoke('queue:dismiss', jobId), retryQueueJob: (jobId) => ipcRenderer.invoke('queue:retryJob', jobId), + getUpdates: () => ipcRenderer.invoke('updates:get'), + checkUpdates: () => ipcRenderer.invoke('updates:check'), + upgradePackages: (keys) => ipcRenderer.invoke('updates:upgrade', keys), + rollbackUpdate: (historyId) => ipcRenderer.invoke('updates:rollback', historyId), + clearUpdateHistory: () => ipcRenderer.invoke('updates:clearHistory'), + onUpdatesChanged: (cb) => { + const handler = (_: unknown, state: UpdatesState) => cb(state); + ipcRenderer.on('updates:changed', handler); + return () => ipcRenderer.removeListener('updates:changed', handler); + }, onQueueUpdated: (cb) => { const handler = (_: unknown, jobs: QueueJob[]) => cb(jobs); ipcRenderer.on('queue:updated', handler); @@ -192,6 +209,7 @@ const api: GetBoxAPI = { windowMaximize: () => ipcRenderer.invoke('window:maximize'), windowClose: () => ipcRenderer.invoke('window:close'), windowIsMaximized: () => ipcRenderer.invoke('window:isMaximized'), + windowNativeMica: () => ipcRenderer.invoke('window:nativeMica'), onWindowState: (cb) => { const handler = (_: unknown, state: { maximized: boolean }) => cb(state); ipcRenderer.on('window:state', handler); @@ -207,4 +225,4 @@ declare global { } } -export type { MergedApp, Package, QueueJob, SearchResult, AppSettings }; +export type { MergedApp, Package, QueueJob, SearchResult, AppSettings, UpdatesState }; diff --git a/electron/providers/catalog.ts b/electron/providers/catalog.ts index 4f7619f..6e9f58d 100644 --- a/electron/providers/catalog.ts +++ b/electron/providers/catalog.ts @@ -767,3 +767,20 @@ export function invalidateCatalogCache(): void { wingetMapCache = null; installersCache = null; } + +/** + * Package-manager id (`winget:Mozilla.Firefox`) → catalog package id, so an update + * reported by winget can be shown under the curated app's name and icon. + * Ids are compared case-insensitively: winget and choco ignore case. + */ +export function catalogIdsBySource(): Map { + const installers = loadInstallers(); + const wingetMap = loadWingetMap(); + const map = new Map(); + for (const item of loadLocalCatalog()) { + const plan = resolveInstallPlan(item.id, installers, wingetMap); + const source = plan.fallback; + if (source) map.set(`${source.provider}:${source.packageId.toLowerCase()}`, item.packageId); + } + return map; +} diff --git a/electron/providers/chocolatey.ts b/electron/providers/chocolatey.ts index 76c2a7b..74d10f1 100644 --- a/electron/providers/chocolatey.ts +++ b/electron/providers/chocolatey.ts @@ -6,10 +6,12 @@ import type { PackageDetails, PackageProvider, ProviderStatus, + UpdateInfo, } from './types.js'; import { getRuntimePaths, testChocolatey, runCmd } from '../bootstrap/runtime.js'; import { runCliWithProgress } from './cli-progress.js'; import { classifyError } from './install-errors.js'; +import { parseChocoOutdated } from '../updates/parse.js'; const INFO_LABEL = /^\s*(Summary|Description|Software Site|Title)\s*:\s*(.*)$/i; @@ -98,9 +100,27 @@ export class ChocolateyProvider implements PackageProvider { } async install(packageId: string, opts: InstallOptions): Promise { + const args = ['install', packageId, '-y']; + if (opts.quiet) args.push('--force'); + return this.run(packageId, args, opts); + } + + async upgrade(packageId: string, opts: InstallOptions): Promise { + return this.run(packageId, ['upgrade', packageId, '-y'], opts); + } + + async installVersion(packageId: string, version: string, opts: InstallOptions): Promise { + return this.run(packageId, ['install', packageId, '--version', version, '--allow-downgrade', '--force', '-y'], opts); + } + + async listUpgrades(): Promise { + if (!(await this.available()).ok) return []; + const out = await runCmd(this.chocoExe(), ['outdated', '--limit-output'], 120000); + return parseChocoOutdated(out).map((u) => ({ ...u, provider: this.id })); + } + + private async run(packageId: string, args: string[], opts: InstallOptions): Promise { try { - const args = ['install', packageId, '-y']; - if (opts.quiet) args.push('--force'); const { code, output } = await runCliWithProgress(this.chocoExe(), args, { timeout: 600000, onProgress: opts.onProgress, diff --git a/electron/providers/scoop.ts b/electron/providers/scoop.ts index 0a41304..5f6a321 100644 --- a/electron/providers/scoop.ts +++ b/electron/providers/scoop.ts @@ -8,10 +8,12 @@ import type { PackageDetails, PackageProvider, ProviderStatus, + UpdateInfo, } from './types.js'; import { getRuntimePaths, testScoop, loadRuntimeState } from '../bootstrap/runtime.js'; import { runCliWithProgress } from './cli-progress.js'; import { classifyError } from './install-errors.js'; +import { parseScoopStatus } from '../updates/parse.js'; import { getAppDataDir } from '../paths.js'; import path from 'node:path'; @@ -206,11 +208,30 @@ export class ScoopProvider implements PackageProvider { } async install(packageId: string, opts: InstallOptions): Promise { + const args = ['install', packageId]; + if (opts.quiet) args.push('-g'); + return this.run(packageId, args, opts); + } + + async upgrade(packageId: string, opts: InstallOptions): Promise { + return this.run(packageId, ['update', packageId], opts); + } + + /** Scoop keeps earlier versions on disk until `scoop cleanup`, so a reset switches back offline. */ + async installVersion(packageId: string, version: string, opts: InstallOptions): Promise { + return this.run(packageId, ['reset', `${packageId}@${version}`], opts); + } + + async listUpgrades(): Promise { + if (!(await this.available()).ok) return []; + const out = await runScoop(['status'], 120000); + return parseScoopStatus(out).map((u) => ({ ...u, provider: this.id })); + } + + private async run(packageId: string, args: string[], opts: InstallOptions): Promise { try { const resolved = await resolveScoopExe(); if (!resolved) throw new Error('Scoop runtime not ready'); - const args = ['install', packageId]; - if (opts.quiet) args.push('-g'); const { code, output } = await runCliWithProgress(resolved.exe, args, { timeout: 300000, env: resolved.env, diff --git a/electron/providers/types.ts b/electron/providers/types.ts index 02a518a..b0717cb 100644 --- a/electron/providers/types.ts +++ b/electron/providers/types.ts @@ -68,6 +68,45 @@ export interface PackageProvider { listAll?(): Promise; /** Description and homepage for a single package, used to fill in index entries. */ describe?(packageId: string): Promise; + /** Installed packages that have a newer version in the source. */ + listUpgrades?(): Promise; + upgrade?(packageId: string, opts: InstallOptions): Promise; + /** Install an exact version over the current one; used to roll back an update. */ + installVersion?(packageId: string, version: string, opts: InstallOptions): Promise; +} + +export interface UpdateInfo { + provider: string; + packageId: string; + name: string; + installed: string; + available: string; + /** Catalog package id when the update belongs to a curated app, for its name and icon. */ + catalogId?: string; +} + +export type JobKind = 'install' | 'upgrade' | 'rollback'; + +export interface UpdateHistoryEntry { + id: string; + kind: 'upgrade' | 'rollback'; + provider: string; + packageId: string; + name: string; + catalogId?: string; + from?: string; + to?: string; + at: number; + ok: boolean; + message?: string; + errorCode?: InstallErrorCode; +} + +export interface UpdatesState { + items: UpdateInfo[]; + checkedAt?: number; + checking: boolean; + history: UpdateHistoryEntry[]; } export interface CuratedEntry { @@ -142,6 +181,11 @@ export interface QueueJob { detail?: string; errorCode?: InstallErrorCode; progress?: InstallProgress; + /** Absent on plain installs. */ + kind?: JobKind; + fromVersion?: string; + toVersion?: string; + catalogId?: string; } export interface AppSettings { @@ -149,7 +193,23 @@ export interface AppSettings { quietInstall: boolean; remoteCatalogUrl?: string; language: 'ru' | 'en'; -} + theme: ThemeId; + /** Custom accent hex; overrides the theme's own accent when set. */ + accent?: string; + /** Interface zoom factor, 0.9–1.25. */ + uiScale: number; + listView: ListView; + /** Look for updates at launch and every 6 hours. */ + autoCheckUpdates: boolean; + /** Queue every found update without asking. */ + autoInstallUpdates: boolean; +} + +export const THEME_IDS = ['classic', 'light', 'oled', 'contrast', 'mica'] as const; +export type ThemeId = (typeof THEME_IDS)[number]; + +export const LIST_VIEWS = ['cards', 'list', 'table', 'tiles'] as const; +export type ListView = (typeof LIST_VIEWS)[number]; export const APP_NAME = 'GetBox'; diff --git a/electron/providers/winget.ts b/electron/providers/winget.ts index 0cb144b..64d405b 100644 --- a/electron/providers/winget.ts +++ b/electron/providers/winget.ts @@ -5,10 +5,12 @@ import type { PackageDetails, PackageProvider, ProviderStatus, + UpdateInfo, } from './types.js'; import { getRuntimePaths, testWinget, runCmd } from '../bootstrap/runtime.js'; import { runCliWithProgress } from './cli-progress.js'; import { classifyError } from './install-errors.js'; +import { parseWingetUpgrade } from '../updates/parse.js'; /** winget prints in the Windows display language, so every label needs both forms. */ const FIELD_LABELS: Record = { @@ -136,15 +138,29 @@ export class WingetProvider implements PackageProvider { } async install(packageId: string, opts: InstallOptions): Promise { + return this.run(packageId, ['install', '--id', packageId, '-e', '--scope', 'user'], opts); + } + + async upgrade(packageId: string, opts: InstallOptions): Promise { + return this.run(packageId, ['upgrade', '--id', packageId, '-e'], opts); + } + + async installVersion(packageId: string, version: string, opts: InstallOptions): Promise { + return this.run(packageId, ['install', '--id', packageId, '-e', '--version', version, '--force'], opts); + } + + async listUpgrades(): Promise { + const exe = this.wingetExe(); + if (!exe || !(await this.available()).ok) return []; + const out = await runCmd(exe, ['upgrade', '--accept-source-agreements', '--disable-interactivity'], 120000); + return parseWingetUpgrade(out).map((u) => ({ ...u, provider: this.id })); + } + + private async run(packageId: string, base: string[], opts: InstallOptions): Promise { const exe = this.wingetExe(); if (!exe) return { ok: false, message: 'winget недоступен' }; try { - const args = [ - 'install', '--id', packageId, '-e', - '--accept-package-agreements', '--accept-source-agreements', - '--disable-interactivity', - '--scope', 'user', - ]; + const args = [...base, '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity']; if (opts.quiet) args.push('--silent'); const { code, output } = await runCliWithProgress(exe, args, { timeout: 600000, diff --git a/electron/queue/install-queue.ts b/electron/queue/install-queue.ts index 46c40e0..2f3003e 100644 --- a/electron/queue/install-queue.ts +++ b/electron/queue/install-queue.ts @@ -1,12 +1,19 @@ import { EventEmitter } from 'node:events'; import crypto from 'node:crypto'; -import type { InstallProgress, QueueJob } from '../providers/types.js'; +import type { InstallProgress, InstallResult, JobKind, QueueJob } from '../providers/types.js'; import { getProvider } from '../providers/registry.js'; import { getSettings } from '../store/settings.js'; import { classifyError } from '../providers/install-errors.js'; const PROGRESS_EMIT_MS = 250; +export interface JobExtra { + kind?: JobKind; + fromVersion?: string; + toVersion?: string; + catalogId?: string; +} + export class InstallQueue extends EventEmitter { private jobs: QueueJob[] = []; private running = false; @@ -20,14 +27,21 @@ export class InstallQueue extends EventEmitter { return [...this.jobs]; } - add(provider: string, packageId: string, name: string): void { + add(provider: string, packageId: string, name: string, extra: JobExtra = {}): void { const id = crypto.randomUUID(); - this.jobs.push({ id, provider, packageId, name, status: 'pending' }); + this.jobs.push({ id, provider, packageId, name, status: 'pending', ...extra }); this.emitUpdated(); this.processNext(); } - addMany(items: Array<{ provider: string; packageId: string; name: string }>): void { + /** True while an install, upgrade or rollback of this package waits or runs. */ + isQueued(provider: string, packageId: string): boolean { + return this.jobs.some( + (j) => j.provider === provider && j.packageId === packageId && (j.status === 'pending' || j.status === 'running'), + ); + } + + addMany(items: Array<{ provider: string; packageId: string; name: string } & JobExtra>): void { for (const item of items) { this.jobs.push({ id: crypto.randomUUID(), @@ -35,6 +49,10 @@ export class InstallQueue extends EventEmitter { packageId: item.packageId, name: item.name, status: 'pending', + kind: item.kind, + fromVersion: item.fromVersion, + toVersion: item.toVersion, + catalogId: item.catalogId, }); } this.emitUpdated(); @@ -161,16 +179,28 @@ export class InstallQueue extends EventEmitter { next.status = 'failed'; next.errorCode = 'unknown'; next.message = `Провайдер ${next.provider} не найден`; - this.finishJob(); + this.finishJob(next.id); return; } try { - const result = await provider.install(next.packageId, { + const opts = { quiet: settings.quietInstall, signal: controller.signal, - onProgress: (progress) => this.reportProgress(next.id, progress), - }); + onProgress: (progress: InstallProgress) => this.reportProgress(next.id, progress), + }; + let result: InstallResult; + if (next.kind === 'upgrade') { + result = provider.upgrade + ? await provider.upgrade(next.packageId, opts) + : { ok: false, errorCode: 'unknown', message: `${provider.displayName} не умеет обновлять пакеты` }; + } else if (next.kind === 'rollback') { + result = provider.installVersion && next.toVersion + ? await provider.installVersion(next.packageId, next.toVersion, opts) + : { ok: false, errorCode: 'unknown', message: `${provider.displayName} не умеет ставить версию` }; + } else { + result = await provider.install(next.packageId, opts); + } if (controller.signal.aborted || result.errorCode === 'cancelled') { next.status = 'cancelled'; @@ -212,7 +242,10 @@ export class InstallQueue extends EventEmitter { private finishJob(jobId?: string): void { const job = jobId ? this.jobs.find((j) => j.id === jobId) : undefined; - if (job) job.progress = undefined; + if (job) { + job.progress = undefined; + this.emit('job-finished', { ...job }); + } if (this.dropRunningOnComplete && job) { this.jobs = this.jobs.filter((j) => j.id !== job.id); diff --git a/electron/store/settings.ts b/electron/store/settings.ts index 42fdfbf..5dae8f4 100644 --- a/electron/store/settings.ts +++ b/electron/store/settings.ts @@ -1,25 +1,57 @@ import Store from 'electron-store'; -import type { AppSettings } from '../providers/types.js'; +import { LIST_VIEWS, THEME_IDS, type AppSettings } from '../providers/types.js'; + +const DEFAULTS: AppSettings = { + enabledProviders: ['catalog', 'scoop', 'winget', 'chocolatey'], + quietInstall: true, + remoteCatalogUrl: undefined, + language: 'ru', + theme: 'classic', + accent: undefined, + uiScale: 1, + listView: 'cards', + autoCheckUpdates: true, + autoInstallUpdates: false, +}; + +export const UI_SCALE_MIN = 0.9; +export const UI_SCALE_MAX = 1.25; const store = new Store<{ settings: AppSettings }>({ name: 'getbox-settings', - defaults: { - settings: { - enabledProviders: ['catalog', 'scoop', 'winget', 'chocolatey'], - quietInstall: true, - remoteCatalogUrl: undefined, - language: 'ru', - }, - }, + defaults: { settings: DEFAULTS }, }); +const HEX_COLOR = /^#[0-9a-f]{6}$/i; + +/** Drops appearance values the renderer cannot apply, so a bad store never breaks the UI. */ +function sanitize(s: AppSettings): AppSettings { + const theme = (THEME_IDS as readonly string[]).includes(s.theme) ? s.theme : DEFAULTS.theme; + const listView = (LIST_VIEWS as readonly string[]).includes(s.listView) ? s.listView : DEFAULTS.listView; + const accent = typeof s.accent === 'string' && HEX_COLOR.test(s.accent) ? s.accent.toLowerCase() : undefined; + const scale = Number(s.uiScale); + const uiScale = Number.isFinite(scale) + ? Math.min(UI_SCALE_MAX, Math.max(UI_SCALE_MIN, Math.round(scale * 100) / 100)) + : DEFAULTS.uiScale; + return { + ...s, + theme, + listView, + accent, + uiScale, + autoCheckUpdates: typeof s.autoCheckUpdates === 'boolean' ? s.autoCheckUpdates : DEFAULTS.autoCheckUpdates, + autoInstallUpdates: typeof s.autoInstallUpdates === 'boolean' ? s.autoInstallUpdates : DEFAULTS.autoInstallUpdates, + }; +} + export function getSettings(): AppSettings { - return store.get('settings'); + // Stores written by older versions lack the appearance keys. + return sanitize({ ...DEFAULTS, ...store.get('settings') }); } export function updateSettings(partial: Partial): AppSettings { const current = getSettings(); - const next = { ...current, ...partial }; + const next = sanitize({ ...current, ...partial }); store.set('settings', next); return next; } diff --git a/electron/updates/parse.ts b/electron/updates/parse.ts new file mode 100644 index 0000000..a736135 --- /dev/null +++ b/electron/updates/parse.ts @@ -0,0 +1,85 @@ +/** + * Parsers for the "what can be upgraded" output of each package manager. + * Pure functions so the smoke tests can feed them captured output. + */ + +export interface ParsedUpgrade { + packageId: string; + name: string; + installed: string; + available: string; +} + +/** Column start offsets of a header line: every run of non-spaces begins a column. */ +function columnStarts(header: string): number[] { + const starts: number[] = []; + const re = /\S+/g; + let m: RegExpExecArray | null; + while ((m = re.exec(header))) starts.push(m.index); + return starts; +} + +function sliceColumns(line: string, starts: number[]): string[] { + return starts.map((start, i) => line.slice(start, starts[i + 1] ?? undefined).trim()); +} + +const WINGET_FOOTER = /^\d+\s|upgrades? available|обновлени|require explicit targeting|явного указания/i; + +/** + * `winget upgrade` prints a fixed-width table: Name | Id | Version | Available | Source. + * Headers are localised but always single words, so their offsets mark the columns. + * Names may contain double spaces, which is why splitting on whitespace is not enough. + */ +export function parseWingetUpgrade(out: string): ParsedUpgrade[] { + // winget redraws its spinner with carriage returns; keep only what stayed on screen. + const lines = out.split('\n').map((l) => l.split('\r').pop() ?? ''); + const dash = lines.findIndex((l) => /^-{10,}\s*$/.test(l.trim())); + if (dash < 1) return []; + const starts = columnStarts(lines[dash - 1]); + if (starts.length < 4) return []; + + const results: ParsedUpgrade[] = []; + for (const line of lines.slice(dash + 1)) { + if (!line.trim()) break; + if (WINGET_FOOTER.test(line.trim())) break; + const [name, id, installed, available] = sliceColumns(line, starts); + if (!id || !available || /\s/.test(id)) continue; + results.push({ packageId: id, name: name || id, installed, available }); + } + return results; +} + +/** + * `scoop status` is a PowerShell table whose headers have spaces in them + * ("Installed Version"), so the dashed underline is what marks the columns. + */ +export function parseScoopStatus(out: string): ParsedUpgrade[] { + const lines = out.split('\n').map((l) => l.replace(/\r$/, '')); + const dash = lines.findIndex((l) => /^-+(\s+-+)+\s*$/.test(l.trim()) && l.trim().startsWith('----')); + if (dash < 1) return []; + const starts = columnStarts(lines[dash]); + if (starts.length < 3) return []; + + const results: ParsedUpgrade[] = []; + for (const line of lines.slice(dash + 1)) { + if (!line.trim()) continue; + const [name, installed, available] = sliceColumns(line, starts); + // Rows with only a note (failed, held) carry no newer version. + if (!name || !available || available === installed) continue; + results.push({ packageId: name, name, installed, available }); + } + return results; +} + +/** `choco outdated --limit-output` prints `id|current|available|pinned` per package. */ +export function parseChocoOutdated(out: string): ParsedUpgrade[] { + const results: ParsedUpgrade[] = []; + for (const raw of out.split('\n')) { + const parts = raw.trim().split('|'); + if (parts.length < 3) continue; + const [id, installed, available, pinned] = parts.map((p) => p.trim()); + if (!id || !available || installed === available || pinned === 'true') continue; + results.push({ packageId: id, name: id, installed, available }); + } + return results; +} diff --git a/electron/updates/updates.ts b/electron/updates/updates.ts new file mode 100644 index 0000000..592b0b7 --- /dev/null +++ b/electron/updates/updates.ts @@ -0,0 +1,140 @@ +import { EventEmitter } from 'node:events'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { QueueJob, UpdateHistoryEntry, UpdateInfo, UpdatesState } from '../providers/types.js'; +import { getAllProviders } from '../providers/registry.js'; +import { catalogIdsBySource } from '../providers/catalog.js'; +import { getSettings } from '../store/settings.js'; +import { getAppDataDir } from '../paths.js'; + +const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; +const FIRST_CHECK_DELAY_MS = 30 * 1000; +const HISTORY_LIMIT = 200; + +interface Persisted { + items: UpdateInfo[]; + checkedAt?: number; + history: UpdateHistoryEntry[]; +} + +function statePath(): string { + return path.join(getAppDataDir(), 'updates.json'); +} + +function load(): Persisted { + try { + const raw = JSON.parse(fs.readFileSync(statePath(), 'utf-8')) as Partial; + return { + items: Array.isArray(raw.items) ? raw.items : [], + checkedAt: typeof raw.checkedAt === 'number' ? raw.checkedAt : undefined, + history: Array.isArray(raw.history) ? raw.history : [], + }; + } catch { + return { items: [], history: [] }; + } +} + +const sameUpdate = (a: { provider: string; packageId: string }, b: { provider: string; packageId: string }) => + a.provider === b.provider && a.packageId.toLowerCase() === b.packageId.toLowerCase(); + +class UpdatesService extends EventEmitter { + private data: Persisted = load(); + private checking = false; + private timer: NodeJS.Timeout | null = null; + + getState(): UpdatesState { + return { ...this.data, checking: this.checking }; + } + + private save(): void { + try { + fs.mkdirSync(getAppDataDir(), { recursive: true }); + fs.writeFileSync(statePath(), JSON.stringify(this.data)); + } catch (err) { + console.error('[GetBox] updates save failed:', err); + } + this.emit('changed', this.getState()); + } + + /** Asks every enabled package manager; one failing source does not hide the others. */ + async check(): Promise { + if (this.checking) return this.getState(); + this.checking = true; + this.emit('changed', this.getState()); + try { + const enabled = new Set(getSettings().enabledProviders); + const sources = getAllProviders().filter((p) => p.listUpgrades && enabled.has(p.id)); + const results = await Promise.allSettled(sources.map((p) => p.listUpgrades!())); + const catalogIds = catalogIdsBySource(); + const items: UpdateInfo[] = []; + results.forEach((r, i) => { + if (r.status === 'rejected') { + console.error(`[GetBox] ${sources[i].id} upgrade check failed:`, r.reason); + return; + } + for (const u of r.value) { + if (items.some((x) => sameUpdate(x, u))) continue; + items.push({ ...u, catalogId: catalogIds.get(`${u.provider}:${u.packageId.toLowerCase()}`) }); + } + }); + this.data.items = items; + this.data.checkedAt = Date.now(); + } finally { + this.checking = false; + this.save(); + } + if (getSettings().autoInstallUpdates && this.data.items.length > 0) { + this.emit('auto-install', this.data.items); + } + return this.getState(); + } + + /** Called by the queue when an upgrade or rollback finishes. */ + record(job: QueueJob): void { + if (job.kind !== 'upgrade' && job.kind !== 'rollback') return; + if (job.status !== 'done' && job.status !== 'failed') return; + const ok = job.status === 'done'; + this.data.history.unshift({ + id: crypto.randomUUID(), + kind: job.kind, + provider: job.provider, + packageId: job.packageId, + name: job.name, + catalogId: job.catalogId, + from: job.fromVersion, + to: job.toVersion, + at: Date.now(), + ok, + message: ok ? undefined : job.message, + errorCode: ok ? undefined : job.errorCode, + }); + this.data.history = this.data.history.slice(0, HISTORY_LIMIT); + if (ok && job.kind === 'upgrade') { + this.data.items = this.data.items.filter((u) => !sameUpdate(u, job)); + } + this.save(); + } + + clearHistory(): void { + this.data.history = []; + this.save(); + } + + /** + * Checks shortly after launch, then every 6 hours. Call again after the + * setting changes; `atLaunch` is only for the first call. + */ + schedule(atLaunch = false): void { + if (this.timer) clearTimeout(this.timer); + this.timer = null; + if (!getSettings().autoCheckUpdates) return; + const due = atLaunch ? 0 : (this.data.checkedAt ?? 0) + CHECK_INTERVAL_MS - Date.now(); + this.timer = setTimeout(async () => { + await this.check().catch((err) => console.error('[GetBox] update check failed:', err)); + this.schedule(); + }, Math.max(FIRST_CHECK_DELAY_MS, due)); + } +} + +export const updates = new UpdatesService(); diff --git a/promo/.gitignore b/promo/.gitignore index e050f39..866b26e 100644 --- a/promo/.gitignore +++ b/promo/.gitignore @@ -5,3 +5,4 @@ shots/ still-*.png contact*.png spec.png +storyboard-frames/ diff --git a/promo/README.md b/promo/README.md index 3ba51ad..397ad39 100644 --- a/promo/README.md +++ b/promo/README.md @@ -1,6 +1,10 @@ # Промо-ролики GetBox - `promo.html` — основной ролик: 40 с, 1920×1080, 30 fps. +- `promo13.html` — обычное промо 1.3: 53 с, тёмное, монтаж в долю под `music13.wav`. Окно приложения стоит на месте, заголовки — над ним. +- `director.js` — общая камера и подсветка для `promo13.html` и `corp13.html`. Зумы и выноски берутся только из `shots/13/marks.json`: `capture13.cjs` измеряет реальные элементы интерфейса на каждом скриншоте. Размер задаётся параметром `h` (240…1080), `debug=1` выводит номер сцены и таймкод. +- `edit13.html` — динамичный эдит 1.3: ~25 с, 130 BPM, склейка на каждую долю (во втором дропе — на полдоли), удары зумом, вспышки, слова-титры. Музыка — `music-edit13.py` (фонк-набросок: ковбелл, 808 с глайдом). +- `corp13.html` — корпоративное промо 1.3: 50 с, светлый стиль. Проблема → решение → 4 блока (каталог, установка, обновления, интерфейс) с выносками на интерфейсе → сводка → призыв. Музыка — `music-corp13.py` (фортепиано и пэд, 100 BPM). - `shorts.html` — вертикальная версия для Shorts / Reels: 30 с, 1080×1920, 60 fps, монтаж по долям 128 BPM. Кадры интерфейса — настоящий renderer GetBox на тестовых данных из `data/`. @@ -13,13 +17,32 @@ node promo/render.cjs video promo.html # покадровый рен python3 promo/music.py # фоновая музыка → promo/music.wav (нужен numpy) ffmpeg -i promo/promo.mp4 -i promo/music.wav -c:v copy -c:a aac -b:a 192k -shortest promo/GetBox-promo.mp4 +# 1.3 — скриншоты всех тем и видов → promo/shots/13/, затем черновик 240p +node promo/capture13.cjs +QUERY="h=240&debug=1" OUT_SUFFIX=-240p CRF=28 node promo/render.cjs video promo13.html +python3 promo/music13.py +ffmpeg -i promo/promo13-240p.mp4 -i promo/music13.wav -c:v copy -c:a aac -b:a 96k -shortest promo/GetBox-1.3-preview-240p.mp4 +# 1.3 — финал: QUERY="h=1080" node promo/render.cjs video promo13.html + +# 1.3 — эдит 360p +QUERY="h=360" OUT_SUFFIX=-360p CRF=24 node promo/render.cjs video edit13.html +python3 promo/music-edit13.py +ffmpeg -i promo/edit13-360p.mp4 -i promo/music-edit13.wav -c:v copy -af loudnorm=I=-12:TP=-1 -ar 48000 -c:a aac -b:a 128k -shortest promo/GetBox-1.3-edit-360p.mp4 + +# 1.3 — корпоративное промо 360p +QUERY="h=360" OUT_SUFFIX=-360p CRF=24 node promo/render.cjs video corp13.html +python3 promo/music-corp13.py +ffmpeg -i promo/corp13-360p.mp4 -i promo/music-corp13.wav -c:v copy -af loudnorm=I=-16:TP=-1.5 -ar 48000 -c:a aac -b:a 128k -shortest promo/GetBox-1.3-corporate-360p.mp4 + # Shorts node promo/render.cjs video shorts.html python3 promo/music-shorts.py -ffmpeg -i promo/shorts.mp4 -i promo/music-shorts.wav -c:v copy -af loudnorm=I=-13:TP=-1 -c:a aac -b:a 192k -shortest promo/GetBox-shorts.mp4 +ffmpeg -i promo/shorts.mp4 -i promo/music-shorts.wav -c:v copy -af loudnorm=I=-13:TP=-1 -ar 48000 -c:a aac -b:a 192k -shortest promo/GetBox-shorts.mp4 ``` Нужны Playwright с Chromium (`PLAYWRIGHT_MODULE`, `CHROMIUM_PATH` — если стоят не в стандартном месте) и ffmpeg с libx264. - `promo.html` — сцены; `render(t)` задаёт состояние кадра по времени, поэтому рендер детерминированный. Тексты и тайминги — там же. -- `music.py`, `music-shorts.py` — синтезированная музыка (96 и 128 BPM, Am–F–C–G), без сторонних сэмплов. +- `music.py`, `music-shorts.py`, `music13.py` — синтезированная музыка (96, 128 и 120 BPM, Am–F–C–G), без сторонних сэмплов. + +`loudnorm` внутри работает на 192 кГц и без `-ar 48000` отдаёт AAC 96 кГц — многие плееры (браузеры, мессенджеры) такой звук молча не воспроизводят. diff --git a/promo/capture13.cjs b/promo/capture13.cjs new file mode 100644 index 0000000..59a7835 --- /dev/null +++ b/promo/capture13.cjs @@ -0,0 +1,180 @@ +// Capture GetBox 1.3 UI states for promo13.html: the built renderer (npm run build) +// with window.getbox stubbed from data/*.json, in every theme and list view. Writes promo/shots/13/*.png. +// Playwright is not a project dependency: npx playwright, or point PLAYWRIGHT_MODULE at an install. +const { chromium } = require(process.env.PLAYWRIGHT_MODULE ?? 'playwright'); +const launchOpts = process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}; +const fs = require('fs'); const path = require('path'); const http = require('http'); +const ROOT = path.join(__dirname, '..'); const OUT = path.join(__dirname, 'shots', '13'); +fs.mkdirSync(OUT, { recursive: true }); +const read = (f) => JSON.parse(fs.readFileSync(path.join(ROOT, 'data', f), 'utf-8')); +const catalog = read('catalog.json'), picks = read('picks.json'), installers = read('installers.json'); +const pickMap = new Map(picks.map((p) => [p.packageRef, p])); +const iconFor = (c) => { for (const s of [c.logoSlug, c.id]) for (const e of ['.svg', '.png', '.jpg']) if (s && fs.existsSync(path.join(ROOT, 'data/icons', s + e))) return `/data/icons/${s}${e}`; }; +// Index packages for «Все пакеты»: real ids from the winget map plus a few Scoop / Chocolatey ones. +const wingetIds = Object.values(read('winget-map.json')).slice(0, 48); +const INDEX = [ + ...wingetIds.map((id) => ({ id, provider: 'winget', name: id.split('.').slice(1).join(' ') || id, version: undefined })), + ...['neovim', 'ripgrep', 'fzf', 'bat', 'fd', 'jq', 'curl', 'lazygit'].map((id) => ({ id, provider: 'scoop', name: id })), + ...['putty', 'wget', 'sysinternals', 'notepadplusplus', 'winscp'].map((id) => ({ id, provider: 'chocolatey', name: id })), +].map((a) => ({ ...a, isInstalled: false, isFavorite: false })); +const FAVS = new Set(['obsidian', 'firefox', 'vlc', 'blender', 'telegram', 'spotify', 'bitwarden']); +function buildData(owned, lang) { + const apps = catalog.map((c) => { const p = pickMap.get(`catalog:${c.packageId}`); const en = lang === 'en'; + return { id: c.packageId, provider: 'catalog', name: c.name, description: (en ? p?.descriptionOverlayEn : p?.descriptionOverlay) ?? c.description, + category: c.category, features: c.features, recommended: c.recommended, curatedId: c.id, isInstalled: owned.has(c.id), isFavorite: FAVS.has(c.id), + iconUrl: iconFor(c), pickTag: en ? p?.tagEn : p?.tag, pickWhy: en ? p?.whyEn : p?.why, version: installers[c.id]?.version }; }); + apps.push(...INDEX); + return { apps, categories: read('categories.json'), profiles: read('profiles.json'), related: read('related.json') }; +} +const QUEUE = [ + { id: 'j1', packageId: 'firefox', provider: 'catalog', name: 'Mozilla Firefox', status: 'running', progress: { phase: 'download', percent: 64, bytesDone: 61e6, bytesTotal: 95e6, bytesPerSecond: 11.8e6 } }, + { id: 'j2', packageId: 'discord', provider: 'catalog', name: 'Discord', status: 'pending' }, + { id: 'j3', packageId: 'obs-studio', provider: 'catalog', name: 'OBS Studio', status: 'pending' }, + { id: 'j4', packageId: 'vscode', provider: 'catalog', name: 'Visual Studio Code', status: 'done', message: 'Установлено' }, + { id: 'j5', packageId: '7zip', provider: 'catalog', name: '7-Zip', status: 'done', message: 'Установлено' }, +]; +const server = http.createServer((req, res) => { + const file = path.join(ROOT, decodeURIComponent(req.url.split('?')[0])); + if (!file.startsWith(ROOT) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) { res.writeHead(404); return res.end(); } + const type = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg' }[path.extname(file)] ?? 'application/octet-stream'; + res.writeHead(200, { 'Content-Type': type }); fs.createReadStream(file).pipe(res); +}); +// Rects of interface elements (CSS px in the 1440×900 shot), measured on every capture. +// The promos frame and highlight these instead of guessing coordinates. Lists are unions of the +// first few matches; a key is left out when the element is not on screen. +const MARK_SELECTORS = { + search: ['.search-box'], tabs: ['.tab-bar'], viewSwitch: ['.view-switch'], + hero: ['.hero-banner'], heroActions: ['.hero-banner-actions'], heroCta: ['.hero-banner-actions > *', 3], forYou: ['.shelf >> nth=0'], + results: ['.list-row', 4], sources: ['.list-row-meta', 4], firstRows: ['.list-row', 3], + grid: ['.paged-grid'], gridTop: ['.capsule', 9], table: ['.app-table'], tableTop: ['.app-table tr', 8], + profiles: ['.profiles-section'], queue: ['.queue-panel.open .queue-list, .queue-panel.open'], queueFirst: ['.queue-item >> nth=0'], + pending: ['.upd-group >> nth=0'], history: ['.upd-group >> nth=1'], autoUpdate: ['.updates-aside'], + rollback: [(p) => p.getByRole('button', { name: 'Откатить до 1.140.2' })], + themes: ['.theme-gallery'], accent: ['.accent-row > *', 12], appearanceGrid: ['.appearance-grid'], + modalRelated: ['.modal-related', 2], modal: ['.modal'], +}; +const MARKS = {}; +async function measure(page) { + const out = {}; + for (const [key, [sel, count = 1]] of Object.entries(MARK_SELECTORS)) { + const loc = typeof sel === 'function' ? sel(page) : page.locator(sel); + const n = Math.min(await loc.count(), count); + let box = null; + for (let i = 0; i < n; i++) { + const b = await loc.nth(i).boundingBox(); + if (!b || b.width < 2 || b.y > 900 || b.x > 1440) continue; + const r = { x: b.x, y: b.y, r: Math.min(1440, b.x + b.width), b: Math.min(900, b.y + b.height) }; + box = box ? { x: Math.min(box.x, r.x), y: Math.min(box.y, r.y), r: Math.max(box.r, r.r), b: Math.max(box.b, r.b) } : r; + } + if (box) out[key] = { x: Math.round(box.x), y: Math.round(box.y), w: Math.round(box.r - box.x), h: Math.round(box.b - box.y) }; + } + return out; +} +async function shot(browser, base, name, { owned = [], lang = 'ru', queue = [], act, look = {}, results = [], zoom = 1 }) { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }); + await page.addInitScript(({ d, lang, queue, look, results }) => { + const ok = async () => ({ ok: true }); + const values = { + getCatalog: async () => d, getSettings: async () => (window.__s), updateSettings: async (p) => (window.__s = { ...window.__s, ...p }), windowNativeMica: async () => false, + getUpdates: async () => { + const now = Date.now(), h = 3600e3, today = new Date(); today.setHours(14, 2, 0, 0); + const t = today.getTime(); + return { checking: false, checkedAt: now - 2 * 60e3, items: [ + { provider: 'winget', packageId: 'Mozilla.Firefox', name: 'Mozilla Firefox (x64 ru)', installed: '156.0', available: '157.0.1', catalogId: 'firefox' }, + { provider: 'winget', packageId: 'Telegram.TelegramDesktop', name: 'Telegram Desktop', installed: '7.2.4', available: '7.3.1', catalogId: 'telegram' }, + { provider: 'scoop', packageId: 'obs-studio', name: 'obs-studio', installed: '32.1.0', available: '32.2.2' }, + ], history: [ + { id: 'h1', kind: 'upgrade', provider: 'winget', packageId: 'Google.Chrome', name: 'Google Chrome', catalogId: 'chrome', from: '154.0.7980', to: '155.0.8059', at: t, ok: true }, + { id: 'h2', kind: 'upgrade', provider: 'winget', packageId: 'Microsoft.VisualStudioCode', name: 'VS Code', catalogId: 'vscode', from: '1.140.2', to: '1.141.0', at: t - 5 * h, ok: true }, + { id: 'h3', kind: 'upgrade', provider: 'winget', packageId: 'Telegram.TelegramDesktop', name: 'Telegram', catalogId: 'telegram', from: '7.2.4', to: '7.3.1', at: t - 24 * h, ok: false, errorCode: 'installer' }, + { id: 'h4', kind: 'upgrade', provider: 'scoop', packageId: 'vlc', name: 'vlc', from: '3.0.23', to: '3.0.24', at: t - 25 * h, ok: true }, + ] }; + }, + getProvidersStatus: async () => [['catalog', 'Каталог GetBox'], ['scoop', 'Scoop'], ['winget', 'winget'], ['chocolatey', 'Chocolatey']].map(([id, displayName]) => ({ id, displayName, ok: true })), + getQueue: async () => queue, + getBootstrapStatus: async () => ({ complete: true, running: false, state: {}, tools: { scoop: { status: 'ok', ok: true }, winget: { status: 'ok', ok: true }, chocolatey: { status: 'ok', ok: true } } }), + getMetaProgress: async () => null, enrichMeta: async () => [], search: async () => ({ results, fromCache: false, stale: false }), + }; + window.__s = { enabledProviders: ['catalog', 'scoop', 'winget', 'chocolatey'], quietInstall: true, language: lang, theme: 'classic', uiScale: 1, listView: 'cards', autoCheckUpdates: true, autoInstallUpdates: false, ...look }; + window.getbox = new Proxy({}, { get: (_t, k) => values[k] ?? (String(k).startsWith('on') ? () => () => {} : ok) }); + }, { d: buildData(new Set(owned), lang), lang, queue, look, results }); + await page.goto(`${base}/dist/index.html`); + await page.waitForTimeout(1200); + // The app scales through Electron's zoom factor; in a plain browser CSS zoom stands in for it. + if (zoom !== 1) await page.evaluate((z) => { document.documentElement.style.zoom = String(z); }, zoom); + if (act) await act(page); + await page.waitForTimeout(500); + MARKS[name] = await measure(page); + await page.screenshot({ path: path.join(OUT, `${name}.png`) }); + await page.close(); + console.log('shot', name); +} +server.listen(0, async () => { + const base = `http://127.0.0.1:${server.address().port}`; + const browser = await chromium.launch(launchOpts); + // Firefox (the featured hero app) stays uninstalled so the hero shows «Установить». + const me = ['vscode', 'steam', 'git']; + const tab = (name) => (p) => p.getByRole('button', { name }).first().click(); + const catalogTab = tab(/Каталог/); + const settings = (p) => p.locator('.sidebar').getByText('Расширенные').first().click(); + const updatesTab = tab(/Обновления/); + + for (const theme of ['classic', 'light', 'oled', 'contrast', 'mica']) { + await shot(browser, base, `home-${theme}`, { owned: me, look: { theme } }); + } + for (const listView of ['cards', 'list', 'table', 'tiles']) { + await shot(browser, base, `cat-${listView}`, { owned: me, look: { listView }, act: catalogTab }); + } + await shot(browser, base, 'cat-tiles-hover', { owned: me, look: { listView: 'tiles' }, act: async (p) => { + await catalogTab(p); await p.waitForTimeout(300); await p.locator('.capsule--tile').nth(1).hover(); + } }); + for (const [name, accent] of [['blue', '#66c0f4'], ['violet', '#8b6cff'], ['teal', '#2dd4bf'], ['pink', '#ef5da8']]) { + await shot(browser, base, `accent-${name}`, { owned: me, look: { theme: 'oled', accent }, act: settings }); + } + await shot(browser, base, 'install-queue', { owned: me, queue: QUEUE, act: tab(/Очередь/) }); + await shot(browser, base, 'updates', { owned: me, act: updatesTab }); + await shot(browser, base, 'updates-queue', { owned: me, queue: [ + { id: 'q1', provider: 'winget', packageId: 'Mozilla.Firefox', name: 'Mozilla Firefox', status: 'running', kind: 'upgrade', fromVersion: '156.0', toVersion: '157.0.1', progress: { phase: 'download', percent: 42 } }, + { id: 'q2', provider: 'winget', packageId: 'Telegram.TelegramDesktop', name: 'Telegram Desktop', status: 'pending', kind: 'upgrade', fromVersion: '7.2.4', toVersion: '7.3.1' }, + ], act: async (p) => { await updatesTab(p); await tab(/Очередь/)(p); } }); + // «Ещё фишки» montage. + await shot(browser, base, 'feat-search', { owned: me, results: [ + { id: 'vlc', provider: 'catalog', name: 'VLC media player', category: 'media' }, + { id: 'VideoLAN.VLC', provider: 'winget', name: 'VLC media player' }, + { id: 'vlc', provider: 'scoop', name: 'vlc' }, + { id: 'vlc', provider: 'chocolatey', name: 'vlc' }, + { id: 'VideoLAN.VLC.Nightly', provider: 'winget', name: 'VLC media player Nightly' }, + ], look: { listView: 'list' }, act: async (p) => { const box = p.getByPlaceholder(/Поиск/); await box.fill('vlc'); await box.press('Enter'); } }); + await shot(browser, base, 'feat-games', { owned: me, act: (p) => p.locator('.sidebar').getByText('Игры').first().click() }); + await shot(browser, base, 'feat-profiles', { owned: me, act: (p) => p.locator('.content-area').evaluate((el) => { + const target = el.querySelector('.profiles-section'); + el.scrollTo(0, target.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop - 24); + }) }); + await shot(browser, base, 'feat-favorites', { owned: me, act: tab(/Избранное/) }); + await shot(browser, base, 'feat-packages', { owned: me, look: { listView: 'table' }, act: tab(/Все пакеты/) }); + await shot(browser, base, 'feat-scale', { owned: me, zoom: 1.25 }); + await shot(browser, base, 'feat-english', { owned: me, lang: 'en' }); + await shot(browser, base, 'feat-fallback', { owned: me, queue: [ + { id: 'f1', packageId: 'notepadplusplus', provider: 'catalog', name: 'Notepad++', status: 'done', message: 'Установлено: Notepad++ (через winget)' }, + { id: 'f2', packageId: 'vlc', provider: 'catalog', name: 'VLC media player', status: 'done', message: 'Установлено: VLC media player' }, + { id: 'f3', packageId: 'obs-studio', provider: 'catalog', name: 'OBS Studio', status: 'running', progress: { phase: 'install', note: 'winget:OBSProject.OBSStudio' } }, + ], act: tab(/Очередь/) }); + // Corporate promo (corp13.html): the same states in the light theme. + const light = { theme: 'light' }; + await shot(browser, base, 'L-home', { owned: me, look: light }); + await shot(browser, base, 'L-search', { owned: me, look: { ...light, listView: 'list' }, results: [ + { id: 'vlc', provider: 'catalog', name: 'VLC media player', category: 'media' }, + { id: 'VideoLAN.VLC', provider: 'winget', name: 'VLC media player' }, + { id: 'vlc', provider: 'scoop', name: 'vlc' }, + { id: 'vlc', provider: 'chocolatey', name: 'vlc' }, + ], act: async (p) => { const box = p.getByPlaceholder(/Поиск/); await box.fill('vlc'); await box.press('Enter'); } }); + await shot(browser, base, 'L-catalog', { owned: me, look: { ...light, listView: 'list' }, act: catalogTab }); + await shot(browser, base, 'L-install', { owned: me, look: light, queue: QUEUE, act: tab(/Очередь/) }); + await shot(browser, base, 'L-updates', { owned: me, look: light, act: updatesTab }); + await shot(browser, base, 'L-appearance', { owned: me, look: light, act: settings }); + await shot(browser, base, 'modal-light', { owned: me, look: { theme: 'light' }, act: async (p) => { + await catalogTab(p); await p.waitForTimeout(300); await p.locator('.capsule').first().click(); + } }); + fs.writeFileSync(path.join(OUT, 'marks.json'), JSON.stringify(MARKS)); + await browser.close(); server.close(); +}); diff --git a/promo/corp13.html b/promo/corp13.html new file mode 100644 index 0000000..7bb7c4e --- /dev/null +++ b/promo/corp13.html @@ -0,0 +1,306 @@ + + + + +GetBox 1.3 corporate promo + + + +
+
+
+ + +
+ +
GETBOX
+
Программы для Windows — под контролем.
+
+ + +
+
+
ЗАДАЧА
+
Установка программ отнимает время
+
+
+
Есть способ проще.
+
+ + +
+
+
РЕШЕНИЕ
+
GetBox — один каталог для всех программ
+
Найти, установить и обновить — в одном окне, в один клик.
+
+
+
+ + +
+ + +
+
+
GETBOX 1.3
+
Всё, что нужно, — в одном приложении
+
+
+
+ + +
+ +
GETBOX1.3
+
Бесплатный магазин программ для Windows 10 и 11
+
Скачать
+
github.com/IncludeSpecial/GetBox/releases
+
+ +
+
+
+ + + + + diff --git a/promo/director.js b/promo/director.js new file mode 100644 index 0000000..5f7ba52 --- /dev/null +++ b/promo/director.js @@ -0,0 +1,88 @@ +// Shared camera and highlight logic for promo13.html and corp13.html. +// +// Every zoom targets a real element: capture13.cjs measures element rects on +// each screenshot (shots/13/marks.json, CSS px of the 1440×900 frame). A camera +// is a list of stops; between stops it eases, at a stop it holds still. A +// highlight dims the rest of the window and labels the element, and only +// appears once the camera has arrived. +(function () { + const SHOT_W = 1440, SHOT_H = 900; + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'shots/13/marks.json', false); + xhr.send(); + const MARKS = JSON.parse(xhr.responseText); + + const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v)); + const seg = (t, a, b) => clamp((t - a) / (b - a)); + const lerp = (a, b, x) => a + (b - a) * x; + const inOut = (x) => (x < .5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2); + const outCubic = (x) => 1 - Math.pow(1 - x, 3); + const FULL = { x: 0, y: 0, w: SHOT_W, h: SHOT_H }; + + function mark(shot, key) { + const m = MARKS[shot] && MARKS[shot][key]; + if (!m) throw new Error(`no mark ${shot}.${key} — re-run capture13.cjs`); + return m; + } + + /** + * The view rect that frames an element: padded, grown to the viewer's aspect, + * never closer than maxZoom and never past the screenshot's edges. + */ + function fit(shot, key, { pad = 48, maxZoom = 2, aspect = SHOT_W / SHOT_H } = {}) { + const m = mark(shot, key); + let w = Math.max(m.w + pad * 2, (m.h + pad * 2) * aspect, SHOT_W / maxZoom); + w = Math.min(w, SHOT_W); + const h = w / aspect; + const x = clamp(m.x + m.w / 2 - w / 2, 0, SHOT_W - w); + const y = clamp(m.y + m.h / 2 - h / 2, 0, SHOT_H - h); + return { x, y, w, h }; + } + + /** Camera rect at local time lt. stops: [{ at, rect }]; the move into each stop lasts `move` seconds. */ + function camera(stops, lt, move = 0.8) { + let rect = stops[0].rect; + for (let i = 1; i < stops.length; i++) { + const s = stops[i]; + const k = inOut(seg(lt, s.at - move, s.at)); + if (k <= 0) break; + rect = mixRect(rect, s.rect, k); + } + return rect; + } + const mixRect = (a, b, k) => ({ x: lerp(a.x, b.x, k), y: lerp(a.y, b.y, k), w: lerp(a.w, b.w, k), h: lerp(a.h, b.h, k) }); + + /** Point the cam element (a 1440×900 layer) at a view rect inside a vw×vh viewer. */ + function apply(cam, vw, vh, r) { + const s = vw / r.w; + const tx = -r.x * s, ty = -r.y * s; + cam.style.transform = `translate(${tx}px, ${ty}px) scale(${s})`; + return { s, tx, ty }; + } + + /** + * Highlight: `box` dims everything around the element and outlines it; `tag` sits + * just above the element (below it when there is no room), kept inside the viewer. + */ + function highlight(box, tag, view, cam, m, k, vw, vh) { + if (k <= 0) { + box.style.opacity = 0; tag.style.opacity = 0; + return; + } + const pad = 6; + const x = m.x * cam.s + cam.tx - pad, y = m.y * cam.s + cam.ty - pad; + const w = m.w * cam.s + pad * 2, h = m.h * cam.s + pad * 2; + Object.assign(box.style, { left: x + 'px', top: y + 'px', width: w + 'px', height: h + 'px', opacity: k }); + tag.style.opacity = k; + const tw = tag.offsetWidth, th = tag.offsetHeight; + const above = y - th - 14 >= 10; + const ty = above ? y - th - 14 : Math.min(vh - th - 10, y + h + 14); + const tx = clamp(x, 10, vw - tw - 10); + tag.style.left = tx + 'px'; + tag.style.top = ty + 'px'; + tag.style.transform = `translateY(${(1 - k) * (above ? 8 : -8)}px)`; + void view; + } + + window.Director = { MARKS, FULL, mark, fit, camera, apply, highlight, clamp, seg, lerp, inOut, outCubic }; +})(); diff --git a/promo/edit13.html b/promo/edit13.html new file mode 100644 index 0000000..f39abdd --- /dev/null +++ b/promo/edit13.html @@ -0,0 +1,200 @@ + + + + +GetBox 1.3 edit + + + +
+
+
+ +
+
+
+
+
+
+
+
+
+
+ + + diff --git a/promo/music-corp13.py b/promo/music-corp13.py new file mode 100644 index 0000000..8a2b18b --- /dev/null +++ b/promo/music-corp13.py @@ -0,0 +1,143 @@ +"""Calm corporate track for corp13.html: 100 bpm, C - G - Am - F, 50 s. + +Soft piano arpeggios and a warm pad from the start; a light kick enters with +the solution (0:10.2), claps and a shaker with the feature section (0:15.6), +a gentle lift into the summary (0:39.6) and a resolving C chord for the call +to action. +""" +import os +import wave + +import numpy as np + +SR, BPM = 44100, 100 +B = 60 / BPM +BAR = 4 * B +DUR = 50.0 +N = int(SR * DUR) +t = np.arange(N) / SR +L = np.zeros(N) +R = np.zeros(N) +rng = np.random.default_rng(3) +SOLUTION, FEATURES, SUMMARY, CTA = 10.2, 15.6, 39.6, 44.4 + + +def hz(n): + return 440 * 2 ** ((n - 69) / 12) + + +def lowpass(x, cutoff): + cutoff = np.broadcast_to(np.asarray(cutoff, dtype=float), x.shape) + a = np.exp(-2 * np.pi * cutoff / SR) + y = np.empty_like(x) + acc = 0.0 + for i in range(len(x)): + acc = (1 - a[i]) * x[i] + a[i] * acc + y[i] = acc + return y + + +def place(buf, start, sig, gain=1.0): + s = int(round(start * SR)) + e = min(s + len(sig), len(buf)) + if 0 <= s < len(buf): + buf[s:e] += sig[: e - s] * gain + + +def both(start, sig, gain=1.0, pan=0.0): + place(L, start, sig, gain * (1 - pan)) + place(R, start, sig, gain * (1 + pan)) + + +def piano(note, length=1.6): + tt = np.arange(int(length * SR)) / SR + f = hz(note) + tone = sum(np.sin(2 * np.pi * f * k * tt) * a * np.exp(-tt * (1.6 + k * 0.9)) for k, a in ((1, 1), (2, .45), (3, .22), (4, .1))) + return tone * (1 - np.exp(-tt * 300)) + + +CHORDS = [[48, 55, 60, 64], [43, 55, 59, 62], [45, 57, 60, 64], [41, 57, 60, 65]] # C G Am F +END_BAR = int(CTA / BAR) + +# Pad: soft sine stack per bar, swelling in. +pad = np.zeros(N) +for bar in range(int(DUR / BAR) + 1): + chord = CHORDS[bar % 4] if bar < END_BAR else [48, 55, 60, 64] + s, e = int(bar * BAR * SR), min(int((bar + 1) * BAR * SR + 0.4 * SR), N) + if s >= N: + break + tt = t[s:e] - t[s] + env = np.minimum(1, tt / 0.6) * np.minimum(1, (tt[-1] - tt) / 0.4 + 0.001) + for n in chord[1:]: + for det in (-0.06, 0.06): + pad[s:e] += np.sin(2 * np.pi * hz(n + det) * tt) * env * 0.025 +pad = lowpass(pad, 1800) * np.clip(t / 3, 0, 1) +L += pad +R += pad + +# Piano: 8th-note arpeggio, a melody note on bar starts from the solution on. +for i in range(int(CTA / (B / 2))): + at = i * B / 2 + chord = CHORDS[int(at // BAR) % 4] + seq = [chord[0] + 12, chord[1] + 12, chord[2] + 12, chord[3] + 12, chord[2] + 12, chord[1] + 12, chord[2] + 12, chord[3] + 12] + both(at, piano(seq[i % 8], 1.2), 0.07, pan=0.25 if i % 2 else -0.25) + if at >= SOLUTION and i % 8 == 0: + both(at, piano(chord[3] + 24, 2.4), 0.06) +for n in (48, 55, 60, 64, 67, 72): # final chord + both(CTA, piano(n, DUR - CTA), 0.09) + + +def kick(): + tt = np.arange(int(0.3 * SR)) / SR + f = 50 + 70 * np.exp(-tt * 30) + return np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-tt * 9) + + +def clap(): + tt = np.arange(int(0.2 * SR)) / SR + env = sum(np.exp(-np.clip(tt - d, 0, None) * 55) * (tt >= d) for d in (0, 0.01, 0.02)) + return lowpass(rng.standard_normal(len(tt)), 2600) * env + + +def shaker(): + tt = np.arange(int(0.08 * SR)) / SR + return np.diff(rng.standard_normal(len(tt) + 1)) * np.exp(-tt * 50) * (1 - np.exp(-tt * 200)) + + +K, C = kick(), clap() +for i in range(int(CTA / B)): + at = i * B + if at >= SOLUTION: + if i % 2 == 0: + both(at, K, 0.32) + if at >= FEATURES: + if i % 2 == 1: + both(at, C, 0.08) + for k in range(4): + both(at + k * B / 4, shaker(), 0.012 if k % 2 else 0.02, pan=0.35) + +# Soft chimes on section changes. +for at in (4.8, SOLUTION, FEATURES, 21.6, 27.6, 33.6, SUMMARY): + tt = np.arange(int(1.4 * SR)) / SR + chime = (np.sin(2 * np.pi * hz(84) * tt) + 0.4 * np.sin(2 * np.pi * hz(91) * tt)) * np.exp(-tt * 3) + both(at, chime, 0.035) +# Swell into the summary. +tt = np.arange(int(1.6 * SR)) / SR +swell = lowpass(rng.standard_normal(len(tt)), 300 + 3000 * (tt / tt[-1]) ** 2) * (tt / tt[-1]) ** 2 +both(SUMMARY - 1.6, swell, 0.08) + +fade = np.ones(N) +fade[-int(2.5 * SR):] = np.linspace(1, 0, int(2.5 * SR)) +fade[:int(0.3 * SR)] = np.linspace(0, 1, int(0.3 * SR)) +L = np.tanh(L * 1.1) * fade +R = np.tanh(R * 1.1) * fade +peak = max(np.abs(L).max(), np.abs(R).max()) +L, R = L / peak * 0.85, R / peak * 0.85 +pcm = (np.stack([L, R], 1) * 32767).astype('= d) for d in (0, 0.009, 0.019, 0.03)) + return np.tanh(lowpass(rng.standard_normal(len(tt)), 3200) * env * 2) + + +def hat(length=0.04, decay=120): + tt = np.arange(int(length * SR)) / SR + return np.diff(rng.standard_normal(len(tt) + 1)) * np.exp(-tt * decay) + + +def crash(length=1.8): + tt = np.arange(int(length * SR)) / SR + return np.diff(rng.standard_normal(len(tt) + 1)) * np.exp(-tt * 2.2) + + +def bass808(n0, n1, length, glide=0.12): + """Sine 808 with a click, a pitch glide from n0 to n1 at the end, and saturation.""" + tt = np.arange(int(length * SR)) / SR + f = np.full(len(tt), hz(n0)) + g0 = max(0.0, length - glide) + k = np.clip((tt - g0) / glide, 0, 1) + f = hz(n0) * (hz(n1) / hz(n0)) ** k + f = f * (1 + 0.8 * np.exp(-tt * 60)) + s = np.sin(2 * np.pi * np.cumsum(f) / SR) + env = np.minimum(1, tt * 200) * np.exp(-tt * 0.9) + return np.tanh(s * env * 3.0) * 0.9 + + +K, CL = kick(), clap() + +# Cowbell riff, one bar long, A minor: (16th step, note). +RIFF = [(0, 81), (3, 81), (6, 84), (8, 81), (10, 79), (13, 76), (15, 79)] +RIFF_B = [(0, 81), (3, 81), (6, 84), (8, 86), (10, 84), (12, 83), (14, 79)] +for bar in range(int(OUTRO / BAR)): + riff = RIFF_B if bar % 2 else RIFF + for step, note in riff: + at = bar * BAR + step * B / 4 + if silent(at): + continue + sec = section(at) + g = {'intro': 0.10, 'drop': 0.16, 'break': 0.07, 'drop2': 0.17}[sec] + both(at, cowbell(note), g, pan=0.15 if step % 2 else -0.15) + +# Intro: the cowbells are muffled, opening up into the drop. +intro_cut = np.where(t < DROP1, 700 + 2800 * (t / DROP1) ** 2, 20000) +break_cut = np.where((t >= BREAK) & (t < DROP2), 900, 20000) +cut = np.minimum(intro_cut, break_cut) +L = lowpass(L, cut) +R = lowpass(R, cut) + +# 808: roots per half bar (A, A, F, G), long notes gliding into the next root. +ROOTS = [33, 33, 29, 31] +for half in range(int(OUTRO / (BAR / 2))): + at = half * BAR / 2 + sec = section(at) + if sec in ('intro', 'outro') or silent(at): + continue + n0 = ROOTS[half % 4] + n1 = ROOTS[(half + 1) % 4] + both(at, bass808(n0, n1, BAR / 2 - 0.01), 0.55 if sec != 'break' else 0.45) + +# Drums. +for i in range(int(OUTRO / (B / 4))): + at = i * B / 4 + step = i % 16 + sec = section(at) + if silent(at) or sec in ('intro', 'outro'): + continue + if sec == 'break': + if step == 0: + both(at, K, 0.6) + if step == 8: + both(at, CL, 0.35) + if step % 4 == 2: + both(at, hat(), 0.05, pan=0.3) + continue + if step in (0, 6, 10) or (sec == 'drop2' and step == 14): + both(at, K, 0.7) + if step in (4, 12): + both(at, CL, 0.42) + if sec == 'drop' and step % 2 == 0: + both(at, hat(), 0.05, pan=-0.3 if step % 4 else 0.3) + if sec == 'drop2': + both(at, hat(), 0.045, pan=-0.3 if step % 2 else 0.3) + bar_end = (i // 16 + 1) * 16 - 4 + if i >= bar_end: # triplet roll into the next bar + for k in range(3): + both(at + k * B / 12, hat(0.03, 160), 0.04) + +# Risers and impacts. +tt = np.arange(int((DROP1 - B - 0.4) * SR)) / SR +riser = lowpass(rng.standard_normal(len(tt)), 200 + 7000 * (tt / tt[-1]) ** 2) * (tt / tt[-1]) ** 2 +both(0.4, riser, 0.3) +tt = np.arange(int((DROP2 - B / 2 - BREAK - BAR) * SR)) / SR +riser2 = lowpass(rng.standard_normal(len(tt)), 300 + 8000 * (tt / tt[-1]) ** 2) * (tt / tt[-1]) ** 2 +both(BREAK + BAR, riser2, 0.3) +for at, g in ((DROP1, 0.16), (BREAK, 0.1), (DROP2, 0.18), (OUTRO, 0.18)): + both(at, crash(), g) + both(at, K, 0.8) +# Outro: final 808 ringing on A. +both(OUTRO, bass808(33, 33, DUR - OUTRO, 0.01), 0.6) +both(OUTRO, cowbell(81, 0.9), 0.18) + +# Hard silence in the pre-drop gaps (tails included), with 5 ms fades. +mask = np.ones(N) +for a0, a1 in ((DROP1 - B, DROP1), (DROP2 - B / 2, DROP2), (OUTRO - B / 2, OUTRO)): + s0, s1, f = int(a0 * SR), int(a1 * SR), int(0.005 * SR) + mask[s0:s1] = 0 + mask[s0 - f:s0] = np.linspace(1, 0, f) +L *= mask +R *= mask + +# Master: saturation, sidechain-ish duck under the kick is already in the 808 envelope; fade and normalise. +fade = np.ones(N) +fade[-int(1.0 * SR):] = np.linspace(1, 0, int(1.0 * SR)) +L = np.tanh(L * 1.4) * fade +R = np.tanh(R * 1.4) * fade +peak = max(np.abs(L).max(), np.abs(R).max()) +L, R = L / peak * 0.9, R / peak * 0.9 +pcm = (np.stack([L, R], 1) * 32767).astype('= N: + break + tt = t[s:e] - t[s] + for note in chord: + for det in (-0.14, 0.0, 0.14): + f = hz(note + det) + pad[s:e] += sum(np.sin(2 * np.pi * f * k * tt + det * k) / k for k in range(1, 7)) * 0.03 +cut = np.select( + [t < DROP1, (t >= BREAK) & (t < DROP2)], + [250 + 2600 * (t / DROP1) ** 2, 500 + 2200 * ((t - BREAK) / (DROP2 - BREAK)) ** 3], + 3200, +) +pad = lowpass(pad, cut) * pump * np.where(t >= END, 1.7, 1.0) +L += pad +R += pad + +# Intro pulse: muted 8th-note synth on the root, also back for the end card. +for i in range(int(DUR / (B / 2))): + at = i * B / 2 + if not (at < DROP1 or at >= END): + continue + root = CHORDS[int(at // BAR) % 4][0] - 12 if at < DROP1 else 45 + tt = np.arange(int(0.18 * SR)) / SR + tone = np.sin(2 * np.pi * hz(root) * tt) * np.exp(-tt * 18) + gain = 0.09 * (0.4 + 0.6 * min(1, at / 6)) if at < DROP1 else 0.07 * max(0, 1 - (at - END) / 3.5) + both(at, tone, gain) + +# Bass: root on 8ths in the drops. +bass = np.zeros(N) +for i in range(int(DUR / (B / 2))): + at = i * B / 2 + if not full_beat(at): + continue + root = CHORDS[int(at // BAR) % 4][0] - 24 + tt = np.arange(int(B / 2 * SR)) / SR + f = hz(root) + saw = 2 * (tt * f - np.floor(tt * f + 0.5)) + place(bass, at, saw * np.exp(-tt * 3) * (1 if i % 2 else 0.8)) +bass = lowpass(bass, 420) * pump +L += bass * 0.55 +R += bass * 0.55 + +# Pluck arpeggio: 16ths in drop 1, a brighter lead an octave up in drop 2. +for i in range(int(DUR / (B / 4))): + at = i * B / 4 + if not full_beat(at): + continue + chord = CHORDS[int(at // BAR) % 4] + seq = [chord[0] + 12, chord[2] + 12, chord[1] + 24, chord[2] + 12, chord[0] + 24, chord[1] + 12] + n = seq[i % len(seq)] + (12 if at >= DROP2 else 0) + tt = np.arange(int(0.25 * SR)) / SR + tone = (np.sin(2 * np.pi * hz(n) * tt) + 0.4 * np.sin(4 * np.pi * hz(n) * tt)) * np.exp(-tt * 14) + both(at, tone, 0.05 if at < DROP2 else 0.065, pan=0.4 if i % 2 else -0.4) + + +def kick(): + tt = np.arange(int(0.32 * SR)) / SR + f = 48 + 110 * np.exp(-tt * 38) + return np.tanh(np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-tt * 8) * 1.6) + + +def clap(): + tt = np.arange(int(0.22 * SR)) / SR + env = sum(np.exp(-np.clip(tt - d, 0, None) * 60) * (tt >= d) for d in (0, 0.011, 0.023)) + return lowpass(rng.standard_normal(len(tt)), 2500) * env + + +def hat(length=0.05, decay=90): + tt = np.arange(int(length * SR)) / SR + return np.diff(rng.standard_normal(len(tt) + 1)) * np.exp(-tt * decay) + + +def crash(): + tt = np.arange(int(1.6 * SR)) / SR + return np.diff(rng.standard_normal(len(tt) + 1)) * np.exp(-tt * 2.6) + + +def blip(f0, f1, length=0.12, decay=30): + tt = np.arange(int(length * SR)) / SR + f = f1 + (f0 - f1) * np.exp(-tt * 40) + return np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-tt * decay) + + +def ding(note): + tt = np.arange(int(0.6 * SR)) / SR + f = hz(note) + return (np.sin(2 * np.pi * f * tt) + 0.3 * np.sin(2 * np.pi * 2.76 * f * tt)) * np.exp(-tt * 6) + + +def whoosh(length=0.28): + tt = np.arange(int(length * SR)) / SR + env = np.sin(np.pi * tt / length) ** 2 + return lowpass(rng.standard_normal(len(tt)), 600 + 5000 * tt / length) * env + + +K, C = kick(), clap() +for i in range(int(DUR / B)): + at = i * B + if 3.0 <= at < DROP1: # hats come in with scene 02 + both(at + B / 2, hat(0.06, 60), 0.03, pan=0.2) + if not full_beat(at): + continue + both(at, K, 0.55) + if i % 2 == 1: + both(at, C, 0.2) + both(at + B / 2, hat(0.09, 40), 0.05, pan=0.2) + both(at + B / 4, hat(), 0.022, pan=-0.3) + both(at + 3 * B / 4, hat(), 0.022, pan=-0.3) + +# 03 — click on «Установить», download riser, ding at 100 %, a beat of silence. +both(7.5, blip(2400, 900, 0.05, 80), 0.3) +tt = np.arange(int((DROP1 - 7.6) * SR)) / SR +riser = lowpass(rng.standard_normal(len(tt)), 200 + 6000 * (tt / tt[-1]) ** 2) * (tt / tt[-1]) ** 2 +both(7.6, riser * (tt < (DROP1 - 7.6) - B), 0.32) +both(8.9, ding(84), 0.12) + +# 04 — a stereo whoosh per theme change. +for k in range(5): + tt = np.arange(int(0.28 * SR)) / SR + w = whoosh() + place(L, DROP1 + k * B - 0.05, w * np.linspace(1, 0.2, len(w)), 0.18) + place(R, DROP1 + k * B - 0.05, w * np.linspace(0.2, 1, len(w)), 0.18) + +# 05 — rising drops on each accent. +for k, f in enumerate((700, 900, 1150)): + both(14.5 + k * B, blip(f * 1.6, f, 0.25, 12), 0.14) + +# 06 — switch clicks. +for k in range(4): + both(17.0 + k * B, blip(3000, 1500, 0.03, 120), 0.2) + +# 07 — notification ping on the badge; 08 — three dings up. +both(21.6, ding(88), 0.13) +for k, n in enumerate((79, 83, 86)): + both(25.8 + k * B, ding(n), 0.11) + +# 09 — reverse cymbal into the stop, then the beat comes back. +rev = crash()[: int(0.9 * SR)][::-1] +both(STOP - 0.9, rev, 0.16) + +# 10 — a whoosh and a snare hit on every feature cut. +for k in range(8): + at = FEATS + k + w = whoosh(0.22) + place(L, at - 0.08, w * np.linspace(0.2, 1, len(w)), 0.16) + place(R, at - 0.08, w * np.linspace(1, 0.2, len(w)), 0.16) + both(at, C, 0.18) + both(at, blip(1800 + 120 * k, 900, 0.06, 60), 0.08) + +# 11 — pops on the chips, riser into drop 2. +for k in range(5): + both(BREAK + 0.6 + k * B / 2, blip(1400, 800, 0.08, 40), 0.09, pan=-0.3 + 0.15 * k) +tt = np.arange(int(2.0 * SR)) / SR +riser2 = lowpass(rng.standard_normal(len(tt)), 300 + 7000 * (tt / tt[-1]) ** 2) * (tt / tt[-1]) ** 2 +both(DROP2 - 2.0, riser2, 0.34) + +# Crashes on the drops, the final hit and a ringing chord. +for at in (DROP1, DROP2): + both(at, crash(), 0.14) +both(END, crash(), 0.15) +both(END, K, 0.6) +tt = np.arange(int((DUR - END) * SR)) / SR +for note in (45, 57, 64, 69): + both(END, np.sin(2 * np.pi * hz(note) * tt) * np.exp(-tt * 0.55), 0.06) + +fade = np.ones(N) +fade[-int(2.0 * SR):] = np.linspace(1, 0, int(2.0 * SR)) +L = np.tanh(L * 1.2) * fade +R = np.tanh(R * 1.2) * fade +peak = max(np.abs(L).max(), np.abs(R).max()) +L, R = L / peak * 0.89, R / peak * 0.89 +pcm = (np.stack([L, R], 1) * 32767).astype(' + + + +GetBox 1.3 promo + + + +
+
+ + +
+ +
GETBOX
+
1.3
+
+ + +
+ +
+
+
+
+
+
+
+ + +
+
+
GETBOX 1.3
+
Всё в одном приложении
+
+
+
+ + +
+ +
GETBOX1.3
+
Магазин программ для Windows 10 и 11
+
Скачать бесплатно
+
github.com/IncludeSpecial/GetBox/releases
+
+ +
+
+ + + + + diff --git a/promo/render.cjs b/promo/render.cjs index c6c87ef..3091250 100644 --- a/promo/render.cjs +++ b/promo/render.cjs @@ -1,6 +1,7 @@ // Render a promo page frame by frame (deterministic) and pipe the frames into ffmpeg. // node promo/render.cjs video [page.html] → promo/.mp4 (no audio) // node promo/render.cjs stills [page.html] t1 t2 … → promo/still--.png +// QUERY="h=240&debug=1" is appended to the page URL (output size, overlays) where the page supports it. // The page sets window.FORMAT = { width, height, fps, duration } and window.render(t). // Playwright is not a project dependency: npx playwright, or point PLAYWRIGHT_MODULE at an install. const { chromium } = require(process.env.PLAYWRIGHT_MODULE ?? 'playwright'); @@ -22,7 +23,7 @@ const server = http.createServer((req, res) => { }).listen(0, async () => { const browser = await chromium.launch(launchOpts); const page = await browser.newPage(); - await page.goto(`http://127.0.0.1:${server.address().port}/promo/${pageFile}`); + await page.goto(`http://127.0.0.1:${server.address().port}/promo/${pageFile}${process.env.QUERY ? `?${process.env.QUERY}` : ''}`); const format = await page.evaluate(() => window.FORMAT ?? { width: 1920, height: 1080, fps: 30, duration: window.DURATION }); await page.setViewportSize({ width: format.width, height: format.height }); await page.evaluate(() => Promise.all([...document.images].map((i) => i.decode().catch(() => {})))); @@ -34,9 +35,9 @@ const server = http.createServer((req, res) => { await page.screenshot({ path: path.join(DIR, `still-${pageName}-${t}.png`) }); } } else { - const out = path.join(DIR, `${pageName}.mp4`); + const out = path.join(DIR, `${pageName}${process.env.OUT_SUFFIX ?? ''}.mp4`); const ff = spawn('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'image2pipe', '-framerate', String(format.fps), '-c:v', 'mjpeg', '-i', '-', - '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', out], { stdio: ['pipe', 'inherit', 'inherit'] }); + '-c:v', 'libx264', '-preset', 'slow', '-crf', process.env.CRF ?? '18', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', out], { stdio: ['pipe', 'inherit', 'inherit'] }); const frames = Math.round(format.duration * format.fps); for (let i = 0; i < frames; i++) { await page.evaluate((t) => window.render(t), i / format.fps); diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index 53519e1..3f0707b 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -639,5 +639,66 @@ test('clearDone removes done, cancelled, and failed', () => { }); } + +console.log('\nupdate checks:'); +{ + const { parseWingetUpgrade, parseScoopStatus, parseChocoOutdated } = loadTsModule( + path.join(__dirname, '..', 'electron', 'updates', 'parse.ts'), + ); + + test('winget upgrade: fixed-width table, names with spaces, footer ignored', () => { + const out = [ + ' - \r \\ \rName Id Version Available Source', + '------------------------------------------------------------------------------------------', + 'Mozilla Firefox (x64 ru) Mozilla.Firefox 156.0 157.0.1 winget', + 'Microsoft Visual Studio Code Microsoft.VisualStudioCode 1.140.2 1.141.0 winget', + 'OBS Studio OBSProject.OBSStudio 32.1.0 32.2.2 winget', + '3 upgrades available.', + '', + 'The following packages have an upgrade available, but require explicit targeting for upgrade:', + ].join('\n'); + const r = parseWingetUpgrade(out); + assert.deepEqual(r.map((u) => u.packageId), ['Mozilla.Firefox', 'Microsoft.VisualStudioCode', 'OBSProject.OBSStudio']); + assert.deepEqual(r[0], { packageId: 'Mozilla.Firefox', name: 'Mozilla Firefox (x64 ru)', installed: '156.0', available: '157.0.1' }); + assert.equal(r[2].name, 'OBS Studio'); + }); + + test('winget upgrade: localised header and footer', () => { + const out = [ + 'Имя ИД Версия Доступно Источник', + '----------------------------------------------------------', + 'Git Git.Git 2.44.0 2.45.1 winget', + 'Доступно обновлений: 1.', + ].join('\n'); + assert.deepEqual(parseWingetUpgrade(out), [{ packageId: 'Git.Git', name: 'Git', installed: '2.44.0', available: '2.45.1' }]); + }); + + test('winget upgrade: "no updates" message yields nothing', () => { + assert.deepEqual(parseWingetUpgrade('No installed package found matching input criteria.\n'), []); + }); + + test('scoop status: dashed underline marks multi-word columns', () => { + const out = [ + 'Scoop is up to date.', + '', + 'Name Installed Version Latest Version Missing Dependencies Info', + '---- ----------------- -------------- -------------------- ----', + 'git 2.44.0 2.45.1', + 'nodejs 21.0.0 22.1.0', + '7zip 23.01 23.01 Held package', + '', + ].join('\r\n'); + assert.deepEqual(parseScoopStatus(out).map((u) => [u.packageId, u.installed, u.available]), [ + ['git', '2.44.0', '2.45.1'], + ['nodejs', '21.0.0', '22.1.0'], + ]); + }); + + test('choco outdated: pinned and unchanged packages are skipped', () => { + const out = 'firefox|156.0|157.0.1|false\nvlc|3.0.20|3.0.21|true\ngit|2.45.1|2.45.1|false\nChocolatey v2.3.0\n'; + assert.deepEqual(parseChocoOutdated(out), [{ packageId: 'firefox', name: 'firefox', installed: '156.0', available: '157.0.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 f1fcd17..1cce99d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { MergedApp, ProfileEntry, QueueJob, SearchResult, ViewMode, AppSettings } from './types'; +import type { MergedApp, ProfileEntry, QueueJob, SearchResult, ViewMode, AppSettings, ListView, UpdatesState } from './types'; import { packageKey } from './types'; import { I18nProvider } from './i18n/I18nContext'; import { translations, type Locale } from './i18n/translations'; @@ -18,6 +18,10 @@ import { ProviderBanner } from './components/ProviderBanner'; import { BootstrapOverlay } from './components/BootstrapOverlay'; import { MetaLoadingBar } from './components/MetaLoadingBar'; import { ToastContainer } from './components/Toast'; +import { ViewSwitcher } from './components/ViewSwitcher'; +import { UpdatesView } from './components/UpdatesView'; +import { AppearanceSettings } from './components/AppearanceSettings'; +import { applyAppearance } from './appearance'; import type { MetaProgress } from './types'; import { isStoreApp } from './i18n/helpers'; @@ -36,11 +40,13 @@ export default function App() { const [related, setRelated] = useState({ stacks: [], alternatives: [] }); const [providers, setProviders] = useState>([]); const [settings, setSettings] = useState(null); + const [nativeMica, setNativeMica] = useState(false); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState(null); const [searchBadge, setSearchBadge] = useState(); const [queueJobs, setQueueJobs] = useState([]); + const [updatesState, setUpdatesState] = useState({ items: [], checking: false, history: [] }); const [queueOpen, setQueueOpen] = useState(false); const [selectedApp, setSelectedApp] = useState(null); const [featuredId, setFeaturedId] = useState(null); @@ -114,6 +120,21 @@ export default function App() { } }, []); + const patchSettings = useCallback(async (partial: Partial) => { + const s = await window.getbox.updateSettings(partial); + setSettings(s); + }, []); + + useEffect(() => { + window.getbox.windowNativeMica().then(setNativeMica, () => setNativeMica(false)); + }, []); + + useEffect(() => { + if (settings) applyAppearance(settings, nativeMica); + }, [settings, nativeMica]); + + const listView: ListView = settings?.listView ?? 'cards'; + const loadBootstrap = useCallback(async () => { try { const status = await window.getbox.getBootstrapStatus(); @@ -209,6 +230,8 @@ export default function App() { const unsubDirty = window.getbox.onCatalogDirty(() => loadCatalog()); const unsubQueue = window.getbox.onQueueUpdated((jobs) => setQueueJobs(jobs)); + window.getbox.getUpdates().then(setUpdatesState, () => {}); + const unsubUpdates = window.getbox.onUpdatesChanged(setUpdatesState); const unsubToast = window.getbox.onToastError((msg) => addToast(msg, 'error')); const unsubIndex = window.getbox.onIndexProgress((msg) => { setIndexBuilding(true); @@ -262,6 +285,7 @@ export default function App() { cancelled = true; unsubDirty(); unsubQueue(); + unsubUpdates(); unsubToast(); unsubIndex(); unsubIndexDone(); @@ -491,6 +515,7 @@ export default function App() { { id: 'recommended', label: t.nav.recommended }, { id: 'favorites', label: t.nav.favorites, count: apps.filter((a) => a.isFavorite).length }, { id: 'installed', label: t.nav.installed, count: apps.filter((a) => a.isInstalled).length }, + { id: 'updates', label: t.nav.updates, count: updatesState.items.length, highlight: true }, ]; return ( @@ -607,6 +632,7 @@ export default function App() { {view === 'settings' && settings && (
+

{t.settings.title}

)} - {view !== 'home' && view !== 'settings' && ( + {view === 'updates' && settings && ( + void window.getbox.checkUpdates().catch(() => addToast(t.toast.updatesError, 'error'))} + onUpgrade={(keys) => { + void window.getbox.upgradePackages(keys.map(({ provider, packageId }) => ({ provider, packageId }))); + setQueueOpen(true); + }} + onRollback={(id) => { + void window.getbox.rollbackUpdate(id); + setQueueOpen(true); + }} + onClearHistory={() => void window.getbox.clearUpdateHistory()} + onOpen={setSelectedApp} + onSettingsChange={(partial) => void patchSettings(partial)} + /> + )} + + {view !== 'home' && view !== 'settings' && view !== 'updates' && ( <> {view === 'catalog' && (

@@ -743,6 +790,12 @@ export default function App() { {view === 'installed' &&

{t.nav.installed}

} {view === 'search' &&

{t.search.results}

} + {filteredApps.length > 0 && ( +
+ void patchSettings({ listView: v })} /> +
+ )} + {filteredApps.length === 0 ? ( view === 'packages' && packageFilter.trim() ? (
@@ -763,6 +816,7 @@ export default function App() { onToggleFavorite={handleToggleFavorite} onVisibleChange={enrichVisible} reasons={view === 'recommended' ? reasonByKey : undefined} + layout={listView} /> )} diff --git a/src/appearance.ts b/src/appearance.ts new file mode 100644 index 0000000..93adfac --- /dev/null +++ b/src/appearance.ts @@ -0,0 +1,54 @@ +import type { AppSettings, ThemeId } from './types'; + +export interface ThemePreview { + id: ThemeId; + /** Swatches for the gallery card: background, surface, text, accent. */ + bg: string; + surface: string; + text: string; + accent: string; +} + +export const THEMES: ThemePreview[] = [ + { id: 'classic', bg: '#0b1219', surface: '#1a2735', text: '#dfe7ee', accent: '#66c0f4' }, + { id: 'light', bg: '#eef2f6', surface: '#ffffff', text: '#14202b', accent: '#8b6cff' }, + { id: 'oled', bg: '#000000', surface: '#141414', text: '#e8e8e8', accent: '#2dd4bf' }, + { id: 'contrast', bg: '#000000', surface: '#000000', text: '#ffffff', accent: '#ffd400' }, + { id: 'mica', bg: '#dfe6f2', surface: '#f7f9fc', text: '#1b1b1f', accent: '#0067c0' }, +]; + +export const ACCENT_PRESETS = ['#66c0f4', '#8b6cff', '#2dd4bf', '#7cb928', '#f59e0b', '#ef5da8', '#0067c0']; + +export const UI_SCALES = [0.9, 1, 1.1, 1.25]; + +/** Picks black or white text for a solid accent background. */ +function onColor(hex: string): string { + const n = parseInt(hex.slice(1), 16); + const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) => { + const v = c / 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }); + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return luminance > 0.36 ? '#06131d' : '#ffffff'; +} + +/** Mirrors the appearance settings onto ; the stylesheet does the rest. */ +export function applyAppearance(settings: Pick, nativeMica: boolean): void { + const root = document.documentElement; + root.dataset.theme = settings.theme; + root.toggleAttribute('data-native-mica', settings.theme === 'mica' && nativeMica); + // High contrast keeps its own yellow so focus and actions stay legible. + if (settings.accent && settings.theme !== 'contrast') { + const light = settings.theme === 'light' || settings.theme === 'mica'; + root.style.setProperty('--accent', settings.accent); + root.style.setProperty( + '--accent-hover', + `color-mix(in srgb, ${settings.accent} 65%, ${light ? '#000' : '#fff'})`, + ); + root.style.setProperty('--on-accent', onColor(settings.accent)); + } else { + root.style.removeProperty('--accent'); + root.style.removeProperty('--accent-hover'); + root.style.removeProperty('--on-accent'); + } +} diff --git a/src/components/AppAction.tsx b/src/components/AppAction.tsx new file mode 100644 index 0000000..cc64fd2 --- /dev/null +++ b/src/components/AppAction.tsx @@ -0,0 +1,69 @@ +import { useI18n } from '../i18n/I18nContext'; +import type { MergedApp, QueueJob } from '../types'; + +interface AppActionProps { + app: MergedApp; + job?: QueueJob; + onInstall: (app: MergedApp) => void; + onRetry?: (app: MergedApp) => void; + onCancel?: (jobId: string) => void; + className?: string; +} + +/** Install / progress / retry button shared by every list layout. */ +export function AppAction({ app, job, onInstall, onRetry, onCancel, className = '' }: AppActionProps) { + const { t } = useI18n(); + const busy = job?.status === 'running' || job?.status === 'pending'; + const failed = job?.status === 'failed'; + const percent = job?.progress?.percent; + + const stop = (fn: () => void) => (e: React.MouseEvent) => { + e.stopPropagation(); + fn(); + }; + + if (busy) { + return ( + + ); + } + if (failed) { + return ( + + ); + } + return ( + + ); +} diff --git a/src/components/AppRows.tsx b/src/components/AppRows.tsx new file mode 100644 index 0000000..8779f2f --- /dev/null +++ b/src/components/AppRows.tsx @@ -0,0 +1,114 @@ +import { memo } from 'react'; +import { useI18n } from '../i18n/I18nContext'; +import type { MergedApp, QueueJob } from '../types'; +import { packageKey } from '../types'; +import { AppCover } from './AppMonogram'; +import { AppAction } from './AppAction'; +import { IconStar, IconStarFilled } from './Icons'; + +interface RowHandlers { + onInstall: (app: MergedApp) => void; + onRetry?: (app: MergedApp) => void; + onCancel?: (jobId: string) => void; + onOpen: (app: MergedApp) => void; + onToggleFavorite: (app: MergedApp) => void; +} + +interface ListRowProps extends RowHandlers { + app: MergedApp; + metaLabel?: string; + reason?: string; + job?: QueueJob; +} + +function FavButton({ app, onToggle }: { app: MergedApp; onToggle: (app: MergedApp) => void }) { + const { t } = useI18n(); + const label = app.isFavorite ? t.app.unfavorite : t.app.favorite; + return ( + + ); +} + +function ListRowInner({ app, metaLabel, reason, job, onInstall, onRetry, onCancel, onOpen, onToggleFavorite }: ListRowProps) { + const { t } = useI18n(); + const description = app.enrichedDescription ?? app.description; + return ( +
onOpen(app)}> + +
+
+ {app.name} + {app.version && {app.version}} + {app.isInstalled && {t.app.installedBadge}} +
+ {reason ? ( +
{reason}
+ ) : ( + description &&
{description}
+ )} +
+ {metaLabel && {metaLabel}} + + +
+ ); +} + +export const AppListRow = memo(ListRowInner); + +interface TableProps extends RowHandlers { + apps: MergedApp[]; + activeJobs?: Map; + metaLabel: (app: MergedApp) => string | undefined; +} + +/** Dense table for long package lists: one line per app, sortable later. */ +export function AppTable({ apps, activeJobs, metaLabel, onInstall, onRetry, onCancel, onOpen, onToggleFavorite }: TableProps) { + const { t } = useI18n(); + return ( + + + + + + + + + + + + {apps.map((app) => { + const key = packageKey(app.provider, app.id); + return ( + onOpen(app)}> + + + + + + + ); + })} + +
{t.views.columns.name}{t.views.columns.version}{t.views.columns.source}{t.views.columns.status}{t.views.columns.actions}
+
+ + {app.name} +
+
{app.version ?? '—'}{metaLabel(app) ?? '—'}{app.isInstalled ? {t.app.installedBadge} : —} +
+ + +
+
+ ); +} diff --git a/src/components/AppearanceSettings.tsx b/src/components/AppearanceSettings.tsx new file mode 100644 index 0000000..38d7d9a --- /dev/null +++ b/src/components/AppearanceSettings.tsx @@ -0,0 +1,111 @@ +import { useI18n } from '../i18n/I18nContext'; +import type { AppSettings } from '../types'; +import { ACCENT_PRESETS, THEMES, UI_SCALES } from '../appearance'; +import { ViewSwitcher } from './ViewSwitcher'; + +interface AppearanceSettingsProps { + settings: AppSettings; + onChange: (partial: Partial) => void | Promise; +} + +export function AppearanceSettings({ settings, onChange }: AppearanceSettingsProps) { + const { t } = useI18n(); + const accentLocked = settings.theme === 'contrast'; + + return ( +
+

{t.appearance.title}

+ +

{t.appearance.theme}

+
+ {THEMES.map((th) => { + const active = settings.theme === th.id; + return ( + + ); + })} +
+ +

{t.appearance.accent}

+
+ + {ACCENT_PRESETS.map((hex) => ( +
+ +
+
+

{t.appearance.listView}

+ void onChange({ listView: v })} labelled /> +
+
+

{t.appearance.scale}

+
+ {UI_SCALES.map((sc) => ( + + ))} +
+
+
+
+ ); +} diff --git a/src/components/CapsuleCard.tsx b/src/components/CapsuleCard.tsx index d1b2fbb..ec34522 100644 --- a/src/components/CapsuleCard.tsx +++ b/src/components/CapsuleCard.tsx @@ -3,6 +3,7 @@ import { useI18n } from '../i18n/I18nContext'; import type { MergedApp, QueueJob } from '../types'; import { AppCover } from './AppMonogram'; import { IconStar, IconStarFilled } from './Icons'; +import { AppAction } from './AppAction'; interface CapsuleCardProps { app: MergedApp; @@ -15,13 +16,12 @@ interface CapsuleCardProps { onCancel?: (jobId: string) => void; onOpen: (app: MergedApp) => void; onToggleFavorite: (app: MergedApp) => void; + /** Square tile with name only; used by the "tiles" list view. */ + tile?: boolean; } -function CapsuleCardInner({ app, categoryLabel, reason, job, onInstall, onRetry, onCancel, onOpen, onToggleFavorite }: CapsuleCardProps) { +function CapsuleCardInner({ app, categoryLabel, reason, job, onInstall, onRetry, onCancel, onOpen, onToggleFavorite, tile }: CapsuleCardProps) { const { t } = useI18n(); - const busy = job?.status === 'running' || job?.status === 'pending'; - const failed = job?.status === 'failed'; - const percent = job?.progress?.percent; const stop = (fn: () => void) => (e: React.MouseEvent) => { e.stopPropagation(); @@ -30,7 +30,7 @@ function CapsuleCardInner({ app, categoryLabel, reason, job, onInstall, onRetry, return (
onOpen(app)} > @@ -49,48 +49,15 @@ function CapsuleCardInner({ app, categoryLabel, reason, job, onInstall, onRetry, > {app.isFavorite ? : } + {/* Tiles keep the action over the art so the name row stays compact. */} + {tile && }
{app.name}
{categoryLabel &&
{categoryLabel}
}
- {busy ? ( - - ) : failed ? ( - - ) : ( - - )} + {!tile && }
{reason &&
{reason}
} @@ -106,6 +73,8 @@ export const CapsuleCard = memo(CapsuleCardInner, (prev, next) => ( prev.app.brandColor === next.app.brandColor && prev.app.name === next.app.name && prev.categoryLabel === next.categoryLabel && + prev.reason === next.reason && + prev.tile === next.tile && prev.job?.status === next.job?.status && prev.job?.errorCode === next.job?.errorCode && prev.job?.progress?.phase === next.job?.progress?.phase && diff --git a/src/components/PagedGrid.tsx b/src/components/PagedGrid.tsx index 26a7604..8f5c57d 100644 --- a/src/components/PagedGrid.tsx +++ b/src/components/PagedGrid.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { useI18n } from '../i18n/I18nContext'; import { categoryLabel } from '../i18n/helpers'; -import type { MergedApp, QueueJob } from '../types'; +import type { ListView, MergedApp, QueueJob } from '../types'; import { listIdentity, packageKey } from '../types'; import { CapsuleCard } from './CapsuleCard'; +import { AppListRow, AppTable } from './AppRows'; const PAGE_SIZE = 48; @@ -29,6 +30,7 @@ interface PagedGridProps { onVisibleChange?: (visibleApps: MergedApp[]) => void; /** Why each app is shown, keyed by packageKey. */ reasons?: Map; + layout?: ListView; } export function PagedGrid({ @@ -42,6 +44,7 @@ export function PagedGrid({ onToggleFavorite, onVisibleChange, reasons, + layout = 'cards', }: PagedGridProps) { const { t } = useI18n(); const [page, setPage] = useState(1); @@ -79,27 +82,63 @@ export function PagedGrid({ ); observer.observe(el); return () => observer.disconnect(); - }, [hasMore, loadMore]); + // The sentinel element is remounted when the layout changes. + }, [hasMore, loadMore, layout]); return (
-
- {visible.map((app) => ( - + metaLabel(app, t, categories)} onInstall={onInstall} onRetry={onRetry} onCancel={onCancel} onOpen={onOpen} onToggleFavorite={onToggleFavorite} /> - ))} - {hasMore &&
} -
+ {hasMore &&
} + + ) : layout === 'list' ? ( +
+ {visible.map((app) => ( + + ))} + {hasMore &&
} +
+ ) : ( +
+ {visible.map((app) => ( + + ))} + {hasMore &&
} +
+ )} {hasMore && ( ))} diff --git a/src/components/UpdatesView.tsx b/src/components/UpdatesView.tsx new file mode 100644 index 0000000..9ef798a --- /dev/null +++ b/src/components/UpdatesView.tsx @@ -0,0 +1,243 @@ +import { useMemo } from 'react'; +import { useI18n } from '../i18n/I18nContext'; +import type { AppSettings, MergedApp, QueueJob, UpdateHistoryEntry, UpdateInfo, UpdatesState } from '../types'; +import { AppCover } from './AppMonogram'; + +/** Providers that can put back an exact earlier version. */ +const ROLLBACK_PROVIDERS = new Set(['winget', 'scoop', 'chocolatey']); + +type Key = { provider: string; packageId: string }; +const keyOf = (k: Key) => `${k.provider}:${k.packageId.toLowerCase()}`; + +interface UpdatesViewProps { + state: UpdatesState; + apps: MergedApp[]; + jobs: QueueJob[]; + settings: AppSettings; + onCheck: () => void; + onUpgrade: (keys: Key[]) => void; + onRollback: (historyId: string) => void; + onClearHistory: () => void; + onOpen: (app: MergedApp) => void; + onSettingsChange: (partial: Partial) => void; +} + +function startOfDay(ts: number): number { + const d = new Date(ts); + d.setHours(0, 0, 0, 0); + return d.getTime(); +} + +export function UpdatesView({ + state, apps, jobs, settings, onCheck, onUpgrade, onRollback, onClearHistory, onOpen, onSettingsChange, +}: UpdatesViewProps) { + const { t, locale } = useI18n(); + + const catalogApps = useMemo(() => { + const map = new Map(); + for (const a of apps) if (a.provider === 'catalog') map.set(a.id, a); + return map; + }, [apps]); + + const activeJobs = useMemo(() => { + const map = new Map(); + for (const j of jobs) { + if ((j.kind === 'upgrade' || j.kind === 'rollback') && (j.status === 'pending' || j.status === 'running')) { + map.set(keyOf(j), j); + } + } + return map; + }, [jobs]); + + const appFor = (entry: { catalogId?: string }) => (entry.catalogId ? catalogApps.get(entry.catalogId) : undefined); + const displayName = (entry: { name: string; catalogId?: string }) => appFor(entry)?.name ?? entry.name; + + // Only the newest successful upgrade of a package can be undone, and only once. + const latestByPackage = useMemo(() => { + const seen = new Set(); + const latest = new Set(); + for (const h of state.history) { + const k = keyOf(h); + if (seen.has(k)) continue; + seen.add(k); + latest.add(h.id); + } + return latest; + }, [state.history]); + + const days = useMemo(() => { + const today = startOfDay(Date.now()); + const groups: Array<{ label: string; entries: UpdateHistoryEntry[] }> = []; + for (const h of state.history) { + const day = startOfDay(h.at); + const label = day === today + ? t.updates.today + : day === today - 86400000 + ? t.updates.yesterday + : new Date(day).toLocaleDateString(locale, { day: 'numeric', month: 'long' }); + const last = groups[groups.length - 1]; + if (last?.label === label) last.entries.push(h); + else groups.push({ label, entries: [h] }); + } + return groups; + }, [state.history, locale, t]); + + const checkedLabel = useMemo(() => { + if (state.checking) return t.updates.checking; + if (!state.checkedAt) return t.updates.neverChecked; + const minutes = Math.round((Date.now() - state.checkedAt) / 60000); + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + const ago = minutes < 60 ? rtf.format(-minutes, 'minute') : rtf.format(-Math.round(minutes / 60), 'hour'); + return `${t.updates.checked} ${ago}`; + }, [state.checking, state.checkedAt, locale, t]); + + const pending = state.items.filter((u) => !activeJobs.has(keyOf(u))); + + const renderIcon = (entry: { name: string; catalogId?: string }) => { + const app = appFor(entry); + return ; + }; + + const openable = (entry: { catalogId?: string }) => { + const app = appFor(entry); + return app ? () => onOpen(app) : undefined; + }; + + const renderPending = (u: UpdateInfo) => { + const job = activeJobs.get(keyOf(u)); + const percent = job?.progress?.percent; + return ( +
  • + + {renderIcon(u)} +
    + + {u.installed} → {u.available} +
    {u.provider}
    +
    + {job ? ( + + {job.status === 'pending' + ? t.queue.status.pending + : typeof percent === 'number' ? `${t.updates.updating} ${Math.round(percent)}%` : t.updates.updating} + + ) : ( + + )} +
  • + ); + }; + + const renderHistory = (h: UpdateHistoryEntry) => { + const time = new Date(h.at).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); + const busy = activeJobs.has(keyOf(h)); + const canRollback = h.ok && h.kind === 'upgrade' && !!h.from && ROLLBACK_PROVIDERS.has(h.provider) + && latestByPackage.has(h.id) && !busy; + const stillAvailable = state.items.find((u) => keyOf(u) === keyOf(h)); + const canRetry = !h.ok && h.kind === 'upgrade' && !!stillAvailable && !busy; + const note = h.ok + ? `${h.kind === 'rollback' ? t.updates.rolledBackAt : t.updates.updatedAt} ${time} · ${h.provider}` + : (h.errorCode ? t.queue.errors[h.errorCode] : h.message) ?? t.app.failed; + return ( +
  • + + {renderIcon(h)} +
    + + {h.from && h.to && {h.from} → {h.to}} +
    {note}
    +
    + {canRollback && ( + + )} + {canRetry && ( + + )} +
  • + ); + }; + + return ( +
    +
    +
    +

    {t.nav.updates}

    + {checkedLabel} + +
    + +
    +
    +

    {t.updates.pending}

    + + {pending.length > 1 && ( + + )} +
    + {state.items.length === 0 ? ( +

    {state.checkedAt ? t.updates.allFresh : t.updates.notChecked}

    + ) : ( +
      {state.items.map(renderPending)}
    + )} +
    + + {days.map((d) => ( +
    +
    +

    {d.label}

    + +
    +
      {d.entries.map(renderHistory)}
    +
    + ))} + + {state.history.length > 0 && ( + + )} +
    + + +
    + ); +} diff --git a/src/components/ViewSwitcher.tsx b/src/components/ViewSwitcher.tsx new file mode 100644 index 0000000..a4bace8 --- /dev/null +++ b/src/components/ViewSwitcher.tsx @@ -0,0 +1,47 @@ +import { useI18n } from '../i18n/I18nContext'; +import type { ListView } from '../types'; + +export const LIST_VIEWS: ListView[] = ['cards', 'list', 'table', 'tiles']; + +function ViewGlyph({ view }: { view: ListView }) { + const common = { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', 'aria-hidden': true } as const; + switch (view) { + case 'cards': + return ; + case 'list': + return ; + case 'table': + return ; + case 'tiles': + return ; + } +} + +interface ViewSwitcherProps { + value: ListView; + onChange: (view: ListView) => void; + /** Show text labels next to the glyphs (settings page). */ + labelled?: boolean; +} + +export function ViewSwitcher({ value, onChange, labelled }: ViewSwitcherProps) { + const { t } = useI18n(); + return ( +
    + {LIST_VIEWS.map((v) => ( + + ))} +
    + ); +} diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index e4e0f42..00690eb 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -11,6 +11,7 @@ export const translations = { recommended: 'Рекомендуем', favorites: 'Избранное', installed: 'Установлено', + updates: 'Обновления', categories: 'Категории', all: 'Все программы', system: 'Система', @@ -125,6 +126,56 @@ export const translations = { nothingFound: 'Ничего не найдено по фильтру', count: 'пакетов', }, + updates: { + check: 'Проверить', + checking: 'Проверяем…', + checked: 'Проверено', + neverChecked: 'Ещё не проверяли', + pending: 'Ожидают', + today: 'Сегодня', + yesterday: 'Вчера', + update: 'Обновить', + updateAll: 'Обновить все', + updating: 'Обновляется', + rollbackTo: 'Откатить до', + updatedAt: 'Обновлено в', + rolledBackAt: 'Откачено в', + allFresh: 'Всё актуально.', + notChecked: 'Нажмите «Проверить», чтобы найти новые версии.', + clearHistory: 'Очистить историю', + autoTitle: 'Автообновление', + autoCheck: 'Проверять при запуске и раз в 6 часов', + autoInstall: 'Ставить обновления в фоне без вопросов', + rollbackNote: 'Откат ставит предыдущую версию через тот же менеджер пакетов: winget, Scoop или Chocolatey. Программы, которые обновляются сами (Steam, Discord, Spotify), в списке не появляются.', + queueLabel: 'обновление', + queueRollback: 'откат', + }, + appearance: { + title: 'Внешний вид', + theme: 'Тема', + themes: { classic: 'Классика', light: 'Светлая', oled: 'OLED', contrast: 'Высокий контраст', mica: 'Windows 11' }, + themeHints: { + classic: 'Тёмная, по умолчанию', + light: 'Для светлых комнат', + oled: 'Чистый чёрный', + contrast: 'Крупный текст, жёлтые акценты', + mica: 'Полупрозрачный фон Mica', + }, + accent: 'Акцентный цвет', + accentAuto: 'Как в теме', + accentCustom: 'Свой', + accentLocked: 'В режиме высокого контраста акцент фиксирован.', + listView: 'Вид списков', + scale: 'Масштаб', + }, + views: { + label: 'Вид списка', + cards: 'Карточки', + list: 'Список', + table: 'Таблица', + tiles: 'Плитки', + columns: { name: 'Название', version: 'Версия', source: 'Источник', status: 'Статус', actions: 'Действия' }, + }, settings: { title: 'Расширенные настройки', quietInstall: 'Тихая установка', @@ -168,6 +219,7 @@ export const translations = { queueError: 'Ошибка очереди', favoriteError: 'Ошибка избранного', searchError: 'Ошибка поиска', + updatesError: 'Не удалось проверить обновления', bootstrapRetry: 'Настройка runtime повторена', bootstrapError: 'Ошибка bootstrap', profileError: 'Ошибка профиля', @@ -190,6 +242,7 @@ export const translations = { recommended: 'Recommended', favorites: 'Favorites', installed: 'Installed', + updates: 'Updates', categories: 'Categories', all: 'All apps', system: 'System', @@ -304,6 +357,56 @@ export const translations = { nothingFound: 'No packages match the filter', count: 'packages', }, + updates: { + check: 'Check', + checking: 'Checking…', + checked: 'Checked', + neverChecked: 'Not checked yet', + pending: 'Pending', + today: 'Today', + yesterday: 'Yesterday', + update: 'Update', + updateAll: 'Update all', + updating: 'Updating', + rollbackTo: 'Roll back to', + updatedAt: 'Updated at', + rolledBackAt: 'Rolled back at', + allFresh: 'Everything is up to date.', + notChecked: 'Press “Check” to look for new versions.', + clearHistory: 'Clear history', + autoTitle: 'Automatic updates', + autoCheck: 'Check at launch and every 6 hours', + autoInstall: 'Install updates in the background without asking', + rollbackNote: 'Rolling back reinstalls the previous version through the same package manager: winget, Scoop or Chocolatey. Apps that update themselves (Steam, Discord, Spotify) are not listed.', + queueLabel: 'update', + queueRollback: 'rollback', + }, + appearance: { + title: 'Appearance', + theme: 'Theme', + themes: { classic: 'Classic', light: 'Light', oled: 'OLED', contrast: 'High contrast', mica: 'Windows 11' }, + themeHints: { + classic: 'Dark, the default', + light: 'For bright rooms', + oled: 'True black', + contrast: 'Large text, yellow accents', + mica: 'Translucent Mica backdrop', + }, + accent: 'Accent colour', + accentAuto: 'Theme default', + accentCustom: 'Custom', + accentLocked: 'High contrast keeps a fixed accent.', + listView: 'List view', + scale: 'Scale', + }, + views: { + label: 'List view', + cards: 'Cards', + list: 'List', + table: 'Table', + tiles: 'Tiles', + columns: { name: 'Name', version: 'Version', source: 'Source', status: 'Status', actions: 'Actions' }, + }, settings: { title: 'Advanced settings', quietInstall: 'Silent install', @@ -347,6 +450,7 @@ export const translations = { queueError: 'Queue error', favoriteError: 'Favorites error', searchError: 'Search error', + updatesError: 'Could not check for updates', bootstrapRetry: 'Runtime setup retried', bootstrapError: 'Bootstrap error', profileError: 'Profile error', diff --git a/src/index.css b/src/index.css index 797e599..d7f1c73 100644 --- a/src/index.css +++ b/src/index.css @@ -12,14 +12,17 @@ --accent: #66c0f4; --accent-hover: #a5d8ff; - --accent-dim: rgba(102, 192, 244, 0.14); - --accent-line: rgba(102, 192, 244, 0.35); + --accent-dim: color-mix(in srgb, var(--accent) 14%, transparent); + --accent-line: color-mix(in srgb, var(--accent) 35%, transparent); + --accent-wash: color-mix(in srgb, var(--accent) 8%, transparent); + --on-accent: #06131d; --cta-from: #7cb928; --cta-to: #4c7a15; --cta-from-hover: #96da33; --cta-to-hover: #5f9a1b; --cta-text: #d2efa4; + --cta-label: #f2fce0; --text-primary: #dfe7ee; --text-secondary: #93a3b3; @@ -33,6 +36,24 @@ --brand: #3a4b5c; + /* Surfaces the themes below repaint. */ + --bg-chrome: #0a1118; + --bg-sidebar: linear-gradient(180deg, #16212d 0%, #101923 100%); + --bg-tabbar: linear-gradient(180deg, #17222e, #121b25); + --btn2-bg: linear-gradient(180deg, #3b526b, #2a3a4c); + --btn2-bg-hover: linear-gradient(180deg, #48627e, #33455a); + --btn2-bg-active: linear-gradient(180deg, #4a678a, #33475f); + --btn2-border: rgba(255, 255, 255, 0.06); + --overlay-hover: rgba(255, 255, 255, 0.05); + --overlay-press: rgba(255, 255, 255, 0.08); + --scroll-thumb: #33445a; + --scroll-thumb-hover: #445a75; + --art-base: #0d1620; + --hero-base: #0e1720; + --modal-base: #131c26; + --backdrop: rgba(4, 8, 12, 0.78); + --border-width: 1px; + --sidebar-width: 216px; --titlebar-height: 36px; --topbar-height: 52px; @@ -84,8 +105,8 @@ button, input, select { ::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar-track { background: var(--bg-inset); } -::-webkit-scrollbar-thumb { background: #33445a; border-radius: 10px; border: 2px solid var(--bg-inset); } -::-webkit-scrollbar-thumb:hover { background: #445a75; } +::-webkit-scrollbar-thumb { background: var(--scroll-thumb); border-radius: 10px; border: 2px solid var(--bg-inset); } +::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hover); } @keyframes spin { to { transform: rotate(360deg); } } @keyframes fade-up { @@ -116,7 +137,7 @@ button, input, select { flex-shrink: 0; display: flex; align-items: center; - background: #0a1118; + background: var(--bg-chrome); border-bottom: 1px solid var(--border); user-select: none; } @@ -171,7 +192,7 @@ button, input, select { } .titlebar-btn:hover { - background: rgba(255, 255, 255, 0.08); + background: var(--overlay-press); color: var(--text-primary); } @@ -254,7 +275,7 @@ button, input, select { flex-shrink: 0; display: flex; flex-direction: column; - background: linear-gradient(180deg, #16212d 0%, #101923 100%); + background: var(--bg-sidebar); border-right: 1px solid var(--border); } @@ -321,7 +342,7 @@ button, input, select { transition: background 0.12s ease, color 0.12s ease; } -.nav-item:hover { background: rgba(255, 255, 255, 0.05); color: var(--text-primary); } +.nav-item:hover { background: var(--overlay-hover); color: var(--text-primary); } .nav-item.active { background: linear-gradient(90deg, var(--accent-dim), transparent); @@ -415,7 +436,7 @@ button, input, select { } .search-box input::placeholder { color: var(--text-muted); } -.search-box input:focus { border-color: var(--accent-line); background: #0a1118; } +.search-box input:focus { border-color: var(--accent-line); background: var(--bg-chrome); } .search-badge { padding: 2px 7px; @@ -461,7 +482,7 @@ button, input, select { padding: 1px 5px; border-radius: 9px; background: var(--accent); - color: #06131d; + color: var(--on-accent); font-size: 10px; font-weight: 700; text-align: center; @@ -476,13 +497,17 @@ button, input, select { height: 38px; flex-shrink: 0; padding: 0 24px; - background: linear-gradient(180deg, #17222e, #121b25); + overflow-x: auto; + scrollbar-width: none; + background: var(--bg-tabbar); border-bottom: 1px solid var(--border); } .tab-item { position: relative; + flex-shrink: 0; padding: 0 14px; + white-space: nowrap; border: 0; background: transparent; color: var(--text-secondary); @@ -494,11 +519,11 @@ button, input, select { transition: color 0.12s ease, background 0.12s ease; } -.tab-item:hover { color: var(--text-primary); background: rgba(255, 255, 255, 0.04); } +.tab-item:hover { color: var(--text-primary); background: var(--overlay-hover); } .tab-item.active { color: var(--accent-hover); - background: rgba(102, 192, 244, 0.08); + background: var(--accent-wash); box-shadow: inset 0 -2px 0 var(--accent); } @@ -529,27 +554,27 @@ button, input, select { .btn-primary { background: linear-gradient(180deg, var(--cta-from), var(--cta-to)); - color: #f2fce0; + color: var(--cta-label); text-shadow: 0 1px 1px rgba(0, 0, 0, 0.35); } .btn-primary:hover { background: linear-gradient(180deg, var(--cta-from-hover), var(--cta-to-hover)); } .btn-secondary { - background: linear-gradient(180deg, #3b526b, #2a3a4c); + background: var(--btn2-bg); color: var(--text-primary); - border-color: rgba(255, 255, 255, 0.06); + border-color: var(--btn2-border); } -.btn-secondary:hover { background: linear-gradient(180deg, #48627e, #33455a); } +.btn-secondary:hover { background: var(--btn2-bg-hover); } .btn-accent { - background: rgba(102, 192, 244, 0.1); + background: var(--accent-wash); border-color: var(--accent-line); color: var(--accent-hover); } -.btn-accent:hover { background: rgba(102, 192, 244, 0.2); } +.btn-accent:hover { background: var(--accent-dim); } .btn-ghost { background: transparent; @@ -570,9 +595,9 @@ button, input, select { cursor: pointer; } -.btn-icon:hover { color: var(--text-primary); background: rgba(255, 255, 255, 0.07); } +.btn-icon:hover { color: var(--text-primary); background: var(--overlay-press); } -.btn-queue.active { background: linear-gradient(180deg, #4a678a, #33475f); } +.btn-queue.active { background: var(--btn2-bg-active); } /* ---------- Headings ---------- */ @@ -664,7 +689,7 @@ button, input, select { align-items: center; padding: 26px 30px; background: - linear-gradient(105deg, color-mix(in srgb, var(--brand) 78%, #000) 0%, color-mix(in srgb, var(--brand) 34%, #0e1720) 46%, #0e1720 100%); + linear-gradient(105deg, color-mix(in srgb, var(--brand) 78%, #000) 0%, color-mix(in srgb, var(--brand) 34%, var(--hero-base)) 46%, var(--hero-base) 100%); } .hero-banner-glow { @@ -802,7 +827,7 @@ button, input, select { place-items: center; background: radial-gradient(circle at 50% 42%, color-mix(in srgb, var(--brand) 62%, transparent), transparent 68%), - linear-gradient(160deg, color-mix(in srgb, var(--brand) 44%, #0d1620), #0d1620 82%); + linear-gradient(160deg, color-mix(in srgb, var(--brand) 44%, var(--art-base)), var(--art-base) 82%); } .capsule-logo { @@ -890,7 +915,7 @@ button, input, select { border: 0; border-radius: var(--r-xs); background: linear-gradient(180deg, var(--cta-from), var(--cta-to)); - color: #f2fce0; + color: var(--cta-label); font-size: 11px; font-weight: 700; cursor: pointer; @@ -898,8 +923,8 @@ button, input, select { } .capsule-cta:hover { background: linear-gradient(180deg, var(--cta-from-hover), var(--cta-to-hover)); } -.capsule-cta.reinstall { background: linear-gradient(180deg, #3b526b, #2a3a4c); color: var(--text-primary); } -.capsule-cta.reinstall:hover { background: linear-gradient(180deg, #48627e, #33455a); } +.capsule-cta.reinstall { background: var(--btn2-bg); color: var(--text-primary); } +.capsule-cta.reinstall:hover { background: var(--btn2-bg-hover); } .capsule-cta.failed { background: color-mix(in srgb, var(--danger) 55%, #2a1515); @@ -1073,7 +1098,7 @@ button, input, select { display: grid; place-items: center; padding: 24px; - background: rgba(4, 8, 12, 0.78); + background: var(--backdrop); backdrop-filter: blur(3px); } @@ -1094,7 +1119,7 @@ button, input, select { gap: 16px; padding: 22px 24px; background: - linear-gradient(115deg, color-mix(in srgb, var(--brand) 68%, #000), color-mix(in srgb, var(--brand) 22%, #131c26) 72%, #131c26); + linear-gradient(115deg, color-mix(in srgb, var(--brand) 68%, #000), color-mix(in srgb, var(--brand) 22%, var(--modal-base)) 72%, var(--modal-base)); border-bottom: 1px solid var(--border); } @@ -1189,12 +1214,11 @@ button, input, select { flex-direction: column; background: var(--bg-app); border-left: 1px solid var(--border); - box-shadow: -12px 0 32px rgba(0, 0, 0, 0.5); transform: translateX(100%); transition: transform 0.2s ease; } -.queue-panel.open { transform: none; } +.queue-panel.open { transform: none; box-shadow: -12px 0 32px rgba(0, 0, 0, 0.5); } .queue-header { display: flex; @@ -1495,7 +1519,7 @@ button, input, select { /* ---------- Settings ---------- */ -.settings-section { max-width: 620px; } +.settings-section { max-width: 880px; } .setting-row { display: flex; @@ -1598,3 +1622,618 @@ button, input, select { .toast.error { border-left-color: var(--danger); } .toast .btn-icon { margin-left: auto; } + +/* ---------- Themes ---------- + Classic lives in :root above. Each theme only repaints tokens; brand art + (hero banner, modal header) stays dark in every theme, so it pins its own + text colours. */ + +.hero-banner-art, .modal-header { + --text-primary: #eef3f7; + --text-secondary: #c2cdd8; + --text-muted: #9fb0c0; + color: var(--text-primary); +} + +:root[data-theme='light'] :is(.hero-banner-art, .modal-header), +:root[data-theme='mica'] :is(.hero-banner-art, .modal-header) { + --accent-hover: color-mix(in srgb, var(--accent) 50%, #fff); +} + +:root[data-theme='light'] { + --bg-base: #eef2f6; + --bg-app: #ffffff; + --bg-surface: #ffffff; + --bg-raised: #f3f6f9; + --bg-inset: #e6ebf1; + --bg-hover: #e2e8ef; + --accent: #8b6cff; + --accent-hover: #5a3fd6; + --on-accent: #ffffff; + --cta-from: #4f8f16; + --cta-to: #3f7410; + --cta-from-hover: #5aa31a; + --cta-to-hover: #4a8513; + --cta-label: #ffffff; + --text-primary: #14202b; + --text-secondary: #4d5d6d; + --text-muted: #5d6d7d; + --border: rgba(20, 32, 43, 0.1); + --border-strong: rgba(20, 32, 43, 0.2); + --danger: #c43c3c; + --ok: #3f7410; + --bg-chrome: #e2e8ef; + --bg-sidebar: #f6f8fb; + --bg-tabbar: #f6f8fb; + --btn2-bg: linear-gradient(180deg, #ffffff, #eef2f6); + --btn2-bg-hover: linear-gradient(180deg, #ffffff, #e2e8ef); + --btn2-bg-active: linear-gradient(180deg, #e7e0ff, #d9cfff); + --btn2-border: rgba(20, 32, 43, 0.16); + --overlay-hover: rgba(20, 32, 43, 0.05); + --overlay-press: rgba(20, 32, 43, 0.09); + --scroll-thumb: #c3ccd6; + --scroll-thumb-hover: #a9b5c2; + --art-base: #e9eef4; + --backdrop: rgba(20, 32, 43, 0.45); + --shadow-card: 0 1px 2px rgba(20, 32, 43, 0.12); + --shadow-lift: 0 6px 20px rgba(20, 32, 43, 0.16); + --shadow-modal: 0 24px 64px rgba(20, 32, 43, 0.3); + color-scheme: light; +} + +:root[data-theme='oled'] { + --bg-base: #000000; + --bg-app: #000000; + --bg-surface: #0b0b0b; + --bg-raised: #141414; + --bg-inset: #050505; + --bg-hover: #1c1c1c; + --accent: #2dd4bf; + --accent-hover: #7ae6d6; + --on-accent: #04201c; + --cta-from: #2dd4bf; + --cta-to: #14a394; + --cta-from-hover: #5ee3d2; + --cta-to-hover: #1bb8a8; + --cta-label: #04201c; + --text-primary: #e8e8e8; + --text-secondary: #a0a0a0; + --text-muted: #8f8f8f; + --border: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.16); + --bg-chrome: #000000; + --bg-sidebar: #000000; + --bg-tabbar: #000000; + --btn2-bg: #1a1a1a; + --btn2-bg-hover: #262626; + --btn2-bg-active: #2e2e2e; + --scroll-thumb: #262626; + --scroll-thumb-hover: #3a3a3a; + --art-base: #050505; + --hero-base: #000000; + --modal-base: #050505; + --backdrop: rgba(0, 0, 0, 0.85); +} + +:root[data-theme='contrast'] { + --bg-base: #000000; + --bg-app: #000000; + --bg-surface: #000000; + --bg-raised: #000000; + --bg-inset: #000000; + --bg-hover: #1f1f1f; + --accent: #ffd400; + --accent-hover: #ffd400; + --accent-dim: rgba(255, 212, 0, 0.22); + --accent-line: #ffd400; + --accent-wash: rgba(255, 212, 0, 0.12); + --on-accent: #000000; + --cta-from: #ffd400; + --cta-to: #ffd400; + --cta-from-hover: #ffe45c; + --cta-to-hover: #ffe45c; + --cta-label: #000000; + --text-primary: #ffffff; + --text-secondary: #ffffff; + --text-muted: #e0e0e0; + --border: #ffffff; + --border-strong: #ffffff; + --border-width: 2px; + --danger: #ff6b6b; + --bg-chrome: #000000; + --bg-sidebar: #000000; + --bg-tabbar: #000000; + --btn2-bg: #000000; + --btn2-bg-hover: #1f1f1f; + --btn2-bg-active: #333333; + --btn2-border: #ffffff; + --overlay-hover: rgba(255, 255, 255, 0.12); + --overlay-press: rgba(255, 255, 255, 0.2); + --scroll-thumb: #ffffff; + --scroll-thumb-hover: #ffd400; + --art-base: #000000; + --hero-base: #000000; + --modal-base: #000000; + --backdrop: rgba(0, 0, 0, 0.9); + --shadow-card: none; + --shadow-lift: none; +} + +:root[data-theme='contrast'] body { font-size: 15px; } +:root[data-theme='contrast'] .btn-primary, +:root[data-theme='contrast'] .capsule-cta { text-shadow: none; } +:root[data-theme='contrast'] .capsule, +:root[data-theme='contrast'] .list-row, +:root[data-theme='contrast'] .setting-row, +:root[data-theme='contrast'] .theme-card, +:root[data-theme='contrast'] .profile-card { outline: 2px solid #ffffff; outline-offset: -2px; } +:root[data-theme='contrast'] .btn-secondary, +:root[data-theme='contrast'] .capsule-cta.reinstall { border: 2px solid #ffffff; } +:root[data-theme='contrast'] .tab-item.active { box-shadow: inset 0 -4px 0 var(--accent); text-decoration: underline; } +:root[data-theme='contrast'] :focus-visible { outline: 3px solid #ffd400; outline-offset: 2px; } + +:root[data-theme='mica'] { + --bg-base: rgba(243, 246, 250, 0.68); + --bg-app: rgba(255, 255, 255, 0.55); + --bg-surface: rgba(255, 255, 255, 0.72); + --bg-raised: rgba(255, 255, 255, 0.86); + --bg-inset: rgba(0, 0, 0, 0.05); + --bg-hover: rgba(0, 0, 0, 0.06); + --accent: #0067c0; + --accent-hover: #005a9e; + --on-accent: #ffffff; + --cta-from: #0f74cc; + --cta-to: #0067c0; + --cta-from-hover: #1a80d8; + --cta-to-hover: #0a6fc6; + --cta-label: #ffffff; + --text-primary: #1b1b1f; + --text-secondary: #5c5c66; + --text-muted: #5f6068; + --border: rgba(0, 0, 0, 0.08); + --border-strong: rgba(0, 0, 0, 0.16); + --danger: #c42b1c; + --ok: #0f7b0f; + --bg-chrome: transparent; + --bg-sidebar: rgba(255, 255, 255, 0.35); + --bg-tabbar: transparent; + --btn2-bg: rgba(255, 255, 255, 0.8); + --btn2-bg-hover: rgba(255, 255, 255, 0.95); + --btn2-bg-active: rgba(0, 103, 192, 0.14); + --btn2-border: rgba(0, 0, 0, 0.12); + --overlay-hover: rgba(0, 0, 0, 0.04); + --overlay-press: rgba(0, 0, 0, 0.07); + --scroll-thumb: rgba(0, 0, 0, 0.25); + --scroll-thumb-hover: rgba(0, 0, 0, 0.4); + --art-base: rgba(230, 236, 245, 0.9); + --backdrop: rgba(30, 40, 70, 0.35); + --shadow-card: 0 1px 3px rgba(30, 40, 70, 0.1); + --shadow-lift: 0 8px 24px rgba(30, 40, 70, 0.16); + --shadow-modal: 0 30px 80px rgba(30, 40, 70, 0.35); + --r-md: 7px; + --r-lg: 10px; + color-scheme: light; +} + +:root[data-theme='light'] .capsule-art, +:root[data-theme='mica'] .capsule-art { + background: + radial-gradient(circle at 50% 42%, color-mix(in srgb, var(--brand) 22%, transparent), transparent 70%), + var(--art-base); +} + +/* Without native Mica (Windows 10, other OSes) paint a soft wallpaper behind the frosted window. */ +:root[data-theme='mica']:not([data-native-mica]) body { + background: + radial-gradient(1200px 800px at 15% 10%, #9ec5ec, transparent 60%), + radial-gradient(1000px 700px at 90% 90%, #c9b8ec, transparent 60%), + #b9c9e6; +} +:root[data-theme='mica'][data-native-mica] body { background: transparent; } +:root[data-theme='mica'] .app-root, +:root[data-theme='mica'] .app-shell { backdrop-filter: blur(30px) saturate(1.3); } +:root[data-theme='mica'] .btn-primary, +:root[data-theme='mica'] .capsule-cta, +:root[data-theme='light'] .btn-primary, +:root[data-theme='light'] .capsule-cta { text-shadow: none; } + +/* ---------- List views ---------- */ + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +.list-toolbar { + display: flex; + justify-content: flex-end; + margin: -6px 0 10px; +} + +.view-switch { + display: inline-flex; + gap: 2px; + padding: 2px; + border: var(--border-width) solid var(--border); + border-radius: var(--r-md); + background: var(--bg-inset); +} + +.view-switch-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 26px; + padding: 0 8px; + border: 0; + border-radius: var(--r-sm); + background: transparent; + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; +} + +.view-switch-btn:hover { color: var(--text-primary); background: var(--overlay-hover); } +.view-switch-btn.active { background: var(--accent); color: var(--on-accent); font-weight: 600; } +.view-switch--labelled .view-switch-btn { height: 30px; padding: 0 12px; } + +/* Tiles: square art, name only, action on hover. */ +.paged-grid--tiles { grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); gap: 8px; } +.capsule--tile .capsule-art { aspect-ratio: 1 / 1; } +.capsule--tile .capsule-logo { width: 46px; height: 46px; } +.capsule--tile .capsule-body { justify-content: center; padding: 7px 8px 8px; } +.capsule--tile .capsule-name { text-align: center; font-size: 12px; } +.capsule--tile .capsule-meta, +.capsule--tile .capsule-reason { display: none; } + +/* The action floats over the bottom of the art and shows on hover / focus. */ +.capsule-cta--tile { + position: absolute; + left: 8px; + right: 8px; + bottom: 8px; + opacity: 0; + transition: opacity 0.13s ease; +} + +.capsule--tile:hover .capsule-cta--tile, +.capsule--tile:focus-within .capsule-cta--tile, +.capsule-cta--tile.busy, +.capsule-cta--tile.failed { opacity: 1; } +.capsule--tile .capsule-installed { top: 6px; left: 6px; } + +/* List: one row per app with description. */ +.app-list { display: flex; flex-direction: column; gap: 4px; width: 100%; } + +.list-row { + display: flex; + align-items: center; + gap: 14px; + padding: 10px 14px; + border-radius: var(--r-md); + background: var(--bg-surface); + box-shadow: var(--shadow-card); + cursor: pointer; + transition: background 0.12s ease, box-shadow 0.12s ease; +} + +.list-row:hover { background: var(--bg-raised); box-shadow: var(--shadow-card), inset 3px 0 0 var(--accent); } +.list-row .app-cover-img--sm { width: 36px; height: 36px; object-fit: contain; } +.list-row-text { flex: 1; min-width: 0; } +.list-row-title { display: flex; align-items: center; gap: 8px; min-width: 0; } +.list-row-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.list-row-version { color: var(--text-muted); } + +.list-row-desc, .list-row-reason { + margin-top: 2px; + overflow: hidden; + color: var(--text-secondary); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.list-row-reason { color: var(--accent-hover); } + +.list-row-meta { + flex: 0 0 auto; + width: 120px; + color: var(--text-muted); + font-size: 10.5px; + letter-spacing: 0.06em; + text-transform: uppercase; + text-align: right; +} + +.row-fav { + display: grid; + place-items: center; + width: 28px; + height: 28px; + flex: 0 0 auto; + border: 0; + border-radius: var(--r-sm); + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.row-fav:hover { color: var(--accent-hover); background: var(--overlay-hover); } +.row-fav.active { color: #f5c451; } +.row-cta { min-width: 104px; } + +.badge { + padding: 1px 6px; + border-radius: var(--r-xs); + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.badge--ok { background: color-mix(in srgb, var(--ok) 22%, transparent); color: var(--ok); } +:root:not([data-theme='light']):not([data-theme='mica']) .badge--ok { color: #b9e47a; } + +/* Table: dense rows for long package lists. */ +.app-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; +} + +.app-table th { + position: sticky; + top: 0; + z-index: 1; + padding: 7px 10px; + border-bottom: var(--border-width) solid var(--border-strong); + background: var(--bg-app); + color: var(--text-muted); + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-align: left; + text-transform: uppercase; +} + +.app-table td { + padding: 5px 10px; + border-bottom: var(--border-width) solid var(--border); + white-space: nowrap; +} + +.app-table tbody tr { cursor: pointer; } +.app-table tbody tr:hover { background: var(--overlay-hover); } +.app-table-name { width: 100%; max-width: 0; } +.app-table-cell { display: flex; align-items: center; gap: 10px; min-width: 0; } +td.app-table-name { font-weight: 600; } +td.app-table-name span { overflow: hidden; text-overflow: ellipsis; } +.app-table .app-cover-img--xs { width: 20px; height: 20px; object-fit: contain; flex: 0 0 auto; } +.app-table-meta { color: var(--text-secondary); text-transform: capitalize; } +.app-table-dim { color: var(--text-muted); } +.app-table-actions { justify-content: flex-end; gap: 4px; } + +/* ---------- Appearance settings ---------- */ + +.appearance { margin-bottom: 26px; } +.appearance + .page-title { margin-top: 8px; } + +.appearance-label { + margin: 16px 0 8px; + color: var(--text-muted); + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.theme-gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 10px; +} + +.theme-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 8px 10px; + border: var(--border-width) solid var(--border); + border-radius: var(--r-lg); + background: var(--bg-surface); + text-align: left; + cursor: pointer; +} + +.theme-card:hover { border-color: var(--accent-line); } +.theme-card.active { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim); } + +.theme-preview { + display: flex; + flex-direction: column; + gap: 5px; + height: 78px; + margin-bottom: 4px; + padding: 7px; + border-radius: var(--r-md); + border: 1px solid rgba(127, 127, 127, 0.25); +} + +.theme-preview-bar { height: 9px; border-radius: 2px; } +.theme-preview-row { flex: 1; display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; } +.theme-preview-row span { border-radius: 2px; } +.theme-preview-cta { width: 40%; height: 8px; border-radius: 2px; } +.theme-preview--contrast .theme-preview-row span, +.theme-preview--contrast .theme-preview-bar { outline: 1px solid #ffffff; } +.theme-preview--mica { background: linear-gradient(135deg, #9ec5ec, #c9b8ec) !important; } +.theme-preview--mica .theme-preview-bar, +.theme-preview--mica .theme-preview-row span { opacity: 0.8; } + +.theme-card-name { font-weight: 600; } +.theme-card-hint { color: var(--text-muted); font-size: 11.5px; } + +.accent-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } + +.accent-swatch { + width: 28px; + height: 28px; + border: 2px solid transparent; + border-radius: 50%; + cursor: pointer; +} + +.accent-swatch.active { border-color: var(--text-primary); box-shadow: 0 0 0 2px var(--bg-app) inset; } +.accent-swatch:disabled, .accent-custom:has(input:disabled) { opacity: 0.4; cursor: not-allowed; } + +.accent-swatch--auto { + width: auto; + padding: 0 12px; + border: var(--border-width) solid var(--border-strong); + border-radius: 14px; + background: transparent; + color: var(--text-secondary); + font-size: 12px; +} + +.accent-swatch--auto.active { border-color: var(--accent); color: var(--accent-hover); box-shadow: none; } + +.accent-custom { display: inline-flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 12px; cursor: pointer; } +.accent-custom input { width: 28px; height: 28px; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: pointer; } +.appearance-note { color: var(--text-muted); font-size: 12px; } + +.appearance-grid { display: flex; flex-wrap: wrap; gap: 8px 28px; } + +/* ---------- Updates ---------- */ + +.tab-count--badge { + display: inline-block; + min-width: 17px; + padding: 0 5px; + border-radius: 9px; + background: var(--accent); + color: var(--on-accent); + font-weight: 700; + text-align: center; +} + +.queue-item-kind { + display: block; + margin-top: 1px; + color: var(--text-muted); + font-weight: 400; +} + +.updates-layout { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 28px; +} + +.updates-main { flex: 999 1 520px; min-width: 0; display: flex; flex-direction: column; gap: 18px; } + +.updates-head { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } +.updates-head .page-title { flex: 1 1 200px; margin: 0; } +.updates-checked { color: var(--text-secondary); font-size: 12px; } + +.upd-group-head { display: flex; align-items: center; gap: 12px; padding-bottom: 6px; } + +.upd-group-title { + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.upd-group-title--pending { color: var(--accent-hover); } +.upd-group-line { flex: 1; height: 1px; background: var(--border); } + +.upd-list { + list-style: none; + margin-left: 7px; + border-left: 2px solid var(--border); +} + +.upd-row { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + padding: 9px 0 9px 22px; +} + +.upd-dot { + position: absolute; + left: -7px; + top: 50%; + width: 12px; + height: 12px; + margin-top: -6px; + border-radius: 50%; + box-shadow: 0 0 0 3px var(--bg-base); +} + +.upd-dot--pending { background: var(--accent); } +.upd-dot--ok { background: var(--ok); } +.upd-dot--fail { background: var(--danger); } + +.upd-row .app-cover-img--sm, +.upd-row .app-monogram--sm { width: 30px !important; height: 30px !important; object-fit: contain; flex: 0 0 auto; } + +.upd-text { flex: 1 1 260px; min-width: 0; } + +.upd-name { + padding: 0; + border: 0; + background: none; + color: var(--text-primary); + font-weight: 600; + cursor: pointer; +} + +.upd-name:disabled { cursor: default; } +.upd-name:not(:disabled):hover { color: var(--accent-hover); } +.upd-versions { margin-left: 8px; color: var(--text-secondary); } +.upd-note { color: var(--text-secondary); font-size: 12px; } +.upd-note--fail { color: color-mix(in srgb, var(--danger) 70%, var(--text-primary)); } +.upd-status { color: var(--accent-hover); font-size: 12px; font-weight: 600; } + +.upd-btn-update { + border-color: color-mix(in srgb, var(--ok) 60%, transparent); + background: color-mix(in srgb, var(--ok) 14%, transparent); + color: var(--text-primary); +} + +.upd-btn-update:hover { background: color-mix(in srgb, var(--ok) 26%, transparent); } + +.upd-btn-retry { + border-color: color-mix(in srgb, var(--danger) 60%, transparent); + background: color-mix(in srgb, var(--danger) 14%, transparent); + color: var(--text-primary); +} + +.upd-empty { padding: 6px 0 6px 2px; color: var(--text-secondary); } +.upd-clear { align-self: flex-start; } + +.updates-aside { + flex: 1 1 260px; + max-width: 320px; + display: flex; + flex-direction: column; + gap: 12px; + padding: 18px; + border: var(--border-width) solid var(--border); + border-radius: var(--r-lg); + background: var(--bg-surface); +} + +.updates-aside h3 { font-size: 13px; } +.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; } diff --git a/src/types.ts b/src/types.ts index 0a64988..9c364c7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -55,6 +55,41 @@ export interface QueueJob { detail?: string; errorCode?: InstallErrorCode; progress?: InstallProgress; + kind?: 'install' | 'upgrade' | 'rollback'; + fromVersion?: string; + toVersion?: string; + catalogId?: string; +} + +export interface UpdateInfo { + provider: string; + packageId: string; + name: string; + installed: string; + available: string; + catalogId?: string; +} + +export interface UpdateHistoryEntry { + id: string; + kind: 'upgrade' | 'rollback'; + provider: string; + packageId: string; + name: string; + catalogId?: string; + from?: string; + to?: string; + at: number; + ok: boolean; + message?: string; + errorCode?: InstallErrorCode; +} + +export interface UpdatesState { + items: UpdateInfo[]; + checkedAt?: number; + checking: boolean; + history: UpdateHistoryEntry[]; } export interface SearchResult { @@ -68,8 +103,17 @@ export interface AppSettings { quietInstall: boolean; remoteCatalogUrl?: string; language: 'ru' | 'en'; + theme: ThemeId; + accent?: string; + uiScale: number; + listView: ListView; + autoCheckUpdates: boolean; + autoInstallUpdates: boolean; } +export type ThemeId = 'classic' | 'light' | 'oled' | 'contrast' | 'mica'; +export type ListView = 'cards' | 'list' | 'table' | 'tiles'; + export interface MetaProgress { running: boolean; total: number; @@ -92,6 +136,7 @@ export type ViewMode = | 'recommended' | 'favorites' | 'installed' + | 'updates' | 'search' | 'settings';