@rcnr/lockdown 1.0.0 → 1.1.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
@@ -46,18 +46,22 @@ interface UseLockdownReturn {
46
46
  * INSTANT SUBMIT (cheating attempts — never accidental):
47
47
  * copy, cut, paste, external drop, devtools shortcuts
48
48
  *
49
- * 2-STRIKE LIMIT (environmental fullscreen exits only):
50
- * Each fullscreen exit starts a 5-second wall-clock countdown to re-enter.
51
- * Blur/visibility events during an active countdown are suppressed
52
- * (they're a side-effect of being outside fullscreen, not separate offenses).
53
- * After the 2nd fullscreen exit, the next exit (or countdown expiry)
54
- * triggers instant auto-submit.
49
+ * 2-STRIKE LIMIT (any focus loss):
50
+ * fullscreen_exit, window_blur (Alt+Tab, Win key, Ctrl+N), tab_switch
51
+ * ALL burn a strike. After 2 strikes, the next violation auto-submits.
52
+ * Fullscreen exits start a 5-second wall-clock countdown to re-enter.
53
+ *
54
+ * FOCUS POLLING HEARTBEAT (500ms):
55
+ * document.hasFocus() is checked every 500ms as a safety net.
56
+ * This catches OS-level actions (Win key, Alt+Tab, notifications)
57
+ * that don't reliably fire browser blur/visibility events on Windows.
55
58
  *
56
59
  * The countdown uses wall-clock timestamps (Date.now()) so freezing
57
60
  * JS execution (e.g. via browser task manager) cannot buy extra time.
58
61
  *
59
62
  * Also blocked (no violation, just prevented):
60
- * view source (Ctrl/Cmd+U), print (Ctrl/Cmd+P), context menu (right-click)
63
+ * view source (Ctrl/Cmd+U), print (Ctrl/Cmd+P), context menu (right-click),
64
+ * Ctrl/Cmd+N (new window), Alt+Tab, Meta/Win key
61
65
  *
62
66
  * Philosophy: brutally simple. Not Proctorio. Just honest guardrails.
63
67
  * If a student makes an honest mistake, the teacher can reset their access.
package/dist/index.d.ts CHANGED
@@ -46,18 +46,22 @@ interface UseLockdownReturn {
46
46
  * INSTANT SUBMIT (cheating attempts — never accidental):
47
47
  * copy, cut, paste, external drop, devtools shortcuts
48
48
  *
49
- * 2-STRIKE LIMIT (environmental fullscreen exits only):
50
- * Each fullscreen exit starts a 5-second wall-clock countdown to re-enter.
51
- * Blur/visibility events during an active countdown are suppressed
52
- * (they're a side-effect of being outside fullscreen, not separate offenses).
53
- * After the 2nd fullscreen exit, the next exit (or countdown expiry)
54
- * triggers instant auto-submit.
49
+ * 2-STRIKE LIMIT (any focus loss):
50
+ * fullscreen_exit, window_blur (Alt+Tab, Win key, Ctrl+N), tab_switch
51
+ * ALL burn a strike. After 2 strikes, the next violation auto-submits.
52
+ * Fullscreen exits start a 5-second wall-clock countdown to re-enter.
53
+ *
54
+ * FOCUS POLLING HEARTBEAT (500ms):
55
+ * document.hasFocus() is checked every 500ms as a safety net.
56
+ * This catches OS-level actions (Win key, Alt+Tab, notifications)
57
+ * that don't reliably fire browser blur/visibility events on Windows.
55
58
  *
56
59
  * The countdown uses wall-clock timestamps (Date.now()) so freezing
57
60
  * JS execution (e.g. via browser task manager) cannot buy extra time.
58
61
  *
59
62
  * Also blocked (no violation, just prevented):
60
- * view source (Ctrl/Cmd+U), print (Ctrl/Cmd+P), context menu (right-click)
63
+ * view source (Ctrl/Cmd+U), print (Ctrl/Cmd+P), context menu (right-click),
64
+ * Ctrl/Cmd+N (new window), Alt+Tab, Meta/Win key
61
65
  *
62
66
  * Philosophy: brutally simple. Not Proctorio. Just honest guardrails.
63
67
  * If a student makes an honest mistake, the teacher can reset their access.
package/dist/index.js CHANGED
@@ -44,6 +44,8 @@ var DEVTOOLS_KEYS = ["I", "J", "C", "K"];
44
44
  var BLUR_SUPPRESS_AFTER_FS_EXIT_MS = 500;
45
45
  var FULLSCREEN_REENTRY_SECONDS = 5;
46
46
  var MAX_FULLSCREEN_EXITS = 2;
47
+ var FOCUS_POLL_INTERVAL_MS = 500;
48
+ var FOCUS_POLL_COOLDOWN_MS = 2e3;
47
49
  function detectMobileDevice() {
48
50
  if (typeof navigator === "undefined") return false;
49
51
  const hasTouch = navigator.maxTouchPoints > 0;
@@ -78,6 +80,7 @@ function useLockdown({
78
80
  const autoSubmittedRef = (0, import_react.useRef)(false);
79
81
  const internalDragRef = (0, import_react.useRef)(false);
80
82
  const hasEnteredFullscreenRef = (0, import_react.useRef)(false);
83
+ const lastFocusPollViolationRef = (0, import_react.useRef)(0);
81
84
  const onAutoSubmitRef = (0, import_react.useRef)(onAutoSubmit);
82
85
  (0, import_react.useEffect)(() => {
83
86
  onAutoSubmitRef.current = onAutoSubmit;
@@ -132,7 +135,7 @@ function useLockdown({
132
135
  triggerAutoSubmit();
133
136
  return;
134
137
  }
135
- if (type === "fullscreen_exit") {
138
+ if (type === "fullscreen_exit" || type === "window_blur" || type === "tab_switch") {
136
139
  fullscreenExitCountRef.current += 1;
137
140
  const remaining = MAX_FULLSCREEN_EXITS - fullscreenExitCountRef.current;
138
141
  setStrikesRemaining(remaining);
@@ -141,12 +144,12 @@ function useLockdown({
141
144
  triggerAutoSubmit();
142
145
  } else if (remaining === 0) {
143
146
  setWarning(
144
- "Final warning: leave fullscreen again and your work will be auto-submitted."
147
+ "Final warning: leave this window again and your work will be auto-submitted."
145
148
  );
146
149
  setTimeout(() => setWarning(null), WARNING_DISPLAY_MS);
147
150
  } else {
148
151
  setWarning(
149
- `Warning: you have ${remaining} chance${remaining > 1 ? "s" : ""} left to re-enter fullscreen.`
152
+ `Warning: you left the writing window. You have ${remaining} chance${remaining > 1 ? "s" : ""} left.`
150
153
  );
151
154
  setTimeout(() => setWarning(null), WARNING_DISPLAY_MS);
152
155
  }
@@ -207,7 +210,6 @@ function useLockdown({
207
210
  }
208
211
  function handleBlur() {
209
212
  if (graceRef.current) return;
210
- if (countdownIntervalRef.current) return;
211
213
  if (Date.now() - lastFsExitRef.current < BLUR_SUPPRESS_AFTER_FS_EXIT_MS)
212
214
  return;
213
215
  addViolation("window_blur");
@@ -253,6 +255,11 @@ function useLockdown({
253
255
  addViolation("devtools_attempt");
254
256
  return;
255
257
  }
258
+ if (modKey && e.key.toLowerCase() === "n") {
259
+ e.preventDefault();
260
+ addViolation("window_blur");
261
+ return;
262
+ }
256
263
  if (modKey && e.key.toLowerCase() === "u") {
257
264
  e.preventDefault();
258
265
  return;
@@ -261,6 +268,14 @@ function useLockdown({
261
268
  e.preventDefault();
262
269
  return;
263
270
  }
271
+ if (e.altKey && e.key === "Tab") {
272
+ e.preventDefault();
273
+ return;
274
+ }
275
+ if (e.key === "Meta" || e.key === "OS") {
276
+ e.preventDefault();
277
+ return;
278
+ }
264
279
  }
265
280
  function handleContextMenu(e) {
266
281
  e.preventDefault();
@@ -292,6 +307,24 @@ function useLockdown({
292
307
  document.removeEventListener("contextmenu", handleContextMenu);
293
308
  };
294
309
  }, [enabled, addViolation, startCountdown, clearCountdown]);
310
+ (0, import_react.useEffect)(() => {
311
+ if (!enabled) return;
312
+ const interval = setInterval(() => {
313
+ if (!hasEnteredFullscreenRef.current) return;
314
+ if (graceRef.current) return;
315
+ if (autoSubmittedRef.current) return;
316
+ if (!document.hasFocus()) {
317
+ const now = Date.now();
318
+ if (now - lastFocusPollViolationRef.current < FOCUS_POLL_COOLDOWN_MS)
319
+ return;
320
+ if (now - lastFsExitRef.current < BLUR_SUPPRESS_AFTER_FS_EXIT_MS)
321
+ return;
322
+ lastFocusPollViolationRef.current = now;
323
+ addViolation("window_blur");
324
+ }
325
+ }, FOCUS_POLL_INTERVAL_MS);
326
+ return () => clearInterval(interval);
327
+ }, [enabled, addViolation]);
295
328
  return {
296
329
  isFullscreen,
297
330
  isMobileDevice,
package/dist/index.mjs CHANGED
@@ -17,6 +17,8 @@ var DEVTOOLS_KEYS = ["I", "J", "C", "K"];
17
17
  var BLUR_SUPPRESS_AFTER_FS_EXIT_MS = 500;
18
18
  var FULLSCREEN_REENTRY_SECONDS = 5;
19
19
  var MAX_FULLSCREEN_EXITS = 2;
20
+ var FOCUS_POLL_INTERVAL_MS = 500;
21
+ var FOCUS_POLL_COOLDOWN_MS = 2e3;
20
22
  function detectMobileDevice() {
21
23
  if (typeof navigator === "undefined") return false;
22
24
  const hasTouch = navigator.maxTouchPoints > 0;
@@ -51,6 +53,7 @@ function useLockdown({
51
53
  const autoSubmittedRef = useRef(false);
52
54
  const internalDragRef = useRef(false);
53
55
  const hasEnteredFullscreenRef = useRef(false);
56
+ const lastFocusPollViolationRef = useRef(0);
54
57
  const onAutoSubmitRef = useRef(onAutoSubmit);
55
58
  useEffect(() => {
56
59
  onAutoSubmitRef.current = onAutoSubmit;
@@ -105,7 +108,7 @@ function useLockdown({
105
108
  triggerAutoSubmit();
106
109
  return;
107
110
  }
108
- if (type === "fullscreen_exit") {
111
+ if (type === "fullscreen_exit" || type === "window_blur" || type === "tab_switch") {
109
112
  fullscreenExitCountRef.current += 1;
110
113
  const remaining = MAX_FULLSCREEN_EXITS - fullscreenExitCountRef.current;
111
114
  setStrikesRemaining(remaining);
@@ -114,12 +117,12 @@ function useLockdown({
114
117
  triggerAutoSubmit();
115
118
  } else if (remaining === 0) {
116
119
  setWarning(
117
- "Final warning: leave fullscreen again and your work will be auto-submitted."
120
+ "Final warning: leave this window again and your work will be auto-submitted."
118
121
  );
119
122
  setTimeout(() => setWarning(null), WARNING_DISPLAY_MS);
120
123
  } else {
121
124
  setWarning(
122
- `Warning: you have ${remaining} chance${remaining > 1 ? "s" : ""} left to re-enter fullscreen.`
125
+ `Warning: you left the writing window. You have ${remaining} chance${remaining > 1 ? "s" : ""} left.`
123
126
  );
124
127
  setTimeout(() => setWarning(null), WARNING_DISPLAY_MS);
125
128
  }
@@ -180,7 +183,6 @@ function useLockdown({
180
183
  }
181
184
  function handleBlur() {
182
185
  if (graceRef.current) return;
183
- if (countdownIntervalRef.current) return;
184
186
  if (Date.now() - lastFsExitRef.current < BLUR_SUPPRESS_AFTER_FS_EXIT_MS)
185
187
  return;
186
188
  addViolation("window_blur");
@@ -226,6 +228,11 @@ function useLockdown({
226
228
  addViolation("devtools_attempt");
227
229
  return;
228
230
  }
231
+ if (modKey && e.key.toLowerCase() === "n") {
232
+ e.preventDefault();
233
+ addViolation("window_blur");
234
+ return;
235
+ }
229
236
  if (modKey && e.key.toLowerCase() === "u") {
230
237
  e.preventDefault();
231
238
  return;
@@ -234,6 +241,14 @@ function useLockdown({
234
241
  e.preventDefault();
235
242
  return;
236
243
  }
244
+ if (e.altKey && e.key === "Tab") {
245
+ e.preventDefault();
246
+ return;
247
+ }
248
+ if (e.key === "Meta" || e.key === "OS") {
249
+ e.preventDefault();
250
+ return;
251
+ }
237
252
  }
238
253
  function handleContextMenu(e) {
239
254
  e.preventDefault();
@@ -265,6 +280,24 @@ function useLockdown({
265
280
  document.removeEventListener("contextmenu", handleContextMenu);
266
281
  };
267
282
  }, [enabled, addViolation, startCountdown, clearCountdown]);
283
+ useEffect(() => {
284
+ if (!enabled) return;
285
+ const interval = setInterval(() => {
286
+ if (!hasEnteredFullscreenRef.current) return;
287
+ if (graceRef.current) return;
288
+ if (autoSubmittedRef.current) return;
289
+ if (!document.hasFocus()) {
290
+ const now = Date.now();
291
+ if (now - lastFocusPollViolationRef.current < FOCUS_POLL_COOLDOWN_MS)
292
+ return;
293
+ if (now - lastFsExitRef.current < BLUR_SUPPRESS_AFTER_FS_EXIT_MS)
294
+ return;
295
+ lastFocusPollViolationRef.current = now;
296
+ addViolation("window_blur");
297
+ }
298
+ }, FOCUS_POLL_INTERVAL_MS);
299
+ return () => clearInterval(interval);
300
+ }, [enabled, addViolation]);
268
301
  return {
269
302
  isFullscreen,
270
303
  isMobileDevice,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rcnr/lockdown",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Shared fullscreen lockdown hook for RCNR student frontends",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",