Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -981,9 +981,10 @@ the worker stops an evidence recording ~2 s after the last one is gone).
| `SCREENREC_REVIEWED` / `SCREENREC_TRIMMED` `{url, …}` | review page → worker | The review approved the take as recorded, or cut it. Only then does the worker broadcast `SCREENREC_EVENT {event:'file'}` — nothing is attached before that. |
| `SCREENREC_CLAIM` / `SCREENREC_UNCLAIM` `{by}` | panel → worker | One panel document at a time owns the upload: the `file` event is a broadcast, and every open panel would otherwise upload the same take. Serialized in `screenrec/claim.js`; an upload that fails un-claims so the next *Retry attach…* — here or in another panel — can take it. |
| `SCREENREC_REVIEW_KEY` | injected review overlay → worker | The one-shot `screenRecReviewKey`, which is how a framed `screenrec/review.html` proves the extension framed it and the page under test did not. |
| `SCREENREC_EVENT` `{event, …}` | worker → panel (broadcast) | `started` / `review` / `file` / `ended`. |
| `SCREENREC_EVENT` `{event, …}` | worker → panel (broadcast) | `started` / `review` / `file` / `ended`. An `ended` with reason `wiped` also closes an open review page. |
| `SCREENREC_OFF` `{cmd, …}` | worker → offscreen document (broadcast); the review page too, for the trim | `start` / `cast-start` / `frame` / `pause` / `stop` / `state` / `revoke` from the worker, and `trim-begin` / `trim-chunk` / `trim-swap` from `screenrec/review.js`. Frames go down a dedicated `screenrec-frames` port instead when one is up: a broadcast would copy every JPEG, several a second, into every extension page. |
| `SCREENREC_FILE` `{file}` | offscreen document → worker | Pushed when a cap or a closed tab ended the recording, with no stop to detach the cast. |
| `SCREENREC_FILE` `{file}` | offscreen document → worker | Pushed when a cap or a closed tab ended the recording, with no stop to detach the cast. Ignored when no offscreen document is open any more: an erase closed it, and the take's bytes went with it. |
| `SCREENREC_WIPE` | panel → worker | Sign out, Disconnect and Forget on the ACTIVE instance: take the cast off the tab, close the offscreen document — the capture and every take's bytes end with it — remove `screenRec`, `screenRecFile`, `screenRecReviewKey` and `screenRecTarget`, and broadcast `ended` with reason `wiped`. Replies `{ok}` or `{ok:false, error}`. |

The evidence handler ignores anything outside its `EVIDENCE_REQUESTS` set so the
two `onMessage` listeners in the worker plus the recorder's do not fight over one
Expand Down Expand Up @@ -2041,7 +2042,8 @@ live credential. Two keys are carried back over that wipe: `theme`
(`shared/theme.js`) and `viewMode` (`shared/view-mode.js`) —
neither a credential nor scoped to one, and both re-written after `clear()`
rather than exempted from it, so the whole-area wipe stays whole.
The sign out first attempts `EVIDENCE_WIPE` (§3.4): the
The sign out first attempts `EVIDENCE_WIPE` (§3.4) and `SCREENREC_WIPE`, side by side, each under
the same 5 s timeout: the
evidence buffer lives in the worker, so a recording still RUNNING would re-mirror
it over the clear ~2 s later. A missing listener is tolerated — no
worker, no recording. A refusal or a 5 s timeout does NOT abort the sign out,
Expand All @@ -2052,7 +2054,7 @@ rides a one-shot page-`sessionStorage` breadcrumb (`signOutRecorderWarning`,
§5.4) that `SettingsErase.takeWarning()` paints onto `settings-forget-status`
— a status line set before `reloadPanel()` would die with the document.
`forget()` takes the same two steps for the ACTIVE instance only —
`EVIDENCE_WIPE` first, then `storage.session.clear()` — because that area is
the two wipes first, then `storage.session.clear()` — because that area is
scoped to no instance but the panel is being reset anyway, and it holds the
recorded steps, the evidence buffer, unsaved editor drafts and pending screenshot
hand-offs. Forgetting an INACTIVE instance touches neither: that data belongs to
Expand Down Expand Up @@ -2129,7 +2131,7 @@ Panel document only. It exists because the panel *navigates away* to that page
rather than embedding it — the panel document is destroyed and rebuilt.

