/
mentoster
/
vk-stilus
Обзор
Документация
Войти
/
mentoster
/
vk-stilus
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
3
CI/CD
Аналитика
Безопасность
master
src/background/command-controller.ts
212 строк
8 KB
mentoster
refactor(background): type privileged lifecycle owners
20 июл 2026, 22:02
20 июл 2026, 22:02
7c5dffb
Код
Авторство
О чём код?
import { verifyInvariant } from '../core/invariant.js'; import { err, ok } from '../core/result.js'; import type { BackgroundBrowserApi, BackgroundDisposer, BackgroundResult, BackgroundTab, ExtensionCommand, MediaCommand, PageActionTarget, TabActionOwner, } from './background-types.js'; import { PAGE_ACTION_IDS } from './page-actions.js'; const COMMAND_CONTEXT = Object.freeze({ owner: 'CommandController' }); const MEDIA_COMMANDS = Object.freeze(new Set<MediaCommand>(['pause', 'prev', 'next', 'stop'])); /** @assertion-exception Pure command error factory owns no lifecycle state. */ function commandError(code: string, message: string) { return Object.freeze({ code, message }); } /** @assertion-exception Pure media-command guard owns no lifecycle state. */ function isMediaCommand(command: unknown): command is MediaCommand { return typeof command === 'string' && MEDIA_COMMANDS.has(command as MediaCommand); } /** @assertion-exception Pure extension-command guard owns no lifecycle state. */ function isExtensionCommand(command: unknown): command is ExtensionCommand { return command === 'eye' || isMediaCommand(command); } /** @assertion-exception Pure tab-target normalization owns no lifecycle state. */ function captureTarget(tab: BackgroundTab): PageActionTarget { const tabId = Number(tab.id); return Number.isInteger(tabId) ? Object.freeze({ tabId }) : Object.freeze({ scope: 'first' as const }); } /** @assertion-exception Pure tab normalization owns no lifecycle state. */ function backgroundTab(value: unknown): BackgroundTab { return value !== null && typeof value === 'object' && !Array.isArray(value) ? Object.freeze(Object.fromEntries(Object.entries(value))) : Object.freeze({}); } /** * @classdesc Owns finite extension command dispatch and the active-tab capture flow. * @responsibility Register one command listener and route five fixed command IDs to reviewed page actions. * @nonresponsibility Accept arbitrary command strings, choose mode, persist settings, or render icons. * @trustBoundary Validates command/tab input and checked permission/capture/script Results. * @lifecycle Initialize once, handle commands while active, dispose one listener. * @invariant Command set remains frozen and at most one listener is registered. * @resourceBudget One listener, one permission request, one capture, and one fixed page action per command. * @sideEffects Requests activeTab permission, captures a visible tab, and invokes reviewed page actions. */ export class CommandController { browserApi: BackgroundBrowserApi; tabController: TabActionOwner; disposer: BackgroundDisposer | null; lastError: unknown; constructor( options: Readonly<{ browserApi: BackgroundBrowserApi; tabController: TabActionOwner; }>, ) { this.browserApi = options.browserApi; this.tabController = options.tabController; this.disposer = null; this.lastError = null; const listenerApi = verifyInvariant( typeof this.browserApi.addCommandListener === 'function', 'command.construct.listener-api', COMMAND_CONTEXT, ); const tabApi = verifyInvariant( typeof this.tabController.executePageAction === 'function', 'command.construct.tab-api', COMMAND_CONTEXT, ); if (!listenerApi.ok || !tabApi.ok) throw new TypeError('CommandController unavailable'); } async initialize(): Promise<BackgroundResult<Readonly<{ state: 'ready' }>>> { if (this.disposer) { return err(commandError('COMMAND_ALREADY_INITIALIZED', 'command listener exists')); } const registered = await this.browserApi.addCommandListener(this.handleCommandListener.bind(this)); if (!registered.ok) return registered; const disposerValid = verifyInvariant( typeof registered.value === 'function', 'command.initialize.disposer', COMMAND_CONTEXT, ); const singleListener = verifyInvariant( this.disposer === null, 'command.initialize.single-listener', COMMAND_CONTEXT, ); if (!disposerValid.ok) return err(disposerValid.error); if (!singleListener.ok) return err(singleListener.error); this.disposer = registered.value; return ok(Object.freeze({ state: 'ready' as const })); } /** @assertion-exception Browser listener delegates to one checked async command handler. */ handleCommandListener(command: unknown, tab: unknown): void { void this.handleCommandEvent(command, tab); } /** @assertion-exception Browser command wrapper stores one checked diagnostic. */ async handleCommandEvent(command: unknown, tab: unknown): Promise<void> { const result = await this.handleCommand(command, backgroundTab(tab)); this.lastError = result.ok ? null : result.error; } /** @assertion-exception Finite command dispatcher delegates to reviewed handlers only. */ handleCommand( command: unknown, tab: BackgroundTab, ): Promise<BackgroundResult<Readonly<Record<string, unknown>>>> { if (!isExtensionCommand(command)) { return Promise.resolve(err(commandError('COMMAND_DENIED', 'command is not allowlisted'))); } return command === 'eye' ? this.handleEyeCommand(tab) : this.handleMediaCommand(command); } handleMediaCommand( command: MediaCommand, ): Promise<BackgroundResult<Readonly<Record<string, unknown>>>> { const allowlisted = verifyInvariant( MEDIA_COMMANDS.has(command), 'command.media.allowlisted', COMMAND_CONTEXT, ); const registryStable = verifyInvariant( MEDIA_COMMANDS.size === 4, 'command.media.registry-stable', COMMAND_CONTEXT, ); if (!allowlisted.ok) return Promise.resolve(err(allowlisted.error)); if (!registryStable.ok) return Promise.resolve(err(registryStable.error)); return this.tabController.executePageAction(PAGE_ACTION_IDS.COMMAND, command, { scope: 'first', }); } /** @assertion-exception Checked permission boundary owns no reusable mutable state. */ async requestEyePermission(): Promise<BackgroundResult<true>> { const permission = await this.browserApi.requestPermission({ permissions: ['activeTab'] }); if (!permission.ok) return permission; return permission.value === true ? ok(true) : err(commandError('COMMAND_PERMISSION_DENIED', 'activeTab permission was denied')); } /** @assertion-exception Checked capture boundary owns no reusable mutable state. */ async captureEyeImage(tab: BackgroundTab): Promise<BackgroundResult<string>> { const windowId = Number.isInteger(tab.windowId) ? Number(tab.windowId) : 0; const capture = await this.browserApi.captureVisibleTab(windowId, { format: 'png' }); if (!capture.ok) return capture; return capture.value.length > 0 ? ok(capture.value) : err(commandError('COMMAND_CAPTURE_INVALID', 'tab capture returned invalid data')); } async handleEyeCommand( tab: BackgroundTab, ): Promise<BackgroundResult<Readonly<Record<string, unknown>>>> { const permission = await this.requestEyePermission(); if (!permission.ok) return permission; const capture = await this.captureEyeImage(tab); if (!capture.ok) return capture; const capturePresent = verifyInvariant( capture.value.startsWith('data:'), 'command.eye.capture-data', COMMAND_CONTEXT, ); const target = captureTarget(tab); const targetIsValid = 'tabId' in target || target.scope === 'first'; const targetValid = verifyInvariant( targetIsValid, 'command.eye.target', COMMAND_CONTEXT, ); if (!capturePresent.ok) return err(capturePresent.error); if (!targetValid.ok) return err(targetValid.error); return this.tabController.executePageAction(PAGE_ACTION_IDS.EYE, capture.value, target); } async dispose(): Promise<BackgroundResult<Readonly<{ state: 'disposed' }>>> { this.disposer?.(); this.disposer = null; const listenerReleased = verifyInvariant( this.disposer === null, 'command.dispose.listener-released', COMMAND_CONTEXT, ); const commandsStable = verifyInvariant( MEDIA_COMMANDS.size === 4, 'command.dispose.commands-stable', COMMAND_CONTEXT, ); if (!listenerReleased.ok) return err(listenerReleased.error); return commandsStable.ok ? ok(Object.freeze({ state: 'disposed' as const })) : err(commandsStable.error); } }