This commit is contained in:
@@ -1,36 +1,121 @@
|
||||
/**
|
||||
* Web platform idle detection using Page Visibility API.
|
||||
* Provides a simplified version of the Electron idle API.
|
||||
* Web platform idle detection.
|
||||
*
|
||||
* Prefers the Idle Detection API (Chromium-only, requires user
|
||||
* permission) which reports actual system-level idle, so a Discord tab
|
||||
* in the background doesn't auto-AFK users who are actively using their
|
||||
* computer. Falls back to input-event tracking + Page Visibility when
|
||||
* IdleDetector is unavailable (Firefox, Safari) or permission is denied.
|
||||
*
|
||||
* The input-event fallback tracks mouse/keyboard/touch/scroll on the
|
||||
* page and resets an activity timestamp. It only measures idle *while
|
||||
* the tab has been active at some point* — it's a proxy, not a true OS
|
||||
* idle signal — but it's strictly better than the old Page-Visibility
|
||||
* approach which treated every backgrounded tab as idle even if the
|
||||
* user was typing in another window.
|
||||
*/
|
||||
|
||||
const ACTIVITY_EVENTS = [
|
||||
'mousemove',
|
||||
'mousedown',
|
||||
'keydown',
|
||||
'touchstart',
|
||||
'scroll',
|
||||
'wheel',
|
||||
'pointerdown',
|
||||
'focus',
|
||||
];
|
||||
|
||||
let idleCallback = null;
|
||||
let lastActiveTime = Date.now();
|
||||
|
||||
let idleDetector = null;
|
||||
let idleDetectorAbort = null;
|
||||
// Guard against stacked listeners when onIdleStateChanged fires twice
|
||||
// without a cleanup in between (StrictMode double-invoke, hot reload,
|
||||
// or a stale consumer). Without this flag, every activity event would
|
||||
// call the callback N times — spotted during the bug audit.
|
||||
let listenersAttached = false;
|
||||
|
||||
function onActivity() {
|
||||
lastActiveTime = Date.now();
|
||||
if (idleCallback) idleCallback({ isIdle: false });
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (!idleCallback) return;
|
||||
if (document.hidden) {
|
||||
idleCallback({ isIdle: true });
|
||||
} else {
|
||||
if (!document.hidden) {
|
||||
lastActiveTime = Date.now();
|
||||
idleCallback({ isIdle: false });
|
||||
if (idleCallback) idleCallback({ isIdle: false });
|
||||
}
|
||||
}
|
||||
|
||||
function attachFallbackListeners() {
|
||||
if (listenersAttached) return;
|
||||
for (const ev of ACTIVITY_EVENTS) {
|
||||
window.addEventListener(ev, onActivity, { passive: true, capture: true });
|
||||
}
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
listenersAttached = true;
|
||||
}
|
||||
|
||||
function detachFallbackListeners() {
|
||||
if (!listenersAttached) return;
|
||||
for (const ev of ACTIVITY_EVENTS) {
|
||||
window.removeEventListener(ev, onActivity, { capture: true });
|
||||
}
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
listenersAttached = false;
|
||||
}
|
||||
|
||||
async function tryStartIdleDetector() {
|
||||
// IdleDetector is Chromium-only and gated behind the `idle-detection`
|
||||
// permission. Fail silently if the API is missing or permission is
|
||||
// denied — the fallback listeners will still run.
|
||||
if (typeof window === 'undefined' || !('IdleDetector' in window)) return false;
|
||||
try {
|
||||
const state = await window.IdleDetector.requestPermission();
|
||||
if (state !== 'granted') return false;
|
||||
idleDetectorAbort = new AbortController();
|
||||
idleDetector = new window.IdleDetector();
|
||||
idleDetector.addEventListener('change', () => {
|
||||
const isIdle =
|
||||
idleDetector.userState === 'idle' ||
|
||||
idleDetector.screenState === 'locked';
|
||||
if (!isIdle) lastActiveTime = Date.now();
|
||||
if (idleCallback) idleCallback({ isIdle });
|
||||
});
|
||||
// Threshold must be >= 60s per spec.
|
||||
await idleDetector.start({ threshold: 60_000, signal: idleDetectorAbort.signal });
|
||||
return true;
|
||||
} catch {
|
||||
idleDetector = null;
|
||||
idleDetectorAbort = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getSystemIdleTime() {
|
||||
// Return seconds since last activity (approximation using visibility)
|
||||
if (document.hidden) {
|
||||
return Math.floor((Date.now() - lastActiveTime) / 1000);
|
||||
}
|
||||
return 0;
|
||||
return Math.floor((Date.now() - lastActiveTime) / 1000);
|
||||
},
|
||||
|
||||
onIdleStateChanged(callback) {
|
||||
idleCallback = callback;
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
attachFallbackListeners();
|
||||
void tryStartIdleDetector();
|
||||
},
|
||||
|
||||
removeIdleStateListener() {
|
||||
idleCallback = null;
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
detachFallbackListeners();
|
||||
if (idleDetectorAbort) {
|
||||
try {
|
||||
idleDetectorAbort.abort();
|
||||
} catch {
|
||||
/* already aborted */
|
||||
}
|
||||
idleDetectorAbort = null;
|
||||
}
|
||||
idleDetector = null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,12 +4,34 @@
|
||||
*/
|
||||
const SESSION_KEY = 'discord-clone-session';
|
||||
|
||||
function isQuotaError(e) {
|
||||
if (!e) return false;
|
||||
const name = e.name || '';
|
||||
const code = e.code;
|
||||
return (
|
||||
name === 'QuotaExceededError' ||
|
||||
name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
|
||||
code === 22 ||
|
||||
code === 1014
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
save(data) {
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(data));
|
||||
return Promise.resolve(true);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (isQuotaError(e)) {
|
||||
// Reject instead of quietly returning `false` — a quota failure
|
||||
// here means encryption keys never made it to disk, so the user
|
||||
// will be logged out on next reload. The caller needs to know.
|
||||
const err = new Error('Browser storage quota exceeded');
|
||||
err.isQuotaError = true;
|
||||
return Promise.reject(err);
|
||||
}
|
||||
// Non-quota serialization failures are still surfaced via `false`
|
||||
// to preserve the existing API contract for Electron parity.
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,12 +4,29 @@
|
||||
*/
|
||||
const PREFIX = 'discord-clone-settings:';
|
||||
|
||||
// Browsers spell the quota error in a few different ways across vendors.
|
||||
// Normalizing here so callers can `if (err.isQuotaError)` regardless.
|
||||
function isQuotaError(e) {
|
||||
if (!e) return false;
|
||||
const name = e.name || '';
|
||||
const code = e.code;
|
||||
return (
|
||||
name === 'QuotaExceededError' ||
|
||||
name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
|
||||
code === 22 ||
|
||||
code === 1014
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
get(key) {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFIX + key);
|
||||
return Promise.resolve(raw !== null ? JSON.parse(raw) : undefined);
|
||||
} catch {
|
||||
// Corrupted JSON or blocked storage access — return undefined so
|
||||
// the caller falls back to defaults. Worth recovering silently
|
||||
// here because a single bad key shouldn't take down the app.
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
},
|
||||
@@ -18,8 +35,17 @@ export default {
|
||||
try {
|
||||
localStorage.setItem(PREFIX + key, JSON.stringify(value));
|
||||
return Promise.resolve();
|
||||
} catch {
|
||||
return Promise.resolve();
|
||||
} catch (e) {
|
||||
if (isQuotaError(e)) {
|
||||
// Reject so the caller can surface the quota error to the user —
|
||||
// silently swallowing meant settings just "didn't save" with no
|
||||
// warning. Decorate with a flag so callers can branch without
|
||||
// sniffing error names themselves.
|
||||
const err = new Error('Browser storage quota exceeded');
|
||||
err.isQuotaError = true;
|
||||
return Promise.reject(err);
|
||||
}
|
||||
return Promise.reject(e);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user