@rcnr/lockdown 1.0.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 +68 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.js +310 -0
- package/dist/index.mjs +282 -0
- package/package.json +44 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** A recorded lockdown violation. */
|
|
2
|
+
interface Violation {
|
|
3
|
+
type: ViolationType;
|
|
4
|
+
timestamp: number;
|
|
5
|
+
}
|
|
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";
|
|
8
|
+
/** Violations that trigger instant auto-submit (cheating attempts). */
|
|
9
|
+
declare const INSTANT_SUBMIT_VIOLATIONS: Set<ViolationType>;
|
|
10
|
+
/** Configuration for the lockdown hook. */
|
|
11
|
+
interface UseLockdownOptions {
|
|
12
|
+
/** Whether lockdown enforcement is active. */
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
/** Grace period in ms after initial fullscreen entry. Default: 5000. */
|
|
15
|
+
gracePeriodMs?: number;
|
|
16
|
+
/** Called when auto-submit is triggered (cheating or strikes exhausted). */
|
|
17
|
+
onAutoSubmit: () => void;
|
|
18
|
+
/** Optional callback fired on every violation (e.g. for backend reporting). */
|
|
19
|
+
onViolation?: (violation: Violation) => void;
|
|
20
|
+
}
|
|
21
|
+
/** Return value from the lockdown hook. */
|
|
22
|
+
interface UseLockdownReturn {
|
|
23
|
+
/** Whether the browser is currently in fullscreen mode. */
|
|
24
|
+
isFullscreen: boolean;
|
|
25
|
+
/** Whether the device is mobile/tablet (cannot support fullscreen lockdown). */
|
|
26
|
+
isMobileDevice: boolean;
|
|
27
|
+
/** All recorded violations. */
|
|
28
|
+
violations: Violation[];
|
|
29
|
+
/** Current warning message, or null. */
|
|
30
|
+
warning: string | null;
|
|
31
|
+
/** Seconds remaining to re-enter fullscreen, or null if in fullscreen. */
|
|
32
|
+
fullscreenCountdown: number | null;
|
|
33
|
+
/** How many fullscreen exits remain before auto-submit. */
|
|
34
|
+
strikesRemaining: number;
|
|
35
|
+
/** Whether fullscreen has been entered at least once. */
|
|
36
|
+
hasEnteredFullscreen: boolean;
|
|
37
|
+
/** Request fullscreen entry. */
|
|
38
|
+
enterFullscreen: () => Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Lockdown hook — enforces fullscreen environment for student tools.
|
|
43
|
+
*
|
|
44
|
+
* Two-tier violation policy:
|
|
45
|
+
*
|
|
46
|
+
* INSTANT SUBMIT (cheating attempts — never accidental):
|
|
47
|
+
* copy, cut, paste, external drop, devtools shortcuts
|
|
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.
|
|
55
|
+
*
|
|
56
|
+
* The countdown uses wall-clock timestamps (Date.now()) so freezing
|
|
57
|
+
* JS execution (e.g. via browser task manager) cannot buy extra time.
|
|
58
|
+
*
|
|
59
|
+
* Also blocked (no violation, just prevented):
|
|
60
|
+
* view source (Ctrl/Cmd+U), print (Ctrl/Cmd+P), context menu (right-click)
|
|
61
|
+
*
|
|
62
|
+
* Philosophy: brutally simple. Not Proctorio. Just honest guardrails.
|
|
63
|
+
* If a student makes an honest mistake, the teacher can reset their access.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
declare function useLockdown({ enabled, gracePeriodMs, onAutoSubmit, onViolation, }: UseLockdownOptions): UseLockdownReturn;
|
|
67
|
+
|
|
68
|
+
export { INSTANT_SUBMIT_VIOLATIONS, type UseLockdownOptions, type UseLockdownReturn, type Violation, type ViolationType, useLockdown };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** A recorded lockdown violation. */
|
|
2
|
+
interface Violation {
|
|
3
|
+
type: ViolationType;
|
|
4
|
+
timestamp: number;
|
|
5
|
+
}
|
|
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";
|
|
8
|
+
/** Violations that trigger instant auto-submit (cheating attempts). */
|
|
9
|
+
declare const INSTANT_SUBMIT_VIOLATIONS: Set<ViolationType>;
|
|
10
|
+
/** Configuration for the lockdown hook. */
|
|
11
|
+
interface UseLockdownOptions {
|
|
12
|
+
/** Whether lockdown enforcement is active. */
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
/** Grace period in ms after initial fullscreen entry. Default: 5000. */
|
|
15
|
+
gracePeriodMs?: number;
|
|
16
|
+
/** Called when auto-submit is triggered (cheating or strikes exhausted). */
|
|
17
|
+
onAutoSubmit: () => void;
|
|
18
|
+
/** Optional callback fired on every violation (e.g. for backend reporting). */
|
|
19
|
+
onViolation?: (violation: Violation) => void;
|
|
20
|
+
}
|
|
21
|
+
/** Return value from the lockdown hook. */
|
|
22
|
+
interface UseLockdownReturn {
|
|
23
|
+
/** Whether the browser is currently in fullscreen mode. */
|
|
24
|
+
isFullscreen: boolean;
|
|
25
|
+
/** Whether the device is mobile/tablet (cannot support fullscreen lockdown). */
|
|
26
|
+
isMobileDevice: boolean;
|
|
27
|
+
/** All recorded violations. */
|
|
28
|
+
violations: Violation[];
|
|
29
|
+
/** Current warning message, or null. */
|
|
30
|
+
warning: string | null;
|
|
31
|
+
/** Seconds remaining to re-enter fullscreen, or null if in fullscreen. */
|
|
32
|
+
fullscreenCountdown: number | null;
|
|
33
|
+
/** How many fullscreen exits remain before auto-submit. */
|
|
34
|
+
strikesRemaining: number;
|
|
35
|
+
/** Whether fullscreen has been entered at least once. */
|
|
36
|
+
hasEnteredFullscreen: boolean;
|
|
37
|
+
/** Request fullscreen entry. */
|
|
38
|
+
enterFullscreen: () => Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Lockdown hook — enforces fullscreen environment for student tools.
|
|
43
|
+
*
|
|
44
|
+
* Two-tier violation policy:
|
|
45
|
+
*
|
|
46
|
+
* INSTANT SUBMIT (cheating attempts — never accidental):
|
|
47
|
+
* copy, cut, paste, external drop, devtools shortcuts
|
|
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.
|
|
55
|
+
*
|
|
56
|
+
* The countdown uses wall-clock timestamps (Date.now()) so freezing
|
|
57
|
+
* JS execution (e.g. via browser task manager) cannot buy extra time.
|
|
58
|
+
*
|
|
59
|
+
* Also blocked (no violation, just prevented):
|
|
60
|
+
* view source (Ctrl/Cmd+U), print (Ctrl/Cmd+P), context menu (right-click)
|
|
61
|
+
*
|
|
62
|
+
* Philosophy: brutally simple. Not Proctorio. Just honest guardrails.
|
|
63
|
+
* If a student makes an honest mistake, the teacher can reset their access.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
declare function useLockdown({ enabled, gracePeriodMs, onAutoSubmit, onViolation, }: UseLockdownOptions): UseLockdownReturn;
|
|
67
|
+
|
|
68
|
+
export { INSTANT_SUBMIT_VIOLATIONS, type UseLockdownOptions, type UseLockdownReturn, type Violation, type ViolationType, useLockdown };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
INSTANT_SUBMIT_VIOLATIONS: () => INSTANT_SUBMIT_VIOLATIONS,
|
|
24
|
+
useLockdown: () => useLockdown
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
|
|
28
|
+
// src/useLockdown.ts
|
|
29
|
+
var import_react = require("react");
|
|
30
|
+
|
|
31
|
+
// src/types.ts
|
|
32
|
+
var INSTANT_SUBMIT_VIOLATIONS = /* @__PURE__ */ new Set([
|
|
33
|
+
"paste_attempt",
|
|
34
|
+
"copy_attempt",
|
|
35
|
+
"cut_attempt",
|
|
36
|
+
"drop_attempt",
|
|
37
|
+
"devtools_attempt"
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
// src/useLockdown.ts
|
|
41
|
+
var DEFAULT_GRACE_PERIOD_MS = 5e3;
|
|
42
|
+
var WARNING_DISPLAY_MS = 5e3;
|
|
43
|
+
var DEVTOOLS_KEYS = ["I", "J", "C", "K"];
|
|
44
|
+
var BLUR_SUPPRESS_AFTER_FS_EXIT_MS = 500;
|
|
45
|
+
var FULLSCREEN_REENTRY_SECONDS = 5;
|
|
46
|
+
var MAX_FULLSCREEN_EXITS = 2;
|
|
47
|
+
function detectMobileDevice() {
|
|
48
|
+
if (typeof navigator === "undefined") return false;
|
|
49
|
+
const hasTouch = navigator.maxTouchPoints > 0;
|
|
50
|
+
const isSmallScreen = window.screen.width < 1024;
|
|
51
|
+
const mobileUA = /Android|iPhone|iPad|iPod|webOS|BlackBerry|Opera Mini|IEMobile/i.test(
|
|
52
|
+
navigator.userAgent
|
|
53
|
+
);
|
|
54
|
+
return mobileUA || hasTouch && isSmallScreen;
|
|
55
|
+
}
|
|
56
|
+
function useLockdown({
|
|
57
|
+
enabled,
|
|
58
|
+
gracePeriodMs = DEFAULT_GRACE_PERIOD_MS,
|
|
59
|
+
onAutoSubmit,
|
|
60
|
+
onViolation
|
|
61
|
+
}) {
|
|
62
|
+
const [isFullscreen, setIsFullscreen] = (0, import_react.useState)(false);
|
|
63
|
+
const [violations, setViolations] = (0, import_react.useState)([]);
|
|
64
|
+
const [warning, setWarning] = (0, import_react.useState)(null);
|
|
65
|
+
const [isMobileDevice] = (0, import_react.useState)(() => detectMobileDevice());
|
|
66
|
+
const [fullscreenCountdown, setFullscreenCountdown] = (0, import_react.useState)(
|
|
67
|
+
null
|
|
68
|
+
);
|
|
69
|
+
const [strikesRemaining, setStrikesRemaining] = (0, import_react.useState)(MAX_FULLSCREEN_EXITS);
|
|
70
|
+
const [hasEnteredFullscreen, setHasEnteredFullscreen] = (0, import_react.useState)(false);
|
|
71
|
+
const graceRef = (0, import_react.useRef)(false);
|
|
72
|
+
const fullscreenExitCountRef = (0, import_react.useRef)(0);
|
|
73
|
+
const lastFsExitRef = (0, import_react.useRef)(0);
|
|
74
|
+
const countdownIntervalRef = (0, import_react.useRef)(
|
|
75
|
+
null
|
|
76
|
+
);
|
|
77
|
+
const countdownStartRef = (0, import_react.useRef)(0);
|
|
78
|
+
const autoSubmittedRef = (0, import_react.useRef)(false);
|
|
79
|
+
const internalDragRef = (0, import_react.useRef)(false);
|
|
80
|
+
const hasEnteredFullscreenRef = (0, import_react.useRef)(false);
|
|
81
|
+
const onAutoSubmitRef = (0, import_react.useRef)(onAutoSubmit);
|
|
82
|
+
(0, import_react.useEffect)(() => {
|
|
83
|
+
onAutoSubmitRef.current = onAutoSubmit;
|
|
84
|
+
}, [onAutoSubmit]);
|
|
85
|
+
const onViolationRef = (0, import_react.useRef)(onViolation);
|
|
86
|
+
(0, import_react.useEffect)(() => {
|
|
87
|
+
onViolationRef.current = onViolation;
|
|
88
|
+
}, [onViolation]);
|
|
89
|
+
const triggerAutoSubmit = (0, import_react.useCallback)(() => {
|
|
90
|
+
if (autoSubmittedRef.current) return;
|
|
91
|
+
autoSubmittedRef.current = true;
|
|
92
|
+
onAutoSubmitRef.current();
|
|
93
|
+
}, []);
|
|
94
|
+
const clearCountdown = (0, import_react.useCallback)(() => {
|
|
95
|
+
if (countdownIntervalRef.current) {
|
|
96
|
+
clearInterval(countdownIntervalRef.current);
|
|
97
|
+
countdownIntervalRef.current = null;
|
|
98
|
+
}
|
|
99
|
+
countdownStartRef.current = 0;
|
|
100
|
+
setFullscreenCountdown(null);
|
|
101
|
+
}, []);
|
|
102
|
+
const startCountdown = (0, import_react.useCallback)(() => {
|
|
103
|
+
clearCountdown();
|
|
104
|
+
countdownStartRef.current = Date.now();
|
|
105
|
+
setFullscreenCountdown(FULLSCREEN_REENTRY_SECONDS);
|
|
106
|
+
countdownIntervalRef.current = setInterval(() => {
|
|
107
|
+
const elapsed = (Date.now() - countdownStartRef.current) / 1e3;
|
|
108
|
+
const remaining = Math.max(
|
|
109
|
+
0,
|
|
110
|
+
Math.ceil(FULLSCREEN_REENTRY_SECONDS - elapsed)
|
|
111
|
+
);
|
|
112
|
+
setFullscreenCountdown(remaining);
|
|
113
|
+
if (remaining <= 0) {
|
|
114
|
+
clearCountdown();
|
|
115
|
+
triggerAutoSubmit();
|
|
116
|
+
}
|
|
117
|
+
}, 500);
|
|
118
|
+
}, [clearCountdown, triggerAutoSubmit]);
|
|
119
|
+
(0, import_react.useEffect)(() => {
|
|
120
|
+
return () => clearCountdown();
|
|
121
|
+
}, [clearCountdown]);
|
|
122
|
+
const addViolation = (0, import_react.useCallback)(
|
|
123
|
+
(type) => {
|
|
124
|
+
if (graceRef.current) return;
|
|
125
|
+
if (!hasEnteredFullscreenRef.current) return;
|
|
126
|
+
if (autoSubmittedRef.current) return;
|
|
127
|
+
const v = { type, timestamp: Date.now() };
|
|
128
|
+
setViolations((prev) => [...prev, v]);
|
|
129
|
+
onViolationRef.current?.(v);
|
|
130
|
+
if (INSTANT_SUBMIT_VIOLATIONS.has(type)) {
|
|
131
|
+
setWarning("Your work has been submitted.");
|
|
132
|
+
triggerAutoSubmit();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (type === "fullscreen_exit") {
|
|
136
|
+
fullscreenExitCountRef.current += 1;
|
|
137
|
+
const remaining = MAX_FULLSCREEN_EXITS - fullscreenExitCountRef.current;
|
|
138
|
+
setStrikesRemaining(remaining);
|
|
139
|
+
if (remaining < 0) {
|
|
140
|
+
setWarning("Your work has been submitted.");
|
|
141
|
+
triggerAutoSubmit();
|
|
142
|
+
} else if (remaining === 0) {
|
|
143
|
+
setWarning(
|
|
144
|
+
"Final warning: leave fullscreen again and your work will be auto-submitted."
|
|
145
|
+
);
|
|
146
|
+
setTimeout(() => setWarning(null), WARNING_DISPLAY_MS);
|
|
147
|
+
} else {
|
|
148
|
+
setWarning(
|
|
149
|
+
`Warning: you have ${remaining} chance${remaining > 1 ? "s" : ""} left to re-enter fullscreen.`
|
|
150
|
+
);
|
|
151
|
+
setTimeout(() => setWarning(null), WARNING_DISPLAY_MS);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
[triggerAutoSubmit]
|
|
156
|
+
);
|
|
157
|
+
const enterFullscreen = (0, import_react.useCallback)(async () => {
|
|
158
|
+
try {
|
|
159
|
+
await document.documentElement.requestFullscreen();
|
|
160
|
+
setIsFullscreen(true);
|
|
161
|
+
clearCountdown();
|
|
162
|
+
if (!hasEnteredFullscreenRef.current) {
|
|
163
|
+
hasEnteredFullscreenRef.current = true;
|
|
164
|
+
setHasEnteredFullscreen(true);
|
|
165
|
+
graceRef.current = true;
|
|
166
|
+
setTimeout(() => {
|
|
167
|
+
graceRef.current = false;
|
|
168
|
+
}, gracePeriodMs);
|
|
169
|
+
} else {
|
|
170
|
+
setHasEnteredFullscreen(true);
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
if (hasEnteredFullscreenRef.current) {
|
|
174
|
+
startCountdown();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}, [gracePeriodMs, clearCountdown, startCountdown]);
|
|
178
|
+
(0, import_react.useEffect)(() => {
|
|
179
|
+
if (!enabled) return;
|
|
180
|
+
function handleBeforeUnload(e) {
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
}
|
|
183
|
+
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
184
|
+
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
185
|
+
}, [enabled]);
|
|
186
|
+
(0, import_react.useEffect)(() => {
|
|
187
|
+
if (!enabled) return;
|
|
188
|
+
function handleFullscreenChange() {
|
|
189
|
+
const fs = !!document.fullscreenElement;
|
|
190
|
+
setIsFullscreen(fs);
|
|
191
|
+
if (fs) {
|
|
192
|
+
hasEnteredFullscreenRef.current = true;
|
|
193
|
+
setHasEnteredFullscreen(true);
|
|
194
|
+
clearCountdown();
|
|
195
|
+
} else if (!graceRef.current && hasEnteredFullscreenRef.current) {
|
|
196
|
+
lastFsExitRef.current = Date.now();
|
|
197
|
+
addViolation("fullscreen_exit");
|
|
198
|
+
if (fullscreenExitCountRef.current <= MAX_FULLSCREEN_EXITS) {
|
|
199
|
+
startCountdown();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function handleVisibilityChange() {
|
|
204
|
+
if (document.hidden && !graceRef.current && !countdownIntervalRef.current) {
|
|
205
|
+
addViolation("tab_switch");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function handleBlur() {
|
|
209
|
+
if (graceRef.current) return;
|
|
210
|
+
if (countdownIntervalRef.current) return;
|
|
211
|
+
if (Date.now() - lastFsExitRef.current < BLUR_SUPPRESS_AFTER_FS_EXIT_MS)
|
|
212
|
+
return;
|
|
213
|
+
addViolation("window_blur");
|
|
214
|
+
}
|
|
215
|
+
function handlePaste(e) {
|
|
216
|
+
e.preventDefault();
|
|
217
|
+
addViolation("paste_attempt");
|
|
218
|
+
}
|
|
219
|
+
function handleCopy(e) {
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
addViolation("copy_attempt");
|
|
222
|
+
}
|
|
223
|
+
function handleCut(e) {
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
addViolation("cut_attempt");
|
|
226
|
+
}
|
|
227
|
+
function handleDragStart() {
|
|
228
|
+
internalDragRef.current = true;
|
|
229
|
+
}
|
|
230
|
+
function handleDragEnd() {
|
|
231
|
+
internalDragRef.current = false;
|
|
232
|
+
}
|
|
233
|
+
function handleDrop(e) {
|
|
234
|
+
if (internalDragRef.current) {
|
|
235
|
+
internalDragRef.current = false;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
e.preventDefault();
|
|
239
|
+
addViolation("drop_attempt");
|
|
240
|
+
}
|
|
241
|
+
function handleDragOver(e) {
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
}
|
|
244
|
+
function handleKeydown(e) {
|
|
245
|
+
const modKey = e.ctrlKey || e.metaKey;
|
|
246
|
+
if (e.key === "F12") {
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
addViolation("devtools_attempt");
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (modKey && e.shiftKey && DEVTOOLS_KEYS.includes(e.key.toUpperCase())) {
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
addViolation("devtools_attempt");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (modKey && e.key.toLowerCase() === "u") {
|
|
257
|
+
e.preventDefault();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (modKey && e.key.toLowerCase() === "p") {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function handleContextMenu(e) {
|
|
266
|
+
e.preventDefault();
|
|
267
|
+
}
|
|
268
|
+
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
|
269
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
270
|
+
window.addEventListener("blur", handleBlur);
|
|
271
|
+
document.addEventListener("paste", handlePaste);
|
|
272
|
+
document.addEventListener("copy", handleCopy);
|
|
273
|
+
document.addEventListener("cut", handleCut);
|
|
274
|
+
document.addEventListener("dragstart", handleDragStart);
|
|
275
|
+
document.addEventListener("dragend", handleDragEnd);
|
|
276
|
+
document.addEventListener("drop", handleDrop);
|
|
277
|
+
document.addEventListener("dragover", handleDragOver);
|
|
278
|
+
document.addEventListener("keydown", handleKeydown);
|
|
279
|
+
document.addEventListener("contextmenu", handleContextMenu);
|
|
280
|
+
return () => {
|
|
281
|
+
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
|
282
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
283
|
+
window.removeEventListener("blur", handleBlur);
|
|
284
|
+
document.removeEventListener("paste", handlePaste);
|
|
285
|
+
document.removeEventListener("copy", handleCopy);
|
|
286
|
+
document.removeEventListener("cut", handleCut);
|
|
287
|
+
document.removeEventListener("dragstart", handleDragStart);
|
|
288
|
+
document.removeEventListener("dragend", handleDragEnd);
|
|
289
|
+
document.removeEventListener("drop", handleDrop);
|
|
290
|
+
document.removeEventListener("dragover", handleDragOver);
|
|
291
|
+
document.removeEventListener("keydown", handleKeydown);
|
|
292
|
+
document.removeEventListener("contextmenu", handleContextMenu);
|
|
293
|
+
};
|
|
294
|
+
}, [enabled, addViolation, startCountdown, clearCountdown]);
|
|
295
|
+
return {
|
|
296
|
+
isFullscreen,
|
|
297
|
+
isMobileDevice,
|
|
298
|
+
violations,
|
|
299
|
+
warning,
|
|
300
|
+
fullscreenCountdown,
|
|
301
|
+
strikesRemaining,
|
|
302
|
+
hasEnteredFullscreen,
|
|
303
|
+
enterFullscreen
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
307
|
+
0 && (module.exports = {
|
|
308
|
+
INSTANT_SUBMIT_VIOLATIONS,
|
|
309
|
+
useLockdown
|
|
310
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// src/useLockdown.ts
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
// src/types.ts
|
|
5
|
+
var INSTANT_SUBMIT_VIOLATIONS = /* @__PURE__ */ new Set([
|
|
6
|
+
"paste_attempt",
|
|
7
|
+
"copy_attempt",
|
|
8
|
+
"cut_attempt",
|
|
9
|
+
"drop_attempt",
|
|
10
|
+
"devtools_attempt"
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
// src/useLockdown.ts
|
|
14
|
+
var DEFAULT_GRACE_PERIOD_MS = 5e3;
|
|
15
|
+
var WARNING_DISPLAY_MS = 5e3;
|
|
16
|
+
var DEVTOOLS_KEYS = ["I", "J", "C", "K"];
|
|
17
|
+
var BLUR_SUPPRESS_AFTER_FS_EXIT_MS = 500;
|
|
18
|
+
var FULLSCREEN_REENTRY_SECONDS = 5;
|
|
19
|
+
var MAX_FULLSCREEN_EXITS = 2;
|
|
20
|
+
function detectMobileDevice() {
|
|
21
|
+
if (typeof navigator === "undefined") return false;
|
|
22
|
+
const hasTouch = navigator.maxTouchPoints > 0;
|
|
23
|
+
const isSmallScreen = window.screen.width < 1024;
|
|
24
|
+
const mobileUA = /Android|iPhone|iPad|iPod|webOS|BlackBerry|Opera Mini|IEMobile/i.test(
|
|
25
|
+
navigator.userAgent
|
|
26
|
+
);
|
|
27
|
+
return mobileUA || hasTouch && isSmallScreen;
|
|
28
|
+
}
|
|
29
|
+
function useLockdown({
|
|
30
|
+
enabled,
|
|
31
|
+
gracePeriodMs = DEFAULT_GRACE_PERIOD_MS,
|
|
32
|
+
onAutoSubmit,
|
|
33
|
+
onViolation
|
|
34
|
+
}) {
|
|
35
|
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
36
|
+
const [violations, setViolations] = useState([]);
|
|
37
|
+
const [warning, setWarning] = useState(null);
|
|
38
|
+
const [isMobileDevice] = useState(() => detectMobileDevice());
|
|
39
|
+
const [fullscreenCountdown, setFullscreenCountdown] = useState(
|
|
40
|
+
null
|
|
41
|
+
);
|
|
42
|
+
const [strikesRemaining, setStrikesRemaining] = useState(MAX_FULLSCREEN_EXITS);
|
|
43
|
+
const [hasEnteredFullscreen, setHasEnteredFullscreen] = useState(false);
|
|
44
|
+
const graceRef = useRef(false);
|
|
45
|
+
const fullscreenExitCountRef = useRef(0);
|
|
46
|
+
const lastFsExitRef = useRef(0);
|
|
47
|
+
const countdownIntervalRef = useRef(
|
|
48
|
+
null
|
|
49
|
+
);
|
|
50
|
+
const countdownStartRef = useRef(0);
|
|
51
|
+
const autoSubmittedRef = useRef(false);
|
|
52
|
+
const internalDragRef = useRef(false);
|
|
53
|
+
const hasEnteredFullscreenRef = useRef(false);
|
|
54
|
+
const onAutoSubmitRef = useRef(onAutoSubmit);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
onAutoSubmitRef.current = onAutoSubmit;
|
|
57
|
+
}, [onAutoSubmit]);
|
|
58
|
+
const onViolationRef = useRef(onViolation);
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
onViolationRef.current = onViolation;
|
|
61
|
+
}, [onViolation]);
|
|
62
|
+
const triggerAutoSubmit = useCallback(() => {
|
|
63
|
+
if (autoSubmittedRef.current) return;
|
|
64
|
+
autoSubmittedRef.current = true;
|
|
65
|
+
onAutoSubmitRef.current();
|
|
66
|
+
}, []);
|
|
67
|
+
const clearCountdown = useCallback(() => {
|
|
68
|
+
if (countdownIntervalRef.current) {
|
|
69
|
+
clearInterval(countdownIntervalRef.current);
|
|
70
|
+
countdownIntervalRef.current = null;
|
|
71
|
+
}
|
|
72
|
+
countdownStartRef.current = 0;
|
|
73
|
+
setFullscreenCountdown(null);
|
|
74
|
+
}, []);
|
|
75
|
+
const startCountdown = useCallback(() => {
|
|
76
|
+
clearCountdown();
|
|
77
|
+
countdownStartRef.current = Date.now();
|
|
78
|
+
setFullscreenCountdown(FULLSCREEN_REENTRY_SECONDS);
|
|
79
|
+
countdownIntervalRef.current = setInterval(() => {
|
|
80
|
+
const elapsed = (Date.now() - countdownStartRef.current) / 1e3;
|
|
81
|
+
const remaining = Math.max(
|
|
82
|
+
0,
|
|
83
|
+
Math.ceil(FULLSCREEN_REENTRY_SECONDS - elapsed)
|
|
84
|
+
);
|
|
85
|
+
setFullscreenCountdown(remaining);
|
|
86
|
+
if (remaining <= 0) {
|
|
87
|
+
clearCountdown();
|
|
88
|
+
triggerAutoSubmit();
|
|
89
|
+
}
|
|
90
|
+
}, 500);
|
|
91
|
+
}, [clearCountdown, triggerAutoSubmit]);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
return () => clearCountdown();
|
|
94
|
+
}, [clearCountdown]);
|
|
95
|
+
const addViolation = useCallback(
|
|
96
|
+
(type) => {
|
|
97
|
+
if (graceRef.current) return;
|
|
98
|
+
if (!hasEnteredFullscreenRef.current) return;
|
|
99
|
+
if (autoSubmittedRef.current) return;
|
|
100
|
+
const v = { type, timestamp: Date.now() };
|
|
101
|
+
setViolations((prev) => [...prev, v]);
|
|
102
|
+
onViolationRef.current?.(v);
|
|
103
|
+
if (INSTANT_SUBMIT_VIOLATIONS.has(type)) {
|
|
104
|
+
setWarning("Your work has been submitted.");
|
|
105
|
+
triggerAutoSubmit();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (type === "fullscreen_exit") {
|
|
109
|
+
fullscreenExitCountRef.current += 1;
|
|
110
|
+
const remaining = MAX_FULLSCREEN_EXITS - fullscreenExitCountRef.current;
|
|
111
|
+
setStrikesRemaining(remaining);
|
|
112
|
+
if (remaining < 0) {
|
|
113
|
+
setWarning("Your work has been submitted.");
|
|
114
|
+
triggerAutoSubmit();
|
|
115
|
+
} else if (remaining === 0) {
|
|
116
|
+
setWarning(
|
|
117
|
+
"Final warning: leave fullscreen again and your work will be auto-submitted."
|
|
118
|
+
);
|
|
119
|
+
setTimeout(() => setWarning(null), WARNING_DISPLAY_MS);
|
|
120
|
+
} else {
|
|
121
|
+
setWarning(
|
|
122
|
+
`Warning: you have ${remaining} chance${remaining > 1 ? "s" : ""} left to re-enter fullscreen.`
|
|
123
|
+
);
|
|
124
|
+
setTimeout(() => setWarning(null), WARNING_DISPLAY_MS);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
[triggerAutoSubmit]
|
|
129
|
+
);
|
|
130
|
+
const enterFullscreen = useCallback(async () => {
|
|
131
|
+
try {
|
|
132
|
+
await document.documentElement.requestFullscreen();
|
|
133
|
+
setIsFullscreen(true);
|
|
134
|
+
clearCountdown();
|
|
135
|
+
if (!hasEnteredFullscreenRef.current) {
|
|
136
|
+
hasEnteredFullscreenRef.current = true;
|
|
137
|
+
setHasEnteredFullscreen(true);
|
|
138
|
+
graceRef.current = true;
|
|
139
|
+
setTimeout(() => {
|
|
140
|
+
graceRef.current = false;
|
|
141
|
+
}, gracePeriodMs);
|
|
142
|
+
} else {
|
|
143
|
+
setHasEnteredFullscreen(true);
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
if (hasEnteredFullscreenRef.current) {
|
|
147
|
+
startCountdown();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}, [gracePeriodMs, clearCountdown, startCountdown]);
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (!enabled) return;
|
|
153
|
+
function handleBeforeUnload(e) {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
}
|
|
156
|
+
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
157
|
+
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
158
|
+
}, [enabled]);
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (!enabled) return;
|
|
161
|
+
function handleFullscreenChange() {
|
|
162
|
+
const fs = !!document.fullscreenElement;
|
|
163
|
+
setIsFullscreen(fs);
|
|
164
|
+
if (fs) {
|
|
165
|
+
hasEnteredFullscreenRef.current = true;
|
|
166
|
+
setHasEnteredFullscreen(true);
|
|
167
|
+
clearCountdown();
|
|
168
|
+
} else if (!graceRef.current && hasEnteredFullscreenRef.current) {
|
|
169
|
+
lastFsExitRef.current = Date.now();
|
|
170
|
+
addViolation("fullscreen_exit");
|
|
171
|
+
if (fullscreenExitCountRef.current <= MAX_FULLSCREEN_EXITS) {
|
|
172
|
+
startCountdown();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function handleVisibilityChange() {
|
|
177
|
+
if (document.hidden && !graceRef.current && !countdownIntervalRef.current) {
|
|
178
|
+
addViolation("tab_switch");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function handleBlur() {
|
|
182
|
+
if (graceRef.current) return;
|
|
183
|
+
if (countdownIntervalRef.current) return;
|
|
184
|
+
if (Date.now() - lastFsExitRef.current < BLUR_SUPPRESS_AFTER_FS_EXIT_MS)
|
|
185
|
+
return;
|
|
186
|
+
addViolation("window_blur");
|
|
187
|
+
}
|
|
188
|
+
function handlePaste(e) {
|
|
189
|
+
e.preventDefault();
|
|
190
|
+
addViolation("paste_attempt");
|
|
191
|
+
}
|
|
192
|
+
function handleCopy(e) {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
addViolation("copy_attempt");
|
|
195
|
+
}
|
|
196
|
+
function handleCut(e) {
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
addViolation("cut_attempt");
|
|
199
|
+
}
|
|
200
|
+
function handleDragStart() {
|
|
201
|
+
internalDragRef.current = true;
|
|
202
|
+
}
|
|
203
|
+
function handleDragEnd() {
|
|
204
|
+
internalDragRef.current = false;
|
|
205
|
+
}
|
|
206
|
+
function handleDrop(e) {
|
|
207
|
+
if (internalDragRef.current) {
|
|
208
|
+
internalDragRef.current = false;
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
addViolation("drop_attempt");
|
|
213
|
+
}
|
|
214
|
+
function handleDragOver(e) {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
}
|
|
217
|
+
function handleKeydown(e) {
|
|
218
|
+
const modKey = e.ctrlKey || e.metaKey;
|
|
219
|
+
if (e.key === "F12") {
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
addViolation("devtools_attempt");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (modKey && e.shiftKey && DEVTOOLS_KEYS.includes(e.key.toUpperCase())) {
|
|
225
|
+
e.preventDefault();
|
|
226
|
+
addViolation("devtools_attempt");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (modKey && e.key.toLowerCase() === "u") {
|
|
230
|
+
e.preventDefault();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (modKey && e.key.toLowerCase() === "p") {
|
|
234
|
+
e.preventDefault();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function handleContextMenu(e) {
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
}
|
|
241
|
+
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
|
242
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
243
|
+
window.addEventListener("blur", handleBlur);
|
|
244
|
+
document.addEventListener("paste", handlePaste);
|
|
245
|
+
document.addEventListener("copy", handleCopy);
|
|
246
|
+
document.addEventListener("cut", handleCut);
|
|
247
|
+
document.addEventListener("dragstart", handleDragStart);
|
|
248
|
+
document.addEventListener("dragend", handleDragEnd);
|
|
249
|
+
document.addEventListener("drop", handleDrop);
|
|
250
|
+
document.addEventListener("dragover", handleDragOver);
|
|
251
|
+
document.addEventListener("keydown", handleKeydown);
|
|
252
|
+
document.addEventListener("contextmenu", handleContextMenu);
|
|
253
|
+
return () => {
|
|
254
|
+
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
|
255
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
256
|
+
window.removeEventListener("blur", handleBlur);
|
|
257
|
+
document.removeEventListener("paste", handlePaste);
|
|
258
|
+
document.removeEventListener("copy", handleCopy);
|
|
259
|
+
document.removeEventListener("cut", handleCut);
|
|
260
|
+
document.removeEventListener("dragstart", handleDragStart);
|
|
261
|
+
document.removeEventListener("dragend", handleDragEnd);
|
|
262
|
+
document.removeEventListener("drop", handleDrop);
|
|
263
|
+
document.removeEventListener("dragover", handleDragOver);
|
|
264
|
+
document.removeEventListener("keydown", handleKeydown);
|
|
265
|
+
document.removeEventListener("contextmenu", handleContextMenu);
|
|
266
|
+
};
|
|
267
|
+
}, [enabled, addViolation, startCountdown, clearCountdown]);
|
|
268
|
+
return {
|
|
269
|
+
isFullscreen,
|
|
270
|
+
isMobileDevice,
|
|
271
|
+
violations,
|
|
272
|
+
warning,
|
|
273
|
+
fullscreenCountdown,
|
|
274
|
+
strikesRemaining,
|
|
275
|
+
hasEnteredFullscreen,
|
|
276
|
+
enterFullscreen
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
export {
|
|
280
|
+
INSTANT_SUBMIT_VIOLATIONS,
|
|
281
|
+
useLockdown
|
|
282
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rcnr/lockdown",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Shared fullscreen lockdown hook for RCNR student frontends",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"react": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/react": "^19.0.0",
|
|
27
|
+
"react": "^19.0.0",
|
|
28
|
+
"tsup": "^8.0.0",
|
|
29
|
+
"typescript": "^5.3.0"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"rcnr",
|
|
33
|
+
"lockdown",
|
|
34
|
+
"fullscreen",
|
|
35
|
+
"react",
|
|
36
|
+
"hook"
|
|
37
|
+
],
|
|
38
|
+
"author": "Ryan-RCNR",
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "https://github.com/Ryan-RCNR/rcnr-lockdown.git"
|
|
43
|
+
}
|
|
44
|
+
}
|