perfectionist-plugins 1.9.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
@@ -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
+ }
@@ -5,6 +5,7 @@ const STYLE_ID = "perfectionist-hide-unowned-skins-style";
5
5
  const HIDDEN_CLASS = "perf-unowned-skin-hidden";
6
6
  const SKIN_ITEM_SELECTOR = ".skin-selection-item";
7
7
  const DOT_SELECTOR = ".skin-selection-indicator-selector";
8
+ const SELECTED_CLASS = "skin-selection-item-selected";
8
9
  const SESSION_URI = "/lol-champ-select/v1/session";
9
10
  const SELECTION_URI = "/lol-champ-select/v1/session/my-selection";
10
11
  const CAROUSEL_URI = "/lol-champ-select/v1/skin-carousel-skins";
@@ -270,6 +271,7 @@ let allSkinIds = null;
270
271
  let currentChampionId = null;
271
272
  let carouselOrder = [];
272
273
  let selectedSkinId = null;
274
+ let lastServerSkinId = null;
273
275
  let failure = null;
274
276
  let requestToken = 0;
275
277
  let refreshTimer = null;
@@ -284,6 +286,10 @@ const lastAppliedLeft = new Map();
284
286
  const skinSuffixCache = new Map();
285
287
  let repackTimer = null;
286
288
  let removeStyle = null;
289
+ let lastSelectedDomIndex = -1;
290
+ let navFixAt = 0;
291
+ let lastSkipSignature = null;
292
+ const widthCache = new Map();
287
293
 
288
294
  function logError(scope, error) {
289
295
  console.error(`[Perfectionist] Hide Unowned Skins: ${scope}`, error);
@@ -327,6 +333,39 @@ function pruneRepack() {
327
333
  }
328
334
  }
329
335
 
