perfectionist-plugins 1.8.0 → 2.0.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/README.md CHANGED
@@ -67,6 +67,28 @@ Built for [Pengu Loader](https://pengu.lol)
67
67
  auto-open mystery loot (icons, ward skins), and auto-open chests with keys.
68
68
  </td>
69
69
  </tr>
70
+ <tr>
71
+ <td>&nbsp;</td>
72
+ </tr>
73
+ <tr>
74
+ <th align="left">🚀 BetterClient</th>
75
+ </tr>
76
+ <tr>
77
+ <td>
78
+ Debloats the client without changing how it looks or behaves: telemetry and analytics calls are dropped,
79
+ duplicate requests are merged, and immutable payloads (game-data assets and rune definitions) are cached
80
+ instead of re-downloaded and re-parsed every time a screen renders.
81
+ <br><br>
82
+ Tiles are marked for asynchronous decode, so building bot lists, champion grids and the tournament-draft
83
+ board no longer stalls the UI, and the in-game un-gate pass backs off to one check every five seconds once it
84
+ has nothing left to unlock.
85
+ <br><br>
86
+ It also keeps the social panel, friend requests and settings usable while a game is running.
87
+ <br><br>
88
+ For memory creep, <code>tools/betterclient-monitor.mjs</code> logs per-process RAM and client phase to CSV and
89
+ diffs heap snapshots.
90
+ </td>
91
+ </tr>
70
92
  </table>
71
93
 
72
94
  ## Install
package/auto-read.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const PLUGIN_ID = "auto-read";
2
- const version = "1.1.0";
2
+ const version = "1.2.0";
3
3
 
4
4
  const SWEEP_DEBOUNCE_MS = 500;
5
5
  const ITEM_COOLDOWN_MS = 10 * 60 * 1000;
@@ -39,6 +39,13 @@ const CLASH_BULK_ACKS = [
39
39
  "/lol-clash/v1/eog-player-update/acknowledge",
40
40
  "/lol-clash/v1/game-end/acknowledge",
41
41
  ];
42
+ const IDENTITY_NOTICE_TYPES = ["ACHIEVEMENT_TITLE", "SUMMONER_ICON", "REGALIA_BANNER"];
43
+ const IDENTITY_ACK_URI = "/lol-inventory/v1/notification/acknowledge";
44
+ const TOKENS_PREFS_URI = "/lol-settings/v2/account/LCUPreferences/lol-customizer-tokens";
45
+ const LEVELUP_PREFS_URI = "/lol-settings/v2/account/LCUPreferences/lol-challenges-latest-level-up";
46
+ const TFT_BATTLE_PASS_URI = "/lol-tft-pass/v1/battle-pass";
47
+ const TFT_HOME_HUB_URI = "/lol-tft/v1/tft/homeHub";
48
+ const TFT_PREFS_URI = "/lol-settings/v2/account/LCUPreferences/lol-tft";
42
49
  const PIP_STYLE_ID = "auto-read-pips-style";
43
50
  const PIP_CSS = ".activity-center__tab_label_notification { display: none !important; }";
44
51
 
@@ -201,6 +208,65 @@ async function sweepNotices() {
201
208
  }
202
209
  }
203
210
 
