perfectionist-plugins 2.4.8 → 2.5.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,793 +1,362 @@
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
- return request.then((response) => (response?.clone ? response.clone() : response));
290
- }
291
-
292
- betterFetch.__better = true;
293
- betterFetch.raw = native;
294
- return betterFetch;
295
- }
296
-
297
- function emitBlockedXhr(xhr, env) {
298
- const reply = "{}";
299
- try {
300
- Object.defineProperty(xhr, "readyState", { configurable: true, get: () => 4 });
301
- Object.defineProperty(xhr, "status", { configurable: true, get: () => 200 });
302
- Object.defineProperty(xhr, "responseText", { configurable: true, get: () => reply });
303
- Object.defineProperty(xhr, "response", { configurable: true, get: () => reply });
304
- } catch {}
305
- const EventCtor = env.Event ?? (typeof Event !== "undefined" ? Event : null);
306
- if (!EventCtor) return;
307
- setTimeout(() => {
308
- try {
309
- xhr.dispatchEvent(new EventCtor("readystatechange"));
310
- xhr.dispatchEvent(new EventCtor("load"));
311
- xhr.dispatchEvent(new EventCtor("loadend"));
312
- } catch {}
313
- }, 0);
314
- }
315
-
316
- function installXhr(env) {
317
- const proto = env.XMLHttpRequest?.prototype;
318
- if (!proto || proto.__better) return null;
319
- const nativeOpen = proto.open;
320
- const nativeSend = proto.send;
321
- proto.open = function (method, url, ...rest) {
322
- this.__betterUrl = String(url);
323
- return nativeOpen.call(this, method, url, ...rest);
324
- };
325
- proto.send = function (body) {
326
- if (active && shouldBlockUrl(this.__betterUrl)) {
327
- emitBlockedXhr(this, env);
328
- return undefined;
329
- }
330
- const path = champSelectPathOf(this.__betterUrl);
331
- const raw = env.fetch?.raw;
332
- if (path && raw && active && phase === "ChampSelect" && !this.__betterPrimed) {
333
- this.__betterPrimed = true;
334
- primeChampSelect(raw, path).finally(() => {
335
- try {
336
- nativeSend.call(this, body);
337
- } catch {}
338
- });
339
- return undefined;
340
- }
341
- return nativeSend.call(this, body);
342
- };
343
- proto.__better = true;
344
- return () => {
345
- proto.open = nativeOpen;
346
- proto.send = nativeSend;
347
- delete proto.__better;
348
- };
349
- }
350
-
351
- function installBeacon(env) {
352
- const nav = env.navigator;
353
- if (!nav?.sendBeacon || nav.sendBeacon.__better) return null;
354
- const native = nav.sendBeacon.bind(nav);
355
- const patched = function (url) {
356
- if (active && shouldBlockUrl(url)) return true;
357
- return native(url, arguments[1]);
358
- };
359
- patched.__better = true;
360
- nav.sendBeacon = patched;
361
- return () => {
362
- nav.sendBeacon = native;
363
- };
364
- }
365
-
366
- let mediaObserver = null;
367
- let mediaScanObserver = null;
368
- let mediaVisibilityListener = null;
369
- let mediaPaused = new WeakSet();
370
-
371
- let imageObserver = null;
372
- let imageQueue = [];
373
- let imageFlushTimer = null;
374
- let imageFlushIdle = false;
375
- let imageVisibilityListener = null;
376
-
377
- function markAsyncDecode(img) {
378
- if (!img || img.tagName !== "IMG" || img.decoding === "async") return 0;
379
- try {
380
- img.decoding = "async";
381
- return 1;
382
- } catch {
383
- return 0;
384
- }
385
- }
386
-
387
- function documentView() {
388
- try {
389
- return document.defaultView ?? null;
390
- } catch {
391
- return null;
392
- }
393
- }
394
-
395
- function pageHidden() {
396
- try {
397
- return document.hidden === true;
398
- } catch {
399
- return false;
400
- }
401
- }
402
-
403
- function scheduleImageFlush() {
404
- if (imageFlushTimer !== null) return;
405
- const view = documentView();
406
- if (typeof view?.requestIdleCallback === "function") {
407
- imageFlushIdle = true;
408
- imageFlushTimer = view.requestIdleCallback(flushImageQueue, { timeout: 600 });
409
- } else {
410
- imageFlushIdle = false;
411
- imageFlushTimer = setTimeout(flushImageQueue, 150);
412
- }
413
- }
414
-
415
- function cancelImageFlush() {
416
- if (imageFlushTimer === null) return;
417
- const view = documentView();
418
- if (imageFlushIdle && typeof view?.cancelIdleCallback === "function") view.cancelIdleCallback(imageFlushTimer);
419
- else clearTimeout(imageFlushTimer);
420
- imageFlushTimer = null;
421
- }
422
-
423
- function scanImages(root) {
424
- let found;
425
- try {
426
- found = root.querySelectorAll("img[src]:not([decoding='async'])");
427
- } catch {
428
- return 0;
429
- }
430
- let marked = 0;
431
- for (const img of found) marked += markAsyncDecode(img);
432
- return marked;
433
- }
434
-
435
- function flushImageQueue() {
436
- imageFlushTimer = null;
437
- if (pageHidden()) {
438
- scheduleImageFlush();
439
- return;
440
- }
441
- const batch = imageQueue.splice(0, IMAGE_BATCH);
442
- for (const root of batch) {
443
- if (!root?.isConnected) continue;
444
- markAsyncDecode(root);
445
- scanImages(root);
446
- }
447
- if (imageQueue.length > 0) scheduleImageFlush();
448
- }
449
-
450
- function enqueueImages(root) {
451
- if (!root || root.nodeType !== 1) return 0;
452
- if (imageQueue.length >= IMAGE_QUEUE_LIMIT) return 0;
453
- if (root.tagName !== "IMG" && root.childElementCount === 0) return 0;
454
- imageQueue.push(root);
455
- scheduleImageFlush();
456
- return 1;
457
- }
458
-
459
- function pauseDecorativeVideo(video) {
460
- if (!video || video.tagName !== "VIDEO" || video.paused) return;
461
- if (!video.autoplay && !video.muted) return;
462
- try {
463
- video.pause();
464
- mediaPaused.add(video);
465
- } catch {}
466
- }
467
-
468
- function resumeDecorativeVideo(video) {
469
- if (!video || !mediaPaused.has(video)) return;
470
- mediaPaused.delete(video);
471
- try {
472
- const play = video.play?.();
473
- if (play?.catch) play.catch(() => {});
474
- } catch {}
475
- }
476
-
477
- function installMediaIdle() {
478
- if (typeof document === "undefined" || !document.documentElement) return () => {};
479
- const Obs = document.defaultView?.IntersectionObserver ?? IntersectionObserver;
480
- const Mut = document.defaultView?.MutationObserver ?? MutationObserver;
481
- if (!Obs || !Mut) return () => {};
482
- mediaObserver = new Obs(
483
- (entries) => {
484
- for (const entry of entries) {
485
- if (entry.isIntersecting) resumeDecorativeVideo(entry.target);
486
- else pauseDecorativeVideo(entry.target);
487
- }
488
- },
489
- { rootMargin: "240px" },
490
- );
491
- const scan = () => {
492
- for (const video of document.querySelectorAll("video")) {
493
- if (video.__betterMedia) continue;
494
- video.__betterMedia = true;
495
- try {
496
- mediaObserver.observe(video);
497
- } catch {}
498
- }
499
- };
500
- scan();
501
- mediaScanObserver = new Mut(scan);
502
- try {
503
- mediaScanObserver.observe(document.documentElement, { childList: true, subtree: true });
504
- } catch {}
505
- mediaVisibilityListener = () => {
506
- if (pageHidden()) {
507
- for (const video of document.querySelectorAll("video")) pauseDecorativeVideo(video);
508
- return;
509
- }
510
- scan();
511
- for (const video of document.querySelectorAll("video")) {
512
- if (video.getClientRects().length > 0) resumeDecorativeVideo(video);
513
- }
514
- };
515
- document.addEventListener("visibilitychange", mediaVisibilityListener);
516
- return () => {
517
- document.removeEventListener("visibilitychange", mediaVisibilityListener);
518
- mediaVisibilityListener = null;
519
- mediaScanObserver?.disconnect();
520
- mediaScanObserver = null;
521
- mediaObserver?.disconnect();
522
- mediaObserver = null;
523
- mediaPaused = new WeakSet();
524
- };
525
- }
526
-
527
- function installImageDecode() {
528
- if (typeof document === "undefined" || !document.documentElement) return () => {};
529
- const Obs = document.defaultView?.MutationObserver ?? MutationObserver;
530
- if (imageObserver || !Obs) return () => {};
531
- imageObserver = new Obs((records) => {
532
- let queued = 0;
533
- for (const record of records) {
534
- if (record.type === "attributes") {
535
- if (record.target?.tagName === "IMG") queued += enqueueImages(record.target);
536
- continue;
537
- }
538
- const nodes = record.addedNodes;
539
- for (let index = 0; index < nodes.length; index++) {
540
- const node = nodes[index];
541
- if (node?.nodeType === 1) queued += enqueueImages(node);
542
- }
543
- if (queued > 400) break;
544
- }
545
- });
546
- try {
547
- imageObserver.observe(document.documentElement, { childList: true, subtree: true });
548
- } catch {
549
- imageObserver = null;
550
- return () => {};
551
- }
552
- enqueueImages(document.documentElement);
553
- imageVisibilityListener = () => {
554
- if (pageHidden()) return;
555
- enqueueImages(document.documentElement);
556
- scheduleImageFlush();
557
- };
558
- document.addEventListener("visibilitychange", imageVisibilityListener);
559
- return () => {
560
- document.removeEventListener("visibilitychange", imageVisibilityListener);
561
- imageVisibilityListener = null;
562
- cancelImageFlush();
563
- imageQueue = [];
564
- imageObserver?.disconnect();
565
- imageObserver = null;
566
- };
567
- }
568
-
569
- function ungateNode(node) {
570
- if (!node?.querySelectorAll) return 0;
571
- let changed = 0;
572
- const candidates = [];
573
- try {
574
- if (node.matches?.(BLOCK_MARKER)) candidates.push(node);
575
- candidates.push(...node.querySelectorAll(BLOCK_MARKER));
576
- } catch {
577
- return 0;
578
- }
579
- for (const element of candidates) {
580
- if (element.disabled === true) {
581
- element.disabled = false;
582
- changed += 1;
583
- }
584
- if (element.hasAttribute?.("disabled")) {
585
- element.removeAttribute("disabled");
586
- changed += 1;
587
- }
588
- if (element.getAttribute?.("aria-disabled") === "true") {
589
- element.removeAttribute("aria-disabled");
590
- changed += 1;
591
- }
592
- if (element.style?.pointerEvents === "none") {
593
- element.style.pointerEvents = "";
594
- changed += 1;
595
- }
596
- }
597
- return changed;
598
- }
599
-
600
- function ungatePass() {
601
- if (!active || phase !== "InProgress") return 0;
602
- let nodes;
603
- try {
604
- nodes = document.querySelectorAll(UNGATE_SELECTOR);
605
- } catch {
606
- return 0;
607
- }
608
- let changed = 0;
609
- for (const node of nodes) changed += ungateNode(node);
610
- return changed;
611
- }
612
-
613
- function ungateDelay() {
614
- return ungateIdle >= UNGATE_BACKOFF_AFTER ? UNGATE_IDLE_MS : UNGATE_MS;
615
- }
616
-
617
- function ungateTick() {
618
- ungateTimer = null;
619
- if (!active || phase !== "InProgress") return;
620
- if (pageHidden()) {
621
- ungateTimer = setTimeout(ungateTick, UNGATE_IDLE_MS);
622
- return;
623
- }
624
- const changed = ungatePass();
625
- ungateIdle = changed > 0 ? 0 : ungateIdle + 1;
626
- ungateTimer = setTimeout(ungateTick, ungateDelay());
627
- }
628
-
629
- function startUngate() {
630
- if (ungateTimer) return;
631
- ungateIdle = 0;
632
- ungateTick();
633
- }
634
-
635
- function stopUngate() {
636
- clearTimeout(ungateTimer);
637
- ungateTimer = null;
638
- ungateIdle = 0;
639
- }
640
-
641
- function onPhase(next) {
642
- const value = typeof next === "string" ? next : next?.phase ?? phase;
643
- if (!value || value === phase) {
644
- if (value === "InProgress") startUngate();
645
- return;
646
- }
647
- phase = value;
648
- clearVolatileCache();
649
- if (phase === "InProgress") startUngate();
650
- else stopUngate();
651
- }
652
-
653
- async function refreshPhase() {
654
- if (!active || pageHidden()) return;
655
- try {
656
- const current = await api?.lcu?.get?.("/lol-gameflow/v1/gameflow-phase");
657
- if (current) onPhase(current);
658
- } catch {}
659
- }
660
-
661
- function startPhaseWatch() {
662
- stopPhaseWatch();
663
- if (unsubscribes.length === 0) {
664
- phaseTimer = setInterval(() => {
665
- if (!pageHidden()) refreshPhase();
666
- }, PHASE_POLL_FALLBACK_MS);
667
- }
668
- phaseWatchListener = () => {
669
- if (!pageHidden()) refreshPhase();
670
- };
671
- document.addEventListener("visibilitychange", phaseWatchListener);
672
- }
673
-
674
- function stopPhaseWatch() {
675
- clearInterval(phaseTimer);
676
- phaseTimer = null;
677
- if (phaseWatchListener) {
678
- document.removeEventListener("visibilitychange", phaseWatchListener);
679
- phaseWatchListener = null;
680
- }
681
- }
682
-
683
- function applyPerformanceMode() {
684
- if (!api) return;
685
- if (performanceMode) {
686
- if (!removePerformanceStyle) removePerformanceStyle = api.addStyle(PERF_STYLE_ID, PERFORMANCE_CSS);
687
- return;
688
- }
689
- if (removePerformanceStyle) {
690
- removePerformanceStyle();
691
- removePerformanceStyle = null;
692
- }
693
- }
694
-
695
- function installNetwork(env) {
696
- const installed = [];
697
- try {
698
- const originalFetch = env.fetch;
699
- env.fetch = createFetch(env);
700
- installed.push(() => {
701
- env.fetch = originalFetch;
702
- });
703
- } catch {}
704
- const xhr = installXhr(env);
705
- if (xhr) installed.push(xhr);
706
- const beacon = installBeacon(env);
707
- if (beacon) installed.push(beacon);
708
- return () => {
709
- for (const restore of installed.reverse()) {
710
- try {
711
- restore();
712
- } catch {}
713
- }
714
- };
715
- }
716
-
717
- function onEnable(utils) {
718
- api = utils;
719
- active = true;
720
- restores = [installNetwork(window), installImageDecode(), installMediaIdle()];
721
- unsubscribes = [];
722
- try {
723
- const off = api.subscribe("/lol-gameflow/v1/gameflow-phase", onPhase);
724
- if (off) unsubscribes.push(off);
725
- } catch {}
726
- try {
727
- api
728
- .lcu?.get?.("/lol-gameflow/v1/gameflow-phase")
729
- .then((value) => onPhase(value))
730
- .catch(() => {});
731
- } catch {}
732
- startPhaseWatch();
733
- performanceMode = api.getOption?.(PLUGIN_ID, "performanceMode") === true;
734
- applyPerformanceMode();
735
- if (phase === "InProgress") startUngate();
736
- }
737
-
738
- function onDisable() {
739
- active = false;
740
- stopPhaseWatch();
741
- stopUngate();
742
- for (const off of unsubscribes) {
743
- try {
744
- off();
745
- } catch {}
746
- }
747
- unsubscribes = [];
748
- for (const restore of restores.reverse()) {
749
- try {
750
- restore();
751
- } catch {}
752
- }
753
- restores = [];
754
- if (removePerformanceStyle) {
755
- removePerformanceStyle();
756
- removePerformanceStyle = null;
757
- }
758
- performanceMode = false;
759
- inflight.clear();
760
- blockDecisions.clear();
761
- for (const key of [...cache.keys()]) evictOne(key);
762
- cacheBytes = 0;
763
- api = null;
764
- }
765
-
766
- function onOptionChange(key, value) {
767
- if (key === "performanceMode") {
768
- performanceMode = value === true;
769
- applyPerformanceMode();
770
- }
771
- }
772
-
773
- export function init() {
774
- const registerWith = (utils) => {
775
- utils.registerPlugin({
776
- id: PLUGIN_ID,
777
- name: "BetterClient",
778
- description:
779
- "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.",
780
- version,
781
- options: OPTIONS,
782
- onEnable,
783
- onDisable,
784
- onOptionChange,
785
- });
786
- };
787
-
788
- if (window.PerfectionistUtils) {
789
- registerWith(window.PerfectionistUtils);
790
- return;
791
- }
792
- addEventListener("perfectionist:utils-ready", (e) => registerWith(e.detail ?? window.PerfectionistUtils), { once: true });
793
- }
1
+ const PLUGIN_ID = "better-client";
2
+ const version = "2.0.0";
3
+
4
+ const PHASE_URI = "/lol-gameflow/v1/gameflow-phase";
5
+
6
+ // The client reports UI events, metrics and traces through these local endpoints and through Datadog RUM.
7
+ // None of it is needed for the client to work; blocked calls get an empty 200 so callers carry on.
8
+ const TELEMETRY_LOCAL = /^\/(?:telemetry\/|lol-telemetry\/|tracing\/v1\/|riotclient\/addorupdatemetric|lol-rms\/v1\/telemetry|error-monitor\/v1\/ux-logs)/i;
9
+ const TELEMETRY_HOST = /(^|\.)(?:datadoghq\.(?:com|eu)|datadog-?intake\.\w+|sentry\.io|bugsnag\.com|browser-intake[\w.-]*|google-analytics\.com|googletagmanager\.com|doubleclick\.net|segment\.io|mixpanel\.com|amplitude\.com)$/i;
10
+ const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
11
+ const DECISION_LIMIT = 512;
12
+ const SWEEP_MS = 60 * 1000;
13
+
14
+ const BACKGROUND_CLASS = "perf-bc-background";
15
+ const LITE_CLASS = "perf-bc-lite";
16
+ const STYLE_ID = "perfectionist-better-client";
17
+ const CSS = `
18
+ html.${BACKGROUND_CLASS} *, html.${BACKGROUND_CLASS} *::before, html.${BACKGROUND_CLASS} *::after {
19
+ animation-play-state: paused !important;
20
+ }
21
+ html.${LITE_CLASS} *, html.${LITE_CLASS} *::before, html.${LITE_CLASS} *::after {
22
+ animation-duration: 1ms !important; animation-delay: 0s !important; animation-iteration-count: 1 !important;
23
+ transition-duration: 0s !important; transition-delay: 0s !important;
24
+ backdrop-filter: none !important; -webkit-backdrop-filter: none !important;
25
+ }`;
26
+
27
+ const OPTIONS = [
28
+ {
29
+ key: "backgroundMode",
30
+ label: "Pause the client while you play",
31
+ description:
32
+ "While a game is running and the client is not focused, pauses the client's videos and animations. They resume the moment you open the client.",
33
+ default: true,
34
+ },
35
+ {
36
+ key: "performanceMode",
37
+ label: "Performance mode",
38
+ description:
39
+ "For weaker PCs: stops looping background videos and animated effects, skips UI animations and turns off background blur.",
40
+ default: false,
41
+ },
42
+ ];
43
+
44
+ let api = null;
45
+ let active = false;
46
+ let phase = "None";
47
+ let backgroundMode = false;
48
+ let liteMode = false;
49
+ let unsubscribe = null;
50
+ let restores = [];
51
+
52
+ function option(key) {
53
+ return api?.getOption?.(PLUGIN_ID, key) === true;
54
+ }
55
+
56
+ /* Telemetry */
57
+
58
+ const decisions = new Map();
59
+
60
+ function isBlocked(raw) {
61
+ const text = typeof raw === "string" ? raw : raw?.url ?? raw?.href ?? String(raw ?? "");
62
+ const known = decisions.get(text);
63
+ if (known !== undefined) return known;
64
+ let verdict = false;
65
+ if (text.startsWith("/") && !text.startsWith("//")) {
66
+ verdict = TELEMETRY_LOCAL.test(text);
67
+ } else {
68
+ try {
69
+ const url = new URL(text, location.href);
70
+ if (url.protocol === "http:" || url.protocol === "https:") {
71
+ verdict = LOCAL_HOSTS.has(url.hostname) ? TELEMETRY_LOCAL.test(url.pathname) : TELEMETRY_HOST.test(url.hostname);
72
+ }
73
+ } catch {}
74
+ }
75
+ if (decisions.size >= DECISION_LIMIT) decisions.clear();
76
+ decisions.set(text, verdict);
77
+ return verdict;
78
+ }
79
+
80
+ function installFetch() {
81
+ const native = window.fetch;
82
+ const patched = function (input, init) {
83
+ if (active && isBlocked(typeof input === "string" ? input : input?.url ?? "")) {
84
+ return Promise.resolve(new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }));
85
+ }
86
+ return native.call(this, input, init);
87
+ };
88
+ window.fetch = patched;
89
+ return () => {
90
+ if (window.fetch === patched) window.fetch = native;
91
+ };
92
+ }
93
+
94
+ function finishBlockedXhr(xhr) {
95
+ try {
96
+ Object.defineProperty(xhr, "readyState", { configurable: true, get: () => 4 });
97
+ Object.defineProperty(xhr, "status", { configurable: true, get: () => 200 });
98
+ Object.defineProperty(xhr, "statusText", { configurable: true, get: () => "OK" });
99
+ Object.defineProperty(xhr, "responseText", { configurable: true, get: () => "{}" });
100
+ Object.defineProperty(xhr, "response", { configurable: true, get: () => (xhr.responseType === "json" ? {} : "{}") });
101
+ Object.defineProperty(xhr, "getAllResponseHeaders", { configurable: true, value: () => "content-type: application/json\r\n" });
102
+ Object.defineProperty(xhr, "getResponseHeader", { configurable: true, value: (name) => (/content-type/i.test(name) ? "application/json" : null) });
103
+ } catch {}
104
+ setTimeout(() => {
105
+ for (const type of ["readystatechange", "load", "loadend"]) {
106
+ try {
107
+ xhr.dispatchEvent(new Event(type));
108
+ } catch {}
109
+ }
110
+ }, 0);
111
+ }
112
+
113
+ function installXhr() {
114
+ const proto = XMLHttpRequest.prototype;
115
+ const nativeOpen = proto.open;
116
+ const nativeSend = proto.send;
117
+ const open = function (method, url, ...rest) {
118
+ this.__perfBcUrl = url;
119
+ return nativeOpen.call(this, method, url, ...rest);
120
+ };
121
+ const send = function (body) {
122
+ if (active && this.__perfBcUrl !== undefined && isBlocked(String(this.__perfBcUrl))) {
123
+ finishBlockedXhr(this);
124
+ return undefined;
125
+ }
126
+ return nativeSend.call(this, body);
127
+ };
128
+ proto.open = open;
129
+ proto.send = send;
130
+ return () => {
131
+ if (proto.open === open) proto.open = nativeOpen;
132
+ if (proto.send === send) proto.send = nativeSend;
133
+ };
134
+ }
135
+
136
+ function installBeacon() {
137
+ const nav = window.navigator;
138
+ if (typeof nav?.sendBeacon !== "function") return () => {};
139
+ const native = nav.sendBeacon;
140
+ const patched = function (url, data) {
141
+ if (active && isBlocked(String(url))) return true;
142
+ return native.call(this, url, data);
143
+ };
144
+ nav.sendBeacon = patched;
145
+ return () => {
146
+ if (nav.sendBeacon === patched) nav.sendBeacon = native;
147
+ };
148
+ }
149
+
150
+ // Riot samples some sessions into Datadog RUM, which then records every interaction, resource and long task.
151
+ function quietDatadog() {
152
+ try {
153
+ window.DD_RUM?.setTrackingConsent?.("not-granted");
154
+ } catch {}
155
+ return () => {
156
+ try {
157
+ window.DD_RUM?.setTrackingConsent?.("granted");
158
+ } catch {}
159
+ };
160
+ }
161
+
162
+ /* Media governor
163
+ *
164
+ * The client keeps looping videos and lottie animations running while they are not rendered at all (the
165
+ * home screen's activity tabs alone keep ~20 videos decoding forever). Media is held while nobody can see it
166
+ * (not rendered, or the client is in the background during a game, or it is a decorative loop in performance
167
+ * mode) and resumed as soon as that stops being true. Only media held here is ever resumed. */
168
+
169
+ const held = new WeakSet();
170
+ const tracked = new Set();
171
+ let observer = null;
172
+
173
+ function inBackground() {
174
+ return backgroundMode && phase === "InProgress" && !document.hasFocus();
175
+ }
176
+
177
+ function lottieOf(element) {
178
+ return element.tagName === "LOL-UIKIT-LOTTIE" ? element.animation ?? null : null;
179
+ }
180
+
181
+ function isLoop(element) {
182
+ return element.tagName === "VIDEO" ? element.loop : lottieOf(element)?.loop === true;
183
+ }
184
+
185
+ function isPlaying(element) {
186
+ if (element.tagName === "VIDEO") return !element.paused;
187
+ const animation = lottieOf(element);
188
+ return Boolean(animation) && animation.isPaused === false;
189
+ }
190
+
191
+ function rendered(element) {
192
+ return element.isConnected && element.getClientRects().length > 0;
193
+ }
194
+
195
+ function shouldHold(element, visible = rendered(element)) {
196
+ return !visible || inBackground() || (liteMode && isLoop(element));
197
+ }
198
+
199
+ function hold(element) {
200
+ if (!isPlaying(element)) return;
201
+ try {
202
+ if (element.tagName === "VIDEO") element.pause();
203
+ else lottieOf(element)?.pause();
204
+ held.add(element);
205
+ } catch {}
206
+ }
207
+
208
+ function release(element) {
209
+ if (!held.has(element)) return;
210
+ held.delete(element);
211
+ if (!element.isConnected) return;
212
+ try {
213
+ if (element.tagName === "VIDEO") {
214
+ if (element.loop || !element.ended) element.play()?.catch?.(() => {});
215
+ } else {
216
+ lottieOf(element)?.play();
217
+ }
218
+ } catch {}
219
+ }
220
+
221
+ function settle(element, visible) {
222
+ if (shouldHold(element, visible)) hold(element);
223
+ else release(element);
224
+ }
225
+
226
+ function onMediaPlay(event) {
227
+ const element = event.target;
228
+ if (shouldHold(element)) queueMicrotask(() => shouldHold(element) && hold(element));
229
+ }
230
+
231
+ function track(element) {
232
+ if (tracked.has(element)) return;
233
+ tracked.add(element);
234
+ if (element.tagName === "VIDEO") element.addEventListener("play", onMediaPlay);
235
+ observer?.observe(element);
236
+ }
237
+
238
+ function untrack(element) {
239
+ observer?.unobserve(element);
240
+ element.removeEventListener("play", onMediaPlay);
241
+ tracked.delete(element);
242
+ }
243
+
244
+ function settleAll() {
245
+ for (const element of tracked) {
246
+ if (element.isConnected) settle(element);
247
+ }
248
+ }
249
+
250
+ function installMediaGovernor() {
251
+ observer = new IntersectionObserver(
252
+ (entries) => {
253
+ for (const entry of entries) settle(entry.target, entry.isIntersecting);
254
+ },
255
+ { rootMargin: "64px" },
256
+ );
257
+ const unwatch = api.watch("video, lol-uikit-lottie", track);
258
+ // Lottie animations start after their JSON loads, which the observer does not see; re-check periodically.
259
+ const sweeper = setInterval(() => {
260
+ for (const element of tracked) {
261
+ if (!element.isConnected) untrack(element);
262
+ else if (element.tagName !== "VIDEO" && isPlaying(element) && shouldHold(element)) hold(element);
263
+ }
264
+ }, SWEEP_MS / 4);
265
+ return () => {
266
+ clearInterval(sweeper);
267
+ unwatch();
268
+ observer?.disconnect();
269
+ observer = null;
270
+ for (const element of [...tracked]) {
271
+ untrack(element);
272
+ if (rendered(element)) release(element);
273
+ }
274
+ };
275
+ }
276
+
277
+ /* Background and performance modes */
278
+
279
+ function applyModes() {
280
+ const root = document.documentElement;
281
+ root.classList.toggle(BACKGROUND_CLASS, active && inBackground());
282
+ root.classList.toggle(LITE_CLASS, active && liteMode);
283
+ settleAll();
284
+ }
285
+
286
+ function installModes() {
287
+ const removeStyle = api.addStyle(STYLE_ID, CSS);
288
+ window.addEventListener("focus", applyModes);
289
+ window.addEventListener("blur", applyModes);
290
+ document.addEventListener("visibilitychange", applyModes);
291
+ return () => {
292
+ window.removeEventListener("focus", applyModes);
293
+ window.removeEventListener("blur", applyModes);
294
+ document.removeEventListener("visibilitychange", applyModes);
295
+ document.documentElement.classList.remove(BACKGROUND_CLASS, LITE_CLASS);
296
+ removeStyle();
297
+ };
298
+ }
299
+
300
+ function onPhase(raw) {
301
+ const next = typeof raw === "string" ? raw : raw?.data;
302
+ if (typeof next !== "string" || !next || next === phase) return;
303
+ phase = next;
304
+ applyModes();
305
+ }
306
+
307
+ function onEnable(utils) {
308
+ api = utils;
309
+ active = true;
310
+ backgroundMode = option("backgroundMode");
311
+ liteMode = option("performanceMode");
312
+ restores = [installFetch(), installXhr(), installBeacon(), quietDatadog(), installModes(), installMediaGovernor()];
313
+ unsubscribe = utils.subscribe(PHASE_URI, onPhase);
314
+ utils.lcu
315
+ .get(PHASE_URI)
316
+ .then(onPhase)
317
+ .catch(() => {});
318
+ applyModes();
319
+ }
320
+
321
+ function onDisable() {
322
+ active = false;
323
+ unsubscribe?.();
324
+ unsubscribe = null;
325
+ for (const restore of restores.reverse()) {
326
+ try {
327
+ restore();
328
+ } catch {}
329
+ }
330
+ restores = [];
331
+ decisions.clear();
332
+ phase = "None";
333
+ api = null;
334
+ }
335
+
336
+ function onOptionChange(key, value) {
337
+ if (key === "backgroundMode") backgroundMode = value === true;
338
+ if (key === "performanceMode") liteMode = value === true;
339
+ applyModes();
340
+ }
341
+
342
+ export function init() {
343
+ const registerWith = (utils) => {
344
+ utils.registerPlugin({
345
+ id: PLUGIN_ID,
346
+ name: "BetterClient",
347
+ description:
348
+ "Makes the client lighter without changing how it looks: pauses videos and animations nobody can see, blocks telemetry, and pauses the client while you are in a game.",
349
+ version,
350
+ options: OPTIONS,
351
+ onEnable,
352
+ onDisable,
353
+ onOptionChange,
354
+ });
355
+ };
356
+
357
+ if (window.PerfectionistUtils) {
358
+ registerWith(window.PerfectionistUtils);
359
+ return;
360
+ }
361
+ addEventListener("perfectionist:utils-ready", (e) => registerWith(e.detail ?? window.PerfectionistUtils), { once: true });
362
+ }