perfectionist-plugins 2.4.3 → 2.4.4

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,796 +1,796 @@
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 TELEMETRY_HOST = /(^|\.)(telemetry|analytics|metrics|sentry|bugsnag|crash-?report(er)?|pulse|tracking|beacon|datadog|loggly)(\.|:|$)/i;
15
- 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;
16
- const ASSET_PATH = /^\/lol-game-data\/assets\//i;
17
- const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
18
-
19
- const CHAMP_SELECT_DATA = /^\/lol-champ-select\/v1\/(session|all-grid-champions|pickable-champion-ids|bannable-champion-ids)$/i;
20
- const CHAMP_SELECT_RETRY_MS = [120, 260, 520];
21
-
22
- const STATIC_ENDPOINTS = [
23
- ["/lol-game-data/assets/", 60 * 60 * 1000],
24
- ["/lol-perks/v1/perks", 60 * 60 * 1000],
25
- ["/riotclient/region-locale", 60 * 1000],
26
- ["/lol-summoner/v1/current-summoner", 10 * 1000],
27
- ["/performance/v1/quickstats", 5000],
28
- ];
29
-
30
- const UNGATE_SELECTORS = [
31
- ".app-controls-settings",
32
- ".lol-settings-button",
33
- "[class*='social-panel']",
34
- ".social-panel",
35
- ".lol-social-panel",
36
- ".lol-navigation",
37
- ".lol-navigation-item",
38
- "[class*='friends-list']",
39
- ];
40
- const UNGATE_SELECTOR = UNGATE_SELECTORS.join(",");
41
- const BLOCK_MARKER = "[disabled], [aria-disabled='true'], [style*='pointer-events']";
42
-
43
- const PERF_STYLE_ID = "perfectionist-better-client-performance";
44
- const PERFORMANCE_CSS = `
45
- [class*="blur"], [class*="Blur"], [class*="backdrop"], [class*="Backdrop"] {
46
- filter: none !important;
47
- backdrop-filter: none !important;
48
- -webkit-backdrop-filter: none !important;
49
- }
50
- [class*="particle"], [class*="Particle"], [class*="parallax"], [class*="Parallax"],
51
- [class*="animated-background"], [class*="animatedBackground"], [class*="glow"], [class*="Glow"] {
52
- animation: none !important;
53
- transition: none !important;
54
- }
55
- [class*="background"] video, [class*="Background"] video { display: none !important; }
56
- `;
57
-
58
- const OPTIONS = [
59
- {
60
- key: "performanceMode",
61
- label: "Performance mode",
62
- description: "Turns off background blur, decorative animation and looping background video to save GPU and CPU on weaker machines.",
63
- default: false,
64
- },
65
- ];
66
-
67
- let api = null;
68
- let active = false;
69
- let phase = "None";
70
- let unsubscribes = [];
71
- let restores = [];
72
- let ungateTimer = null;
73
- let ungateIdle = 0;
74
- let phaseTimer = null;
75
- let phaseWatchListener = null;
76
- let performanceMode = false;
77
- let removePerformanceStyle = null;
78
- let cacheBytes = 0;
79
-
80
- const cache = new Map();
81
- const inflight = new Map();
82
- const blockDecisions = new Map();
83
-
84
- function baseHref() {
85
- try {
86
- return typeof location !== "undefined" ? location.href : "https://127.0.0.1/";
87
- } catch {
88
- return "https://127.0.0.1/";
89
- }
90
- }
91
-
92
- function resolveUrl(raw, base) {
93
- try {
94
- return new URL(String(raw), base ?? baseHref());
95
- } catch {
96
- return null;
97
- }
98
- }
99
-
100
- function isLocalHost(host) {
101
- return LOCAL_HOSTS.has(String(host ?? "").replace(/^\[|\]$/g, ""));
102
- }
103
-
104
- function isTelemetryUrl(raw, base) {
105
- const text = typeof raw === "string" ? raw : urlOf(raw);
106
- const memo = blockDecisions.get(text);
107
- if (memo !== undefined) return memo;
108
-
109
- let verdict;
110
- if (text.startsWith("/") && !text.startsWith("//")) {
111
- verdict = !ASSET_PATH.test(text) && TELEMETRY_PATH.test(text);
112
- } else {
113
- const url = resolveUrl(text, base);
114
- if (!url || url.protocol === "data:" || url.protocol === "blob:") verdict = false;
115
- else if (ASSET_PATH.test(url.pathname)) verdict = false;
116
- else if (isLocalHost(url.hostname)) verdict = TELEMETRY_PATH.test(url.pathname);
117
- else verdict = TELEMETRY_HOST.test(url.hostname) || TELEMETRY_PATH.test(url.pathname);
118
- }
119
-
120
- if (blockDecisions.size >= DECISION_CACHE_LIMIT) blockDecisions.clear();
121
- blockDecisions.set(text, verdict);
122
- return verdict;
123
- }
124
-
125
- function shouldBlockUrl(raw, base) {
126
- return isTelemetryUrl(raw, base);
127
- }
128
-
129
- function staticTtl(path) {
130
- if (!path) return 0;
131
- for (const [prefix, ttl] of STATIC_ENDPOINTS) {
132
- if (path.startsWith(prefix)) return ttl;
133
- }
134
- return 0;
135
- }
136
-
137
- function methodOf(input, init) {
138
- const method = init?.method ?? (input && typeof input === "object" && "method" in input ? input.method : "GET");
139
- return String(method ?? "GET").toUpperCase();
140
- }
141
-
142
- function urlOf(input) {
143
- if (typeof input === "string") return input;
144
- if (input && typeof input === "object") {
145
- if (typeof input.url === "string") return input.url;
146
- if (typeof input.href === "string") return input.href;
147
- }
148
- return String(input);
149
- }
150
-
151
- function emptyJsonResponse(env) {
152
- const Ctor = env?.Response ?? (typeof Response !== "undefined" ? Response : null);
153
- if (!Ctor) return undefined;
154
- try {
155
- return new Ctor("{}", { status: 200, headers: { "Content-Type": "application/json" } });
156
- } catch {
157
- return undefined;
158
- }
159
- }
160
-
161
- function evictOne(key) {
162
- const entry = cache.get(key);
163
- if (!entry) return;
164
- cacheBytes -= entry.bytes;
165
- cache.delete(key);
166
- }
167
-
168
- function evict(max) {
169
- while (cache.size >= max) evictOne(cache.keys().next().value);
170
- }
171
-
172
- function storeInCache(key, ttl, response) {
173
- const type = String(response.headers?.get?.("Content-Type") ?? "");
174
- if (type && !/json|javascript|plain/i.test(type)) return;
175
- const length = Number(response.headers?.get?.("Content-Length") ?? 0) || 0;
176
- if (length > MAX_CACHE_ENTRY_BYTES) return;
177
- evict(MAX_CACHE_ENTRIES);
178
- cache.set(key, { at: Date.now(), ttl, res: response.clone(), bytes: length });
179
- cacheBytes += length;
180
- while (cacheBytes > MAX_CACHE_BYTES && cache.size > 1) {
181
- evictOne(cache.keys().next().value);
182
- }
183
- }
184
-
185
- function clearVolatileCache() {
186
- for (const [key, entry] of [...cache]) {
187
- if (entry.ttl < 60 * 60 * 1000) evictOne(key);
188
- }
189
- }
190
-
191
- function champSelectDataPath(url) {
192
- if (!url) return null;
193
- return CHAMP_SELECT_DATA.test(url.pathname) ? url.pathname.toLowerCase() : null;
194
- }
195
-
196
- function isEmptyChampSelect(path, json) {
197
- if (path.endsWith("/session")) {
198
- if (!json || typeof json !== "object") return true;
199
- return !Array.isArray(json.myTeam) || json.myTeam.length === 0;
200
- }
201
- return Array.isArray(json) && json.length === 0;
202
- }
203
-
204
- function champSelectPathOf(rawUrl) {
205
- return champSelectDataPath(resolveUrl(rawUrl));
206
- }
207
-
208
- async function primeChampSelect(raw, path) {
209
- for (const wait of [0, 120, 240]) {
210
- if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
211
- try {
212
- const response = await raw(path);
213
- if (!response?.ok) continue;
214
- const json = await response.json();
215
- if (!isEmptyChampSelect(path, json)) return;
216
- } catch {}
217
- }
218
- }
219
-
220
- function createFetch(env) {
221
- const native = env.fetch.bind(env);
222
-
223
- function repairChampSelect(input, init, request, path) {
224
- let attempt = 0;
225
- const step = (current) =>
226
- current.then((response) => {
227
- if (!active || phase !== "ChampSelect" || attempt >= CHAMP_SELECT_RETRY_MS.length) return response;
228
- if (!response || !response.ok || !response.clone) return response;
229
- return response.clone().json().then(
230
- (json) => {
231
- if (!isEmptyChampSelect(path, json)) return response;
232
- const wait = CHAMP_SELECT_RETRY_MS[attempt];
233
- attempt += 1;
234
- return new Promise((resolve) => setTimeout(resolve, wait)).then(() => step(native(input, init)));
235
- },
236
- () => response,
237
- );
238
- });
239
- return step(request);
240
- }
241
-
242
- function betterFetch(input, init) {
243
- const raw = urlOf(input);
244
-
245
- if (active && shouldBlockUrl(raw)) {
246
- return Promise.resolve(emptyJsonResponse(env));
247
- }
248
-
249
- const method = methodOf(input, init);
250
- if (method !== "GET" && method !== "HEAD") {
251
- return native(input, init);
252
- }
253
-
254
- const url = resolveUrl(raw);
255
- if (!url || !isLocalHost(url.hostname)) {
256
- return native(input, init);
257
- }
258
-
259
- const key = `${method} ${url.pathname}${url.search}`;
260
- const pending = inflight.get(key);
261
- if (pending) {
262
- return pending.then((response) => (response?.clone ? response.clone() : response));
263
- }
264
-
265
- const ttl = active ? staticTtl(url.pathname) : 0;
266
- if (ttl) {
267
- const hit = cache.get(key);
268
- if (hit && Date.now() - hit.at < hit.ttl) {
269
- return Promise.resolve(hit.res.clone());
270
- }
271
- }
272
-
273
- let request = native(input, init);
274
- const dataPath = champSelectDataPath(url);
275
- if (dataPath && active && phase === "ChampSelect") {
276
- request = repairChampSelect(input, init, request, dataPath);
277
- }
278
-
279
- inflight.set(key, request);
280
- const done = () => inflight.delete(key);
281
- request.then(done, done);
282
-
283
- if (ttl) {
284
- request.then((response) => {
285
- if (!response?.ok || !active) return;
286
- storeInCache(key, ttl, response);
287
- }, () => {});
288
- }
289
- // Every caller gets its own clone; the original response stays unread so
290
- // concurrent callers can clone it after one caller consumes the body.
291
- return request.then((response) => (response?.clone ? response.clone() : response));
292
- }
293
-
294
- betterFetch.__better = true;
295
- betterFetch.raw = native;
296
- return betterFetch;
297
- }
298
-
299
- function emitBlockedXhr(xhr, env) {
300
- const reply = "{}";
301
- try {
302
- Object.defineProperty(xhr, "readyState", { configurable: true, get: () => 4 });
303
- Object.defineProperty(xhr, "status", { configurable: true, get: () => 200 });
304
- Object.defineProperty(xhr, "responseText", { configurable: true, get: () => reply });
305
- Object.defineProperty(xhr, "response", { configurable: true, get: () => reply });
306
- } catch {}
307
- const EventCtor = env.Event ?? (typeof Event !== "undefined" ? Event : null);
308
- if (!EventCtor) return;
309
- setTimeout(() => {
310
- try {
311
- xhr.dispatchEvent(new EventCtor("readystatechange"));
312
- xhr.dispatchEvent(new EventCtor("load"));
313
- xhr.dispatchEvent(new EventCtor("loadend"));
314
- } catch {}
315
- }, 0);
316
- }
317
-
318
- function installXhr(env) {
319
- const proto = env.XMLHttpRequest?.prototype;
320
- if (!proto || proto.__better) return null;
321
- const nativeOpen = proto.open;
322
- const nativeSend = proto.send;
323
- proto.open = function (method, url, ...rest) {
324
- this.__betterUrl = String(url);
325
- return nativeOpen.call(this, method, url, ...rest);
326
- };
327
- proto.send = function (body) {
328
- if (active && shouldBlockUrl(this.__betterUrl)) {
329
- emitBlockedXhr(this, env);
330
- return undefined;
331
- }
332
- const path = champSelectPathOf(this.__betterUrl);
333
- const raw = env.fetch?.raw;
334
- if (path && raw && active && phase === "ChampSelect" && !this.__betterPrimed) {
335
- this.__betterPrimed = true;
336
- primeChampSelect(raw, path).finally(() => {
337
- try {
338
- nativeSend.call(this, body);
339
- } catch {}
340
- });
341
- return undefined;
342
- }
343
- return nativeSend.call(this, body);
344
- };
345
- proto.__better = true;
346
- return () => {
347
- proto.open = nativeOpen;
348
- proto.send = nativeSend;
349
- delete proto.__better;
350
- };
351
- }
352
-
353
- function installBeacon(env) {
354
- const nav = env.navigator;
355
- if (!nav?.sendBeacon || nav.sendBeacon.__better) return null;
356
- const native = nav.sendBeacon.bind(nav);
357
- const patched = function (url) {
358
- if (active && shouldBlockUrl(url)) return true;
359
- return native(url, arguments[1]);
360
- };
361
- patched.__better = true;
362
- nav.sendBeacon = patched;
363
- return () => {
364
- nav.sendBeacon = native;
365
- };
366
- }
367
-
368
- let mediaObserver = null;
369
- let mediaScanObserver = null;
370
- let mediaVisibilityListener = null;
371
- let mediaPaused = new WeakSet();
372
-
373
- let imageObserver = null;
374
- let imageQueue = [];
375
- let imageFlushTimer = null;
376
- let imageFlushIdle = false;
377
- let imageVisibilityListener = null;
378
-
379
- function markAsyncDecode(img) {
380
- if (!img || img.tagName !== "IMG" || img.decoding === "async") return 0;
381
- try {
382
- img.decoding = "async";
383
- return 1;
384
- } catch {
385
- return 0;
386
- }
387
- }
388
-
389
- function documentView() {
390
- try {
391
- return document.defaultView ?? null;
392
- } catch {
393
- return null;
394
- }
395
- }
396
-
397
- function pageHidden() {
398
- try {
399
- return document.hidden === true;
400
- } catch {
401
- return false;
402
- }
403
- }
404
-
405
- function scheduleImageFlush() {
406
- if (imageFlushTimer !== null) return;
407
- const view = documentView();
408
- if (typeof view?.requestIdleCallback === "function") {
409
- imageFlushIdle = true;
410
- imageFlushTimer = view.requestIdleCallback(flushImageQueue, { timeout: 600 });
411
- } else {
412
- imageFlushIdle = false;
413
- imageFlushTimer = setTimeout(flushImageQueue, 150);
414
- }
415
- }
416
-
417
- function cancelImageFlush() {
418
- if (imageFlushTimer === null) return;
419
- const view = documentView();
420
- if (imageFlushIdle && typeof view?.cancelIdleCallback === "function") view.cancelIdleCallback(imageFlushTimer);
421
- else clearTimeout(imageFlushTimer);
422
- imageFlushTimer = null;
423
- }
424
-
425
- function scanImages(root) {
426
- let found;
427
- try {
428
- found = root.querySelectorAll("img[src]:not([decoding='async'])");
429
- } catch {
430
- return 0;
431
- }
432
- let marked = 0;
433
- for (const img of found) marked += markAsyncDecode(img);
434
- return marked;
435
- }
436
-
437
- function flushImageQueue() {
438
- imageFlushTimer = null;
439
- if (pageHidden()) {
440
- scheduleImageFlush();
441
- return;
442
- }
443
- const batch = imageQueue.splice(0, IMAGE_BATCH);
444
- for (const root of batch) {
445
- if (!root?.isConnected) continue;
446
- markAsyncDecode(root);
447
- scanImages(root);
448
- }
449
- if (imageQueue.length > 0) scheduleImageFlush();
450
- }
451
-
452
- function enqueueImages(root) {
453
- if (!root || root.nodeType !== 1) return 0;
454
- if (imageQueue.length >= IMAGE_QUEUE_LIMIT) return 0;
455
- if (root.tagName !== "IMG" && root.childElementCount === 0) return 0;
456
- imageQueue.push(root);
457
- scheduleImageFlush();
458
- return 1;
459
- }
460
-
461
- function pauseDecorativeVideo(video) {
462
- if (!video || video.tagName !== "VIDEO" || video.paused) return;
463
- if (!video.autoplay && !video.muted) return;
464
- try {
465
- video.pause();
466
- mediaPaused.add(video);
467
- } catch {}
468
- }
469
-
470
- function resumeDecorativeVideo(video) {
471
- if (!video || !mediaPaused.has(video)) return;
472
- mediaPaused.delete(video);
473
- try {
474
- const play = video.play?.();
475
- if (play?.catch) play.catch(() => {});
476
- } catch {}
477
- }
478
-
479
- function installMediaIdle() {
480
- if (typeof document === "undefined" || !document.documentElement) return () => {};
481
- const Obs = document.defaultView?.IntersectionObserver ?? IntersectionObserver;
482
- const Mut = document.defaultView?.MutationObserver ?? MutationObserver;
483
- if (!Obs || !Mut) return () => {};
484
- mediaObserver = new Obs(
485
- (entries) => {
486
- for (const entry of entries) {
487
- if (entry.isIntersecting) resumeDecorativeVideo(entry.target);
488
- else pauseDecorativeVideo(entry.target);
489
- }
490
- },
491
- { rootMargin: "240px" },
492
- );
493
- const scan = () => {
494
- for (const video of document.querySelectorAll("video")) {
495
- if (video.__betterMedia) continue;
496
- video.__betterMedia = true;
497
- try {
498
- mediaObserver.observe(video);
499
- } catch {}
500
- }
501
- };
502
- scan();
503
- mediaScanObserver = new Mut(scan);
504
- try {
505
- mediaScanObserver.observe(document.documentElement, { childList: true, subtree: true });
506
- } catch {}
507
- mediaVisibilityListener = () => {
508
- if (pageHidden()) {
509
- for (const video of document.querySelectorAll("video")) pauseDecorativeVideo(video);
510
- return;
511
- }
512
- scan();
513
- for (const video of document.querySelectorAll("video")) {
514
- if (video.getClientRects().length > 0) resumeDecorativeVideo(video);
515
- }
516
- };
517
- document.addEventListener("visibilitychange", mediaVisibilityListener);
518
- return () => {
519
- document.removeEventListener("visibilitychange", mediaVisibilityListener);
520
- mediaVisibilityListener = null;
521
- mediaScanObserver?.disconnect();
522
- mediaScanObserver = null;
523
- mediaObserver?.disconnect();
524
- mediaObserver = null;
525
- mediaPaused = new WeakSet();
526
- };
527
- }
528
-
529
- function installImageDecode() {
530
- if (typeof document === "undefined" || !document.documentElement) return () => {};
531
- const Obs = document.defaultView?.MutationObserver ?? MutationObserver;
532
- if (imageObserver || !Obs) return () => {};
533
- imageObserver = new Obs((records) => {
534
- let queued = 0;
535
- for (const record of records) {
536
- if (record.type === "attributes") {
537
- if (record.target?.tagName === "IMG") queued += enqueueImages(record.target);
538
- continue;
539
- }
540
- const nodes = record.addedNodes;
541
- for (let index = 0; index < nodes.length; index++) {
542
- const node = nodes[index];
543
- if (node?.nodeType === 1) queued += enqueueImages(node);
544
- }
545
- if (queued > 400) break;
546
- }
547
- });
548
- try {
549
- imageObserver.observe(document.documentElement, { childList: true, subtree: true });
550
- } catch {
551
- imageObserver = null;
552
- return () => {};
553
- }
554
- enqueueImages(document.documentElement);
555
- imageVisibilityListener = () => {
556
- if (pageHidden()) return;
557
- enqueueImages(document.documentElement);
558
- scheduleImageFlush();
559
- };
560
- document.addEventListener("visibilitychange", imageVisibilityListener);
561
- return () => {
562
- document.removeEventListener("visibilitychange", imageVisibilityListener);
563
- imageVisibilityListener = null;
564
- cancelImageFlush();
565
- imageQueue = [];
566
- imageObserver?.disconnect();
567
- imageObserver = null;
568
- };
569
- }
570
-
571
- function ungateNode(node) {
572
- if (!node?.querySelectorAll) return 0;
573
- let changed = 0;
574
- const candidates = [];
575
- try {
576
- if (node.matches?.(BLOCK_MARKER)) candidates.push(node);
577
- candidates.push(...node.querySelectorAll(BLOCK_MARKER));
578
- } catch {
579
- return 0;
580
- }
581
- for (const element of candidates) {
582
- if (element.disabled === true) {
583
- element.disabled = false;
584
- changed += 1;
585
- }
586
- if (element.hasAttribute?.("disabled")) {
587
- element.removeAttribute("disabled");
588
- changed += 1;
589
- }
590
- if (element.getAttribute?.("aria-disabled") === "true") {
591
- element.removeAttribute("aria-disabled");
592
- changed += 1;
593
- }
594
- if (element.style?.pointerEvents === "none") {
595
- element.style.pointerEvents = "";
596
- changed += 1;
597
- }
598
- }
599
- return changed;
600
- }
601
-
602
- function ungatePass() {
603
- if (!active || phase !== "InProgress") return 0;
604
- let nodes;
605
- try {
606
- nodes = document.querySelectorAll(UNGATE_SELECTOR);
607
- } catch {
608
- return 0;
609
- }
610
- let changed = 0;
611
- for (const node of nodes) changed += ungateNode(node);
612
- return changed;
613
- }
614
-
615
- function ungateDelay() {
616
- return ungateIdle >= UNGATE_BACKOFF_AFTER ? UNGATE_IDLE_MS : UNGATE_MS;
617
- }
618
-
619
- function ungateTick() {
620
- ungateTimer = null;
621
- if (!active || phase !== "InProgress") return;
622
- if (pageHidden()) {
623
- ungateTimer = setTimeout(ungateTick, UNGATE_IDLE_MS);
624
- return;
625
- }
626
- const changed = ungatePass();
627
- ungateIdle = changed > 0 ? 0 : ungateIdle + 1;
628
- ungateTimer = setTimeout(ungateTick, ungateDelay());
629
- }
630
-
631
- function startUngate() {
632
- if (ungateTimer) return;
633
- ungateIdle = 0;
634
- ungateTick();
635
- }
636
-
637
- function stopUngate() {
638
- clearTimeout(ungateTimer);
639
- ungateTimer = null;
640
- ungateIdle = 0;
641
- }
642
-
643
- function onPhase(next) {
644
- const value = typeof next === "string" ? next : next?.phase ?? phase;
645
- if (!value || value === phase) {
646
- if (value === "InProgress") startUngate();
647
- return;
648
- }
649
- phase = value;
650
- clearVolatileCache();
651
- if (phase === "InProgress") startUngate();
652
- else stopUngate();
653
- }
654
-
655
- async function refreshPhase() {
656
- if (!active || pageHidden()) return;
657
- try {
658
- const current = await api?.lcu?.get?.("/lol-gameflow/v1/gameflow-phase");
659
- if (current) onPhase(current);
660
- } catch {}
661
- }
662
-
663
- function startPhaseWatch() {
664
- stopPhaseWatch();
665
- if (unsubscribes.length === 0) {
666
- phaseTimer = setInterval(() => {
667
- if (!pageHidden()) refreshPhase();
668
- }, PHASE_POLL_FALLBACK_MS);
669
- }
670
- phaseWatchListener = () => {
671
- if (!pageHidden()) refreshPhase();
672
- };
673
- document.addEventListener("visibilitychange", phaseWatchListener);
674
- }
675
-
676
- function stopPhaseWatch() {
677
- clearInterval(phaseTimer);
678
- phaseTimer = null;
679
- if (phaseWatchListener) {
680
- document.removeEventListener("visibilitychange", phaseWatchListener);
681
- phaseWatchListener = null;
682
- }
683
- }
684
-
685
- function applyPerformanceMode() {
686
- if (!api) return;
687
- if (performanceMode) {
688
- if (!removePerformanceStyle) removePerformanceStyle = api.addStyle(PERF_STYLE_ID, PERFORMANCE_CSS);
689
- return;
690
- }
691
- if (removePerformanceStyle) {
692
- removePerformanceStyle();
693
- removePerformanceStyle = null;
694
- }
695
- }
696
-
697
- function installNetwork(env) {
698
- const installed = [];
699
- try {
700
- const originalFetch = env.fetch;
701
- env.fetch = createFetch(env);
702
- installed.push(() => {
703
- env.fetch = originalFetch;
704
- });
705
- } catch {}
706
- const xhr = installXhr(env);
707
- if (xhr) installed.push(xhr);
708
- const beacon = installBeacon(env);
709
- if (beacon) installed.push(beacon);
710
- return () => {
711
- for (const restore of installed.reverse()) {
712
- try {
713
- restore();
714
- } catch {}
715
- }
716
- };
717
- }
718
-
719
- function onEnable(utils) {
720
- api = utils;
721
- active = true;
722
- restores = [installNetwork(window), installImageDecode(), installMediaIdle()];
723
- unsubscribes = [];
724
- try {
725
- const off = api.subscribe("/lol-gameflow/v1/gameflow-phase", onPhase);
726
- if (off) unsubscribes.push(off);
727
- } catch {}
728
- try {
729
- api
730
- .lcu?.get?.("/lol-gameflow/v1/gameflow-phase")
731
- .then((value) => onPhase(value))
732
- .catch(() => {});
733
- } catch {}
734
- startPhaseWatch();
735
- performanceMode = api.getOption?.(PLUGIN_ID, "performanceMode") === true;
736
- applyPerformanceMode();
737
- if (phase === "InProgress") startUngate();
738
- }
739
-
740
- function onDisable() {
741
- active = false;
742
- stopPhaseWatch();
743
- stopUngate();
744
- for (const off of unsubscribes) {
745
- try {
746
- off();
747
- } catch {}
748
- }
749
- unsubscribes = [];
750
- for (const restore of restores.reverse()) {
751
- try {
752
- restore();
753
- } catch {}
754
- }
755
- restores = [];
756
- if (removePerformanceStyle) {
757
- removePerformanceStyle();
758
- removePerformanceStyle = null;
759
- }
760
- performanceMode = false;
761
- inflight.clear();
762
- blockDecisions.clear();
763
- for (const key of [...cache.keys()]) evictOne(key);
764
- cacheBytes = 0;
765
- api = null;
766
- }
767
-
768
- function onOptionChange(key, value) {
769
- if (key === "performanceMode") {
770
- performanceMode = value === true;
771
- applyPerformanceMode();
772
- }
773
- }
774
-
775
- export function init() {
776
- const registerWith = (utils) => {
777
- utils.registerPlugin({
778
- id: PLUGIN_ID,
779
- name: "BetterClient",
780
- description:
781
- "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.",
782
- version,
783
- defaultEnabled: true,
784
- options: OPTIONS,
785
- onEnable,
786
- onDisable,
787
- onOptionChange,
788
- });
789
- };
790
-
791
- if (window.PerfectionistUtils) {
792
- registerWith(window.PerfectionistUtils);
793
- return;
794
- }
795
- addEventListener("perfectionist:utils-ready", (e) => registerWith(e.detail ?? window.PerfectionistUtils), { once: true });
796
- }
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 TELEMETRY_HOST = /(^|\.)(telemetry|analytics|metrics|sentry|bugsnag|crash-?report(er)?|pulse|tracking|beacon|datadog|loggly)(\.|:|$)/i;
15
+ 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;
16
+ const ASSET_PATH = /^\/lol-game-data\/assets\//i;
17
+ const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
18
+
19
+ const CHAMP_SELECT_DATA = /^\/lol-champ-select\/v1\/(session|all-grid-champions|pickable-champion-ids|bannable-champion-ids)$/i;
20
+ const CHAMP_SELECT_RETRY_MS = [120, 260, 520];
21
+
22
+ const STATIC_ENDPOINTS = [
23
+ ["/lol-game-data/assets/", 60 * 60 * 1000],
24
+ ["/lol-perks/v1/perks", 60 * 60 * 1000],
25
+ ["/riotclient/region-locale", 60 * 1000],
26
+ ["/lol-summoner/v1/current-summoner", 10 * 1000],
27
+ ["/performance/v1/quickstats", 5000],
28
+ ];
29
+
30
+ const UNGATE_SELECTORS = [
31
+ ".app-controls-settings",
32
+ ".lol-settings-button",
33
+ "[class*='social-panel']",
34
+ ".social-panel",
35
+ ".lol-social-panel",
36
+ ".lol-navigation",
37
+ ".lol-navigation-item",
38
+ "[class*='friends-list']",
39
+ ];
40
+ const UNGATE_SELECTOR = UNGATE_SELECTORS.join(",");
41
+ const BLOCK_MARKER = "[disabled], [aria-disabled='true'], [style*='pointer-events']";
42
+
43
+ const PERF_STYLE_ID = "perfectionist-better-client-performance";
44
+ const PERFORMANCE_CSS = `
45
+ [class*="blur"], [class*="Blur"], [class*="backdrop"], [class*="Backdrop"] {
46
+ filter: none !important;
47
+ backdrop-filter: none !important;
48
+ -webkit-backdrop-filter: none !important;
49
+ }
50
+ [class*="particle"], [class*="Particle"], [class*="parallax"], [class*="Parallax"],
51
+ [class*="animated-background"], [class*="animatedBackground"], [class*="glow"], [class*="Glow"] {
52
+ animation: none !important;
53
+ transition: none !important;
54
+ }
55
+ [class*="background"] video, [class*="Background"] video { display: none !important; }
56
+ `;
57
+
58
+ const OPTIONS = [
59
+ {
60
+ key: "performanceMode",
61
+ label: "Performance mode",
62
+ description: "Turns off background blur, decorative animation and looping background video to save GPU and CPU on weaker machines.",
63
+ default: false,
64
+ },
65
+ ];
66
+
67
+ let api = null;
68
+ let active = false;
69
+ let phase = "None";
70
+ let unsubscribes = [];
71
+ let restores = [];
72
+ let ungateTimer = null;
73
+ let ungateIdle = 0;
74
+ let phaseTimer = null;
75
+ let phaseWatchListener = null;
76
+ let performanceMode = false;
77
+ let removePerformanceStyle = null;
78
+ let cacheBytes = 0;
79
+
80
+ const cache = new Map();
81
+ const inflight = new Map();
82
+ const blockDecisions = new Map();
83
+
84
+ function baseHref() {
85
+ try {
86
+ return typeof location !== "undefined" ? location.href : "https://127.0.0.1/";
87
+ } catch {
88
+ return "https://127.0.0.1/";
89
+ }
90
+ }
91
+
92
+ function resolveUrl(raw, base) {
93
+ try {
94
+ return new URL(String(raw), base ?? baseHref());
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function isLocalHost(host) {
101
+ return LOCAL_HOSTS.has(String(host ?? "").replace(/^\[|\]$/g, ""));
102
+ }
103
+
104
+ function isTelemetryUrl(raw, base) {
105
+ const text = typeof raw === "string" ? raw : urlOf(raw);
106
+ const memo = blockDecisions.get(text);
107
+ if (memo !== undefined) return memo;
108
+
109
+ let verdict;
110
+ if (text.startsWith("/") && !text.startsWith("//")) {
111
+ verdict = !ASSET_PATH.test(text) && TELEMETRY_PATH.test(text);
112
+ } else {
113
+ const url = resolveUrl(text, base);
114
+ if (!url || url.protocol === "data:" || url.protocol === "blob:") verdict = false;
115
+ else if (ASSET_PATH.test(url.pathname)) verdict = false;
116
+ else if (isLocalHost(url.hostname)) verdict = TELEMETRY_PATH.test(url.pathname);
117
+ else verdict = TELEMETRY_HOST.test(url.hostname) || TELEMETRY_PATH.test(url.pathname);
118
+ }
119
+
120
+ if (blockDecisions.size >= DECISION_CACHE_LIMIT) blockDecisions.clear();
121
+ blockDecisions.set(text, verdict);
122
+ return verdict;
123
+ }
124
+
125
+ function shouldBlockUrl(raw, base) {
126
+ return isTelemetryUrl(raw, base);
127
+ }
128
+
129
+ function staticTtl(path) {
130
+ if (!path) return 0;
131
+ for (const [prefix, ttl] of STATIC_ENDPOINTS) {
132
+ if (path.startsWith(prefix)) return ttl;
133
+ }
134
+ return 0;
135
+ }
136
+
137
+ function methodOf(input, init) {
138
+ const method = init?.method ?? (input && typeof input === "object" && "method" in input ? input.method : "GET");
139
+ return String(method ?? "GET").toUpperCase();
140
+ }
141
+
142
+ function urlOf(input) {
143
+ if (typeof input === "string") return input;
144
+ if (input && typeof input === "object") {
145
+ if (typeof input.url === "string") return input.url;
146
+ if (typeof input.href === "string") return input.href;
147
+ }
148
+ return String(input);
149
+ }
150
+
151
+ function emptyJsonResponse(env) {
152
+ const Ctor = env?.Response ?? (typeof Response !== "undefined" ? Response : null);
153
+ if (!Ctor) return undefined;
154
+ try {
155
+ return new Ctor("{}", { status: 200, headers: { "Content-Type": "application/json" } });
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ }
160
+
161
+ function evictOne(key) {
162
+ const entry = cache.get(key);
163
+ if (!entry) return;
164
+ cacheBytes -= entry.bytes;
165
+ cache.delete(key);
166
+ }
167
+
168
+ function evict(max) {
169
+ while (cache.size >= max) evictOne(cache.keys().next().value);
170
+ }
171
+
172
+ function storeInCache(key, ttl, response) {
173
+ const type = String(response.headers?.get?.("Content-Type") ?? "");
174
+ if (type && !/json|javascript|plain/i.test(type)) return;
175
+ const length = Number(response.headers?.get?.("Content-Length") ?? 0) || 0;
176
+ if (length > MAX_CACHE_ENTRY_BYTES) return;
177
+ evict(MAX_CACHE_ENTRIES);
178
+ cache.set(key, { at: Date.now(), ttl, res: response.clone(), bytes: length });
179
+ cacheBytes += length;
180
+ while (cacheBytes > MAX_CACHE_BYTES && cache.size > 1) {
181
+ evictOne(cache.keys().next().value);
182
+ }
183
+ }
184
+
185
+ function clearVolatileCache() {
186
+ for (const [key, entry] of [...cache]) {
187
+ if (entry.ttl < 60 * 60 * 1000) evictOne(key);
188
+ }
189
+ }
190
+
191
+ function champSelectDataPath(url) {
192
+ if (!url) return null;
193
+ return CHAMP_SELECT_DATA.test(url.pathname) ? url.pathname.toLowerCase() : null;
194
+ }
195
+
196
+ function isEmptyChampSelect(path, json) {
197
+ if (path.endsWith("/session")) {
198
+ if (!json || typeof json !== "object") return true;
199
+ return !Array.isArray(json.myTeam) || json.myTeam.length === 0;
200
+ }
201
+ return Array.isArray(json) && json.length === 0;
202
+ }
203
+
204
+ function champSelectPathOf(rawUrl) {
205
+ return champSelectDataPath(resolveUrl(rawUrl));
206
+ }
207
+
208
+ async function primeChampSelect(raw, path) {
209
+ for (const wait of [0, 120, 240]) {
210
+ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
211
+ try {
212
+ const response = await raw(path);
213
+ if (!response?.ok) continue;
214
+ const json = await response.json();
215
+ if (!isEmptyChampSelect(path, json)) return;
216
+ } catch {}
217
+ }
218
+ }
219
+
220
+ function createFetch(env) {
221
+ const native = env.fetch.bind(env);
222
+
223
+ function repairChampSelect(input, init, request, path) {
224
+ let attempt = 0;
225
+ const step = (current) =>
226
+ current.then((response) => {
227
+ if (!active || phase !== "ChampSelect" || attempt >= CHAMP_SELECT_RETRY_MS.length) return response;
228
+ if (!response || !response.ok || !response.clone) return response;
229
+ return response.clone().json().then(
230
+ (json) => {
231
+ if (!isEmptyChampSelect(path, json)) return response;
232
+ const wait = CHAMP_SELECT_RETRY_MS[attempt];
233
+ attempt += 1;
234
+ return new Promise((resolve) => setTimeout(resolve, wait)).then(() => step(native(input, init)));
235
+ },
236
+ () => response,
237
+ );
238
+ });
239
+ return step(request);
240
+ }
241
+
242
+ function betterFetch(input, init) {
243
+ const raw = urlOf(input);
244
+
245
+ if (active && shouldBlockUrl(raw)) {
246
+ return Promise.resolve(emptyJsonResponse(env));
247
+ }
248
+
249
+ const method = methodOf(input, init);
250
+ if (method !== "GET" && method !== "HEAD") {
251
+ return native(input, init);
252
+ }
253
+
254
+ const url = resolveUrl(raw);
255
+ if (!url || !isLocalHost(url.hostname)) {
256
+ return native(input, init);
257
+ }
258
+
259
+ const key = `${method} ${url.pathname}${url.search}`;
260
+ const pending = inflight.get(key);
261
+ if (pending) {
262
+ return pending.then((response) => (response?.clone ? response.clone() : response));
263
+ }
264
+
265
+ const ttl = active ? staticTtl(url.pathname) : 0;
266
+ if (ttl) {
267
+ const hit = cache.get(key);
268
+ if (hit && Date.now() - hit.at < hit.ttl) {
269
+ return Promise.resolve(hit.res.clone());
270
+ }
271
+ }
272
+
273
+ let request = native(input, init);
274
+ const dataPath = champSelectDataPath(url);
275
+ if (dataPath && active && phase === "ChampSelect") {
276
+ request = repairChampSelect(input, init, request, dataPath);
277
+ }
278
+
279
+ inflight.set(key, request);
280
+ const done = () => inflight.delete(key);
281
+ request.then(done, done);
282
+
283
+ if (ttl) {
284
+ request.then((response) => {
285
+ if (!response?.ok || !active) return;
286
+ storeInCache(key, ttl, response);
287
+ }, () => {});
288
+ }
289
+ // Every caller gets its own clone; the original response stays unread so
290
+ // concurrent callers can clone it after one caller consumes the body.
291
+ return request.then((response) => (response?.clone ? response.clone() : response));
292
+ }
293
+
294
+ betterFetch.__better = true;
295
+ betterFetch.raw = native;
296
+ return betterFetch;
297
+ }
298
+
299
+ function emitBlockedXhr(xhr, env) {
300
+ const reply = "{}";
301
+ try {
302
+ Object.defineProperty(xhr, "readyState", { configurable: true, get: () => 4 });
303
+ Object.defineProperty(xhr, "status", { configurable: true, get: () => 200 });
304
+ Object.defineProperty(xhr, "responseText", { configurable: true, get: () => reply });
305
+ Object.defineProperty(xhr, "response", { configurable: true, get: () => reply });
306
+ } catch {}
307
+ const EventCtor = env.Event ?? (typeof Event !== "undefined" ? Event : null);
308
+ if (!EventCtor) return;
309
+ setTimeout(() => {
310
+ try {
311
+ xhr.dispatchEvent(new EventCtor("readystatechange"));
312
+ xhr.dispatchEvent(new EventCtor("load"));
313
+ xhr.dispatchEvent(new EventCtor("loadend"));
314
+ } catch {}
315
+ }, 0);
316
+ }
317
+
318
+ function installXhr(env) {
319
+ const proto = env.XMLHttpRequest?.prototype;
320
+ if (!proto || proto.__better) return null;
321
+ const nativeOpen = proto.open;
322
+ const nativeSend = proto.send;
323
+ proto.open = function (method, url, ...rest) {
324
+ this.__betterUrl = String(url);
325
+ return nativeOpen.call(this, method, url, ...rest);
326
+ };
327
+ proto.send = function (body) {
328
+ if (active && shouldBlockUrl(this.__betterUrl)) {
329
+ emitBlockedXhr(this, env);
330
+ return undefined;
331
+ }
332
+ const path = champSelectPathOf(this.__betterUrl);
333
+ const raw = env.fetch?.raw;
334
+ if (path && raw && active && phase === "ChampSelect" && !this.__betterPrimed) {
335
+ this.__betterPrimed = true;
336
+ primeChampSelect(raw, path).finally(() => {
337
+ try {
338
+ nativeSend.call(this, body);
339
+ } catch {}
340
+ });
341
+ return undefined;
342
+ }
343
+ return nativeSend.call(this, body);
344
+ };
345
+ proto.__better = true;
346
+ return () => {
347
+ proto.open = nativeOpen;
348
+ proto.send = nativeSend;
349
+ delete proto.__better;
350
+ };
351
+ }
352
+
353
+ function installBeacon(env) {
354
+ const nav = env.navigator;
355
+ if (!nav?.sendBeacon || nav.sendBeacon.__better) return null;
356
+ const native = nav.sendBeacon.bind(nav);
357
+ const patched = function (url) {
358
+ if (active && shouldBlockUrl(url)) return true;
359
+ return native(url, arguments[1]);
360
+ };
361
+ patched.__better = true;
362
+ nav.sendBeacon = patched;
363
+ return () => {
364
+ nav.sendBeacon = native;
365
+ };
366
+ }
367
+
368
+ let mediaObserver = null;
369
+ let mediaScanObserver = null;
370
+ let mediaVisibilityListener = null;
371
+ let mediaPaused = new WeakSet();
372
+
373
+ let imageObserver = null;
374
+ let imageQueue = [];
375
+ let imageFlushTimer = null;
376
+ let imageFlushIdle = false;
377
+ let imageVisibilityListener = null;
378
+
379
+ function markAsyncDecode(img) {
380
+ if (!img || img.tagName !== "IMG" || img.decoding === "async") return 0;
381
+ try {
382
+ img.decoding = "async";
383
+ return 1;
384
+ } catch {
385
+ return 0;
386
+ }
387
+ }
388
+
389
+ function documentView() {
390
+ try {
391
+ return document.defaultView ?? null;
392
+ } catch {
393
+ return null;
394
+ }
395
+ }
396
+
397
+ function pageHidden() {
398
+ try {
399
+ return document.hidden === true;
400
+ } catch {
401
+ return false;
402
+ }
403
+ }
404
+
405
+ function scheduleImageFlush() {
406
+ if (imageFlushTimer !== null) return;
407
+ const view = documentView();
408
+ if (typeof view?.requestIdleCallback === "function") {
409
+ imageFlushIdle = true;
410
+ imageFlushTimer = view.requestIdleCallback(flushImageQueue, { timeout: 600 });
411
+ } else {
412
+ imageFlushIdle = false;
413
+ imageFlushTimer = setTimeout(flushImageQueue, 150);
414
+ }
415
+ }
416
+
417
+ function cancelImageFlush() {
418
+ if (imageFlushTimer === null) return;
419
+ const view = documentView();
420
+ if (imageFlushIdle && typeof view?.cancelIdleCallback === "function") view.cancelIdleCallback(imageFlushTimer);
421
+ else clearTimeout(imageFlushTimer);
422
+ imageFlushTimer = null;
423
+ }
424
+
425
+ function scanImages(root) {
426
+ let found;
427
+ try {
428
+ found = root.querySelectorAll("img[src]:not([decoding='async'])");
429
+ } catch {
430
+ return 0;
431
+ }
432
+ let marked = 0;
433
+ for (const img of found) marked += markAsyncDecode(img);
434
+ return marked;
435
+ }
436
+
437
+ function flushImageQueue() {
438
+ imageFlushTimer = null;
439
+ if (pageHidden()) {
440
+ scheduleImageFlush();
441
+ return;
442
+ }
443
+ const batch = imageQueue.splice(0, IMAGE_BATCH);
444
+ for (const root of batch) {
445
+ if (!root?.isConnected) continue;
446
+ markAsyncDecode(root);
447
+ scanImages(root);
448
+ }
449
+ if (imageQueue.length > 0) scheduleImageFlush();
450
+ }
451
+
452
+ function enqueueImages(root) {
453
+ if (!root || root.nodeType !== 1) return 0;
454
+ if (imageQueue.length >= IMAGE_QUEUE_LIMIT) return 0;
455
+ if (root.tagName !== "IMG" && root.childElementCount === 0) return 0;
456
+ imageQueue.push(root);
457
+ scheduleImageFlush();
458
+ return 1;
459
+ }
460
+
461
+ function pauseDecorativeVideo(video) {
462
+ if (!video || video.tagName !== "VIDEO" || video.paused) return;
463
+ if (!video.autoplay && !video.muted) return;
464
+ try {
465
+ video.pause();
466
+ mediaPaused.add(video);
467
+ } catch {}
468
+ }
469
+
470
+ function resumeDecorativeVideo(video) {
471
+ if (!video || !mediaPaused.has(video)) return;
472
+ mediaPaused.delete(video);
473
+ try {
474
+ const play = video.play?.();
475
+ if (play?.catch) play.catch(() => {});
476
+ } catch {}
477
+ }
478
+
479
+ function installMediaIdle() {
480
+ if (typeof document === "undefined" || !document.documentElement) return () => {};
481
+ const Obs = document.defaultView?.IntersectionObserver ?? IntersectionObserver;
482
+ const Mut = document.defaultView?.MutationObserver ?? MutationObserver;
483
+ if (!Obs || !Mut) return () => {};
484
+ mediaObserver = new Obs(
485
+ (entries) => {
486
+ for (const entry of entries) {
487
+ if (entry.isIntersecting) resumeDecorativeVideo(entry.target);
488
+ else pauseDecorativeVideo(entry.target);
489
+ }
490
+ },
491
+ { rootMargin: "240px" },
492
+ );
493
+ const scan = () => {
494
+ for (const video of document.querySelectorAll("video")) {
495
+ if (video.__betterMedia) continue;
496
+ video.__betterMedia = true;
497
+ try {
498
+ mediaObserver.observe(video);
499
+ } catch {}
500
+ }
501
+ };
502
+ scan();
503
+ mediaScanObserver = new Mut(scan);
504
+ try {
505
+ mediaScanObserver.observe(document.documentElement, { childList: true, subtree: true });
506
+ } catch {}
507
+ mediaVisibilityListener = () => {
508
+ if (pageHidden()) {
509
+ for (const video of document.querySelectorAll("video")) pauseDecorativeVideo(video);
510
+ return;
511
+ }
512
+ scan();
513
+ for (const video of document.querySelectorAll("video")) {
514
+ if (video.getClientRects().length > 0) resumeDecorativeVideo(video);
515
+ }
516
+ };
517
+ document.addEventListener("visibilitychange", mediaVisibilityListener);
518
+ return () => {
519
+ document.removeEventListener("visibilitychange", mediaVisibilityListener);
520
+ mediaVisibilityListener = null;
521
+ mediaScanObserver?.disconnect();
522
+ mediaScanObserver = null;
523
+ mediaObserver?.disconnect();
524
+ mediaObserver = null;
525
+ mediaPaused = new WeakSet();
526
+ };
527
+ }
528
+
529
+ function installImageDecode() {
530
+ if (typeof document === "undefined" || !document.documentElement) return () => {};
531
+ const Obs = document.defaultView?.MutationObserver ?? MutationObserver;
532
+ if (imageObserver || !Obs) return () => {};
533
+ imageObserver = new Obs((records) => {
534
+ let queued = 0;
535
+ for (const record of records) {
536
+ if (record.type === "attributes") {
537
+ if (record.target?.tagName === "IMG") queued += enqueueImages(record.target);
538
+ continue;
539
+ }
540
+ const nodes = record.addedNodes;
541
+ for (let index = 0; index < nodes.length; index++) {
542
+ const node = nodes[index];
543
+ if (node?.nodeType === 1) queued += enqueueImages(node);
544
+ }
545
+ if (queued > 400) break;
546
+ }
547
+ });
548
+ try {
549
+ imageObserver.observe(document.documentElement, { childList: true, subtree: true });
550
+ } catch {
551
+ imageObserver = null;
552
+ return () => {};
553
+ }
554
+ enqueueImages(document.documentElement);
555
+ imageVisibilityListener = () => {
556
+ if (pageHidden()) return;
557
+ enqueueImages(document.documentElement);
558
+ scheduleImageFlush();
559
+ };
560
+ document.addEventListener("visibilitychange", imageVisibilityListener);
561
+ return () => {
562
+ document.removeEventListener("visibilitychange", imageVisibilityListener);
563
+ imageVisibilityListener = null;
564
+ cancelImageFlush();
565
+ imageQueue = [];
566
+ imageObserver?.disconnect();
567
+ imageObserver = null;
568
+ };
569
+ }
570
+
571
+ function ungateNode(node) {
572
+ if (!node?.querySelectorAll) return 0;
573
+ let changed = 0;
574
+ const candidates = [];
575
+ try {
576
+ if (node.matches?.(BLOCK_MARKER)) candidates.push(node);
577
+ candidates.push(...node.querySelectorAll(BLOCK_MARKER));
578
+ } catch {
579
+ return 0;
580
+ }
581
+ for (const element of candidates) {
582
+ if (element.disabled === true) {
583
+ element.disabled = false;
584
+ changed += 1;
585
+ }
586
+ if (element.hasAttribute?.("disabled")) {
587
+ element.removeAttribute("disabled");
588
+ changed += 1;
589
+ }
590
+ if (element.getAttribute?.("aria-disabled") === "true") {
591
+ element.removeAttribute("aria-disabled");
592
+ changed += 1;
593
+ }
594
+ if (element.style?.pointerEvents === "none") {
595
+ element.style.pointerEvents = "";
596
+ changed += 1;
597
+ }
598
+ }
599
+ return changed;
600
+ }
601
+
602
+ function ungatePass() {
603
+ if (!active || phase !== "InProgress") return 0;
604
+ let nodes;
605
+ try {
606
+ nodes = document.querySelectorAll(UNGATE_SELECTOR);
607
+ } catch {
608
+ return 0;
609
+ }
610
+ let changed = 0;
611
+ for (const node of nodes) changed += ungateNode(node);
612
+ return changed;
613
+ }
614
+
615
+ function ungateDelay() {
616
+ return ungateIdle >= UNGATE_BACKOFF_AFTER ? UNGATE_IDLE_MS : UNGATE_MS;
617
+ }
618
+
619
+ function ungateTick() {
620
+ ungateTimer = null;
621
+ if (!active || phase !== "InProgress") return;
622
+ if (pageHidden()) {
623
+ ungateTimer = setTimeout(ungateTick, UNGATE_IDLE_MS);
624
+ return;
625
+ }
626
+ const changed = ungatePass();
627
+ ungateIdle = changed > 0 ? 0 : ungateIdle + 1;
628
+ ungateTimer = setTimeout(ungateTick, ungateDelay());
629
+ }
630
+
631
+ function startUngate() {
632
+ if (ungateTimer) return;
633
+ ungateIdle = 0;
634
+ ungateTick();
635
+ }
636
+
637
+ function stopUngate() {
638
+ clearTimeout(ungateTimer);
639
+ ungateTimer = null;
640
+ ungateIdle = 0;
641
+ }
642
+
643
+ function onPhase(next) {
644
+ const value = typeof next === "string" ? next : next?.phase ?? phase;
645
+ if (!value || value === phase) {
646
+ if (value === "InProgress") startUngate();
647
+ return;
648
+ }
649
+ phase = value;
650
+ clearVolatileCache();
651
+ if (phase === "InProgress") startUngate();
652
+ else stopUngate();
653
+ }
654
+
655
+ async function refreshPhase() {
656
+ if (!active || pageHidden()) return;
657
+ try {
658
+ const current = await api?.lcu?.get?.("/lol-gameflow/v1/gameflow-phase");
659
+ if (current) onPhase(current);
660
+ } catch {}
661
+ }
662
+
663
+ function startPhaseWatch() {
664
+ stopPhaseWatch();
665
+ if (unsubscribes.length === 0) {
666
+ phaseTimer = setInterval(() => {
667
+ if (!pageHidden()) refreshPhase();
668
+ }, PHASE_POLL_FALLBACK_MS);
669
+ }
670
+ phaseWatchListener = () => {
671
+ if (!pageHidden()) refreshPhase();
672
+ };
673
+ document.addEventListener("visibilitychange", phaseWatchListener);
674
+ }
675
+
676
+ function stopPhaseWatch() {
677
+ clearInterval(phaseTimer);
678
+ phaseTimer = null;
679
+ if (phaseWatchListener) {
680
+ document.removeEventListener("visibilitychange", phaseWatchListener);
681
+ phaseWatchListener = null;
682
+ }
683
+ }
684
+
685
+ function applyPerformanceMode() {
686
+ if (!api) return;
687
+ if (performanceMode) {
688
+ if (!removePerformanceStyle) removePerformanceStyle = api.addStyle(PERF_STYLE_ID, PERFORMANCE_CSS);
689
+ return;
690
+ }
691
+ if (removePerformanceStyle) {
692
+ removePerformanceStyle();
693
+ removePerformanceStyle = null;
694
+ }
695
+ }
696
+
697
+ function installNetwork(env) {
698
+ const installed = [];
699
+ try {
700
+ const originalFetch = env.fetch;
701
+ env.fetch = createFetch(env);
702
+ installed.push(() => {
703
+ env.fetch = originalFetch;
704
+ });
705
+ } catch {}
706
+ const xhr = installXhr(env);
707
+ if (xhr) installed.push(xhr);
708
+ const beacon = installBeacon(env);
709
+ if (beacon) installed.push(beacon);
710
+ return () => {
711
+ for (const restore of installed.reverse()) {
712
+ try {
713
+ restore();
714
+ } catch {}
715
+ }
716
+ };
717
+ }
718
+
719
+ function onEnable(utils) {
720
+ api = utils;
721
+ active = true;
722
+ restores = [installNetwork(window), installImageDecode(), installMediaIdle()];
723
+ unsubscribes = [];
724
+ try {
725
+ const off = api.subscribe("/lol-gameflow/v1/gameflow-phase", onPhase);
726
+ if (off) unsubscribes.push(off);
727
+ } catch {}
728
+ try {
729
+ api
730
+ .lcu?.get?.("/lol-gameflow/v1/gameflow-phase")
731
+ .then((value) => onPhase(value))
732
+ .catch(() => {});
733
+ } catch {}
734
+ startPhaseWatch();
735
+ performanceMode = api.getOption?.(PLUGIN_ID, "performanceMode") === true;
736
+ applyPerformanceMode();
737
+ if (phase === "InProgress") startUngate();
738
+ }
739
+
740
+ function onDisable() {
741
+ active = false;
742
+ stopPhaseWatch();
743
+ stopUngate();
744
+ for (const off of unsubscribes) {
745
+ try {
746
+ off();
747
+ } catch {}
748
+ }
749
+ unsubscribes = [];
750
+ for (const restore of restores.reverse()) {
751
+ try {
752
+ restore();
753
+ } catch {}
754
+ }
755
+ restores = [];
756
+ if (removePerformanceStyle) {
757
+ removePerformanceStyle();
758
+ removePerformanceStyle = null;
759
+ }
760
+ performanceMode = false;
761
+ inflight.clear();
762
+ blockDecisions.clear();
763
+ for (const key of [...cache.keys()]) evictOne(key);
764
+ cacheBytes = 0;
765
+ api = null;
766
+ }
767
+
768
+ function onOptionChange(key, value) {
769
+ if (key === "performanceMode") {
770
+ performanceMode = value === true;
771
+ applyPerformanceMode();
772
+ }
773
+ }
774
+
775
+ export function init() {
776
+ const registerWith = (utils) => {
777
+ utils.registerPlugin({
778
+ id: PLUGIN_ID,
779
+ name: "BetterClient",
780
+ description:
781
+ "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.",
782
+ version,
783
+ defaultEnabled: true,
784
+ options: OPTIONS,
785
+ onEnable,
786
+ onDisable,
787
+ onOptionChange,
788
+ });
789
+ };
790
+
791
+ if (window.PerfectionistUtils) {
792
+ registerWith(window.PerfectionistUtils);
793
+ return;
794
+ }
795
+ addEventListener("perfectionist:utils-ready", (e) => registerWith(e.detail ?? window.PerfectionistUtils), { once: true });
796
+ }