`signOutRecorderWarning` — a one-shot reason string written by
`SettingsErase.leaveWarning()` when an erase's `EVIDENCE_WIPE` failed, and
`SettingsErase.leaveWarning()` when an erase's `EVIDENCE_WIPE` or `SCREENREC_WIPE` failed (it names which), and
consumed by `SettingsErase.takeWarning()` off `fillSettingsForm()`
(`screens/settings.js`), for the same reason: the erase succeeded and the panel
reloads, so the warning has to outlive the document that raised it. Not one of
Expand Down
15 changes: 8 additions & 7 deletions docs/guide/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,16 @@ saved.
- **Forget this instance** — deletes the saved token, project and
preferences of the instance the form points at, after a confirmation.
If that is the instance you are on, its restored session, queued results
waiting to be sent, recorded steps, captured log and unsaved drafts go
too, a running recording is stopped, and the panel returns to the connect
screen. Other instances are kept.
waiting to be sent, recorded steps, captured log, screen recording and
unsaved drafts go too, a running recording is stopped, and the panel
returns to the connect screen. Other instances are kept.

## Stored credentials

**Sign out** is for a shared machine: after a confirmation it erases every
saved token, instance, history entry, queued result, session, unsaved test
draft, recorded step and captured log from this browser, stops a running
recording, and reloads the panel onto the connect screen. The colour
draft, recorded step, captured log and screen recording from this browser,
stops a running recording, and reloads the panel onto the connect screen. The colour
scheme and the side-panel-or-window choice stay. Site access is Chrome's
own setting for the extension, on `chrome://extensions`, and is not
touched.
Expand Down Expand Up @@ -124,5 +124,6 @@ From any tab, `Alt+Shift+R` starts or stops a
- **"Nothing saved for …"** — Forget was pressed for a server that was never
saved; nothing was erased.
- **A warning about the recording after Forget or Sign out** — the erase
happened, but the console & network recorder could not be stopped;
restart the browser to be sure its log is gone.
happened, but the console & network recorder or the screen recorder could
not be stopped; the warning names which. Restart the browser to be sure
the log or the video is gone.
4 changes: 4 additions & 0 deletions extension/screenrec/review.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,10 @@
});

window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !exporting) closeReview(); });
// Disconnect, Forget or Sign out threw the take away: nothing is left here to review.
chrome.runtime.onMessage.addListener((msg) => {
if (msg && msg.type === 'SCREENREC_EVENT' && msg.event === 'ended' && msg.reason === 'wiped') closeReview();
});

// ---- boot ------------------------------------------------------------------

Expand Down
25 changes: 24 additions & 1 deletion extension/screenrec/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,24 @@ async function srecStop(reason) {
return { ok: true };
}

// Disconnect, Forget and Sign out: closing the recorder page ends the capture and frees every take.
async function srecWipe() {
await castSeeded; // a worker woken by this message must still know which tab it holds
await srecTeardownCast(await srecGet());
try { await chrome.offscreen.closeDocument(); } catch { /* none open */ }
await chrome.storage.session.remove([SREC_KEY, SREC_FILE_KEY, SREC_RKEY_KEY, SREC_TARGET_KEY]);
srecTell({ type: 'SCREENREC_EVENT', event: 'ended', reason: 'wiped' });
return { ok: true };
}

// A take pushed by a recorder page that is already gone has no bytes left to review or attach.
async function srecDocOpen() {
try {
const open = await chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] });
return !!(open && open.length);
} catch { return true; } // no way to tell: keep the take, as before
}

