From d6ca4c3816e176a5ca97f177b9c9f488c74564ad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 23:51:13 +0000 Subject: [PATCH 01/11] Add themes, custom accent, UI scale and four list views Five themes (classic, light, OLED, high contrast, Windows 11 with native Mica on 22H2+), a custom accent colour, interface zoom and a cards / list / table / tiles switcher above every list. Hardcoded colours in the stylesheet became tokens that each theme repaints; appearance values are validated in the main process before they are stored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LtWxNVyWyutDLyX8cWQFC --- CHANGELOG.md | 9 + electron/main.ts | 21 + electron/preload.ts | 2 + electron/providers/types.ts | 12 + electron/store/settings.ts | 44 +- src/App.tsx | 29 +- src/appearance.ts | 54 +++ src/components/AppAction.tsx | 69 ++++ src/components/AppRows.tsx | 114 ++++++ src/components/AppearanceSettings.tsx | 111 ++++++ src/components/CapsuleCard.tsx | 49 +-- src/components/PagedGrid.tsx | 65 ++- src/components/ViewSwitcher.tsx | 47 +++ src/i18n/translations.ts | 52 +++ src/index.css | 555 ++++++++++++++++++++++++-- src/types.ts | 7 + 16 files changed, 1143 insertions(+), 97 deletions(-) create mode 100644 src/appearance.ts create mode 100644 src/components/AppAction.tsx create mode 100644 src/components/AppRows.tsx create mode 100644 src/components/AppearanceSettings.tsx create mode 100644 src/components/ViewSwitcher.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 36bd67f..d505cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Не выпущено + +### Внешний вид +- Пять тем в «Расширенных → Внешний вид»: Классика, Светлая, 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..d24d1ba 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'; @@ -153,10 +154,28 @@ 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 sendToRenderer(channel: string, ...args: unknown[]): void { mainWindow?.webContents.send(channel, ...args); } @@ -253,10 +272,12 @@ 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 (partial.language && partial.language !== prev.language) { invalidateCatalog('index'); sendToRenderer('catalog:onDirty'); diff --git a/electron/preload.ts b/electron/preload.ts index a9a86bc..0916502 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -83,6 +83,7 @@ export interface GetBoxAPI { windowMaximize: () => Promise; windowClose: () => Promise; windowIsMaximized: () => Promise; + windowNativeMica: () => Promise; onWindowState: (cb: (state: { maximized: boolean }) => void) => () => void; } @@ -192,6 +193,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); diff --git a/electron/providers/types.ts b/electron/providers/types.ts index 02a518a..ddd5e9a 100644 --- a/electron/providers/types.ts +++ b/electron/providers/types.ts @@ -149,8 +149,20 @@ 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; } +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'; export function packageKey(provider: string, packageId: string): string { diff --git a/electron/store/settings.ts b/electron/store/settings.ts index 42fdfbf..4527a33 100644 --- a/electron/store/settings.ts +++ b/electron/store/settings.ts @@ -1,25 +1,47 @@ 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', +}; + +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 }; +} + 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/src/App.tsx b/src/App.tsx index f1fcd17..5a2f0bf 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 } from './types'; import { packageKey } from './types'; import { I18nProvider } from './i18n/I18nContext'; import { translations, type Locale } from './i18n/translations'; @@ -18,6 +18,9 @@ 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 { AppearanceSettings } from './components/AppearanceSettings'; +import { applyAppearance } from './appearance'; import type { MetaProgress } from './types'; import { isStoreApp } from './i18n/helpers'; @@ -36,6 +39,7 @@ 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); @@ -114,6 +118,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(); @@ -607,6 +626,7 @@ export default function App() { {view === 'settings' && settings && (
+

{t.settings.title}

