@rcnr/lockdown 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -4,7 +4,7 @@ interface Violation {
4
4
  timestamp: number;
5
5
  }
6
6
  /** All violation types the lockdown hook can detect. */
7
- type ViolationType = "fullscreen_exit" | "tab_switch" | "window_blur" | "paste_attempt" | "copy_attempt" | "cut_attempt" | "drop_attempt" | "devtools_attempt";
7
+ type ViolationType = "fullscreen_exit" | "tab_switch" | "window_blur" | "paste_attempt" | "copy_attempt" | "cut_attempt" | "drop_attempt" | "devtools_attempt" | "extension_detected";
8
8
  /** Violations that trigger instant auto-submit (cheating attempts). */
9
9
  declare const INSTANT_SUBMIT_VIOLATIONS: Set<ViolationType>;
10
10
  /** Configuration for the lockdown hook. */
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ interface Violation {
4
4
  timestamp: number;
5
5
  }
6
6
  /** All violation types the lockdown hook can detect. */
7
- type ViolationType = "fullscreen_exit" | "tab_switch" | "window_blur" | "paste_attempt" | "copy_attempt" | "cut_attempt" | "drop_attempt" | "devtools_attempt";
7
+ type ViolationType = "fullscreen_exit" | "tab_switch" | "window_blur" | "paste_attempt" | "copy_attempt" | "cut_attempt" | "drop_attempt" | "devtools_attempt" | "extension_detected";
8
8
  /** Violations that trigger instant auto-submit (cheating attempts). */
9
9
  declare const INSTANT_SUBMIT_VIOLATIONS: Set<ViolationType>;
10
10
  /** Configuration for the lockdown hook. */
package/dist/index.js CHANGED
@@ -34,7 +34,8 @@ var INSTANT_SUBMIT_VIOLATIONS = /* @__PURE__ */ new Set([
34
34
  "copy_attempt",
35
35
  "cut_attempt",
36
36
  "drop_attempt",
37
- "devtools_attempt"
37
+ "devtools_attempt",
38
+ "extension_detected"
38
39
  ]);
39
40
 
40
41
  // src/useLockdown.ts
@@ -147,15 +148,17 @@ function useLockdown({
147
148
  "Final warning: leave this window again and your work will be auto-submitted."
148
149
  );
149
150
  setTimeout(() => setWarning(null), WARNING_DISPLAY_MS);
151
+ startCountdown();
150
152
  } else {
151
153
  setWarning(
152
154
  `Warning: you left the writing window. You have ${remaining} chance${remaining > 1 ? "s" : ""} left.`
153
155
  );
154
156
  setTimeout(() => setWarning(null), WARNING_DISPLAY_MS);
157
+ startCountdown();
155
158
  }
156
159
  }
157
160
  },
158
- [triggerAutoSubmit]
161
+ [triggerAutoSubmit, startCountdown]
159
162
  );
