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:
@@ -38,6 +38,74 @@ let isRecording = false;
|
||||
let offscreenCreated = false;
|
||||
let lastScreenshotTime = 0;
|
||||
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.
|
||||
let videoPort: chrome.runtime.Port | null = null;
|
||||
// Offscreen readiness Promise — set up at module load, resolved on first
|
||||
@@ -342,11 +410,17 @@ async function startVideoCapture() {
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to start video capture:', error);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -648,6 +722,27 @@ chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) =>
|
||||
offscreenReadyResolve = null;
|
||||
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
|
||||
// in Plan 01-03:
|
||||
// - the offscreen recorder now owns the buffer (D-16);
|
||||
@@ -693,9 +788,85 @@ async function initialize() {
|
||||
} catch (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');
|
||||
}
|
||||
|
||||
// ─── 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) => {
|
||||
logger.log('Extension installed/updated:', details.reason);
|
||||
|
||||
Reference in New Issue
Block a user