mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 02:42:08 +00:00
Update MultiTabDetector.ts (#588)
## Description: Replaced the original focus/visibility-based MultiTabDetector implementation with a lock-based system using localStorage to reliably prevent multitabbing in OpenFront. The new system enforces a single active tab per browser instance by using a unique tabId and a shared lock key with periodic heartbeats. This eliminates race conditions, prevents event-hook tampering, and avoids fingerprinting while maintaining compatibility with existing punishment callbacks and UI warnings. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [X] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: Lucas --------- Co-authored-by: evan <openfrontio@gmail.com>
This commit is contained in:
@@ -1,130 +1,108 @@
|
||||
export class MultiTabDetector {
|
||||
private focusChanges: number[] = [];
|
||||
private readonly maxFocusChanges: number = 10;
|
||||
private readonly timeWindow: number = 60_000;
|
||||
private readonly punishmentDelays: number[] = [
|
||||
2_000, 3_000, 5_000, 10_000, 30_000, 60_000,
|
||||
];
|
||||
private lastFocusChangeTime: number = 0;
|
||||
private isPunished: boolean = false;
|
||||
private isMonitoring: boolean = false;
|
||||
private startPenaltyCallback?: (duration: number) => void;
|
||||
private readonly tabId = `${Date.now()}-${Math.random()}`;
|
||||
private readonly lockKey = "multi-tab-lock";
|
||||
private readonly heartbeatIntervalMs = 1_000;
|
||||
private readonly staleThresholdMs = 3_000;
|
||||
|
||||
private numPunishmentsGiven = 0;
|
||||
private heartbeatTimer: number | null = null;
|
||||
private isPunished = false;
|
||||
private punishmentCount = 0;
|
||||
private startPenaltyCallback: (duration: number) => void = () => {};
|
||||
|
||||
constructor() {
|
||||
window.addEventListener("storage", this.onStorageEvent.bind(this));
|
||||
window.addEventListener("beforeunload", this.onBeforeUnload.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring for multi-tabbing behavior
|
||||
*
|
||||
* @param startPenalty Callback function when punishment starts
|
||||
*/
|
||||
public startMonitoring(startPenalty: (duration: number) => void): void {
|
||||
if (this.isMonitoring) return;
|
||||
|
||||
this.isMonitoring = true;
|
||||
this.startPenaltyCallback = startPenalty;
|
||||
|
||||
// Event listeners for window focus/blur
|
||||
window.addEventListener("blur", this.handleFocusChange.bind(this));
|
||||
window.addEventListener("focus", this.handleFocusChange.bind(this));
|
||||
|
||||
// Also track visibility changes for tab switching
|
||||
document.addEventListener(
|
||||
"visibilitychange",
|
||||
this.handleVisibilityChange.bind(this),
|
||||
this.writeLock();
|
||||
this.heartbeatTimer = window.setInterval(
|
||||
() => this.heartbeat(),
|
||||
this.heartbeatIntervalMs,
|
||||
);
|
||||
}
|
||||
|
||||
public stopMonitoring(): void {
|
||||
if (!this.isMonitoring) return;
|
||||
|
||||
this.isMonitoring = false;
|
||||
|
||||
// Remove event listeners
|
||||
window.removeEventListener("blur", this.handleFocusChange.bind(this));
|
||||
window.removeEventListener("focus", this.handleFocusChange.bind(this));
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
this.handleVisibilityChange.bind(this),
|
||||
);
|
||||
|
||||
// Clear data
|
||||
this.focusChanges = [];
|
||||
this.isPunished = false;
|
||||
}
|
||||
|
||||
private handleFocusChange(): void {
|
||||
const currentTime = Date.now();
|
||||
|
||||
this.recordFocusChange(currentTime);
|
||||
|
||||
// Check for multi-tabbing when focus is gained
|
||||
if (document.hasFocus() && !this.isPunished) {
|
||||
this.checkForMultiTabbing(currentTime);
|
||||
if (this.heartbeatTimer !== null) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleVisibilityChange(): void {
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Record and check regardless of current focus state
|
||||
this.recordFocusChange(currentTime);
|
||||
|
||||
// Only check when tab becomes visible
|
||||
if (document.visibilityState === "visible" && !this.isPunished) {
|
||||
this.checkForMultiTabbing(currentTime);
|
||||
const lock = this.readLock();
|
||||
if (lock?.owner === this.tabId) {
|
||||
localStorage.removeItem(this.lockKey);
|
||||
}
|
||||
window.removeEventListener("storage", this.onStorageEvent.bind(this));
|
||||
window.removeEventListener("beforeunload", this.onBeforeUnload.bind(this));
|
||||
}
|
||||
|
||||
private recordFocusChange(timestamp: number): void {
|
||||
if (Math.abs(this.lastFocusChangeTime - timestamp) < 100) {
|
||||
// Don't count multiple triggers at same time
|
||||
private heartbeat(): void {
|
||||
const now = Date.now();
|
||||
const lock = this.readLock();
|
||||
|
||||
if (
|
||||
!lock ||
|
||||
lock.owner === this.tabId ||
|
||||
now - lock.timestamp > this.staleThresholdMs
|
||||
) {
|
||||
this.writeLock();
|
||||
this.isPunished = false;
|
||||
return;
|
||||
}
|
||||
this.focusChanges.push(timestamp);
|
||||
console.log(`pushing focus change at ${timestamp}`);
|
||||
this.lastFocusChangeTime = timestamp;
|
||||
|
||||
// Keep only recent changes
|
||||
if (this.focusChanges.length > this.maxFocusChanges) {
|
||||
this.focusChanges.shift();
|
||||
if (!this.isPunished) {
|
||||
this.applyPunishment();
|
||||
}
|
||||
}
|
||||
|
||||
private checkForMultiTabbing(currentTime: number): void {
|
||||
// Only if we have enough data points
|
||||
if (this.focusChanges.length >= this.maxFocusChanges) {
|
||||
const oldestChange = this.focusChanges[0];
|
||||
const timeSpan = currentTime - oldestChange;
|
||||
|
||||
// If changes happened within detection window
|
||||
if (timeSpan <= this.timeWindow) {
|
||||
private onStorageEvent(e: StorageEvent): void {
|
||||
if (e.key === this.lockKey && e.newValue) {
|
||||
let other: { owner: string; timestamp: number };
|
||||
try {
|
||||
other = JSON.parse(e.newValue);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse lock", e);
|
||||
return;
|
||||
}
|
||||
if (other.owner !== this.tabId && !this.isPunished) {
|
||||
this.applyPunishment();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onBeforeUnload(): void {
|
||||
const lock = this.readLock();
|
||||
if (lock?.owner === this.tabId) {
|
||||
localStorage.removeItem(this.lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
private applyPunishment(): void {
|
||||
// Prevent multiple punishments
|
||||
if (this.isPunished) return;
|
||||
this.isPunished = true;
|
||||
|
||||
let punishmentDelay = 0;
|
||||
if (this.numPunishmentsGiven >= this.punishmentDelays.length) {
|
||||
punishmentDelay = this.punishmentDelays[this.punishmentDelays.length - 1];
|
||||
} else {
|
||||
punishmentDelay = this.punishmentDelays[this.numPunishmentsGiven];
|
||||
}
|
||||
|
||||
this.numPunishmentsGiven++;
|
||||
|
||||
// Call the start penalty callback
|
||||
if (this.startPenaltyCallback) {
|
||||
this.startPenaltyCallback(punishmentDelay);
|
||||
}
|
||||
|
||||
// Remove penalty after delay
|
||||
this.punishmentCount++;
|
||||
const delay = 10_000;
|
||||
this.startPenaltyCallback(delay);
|
||||
setTimeout(() => {
|
||||
this.isPunished = false;
|
||||
}, punishmentDelay);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private writeLock(): void {
|
||||
localStorage.setItem(
|
||||
this.lockKey,
|
||||
JSON.stringify({ owner: this.tabId, timestamp: Date.now() }),
|
||||
);
|
||||
}
|
||||
|
||||
private readLock(): { owner: string; timestamp: number } | null {
|
||||
const raw = localStorage.getItem(this.lockKey);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse lock", raw, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user