160
163
  const enterFullscreen = (0, import_react.useCallback)(async () => {
161
164
  try {
@@ -198,9 +201,6 @@ function useLockdown({
198
201
  } else if (!graceRef.current && hasEnteredFullscreenRef.current) {
199
202
  lastFsExitRef.current = Date.now();
200
203
  addViolation("fullscreen_exit");
201
- if (fullscreenExitCountRef.current <= MAX_FULLSCREEN_EXITS) {
202
- startCountdown();
203
- }
204
204
  }
205
205
  }
206
206
  function handleVisibilityChange() {
@@ -280,9 +280,15 @@ function useLockdown({
280
280
  function handleContextMenu(e) {
281
281
  e.preventDefault();
282
282
  }
283
+ function handleFocus() {
284
+ if (countdownIntervalRef.current) {
285
+ clearCountdown();
286
+ }
287
+ }
283
288
  document.addEventListener("fullscreenchange", handleFullscreenChange);
284
289
  document.addEventListener("visibilitychange", handleVisibilityChange);
285
290
  window.addEventListener("blur", handleBlur);
291
+ window.addEventListener("focus", handleFocus);
286
292
  document.addEventListener("paste", handlePaste);
287
293
  document.addEventListener("copy", handleCopy);
288
294
  document.addEventListener("cut", handleCut);
@@ -296,6 +302,7 @@ function useLockdown({
296
302
  document.removeEventListener("fullscreenchange", handleFullscreenChange);
297
303
  document.removeEventListener("visibilitychange", handleVisibilityChange);
298
304
  window.removeEventListener("blur", handleBlur);
305
+ window.removeEventListener("focus", handleFocus);
299
306
  document.removeEventListener("paste", handlePaste);
300
307
  document.removeEventListener("copy", handleCopy);
301
308
  document.removeEventListener("cut", handleCut);
@@ -307,6 +314,104 @@ function useLockdown({
307
314
  document.removeEventListener("contextmenu", handleContextMenu);
308
315
  };
309
316
  }, [enabled, addViolation, startCountdown, clearCountdown]);
317
+ (0, import_react.useEffect)(() => {
318
+ if (!enabled) return;
319
+ const EXTENSION_SIGNATURES = [
320
+ "grammarly",
321
+ "schoolai",
322
+ "quillbot",
323
+ "languagetool",
324
+ "ginger",
325
+ "writefull",
326
+ "wordtune",
327
+ "prowritingaid",
328
+ "hemingway",
329
+ "copyleaks",
330
+ "jenni",
331
+ "jasper",
332
+ "textblaze",
333
+ "compose-ai",
334
+ "hyperwrite",
335
+ "otter-ai",
336
+ "fireflies"
337
+ ];
338
+ function isExtensionElement(el) {
339
+ if (el.tagName === "IFRAME") {
340
+ const src = el.getAttribute("src") || "";
341
+ if (/^(chrome|moz)-extension:\/\//i.test(src)) return true;
342
+ if (!src || src.startsWith("blob:")) {
343
+ const id2 = el.id?.toLowerCase() || "";
344
+ const cls2 = typeof el.className === "string" ? el.className.toLowerCase() : "";
345
+ if (EXTENSION_SIGNATURES.some(
346
+ (sig) => id2.includes(sig) || cls2.includes(sig)
347
+ )) {
348
+ return true;
349
+ }
350
+ }
351
+ }
352
+ if (el.shadowRoot && !el.hasAttribute("data-rcnr")) {
353
+ return true;
354
+ }
355
+ const id = el.id?.toLowerCase() || "";
356
+ const cls = typeof el.className === "string" ? el.className.toLowerCase() : "";
357
+ for (const attr of el.getAttributeNames?.() || []) {
358
+ const attrLower = attr.toLowerCase();
359
+ if (EXTENSION_SIGNATURES.some((sig) => attrLower.includes(sig))) {
360
+ return true;
361
+ }
362
+ }
363
+ if (EXTENSION_SIGNATURES.some(
364
+ (sig) => id.includes(sig) || cls.includes(sig)
365
+ )) {
366
+ return true;
367
+ }
368
+ if (el.parentElement === document.body && !el.hasAttribute("data-rcnr")) {
369
+ const tag = el.tagName?.toLowerCase() || "";
370
+ if (tag === "div" || tag === "section" || tag === "aside") {
371
+ const style = window.getComputedStyle(el);
372
+ if ((style.position === "fixed" || style.position === "absolute") && parseInt(style.width, 10) > 200) {
373
+ const html = el.innerHTML?.toLowerCase() || "";
374
+ if (/chrome-extension:|moz-extension:/i.test(html)) {
375
+ return true;
376
+ }
377
+ }
378
+ }
379
+ }
380
+ return false;
381
+ }
382
+ const observer = new MutationObserver((mutations) => {
383
+ if (!hasEnteredFullscreenRef.current) return;
384
+ if (graceRef.current) return;
385
+ if (autoSubmittedRef.current) return;
386
+ for (const mutation of mutations) {
387
+ for (const node of mutation.addedNodes) {
388
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
389
+ if (isExtensionElement(node)) {
390
+ addViolation("extension_detected");
391
+ return;
392
+ }
393
+ }
394
+ }
395
+ });
396
+ observer.observe(document.documentElement, {
397
+ childList: true,
398
+ subtree: true
399
+ });
400
+ const scanTimer = requestAnimationFrame(() => {
401
+ if (!hasEnteredFullscreenRef.current) return;
402
+ const allElements = document.querySelectorAll("*");
403
+ for (const el of allElements) {
404
+ if (isExtensionElement(el)) {
405
+ addViolation("extension_detected");
406
+ break;
407
+ }
408
+ }
409
+ });
410
+ return () => {
411
+ observer.disconnect();
412
+ cancelAnimationFrame(scanTimer);
413
+ };
414
+ }, [enabled, addViolation]);
310
415
  (0, import_react.useEffect)(() => {
311
416
  if (!enabled) return;
312
417
  const interval = setInterval(() => {
package/dist/index.mjs CHANGED
@@ -7,7 +7,8 @@ var INSTANT_SUBMIT_VIOLATIONS = /* @__PURE__ */ new Set([
7
7
  "copy_attempt",
8
8
  "cut_attempt",
9
9
  "drop_attempt",
10
- "devtools_attempt"
10
+ "devtools_attempt",
11
+ "extension_detected"
11
12
  ]);
12
13
 
13
14
  // src/useLockdown.ts
@@ -120,15 +121,17 @@ function useLockdown({
120
121
  "Final warning: leave this window again and your work will be auto-submitted."
121
122
  );
122
123
  setTimeout(() => setWarning(null), WARNING_DISPLAY_MS);
124
+ startCountdown();
123
125
  } else {
124
126
  setWarning(
125
127
  `Warning: you left the writing window. You have ${remaining} chance${remaining > 1 ? "s" : ""} left.`
126
128
  );
127
129
  setTimeout(() => setWarning(null), WARNING_DISPLAY_MS);
130
+ startCountdown();
128
131
  }
129
132
  }
130
133
  },
131
- [triggerAutoSubmit]
134
+ [triggerAutoSubmit, startCountdown]
132
135
  );
133
136
  const enterFullscreen = useCallback(async () => {
134
137
  try {
@@ -171,9 +174,6 @@ function useLockdown({
171
174
  } else if (!graceRef.current && hasEnteredFullscreenRef.current) {
172
175
  lastFsExitRef.current = Date.now();
173
176
  addViolation("fullscreen_exit");
174
- if (fullscreenExitCountRef.current <= MAX_FULLSCREEN_EXITS) {
175
- startCountdown();
176
- }
177
177
  }
178
178
  }
179
179
  function handleVisibilityChange() {
@@ -253,9 +253,15 @@ function useLockdown({
253
253
  function handleContextMenu(e) {
254
254
  e.preventDefault();
255
255
  }
256
+ function handleFocus() {
257
+ if (countdownIntervalRef.current) {
258
+ clearCountdown();
259
+ }
260
+ }
256
261
  document.addEventListener("fullscreenchange", handleFullscreenChange);
257
262
  document.addEventListener("visibilitychange", handleVisibilityChange);
258
263
  window.addEventListener("blur", handleBlur);
264
+ window.addEventListener("focus", handleFocus);
259
265
  document.addEventListener("paste", handlePaste);
260
266
  document.addEventListener("copy", handleCopy);
261
267
  document.addEventListener("cut", handleCut);
@@ -269,6 +275,7 @@ function useLockdown({
269
275
  document.removeEventListener("fullscreenchange", handleFullscreenChange);
270
276
  document.removeEventListener("visibilitychange", handleVisibilityChange);
271
277
  window.removeEventListener("blur", handleBlur);
278
+ window.removeEventListener("focus", handleFocus);
272
279
  document.removeEventListener("paste", handlePaste);
273
280
  document.removeEventListener("copy", handleCopy);
274
281
  document.removeEventListener("cut", handleCut);
@@ -280,6 +287,104 @@ function useLockdown({
280
287
  document.removeEventListener("contextmenu", handleContextMenu);
281
288
  };
282
289
  }, [enabled, addViolation, startCountdown, clearCountdown]);
290
+ useEffect(() => {
291
+ if (!enabled) return;
292
+ const EXTENSION_SIGNATURES = [
293
+ "grammarly",
294
+ "schoolai",
295
+ "quillbot",
296
+ "languagetool",
297
+ "ginger",
298
+ "writefull",
299
+ "wordtune",
300
+ "prowritingaid",
301
+ "hemingway",
302
+ "copyleaks",
303
+ "jenni",
304
+ "jasper",
305
+ "textblaze",
306
+ "compose-ai",
307
+ "hyperwrite",
308
+ "otter-ai",
309
+ "fireflies"
310
+ ];
311
+ function isExtensionElement(el) {
312
+ if (el.tagName === "IFRAME") {
313
+ const src = el.getAttribute("src") || "";
314
+ if (/^(chrome|moz)-extension:\/\//i.test(src)) return true;
315
+ if (!src || src.startsWith("blob:")) {
316
+ const id2 = el.id?.toLowerCase() || "";
317
+ const cls2 = typeof el.className === "string" ? el.className.toLowerCase() : "";
318
+ if (EXTENSION_SIGNATURES.some(
319
+ (sig) => id2.includes(sig) || cls2.includes(sig)
320
+ )) {
321
+ return true;
322
+ }
323
+ }
324
+ }
325
+ if (el.shadowRoot && !el.hasAttribute("data-rcnr")) {
326
+ return true;
327
+ }
328
+ const id = el.id?.toLowerCase() || "";
329
+ const cls = typeof el.className === "string" ? el.className.toLowerCase() : "";
330
+ for (const attr of el.getAttributeNames?.() || []) {
331
+ const attrLower = attr.toLowerCase();
332
+ if (EXTENSION_SIGNATURES.some((sig) => attrLower.includes(sig))) {
333
+ return true;
334
+ }
335
+ }
336
+ if (EXTENSION_SIGNATURES.some(
337
+ (sig) => id.includes(sig) || cls.includes(sig)
338
+ )) {
339
+ return true;
340
+ }
341
+ if (el.parentElement === document.body && !el.hasAttribute("data-rcnr")) {
342
+ const tag = el.tagName?.toLowerCase() || "";
343
+ if (tag === "div" || tag === "section" || tag === "aside") {
344
+ const style = window.getComputedStyle(el);
345
+ if ((style.position === "fixed" || style.position === "absolute") && parseInt(style.width, 10) > 200) {
346
+ const html = el.innerHTML?.toLowerCase() || "";
347
+ if (/chrome-extension:|moz-extension:/i.test(html)) {
348
+ return true;
349
+ }
350
+ }
351
+ }
352
+ }
353
+ return false;
354
+ }
355
+ const observer = new MutationObserver((mutations) => {
356
+ if (!hasEnteredFullscreenRef.current) return;
357
+ if (graceRef.current) return;
358
+ if (autoSubmittedRef.current) return;
359
+ for (const mutation of mutations) {
360
+ for (const node of mutation.addedNodes) {
361
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
362
+ if (isExtensionElement(node)) {
363
+ addViolation("extension_detected");
364
+ return;
365
+ }
366
+ }
367
+ }
368
+ });
369
+ observer.observe(document.documentElement, {
370
+ childList: true,
371
+ subtree: true
372
+ });
373
+ const scanTimer = requestAnimationFrame(() => {
374
+ if (!hasEnteredFullscreenRef.current) return;
375
+ const allElements = document.querySelectorAll("*");
376
+ for (const el of allElements) {
377
+ if (isExtensionElement(el)) {
378
+ addViolation("extension_detected");
379
+ break;
380
+ }
381
+ }
382
+ });
383
+ return () => {
384
+ observer.disconnect();
385
+ cancelAnimationFrame(scanTimer);
386
+ };
387
+ }, [enabled, addViolation]);
283
388
  useEffect(() => {
284
389
  if (!enabled) return;
285
390
  const interval = setInterval(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rcnr/lockdown",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Shared fullscreen lockdown hook for RCNR student frontends",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",