@rcnr/lockdown 1.1.1 → 1.3.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" | "voice_input" | "pip_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" | "voice_input" | "pip_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,10 @@ 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",
39
+ "voice_input",
40
+ "pip_detected"
38
41
  ]);
39
42
 
40
43
  // src/useLockdown.ts
@@ -267,6 +270,10 @@ function useLockdown({
267
270
  e.preventDefault();
268
271
  return;
269
272
  }
273
+ if (modKey && (e.key.toLowerCase() === "f" || e.key.toLowerCase() === "h")) {
274
+ e.preventDefault();
275
+ return;
276
+ }
270
277
  if (e.altKey && e.key === "Tab") {
271
278
  e.preventDefault();
272
279
  return;
@@ -279,6 +286,19 @@ function useLockdown({
279
286
  function handleContextMenu(e) {
280
287
  e.preventDefault();
281
288
  }
289
+ function handleBeforeInput(e) {
290
+ const inputEvent = e;
291
+ const type = inputEvent.inputType;
292
+ if (type === "insertFromDictation" || type === "insertFromVoice" || // Some browsers report dictation as insertReplacementText
293
+ // but only flag it if it inserts a lot of text at once (>50 chars)
294
+ type === "insertReplacementText" && inputEvent.data && inputEvent.data.length > 50) {
295
+ e.preventDefault();
296
+ addViolation("voice_input");
297
+ }
298
+ }
299
+ function handlePipEnter() {
300
+ addViolation("pip_detected");
301
+ }
282
302
  function handleFocus() {
283
303
  if (countdownIntervalRef.current) {
284
304
  clearCountdown();
@@ -297,6 +317,8 @@ function useLockdown({
297
317
  document.addEventListener("dragover", handleDragOver);
298
318
  document.addEventListener("keydown", handleKeydown);
299
319
  document.addEventListener("contextmenu", handleContextMenu);
320
+ document.addEventListener("beforeinput", handleBeforeInput);
321
+ document.addEventListener("enterpictureinpicture", handlePipEnter, true);
300
322
  return () => {
301
323
  document.removeEventListener("fullscreenchange", handleFullscreenChange);
302
324
  document.removeEventListener("visibilitychange", handleVisibilityChange);
@@ -311,8 +333,108 @@ function useLockdown({
311
333
  document.removeEventListener("dragover", handleDragOver);
312
334
  document.removeEventListener("keydown", handleKeydown);
313
335
  document.removeEventListener("contextmenu", handleContextMenu);
336
+ document.removeEventListener("beforeinput", handleBeforeInput);
337
+ document.removeEventListener("enterpictureinpicture", handlePipEnter, true);
314
338
  };
315
339
  }, [enabled, addViolation, startCountdown, clearCountdown]);
340
+ (0, import_react.useEffect)(() => {
341
+ if (!enabled) return;
342
+ const EXTENSION_SIGNATURES = [
343
+ "grammarly",
344
+ "schoolai",
345
+ "quillbot",
346
+ "languagetool",
347
+ "ginger",
348
+ "writefull",
349
+ "wordtune",
350
+ "prowritingaid",
351
+ "hemingway",
352
+ "copyleaks",
353
+ "jenni",
354
+ "jasper",
355
+ "textblaze",
356
+ "compose-ai",
357
+ "hyperwrite",
358
+ "otter-ai",
359
+ "fireflies"
360
+ ];
361
+ function isExtensionElement(el) {
362
+ if (el.tagName === "IFRAME") {
363
+ const src = el.getAttribute("src") || "";
364
+ if (/^(chrome|moz)-extension:\/\//i.test(src)) return true;
365
+ if (!src || src.startsWith("blob:")) {
366
+ const id2 = el.id?.toLowerCase() || "";
367
+ const cls2 = typeof el.className === "string" ? el.className.toLowerCase() : "";
368
+ if (EXTENSION_SIGNATURES.some(
369
+ (sig) => id2.includes(sig) || cls2.includes(sig)
370
+ )) {
371
+ return true;
372
+ }
373
+ }
374
+ }
375
+ if (el.shadowRoot && !el.hasAttribute("data-rcnr")) {
376
+ return true;
377
+ }
378
+ const id = el.id?.toLowerCase() || "";
379
+ const cls = typeof el.className === "string" ? el.className.toLowerCase() : "";
380
+ for (const attr of el.getAttributeNames?.() || []) {
381
+ const attrLower = attr.toLowerCase();
382
+ if (EXTENSION_SIGNATURES.some((sig) => attrLower.includes(sig))) {
383
+ return true;
384
+ }
385
+ }
386
+ if (EXTENSION_SIGNATURES.some(
387
+ (sig) => id.includes(sig) || cls.includes(sig)
388
+ )) {
389
+ return true;
390
+ }
391
+ if (el.parentElement === document.body && !el.hasAttribute("data-rcnr")) {
392
+ const tag = el.tagName?.toLowerCase() || "";
393
+ if (tag === "div" || tag === "section" || tag === "aside") {
394
+ const style = window.getComputedStyle(el);
395
+ if ((style.position === "fixed" || style.position === "absolute") && parseInt(style.width, 10) > 200) {
396
+ const html = el.innerHTML?.toLowerCase() || "";
397
+ if (/chrome-extension:|moz-extension:/i.test(html)) {
398
+ return true;
399
+ }
400
+ }
401
+ }
402
+ }
403
+ return false;
404
+ }
405
+ const observer = new MutationObserver((mutations) => {
406
+ if (!hasEnteredFullscreenRef.current) return;
407
+ if (graceRef.current) return;
408
+ if (autoSubmittedRef.current) return;
409
+ for (const mutation of mutations) {
410
+ for (const node of mutation.addedNodes) {
411
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
412
+ if (isExtensionElement(node)) {
413
+ addViolation("extension_detected");
414
+ return;
415
+ }
416
+ }
417
+ }
418
+ });
419
+ observer.observe(document.documentElement, {
420
+ childList: true,
421
+ subtree: true
422
+ });
423
+ const scanTimer = requestAnimationFrame(() => {
424
+ if (!hasEnteredFullscreenRef.current) return;
425
+ const allElements = document.querySelectorAll("*");
426
+ for (const el of allElements) {
427
+ if (isExtensionElement(el)) {
428
+ addViolation("extension_detected");
429
+ break;
430
+ }
431
+ }
432
+ });
433
+ return () => {
434
+ observer.disconnect();
435
+ cancelAnimationFrame(scanTimer);
436
+ };
437
+ }, [enabled, addViolation]);
316
438
  (0, import_react.useEffect)(() => {
317
439
  if (!enabled) return;
318
440
  const interval = setInterval(() => {
package/dist/index.mjs CHANGED
@@ -7,7 +7,10 @@ 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",
12
+ "voice_input",
13
+ "pip_detected"
11
14
  ]);
12
15
 
13
16
  // src/useLockdown.ts
@@ -240,6 +243,10 @@ function useLockdown({
240
243
  e.preventDefault();
241
244
  return;
242
245
  }
246
+ if (modKey && (e.key.toLowerCase() === "f" || e.key.toLowerCase() === "h")) {
247
+ e.preventDefault();
248
+ return;
249
+ }
243
250
  if (e.altKey && e.key === "Tab") {
244
251
  e.preventDefault();
245
252
  return;
@@ -252,6 +259,19 @@ function useLockdown({
252
259
  function handleContextMenu(e) {
253
260
  e.preventDefault();
254
261
  }
262
+ function handleBeforeInput(e) {
263
+ const inputEvent = e;
264
+ const type = inputEvent.inputType;
265
+ if (type === "insertFromDictation" || type === "insertFromVoice" || // Some browsers report dictation as insertReplacementText
266
+ // but only flag it if it inserts a lot of text at once (>50 chars)
267
+ type === "insertReplacementText" && inputEvent.data && inputEvent.data.length > 50) {
268
+ e.preventDefault();
269
+ addViolation("voice_input");
270
+ }
271
+ }
272
+ function handlePipEnter() {
273
+ addViolation("pip_detected");
274
+ }
255
275
  function handleFocus() {
256
276
  if (countdownIntervalRef.current) {
257
277
  clearCountdown();
@@ -270,6 +290,8 @@ function useLockdown({
270
290
  document.addEventListener("dragover", handleDragOver);
271
291
  document.addEventListener("keydown", handleKeydown);
272
292
  document.addEventListener("contextmenu", handleContextMenu);
293
+ document.addEventListener("beforeinput", handleBeforeInput);
294
+ document.addEventListener("enterpictureinpicture", handlePipEnter, true);
273
295
  return () => {
274
296
  document.removeEventListener("fullscreenchange", handleFullscreenChange);
275
297
  document.removeEventListener("visibilitychange", handleVisibilityChange);
@@ -284,8 +306,108 @@ function useLockdown({
284
306
  document.removeEventListener("dragover", handleDragOver);
285
307
  document.removeEventListener("keydown", handleKeydown);
286
308
  document.removeEventListener("contextmenu", handleContextMenu);
309
+ document.removeEventListener("beforeinput", handleBeforeInput);
310
+ document.removeEventListener("enterpictureinpicture", handlePipEnter, true);
287
311
  };
288
312
  }, [enabled, addViolation, startCountdown, clearCountdown]);
313
+ useEffect(() => {
314
+ if (!enabled) return;
315
+ const EXTENSION_SIGNATURES = [
316
+ "grammarly",
317
+ "schoolai",
318
+ "quillbot",
319
+ "languagetool",
320
+ "ginger",
321
+ "writefull",
322
+ "wordtune",
323
+ "prowritingaid",
324
+ "hemingway",
325
+ "copyleaks",
326
+ "jenni",
327
+ "jasper",
328
+ "textblaze",
329
+ "compose-ai",
330
+ "hyperwrite",
331
+ "otter-ai",
332
+ "fireflies"
333
+ ];
334
+ function isExtensionElement(el) {
335
+ if (el.tagName === "IFRAME") {
336
+ const src = el.getAttribute("src") || "";
337
+ if (/^(chrome|moz)-extension:\/\//i.test(src)) return true;
338
+ if (!src || src.startsWith("blob:")) {
339
+ const id2 = el.id?.toLowerCase() || "";
340
+ const cls2 = typeof el.className === "string" ? el.className.toLowerCase() : "";
341
+ if (EXTENSION_SIGNATURES.some(
342
+ (sig) => id2.includes(sig) || cls2.includes(sig)
343
+ )) {
344
+ return true;
345
+ }
346
+ }
347
+ }
348
+ if (el.shadowRoot && !el.hasAttribute("data-rcnr")) {
349
+ return true;
350
+ }
351
+ const id = el.id?.toLowerCase() || "";
352
+ const cls = typeof el.className === "string" ? el.className.toLowerCase() : "";
353
+ for (const attr of el.getAttributeNames?.() || []) {
354
+ const attrLower = attr.toLowerCase();
355
+ if (EXTENSION_SIGNATURES.some((sig) => attrLower.includes(sig))) {
356
+ return true;
357
+ }
358
+ }
359
+ if (EXTENSION_SIGNATURES.some(
360
+ (sig) => id.includes(sig) || cls.includes(sig)
361
+ )) {
362
+ return true;
363
+ }
364
+ if (el.parentElement === document.body && !el.hasAttribute("data-rcnr")) {
365
+ const tag = el.tagName?.toLowerCase() || "";
366
+ if (tag === "div" || tag === "section" || tag === "aside") {
367
+ const style = window.getComputedStyle(el);
368
+ if ((style.position === "fixed" || style.position === "absolute") && parseInt(style.width, 10) > 200) {
369
+ const html = el.innerHTML?.toLowerCase() || "";
370
+ if (/chrome-extension:|moz-extension:/i.test(html)) {
371
+ return true;
372
+ }
373
+ }
374
+ }
375
+ }
376
+ return false;
377
+ }
378
+ const observer = new MutationObserver((mutations) => {
379
+ if (!hasEnteredFullscreenRef.current) return;
380
+ if (graceRef.current) return;
381
+ if (autoSubmittedRef.current) return;
382
+ for (const mutation of mutations) {
383
+ for (const node of mutation.addedNodes) {
384
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
385
+ if (isExtensionElement(node)) {
386
+ addViolation("extension_detected");
387
+ return;
388
+ }
389
+ }
390
+ }
391
+ });
392
+ observer.observe(document.documentElement, {
393
+ childList: true,
394
+ subtree: true
395
+ });
396
+ const scanTimer = requestAnimationFrame(() => {
397
+ if (!hasEnteredFullscreenRef.current) return;
398
+ const allElements = document.querySelectorAll("*");
399
+ for (const el of allElements) {
400
+ if (isExtensionElement(el)) {
401
+ addViolation("extension_detected");
402
+ break;
403
+ }
404
+ }
405
+ });
406
+ return () => {
407
+ observer.disconnect();
408
+ cancelAnimationFrame(scanTimer);
409
+ };
410
+ }, [enabled, addViolation]);
289
411
  useEffect(() => {
290
412
  if (!enabled) return;
291
413
  const interval = setInterval(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rcnr/lockdown",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Shared fullscreen lockdown hook for RCNR student frontends",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",