perfectionist-plugins 2.0.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/better-client.js CHANGED
@@ -1,512 +1,1093 @@
1
- const PLUGIN_ID = "better-client";
2
- const version = "1.2.0";
3
-
4
- const MAX_CACHE_ENTRIES = 24;
5
- const MAX_CACHE_ENTRY_BYTES = 2 * 1024 * 1024;
6
- const MAX_CACHE_BYTES = 16 * 1024 * 1024;
7
- const UNGATE_MS = 1000;
8
- const UNGATE_IDLE_MS = 5000;
9
- const UNGATE_BACKOFF_AFTER = 20;
10
- const PHASE_POLL_MS = 5000;
11
- const IMAGE_BATCH = 200;
12
-
13
- const TELEMETRY_HOST = /(^|\.)(telemetry|analytics|metrics|sentry|bugsnag|crash-?report(er)?|pulse|tracking|beacon|datadog|loggly)(\.|:|$)/i;
14
- const TELEMETRY_PATH = /(^|\/)(lol-telemetry|lol-analytics|lol-metrics|lol-rms|rms|lol-sentry|lol-crash|telemetry|analytics|metrics|sentry|bugsnag|crash-?report\w*|tracking|beacon)(\/|$)/i;
15
- const ASSET_PATH = /^\/lol-game-data\/assets\//i;
16
- const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
17
-
18
- const STATIC_ENDPOINTS = [
19
- ["/lol-game-data/assets/", 60 * 60 * 1000],
20
- ["/lol-perks/v1/perks", 60 * 60 * 1000],
21
- ["/performance/v1/quickstats", 5000],
22
- ];
23
-
24
- const UNGATE_SELECTORS = [
25
- ".app-controls-settings",
26
- ".lol-settings-button",
27
- "[class*='social-panel']",
28
- ".social-panel",
29
- ".lol-social-panel",
30
- ".lol-navigation",
31
- ".lol-navigation-item",
32
- "[class*='friends-list']",
33
- ];
34
- const BLOCK_MARKER = "[disabled], [aria-disabled='true'], [style*='pointer-events']";
35
-
36
- let api = null;
37
- let active = false;
38
- let phase = "None";
39
- let unsubscribes = [];
40
- let restores = [];
41
- let ungateTimer = null;
42
- let ungateIdle = 0;
43
- let phaseTimer = null;
44
- let cacheBytes = 0;
45
-
46
- const cache = new Map();
47
- const inflight = new Map();
48
-
49
- function baseHref() {
50
- try {
51
- return typeof location !== "undefined" ? location.href : "https://127.0.0.1/";
52
- } catch {
53
- return "https://127.0.0.1/";
54
- }
55
- }
56
-
57
- function resolveUrl(raw, base) {
58
- try {
59
- return new URL(String(raw), base ?? baseHref());
60
- } catch {
61
- return null;
62
- }
63
- }
64
-
65
- function isLocalHost(host) {
66
- return LOCAL_HOSTS.has(String(host ?? "").replace(/^\[|\]$/g, ""));
67
- }
68
-
69
- function isTelemetryUrl(raw, base) {
70
- const url = resolveUrl(raw, base);
71
- if (!url || url.protocol === "data:" || url.protocol === "blob:") return false;
72
- if (ASSET_PATH.test(url.pathname)) return false;
73
- if (isLocalHost(url.hostname)) return TELEMETRY_PATH.test(url.pathname);
74
- return TELEMETRY_HOST.test(url.hostname) || TELEMETRY_PATH.test(url.pathname);
75
- }
76
-
77
- function shouldBlockUrl(raw, base) {
78
- return isTelemetryUrl(raw, base);
79
- }
80
-
81
- function staticTtl(path) {
82
- if (!path) return 0;
83
- for (const [prefix, ttl] of STATIC_ENDPOINTS) {
84
- if (path.startsWith(prefix)) return ttl;
85
- }
86
- return 0;
87
- }
88
-
89
- function methodOf(input, init) {
90
- const method = init?.method ?? (input && typeof input === "object" && "method" in input ? input.method : "GET");
91
- return String(method ?? "GET").toUpperCase();
92
- }
93
-
94
- function urlOf(input) {
95
- if (typeof input === "string") return input;
96
- if (input && typeof input === "object") {
97
- if (typeof input.url === "string") return input.url;
98
- if (typeof input.href === "string") return input.href;
99
- }
100
- return String(input);
101
- }
102
-
103
- function emptyJsonResponse(env) {
104
- const Ctor = env?.Response ?? (typeof Response !== "undefined" ? Response : null);
105
- if (!Ctor) return undefined;
106
- try {
107
- return new Ctor("{}", { status: 200, headers: { "Content-Type": "application/json" } });
108
- } catch {
109
- return undefined;
110
- }
111
- }
112
-
113
- function evictOne(key) {
114
- const entry = cache.get(key);
115
- if (!entry) return;
116
- cacheBytes -= entry.bytes;
117
- cache.delete(key);
118
- }
119
-
120
- function evict(max) {
121
- while (cache.size >= max) evictOne(cache.keys().next().value);
122
- }
123
-
124
- function storeInCache(key, ttl, response) {
125
- const type = String(response.headers?.get?.("Content-Type") ?? "");
126
- if (type && !/json|javascript|plain/i.test(type)) return;
127
- const length = Number(response.headers?.get?.("Content-Length") ?? 0) || 0;
128
- if (length > MAX_CACHE_ENTRY_BYTES) return;
129
- evict(MAX_CACHE_ENTRIES);
130
- cache.set(key, { at: Date.now(), ttl, res: response.clone(), bytes: length });
131
- cacheBytes += length;
132
- while (cacheBytes > MAX_CACHE_BYTES && cache.size > 1) {
133
- evictOne(cache.keys().next().value);
134
- }
135
- }
136
-
137
- function clearVolatileCache() {
138
- for (const [key, entry] of [...cache]) {
139
- if (entry.ttl < 60 * 60 * 1000) evictOne(key);
140
- }
141
- }
142
-
143
- function createFetch(env) {
144
- const native = env.fetch.bind(env);
145
-
146
- function betterFetch(input, init) {
147
- const raw = urlOf(input);
148
- const method = methodOf(input, init);
149
-
150
- if (active && shouldBlockUrl(raw)) {
151
- return Promise.resolve(emptyJsonResponse(env));
152
- }
153
-
154
- const url = resolveUrl(raw);
155
- const local = url ? isLocalHost(url.hostname) : false;
156
- const cacheable = local && method === "GET";
157
- const key = `${method} ${url ? url.origin + url.pathname + url.search : raw}`;
158
-
159
- if (cacheable && active) {
160
- const pending = inflight.get(key);
161
- if (pending) {
162
- return pending.then((response) => (response?.clone ? response.clone() : response));
163
- }
164
- }
165
-
166
- if (cacheable && active) {
167
- const ttl = staticTtl(url?.pathname);
168
- const hit = ttl ? cache.get(key) : null;
169
- if (hit && Date.now() - hit.at < hit.ttl) {
170
- return Promise.resolve(hit.res.clone());
171
- }
172
- }
173
-
174
- const request = native(input, init);
175
- if (local && (method === "GET" || method === "HEAD")) {
176
- inflight.set(key, request);
177
- const done = () => inflight.delete(key);
178
- request.then(done, done);
179
- if (cacheable) {
180
- request.then((response) => {
181
- const ttl = staticTtl(url?.pathname);
182
- if (!response?.ok || !ttl || !active) return;
183
- storeInCache(key, ttl, response);
184
- }, () => {});
185
- }
186
- }
187
- return request;
188
- }
189
-
190
- betterFetch.__better = true;
191
- return betterFetch;
192
- }
193
-
194
- function emitBlockedXhr(xhr, env) {
195
- const reply = "{}";
196
- try {
197
- Object.defineProperty(xhr, "readyState", { configurable: true, get: () => 4 });
198
- Object.defineProperty(xhr, "status", { configurable: true, get: () => 200 });
199
- Object.defineProperty(xhr, "responseText", { configurable: true, get: () => reply });
200
- Object.defineProperty(xhr, "response", { configurable: true, get: () => reply });
201
- } catch {}
202
- const EventCtor = env.Event ?? (typeof Event !== "undefined" ? Event : null);
203
- if (!EventCtor) return;
204
- setTimeout(() => {
205
- try {
206
- xhr.dispatchEvent(new EventCtor("readystatechange"));
207
- xhr.dispatchEvent(new EventCtor("load"));
208
- xhr.dispatchEvent(new EventCtor("loadend"));
209
- } catch {}
210
- }, 0);
211
- }
212
-
213
- function installXhr(env) {
214
- const proto = env.XMLHttpRequest?.prototype;
215
- if (!proto || proto.__better) return null;
216
- const nativeOpen = proto.open;
217
- const nativeSend = proto.send;
218
- proto.open = function (method, url, ...rest) {
219
- this.__betterUrl = String(url);
220
- return nativeOpen.call(this, method, url, ...rest);
221
- };
222
- proto.send = function (body) {
223
- if (active && shouldBlockUrl(this.__betterUrl)) {
224
- emitBlockedXhr(this, env);
225
- return undefined;
226
- }
227
- return nativeSend.call(this, body);
228
- };
229
- proto.__better = true;
230
- return () => {
231
- proto.open = nativeOpen;
232
- proto.send = nativeSend;
233
- delete proto.__better;
234
- };
235
- }
236
-
237
- function installBeacon(env) {
238
- const nav = env.navigator;
239
- if (!nav?.sendBeacon || nav.sendBeacon.__better) return null;
240
- const native = nav.sendBeacon.bind(nav);
241
- const patched = function (url) {
242
- if (active && shouldBlockUrl(url)) return true;
243
- return native(url, arguments[1]);
244
- };
245
- patched.__better = true;
246
- nav.sendBeacon = patched;
247
- return () => {
248
- nav.sendBeacon = native;
249
- };
250
- }
251
-
252
- let imageObserver = null;
253
- let imageQueue = [];
254
- let imageFlushTimer = null;
255
-
256
- function markAsyncDecode(img) {
257
- if (!img || img.tagName !== "IMG" || img.decoding === "async") return 0;
258
- try {
259
- img.decoding = "async";
260
- return 1;
261
- } catch {
262
- return 0;
263
- }
264
- }
265
-
266
- function flushImageQueue() {
267
- imageFlushTimer = null;
268
- const batch = imageQueue.splice(0, IMAGE_BATCH);
269
- for (const img of batch) markAsyncDecode(img);
270
- if (imageQueue.length > 0) scheduleImageFlush();
271
- }
272
-
273
- function scheduleImageFlush() {
274
- if (imageFlushTimer !== null) return;
275
- imageFlushTimer = setTimeout(flushImageQueue, 150);
276
- }
277
-
278
- function enqueueImages(root) {
279
- if (!root || root.nodeType !== 1) return 0;
280
- let added = 0;
281
- if (root.tagName === "IMG") {
282
- imageQueue.push(root);
283
- added += 1;
284
- }
285
- let list = [];
286
- try {
287
- list = root.querySelectorAll("img:not([decoding='async'])");
288
- } catch {
289
- list = [];
290
- }
291
- for (const img of list) {
292
- imageQueue.push(img);
293
- added += 1;
294
- }
295
- if (imageQueue.length > 6000) imageQueue.splice(0, imageQueue.length - 6000);
296
- if (added > 0) scheduleImageFlush();
297
- return added;
298
- }
299
-
300
- function installImageDecode() {
301
- if (typeof document === "undefined" || !document.documentElement) return () => {};
302
- const Obs = document.defaultView?.MutationObserver ?? MutationObserver;
303
- if (imageObserver || !Obs) return () => {};
304
- imageObserver = new Obs((records) => {
305
- let queued = false;
306
- for (const record of records) {
307
- if (record.type === "attributes") {
308
- if (enqueueImages(record.target) > 0) queued = true;
309
- continue;
310
- }
311
- for (const node of record.addedNodes) {
312
- if (node?.nodeType !== 1) continue;
313
- if (enqueueImages(node) > 0) queued = true;
314
- }
315
- }
316
- if (queued) scheduleImageFlush();
317
- });
318
- try {
319
- imageObserver.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["src"] });
320
- } catch {
321
- imageObserver = null;
322
- return () => {};
323
- }
324
- enqueueImages(document.documentElement);
325
- const sweep = setInterval(() => enqueueImages(document.documentElement), 30000);
326
- return () => {
327
- clearInterval(sweep);
328
- clearTimeout(imageFlushTimer);
329
- imageFlushTimer = null;
330
- imageQueue = [];
331
- imageObserver?.disconnect();
332
- imageObserver = null;
333
- };
334
- }
335
-
336
- function ungateNode(node) {
337
- if (!node?.querySelectorAll) return 0;
338
- let changed = 0;
339
- const candidates = [];
340
- try {
341
- if (node.matches?.(BLOCK_MARKER)) candidates.push(node);
342
- candidates.push(...node.querySelectorAll(BLOCK_MARKER));
343
- } catch {
344
- return 0;
345
- }
346
- for (const element of candidates) {
347
- if (element.disabled === true) {
348
- element.disabled = false;
349
- changed += 1;
350
- }
351
- if (element.hasAttribute?.("disabled")) {
352
- element.removeAttribute("disabled");
353
- changed += 1;
354
- }
355
- if (element.getAttribute?.("aria-disabled") === "true") {
356
- element.removeAttribute("aria-disabled");
357
- changed += 1;
358
- }
359
- if (element.style?.pointerEvents === "none") {
360
- element.style.pointerEvents = "";
361
- changed += 1;
362
- }
363
- }
364
- return changed;
365
- }
366
-
367
- function ungatePass() {
368
- if (!active || phase !== "InProgress") return 0;
369
- let changed = 0;
370
- for (const selector of UNGATE_SELECTORS) {
371
- let nodes = [];
372
- try {
373
- nodes = document.querySelectorAll(selector);
374
- } catch {
375
- nodes = [];
376
- }
377
- for (const node of nodes) changed += ungateNode(node);
378
- }
379
- return changed;
380
- }
381
-
382
- function ungateDelay() {
383
- return ungateIdle >= UNGATE_BACKOFF_AFTER ? UNGATE_IDLE_MS : UNGATE_MS;
384
- }
385
-
386
- function ungateTick() {
387
- ungateTimer = null;
388
- if (!active || phase !== "InProgress") return;
389
- const changed = ungatePass();
390
- ungateIdle = changed > 0 ? 0 : ungateIdle + 1;
391
- ungateTimer = setTimeout(ungateTick, ungateDelay());
392
- }
393
-
394
- function startUngate() {
395
- if (ungateTimer) return;
396
- ungateIdle = 0;
397
- ungateTick();
398
- }
399
-
400
- function stopUngate() {
401
- clearTimeout(ungateTimer);
402
- ungateTimer = null;
403
- ungateIdle = 0;
404
- }
405
-
406
- function onPhase(next) {
407
- const value = typeof next === "string" ? next : next?.phase ?? phase;
408
- if (!value || value === phase) {
409
- if (value === "InProgress") startUngate();
410
- return;
411
- }
412
- phase = value;
413
- clearVolatileCache();
414
- if (phase === "InProgress") startUngate();
415
- else stopUngate();
416
- }
417
-
418
- async function refreshPhase() {
419
- if (!active) return;
420
- if (typeof document !== "undefined" && document.hidden) return;
421
- try {
422
- const current = await api?.lcu?.get?.("/lol-gameflow/v1/gameflow-phase");
423
- if (current) onPhase(current);
424
- } catch {}
425
- }
426
-
427
- function installNetwork(env) {
428
- const installed = [];
429
- try {
430
- const originalFetch = env.fetch;
431
- env.fetch = createFetch(env);
432
- installed.push(() => {
433
- env.fetch = originalFetch;
434
- });
435
- } catch {}
436
- const xhr = installXhr(env);
437
- if (xhr) installed.push(xhr);
438
- const beacon = installBeacon(env);
439
- if (beacon) installed.push(beacon);
440
- return () => {
441
- for (const restore of installed.reverse()) {
442
- try {
443
- restore();
444
- } catch {}
445
- }
446
- };
447
- }
448
-
449
- function onEnable(utils) {
450
- api = utils;
451
- active = true;
452
- restores = [installNetwork(window), installImageDecode()];
453
- unsubscribes = [];
454
- try {
455
- const off = api.subscribe("/lol-gameflow/v1/gameflow-phase", onPhase);
456
- if (off) unsubscribes.push(off);
457
- } catch {}
458
- try {
459
- api
460
- .lcu?.get?.("/lol-gameflow/v1/gameflow-phase")
461
- .then((value) => onPhase(value))
462
- .catch(() => {});
463
- } catch {}
464
- refreshPhase();
465
- clearInterval(phaseTimer);
466
- phaseTimer = setInterval(refreshPhase, PHASE_POLL_MS);
467
- if (phase === "InProgress") startUngate();
468
- }
469
-
470
- function onDisable() {
471
- active = false;
472
- clearInterval(phaseTimer);
473
- phaseTimer = null;
474
- stopUngate();
475
- for (const off of unsubscribes) {
476
- try {
477
- off();
478
- } catch {}
479
- }
480
- unsubscribes = [];
481
- for (const restore of restores.reverse()) {
482
- try {
483
- restore();
484
- } catch {}
485
- }
486
- restores = [];
487
- inflight.clear();
488
- for (const key of [...cache.keys()]) evictOne(key);
489
- cacheBytes = 0;
490
- api = null;
491
- }
492
-
493
- export function init() {
494
- const registerWith = (utils) => {
495
- utils.registerPlugin({
496
- id: PLUGIN_ID,
497
- name: "BetterClient",
498
- description:
499
- "Debloats the client: blocks telemetry and analytics calls, merges duplicate requests, caches static client data, decodes images asynchronously and keeps settings, chat and friend requests usable during a game.",
500
- version,
501
- defaultEnabled: true,
502
- onEnable,
503
- onDisable,
504
- });
505
- };
506
-
507
- if (window.PerfectionistUtils) {
508
- registerWith(window.PerfectionistUtils);
509
- return;
510
- }
511
- addEventListener("perfectionist:utils-ready", (e) => registerWith(e.detail ?? window.PerfectionistUtils), { once: true });
512
- }
1
+ const PLUGIN_ID = "better-client";
2
+ const version = "1.7.0";
3
+
4
+ const MAX_CACHE_ENTRIES = 24;
5
+ const MAX_CACHE_ENTRY_BYTES = 2 * 1024 * 1024;
6
+ const MAX_CACHE_BYTES = 16 * 1024 * 1024;
7
+ const UNGATE_MS = 1000;
8
+ const UNGATE_IDLE_MS = 5000;
9
+ const UNGATE_BACKOFF_AFTER = 20;
10
+ const PHASE_POLL_FALLBACK_MS = 15000;
11
+ const IMAGE_BATCH = 160;
12
+ const IMAGE_QUEUE_LIMIT = 4000;
13
+ const DECISION_CACHE_LIMIT = 256;
14
+ const DIAGNOSTICS_HEAP_MS = 30000;
15
+ const DIAGNOSTICS_QUIET_MS = 600;
16
+ const DIAGNOSTICS_MAX_SETTLE_MS = 6000;
17
+ const DIAGNOSTICS_SAMPLE_LIMIT = 240;
18
+
19
+ const TELEMETRY_HOST = /(^|\.)(telemetry|analytics|metrics|sentry|bugsnag|crash-?report(er)?|pulse|tracking|beacon|datadog|loggly)(\.|:|$)/i;
20
+ const TELEMETRY_PATH = /(^|\/)(lol-telemetry|lol-analytics|lol-metrics|lol-rms|rms|lol-sentry|lol-crash|telemetry|analytics|metrics|sentry|bugsnag|crash-?report\w*|tracking|beacon|tracing)(\/|\?|$)/i;
21
+ const ASSET_PATH = /^\/lol-game-data\/assets\//i;
22
+ const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
23
+
24
+ const CHAMP_SELECT_DATA = /^\/lol-champ-select\/v1\/(session|all-grid-champions|pickable-champion-ids|bannable-champion-ids)$/i;
25
+ const CHAMP_SELECT_RETRY_MS = [120, 260, 520];
26
+
27
+ const STATIC_ENDPOINTS = [
28
+ ["/lol-game-data/assets/", 60 * 60 * 1000],
29
+ ["/lol-perks/v1/perks", 60 * 60 * 1000],
30
+ ["/riotclient/region-locale", 60 * 1000],
31
+ ["/lol-summoner/v1/current-summoner", 10 * 1000],
32
+ ["/performance/v1/quickstats", 5000],
33
+ ];
34
+
35
+ const UNGATE_SELECTORS = [
36
+ ".app-controls-settings",
37
+ ".lol-settings-button",
38
+ "[class*='social-panel']",
39
+ ".social-panel",
40
+ ".lol-social-panel",
41
+ ".lol-navigation",
42
+ ".lol-navigation-item",
43
+ "[class*='friends-list']",
44
+ ];
45
+ const UNGATE_SELECTOR = UNGATE_SELECTORS.join(",");
46
+ const BLOCK_MARKER = "[disabled], [aria-disabled='true'], [style*='pointer-events']";
47
+
48
+ const PERF_STYLE_ID = "perfectionist-better-client-performance";
49
+ const PERFORMANCE_CSS = `
50
+ [class*="blur"], [class*="Blur"], [class*="backdrop"], [class*="Backdrop"] {
51
+ filter: none !important;
52
+ backdrop-filter: none !important;
53
+ -webkit-backdrop-filter: none !important;
54
+ }
55
+ [class*="particle"], [class*="Particle"], [class*="parallax"], [class*="Parallax"],
56
+ [class*="animated-background"], [class*="animatedBackground"], [class*="glow"], [class*="Glow"] {
57
+ animation: none !important;
58
+ transition: none !important;
59
+ }
60
+ [class*="background"] video, [class*="Background"] video { display: none !important; }
61
+ `;
62
+
63
+ const OPTIONS = [
64
+ {
65
+ key: "performanceMode",
66
+ label: "Performance mode",
67
+ description: "Turns off background blur, decorative animation and looping background video to save GPU and CPU on weaker machines.",
68
+ default: false,
69
+ },
70
+ {
71
+ key: "diagnostics",
72
+ label: "Diagnostics",
73
+ description: "Records long tasks, screen settle times, request counts and heap samples in memory only. Nothing is sent over the network.",
74
+ default: false,
75
+ },
76
+ {
77
+ key: "copyReport",
78
+ type: "action",
79
+ label: "Copy report",
80
+ description: "Copies the in-memory diagnostics summary. It contains no account, summoner, token or LCU data.",
81
+ onClick: () => copyDiagnosticsReport(),
82
+ },
83
+ {
84
+ key: "blocked",
85
+ type: "info",
86
+ label: "Blocked telemetry",
87
+ description: "Requests dropped at the client UI layer and answered with an empty success.",
88
+ lines: () => blockedSummary(),
89
+ },
90
+ ];
91
+
92
+ let api = null;
93
+ let active = false;
94
+ let phase = "None";
95
+ let unsubscribes = [];
96
+ let restores = [];
97
+ let ungateTimer = null;
98
+ let ungateIdle = 0;
99
+ let phaseTimer = null;
100
+ let phaseWatchListener = null;
101
+ let performanceMode = false;
102
+ let removePerformanceStyle = null;
103
+ let cacheBytes = 0;
104
+
105
+ let diagnosticsOn = false;
106
+ let diagnosticsStartedAt = 0;
107
+ let diagnosticsLongCount = 0;
108
+ let diagnosticsLongMs = 0;
109
+ let diagnosticsLongMax = 0;
110
+ let diagnosticsRequests = 0;
111
+ let diagnosticsBytes = 0;
112
+ let diagnosticsHeap = [];
113
+ let diagnosticsNavSamples = [];
114
+ let diagnosticsObservers = [];
115
+ let diagnosticsHeapTimer = null;
116
+ let diagnosticsNavStart = null;
117
+ let diagnosticsNavName = "";
118
+ let diagnosticsNavActivity = 0;
119
+ let diagnosticsNavFrames = 0;
120
+ let diagnosticsLastReport = "";
121
+ let diagnosticsClickListener = null;
122
+
123
+ const cache = new Map();
124
+ const inflight = new Map();
125
+ const blockDecisions = new Map();
126
+ const blockedStats = new Map();
127
+
128
+ function baseHref() {
129
+ try {
130
+ return typeof location !== "undefined" ? location.href : "https://127.0.0.1/";
131
+ } catch {
132
+ return "https://127.0.0.1/";
133
+ }
134
+ }
135
+
136
+ function resolveUrl(raw, base) {
137
+ try {
138
+ return new URL(String(raw), base ?? baseHref());
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ function isLocalHost(host) {
145
+ return LOCAL_HOSTS.has(String(host ?? "").replace(/^\[|\]$/g, ""));
146
+ }
147
+
148
+ function isTelemetryUrl(raw, base) {
149
+ const text = typeof raw === "string" ? raw : urlOf(raw);
150
+ const memo = blockDecisions.get(text);
151
+ if (memo !== undefined) return memo;
152
+
153
+ let verdict;
154
+ if (text.startsWith("/") && !text.startsWith("//")) {
155
+ verdict = !ASSET_PATH.test(text) && TELEMETRY_PATH.test(text);
156
+ } else {
157
+ const url = resolveUrl(text, base);
158
+ if (!url || url.protocol === "data:" || url.protocol === "blob:") verdict = false;
159
+ else if (ASSET_PATH.test(url.pathname)) verdict = false;
160
+ else if (isLocalHost(url.hostname)) verdict = TELEMETRY_PATH.test(url.pathname);
161
+ else verdict = TELEMETRY_HOST.test(url.hostname) || TELEMETRY_PATH.test(url.pathname);
162
+ }
163
+
164
+ if (blockDecisions.size >= DECISION_CACHE_LIMIT) blockDecisions.clear();
165
+ blockDecisions.set(text, verdict);
166
+ return verdict;
167
+ }
168
+
169
+ function blockedKey(raw) {
170
+ const url = resolveUrl(raw);
171
+ if (!url) return String(raw).slice(0, 80);
172
+ if (isLocalHost(url.hostname)) return url.pathname.split("/").slice(0, 3).join("/") || url.pathname;
173
+ return url.hostname;
174
+ }
175
+
176
+ function recordBlocked(raw) {
177
+ const key = blockedKey(raw);
178
+ blockedStats.set(key, (blockedStats.get(key) ?? 0) + 1);
179
+ try {
180
+ window.__betterBlocked = Object.fromEntries(blockedStats);
181
+ } catch {}
182
+ }
183
+
184
+ function blockedSummary() {
185
+ const entries = [...blockedStats.entries()].sort((a, b) => b[1] - a[1]);
186
+ const total = entries.reduce((sum, entry) => sum + entry[1], 0);
187
+ const lines = entries.slice(0, 6).map(([key, count]) => `${key} x${count}`);
188
+ if (lines.length === 0) lines.push("Nothing blocked since the client started.");
189
+ lines.push(`Total: ${total} request${total === 1 ? "" : "s"} answered locally.`);
190
+ return lines;
191
+ }
192
+
193
+ function shouldBlockUrl(raw, base) {
194
+ const verdict = isTelemetryUrl(raw, base);
195
+ if (verdict) recordBlocked(raw);
196
+ return verdict;
197
+ }
198
+
199
+ function staticTtl(path) {
200
+ if (!path) return 0;
201
+ for (const [prefix, ttl] of STATIC_ENDPOINTS) {
202
+ if (path.startsWith(prefix)) return ttl;
203
+ }
204
+ return 0;
205
+ }
206
+
207
+ function methodOf(input, init) {
208
+ const method = init?.method ?? (input && typeof input === "object" && "method" in input ? input.method : "GET");
209
+ return String(method ?? "GET").toUpperCase();
210
+ }
211
+
212
+ function urlOf(input) {
213
+ if (typeof input === "string") return input;
214
+ if (input && typeof input === "object") {
215
+ if (typeof input.url === "string") return input.url;
216
+ if (typeof input.href === "string") return input.href;
217
+ }
218
+ return String(input);
219
+ }
220
+
221
+ function emptyJsonResponse(env) {
222
+ const Ctor = env?.Response ?? (typeof Response !== "undefined" ? Response : null);
223
+ if (!Ctor) return undefined;
224
+ try {
225
+ return new Ctor("{}", { status: 200, headers: { "Content-Type": "application/json" } });
226
+ } catch {
227
+ return undefined;
228
+ }
229
+ }
230
+
231
+ function evictOne(key) {
232
+ const entry = cache.get(key);
233
+ if (!entry) return;
234
+ cacheBytes -= entry.bytes;
235
+ cache.delete(key);
236
+ }
237
+
238
+ function evict(max) {
239
+ while (cache.size >= max) evictOne(cache.keys().next().value);
240
+ }
241
+
242
+ function storeInCache(key, ttl, response) {
243
+ const type = String(response.headers?.get?.("Content-Type") ?? "");
244
+ if (type && !/json|javascript|plain/i.test(type)) return;
245
+ const length = Number(response.headers?.get?.("Content-Length") ?? 0) || 0;
246
+ if (length > MAX_CACHE_ENTRY_BYTES) return;
247
+ evict(MAX_CACHE_ENTRIES);
248
+ cache.set(key, { at: Date.now(), ttl, res: response.clone(), bytes: length });
249
+ cacheBytes += length;
250
+ while (cacheBytes > MAX_CACHE_BYTES && cache.size > 1) {
251
+ evictOne(cache.keys().next().value);
252
+ }
253
+ }
254
+
255
+ function clearVolatileCache() {
256
+ for (const [key, entry] of [...cache]) {
257
+ if (entry.ttl < 60 * 60 * 1000) evictOne(key);
258
+ }
259
+ }
260
+
261
+ function diagnosticsScreenName(target) {
262
+ const className = String(target?.className ?? "");
263
+ const match = className.match(/menu_item_navbar_([a-z]+)/i);
264
+ if (!match) return "Profile";
265
+ const name = match[1].toLowerCase();
266
+ return name === "league" ? "Home" : name.charAt(0).toUpperCase() + name.slice(1);
267
+ }
268
+
269
+ function diagnosticsTouch() {
270
+ if (diagnosticsNavStart !== null) diagnosticsNavActivity = performance.now();
271
+ }
272
+
273
+ function diagnosticsFinishNavigation() {
274
+ if (diagnosticsNavStart === null) return;
275
+ const elapsed = Math.max(0, Math.round(performance.now() - diagnosticsNavStart));
276
+ const screen = diagnosticsNavName;
277
+ diagnosticsNavStart = null;
278
+ if (!screen) return;
279
+ diagnosticsNavSamples.push({ screen, ms: elapsed });
280
+ if (diagnosticsNavSamples.length > DIAGNOSTICS_SAMPLE_LIMIT) diagnosticsNavSamples.shift();
281
+ }
282
+
283
+ function diagnosticsNavFrame() {
284
+ if (!diagnosticsOn || diagnosticsNavStart === null) return;
285
+ diagnosticsNavFrames += 1;
286
+ const now = performance.now();
287
+ const age = now - diagnosticsNavStart;
288
+ const quiet = now - diagnosticsNavActivity;
289
+ if ((age >= 800 && diagnosticsNavFrames >= 2 && quiet >= DIAGNOSTICS_QUIET_MS) || age >= DIAGNOSTICS_MAX_SETTLE_MS) {
290
+ diagnosticsFinishNavigation();
291
+ return;
292
+ }
293
+ requestAnimationFrame(diagnosticsNavFrame);
294
+ }
295
+
296
+ function diagnosticsBeginNavigation(screen) {
297
+ if (!diagnosticsOn || !screen) return;
298
+ if (diagnosticsNavStart !== null) diagnosticsFinishNavigation();
299
+ diagnosticsNavStart = performance.now();
300
+ diagnosticsNavName = screen;
301
+ diagnosticsNavActivity = diagnosticsNavStart;
302
+ diagnosticsNavFrames = 0;
303
+ requestAnimationFrame(diagnosticsNavFrame);
304
+ }
305
+
306
+ function diagnosticsClick(event) {
307
+ if (!diagnosticsOn) return;
308
+ const target = event.target?.closest?.('[class*="menu_item_navbar_"], [class*="details-view-profile"]');
309
+ if (!target) return;
310
+ diagnosticsBeginNavigation(diagnosticsScreenName(target));
311
+ }
312
+
313
+ function diagnosticsSampleHeap() {
314
+ if (!diagnosticsOn) return;
315
+ const used = performance.memory?.usedJSHeapSize;
316
+ if (!Number.isFinite(used)) return;
317
+ diagnosticsHeap.push({ mb: +(used / 1048576).toFixed(1), at: Date.now() });
318
+ if (diagnosticsHeap.length > DIAGNOSTICS_SAMPLE_LIMIT) diagnosticsHeap.shift();
319
+ }
320
+
321
+ function diagnosticsStart() {
322
+ if (diagnosticsOn) return;
323
+ diagnosticsOn = true;
324
+ diagnosticsStartedAt = Date.now();
325
+ diagnosticsLongCount = 0;
326
+ diagnosticsLongMs = 0;
327
+ diagnosticsLongMax = 0;
328
+ diagnosticsRequests = 0;
329
+ diagnosticsBytes = 0;
330
+ diagnosticsHeap = [];
331
+ diagnosticsNavSamples = [];
332
+ diagnosticsLastReport = "";
333
+ diagnosticsNavStart = null;
334
+
335
+ try {
336
+ const longTasks = new PerformanceObserver((list) => {
337
+ for (const entry of list.getEntries()) {
338
+ diagnosticsLongCount += 1;
339
+ diagnosticsLongMs += entry.duration;
340
+ diagnosticsLongMax = Math.max(diagnosticsLongMax, entry.duration);
341
+ diagnosticsTouch();
342
+ }
343
+ });
344
+ longTasks.observe({ entryTypes: ["longtask"] });
345
+ diagnosticsObservers.push(longTasks);
346
+ } catch {}
347
+
348
+ try {
349
+ const resources = new PerformanceObserver((list) => {
350
+ for (const entry of list.getEntries()) {
351
+ diagnosticsRequests += 1;
352
+ diagnosticsBytes += Number(entry.transferSize || entry.encodedBodySize || 0);
353
+ diagnosticsTouch();
354
+ }
355
+ });
356
+ resources.observe({ entryTypes: ["resource"] });
357
+ diagnosticsObservers.push(resources);
358
+ } catch {}
359
+
360
+ try {
361
+ const mutations = new MutationObserver(diagnosticsTouch);
362
+ mutations.observe(document.documentElement ?? document, { childList: true, subtree: true });
363
+ diagnosticsObservers.push(mutations);
364
+ } catch {}
365
+
366
+ diagnosticsClickListener = diagnosticsClick;
367
+ document.addEventListener("click", diagnosticsClickListener, true);
368
+ diagnosticsSampleHeap();
369
+ diagnosticsHeapTimer = setInterval(diagnosticsSampleHeap, DIAGNOSTICS_HEAP_MS);
370
+ }
371
+
372
+ function diagnosticsStop() {
373
+ if (!diagnosticsOn) return;
374
+ if (diagnosticsNavStart !== null) diagnosticsFinishNavigation();
375
+ diagnosticsLastReport = diagnosticsReportText();
376
+ diagnosticsOn = false;
377
+ for (const observer of diagnosticsObservers) {
378
+ try {
379
+ observer.disconnect();
380
+ } catch {}
381
+ }
382
+ diagnosticsObservers = [];
383
+ clearInterval(diagnosticsHeapTimer);
384
+ diagnosticsHeapTimer = null;
385
+ if (diagnosticsClickListener) document.removeEventListener("click", diagnosticsClickListener, true);
386
+ diagnosticsClickListener = null;
387
+ diagnosticsNavStart = null;
388
+ }
389
+
390
+ function diagnosticsPlugins() {
391
+ try {
392
+ return api
393
+ ?.getPlugins?.()
394
+ .filter((plugin) => plugin.enabled)
395
+ .map((plugin) => plugin.name)
396
+ .join(", ") || "none";
397
+ } catch {
398
+ return "unknown";
399
+ }
400
+ }
401
+
402
+ function diagnosticsDuration(ms) {
403
+ const total = Math.max(0, Math.round(ms / 1000));
404
+ const minutes = Math.floor(total / 60);
405
+ const seconds = total % 60;
406
+ return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
407
+ }
408
+
409
+ function diagnosticsMedian(values) {
410
+ if (values.length === 0) return 0;
411
+ const sorted = [...values].sort((a, b) => a - b);
412
+ const middle = Math.floor(sorted.length / 2);
413
+ return sorted.length % 2 ? sorted[middle] : Math.round((sorted[middle - 1] + sorted[middle]) / 2);
414
+ }
415
+
416
+ function diagnosticsReportText() {
417
+ if (!diagnosticsOn && diagnosticsLastReport) return diagnosticsLastReport;
418
+ const duration = diagnosticsStartedAt ? diagnosticsDuration(Date.now() - diagnosticsStartedAt) : "0s";
419
+ const lines = [
420
+ "Perfectionism diagnostics",
421
+ `Duration: ${duration}`,
422
+ `Plugins on: ${diagnosticsPlugins()}`,
423
+ `Long tasks: ${diagnosticsLongCount}, ${Math.round(diagnosticsLongMs)} ms total, ${Math.round(diagnosticsLongMax)} ms max`,
424
+ `Requests: ${diagnosticsRequests}, ${(diagnosticsBytes / 1048576).toFixed(1)} MB`,
425
+ ];
426
+
427
+ if (diagnosticsHeap.length > 0) {
428
+ const first = diagnosticsHeap[0].mb;
429
+ const last = diagnosticsHeap[diagnosticsHeap.length - 1].mb;
430
+ const peak = Math.max(...diagnosticsHeap.map((sample) => sample.mb));
431
+ lines.push(`Heap: ${first.toFixed(1)} MB start, ${last.toFixed(1)} MB latest, ${peak.toFixed(1)} MB peak (${diagnosticsHeap.length} samples)`);
432
+ } else {
433
+ lines.push("Heap: unavailable");
434
+ }
435
+
436
+ if (diagnosticsNavSamples.length === 0) {
437
+ lines.push("Screens: no navigation samples");
438
+ } else {
439
+ lines.push("Screens:");
440
+ const order = [];
441
+ const grouped = new Map();
442
+ for (const sample of diagnosticsNavSamples) {
443
+ if (!grouped.has(sample.screen)) {
444
+ grouped.set(sample.screen, []);
445
+ order.push(sample.screen);
446
+ }
447
+ grouped.get(sample.screen).push(sample.ms);
448
+ }
449
+ for (const screen of order) {
450
+ const values = grouped.get(screen);
451
+ lines.push(` ${screen}: ${values.length}x, ${diagnosticsMedian(values)} ms median, ${Math.min(...values)}-${Math.max(...values)} ms`);
452
+ }
453
+ }
454
+
455
+ lines.push("Local only. Nothing is uploaded.");
456
+ return lines.join("\n");
457
+ }
458
+
459
+ async function copyDiagnosticsReport() {
460
+ const text = diagnosticsOn ? diagnosticsReportText() : diagnosticsLastReport || diagnosticsReportText();
461
+ if (!diagnosticsOn) diagnosticsLastReport = text;
462
+ try {
463
+ if (navigator.clipboard?.writeText) {
464
+ await navigator.clipboard.writeText(text);
465
+ return text;
466
+ }
467
+ } catch {}
468
+ const area = document.createElement("textarea");
469
+ area.value = text;
470
+ area.setAttribute("readonly", "");
471
+ area.style.position = "fixed";
472
+ area.style.opacity = "0";
473
+ document.body.appendChild(area);
474
+ area.select();
475
+ try {
476
+ document.execCommand("copy");
477
+ } finally {
478
+ area.remove();
479
+ }
480
+ return text;
481
+ }
482
+
483
+ function champSelectDataPath(url) {
484
+ if (!url) return null;
485
+ return CHAMP_SELECT_DATA.test(url.pathname) ? url.pathname.toLowerCase() : null;
486
+ }
487
+
488
+ function isEmptyChampSelect(path, json) {
489
+ if (path.endsWith("/session")) {
490
+ if (!json || typeof json !== "object") return true;
491
+ return !Array.isArray(json.myTeam) || json.myTeam.length === 0;
492
+ }
493
+ return Array.isArray(json) && json.length === 0;
494
+ }
495
+
496
+ function champSelectPathOf(rawUrl) {
497
+ return champSelectDataPath(resolveUrl(rawUrl));
498
+ }
499
+
500
+ async function primeChampSelect(raw, path) {
501
+ for (const wait of [0, 120, 240]) {
502
+ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
503
+ try {
504
+ const response = await raw(path);
505
+ if (!response?.ok) continue;
506
+ const json = await response.json();
507
+ if (!isEmptyChampSelect(path, json)) return;
508
+ } catch {}
509
+ }
510
+ }
511
+
512
+ function createFetch(env) {
513
+ const native = env.fetch.bind(env);
514
+
515
+ function repairChampSelect(input, init, request, path) {
516
+ let attempt = 0;
517
+ const step = (current) =>
518
+ current.then((response) => {
519
+ if (!active || phase !== "ChampSelect" || attempt >= CHAMP_SELECT_RETRY_MS.length) return response;
520
+ if (!response || !response.ok || !response.clone) return response;
521
+ return response.clone().json().then(
522
+ (json) => {
523
+ if (!isEmptyChampSelect(path, json)) return response;
524
+ const wait = CHAMP_SELECT_RETRY_MS[attempt];
525
+ attempt += 1;
526
+ return new Promise((resolve) => setTimeout(resolve, wait)).then(() => step(native(input, init)));
527
+ },
528
+ () => response,
529
+ );
530
+ });
531
+ return step(request);
532
+ }
533
+
534
+ function betterFetch(input, init) {
535
+ const raw = urlOf(input);
536
+
537
+ if (active && shouldBlockUrl(raw)) {
538
+ return Promise.resolve(emptyJsonResponse(env));
539
+ }
540
+
541
+ const method = methodOf(input, init);
542
+ if (method !== "GET" && method !== "HEAD") {
543
+ return native(input, init);
544
+ }
545
+
546
+ const url = resolveUrl(raw);
547
+ if (!url || !isLocalHost(url.hostname)) {
548
+ return native(input, init);
549
+ }
550
+
551
+ const key = `${method} ${url.pathname}${url.search}`;
552
+ const pending = inflight.get(key);
553
+ if (pending) {
554
+ return pending.then((response) => (response?.clone ? response.clone() : response));
555
+ }
556
+
557
+ const ttl = active ? staticTtl(url.pathname) : 0;
558
+ if (ttl) {
559
+ const hit = cache.get(key);
560
+ if (hit && Date.now() - hit.at < hit.ttl) {
561
+ return Promise.resolve(hit.res.clone());
562
+ }
563
+ }
564
+
565
+ let request = native(input, init);
566
+ const dataPath = champSelectDataPath(url);
567
+ if (dataPath && active && phase === "ChampSelect") {
568
+ request = repairChampSelect(input, init, request, dataPath);
569
+ }
570
+
571
+ inflight.set(key, request);
572
+ const done = () => inflight.delete(key);
573
+ request.then(done, done);
574
+
575
+ if (ttl) {
576
+ request.then((response) => {
577
+ if (!response?.ok || !active) return;
578
+ storeInCache(key, ttl, response);
579
+ }, () => {});
580
+ }
581
+ return request;
582
+ }
583
+
584
+ betterFetch.__better = true;
585
+ betterFetch.raw = native;
586
+ return betterFetch;
587
+ }
588
+
589
+ function emitBlockedXhr(xhr, env) {
590
+ const reply = "{}";
591
+ try {
592
+ Object.defineProperty(xhr, "readyState", { configurable: true, get: () => 4 });
593
+ Object.defineProperty(xhr, "status", { configurable: true, get: () => 200 });
594
+ Object.defineProperty(xhr, "responseText", { configurable: true, get: () => reply });
595
+ Object.defineProperty(xhr, "response", { configurable: true, get: () => reply });
596
+ } catch {}
597
+ const EventCtor = env.Event ?? (typeof Event !== "undefined" ? Event : null);
598
+ if (!EventCtor) return;
599
+ setTimeout(() => {
600
+ try {
601
+ xhr.dispatchEvent(new EventCtor("readystatechange"));
602
+ xhr.dispatchEvent(new EventCtor("load"));
603
+ xhr.dispatchEvent(new EventCtor("loadend"));
604
+ } catch {}
605
+ }, 0);
606
+ }
607
+
608
+ function installXhr(env) {
609
+ const proto = env.XMLHttpRequest?.prototype;
610
+ if (!proto || proto.__better) return null;
611
+ const nativeOpen = proto.open;
612
+ const nativeSend = proto.send;
613
+ proto.open = function (method, url, ...rest) {
614
+ this.__betterUrl = String(url);
615
+ return nativeOpen.call(this, method, url, ...rest);
616
+ };
617
+ proto.send = function (body) {
618
+ if (active && shouldBlockUrl(this.__betterUrl)) {
619
+ emitBlockedXhr(this, env);
620
+ return undefined;
621
+ }
622
+ const path = champSelectPathOf(this.__betterUrl);
623
+ const raw = env.fetch?.raw;
624
+ if (path && raw && active && phase === "ChampSelect" && !this.__betterPrimed) {
625
+ this.__betterPrimed = true;
626
+ primeChampSelect(raw, path).finally(() => {
627
+ try {
628
+ nativeSend.call(this, body);
629
+ } catch {}
630
+ });
631
+ return undefined;
632
+ }
633
+ return nativeSend.call(this, body);
634
+ };
635
+ proto.__better = true;
636
+ return () => {
637
+ proto.open = nativeOpen;
638
+ proto.send = nativeSend;
639
+ delete proto.__better;
640
+ };
641
+ }
642
+
643
+ function installBeacon(env) {
644
+ const nav = env.navigator;
645
+ if (!nav?.sendBeacon || nav.sendBeacon.__better) return null;
646
+ const native = nav.sendBeacon.bind(nav);
647
+ const patched = function (url) {
648
+ if (active && shouldBlockUrl(url)) return true;
649
+ return native(url, arguments[1]);
650
+ };
651
+ patched.__better = true;
652
+ nav.sendBeacon = patched;
653
+ return () => {
654
+ nav.sendBeacon = native;
655
+ };
656
+ }
657
+
658
+ let mediaObserver = null;
659
+ let mediaScanObserver = null;
660
+ let mediaVisibilityListener = null;
661
+ let mediaPaused = new WeakSet();
662
+
663
+ let imageObserver = null;
664
+ let imageQueue = [];
665
+ let imageFlushTimer = null;
666
+ let imageFlushIdle = false;
667
+ let imageVisibilityListener = null;
668
+
669
+ function markAsyncDecode(img) {
670
+ if (!img || img.tagName !== "IMG" || img.decoding === "async") return 0;
671
+ try {
672
+ img.decoding = "async";
673
+ return 1;
674
+ } catch {
675
+ return 0;
676
+ }
677
+ }
678
+
679
+ function documentView() {
680
+ try {
681
+ return document.defaultView ?? null;
682
+ } catch {
683
+ return null;
684
+ }
685
+ }
686
+
687
+ function pageHidden() {
688
+ try {
689
+ return document.hidden === true;
690
+ } catch {
691
+ return false;
692
+ }
693
+ }
694
+
695
+ function scheduleImageFlush() {
696
+ if (imageFlushTimer !== null) return;
697
+ const view = documentView();
698
+ if (typeof view?.requestIdleCallback === "function") {
699
+ imageFlushIdle = true;
700
+ imageFlushTimer = view.requestIdleCallback(flushImageQueue, { timeout: 600 });
701
+ } else {
702
+ imageFlushIdle = false;
703
+ imageFlushTimer = setTimeout(flushImageQueue, 150);
704
+ }
705
+ }
706
+
707
+ function cancelImageFlush() {
708
+ if (imageFlushTimer === null) return;
709
+ const view = documentView();
710
+ if (imageFlushIdle && typeof view?.cancelIdleCallback === "function") view.cancelIdleCallback(imageFlushTimer);
711
+ else clearTimeout(imageFlushTimer);
712
+ imageFlushTimer = null;
713
+ }
714
+
715
+ function scanImages(root) {
716
+ let found;
717
+ try {
718
+ found = root.querySelectorAll("img[src]:not([decoding='async'])");
719
+ } catch {
720
+ return 0;
721
+ }
722
+ let marked = 0;
723
+ for (const img of found) marked += markAsyncDecode(img);
724
+ return marked;
725
+ }
726
+
727
+ function flushImageQueue() {
728
+ imageFlushTimer = null;
729
+ if (pageHidden()) {
730
+ scheduleImageFlush();
731
+ return;
732
+ }
733
+ const batch = imageQueue.splice(0, IMAGE_BATCH);
734
+ for (const root of batch) {
735
+ if (!root?.isConnected) continue;
736
+ markAsyncDecode(root);
737
+ scanImages(root);
738
+ }
739
+ if (imageQueue.length > 0) scheduleImageFlush();
740
+ }
741
+
742
+ function enqueueImages(root) {
743
+ if (!root || root.nodeType !== 1) return 0;
744
+ if (imageQueue.length >= IMAGE_QUEUE_LIMIT) return 0;
745
+ if (root.tagName !== "IMG" && root.childElementCount === 0) return 0;
746
+ imageQueue.push(root);
747
+ scheduleImageFlush();
748
+ return 1;
749
+ }
750
+
751
+ function pauseDecorativeVideo(video) {
752
+ if (!video || video.tagName !== "VIDEO" || video.paused) return;
753
+ if (!video.autoplay && !video.muted) return;
754
+ try {
755
+ video.pause();
756
+ mediaPaused.add(video);
757
+ } catch {}
758
+ }
759
+
760
+ function resumeDecorativeVideo(video) {
761
+ if (!video || !mediaPaused.has(video)) return;
762
+ mediaPaused.delete(video);
763
+ try {
764
+ const play = video.play?.();
765
+ if (play?.catch) play.catch(() => {});
766
+ } catch {}
767
+ }
768
+
769
+ function installMediaIdle() {
770
+ if (typeof document === "undefined" || !document.documentElement) return () => {};
771
+ const Obs = document.defaultView?.IntersectionObserver ?? IntersectionObserver;
772
+ const Mut = document.defaultView?.MutationObserver ?? MutationObserver;
773
+ if (!Obs || !Mut) return () => {};
774
+ mediaObserver = new Obs(
775
+ (entries) => {
776
+ for (const entry of entries) {
777
+ if (entry.isIntersecting) resumeDecorativeVideo(entry.target);
778
+ else pauseDecorativeVideo(entry.target);
779
+ }
780
+ },
781
+ { rootMargin: "240px" },
782
+ );
783
+ const scan = () => {
784
+ for (const video of document.querySelectorAll("video")) {
785
+ if (video.__betterMedia) continue;
786
+ video.__betterMedia = true;
787
+ try {
788
+ mediaObserver.observe(video);
789
+ } catch {}
790
+ }
791
+ };
792
+ scan();
793
+ mediaScanObserver = new Mut(scan);
794
+ try {
795
+ mediaScanObserver.observe(document.documentElement, { childList: true, subtree: true });
796
+ } catch {}
797
+ mediaVisibilityListener = () => {
798
+ if (pageHidden()) {
799
+ for (const video of document.querySelectorAll("video")) pauseDecorativeVideo(video);
800
+ return;
801
+ }
802
+ scan();
803
+ for (const video of document.querySelectorAll("video")) {
804
+ if (video.getClientRects().length > 0) resumeDecorativeVideo(video);
805
+ }
806
+ };
807
+ document.addEventListener("visibilitychange", mediaVisibilityListener);
808
+ return () => {
809
+ document.removeEventListener("visibilitychange", mediaVisibilityListener);
810
+ mediaVisibilityListener = null;
811
+ mediaScanObserver?.disconnect();
812
+ mediaScanObserver = null;
813
+ mediaObserver?.disconnect();
814
+ mediaObserver = null;
815
+ mediaPaused = new WeakSet();
816
+ };
817
+ }
818
+
819
+ function installImageDecode() {
820
+ if (typeof document === "undefined" || !document.documentElement) return () => {};
821
+ const Obs = document.defaultView?.MutationObserver ?? MutationObserver;
822
+ if (imageObserver || !Obs) return () => {};
823
+ imageObserver = new Obs((records) => {
824
+ let queued = 0;
825
+ for (const record of records) {
826
+ if (record.type === "attributes") {
827
+ if (record.target?.tagName === "IMG") queued += enqueueImages(record.target);
828
+ continue;
829
+ }
830
+ const nodes = record.addedNodes;
831
+ for (let index = 0; index < nodes.length; index++) {
832
+ const node = nodes[index];
833
+ if (node?.nodeType === 1) queued += enqueueImages(node);
834
+ }
835
+ if (queued > 400) break;
836
+ }
837
+ });
838
+ try {
839
+ imageObserver.observe(document.documentElement, { childList: true, subtree: true });
840
+ } catch {
841
+ imageObserver = null;
842
+ return () => {};
843
+ }
844
+ enqueueImages(document.documentElement);
845
+ imageVisibilityListener = () => {
846
+ if (pageHidden()) return;
847
+ enqueueImages(document.documentElement);
848
+ scheduleImageFlush();
849
+ };
850
+ document.addEventListener("visibilitychange", imageVisibilityListener);
851
+ return () => {
852
+ document.removeEventListener("visibilitychange", imageVisibilityListener);
853
+ imageVisibilityListener = null;
854
+ cancelImageFlush();
855
+ imageQueue = [];
856
+ imageObserver?.disconnect();
857
+ imageObserver = null;
858
+ };
859
+ }
860
+
861
+ function ungateNode(node) {
862
+ if (!node?.querySelectorAll) return 0;
863
+ let changed = 0;
864
+ const candidates = [];
865
+ try {
866
+ if (node.matches?.(BLOCK_MARKER)) candidates.push(node);
867
+ candidates.push(...node.querySelectorAll(BLOCK_MARKER));
868
+ } catch {
869
+ return 0;
870
+ }
871
+ for (const element of candidates) {
872
+ if (element.disabled === true) {
873
+ element.disabled = false;
874
+ changed += 1;
875
+ }
876
+ if (element.hasAttribute?.("disabled")) {
877
+ element.removeAttribute("disabled");
878
+ changed += 1;
879
+ }
880
+ if (element.getAttribute?.("aria-disabled") === "true") {
881
+ element.removeAttribute("aria-disabled");
882
+ changed += 1;
883
+ }
884
+ if (element.style?.pointerEvents === "none") {
885
+ element.style.pointerEvents = "";
886
+ changed += 1;
887
+ }
888
+ }
889
+ return changed;
890
+ }
891
+
892
+ function ungatePass() {
893
+ if (!active || phase !== "InProgress") return 0;
894
+ let nodes;
895
+ try {
896
+ nodes = document.querySelectorAll(UNGATE_SELECTOR);
897
+ } catch {
898
+ return 0;
899
+ }
900
+ let changed = 0;
901
+ for (const node of nodes) changed += ungateNode(node);
902
+ return changed;
903
+ }
904
+
905
+ function ungateDelay() {
906
+ return ungateIdle >= UNGATE_BACKOFF_AFTER ? UNGATE_IDLE_MS : UNGATE_MS;
907
+ }
908
+
909
+ function ungateTick() {
910
+ ungateTimer = null;
911
+ if (!active || phase !== "InProgress") return;
912
+ if (pageHidden()) {
913
+ ungateTimer = setTimeout(ungateTick, UNGATE_IDLE_MS);
914
+ return;
915
+ }
916
+ const changed = ungatePass();
917
+ ungateIdle = changed > 0 ? 0 : ungateIdle + 1;
918
+ ungateTimer = setTimeout(ungateTick, ungateDelay());
919
+ }
920
+
921
+ function startUngate() {
922
+ if (ungateTimer) return;
923
+ ungateIdle = 0;
924
+ ungateTick();
925
+ }
926
+
927
+ function stopUngate() {
928
+ clearTimeout(ungateTimer);
929
+ ungateTimer = null;
930
+ ungateIdle = 0;
931
+ }
932
+
933
+ function onPhase(next) {
934
+ const value = typeof next === "string" ? next : next?.phase ?? phase;
935
+ if (!value || value === phase) {
936
+ if (value === "InProgress") startUngate();
937
+ return;
938
+ }
939
+ phase = value;
940
+ clearVolatileCache();
941
+ if (phase === "InProgress") startUngate();
942
+ else stopUngate();
943
+ }
944
+
945
+ async function refreshPhase() {
946
+ if (!active || pageHidden()) return;
947
+ try {
948
+ const current = await api?.lcu?.get?.("/lol-gameflow/v1/gameflow-phase");
949
+ if (current) onPhase(current);
950
+ } catch {}
951
+ }
952
+
953
+ function startPhaseWatch() {
954
+ stopPhaseWatch();
955
+ if (unsubscribes.length === 0) {
956
+ phaseTimer = setInterval(() => {
957
+ if (!pageHidden()) refreshPhase();
958
+ }, PHASE_POLL_FALLBACK_MS);
959
+ }
960
+ phaseWatchListener = () => {
961
+ if (!pageHidden()) refreshPhase();
962
+ };
963
+ document.addEventListener("visibilitychange", phaseWatchListener);
964
+ }
965
+
966
+ function stopPhaseWatch() {
967
+ clearInterval(phaseTimer);
968
+ phaseTimer = null;
969
+ if (phaseWatchListener) {
970
+ document.removeEventListener("visibilitychange", phaseWatchListener);
971
+ phaseWatchListener = null;
972
+ }
973
+ }
974
+
975
+ function applyPerformanceMode() {
976
+ if (!api) return;
977
+ if (performanceMode) {
978
+ if (!removePerformanceStyle) removePerformanceStyle = api.addStyle(PERF_STYLE_ID, PERFORMANCE_CSS);
979
+ return;
980
+ }
981
+ if (removePerformanceStyle) {
982
+ removePerformanceStyle();
983
+ removePerformanceStyle = null;
984
+ }
985
+ }
986
+
987
+ function installNetwork(env) {
988
+ const installed = [];
989
+ try {
990
+ const originalFetch = env.fetch;
991
+ env.fetch = createFetch(env);
992
+ installed.push(() => {
993
+ env.fetch = originalFetch;
994
+ });
995
+ } catch {}
996
+ const xhr = installXhr(env);
997
+ if (xhr) installed.push(xhr);
998
+ const beacon = installBeacon(env);
999
+ if (beacon) installed.push(beacon);
1000
+ return () => {
1001
+ for (const restore of installed.reverse()) {
1002
+ try {
1003
+ restore();
1004
+ } catch {}
1005
+ }
1006
+ };
1007
+ }
1008
+
1009
+ function onEnable(utils) {
1010
+ api = utils;
1011
+ active = true;
1012
+ restores = [installNetwork(window), installImageDecode(), installMediaIdle()];
1013
+ unsubscribes = [];
1014
+ try {
1015
+ const off = api.subscribe("/lol-gameflow/v1/gameflow-phase", onPhase);
1016
+ if (off) unsubscribes.push(off);
1017
+ } catch {}
1018
+ try {
1019
+ api
1020
+ .lcu?.get?.("/lol-gameflow/v1/gameflow-phase")
1021
+ .then((value) => onPhase(value))
1022
+ .catch(() => {});
1023
+ } catch {}
1024
+ startPhaseWatch();
1025
+ performanceMode = api.getOption?.(PLUGIN_ID, "performanceMode") === true;
1026
+ applyPerformanceMode();
1027
+ if (api.getOption?.(PLUGIN_ID, "diagnostics") === true) diagnosticsStart();
1028
+ if (phase === "InProgress") startUngate();
1029
+ }
1030
+
1031
+ function onDisable() {
1032
+ active = false;
1033
+ diagnosticsStop();
1034
+ stopPhaseWatch();
1035
+ stopUngate();
1036
+ for (const off of unsubscribes) {
1037
+ try {
1038
+ off();
1039
+ } catch {}
1040
+ }
1041
+ unsubscribes = [];
1042
+ for (const restore of restores.reverse()) {
1043
+ try {
1044
+ restore();
1045
+ } catch {}
1046
+ }
1047
+ restores = [];
1048
+ if (removePerformanceStyle) {
1049
+ removePerformanceStyle();
1050
+ removePerformanceStyle = null;
1051
+ }
1052
+ performanceMode = false;
1053
+ inflight.clear();
1054
+ blockDecisions.clear();
1055
+ blockedStats.clear();
1056
+ for (const key of [...cache.keys()]) evictOne(key);
1057
+ cacheBytes = 0;
1058
+ api = null;
1059
+ }
1060
+
1061
+ function onOptionChange(key, value) {
1062
+ if (key === "performanceMode") {
1063
+ performanceMode = value === true;
1064
+ applyPerformanceMode();
1065
+ }
1066
+ if (key === "diagnostics") {
1067
+ if (value === true) diagnosticsStart();
1068
+ else diagnosticsStop();
1069
+ }
1070
+ }
1071
+
1072
+ export function init() {
1073
+ const registerWith = (utils) => {
1074
+ utils.registerPlugin({
1075
+ id: PLUGIN_ID,
1076
+ name: "BetterClient",
1077
+ description:
1078
+ "Debloats the client: blocks telemetry and analytics calls, merges duplicate requests, caches static client data, decodes images asynchronously and keeps settings, chat and friend requests usable during a game.",
1079
+ version,
1080
+ defaultEnabled: true,
1081
+ options: OPTIONS,
1082
+ onEnable,
1083
+ onDisable,
1084
+ onOptionChange,
1085
+ });
1086
+ };
1087
+
1088
+ if (window.PerfectionistUtils) {
1089
+ registerWith(window.PerfectionistUtils);
1090
+ return;
1091
+ }
1092
+ addEventListener("perfectionist:utils-ready", (e) => registerWith(e.detail ?? window.PerfectionistUtils), { once: true });
1093
+ }