1.1.00
All checks were successful
Build and Release / build-and-release (push) Successful in 20m36s

This commit is contained in:
Bryan1029384756
2026-04-16 20:14:27 -05:00
parent 56a12fdf3e
commit 6813bb40dc
40 changed files with 2228 additions and 387 deletions

View File

@@ -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;
},
};

View File

@@ -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);
}
},

View File

@@ -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);
}
},
};