@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 +11 -7
- package/dist/index.d.ts +11 -7
- package/dist/index.js +47 -8
- package/dist/index.mjs +47 -8
- package/package.json +1 -1
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 (
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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 (
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|