feat(01-09): GREEN — toolbar onClicked + badge state machine + onStartup notification + SAVE-only popup

Plan 01-09 Task 4 GREEN — flips all 13 Task 3 RED tests to GREEN:

src/background/index.ts:
  • Badge palette + notification id prefix constants (SCREAMING_SNAKE).
  • setBadgeState(state) helper: 3-state machine REC/OFF/ERROR with
    deterministic setBadgeText + setBadgeBackgroundColor + setTitle.
    Each chrome call wrapped in try/catch (defense in depth).
  • setIdleMode / setRecordingMode / setErrorMode helpers — drive the
    setPopup dance: '' in OFF (so onClicked fires), html path in REC/
    ERROR (so popup opens for SAVE).
  • startVideoCapture wires setRecordingMode on success, setErrorMode
    in catch.
  • chrome.action.onClicked.addListener — direct toolbar-to-picker flow
    (no popup needed for start). isRecording guard prevents double-start.
  • chrome.runtime.onStartup.addListener — fires once per browser
    session; creates mokosh-startup- notification inviting recording.
  • chrome.notifications.onClicked.addListener — T-1-09-01 spoofing
    mitigation via 'mokosh-' prefix gate; clears notification + invokes
    startVideoCapture (notification click is a valid activation gesture).
  • RECORDING_ERROR onMessage branch — setErrorMode + creates a
    mokosh-recovery- notification inviting the operator to restart.
  • initialize() calls setIdleMode at SW boot — ensures fresh OFF state
    on every (re-)spawn including Chrome's idle-eviction respawn.
  • All new listener registrations wrapped in try/catch so unit-test
    chrome stubs that don't define action/notifications/onStartup don't
    crash SW load (preserves the 5 pre-existing request-id-protocol +
    1 port-lifecycle-continuous tests as GREEN).

src/popup/index.ts:
  • Removed checkPermissions + requestPermissions functions entirely
    (no more REQUEST_PERMISSIONS round-trip on popup open).
  • popupState defaults isRecording=true, hasPermissions=true under
    SAVE-only charter — the popup ONLY opens when recording is active
    (REC/ERROR setPopup html path), so SAVE button is always enabled.
  • init() calls updateUI() directly (no async permission probe).
  • Empty-state copy updated: 'Откройте запись через иконку расширения'
    (Open recording via the extension icon — points operator back to
    the toolbar for starting a new session).
  • saveArchive() simplified: no permission re-check.

manifest.json:
  • Added 'notifications' to permissions array (preserves all existing).
  • default_popup retained — popup still opens in REC/ERROR modes.

smoke.sh (W-04 5-sub-step update):
  • SHARE_TARGET='Entire screen' (was 'Mokosh Smoke Test').
  • Added 14-line locale-fallback comment block citing Chromium
    generated_resources.grd as authoritative source + 4 known locale
    strings + KEEP_PROFILE=1 fallback path.
  • <title> changed to 'Mokosh Smoke Test — monitor mode' to keep tab
    title distinct from the screen-source string.
  • <ol> instruction updated: picker auto-accepts entire screen, not
    the tab. Body intro paragraph also updated.
  • T+/wall timer overlay (commit 923aaca) preserved — no behavioral
    change to polling/Downloads-snapshot/ffprobe-gate logic.

Tests: 13/13 new GREEN; full suite 18 files / 81 tests / all GREEN.
tsc --noEmit exit 0. npm run build exit 0; dist/manifest.json has
'notifications' permission. Tier-1 SW-bundle-import gate (Layer 1 + 2)
remains GREEN.
This commit is contained in:
2026-05-17 15:46:25 +02:00
parent 2d7ff7d4e3
commit 06dee246c9
4 changed files with 219 additions and 70 deletions

View File