336
+ function guardSelection(items) {
337
+ const selectedIndex = items.findIndex((item) => item.classList.contains(SELECTED_CLASS));
338
+ if (selectedIndex < 0) return false;
339
+ const previous = lastSelectedDomIndex;
340
+ lastSelectedDomIndex = selectedIndex;
341
+ const selected = items[selectedIndex];
342
+ const hidden = selected.classList.contains(HIDDEN_CLASS) || Boolean(selected.parentElement?.classList.contains(HIDDEN_CLASS));
343
+ if (!hidden) {
344
+ lastSkipSignature = null;
345
+ return false;
346
+ }
347
+
348
+ const visible = items.map((item, index) => (item.classList.contains(HIDDEN_CLASS) ? -1 : index)).filter((index) => index >= 0);
349
+ if (visible.length === 0) return false;
350
+
351
+ const forward = previous < 0 ? true : selectedIndex >= previous;
352
+ const target = forward
353
+ ? (visible.find((index) => index > selectedIndex) ?? visible[0])
354
+ : ([...visible].reverse().find((index) => index < selectedIndex) ?? visible[visible.length - 1]);
355
+ const skinId = readSkinId(items[target]);
356
+ if (skinId === null) return false;
357
+
358
+ const signature = `${selectedIndex}:${skinId}`;
359
+ if (lastSkipSignature === signature) return true;
360
+ const now = Date.now();
361
+ if (now < navFixAt) return true;
362
+ navFixAt = now + 150;
363
+ lastSkipSignature = signature;
364
+ lastSelectedDomIndex = target;
365
+ selectSkin(skinId).catch((error) => logError("could not skip the unowned skin.", error));
366
+ return true;
367
+ }
368
+
330
369
  function repackVisibleItems(items) {
331
370
  pruneRepack();
332
371
  const visible = items.filter((item) => !item.classList.contains(HIDDEN_CLASS));
@@ -343,7 +382,14 @@ function repackVisibleItems(items) {
343
382
  if (!anchor) anchor = visible[Math.floor(visible.length / 2)];
344
383
  const anchorDomIndex = items.indexOf(anchor);
345
384
 
346
- const widthOf = (item) => item.getBoundingClientRect().width || (item === anchor ? 117 : 73);
385
+ const widthOf = (item) => {
386
+ const measured = item.getBoundingClientRect().width;
387
+ if (measured > 0) {
388
+ widthCache.set(item, measured);
389
+ return measured;
390
+ }
391
+ return widthCache.get(item) ?? (item === anchor ? 117 : 73);
392
+ };
347
393
  const toPx = (item) => {
348
394
  const raw = item.style.left || "0";
349
395
  if (raw.endsWith("px")) return parseFloat(raw);
@@ -424,8 +470,10 @@ function applyAll() {
424
470
  else if (dotsHidden === 0 && dotList.hasAttribute("data-perf-dots")) dotList.removeAttribute("data-perf-dots");
425
471
  }
426
472
 
427
- if (ownedSkinIds !== null && hidden > 0) scheduleRepack();
428
- else if (hidden === 0) restoreRepack();
473
+ if (ownedSkinIds !== null && hidden > 0) {
474
+ if (guardSelection(items)) runRepack();
475
+ else scheduleRepack();
476
+ } else if (hidden === 0) restoreRepack();
429
477
  }
430
478
 
431
479
  function scheduleRepack() {
@@ -585,11 +633,13 @@ function updateForSession(session, selection) {
585
633
 
586
634
  const knownUnownedSkin = allSkinIds !== null && allSkinIds.has(serverSkinId) && !ownedSkinIds.has(serverSkinId);
587
635
  if (!selectInFlight && knownUnownedSkin) {
588
- const fallback = nextOwnedSkin(ownedSkinIds, 0, 1);
636
+ const direction = lastServerSkinId !== null && serverSkinId !== null && serverSkinId < lastServerSkinId ? -1 : 1;
637
+ const fallback = nextOwnedSkin(ownedSkinIds, serverSkinId ?? 0, direction) ?? nextOwnedSkin(ownedSkinIds, 0, 1);
589
638
  if (fallback !== null) {
590
639
  selectSkin(fallback).catch((error) => logError("could not correct the selected skin.", error));
591
640
  }
592
641
  }
642
+ if (!selectInFlight && belongsToChampion && ownedSkinIds.has(serverSkinId)) lastServerSkinId = serverSkinId;
593
643
 
594
644
  applyAllGuarded();
595
645
  }
package/package.json CHANGED
@@ -1,34 +1,37 @@
1
- {
2
- "name": "perfectionist-plugins",
3
- "version": "1.9.0",
4
- "description": "Small plugins that fix the annoying parts of League of Legends champ select. Built for Pengu Loader.",
5
- "type": "module",
6
- "main": "perfectionist-utils.js",
7
- "exports": {
8
- ".": "./perfectionist-utils.js",
9
- "./perfectionist-utils.js": "./perfectionist-utils.js",
10
- "./ban-blocker.js": "./ban-blocker.js",
11
- "./hide-unowned-skins.js": "./hide-unowned-skins.js",
12
- "./unlock-profile-skins.js": "./unlock-profile-skins.js",
13
- "./auto-claimer.js": "./auto-claimer.js",
14
- "./auto-read.js": "./auto-read.js"
15
- },
16
- "files": [
17
- "perfectionist-utils.js",
18
- "ban-blocker.js",
19
- "hide-unowned-skins.js",
20
- "unlock-profile-skins.js",
21
- "auto-claimer.js",
22
- "auto-read.js"
23
- ],
24
- "repository": {
25
- "type": "git",
26
- "url": "git+https://github.com/SrKing-9812/Perfectionist-Plugins.git"
27
- },
28
- "keywords": [
29
- "pengu-loader",
30
- "league-of-legends",
31
- "plugin"
32
- ],
33
- "author": "Sr. King"
34
- }
1
+ {
2
+ "name": "perfectionist-plugins",
3
+ "version": "2.0.0",
4
+ "description": "Small plugins that fix the annoying parts of League of Legends champ select. Built for Pengu Loader.",
5
+ "type": "module",
6
+ "main": "perfectionist-utils.js",
7
+ "exports": {
8
+ ".": "./perfectionist-utils.js",
9
+ "./perfectionist-utils.js": "./perfectionist-utils.js",
10
+ "./ban-blocker.js": "./ban-blocker.js",
11
+ "./hide-unowned-skins.js": "./hide-unowned-skins.js",
12
+ "./unlock-profile-skins.js": "./unlock-profile-skins.js",
13
+ "./auto-claimer.js": "./auto-claimer.js",
14
+ "./auto-read.js": "./auto-read.js",
15
+ "./better-client.js": "./better-client.js"
16
+ },
17
+ "files": [
18
+ "perfectionist-utils.js",
19
+ "ban-blocker.js",
20
+ "hide-unowned-skins.js",
21
+ "unlock-profile-skins.js",
22
+ "auto-claimer.js",
23
+ "auto-read.js",
24
+ "better-client.js",
25
+ "tools/betterclient-monitor.js"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/SrKing-9812/Perfectionist-Plugins.git"
30
+ },
31
+ "keywords": [
32
+ "pengu-loader",
33
+ "league-of-legends",
34
+ "plugin"
35
+ ],
36
+ "author": "Sr. King"
37
+ }
@@ -6,7 +6,7 @@ const PANEL_ID = "perfectionist-plugins-panel";
6
6
  const STYLE_ID = "perfectionist-plugins-style";
7
7
  const TAB_TITLE = "Perfectionism";
8
8
 
9
- export const version = "1.8.0";
9
+ export const version = "2.0.0";
10
10
 
11
11
  const PKG = "perfectionist-plugins";
12
12
  const CDN = "https://cdn.jsdelivr.net/npm";
@@ -22,6 +22,13 @@ const BUNDLED_MODULES = [
22
22
  description: "Shared core for the Perfectionism plugins.",
23
23
  core: true,
24
24
  },
25
+ {
26
+ id: "better-client",
27
+ file: "better-client.js",
28
+ name: "BetterClient",
29
+ version: "1.2.0",
30
+ description: "Debloats the client: blocks telemetry, merges duplicate requests, caches immutable data, decodes images asynchronously and fixes several bugs.",
31
+ },
25
32
  {
26
33
  id: "ban-blocker",
27
34
  file: "ban-blocker.js",
@@ -29,13 +36,6 @@ const BUNDLED_MODULES = [
29
36
  version: "3.6.0",
30
37
  description: "During bans, blocking a teammate instantly claims the champion they hover to ban.",
31
38
  },
32
- {
33
- id: "hide-unowned-skins",
34
- file: "hide-unowned-skins.js",
35
- name: "Hide Unowned Skins",
36
- version: "2.4.0",
37
- description: "In champion select, only shows the skins you own for the picked champion.",
38
- },
39
39
  {
40
40
  id: "unlock-profile-skins",
41
41
  file: "unlock-profile-skins.js",
@@ -43,6 +43,13 @@ const BUNDLED_MODULES = [
43
43
  version: "1.2.1",
44
44
  description: "Unlocks every skin in the Set profile background window.",
45
45
  },
46
+ {
47
+ id: "hide-unowned-skins",
48
+ file: "hide-unowned-skins.js",
49
+ name: "Hide Unowned Skins",
50
+ version: "2.4.0",
51
+ description: "In champion select, only shows the skins you own for the picked champion.",
52
+ },
46
53
  {
47
54
  id: "auto-claimer",
48
55
  file: "auto-claimer.js",
@@ -741,9 +748,6 @@ function installedModules() {
741
748
  return getAvailableModules().filter(isInstalled);
742
749
  }
743
750
 
744
- // The master switch is a UI lock only: it greys out every card's settings and
745
- // toggle (same treatment as a disabled parent's child options) while each
746
- // plugin keeps its own enabled/disabled state untouched.
747
751
  function masterLocked() {
748
752
  return installedModules().length > 0 && state.masterLocked === true;
749
753
  }
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env node
2
+ // BetterClient memory monitor.
3
+ //
4
+ // Samples every League/Riot process once per interval, records client phase
5
+ // (read straight from the LCU with the running client's own credentials) and
6
+ // writes a CSV you can chart. With --diff it compares two V8 heap snapshots and
7
+ // prints the constructors that grew the most.
8
+ //
9
+ // node tools/betterclient-monitor.mjs # live table + CSV
10
+ // node tools/betterclient-monitor.mjs --minutes 240 # long session
11
+ // node tools/betterclient-monitor.mjs --snapshots 15 # heap snapshots via CDP
12
+ // node tools/betterclient-monitor.mjs --diff a.heapsnapshot b.heapsnapshot
13
+ //
14
+ // Nothing is installed and nothing is written outside --out / --snapshot-dir.
15
+
16
+ import { execFileSync } from "node:child_process";
17
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import https from "node:https";
19
+
20
+ const args = process.argv.slice(2);
21
+ const flag = (name, fallback) => {
22
+ const index = args.indexOf(`--${name}`);
23
+ if (index === -1) return fallback;
24
+ const value = args[index + 1];
25
+ return value && !value.startsWith("--") ? value : true;
26
+ };
27
+
28
+ const diffFiles = args.filter((entry) => entry.endsWith(".heapsnapshot"));
29
+ if (args.includes("--diff")) {
30
+ diffSnapshots(diffFiles[0], diffFiles[1]);
31
+ process.exit(0);
32
+ }
33
+
34
+ const intervalSec = Number(flag("interval", 5));
35
+ const minutes = Number(flag("minutes", 0));
36
+ const out = String(flag("out", "betterclient-monitor.csv"));
37
+ const snapshotEvery = Number(flag("snapshots", 0));
38
+ const snapshotDir = String(flag("snapshot-dir", "heapsnapshots"));
39
+ const snapshotPrefix = String(flag("prefix", "betterclient"));
40
+
41
+ const NAMES = ["LeagueClient", "LeagueClientUx", "LeagueClientUxRender", "RiotClientServices", "RiotClientCrashHandler"];
42
+
43
+ function powershell(script) {
44
+ return execFileSync("powershell", ["-NoProfile", "-Command", script], { encoding: "utf8", windowsHide: true });
45
+ }
46
+
47
+ function sampleProcesses() {
48
+ const filter = NAMES.map((name) => `$_.ProcessName -eq '${name}'`).join(" -or ");
49
+ const script = `Get-Process -ErrorAction SilentlyContinue | Where-Object { ${filter} } | ForEach-Object { "$($_.ProcessName)|$($_.Id)|$($_.WorkingSet64)|$($_.PrivateMemorySize64)|$($_.TotalProcessorTime.TotalSeconds)" }`;
50
+ const rows = powershell(script)
51
+ .split(/\r?\n/)
52
+ .map((line) => line.trim())
53
+ .filter(Boolean)
54
+ .map((line) => {
55
+ const [name, pid, workingSet, priv, cpu] = line.split("|");
56
+ return { name, pid: Number(pid), mb: Math.round(Number(workingSet) / 1048576), privateMb: Math.round(Number(priv) / 1048576), cpu: Number(cpu) };
57
+ });
58
+ return rows.sort((a, b) => a.pid - b.pid);
59
+ }
60
+
61
+ let lcu = null;
62
+ function discoverLcu() {
63
+ if (lcu) return lcu;
64
+ try {
65
+ const commandLine = powershell(`(Get-CimInstance Win32_Process -Filter "Name='LeagueClientUx.exe'").CommandLine`);
66
+ const port = /"?"--app-port=(\d+)/.exec(commandLine)?.[1];
67
+ const token = /"?"--remoting-auth-token=([\w-]+)/.exec(commandLine)?.[1];
68
+ if (port && token) lcu = { port: Number(port), token };
69
+ } catch {}
70
+ return lcu;
71
+ }
72
+
73
+ function lcuGet(path) {
74
+ const auth = discoverLcu();
75
+ if (!auth) return Promise.resolve(null);
76
+ return new Promise((resolve) => {
77
+ const request = https.request(
78
+ {
79
+ host: "127.0.0.1",
80
+ port: auth.port,
81
+ path,
82
+ method: "GET",
83
+ rejectUnauthorized: false,
84
+ headers: { Authorization: `Basic ${Buffer.from(`riot:${auth.token}`).toString("base64")}`, Accept: "application/json" },
85
+ timeout: 4000,
86
+ },
87
+ (response) => {
88
+ let body = "";
89
+ response.on("data", (chunk) => (body += chunk));
90
+ response.on("end", () => {
91
+ try {
92
+ resolve(JSON.parse(body));
93
+ } catch {
94
+ resolve(body);
95
+ }
96
+ });
97
+ },
98
+ );
99
+ request.on("error", () => resolve(null));
100
+ request.on("timeout", () => {
101
+ request.destroy();
102
+ resolve(null);
103
+ });
104
+ request.end();
105
+ });
106
+ }
107
+
108
+ async function cdpTarget() {
109
+ for (const port of [9222, 9223, 9229, 9333]) {
110
+ try {
111
+ const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(1500) });
112
+ if (!response.ok) continue;
113
+ const targets = await response.json();
114
+ const page = targets.find((t) => t.webSocketDebuggerUrl && /league|client|riot/i.test(t.url ?? "")) ?? targets.find((t) => t.webSocketDebuggerUrl);
115
+ if (page) return page.webSocketDebuggerUrl;
116
+ } catch {}
117
+ }
118
+ return null;
119
+ }
120
+
121
+ async function takeHeapSnapshot() {
122
+ const url = await cdpTarget();
123
+ if (!url) {
124
+ console.log("[snapshot] no CDP endpoint found - start the client with a remote debugging port to record heap snapshots");
125
+ return null;
126
+ }
127
+ if (!existsSync(snapshotDir)) mkdirSync(snapshotDir, { recursive: true });
128
+ const name = `${snapshotDir}/${snapshotPrefix}-${new Date().toISOString().replace(/[:.]/g, "-")}.heapsnapshot`;
129
+ const socket = new WebSocket(url);
130
+ const chunks = [];
131
+ let id = 0;
132
+ const send = (method, params = {}) => socket.send(JSON.stringify({ id: ++id, method, params }));
133
+ await new Promise((resolve, reject) => {
134
+ const timer = setTimeout(() => reject(new Error("snapshot timed out")), 180000);
135
+ socket.addEventListener("open", () => {
136
+ send("HeapProfiler.enable");
137
+ send("HeapProfiler.collectGarbage");
138
+ send("HeapProfiler.takeHeapSnapshot", { reportProgress: false, captureNumericValue: false });
139
+ });
140
+ socket.addEventListener("message", (event) => {
141
+ const message = JSON.parse(event.data);
142
+ if (message.method === "HeapProfiler.addHeapSnapshotChunk") chunks.push(message.params.chunk);
143
+ if (message.id === id) {
144
+ clearTimeout(timer);
145
+ if (message.error) reject(new Error(message.error.message));
146
+ else resolve();
147
+ }
148
+ });
149
+ socket.addEventListener("error", (error) => {
150
+ clearTimeout(timer);
151
+ reject(error);
152
+ });
153
+ }).finally(() => socket.close());
154
+ writeFileSync(name, chunks.join(""));
155
+ console.log(`[snapshot] wrote ${name} (${(chunks.join("").length / 1048576).toFixed(1)} MB)`);
156
+ return name;
157
+ }
158
+
159
+ function diffSnapshots(fromPath, toPath) {
160
+ if (!fromPath || !toPath) {
161
+ console.log("usage: node tools/betterclient-monitor.mjs --diff old.heapsnapshot new.heapsnapshot");
162
+ return;
163
+ }
164
+ for (const path of [fromPath, toPath]) {
165
+ if (!existsSync(path)) {
166
+ console.log(`missing file: ${path}`);
167
+ return;
168
+ }
169
+ }
170
+ const tally = (snapshot) => {
171
+ const nodeFields = snapshot.snapshot.meta.node_fields;
172
+ const nodeTypes = snapshot.snapshot.meta.node_types[nodeFields.indexOf("type")];
173
+ const nameOffset = nodeFields.indexOf("name");
174
+ const sizeOffset = nodeFields.indexOf("self_size");
175
+ const width = nodeFields.length;
176
+ const nodes = snapshot.nodes;
177
+ const strings = snapshot.strings;
178
+ const map = new Map();
179
+ for (let i = 0; i < nodes.length; i += width) {
180
+ const type = nodeTypes[nodes[i]];
181
+ const name = strings[nodes[i + nameOffset]] ?? type;
182
+ const key = `${type}:${name}`;
183
+ const entry = map.get(key) ?? { count: 0, bytes: 0 };
184
+ entry.count += 1;
185
+ entry.bytes += nodes[i + sizeOffset];
186
+ map.set(key, entry);
187
+ }
188
+ return map;
189
+ };
190
+ const before = tally(JSON.parse(readFileSync(fromPath, "utf8")));
191
+ const after = tally(JSON.parse(readFileSync(toPath, "utf8")));
192
+ const rows = [];
193
+ for (const [key, value] of after) {
194
+ const previous = before.get(key) ?? { count: 0, bytes: 0 };
195
+ rows.push({ key, count: value.count - previous.count, bytes: value.bytes - previous.bytes });
196
+ }
197
+ rows.sort((a, b) => b.bytes - a.bytes);
198
+ console.log(`\ntop growers between the two snapshots:`);
199
+ for (const row of rows.slice(0, 25)) {
200
+ if (row.bytes <= 0) break;
201
+ console.log(` +${(row.bytes / 1048576).toFixed(2)} MB ${String(row.count).padStart(7)} nodes ${row.key}`);
202
+ }
203
+ const total = rows.reduce((sum, row) => sum + row.bytes, 0);
204
+ console.log(`\nnet ${(total / 1048576).toFixed(1)} MB`);
205
+ }
206
+
207
+ const started = Date.now();
208
+ const history = new Map();
209
+ writeFileSync(out, "timestamp,phase,pid,process,working_set_mb,private_mb,cpu_seconds,cpu_percent\n");
210
+ console.log(`writing ${out} - every ${intervalSec}s${minutes ? ` for ${minutes} min` : " until Ctrl+C"}`);
211
+
212
+ const lastCpu = new Map();
213
+ let lastCpuAt = Date.now();
214
+ let tick = 0;
215
+
216
+ async function loop() {
217
+ tick += 1;
218
+ let phase = "?";
219
+ try {
220
+ phase = await lcuGet("/lol-gameflow/v1/gameflow-phase");
221
+ } catch {}
222
+ const rows = sampleProcesses();
223
+ const now = Date.now();
224
+ const elapsed = (now - lastCpuAt) / 1000;
225
+ const parts = [];
226
+ for (const row of rows) {
227
+ const previous = lastCpu.get(row.pid);
228
+ const cpuPercent = previous !== undefined && elapsed > 0 ? Math.max(0, ((row.cpu - previous) / elapsed) * 100) : 0;
229
+ lastCpu.set(row.pid, row.cpu);
230
+ appendFileSync(out, `${new Date(now).toISOString()},${phase},${row.pid},${row.name},${row.mb},${row.privateMb},${row.cpu.toFixed(1)},${cpuPercent.toFixed(1)}\n`);
231
+ parts.push(`${row.name.replace("LeagueClient", "LC")}#${row.pid} ${row.mb}MB ${cpuPercent.toFixed(0)}%`);
232
+ const series = history.get(row.pid) ?? [];
233
+ series.push(row.mb);
234
+ history.set(row.pid, series);
235
+ }
236
+ lastCpuAt = now;
237
+ const totalMb = rows.reduce((sum, row) => sum + row.mb, 0);
238
+ console.log(`[${new Date(now).toLocaleTimeString()}] phase=${phase} total=${totalMb}MB | ${parts.join(" | ")}`);
239
+
240
+ if (snapshotEvery > 0 && tick % Math.max(1, Math.round((snapshotEvery * 60) / intervalSec)) === 0) {
241
+ await takeHeapSnapshot().catch((error) => console.log(`[snapshot] failed: ${error.message}`));
242
+ }
243
+
244
+ if (minutes > 0 && now - started >= minutes * 60 * 1000) {
245
+ console.log("\ngrowth over the run:");
246
+ for (const [pid, series] of history) {
247
+ if (series.length < 2) continue;
248
+ const first = series[0];
249
+ const last = series[series.length - 1];
250
+ const delta = last - first;
251
+ const minutesRun = Math.max(0.1, (Date.now() - started) / 60000);
252
+ const perHour = (delta / minutesRun) * 60;
253
+ const row = rows.find((entry) => entry.pid === pid);
254
+ console.log(` ${row?.name ?? "process"}#${pid}: ${first}MB -> ${last}MB (${delta >= 0 ? "+" : ""}${delta}MB, ${perHour >= 0 ? "+" : ""}${perHour.toFixed(0)}MB/h)`);
255
+ if (perHour > 200) console.log(" ^ leak candidate - this process alone keeps growing");
256
+ }
257
+ console.log(`\nfull series in ${out}`);
258
+ process.exit(0);
259
+ }
260
+ }
261
+
262
+ while (true) {
263
+ try {
264
+ await loop();
265
+ } catch (error) {
266
+ console.log(`sample failed: ${error.message}`);
267
+ }
268
+ await new Promise((resolve) => setTimeout(resolve, intervalSec * 1000));
269
+ }