// Everything that ends a recording funnels through here: state cleared, file parked — and the
// REVIEW opened over the page (#68 preview+trim). Nothing is attached until the tester says so
// there; the panel only hears 'file' once the review answers.
Expand Down Expand Up @@ -473,8 +491,13 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
return true;
// Pushed by the offscreen document when a cap or a closed tab ended the recording.
case 'SCREENREC_FILE':
srecGet().then((st) => srecFinish(msg.file, st, msg.file && msg.file.reason));
srecDocOpen().then(async (open) => {
if (open) await srecFinish(msg.file, await srecGet(), msg.file && msg.file.reason);
});
return false;
case 'SCREENREC_WIPE':
srecWipe().then(sendResponse, (e) => sendResponse({ ok: false, error: String((e && e.message) || e) }));
return true;
default: return undefined;
}
});
2 changes: 1 addition & 1 deletion extension/sidepanel/screens/screen-rec.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ chrome.runtime.onMessage.addListener((msg) => {
if (msg.event === 'file' && msg.file) srecAttach(msg.file);
else {
// Nothing left to attach — nothing was recorded, or the take is gone — so the plaque goes too.
if (msg.event === 'ended' && (msg.empty || msg.reason === 'discarded')) hideToast();
if (msg.event === 'ended' && (msg.empty || msg.reason === 'discarded' || msg.reason === 'wiped')) hideToast();
srecRefresh();
}
return undefined;
Expand Down
47 changes: 37 additions & 10 deletions extension/sidepanel/screens/settings-erase.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,21 @@ const EVIDENCE_WIPE_MS = 5000;
// PAGE sessionStorage, like `tcReturn`: not an area the erase claims to wipe, holds
// no credential, and dies with the browser — which is when the buffer dies too.
const EVIDENCE_WIPE_WARN_KEY = 'signOutRecorderWarning';
const evidenceWipeWarning = (why, lead) => `${lead} — but the console & network `
+ `recording could not be stopped: ${why}. Assume its log is still on this machine until you `
+ `restart the browser.`;

// The two recorders an erase stops, each in the words its warning uses.
const RECORDER_WIPES = [
{ type: 'EVIDENCE_WIPE', what: 'the console & network recording', left: 'its log', short: 'console & network' },
{ type: 'SCREENREC_WIPE', what: 'the screen recording', left: 'its video', short: 'screen' },
];
const evidenceWipeWarning = (failed, lead) => {
const tail = 'still on this machine until you restart the browser.';
if (failed.length === 1) {
const [f] = failed;
return `${lead} — but ${f.what} could not be stopped: ${f.why}. Assume ${f.left} is ${tail}`;
}
const whys = failed.map((f) => `${f.short}: ${f.why}`).join('; ');
return `${lead} — but neither recording could be stopped (${whys}). Assume the log and the video are ${tail}`;
};

const SettingsErase = {
HOST_SCOPED_KEYS,
Expand Down Expand Up @@ -79,8 +91,8 @@ const SettingsErase = {
const ok = await ConfirmDialog.ask(
`${verb} ${host}? Its saved token, project and preferences are deleted from this browser`
+ (active ? ', together with its restored session, any queued results still waiting to be sent, '
+ 'and this session\'s recorded steps, captured log and unsaved drafts — a running recording '
+ 'is stopped for you' : '')
+ 'and this session\'s recorded steps, captured log, screen recording and unsaved drafts — a '
+ 'running recording is stopped for you' : '')
+ '. Other instances are kept.', verb);
if (!ok) return;
const hostSettings = { ...state.hostSettings };
Expand Down Expand Up @@ -119,8 +131,10 @@ const SettingsErase = {
setStatusLine(statusId, `${host} forgotten`, 'ok');
},

// `e.failed` names each recorder that would not stop; a bare error is the log's, as before.
leaveWarning(e, lead) {
try { sessionStorage.setItem(EVIDENCE_WIPE_WARN_KEY, evidenceWipeWarning(String((e && e.message) || e), lead)); }
const failed = (e && e.failed) || [{ ...RECORDER_WIPES[0], why: String((e && e.message) || e) }];
try { sessionStorage.setItem(EVIDENCE_WIPE_WARN_KEY, evidenceWipeWarning(failed, lead)); }
catch { /* sessionStorage unavailable — the erase still stands */ }
},

Expand All @@ -137,10 +151,9 @@ const SettingsErase = {

// #183: `evidenceMirror` is only a copy of the worker's ring buffer — a RUNNING
// recording writes it back ~2 s after a clear. Throws on anything but a clean wipe.
async wipeRecording() {
if (!hasChrome || !chrome.runtime || !chrome.runtime.sendMessage) return;
async wipeOne(type) {
const resp = await Promise.race([
chrome.runtime.sendMessage({ type: 'EVIDENCE_WIPE' }).catch((e) => {
chrome.runtime.sendMessage({ type }).catch((e) => {
// No worker to answer means no recording to stop — proceed, don't fail.
if (/receiving end|Could not establish/i.test(String((e && e.message) || e))) return { ok: true };
throw e;
Expand All @@ -151,10 +164,24 @@ const SettingsErase = {
if (!resp || resp.ok !== true) throw new Error((resp && resp.error) || 'the recorder could not be stopped');
},

// Both at once, so 5 s at most; the error's `failed` lists every recorder that would not stop.
async wipeRecording() {
if (!hasChrome || !chrome.runtime || !chrome.runtime.sendMessage) return;
const results = await Promise.allSettled(RECORDER_WIPES.map((w) => SettingsErase.wipeOne(w.type)));
const failed = RECORDER_WIPES
.map((w, i) => (results[i].status === 'rejected'
? { ...w, why: String((results[i].reason && results[i].reason.message) || results[i].reason) } : null))
.filter(Boolean);
if (!failed.length) return;
const err = new Error(failed[0].why);
err.failed = failed;
throw err;
},

async signOut() {
const ok = await ConfirmDialog.ask(
'Sign out? Every saved token, instance, history entry, queued result, session, unsaved '
+ 'test draft, recorded step and captured log is deleted from this '
+ 'test draft, recorded step, captured log and screen recording is deleted from this '
+ 'browser. A running recording is stopped for you. Site access stays — it is Chrome\'s own '
+ 'setting, under chrome://extensions → Details → Site access.', 'Sign out');
if (!ok) return;
Expand Down
8 changes: 8 additions & 0 deletions tests/screen-rec.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,14 @@ test('31: a take the tester discarded in the review takes the plaque down too',
assert.deepEqual(h.types(), ['SCREENREC_STATUS']);
});

test('31b: a take an erase threw away takes the plaque down in every other panel too', async () => {
const h = load();
h.message({ type: 'SCREENREC_EVENT', event: 'ended', reason: 'wiped' });
await settle();
assert.equal(h.calls.hides, 1);
assert.deepEqual(h.types(), ['SCREENREC_STATUS']);
});

test('32: an ordinary end leaves the plaque standing and only repaints', async () => {
const h = load();
h.worker.SCREENREC_STATUS = IDLE(TAKE({ reviewed: false }));
Expand Down
28 changes: 28 additions & 0 deletions tests/screenrec-review.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ async function load(opts = {}) {
sessionFail,
});
fake.chrome.runtime.sendMessage = async (msg) => { sent.push(msg); return reply(msg, sent.length); };
// The worker's broadcasts reach this page through its own runtime listener.
const heard = [];
fake.chrome.runtime.onMessage = { addListener: (fn) => { heard.push(fn); } };
if (stallKey) {
const read = fake.chrome.storage.session.get;
fake.chrome.storage.session.get = (k) => (k === 'screenRecReviewKey' ? new Promise(() => {}) : read(k));
Expand Down Expand Up @@ -233,6 +236,7 @@ async function load(opts = {}) {

const h = {
doc, video, timeline, clock, seeks, plays, recLog, posts, sent, closes, $, x,
broadcast: (msg) => { for (const fn of heard) fn(msg, {}, () => {}); },
session: fake.session,
// render() writes the live cut object onto the element it paints — the module's own readout.
cuts: () => timeline.querySelectorAll('.cut').map((el) => plain(el._cut)),
Expand Down Expand Up @@ -963,3 +967,27 @@ test('34f (#105): a click harvested before the key check has answered acts on no
assert.deepEqual(h.types(), []);
assert.deepEqual(h.posts, []);
});

// ---- the erase ---------------------------------------------------------------

test('an erase that threw the take away closes the review, framed or in a tab of its own', async () => {
const framed = await load();
framed.broadcast({ type: 'SCREENREC_EVENT', event: 'ended', reason: 'wiped' });
assert.deepEqual(framed.posts.map((p) => p.data), [{ type: 'TESTOMAT_REVIEW_CLOSE' }]);
const tab = await load({ framed: false });
tab.broadcast({ type: 'SCREENREC_EVENT', event: 'ended', reason: 'wiped' });
assert.equal(tab.closes.length, 1);
});

test('any other end the worker announces leaves the review open', async () => {
const h = await load();
for (const msg of [
{ type: 'SCREENREC_EVENT', event: 'ended', reason: 'discarded' },
{ type: 'SCREENREC_EVENT', event: 'ended', reason: 'user', empty: true },
{ type: 'SCREENREC_EVENT', event: 'review' },
{ type: 'EVIDENCE_WIPE' },
null,
]) h.broadcast(msg);
assert.deepEqual(h.posts, []);
assert.equal(h.closes.length, 0);
});
Loading
Loading