mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 21:24:14 +00:00
13abdc30ef
## 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>
109 lines
2.8 KiB
TypeScript
109 lines
2.8 KiB
TypeScript
export class MultiTabDetector {
|
|
private readonly tabId = `${Date.now()}-${Math.random()}`;
|
|
private readonly lockKey = "multi-tab-lock";
|
|
private readonly heartbeatIntervalMs = 1_000;
|
|
private readonly staleThresholdMs = 3_000;
|
|
|
|
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));
|
|
}
|
|
|
|
public startMonitoring(startPenalty: (duration: number) => void): void {
|
|
this.startPenaltyCallback = startPenalty;
|
|
this.writeLock();
|
|
this.heartbeatTimer = window.setInterval(
|
|
() => this.heartbeat(),
|
|
this.heartbeatIntervalMs,
|
|
);
|
|
}
|
|
|
|
public stopMonitoring(): void {
|
|
if (this.heartbeatTimer !== null) {
|
|
clearInterval(this.heartbeatTimer);
|
|
this.heartbeatTimer = null;
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
|
|
if (!this.isPunished) {
|
|
this.applyPunishment();
|
|
}
|
|
}
|
|
|
|
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 {
|
|
this.isPunished = true;
|
|
this.punishmentCount++;
|
|
const delay = 10_000;
|
|
this.startPenaltyCallback(delay);
|
|
setTimeout(() => {
|
|
this.isPunished = false;
|
|
}, 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;
|
|
}
|
|
}
|
|
}
|