@rcnr/lockdown 1.0.0 → 1.1.1

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,18 +144,20 @@ 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);
150
+ startCountdown();
147
151
  } else {
148
152
  setWarning(
149
- `Warning: you have ${remaining} chance${remaining > 1 ? "s" : ""} left to re-enter fullscreen.`
153
+ `Warning: you left the writing window. You have ${remaining} chance${remaining > 1 ? "s" : ""} left.`
150
154
  );
151
155
  setTimeout(() => setWarning(null), WARNING_DISPLAY_MS);
156
+ startCountdown();
152
157
  }
153
158
  }
154
159
  },
155
- [triggerAutoSubmit]
160
+ [triggerAutoSubmit, startCountdown]
156
161
  );
157
162
  const enterFullscreen = (0, import_react.useCallback)(async () => {
158
163
  try {
@@ -195,9 +200,6 @@ function useLockdown({
195
200
  } else if (!graceRef.current && hasEnteredFullscreenRef.current) {
196
201
  lastFsExitRef.current = Date.now();
197
202
  addViolation("fullscreen_exit");
198
- if (fullscreenExitCountRef.current <= MAX_FULLSCREEN_EXITS) {
199
- startCountdown();
200
- }
201
203
  }
202
204
  }
203
205
  function handleVisibilityChange() {
@@ -207,7 +209,6 @@ function useLockdown({
207
209
  }
208
210
  function handleBlur() {
209
211
  if (graceRef.current) return;
210
- if (countdownIntervalRef.current) return;
211
212
  if (Date.now() - lastFsExitRef.current < BLUR_SUPPRESS_AFTER_FS_EXIT_MS)
212
213
  return;
213
214
  addViolation("window_blur");
@@ -253,6 +254,11 @@ function useLockdown({
253
254
  addViolation("devtools_attempt");
254
255
  return;
255
256
  }
257
+ if (modKey && e.key.toLowerCase() === "n") {
258
+ e.preventDefault();
259
+ addViolation("window_blur");
260
+ return;
261
+ }
256
262
  if (modKey && e.key.toLowerCase() === "u") {
257
263
  e.preventDefault();
258
264
  return;
@@ -261,13 +267,27 @@ function useLockdown({
261
267
  e.preventDefault();
262
268
  return;
263
269
  }
270
+ if (e.altKey && e.key === "Tab") {
271
+ e.preventDefault();
272
+ return;
273
+ }
274
+ if (e.key === "Meta" || e.key === "OS") {
275
+ e.preventDefault();
276
+ return;
277
+ }
264
278
  }
265
279
  function handleContextMenu(e) {
266
280
  e.preventDefault();
267
281
  }
282
+ function handleFocus() {
283
+ if (countdownIntervalRef.current) {
284
+ clearCountdown();
285
+ }
286
+ }
268
287
  document.addEventListener("fullscreenchange", handleFullscreenChange);
269
288
  document.addEventListener("visibilitychange", handleVisibilityChange);
270
289
  window.addEventListener("blur", handleBlur);
290
+ window.addEventListener("focus", handleFocus);
271
291
  document.addEventListener("paste", handlePaste);
272
292
  document.addEventListener("copy", handleCopy);
273
293
  document.addEventListener("cut", handleCut);
@@ -281,6 +301,7 @@ function useLockdown({
281
301
  document.removeEventListener("fullscreenchange", handleFullscreenChange);
282
302
  document.removeEventListener("visibilitychange", handleVisibilityChange);
283
303
  window.removeEventListener("blur", handleBlur);
304
+ window.removeEventListener("focus", handleFocus);
284
305
  document.removeEventListener("paste", handlePaste);
285
306
  document.removeEventListener("copy", handleCopy);
286
307
  document.removeEventListener("cut", handleCut);
@@ -292,6 +313,24 @@ function useLockdown({
292
313
  document.removeEventListener("contextmenu", handleContextMenu);
293
314
  };
294
315
  }, [enabled, addViolation, startCountdown, clearCountdown]);
316
+ (0, import_react.useEffect)(() => {
317
+ if (!enabled) return;
318
+ const interval = setInterval(() => {
319
+ if (!hasEnteredFullscreenRef.current) return;
320
+ if (graceRef.current) return;
321
+ if (autoSubmittedRef.current) return;
322
+ if (!document.hasFocus()) {
323
+ const now = Date.now();
324
+ if (now - lastFocusPollViolationRef.current < FOCUS_POLL_COOLDOWN_MS)
325
+ return;
326
+ if (now - lastFsExitRef.current < BLUR_SUPPRESS_AFTER_FS_EXIT_MS)
327
+ return;
328
+ lastFocusPollViolationRef.current = now;
329
+ addViolation("window_blur");
330
+ }
331
+ }, FOCUS_POLL_INTERVAL_MS);
332
+ return () => clearInterval(interval);
333
+ }, [enabled, addViolation]);
295
334
  return {
296
335
  isFullscreen,
297
336
  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,18 +117,20 @@ 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);
123
+ startCountdown();
120
124
  } else {
121
125
  setWarning(
122
- `Warning: you have ${remaining} chance${remaining > 1 ? "s" : ""} left to re-enter fullscreen.`
126
+ `Warning: you left the writing window. You have ${remaining} chance${remaining > 1 ? "s" : ""} left.`
123
127
  );
124
128
  setTimeout(() => setWarning(null), WARNING_DISPLAY_MS);
129
+ startCountdown();
125
130
  }
126
131
  }
127
132
  },
128
- [triggerAutoSubmit]
133
+ [triggerAutoSubmit, startCountdown]
129
134
  );
130
135
  const enterFullscreen = useCallback(async () => {
131
136
  try {
@@ -168,9 +173,6 @@ function useLockdown({
168
173
  } else if (!graceRef.current && hasEnteredFullscreenRef.current) {
169
174
  lastFsExitRef.current = Date.now();
170
175
  addViolation("fullscreen_exit");
171
- if (fullscreenExitCountRef.current <= MAX_FULLSCREEN_EXITS) {
172
- startCountdown();
173
- }
174
176
  }
175
177
  }
176
178
  function handleVisibilityChange() {
@@ -180,7 +182,6 @@ function useLockdown({
180
182
  }
181
183
  function handleBlur() {
182
184
  if (graceRef.current) return;
183
- if (countdownIntervalRef.current) return;
184
185
  if (Date.now() - lastFsExitRef.current < BLUR_SUPPRESS_AFTER_FS_EXIT_MS)
185
186
  return;
186
187
  addViolation("window_blur");
@@ -226,6 +227,11 @@ function useLockdown({
226
227
  addViolation("devtools_attempt");
227
228
  return;
228
229
  }
230
+ if (modKey && e.key.toLowerCase() === "n") {
231
+ e.preventDefault();
232
+ addViolation("window_blur");
233
+ return;
234
+ }
229
235
  if (modKey && e.key.toLowerCase() === "u") {
230
236
  e.preventDefault();
231
237
  return;
@@ -234,13 +240,27 @@ function useLockdown({
234
240
  e.preventDefault();
235
241
  return;
236
242
  }
243
+ if (e.altKey && e.key === "Tab") {
244
+ e.preventDefault();
245
+ return;
246
+ }
247
+ if (e.key === "Meta" || e.key === "OS") {
248
+ e.preventDefault();
249
+ return;
250
+ }
237
251
  }
238
252
  function handleContextMenu(e) {
239
253
  e.preventDefault();
240
254
  }
255
+ function handleFocus() {
256
+ if (countdownIntervalRef.current) {
257
+ clearCountdown();
258
+ }
259
+ }
241
260
  document.addEventListener("fullscreenchange", handleFullscreenChange);
242
261
  document.addEventListener("visibilitychange", handleVisibilityChange);
243
262
  window.addEventListener("blur", handleBlur);
263
+ window.addEventListener("focus", handleFocus);
244
264
  document.addEventListener("paste", handlePaste);
245
265
  document.addEventListener("copy", handleCopy);
246
266
  document.addEventListener("cut", handleCut);
@@ -254,6 +274,7 @@ function useLockdown({
254
274
  document.removeEventListener("fullscreenchange", handleFullscreenChange);
255
275
  document.removeEventListener("visibilitychange", handleVisibilityChange);
256
276
  window.removeEventListener("blur", handleBlur);
277
+ window.removeEventListener("focus", handleFocus);
257
278
  document.removeEventListener("paste", handlePaste);
258
279
  document.removeEventListener("copy", handleCopy);
259
280
  document.removeEventListener("cut", handleCut);
@@ -265,6 +286,24 @@ function useLockdown({
265
286
  document.removeEventListener("contextmenu", handleContextMenu);
266
287
  };
267
288
  }, [enabled, addViolation, startCountdown, clearCountdown]);
289
+ useEffect(() => {
290
+ if (!enabled) return;
291
+ const interval = setInterval(() => {
292
+ if (!hasEnteredFullscreenRef.current) return;
293
+ if (graceRef.current) return;
294
+ if (autoSubmittedRef.current) return;
295
+ if (!document.hasFocus()) {
296
+ const now = Date.now();
297
+ if (now - lastFocusPollViolationRef.current < FOCUS_POLL_COOLDOWN_MS)
298
+ return;
299
+ if (now - lastFsExitRef.current < BLUR_SUPPRESS_AFTER_FS_EXIT_MS)
300
+ return;
301
+ lastFocusPollViolationRef.current = now;
302
+ addViolation("window_blur");
303
+ }
304
+ }, FOCUS_POLL_INTERVAL_MS);
305
+ return () => clearInterval(interval);
306
+ }, [enabled, addViolation]);
268
307
  return {
269
308
  isFullscreen,
270
309
  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.1",
4
4
  "description": "Shared fullscreen lockdown hook for RCNR student frontends",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",