{reason &&
{reason}
} @@ -106,6 +71,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/i18n/translations.ts b/src/i18n/translations.ts index e4e0f42..8fd826d 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -125,6 +125,32 @@ export const translations = { nothingFound: 'Ничего не найдено по фильтру', count: 'пакетов', }, + 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: 'Тихая установка', @@ -304,6 +330,32 @@ export const translations = { nothingFound: 'No packages match the filter', count: 'packages', }, + 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', diff --git a/src/index.css b/src/index.css index 797e599..97870d6 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,7 +497,7 @@ button, input, select { height: 38px; flex-shrink: 0; padding: 0 24px; - background: linear-gradient(180deg, #17222e, #121b25); + background: var(--bg-tabbar); border-bottom: 1px solid var(--border); } @@ -494,11 +515,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 +550,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 +591,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 +685,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 +823,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 +911,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 +919,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 +1094,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 +1115,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 +1210,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 +1515,7 @@ button, input, select { /* ---------- Settings ---------- */ -.settings-section { max-width: 620px; } +.settings-section { max-width: 880px; } .setting-row { display: flex; @@ -1598,3 +1618,476 @@ 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 { flex-direction: column; align-items: stretch; gap: 6px; padding: 7px 8px 8px; } +.capsule--tile .capsule-name { text-align: center; font-size: 12px; } +.capsule--tile .capsule-meta, +.capsule--tile .capsule-reason { display: none; } +.capsule--tile .capsule-cta { width: 100%; opacity: 0; transition: opacity 0.13s ease; } +.capsule--tile:hover .capsule-cta, +.capsule--tile:focus-within .capsule-cta, +.capsule--tile .capsule-cta.busy, +.capsule--tile .capsule-cta.failed { opacity: 1; } + +/* 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; } diff --git a/src/types.ts b/src/types.ts index 0a64988..d3469a7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,8 +68,15 @@ export interface AppSettings { quietInstall: boolean; remoteCatalogUrl?: string; language: 'ru' | 'en'; + theme: ThemeId; + accent?: string; + uiScale: number; + listView: ListView; } +export type ThemeId = 'classic' | 'light' | 'oled' | 'contrast' | 'mica'; +export type ListView = 'cards' | 'list' | 'table' | 'tiles'; + export interface MetaProgress { running: boolean; total: number; From 28f489007a027f9582ae0c2809a75ef1d0f26298 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:07:58 +0000 Subject: [PATCH 02/11] Add program updates with history and rollback New Updates tab lists what winget, Scoop and Chocolatey can upgrade, showing catalog apps under their own name and icon. Upgrades and rollbacks run through the install queue; each finished one lands in a per-day history, and the newest successful upgrade of a package can be rolled back to the previous version (winget --version, scoop reset, choco --allow-downgrade). Checks run at launch and every 6 hours, with optional background install. Output parsers are covered by smoke tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LtWxNVyWyutDLyX8cWQFC --- CHANGELOG.md | 6 + electron/main.ts | 48 +++++- electron/preload.ts | 20 ++- electron/providers/catalog.ts | 17 +++ electron/providers/chocolatey.ts | 24 ++- electron/providers/scoop.ts | 25 +++- electron/providers/types.ts | 48 ++++++ electron/providers/winget.ts | 28 +++- electron/queue/install-queue.ts | 51 +++++-- electron/store/settings.ts | 12 +- electron/updates/parse.ts | 85 +++++++++++ electron/updates/updates.ts | 140 ++++++++++++++++++ scripts/smoke.cjs | 61 ++++++++ src/App.tsx | 31 +++- src/components/QueuePanel.tsx | 14 +- src/components/TabBar.tsx | 6 +- src/components/UpdatesView.tsx | 243 +++++++++++++++++++++++++++++++ src/i18n/translations.ts | 52 +++++++ src/index.css | 135 +++++++++++++++++ src/types.ts | 38 +++++ 20 files changed, 1056 insertions(+), 28 deletions(-) create mode 100644 electron/updates/parse.ts create mode 100644 electron/updates/updates.ts create mode 100644 src/components/UpdatesView.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index d505cc9..30936cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Не выпущено +### Обновления программ +- Новая вкладка «Обновления»: что можно обновить через 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 готовых или любой через палитру); цвет текста на кнопках подбирается по яркости акцента. diff --git a/electron/main.ts b/electron/main.ts index d24d1ba..6de28a3 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -8,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'; @@ -15,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, @@ -176,6 +177,22 @@ function applyAppearance(): void { 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); } @@ -258,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 () => { @@ -278,6 +319,7 @@ function setupIpc(): void { 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'); @@ -399,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 0916502..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; @@ -133,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); @@ -209,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 ddd5e9a..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 { @@ -155,6 +199,10 @@ export interface AppSettings { /** 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; 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 4527a33..5dae8f4 100644 --- a/electron/store/settings.ts +++ b/electron/store/settings.ts @@ -10,6 +10,8 @@ const DEFAULTS: AppSettings = { accent: undefined, uiScale: 1, listView: 'cards', + autoCheckUpdates: true, + autoInstallUpdates: false, }; export const UI_SCALE_MIN = 0.9; @@ -31,7 +33,15 @@ function sanitize(s: AppSettings): AppSettings { 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 }; + 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 { 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/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 5a2f0bf..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, ListView } 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'; @@ -19,6 +19,7 @@ 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'; @@ -45,6 +46,7 @@ export default function App() { 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); @@ -228,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); @@ -281,6 +285,7 @@ export default function App() { cancelled = true; unsubDirty(); unsubQueue(); + unsubUpdates(); unsubToast(); unsubIndex(); unsubIndexDone(); @@ -510,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 ( @@ -714,7 +720,28 @@ export default function App() {
)} - {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' && (

diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 04163b6..3b566d3 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -89,8 +89,18 @@ function QueueJobRow({ return (
-
{job.name}
-
{t.queue.status[job.status]}
+
+ {job.name} + {(job.kind === 'upgrade' || job.kind === 'rollback') && ( + + {job.kind === 'rollback' ? t.updates.queueRollback : t.updates.queueLabel} + {job.fromVersion && job.toVersion ? ` ${job.fromVersion} → ${job.toVersion}` : ''} + + )} +
+
+ {job.status === 'running' && job.kind === 'upgrade' ? t.updates.updating : t.queue.status[job.status]} +
{friendly && job.status !== 'pending' && job.status !== 'running' && (
{friendly}
diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 7ce4f1c..0200457 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -5,6 +5,8 @@ export interface TabDescriptor { id: ViewMode; label: string; count?: number; + /** Count drawn as an accent badge: something waits for the user. */ + highlight?: boolean; } interface TabBarProps { @@ -26,7 +28,9 @@ export function TabBar({ tabs, active, onSelect }: TabBarProps) { onClick={() => onSelect(tab.id)} > {tab.label} - {tab.count !== undefined && tab.count > 0 && {tab.count}} + {tab.count !== undefined && tab.count > 0 && ( + {tab.count} + )} ))} 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/i18n/translations.ts b/src/i18n/translations.ts index 8fd826d..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,30 @@ 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: 'Тема', @@ -194,6 +219,7 @@ export const translations = { queueError: 'Ошибка очереди', favoriteError: 'Ошибка избранного', searchError: 'Ошибка поиска', + updatesError: 'Не удалось проверить обновления', bootstrapRetry: 'Настройка runtime повторена', bootstrapError: 'Ошибка bootstrap', profileError: 'Ошибка профиля', @@ -216,6 +242,7 @@ export const translations = { recommended: 'Recommended', favorites: 'Favorites', installed: 'Installed', + updates: 'Updates', categories: 'Categories', all: 'All apps', system: 'System', @@ -330,6 +357,30 @@ 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', @@ -399,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 97870d6..e6dbe90 100644 --- a/src/index.css +++ b/src/index.css @@ -497,13 +497,17 @@ button, input, select { height: 38px; flex-shrink: 0; padding: 0 24px; + 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); @@ -2091,3 +2095,134 @@ td.app-table-name span { overflow: hidden; text-overflow: ellipsis; } .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 d3469a7..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 { @@ -72,6 +107,8 @@ export interface AppSettings { accent?: string; uiScale: number; listView: ListView; + autoCheckUpdates: boolean; + autoInstallUpdates: boolean; } export type ThemeId = 'classic' | 'light' | 'oled' | 'contrast' | 'mica'; @@ -99,6 +136,7 @@ export type ViewMode = | 'recommended' | 'favorites' | 'installed' + | 'updates' | 'search' | 'settings'; From e862ab960d65c0382cb650aa3ab7b13668d70d4e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:49:09 +0000 Subject: [PATCH 03/11] Add GetBox 1.3 promo animatic promo13.html follows the 12-shot storyboard (45 s, 120 bpm) and renders at any height via ?h=, with a scene/timecode overlay for review (?debug=1). music13.py synthesises the track with both drops and the scene sound effects on the picture's beats. render.cjs passes QUERY, OUT_SUFFIX and CRF through for low-res drafts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LtWxNVyWyutDLyX8cWQFC --- promo/README.md | 9 +- promo/music13.py | 247 ++++++++++++++++++++++++ promo/promo13.html | 454 +++++++++++++++++++++++++++++++++++++++++++++ promo/render.cjs | 7 +- 4 files changed, 713 insertions(+), 4 deletions(-) create mode 100644 promo/music13.py create mode 100644 promo/promo13.html diff --git a/promo/README.md b/promo/README.md index 3ba51ad..92e511b 100644 --- a/promo/README.md +++ b/promo/README.md @@ -1,6 +1,7 @@ # Промо-ролики GetBox - `promo.html` — основной ролик: 40 с, 1920×1080, 30 fps. +- `promo13.html` — ролик о версии 1.3: 45 с, 120 BPM, 12 сцен по раскадровке. Размер задаётся параметром `h` (240…1080), `debug=1` выводит номер сцены и таймкод. - `shorts.html` — вертикальная версия для Shorts / Reels: 30 с, 1080×1920, 60 fps, монтаж по долям 128 BPM. Кадры интерфейса — настоящий renderer GetBox на тестовых данных из `data/`. @@ -13,6 +14,12 @@ 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 — черновик 240p для проверки (скриншоты 1.3 лежат в promo/shots/13/) +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 + # Shorts node promo/render.cjs video shorts.html python3 promo/music-shorts.py @@ -22,4 +29,4 @@ ffmpeg -i promo/shorts.mp4 -i promo/music-shorts.wav -c:v copy -af loudnorm=I=-1 Нужны 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), без сторонних сэмплов. diff --git a/promo/music13.py b/promo/music13.py new file mode 100644 index 0000000..e298332 --- /dev/null +++ b/promo/music13.py @@ -0,0 +1,247 @@ +"""Procedural 45 s track for the GetBox 1.3 promo (promo13.html): 120 bpm. + +Sections follow the storyboard: filtered intro with a pulse (0-10 s), +drop 1 at 0:10 under the theme / accent / views / updates scenes, a one-beat +stop for the rollback (0:29.5), a filtered breakdown for the suggestions +scene (0:32-0:36), drop 2 at 0:36 for the fan, and a ringing final chord. +Scene sound effects (click, dings, whooshes, reverse cymbal) sit on the +same beats as the picture. +""" +import os +import wave + +import numpy as np + +SR, BPM = 44100, 120 +B = 60 / BPM +BAR = 4 * B +DUR = 45.0 +N = int(SR * DUR) +t = np.arange(N) / SR +L = np.zeros(N) +R = np.zeros(N) +rng = np.random.default_rng(13) + +DROP1, STOP, BREAK, DROP2, END = 10.0, 29.5, 32.0, 36.0, 41.0 + + +def hz(n): + return 440 * 2 ** ((n - 69) / 12) + + +def lowpass(x, cutoff): + """One-pole low-pass; cutoff may be a scalar or a per-sample array.""" + 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(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 full_beat(at): + """Where the full groove plays: both drops, minus the rollback stop.""" + return (DROP1 <= at < BREAK and not (STOP <= at < STOP + B)) or (DROP2 <= at < END) + + +# Am – F – C – G, one chord per bar. +CHORDS = [[57, 60, 64], [53, 57, 60], [48, 52, 55], [55, 59, 62]] + +pump = np.ones(N) +for i in range(int(DUR / B)): + at = i * B + if full_beat(at): + s = int(at * SR) + e = min(s + int(B * SR), N) + x = np.arange(e - s) / SR + pump[s:e] = 1 - 0.6 * np.exp(-x * 14) + +# Pad: detuned saw stack; the filter opens through the intro and closes in the breakdown. +pad = np.zeros(N) +for bar in range(int(DUR / BAR) + 1): + chord = CHORDS[bar % 4] if bar * BAR < END else [57, 64, 69] + s, e = int(bar * BAR * SR), min(int((bar + 1) * BAR * SR + 0.3 * SR), N) + if s >= 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 — pops on the chips, riser into drop 2. +for k in range(5): + both(32.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 + + + +
    +
    + + +
    +
    +
    + +
    +
    1.3
    +
    + + +
    +
    +
    +
    Все программы —
    +
    в одном окне
    +
    118 в каталоге · + winget, Scoop, Chocolatey
    +
    +
    + + +
    +
    +
    + +
    +
    +
    +
    Один клик.
    +
    + SHA256 ✓ + тихая установка +
    +
    +
    + + +
    +
    +
    +
    Пять тем.
    +
    + + +
    +
    +
    +
    +
    Свой акцент.
    +
    +
    +
    + + +
    +
    +
    ▦☰≡▪▪
    +
    +
    +
    + + +
    +
    +
    3
    +
    +
    Обновления — в одном месте
    +
    winget · Scoop · Chocolatey
    +
    +
    + + +
    +
    +
    +
    +
    +
    Все — одной кнопкой.
    +
    +
    + + +
    +
    +
    +
    +
    + 1.141.0 + + 1.140.2 +
    +
    +
    +
    Не понравилось? Откат в один клик.
    +
    +
    + + +
    +
    +
    +
    Подскажет, что поставить ещё.
    +
    + + +
    +
    +
    +
    Твой Windows. Твой вид.
    +
    +
    + + +
    + +
    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); From 1283c8e26d89fb5c2cf79ac135dd8de1f2aae52f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:58:48 +0000 Subject: [PATCH 04/11] Tidy the tiles view and make the 1.3 promo punchier Tiles no longer reserve an empty row for the hidden install button: the action now floats over the bottom of the art on hover or focus. The promo gets whip transitions with motion blur, beat punches and shakes on the drops, word-by-word kinetic titles, a circular accent reveal from its swatch, a tape-rewind rollback and a 2x2 grid of list views that assembles from flying, labelled cells. capture13.cjs regenerates every screenshot the promo uses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LtWxNVyWyutDLyX8cWQFC --- promo/README.md | 3 +- promo/capture13.cjs | 100 +++++++ promo/promo13.html | 460 ++++++++++++++++++++------------- src/components/CapsuleCard.tsx | 4 +- src/index.css | 23 +- 5 files changed, 402 insertions(+), 188 deletions(-) create mode 100644 promo/capture13.cjs diff --git a/promo/README.md b/promo/README.md index 92e511b..d75d5ad 100644 --- a/promo/README.md +++ b/promo/README.md @@ -14,7 +14,8 @@ 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 — черновик 240p для проверки (скриншоты 1.3 лежат в promo/shots/13/) +# 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 diff --git a/promo/capture13.cjs b/promo/capture13.cjs new file mode 100644 index 0000000..29e8092 --- /dev/null +++ b/promo/capture13.cjs @@ -0,0 +1,100 @@ +// 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}`; }; +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: c.id === 'obsidian', + iconUrl: iconFor(c), pickTag: en ? p?.tagEn : p?.tag, pickWhy: en ? p?.whyEn : p?.why, version: installers[c.id]?.version }; }); + 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); +}); +async function shot(browser, base, name, { owned = [], lang = 'ru', queue = [], act, look = {} }) { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 }); + await page.addInitScript(({ d, lang, queue, look }) => { + 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: [] }), + }; + 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 }); + await page.goto(`${base}/dist/index.html`); + await page.waitForTimeout(1200); + if (act) await act(page); + await page.waitForTimeout(500); + 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); + const me = ['vscode', 'steam', 'firefox', '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); } }); + 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(); + } }); + await browser.close(); server.close(); +}); diff --git a/promo/promo13.html b/promo/promo13.html index 6a8dfcb..9fdfee6 100644 --- a/promo/promo13.html +++ b/promo/promo13.html @@ -4,17 +4,15 @@ GetBox 1.3 promo
    +
    -
    -
    +
    +
    +
    -
    -
    1.3
    +
    +
    1.3
    -
    -
    -
    -
    Все программы —
    -
    в одном окне
    -
    118 в каталоге · + winget, Scoop, Chocolatey
    +
    +
    +
    +
    +
    +
    0 программ в каталоге
    + winget · Scoop · Chocolatey
    -
    +
    +
    +
    -
    -
    +
    -
    Один клик.
    -
    - SHA256 ✓ - тихая установка +
    +
    + SHA256 ✓ + тихая установка + очередь
    @@ -91,33 +100,34 @@
    -
    Пять тем.
    +
    -
    +
    -
    Свой акцент.
    +
    Свой акцент.
    +
    -
    ▦☰≡▪▪
    -
    -
    +
    +
    +
    -
    +
    3
    -
    Обновления — в одном месте
    +
    winget · Scoop · Chocolatey
    @@ -127,24 +137,22 @@
    -
    -
    Все — одной кнопкой.
    -
    +
    -
    -
    - 1.141.0 - - 1.140.2 +
    +
    +
    + +
    -
    Не понравилось? Откат в один клик.
    +
    Не понравилось? Откат в один клик.
    @@ -152,32 +160,35 @@
    -
    Подскажет, что поставить ещё.
    +
    +
    -
    Твой Windows. Твой вид.
    +
    Твой Windows. Твой вид.
    -
    GETBOX1.3
    -
    Магазин программ для Windows 10 и 11
    +
    GETBOX1.3
    +
    Магазин программ для Windows 10 и 11
    Скачать бесплатно
    -
    github.com/IncludeSpecial/GetBox/releases
    +
    github.com/IncludeSpecial/GetBox/releases
    - +
    +
    diff --git a/src/components/CapsuleCard.tsx b/src/components/CapsuleCard.tsx index 4567256..ec34522 100644 --- a/src/components/CapsuleCard.tsx +++ b/src/components/CapsuleCard.tsx @@ -49,13 +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}
    }
    - + {!tile && }
    {reason &&
    {reason}
    } diff --git a/src/index.css b/src/index.css index e6dbe90..d7f1c73 100644 --- a/src/index.css +++ b/src/index.css @@ -1885,15 +1885,26 @@ button, input, select { .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 { flex-direction: column; align-items: stretch; gap: 6px; padding: 7px 8px 8px; } +.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; } -.capsule--tile .capsule-cta { width: 100%; opacity: 0; transition: opacity 0.13s ease; } -.capsule--tile:hover .capsule-cta, -.capsule--tile:focus-within .capsule-cta, -.capsule--tile .capsule-cta.busy, -.capsule--tile .capsule-cta.failed { opacity: 1; } + +/* 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%; } From cbc786e10933dca3568f7d910b8e575b3e734c35 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:06:19 +0000 Subject: [PATCH 05/11] Add a rapid-fire features montage to the 1.3 promo Eight more features get one cut each on the beat between the rollback and the suggestions scene: search across every source, categories, install sets, favourites, the package index, 125% scale, Russian / English and the winget fallback. The track grows to 53 s with a hit and a whoosh on every cut; capture13.cjs takes the new screens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LtWxNVyWyutDLyX8cWQFC --- promo/README.md | 2 +- promo/capture13.cjs | 43 ++++++++++++++++++++++--- promo/music13.py | 22 +++++++++---- promo/promo13.html | 76 +++++++++++++++++++++++++++++++++++++-------- 4 files changed, 118 insertions(+), 25 deletions(-) diff --git a/promo/README.md b/promo/README.md index d75d5ad..67663d0 100644 --- a/promo/README.md +++ b/promo/README.md @@ -1,7 +1,7 @@ # Промо-ролики GetBox - `promo.html` — основной ролик: 40 с, 1920×1080, 30 fps. -- `promo13.html` — ролик о версии 1.3: 45 с, 120 BPM, 12 сцен по раскадровке. Размер задаётся параметром `h` (240…1080), `debug=1` выводит номер сцены и таймкод. +- `promo13.html` — ролик о версии 1.3: 53 с, 120 BPM, 13 сцен (раскадровка + нарезка «Ещё фишки»). Размер задаётся параметром `h` (240…1080), `debug=1` выводит номер сцены и таймкод. - `shorts.html` — вертикальная версия для Shorts / Reels: 30 с, 1080×1920, 60 fps, монтаж по долям 128 BPM. Кадры интерфейса — настоящий renderer GetBox на тестовых данных из `data/`. diff --git a/promo/capture13.cjs b/promo/capture13.cjs index 29e8092..9b9df3c 100644 --- a/promo/capture13.cjs +++ b/promo/capture13.cjs @@ -10,11 +10,20 @@ const read = (f) => JSON.parse(fs.readFileSync(path.join(ROOT, 'data', f), 'utf- 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: c.id === 'obsidian', + 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 = [ @@ -30,9 +39,9 @@ const server = http.createServer((req, res) => { 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); }); -async function shot(browser, base, name, { owned = [], lang = 'ru', queue = [], act, look = {} }) { +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: 1 }); - await page.addInitScript(({ d, lang, queue, look }) => { + 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, @@ -53,13 +62,15 @@ async function shot(browser, base, name, { owned = [], lang = 'ru', queue = [], 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: [] }), + 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 }); + }, { 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); await page.screenshot({ path: path.join(OUT, `${name}.png`) }); @@ -93,6 +104,28 @@ server.listen(0, async () => { { 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(/Очередь/) }); 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(); } }); diff --git a/promo/music13.py b/promo/music13.py index e298332..80f7d46 100644 --- a/promo/music13.py +++ b/promo/music13.py @@ -2,8 +2,9 @@ Sections follow the storyboard: filtered intro with a pulse (0-10 s), drop 1 at 0:10 under the theme / accent / views / updates scenes, a one-beat -stop for the rollback (0:29.5), a filtered breakdown for the suggestions -scene (0:32-0:36), drop 2 at 0:36 for the fan, and a ringing final chord. +stop for the rollback (0:29.5), the rapid-fire features montage with a hit +on every cut (0:32-0:40), a filtered breakdown for the suggestions scene +(0:40-0:44), drop 2 at 0:44 for the fan, and a ringing final chord. Scene sound effects (click, dings, whooshes, reverse cymbal) sit on the same beats as the picture. """ @@ -15,14 +16,14 @@ SR, BPM = 44100, 120 B = 60 / BPM BAR = 4 * B -DUR = 45.0 +DUR = 53.0 N = int(SR * DUR) t = np.arange(N) / SR L = np.zeros(N) R = np.zeros(N) rng = np.random.default_rng(13) -DROP1, STOP, BREAK, DROP2, END = 10.0, 29.5, 32.0, 36.0, 41.0 +DROP1, STOP, FEATS, BREAK, DROP2, END = 10.0, 29.5, 32.0, 40.0, 44.0, 49.0 def hz(n): @@ -215,9 +216,18 @@ def whoosh(length=0.28): rev = crash()[: int(0.9 * SR)][::-1] both(STOP - 0.9, rev, 0.16) -# 10 — pops on the chips, riser into drop 2. +# 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(32.6 + k * B / 2, blip(1400, 800, 0.08, 40), 0.09, pan=-0.3 + 0.15 * k) + 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) diff --git a/promo/promo13.html b/promo/promo13.html index 9fdfee6..8d9f31b 100644 --- a/promo/promo13.html +++ b/promo/promo13.html @@ -156,7 +156,19 @@
    - + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    @@ -187,8 +199,8 @@
    + + diff --git a/promo/music-edit13.py b/promo/music-edit13.py new file mode 100644 index 0000000..2116a49 --- /dev/null +++ b/promo/music-edit13.py @@ -0,0 +1,211 @@ +"""Phonk-style placeholder track for the 1.3 edit (edit13.html): 130 bpm, A minor. + +Bars 1-2 filtered cowbell intro with a riser and a one-beat silence before +the drop; bars 3-6 drop (cowbell riff, distorted 808 with glides, hard kick, +clap on 2 and 4); bars 7-8 half-time break; bars 9-12 second drop with hat +rolls; then a final hit and the 808 ringing out. +""" +import os +import wave + +import numpy as np + +SR, BPM = 44100, 130 +B = 60 / BPM +BAR = 4 * B +DROP1, BREAK, DROP2, OUTRO = 2 * BAR, 6 * BAR, 8 * BAR, 12 * BAR +DUR = OUTRO + 2.6 * B * 2 +N = int(SR * DUR) +t = np.arange(N) / SR +L = np.zeros(N) +R = np.zeros(N) +rng = np.random.default_rng(7) + + +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 section(at): + if at < DROP1: + return 'intro' + if at < BREAK: + return 'drop' + if at < DROP2: + return 'break' + if at < OUTRO: + return 'drop2' + return 'outro' + + +def silent(at): + """The one-beat (or half-beat) gaps right before each drop and the outro hit.""" + return (DROP1 - B <= at < DROP1) or (DROP2 - B / 2 <= at < DROP2) or (OUTRO - B / 2 <= at < OUTRO) + + +# ---------- sounds ---------- +def cowbell(note, length=0.32): + tt = np.arange(int(length * SR)) / SR + f = hz(note) + sq = np.sign(np.sin(2 * np.pi * f * tt)) + 0.8 * np.sign(np.sin(2 * np.pi * f * 1.48 * tt)) + env = np.exp(-tt * 11) * (1 - np.exp(-tt * 900)) + return lowpass(sq * env, 3800) * 0.5 + + +def kick(): + tt = np.arange(int(0.35 * SR)) / SR + f = 45 + 160 * np.exp(-tt * 30) + return np.tanh(np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-tt * 7) * 2.5) + + +def clap(): + tt = np.arange(int(0.25 * SR)) / SR + env = sum(np.exp(-np.clip(tt - d, 0, None) * 45) * (tt >= 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(' Date: Tue, 13 Oct 2026 01:33:05 +0000 Subject: [PATCH 08/11] Add a corporate-style 1.3 promo corp13.html is a calm, light-theme explainer: problem, solution, four feature blocks with callouts on the real interface and a section progress bar, a summary grid and a call to action. music-corp13.py adds a soft piano and pad track; capture13.cjs takes the light-theme screens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LtWxNVyWyutDLyX8cWQFC --- promo/README.md | 6 + promo/capture13.cjs | 13 ++ promo/corp13.html | 310 ++++++++++++++++++++++++++++++++++++++++++ promo/music-corp13.py | 143 +++++++++++++++++++ 4 files changed, 472 insertions(+) create mode 100644 promo/corp13.html create mode 100644 promo/music-corp13.py diff --git a/promo/README.md b/promo/README.md index b7d3556..6fc0a30 100644 --- a/promo/README.md +++ b/promo/README.md @@ -3,6 +3,7 @@ - `promo.html` — основной ролик: 40 с, 1920×1080, 30 fps. - `promo13.html` — ролик о версии 1.3: 53 с, 120 BPM, 13 сцен (раскадровка + нарезка «Ещё фишки»). Размер задаётся параметром `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/`. @@ -27,6 +28,11 @@ 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 -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 -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 diff --git a/promo/capture13.cjs b/promo/capture13.cjs index eadcb00..f254514 100644 --- a/promo/capture13.cjs +++ b/promo/capture13.cjs @@ -126,6 +126,19 @@ server.listen(0, async () => { { 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(); } }); diff --git a/promo/corp13.html b/promo/corp13.html new file mode 100644 index 0000000..f343566 --- /dev/null +++ b/promo/corp13.html @@ -0,0 +1,310 @@ + + + + +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/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(' Date: Tue, 13 Oct 2026 01:36:00 +0000 Subject: [PATCH 09/11] Drop the fixed catalog size from the promos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog keeps growing and winget, Scoop and Chocolatey add thousands more packages, so the videos no longer count to 118: they say «thousands of programs» and describe a growing, curated catalog instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LtWxNVyWyutDLyX8cWQFC --- promo/corp13.html | 4 ++-- promo/promo13.html | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/promo/corp13.html b/promo/corp13.html index f343566..d7efd3b 100644 --- a/promo/corp13.html +++ b/promo/corp13.html @@ -172,7 +172,7 @@ // Pillars: [id, start, kicker, title, body, bullets, shot timeline, side, callouts] const P0 = 15.6, PLEN = 6; const PILLARS = [ - { kicker: '01 · КАТАЛОГ', title: 'Всё в одном каталоге', bullets: ['118 проверенных программ', 'Поиск сразу в winget, Scoop и Chocolatey', 'Подборки и наборы в один клик'], + { kicker: '01 · КАТАЛОГ', title: 'Всё в одном каталоге', bullets: ['Проверенный каталог, который растёт', 'Тысячи пакетов winget, Scoop и Chocolatey', 'Подборки и наборы в один клик'], shots: [['L-search', FULL, R(232, 90, 1000, 562)], ['L-catalog', FULL, R(232, 110, 1000, 562)]], side: 'right', callouts: [[0.9, 2.9, R(232, 215, 1190, 230), 'Один запрос — все источники'], [3.4, 5.6, R(1282, 178, 135, 32), '4 вида списка']] }, { kicker: '02 · УСТАНОВКА', title: 'Безопасно и без мастеров', bullets: ['Проверка SHA256 из манифестов winget', 'Тихая установка в фоне', 'Очередь с прогрессом и отменой'], @@ -203,7 +203,7 @@ }).join(''); $('tiles8').innerHTML = [ - ['grid', 'Каталог', '118 программ и наборы'], ['search', 'Поиск', 'каталог · winget · Scoop · Chocolatey'], + ['grid', 'Каталог', 'проверенные программы и наборы'], ['search', 'Поиск', 'каталог · winget · Scoop · Chocolatey'], ['shield', 'Безопасность', 'проверка SHA256 перед запуском'], ['refresh', 'Обновления', 'история и откат'], ['palette', 'Оформление', '5 тем, акцент, масштаб'], ['globe', 'Языки', 'русский и English'], ].map(([i, b, s], k) => `
    ${svg(i)}
    ${b}${s}
    `).join(''); diff --git a/promo/promo13.html b/promo/promo13.html index 86e9bfe..49f91ec 100644 --- a/promo/promo13.html +++ b/promo/promo13.html @@ -74,7 +74,7 @@
    -
    0 программ в каталоге
    + winget · Scoop · Chocolatey
    +
    Тысячи программ
    каталог GetBox + winget · Scoop · Chocolatey
    @@ -369,7 +369,6 @@ words($('t2a'), 'Все программы —', lt, 0.35); words($('t2b'), 'в одном окне', lt, 0.85); rise($('t2c'), lt, 1.3, 0.3, 30); - $('n2').textContent = Math.round(118 * outCubic(seg(lt, 1.3, 2.3))); } // 03 — punch into the hero button, click, the queue slides in From 14e101726338fde8ccb7d4f80c5bf6b0184f1051 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:54:59 +0000 Subject: [PATCH 10/11] Rebuild both 1.3 promos around measured interface elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capture13.cjs now records the rects of key elements on every screenshot (marks.json), and director.js frames and highlights only those: the camera shows the full window, eases onto an element, holds, and only then dims the rest and labels it. Titles live outside the window, so nothing overlaps the interface. The regular promo is rewritten on this engine and kept in time with music13.wav; the corporate one uses it for its four feature blocks. Firefox stays uninstalled in the captures so the hero reads «Установить». Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LtWxNVyWyutDLyX8cWQFC --- promo/README.md | 3 +- promo/capture13.cjs | 36 ++- promo/corp13.html | 90 +++--- promo/director.js | 88 ++++++ promo/promo13.html | 747 +++++++++++++------------------------------- 5 files changed, 378 insertions(+), 586 deletions(-) create mode 100644 promo/director.js diff --git a/promo/README.md b/promo/README.md index 6fc0a30..eae96e3 100644 --- a/promo/README.md +++ b/promo/README.md @@ -1,7 +1,8 @@ # Промо-ролики GetBox - `promo.html` — основной ролик: 40 с, 1920×1080, 30 fps. -- `promo13.html` — ролик о версии 1.3: 53 с, 120 BPM, 13 сцен (раскадровка + нарезка «Ещё фишки»). Размер задаётся параметром `h` (240…1080), `debug=1` выводит номер сцены и таймкод. +- `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. diff --git a/promo/capture13.cjs b/promo/capture13.cjs index f254514..59a7835 100644 --- a/promo/capture13.cjs +++ b/promo/capture13.cjs @@ -39,6 +39,37 @@ const server = http.createServer((req, res) => { 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 }) => { @@ -73,6 +104,7 @@ async function shot(browser, base, name, { owned = [], lang = 'ru', queue = [], 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); @@ -80,7 +112,8 @@ async function shot(browser, base, name, { owned = [], lang = 'ru', queue = [], server.listen(0, async () => { const base = `http://127.0.0.1:${server.address().port}`; const browser = await chromium.launch(launchOpts); - const me = ['vscode', 'steam', 'firefox', 'git']; + // 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(); @@ -142,5 +175,6 @@ server.listen(0, async () => { 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 index d7efd3b..7bb7c4e 100644 --- a/promo/corp13.html +++ b/promo/corp13.html @@ -21,9 +21,11 @@ box-shadow: 0 50px 110px rgba(16, 36, 70, .18), 0 0 0 1px rgba(14, 26, 43, .07); } .cam { position: absolute; left: 0; top: 0; width: 1440px; height: 900px; transform-origin: 0 0; } .cam img { position: absolute; left: 0; top: 0; width: 1440px; height: 900px; } - .callout { position: absolute; border-radius: 10px; border: 3px solid var(--accent); box-shadow: 0 0 0 6px rgba(26,115,200,.15); } - .tag { position: absolute; padding: 10px 18px; border-radius: 10px; background: var(--accent); color: #fff; font-size: 26px; font-weight: 700; - white-space: nowrap; box-shadow: 0 12px 30px rgba(26,115,200,.3); } + /* Highlight: everything around the element is dimmed; a short label sits next to it. */ + .hl-box { position: absolute; border-radius: 8px; opacity: 0; pointer-events: none; + box-shadow: 0 0 0 2px var(--accent), 0 0 0 9999px rgba(14, 26, 43, .28); } + .hl-tag { position: absolute; left: 0; top: 0; padding: 9px 16px; border-radius: 8px; background: var(--ink); color: #fff; + font-size: 22px; font-weight: 600; white-space: nowrap; opacity: 0; box-shadow: 0 10px 24px rgba(14,26,43,.25); } .kicker { font: 700 26px 'DejaVu Sans Mono', monospace; letter-spacing: .14em; color: var(--accent); } .title { font-size: 66px; font-weight: 800; letter-spacing: -.02em; line-height: 1.08; } @@ -104,6 +106,7 @@
    + From 054d13ec724177e98d338f89c345f9f3a408fd30 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 02:02:13 +0000 Subject: [PATCH 11/11] Pin promo audio to 48 kHz after loudnorm loudnorm resamples internally and left the AAC track at 96 kHz, which many players silently skip. The mux commands now pass -ar 48000. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LtWxNVyWyutDLyX8cWQFC --- promo/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/promo/README.md b/promo/README.md index eae96e3..397ad39 100644 --- a/promo/README.md +++ b/promo/README.md @@ -27,20 +27,22 @@ ffmpeg -i promo/promo13-240p.mp4 -i promo/music13.wav -c:v copy -c:a aac -b:a 96 # 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 -c:a aac -b:a 128k -shortest promo/GetBox-1.3-edit-360p.mp4 +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 -c:a aac -b:a 128k -shortest promo/GetBox-1.3-corporate-360p.mp4 +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`, `music13.py` — синтезированная музыка (96, 128 и 120 BPM, Am–F–C–G), без сторонних сэмплов. + +`loudnorm` внутри работает на 192 кГц и без `-ar 48000` отдаёт AAC 96 кГц — многие плееры (браузеры, мессенджеры) такой звук молча не воспроизводят.