211
+ async function sweepIdentity() {
212
+ for (const type of IDENTITY_NOTICE_TYPES) {
213
+ const list = await getJson(`/lol-inventory/v1/notifications/${type}`).catch(() => null);
214
+ if (!Array.isArray(list)) continue;
215
+ for (const note of list) {
216
+ const id = note?.id;
217
+ if (id == null || note.acknowledged === true) continue;
218
+ if (!ready(`invnote:${id}`, ITEM_COOLDOWN_MS)) continue;
219
+ stamp(`invnote:${id}`);
220
+ await sendJson("POST", IDENTITY_ACK_URI, { id }).catch(() => {});
221
+ }
222
+ }
223
+ if (!ready("tokens-visit", BULK_COOLDOWN_MS)) return;
224
+ const [tokens, levelUp] = await Promise.all([
225
+ getJson(TOKENS_PREFS_URI).catch(() => null),
226
+ getJson(LEVELUP_PREFS_URI).catch(() => null),
227
+ ]);
228
+ const lastVisit = Number(tokens?.data?.lastVisitTime ?? 0);
229
+ const lastLevelUp = Number(levelUp?.data?.lastLevelUpTime ?? 0);
230
+ if (!lastVisit || !(lastLevelUp > lastVisit)) return;
231
+ stamp("tokens-visit");
232
+ await sendJson("PUT", TOKENS_PREFS_URI, { schemaVersion: 1, data: { lastVisitTime: Date.now() } }).catch(() => {});
233
+ }
234
+
235
+ async function sweepTft() {
236
+ const bp = await getJson(TFT_BATTLE_PASS_URI).catch(() => null);
237
+ if (bp && Array.isArray(bp.milestones) && Array.isArray(bp.bonuses)) {
238
+ const list = [...bp.milestones, ...bp.bonuses];
239
+ const claimable = list.some((m) => m && m.status === "CLAIMABLE" && !m.isLocked);
240
+ const pending = list.some((m) => m && m.isClaimRequestPending);
241
+ const passId = bp?.info?.passId;
242
+ if (claimable && !pending && passId && ready("tft-claim", BULK_COOLDOWN_MS)) {
243
+ stamp("tft-claim");
244
+ await sendJson("PUT", `/lol-tft-pass/v1/pass/${encodeURIComponent(passId)}/milestone/claimAllRewards`).catch(() => {});
245
+ }
246
+ }
247
+ if (!ready("tft-promos", BULK_COOLDOWN_MS)) return;
248
+ const [hub, prefs] = await Promise.all([
249
+ getJson(TFT_HOME_HUB_URI).catch(() => null),
250
+ getJson(TFT_PREFS_URI).catch(() => null),
251
+ ]);
252
+ const store = hub?.storePromoOfferIds;
253
+ const battle = hub?.battlePassOfferIds;
254
+ const tact = hub?.tacticianPromoOfferIds;
255
+ if (!hub?.enabled || !Array.isArray(store) || !Array.isArray(battle) || !Array.isArray(tact)) return;
256
+ const target = { storeOfferIds: [battle[0], store[0]], tacticianOfferIds: tact };
257
+ const seen = prefs?.data?.seenOfferIds;
258
+ const same =
259
+ Array.isArray(seen?.storeOfferIds) &&
260
+ seen.storeOfferIds.length === target.storeOfferIds.length &&
261
+ seen.storeOfferIds.every((value, index) => value === target.storeOfferIds[index]) &&
262
+ Array.isArray(seen?.tacticianOfferIds) &&
263
+ seen.tacticianOfferIds.length === target.tacticianOfferIds.length &&
264
+ seen.tacticianOfferIds.every((value, index) => value === target.tacticianOfferIds[index]);
265
+ if (same) return;
266
+ stamp("tft-promos");
267
+ await sendJson("PATCH", TFT_PREFS_URI, { data: { seenOfferIds: target }, schemaVersion: 1 }).catch(() => {});
268
+ }
269
+
204
270
  function syncPips() {
205
271
  const wanted = active && on("pips");
206
272
  let style = document.getElementById(PIP_STYLE_ID);
@@ -230,7 +296,11 @@ async function runSweep() {
230
296
  try {
231
297
  if (on("quests")) await sweepQuests().catch(() => {});
232
298
  if (on("loot")) await sweepLootMarks().catch(() => {});
233
- if (on("rewards")) await sweepRewards().catch(() => {});
299
+ if (on("rewards")) {
300
+ await sweepRewards().catch(() => {});
301
+ await sweepIdentity().catch(() => {});
302
+ }
303
+ if (on("tft")) await sweepTft().catch(() => {});
234
304
  if (on("clash")) await sweepClash().catch(() => {});
235
305
  if (on("ranked")) await sweepRanked().catch(() => {});
236
306
  if (on("notices")) await sweepNotices().catch(() => {});
@@ -247,7 +317,7 @@ function onEnable(utils) {
247
317
  api = utils;
248
318
  active = true;
249
319
  cooldowns.clear();
250
- for (const uri of [MISSIONS_URI, LOOT_URI, CLASH_FLAGS_URI, REWARDS_GRANTS_URI, LOOT_NOTIF_URI, MASTERY_NOTIF_URI, ...HONOR_NOTICES.map((n) => n.uri), RANKED_NOTIF_URI, EOS_NOTIF_URI, ...NOTICE_LIST_URIS]) {
320
+ for (const uri of [MISSIONS_URI, LOOT_URI, CLASH_FLAGS_URI, REWARDS_GRANTS_URI, LOOT_NOTIF_URI, MASTERY_NOTIF_URI, ...HONOR_NOTICES.map((n) => n.uri), RANKED_NOTIF_URI, EOS_NOTIF_URI, ...NOTICE_LIST_URIS, ...IDENTITY_NOTICE_TYPES.map((type) => `/lol-inventory/v1/notifications/${type}`), TOKENS_PREFS_URI, LEVELUP_PREFS_URI, TFT_BATTLE_PASS_URI, TFT_HOME_HUB_URI, TFT_PREFS_URI]) {
251
321
  try {
252
322
  const off = api.subscribe(uri, scheduleSweep);
253
323
  if (off) unsubscribes.push(off);
@@ -286,7 +356,7 @@ export function init() {
286
356
  utils.registerPlugin({
287
357
  id: PLUGIN_ID,
288
358
  name: "Auto Read",
289
- description: "Automatically marks quests, loot, Clash and client notices as read so no badge or dot stays lit.",
359
+ description: "Automatically marks quests, loot, Clash, TFT and client notices as read so no badge or dot stays lit.",
290
360
  version,
291
361
  defaultEnabled: true,
292
362
  options: [
@@ -299,6 +369,7 @@ export function init() {
299
369
  { id: "quests", label: "Quests", description: "Quest and mission badges, including the mission button counter.", default: true },
300
370
  { id: "loot", label: "Loot marks", description: "The new mark on freshly earned loot.", default: true },
301
371
  { id: "rewards", label: "Rewards", description: "The dot on your avatar for new tokens, titles and banners, plus mastery, honor and loot notifications.", default: true },
372
+ { id: "tft", label: "TFT tab", description: "Claims claimable TFT pass rewards and marks TFT promo offers as seen so the TFT tab dot stays off.", default: true },
302
373
  { id: "clash", label: "Clash", description: "Clash tab flags and Clash notifications.", default: true },
303
374
  { id: "ranked", label: "Ranked", description: "Ranked, end-of-season and champion mastery notices.", default: true },
304
375
  { id: "notices", label: "Notices", description: "Patch, patcher, leaver buster, lobby, behavior and gameflow notices.", default: true },
@@ -0,0 +1,512 @@
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
+ }