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:
icslucas
2025-04-24 20:51:17 +02:00
committed by GitHub
parent d5191838cd
commit 13abdc30ef
2 changed files with 137 additions and 102 deletions
+77 -99
View File
@@ -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;
}
}
}