@@ -9,7 +9,8 @@
"downloads", "downloads",
"scripting", "scripting",
"storage", "storage",
"offscreen" "offscreen",
"notifications"
], ],
"host_permissions": [ "host_permissions": [
"<all_urls>" "<all_urls>"

View File

@@ -41,7 +41,25 @@ DOWNLOADS_DIR="${HOME}/Downloads"
FIXTURE_DEST="${REPO_DIR}/tests/fixtures/last_30sec.webm" FIXTURE_DEST="${REPO_DIR}/tests/fixtures/last_30sec.webm"
WEBM_TMP="/tmp/mokosh-last_30sec.webm" WEBM_TMP="/tmp/mokosh-last_30sec.webm"
CHROME_LOG="/tmp/mokosh-chrome.log" CHROME_LOG="/tmp/mokosh-chrome.log"
SHARE_TARGET="Mokosh Smoke Test" # Plan 01-09 D-15-display-surface: SHARE_TARGET must match the OS-locale-
# specific name Chrome's picker uses for entire-screen selection. Known
# strings (Chrome stable, observed at plan-write time):
# English: "Entire screen"
# Russian: "Весь экран"
# German: "Gesamter Bildschirm"
# French: "Ecran entier"
# Authoritative source: Chromium's grit resource definitions in
# chrome/app/generated_resources.grd (per-locale .xtb translations under
# chrome/app/resources/). The IDS_DESKTOP_MEDIA_PICKER_SOURCE_TYPE_SCREEN
# identifier lives in the .grd, NOT in the .cc — per-locale translations
# drift across Chrome major versions, so the only reliable check is to
# run the picker once and inspect the source label in Chrome's UI.
# If --auto-select-desktop-capture-source="${SHARE_TARGET}" fails to
# auto-accept on the operator's Chrome locale, the operator picks the
# screen manually one time; KEEP_PROFILE=1 on subsequent runs carries
# the picker's last-pick memory across re-runs, sidestepping the
# auto-select string altogether.
SHARE_TARGET="Entire screen"
CHROME_BIN="${CHROME_BIN:-/usr/bin/google-chrome-stable}" CHROME_BIN="${CHROME_BIN:-/usr/bin/google-chrome-stable}"
KEEP_PROFILE="${KEEP_PROFILE:-0}" KEEP_PROFILE="${KEEP_PROFILE:-0}"
POLL_TIMEOUT="${POLL_TIMEOUT:-900}" POLL_TIMEOUT="${POLL_TIMEOUT:-900}"
@@ -104,7 +122,7 @@ fi
# --- compose the smoke tab data URL --- # --- compose the smoke tab data URL ---
read -r -d '' SMOKE_HTML <<'EOF' || true read -r -d '' SMOKE_HTML <<'EOF' || true
<title>Mokosh Smoke Test</title> <title>Mokosh Smoke Test — monitor mode</title>
<style> <style>
body{font-family:sans-serif;background:#222;color:#eee;padding:40px;line-height:1.5} body{font-family:sans-serif;background:#222;color:#eee;padding:40px;line-height:1.5}
code{background:#444;padding:2px 6px;border-radius:3px} code{background:#444;padding:2px 6px;border-radius:3px}
@@ -128,12 +146,12 @@ ol li{margin:6px 0}
<div><span class="lbl">wall</span> <span class="val" id="t-wall">--:--:--</span></div> <div><span class="lbl">wall</span> <span class="val" id="t-wall">--:--:--</span></div>
</div> </div>
<h1>🧵 Mokosh Smoke Test</h1> <h1>🧵 Mokosh Smoke Test</h1>
<p>This tab is the share-screen target. The picker auto-accepts because the title matches <code>--auto-select-desktop-capture-source</code>.</p> <p>Plan 01-09: this tab is <em>informational</em>; the picker shares the whole monitor (D-15-display-surface charter). The picker auto-accepts the entire-screen source matching <code>--auto-select-desktop-capture-source="Entire screen"</code> (locale-specific — see SHARE_TARGET comment block in smoke.sh).</p>
<h2>Steps:</h2> <h2>Steps:</h2>
<ol> <ol>
<li><strong class="flash">First time only:</strong> Go to <code>chrome://extensions</code> → toggle <strong>Developer mode</strong> ON → <strong>Load unpacked</strong> → select <code>/home/parf/projects/work/repremium/dist</code>.<br>(Set <code>KEEP_PROFILE=1</code> when re-running this script to skip the reload.)</li> <li><strong class="flash">First time only:</strong> Go to <code>chrome://extensions</code> → toggle <strong>Developer mode</strong> ON → <strong>Load unpacked</strong> → select <code>/home/parf/projects/work/repremium/dist</code>.<br>(Set <code>KEEP_PROFILE=1</code> when re-running this script to skip the reload.)</li>
<li>Click the <strong>AI Call Recorder</strong> toolbar icon (or puzzle-piece menu).</li> <li>Click the <strong>AI Call Recorder</strong> toolbar icon (or puzzle-piece menu).</li>
<li>The picker auto-accepts <em>this tab</em>. Confirm Chrome's "Sharing your screen" indicator appears.</li> <li>The picker auto-accepts <em>the entire screen</em> (not this tab — the new Plan 01-09 D-15-display-surface charter constrains to monitor mode). Confirm Chrome's "Sharing your screen" indicator appears.</li>
<li>Wait <strong>≥ 35 seconds</strong>. <em>Note the timer value in the corner</em>. Move the mouse around or scroll this page so vp9 has frame deltas.</li> <li>Wait <strong>≥ 35 seconds</strong>. <em>Note the timer value in the corner</em>. Move the mouse around or scroll this page so vp9 has frame deltas.</li>
<li>Click the toolbar icon again → click <strong>Сохранить отчёт об ошибке</strong>. <em>Note T+ and wall at the moment you click — compare to the LAST visible timer values in the saved video. Gap = operator-visible "stale" window.</em></li> <li>Click the toolbar icon again → click <strong>Сохранить отчёт об ошибке</strong>. <em>Note T+ and wall at the moment you click — compare to the LAST visible timer values in the saved video. Gap = operator-visible "stale" window.</em></li>
</ol> </ol>

View File

@@ -38,6 +38,74 @@ let isRecording = false;
let offscreenCreated = false; let offscreenCreated = false;
let lastScreenshotTime = 0; let lastScreenshotTime = 0;
let cachedScreenshot: Blob | null = null; let cachedScreenshot: Blob | null = null;
// ─── Plan 01-09 badge palette + notification constants ───────────────
// Project naming standard: SCREAMING_SNAKE for true constants. These
// drive the operator-facing badge state machine + notification flow.
const BADGE_REC_COLOR = '#00C853'; // material green
const BADGE_OFF_COLOR = '#D32F2F'; // material red
const BADGE_ERROR_COLOR = '#F9A825'; // material amber-700
const BADGE_REC_TEXT = 'REC';
const BADGE_OFF_TEXT = '';
const BADGE_ERROR_TEXT = 'ERR';
const BADGE_REC_TITLE = 'Recording — last 30 s buffered. Click to save.';
const BADGE_OFF_TITLE = 'Not recording. Click to start.';
const BADGE_ERROR_TITLE = 'Recording error. Click to try again.';
const NOTIFICATION_ICON_PATH = 'icons/icon128.png';
const NOTIFICATION_STARTUP_PREFIX = 'mokosh-startup-';
const NOTIFICATION_RECOVERY_PREFIX = 'mokosh-recovery-';
const POPUP_HTML_PATH = 'src/popup/index.html';
// ─── Plan 01-09 badge state machine + mode helpers ───────────────────
// 3-state machine: REC (during recording), OFF (idle), ERROR (after
// RECORDING_ERROR). Each setBadgeState call is best-effort: chrome.action
// methods may be undefined in unit-test contexts that did not stub the
// whole surface — wrap each call in try/catch (defense in depth).
type BadgeState = 'REC' | 'OFF' | 'ERROR';
function setBadgeState(state: BadgeState): void {
let text: string;
let color: string;
let title: string;
if (state === 'REC') {
text = BADGE_REC_TEXT;
color = BADGE_REC_COLOR;
title = BADGE_REC_TITLE;
} else if (state === 'OFF') {
text = BADGE_OFF_TEXT;
color = BADGE_OFF_COLOR;
title = BADGE_OFF_TITLE;
} else {
text = BADGE_ERROR_TEXT;
color = BADGE_ERROR_COLOR;
title = BADGE_ERROR_TITLE;
}
try { chrome.action.setBadgeText({ text }); } catch (e) { logger.warn('setBadgeText failed:', e); }
try { chrome.action.setBadgeBackgroundColor({ color }); } catch (e) { logger.warn('setBadgeBackgroundColor failed:', e); }
try { chrome.action.setTitle({ title }); } catch (e) { logger.warn('setTitle failed:', e); }
}
// In idle mode the popup is empty ('' string) — this is what makes
// chrome.action.onClicked fire on toolbar clicks (per the MV3 docs:
// the click event only fires when no default_popup is set). In REC/
// ERROR modes the popup html is set back so toolbar clicks open the
// SAVE-only popup.
function setIdleMode(): void {
try { chrome.action.setPopup({ popup: '' }); } catch (e) { logger.warn('setPopup OFF failed:', e); }
setBadgeState('OFF');
}
function setRecordingMode(): void {
try { chrome.action.setPopup({ popup: POPUP_HTML_PATH }); } catch (e) { logger.warn('setPopup REC failed:', e); }
setBadgeState('REC');
}
function setErrorMode(): void {
// ERROR mode keeps the popup accessible so the operator can still
// attempt SAVE if any data is buffered + see the error copy.
try { chrome.action.setPopup({ popup: POPUP_HTML_PATH }); } catch (e) { logger.warn('setPopup ERROR failed:', e); }
setBadgeState('ERROR');
}
// Port from offscreen (D-17). Re-assigned on every (re)connect. // Port from offscreen (D-17). Re-assigned on every (re)connect.
let videoPort: chrome.runtime.Port | null = null; let videoPort: chrome.runtime.Port | null = null;
// Offscreen readiness Promise — set up at module load, resolved on first // Offscreen readiness Promise — set up at module load, resolved on first
@@ -342,11 +410,17 @@ async function startVideoCapture() {
} }
isRecording = true; isRecording = true;
// Plan 01-09: transition badge + popup mode to REC so the toolbar
// click opens the SAVE-only popup (not re-fires onClicked).
setRecordingMode();
logger.log('Video recording started successfully'); logger.log('Video recording started successfully');
} catch (error) { } catch (error) {
logger.error('Failed to start video capture:', error); logger.error('Failed to start video capture:', error);
isRecording = false; isRecording = false;
// Plan 01-09: transition to ERROR state so the badge surfaces the
// failure visually and the popup is reachable for any next-action UI.
setErrorMode();
throw error; throw error;
} }
} }
@@ -648,6 +722,27 @@ chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) =>
offscreenReadyResolve = null; offscreenReadyResolve = null;
return false; return false;
case 'RECORDING_ERROR':
// Plan 01-09 — the offscreen recorder broadcasts this on capture
// failure (codec missing, picker cancelled, wrong-display-surface,
// mid-record stream end, etc.). Surface to the operator via the
// badge + a recovery notification.
logger.warn('RECORDING_ERROR received:', message);
setErrorMode();
try {
const recoveryId = NOTIFICATION_RECOVERY_PREFIX + Date.now();
chrome.notifications.create(recoveryId, {
type: 'basic',
iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH),
title: 'Mokosh stopped',
message: 'Recording stopped. Click here to start a new session.',
priority: 1,
});
} catch (e) {
logger.warn('Recovery notification create failed:', e);
}
return false;
// Legacy chunk-streaming and IndexedDB save/load handlers were removed // Legacy chunk-streaming and IndexedDB save/load handlers were removed
// in Plan 01-03: // in Plan 01-03:
// - the offscreen recorder now owns the buffer (D-16); // - the offscreen recorder now owns the buffer (D-16);
@@ -693,9 +788,85 @@ async function initialize() {
} catch (err) { } catch (err) {
logger.warn('chrome.offscreen.hasDocument check failed:', err); logger.warn('chrome.offscreen.hasDocument check failed:', err);
} }
// Plan 01-09: enter idle mode at SW init — sets popup to '' so the
// first toolbar click fires onClicked directly (skipping the popup),
// and paints the OFF badge so the operator sees recording state at
// a glance.
setIdleMode();
logger.log('Service Worker initialized'); logger.log('Service Worker initialized');
} }
// ─── Plan 01-09 listener registrations ───────────────────────────────
// All listener registrations are wrapped in try/catch so SW module load
// stays defensively resilient when the chrome.action / chrome.notifications
// / chrome.runtime.onStartup surfaces are absent or partially stubbed
// (some unit test contexts only stub the runtime/tabs/offscreen subset).
// In production all three surfaces are guaranteed by the MV3 manifest.
// chrome.action.onClicked: fires ONLY when setPopup is '' (idle mode).
// On click, if we are not already recording, start the capture. The
// activation gesture is the toolbar click itself — getDisplayMedia
// accepts that as a valid user gesture per the W3C Screen Capture spec.
try {
chrome.action.onClicked.addListener(async () => {
if (isRecording) {
logger.log('Toolbar onClicked while already recording — no-op');
return;
}
try {
await startVideoCapture();
} catch (err) {
logger.warn('toolbar-onClicked start failed:', err);
}
});
} catch (e) {
logger.warn('chrome.action.onClicked.addListener failed:', e);
}
// chrome.runtime.onStartup: fires once when a new browser session starts
// (NOT on extension install — that's onInstalled). Show an OS-level
// notification inviting the operator to start a recording.
try {
chrome.runtime.onStartup.addListener(() => {
setIdleMode();
try {
const notificationId = NOTIFICATION_STARTUP_PREFIX + Date.now();
chrome.notifications.create(notificationId, {
type: 'basic',
iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH),
title: 'Mokosh ready',
message: 'Click here to start recording your session.',
priority: 1,
});
} catch (e) {
logger.warn('Startup notification create failed:', e);
}
});
} catch (e) {
logger.warn('chrome.runtime.onStartup.addListener failed:', e);
}
// chrome.notifications.onClicked: T-1-09-01 mitigation — only accept ids
// in our 'mokosh-' namespace. chrome.notifications ids are per-extension
// scoped at the OS level, but the prefix check is defense in depth.
try {
chrome.notifications.onClicked.addListener((notificationId) => {
if (!notificationId.startsWith('mokosh-')) return;
try {
chrome.notifications.clear(notificationId);
} catch (e) {
logger.warn('notifications.clear failed:', e);
}
// The notification click is itself an activation gesture, so
// startVideoCapture can call getDisplayMedia successfully from here.
startVideoCapture().catch((err) => {
logger.warn('notification-triggered start failed:', err);
});
});
} catch (e) {
logger.warn('chrome.notifications.onClicked.addListener failed:', e);
}
// Запуск при установке // Запуск при установке
chrome.runtime.onInstalled.addListener((details) => { chrome.runtime.onInstalled.addListener((details) => {
logger.log('Extension installed/updated:', details.reason); logger.log('Extension installed/updated:', details.reason);

View File

@@ -5,9 +5,16 @@ const saveButton = document.getElementById('saveButton') as HTMLButtonElement;
const statusMessage = document.getElementById('statusMessage') as HTMLDivElement; const statusMessage = document.getElementById('statusMessage') as HTMLDivElement;
// Состояние // Состояние
// Plan 01-09: under the SAVE-only popup charter, the popup ONLY opens
// when setPopup was switched to the html path (which happens in REC and
// ERROR modes — see src/background/index.ts setRecordingMode/setErrorMode).
// In OFF mode setPopup('') causes the toolbar click to fire onClicked
// directly and skip the popup entirely. Therefore, when the popup
// loads, we KNOW recording is active (or just errored) — initialize
// hasPermissions to true so the SAVE button is enabled immediately.
let popupState: PopupState = { let popupState: PopupState = {
isRecording: false, isRecording: true,
hasPermissions: false, hasPermissions: true,
status: 'idle' status: 'idle'
}; };
@@ -47,8 +54,12 @@ function updateUI() {
} }
// Сообщение о статусе // Сообщение о статусе
// Plan 01-09: SAVE-only charter — the popup is ONLY accessible when
// recording is active (REC mode setPopup) or in ERROR (operator may
// still attempt SAVE). The empty-state copy points operators back to
// the toolbar icon for starting a NEW session.
if (!popupState.hasPermissions && popupState.status === 'idle') { if (!popupState.hasPermissions && popupState.status === 'idle') {
statusMessage.textContent = 'Необходимо разрешение на запись экрана'; statusMessage.textContent = 'Откройте запись через иконку расширения';
statusMessage.className = 'status-message error'; statusMessage.className = 'status-message error';
} else { } else {
statusMessage.textContent = ''; statusMessage.textContent = '';
@@ -56,69 +67,13 @@ function updateUI() {
} }
} }
// Проверка разрешений при открытии popup // Plan 01-09: removed checkPermissions / requestPermissions auto-start
async function checkPermissions() { // path. The popup is now SAVE-only — the toolbar onClicked handler in
try { // the SW owns recording start. See src/background/index.ts
log('Sending REQUEST_PERMISSIONS message...'); // chrome.action.onClicked.addListener for the new start flow.
const response = await chrome.runtime.sendMessage({
type: 'REQUEST_PERMISSIONS'
});
log('Got response:', response);
popupState.hasPermissions = response.granted;
log('Permissions check:', popupState.hasPermissions);
if (popupState.hasPermissions) {
popupState.isRecording = true;
}
updateUI();
// Если нет разрешений, запрашиваем автоматически
if (!popupState.hasPermissions) {
await requestPermissions();
}
} catch (error) {
log('Error checking permissions:', error);
popupState.hasPermissions = false;
updateUI();
}
}
// Запрос разрешений
async function requestPermissions() {
try {
const response = await chrome.runtime.sendMessage({
type: 'REQUEST_PERMISSIONS'
});
popupState.hasPermissions = response.granted;
popupState.isRecording = response.granted;
log('Permission request result:', response.granted);
updateUI();
if (!response.granted) {
statusMessage.textContent = 'Разрешение на запись обязательно для работы расширения';
statusMessage.className = 'status-message error';
}
} catch (error) {
log('Error requesting permissions:', error);
popupState.hasPermissions = false;
updateUI();
}
}
// Сохранение архива // Сохранение архива
async function saveArchive() { async function saveArchive() {
if (!popupState.hasPermissions) {
log('Cannot save: no permissions');
await requestPermissions();
return;
}
popupState.status = 'saving'; popupState.status = 'saving';
updateUI(); updateUI();
@@ -163,8 +118,12 @@ function init() {
// Привязка событий // Привязка событий
saveButton.addEventListener('click', saveArchive); saveButton.addEventListener('click', saveArchive);
// Проверяем разрешения при открытии // Plan 01-09: SAVE-only popup — no permission round-trip on open. The
checkPermissions(); // popup is only reachable when recording is active (background SW sets
// setPopup to this html in REC/ERROR modes; OFF mode uses setPopup('')
// which makes the toolbar click fire onClicked instead). hasPermissions
// defaults to true so SAVE is enabled immediately.
updateUI();
log('Popup initialized'); log('Popup initialized');
} }