@spicetify/kit 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/create.js CHANGED
@@ -206,7 +206,7 @@ test("nowPlaying returns the track name, or a fallback when idle", () => {
206
206
  function writeThemeModule(dir, name, description, author, bare, cwd) {
207
207
  writeFileSync(path.join(dir, "metadata.json"), `${JSON.stringify({
208
208
  name,
209
- tags: ["theme"],
209
+ kind: "theme",
210
210
  version: "0.1.0",
211
211
  authors: [author],
212
212
  description,
@@ -315,13 +315,13 @@ export async function runCreate(argv, cwd = process.cwd()) {
315
315
  writeThemeModule(dir, name, description, author, bare, cwd);
316
316
  return;
317
317
  }
318
- const tag = template === "app" ? "app" : "extension";
318
+ const kind = template === "app" ? "app" : "extension";
319
319
  // Extensions are behavior-only; templates that render a page ship css.
320
320
  const hasCss = template !== "extension";
321
321
  const entries = hasCss ? { js: "index.js", css: "index.css" } : { js: "index.js" };
322
322
  writeFileSync(path.join(dir, "metadata.json"), `${JSON.stringify({
323
323
  name,
324
- tags: [tag],
324
+ kind,
325
325
  version: "0.1.0",
326
326
  authors: [author],
327
327
  description,
@@ -58,7 +58,7 @@ export async function runFromTheme(argv, cwd = process.cwd()) {
58
58
  }
59
59
  writeFileSync(path.join(dir, "metadata.json"), `${JSON.stringify({
60
60
  name,
61
- tags: ["theme"],
61
+ kind: "theme",
62
62
  version: "0.1.0",
63
63
  authors: [author],
64
64
  description: `${path.basename(themeDir)} theme, migrated from the classic format`,
package/dist/push.js CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { readdirSync, readFileSync, statSync } from "node:fs";
14
14
  import path from "node:path";
15
+ import { KIND_OF_META_SOURCE } from "./vault-metadata.js";
15
16
  // localStorage quota is ~5 MB for the xpui origin, shared with Spotify's own
16
17
  // keys and other local modules, so thresholds are conservative. Counted in
17
18
  // UTF-16 code units (JSON.stringify(...).length), the unit the quota counts.
@@ -234,9 +235,15 @@ export function push(rec, id, port) {
234
235
  // Refuse an oversized install before opening the socket (U7), so the failure
235
236
  // is a named cause here rather than an opaque client-side quota error.
236
237
  checkQuota(rec);
237
- // The whole exchange runs in the client: disable the old instance, install
238
- // the fresh content, and re-enable anything the unload cascade took down.
239
- const expr = `(async () => {
238
+ return evaluate(port, pushExpression(rec, id, nonce, stamped));
239
+ }
240
+ /**
241
+ * The expression push evaluates in the client: it installs the record, then
242
+ * re-enables anything the unload cascade took down, except other themes when
243
+ * the pushed module is one.
244
+ */
245
+ export function pushExpression(rec, id, nonce, stamped) {
246
+ return `(async () => {
240
247
  ${AWAIT_LOADER}
241
248
  if (!M) return JSON.stringify({ error: "loader not ready" });
242
249
  const rec = ${JSON.stringify(rec)};
@@ -251,8 +258,10 @@ export function push(rec, id, port) {
251
258
  if (reenabled) await (M.reload ?? M.enable)(id);
252
259
  // Re-enabling a theme the loader just unloaded would fight the
253
260
  // single-active-theme invariant and knock the pushed theme back off.
254
- const pushedIsTheme = (rec.metadata.tags ?? []).includes("theme");
255
- const isTheme = (mid) => ((M.manifest?.modules?.find((m) => m.identifier === mid)?.tags) ?? []).includes("theme");
261
+ const kindOf = ${KIND_OF_META_SOURCE};
262
+ const themed = (meta) => kindOf(meta) === "theme";
263
+ const pushedIsTheme = themed(rec.metadata);
264
+ const isTheme = (mid) => themed(M.manifest?.modules?.find((m) => m.identifier === mid));
256
265
  for (const other of before) {
257
266
  if (pushedIsTheme && isTheme(other)) continue;
258
267
  const s = M.list().find((m) => m.identifier === other);
@@ -268,7 +277,6 @@ export function push(rec, id, port) {
268
277
  reenabled,
269
278
  });
270
279
  })()`;
271
- return evaluate(port, expr);
272
280
  }
273
281
  // removeLocal drops a hot-pushed override so the client falls back to the
274
282
  // staged or store-installed copy.
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { readdirSync, readFileSync, statSync } from "node:fs";
6
6
  import path from "node:path";
7
+ import { kindOfMeta } from "./vault-metadata.js";
7
8
  export const STDLIB_BOUNDARY_WARNING_RULES = ["ambient-client", "client-dom", "direct-map"];
8
9
  const SOURCE_EXTENSIONS = new Set([".js", ".jsx", ".mjs", ".ts", ".tsx"]);
9
10
  const STYLE_EXTENSIONS = new Set([".css", ".scss"]);
@@ -202,7 +203,7 @@ export function checkExternalStdlibBoundary(root, metadata) {
202
203
  const entries = meta.entries;
203
204
  if (typeof entries?.js !== "string" || meta.name === "stdlib")
204
205
  return out;
205
- const isTheme = meta.kind === "theme" || (Array.isArray(meta.tags) && meta.tags.some((tag) => tag === "theme"));
206
+ const isTheme = kindOfMeta(meta) === "theme";
206
207
  const dependencies = meta.dependencies;
207
208
  const missingDependency = !isTheme &&
208
209
  typeof dependencies === "object" &&
@@ -12,6 +12,14 @@ export const kindOfMeta = (meta) => {
12
12
  const tags = Array.isArray(meta.tags) ? meta.tags : [];
13
13
  return KINDS.find((kind) => tags.includes(kind));
14
14
  };
15
+ // kindOfMeta as JavaScript source, for expressions evaluated inside the
16
+ // client where kit code cannot be imported. It also accepts a missing meta.
17
+ export const KIND_OF_META_SOURCE = `((meta) => {
18
+ const kinds = ${JSON.stringify(KINDS)};
19
+ if (typeof meta?.kind === "string" && kinds.includes(meta.kind)) return meta.kind;
20
+ const tags = Array.isArray(meta?.tags) ? meta.tags : [];
21
+ return kinds.find((kind) => tags.includes(kind));
22
+ })`;
15
23
  // metadata.json authors are plain names; author objects (with a github)
16
24
  // pass through, so an artifact may declare either.
17
25
  const normalizeAuthors = (authors) => authors.flatMap((a) => {
package/dist/version.js CHANGED
@@ -4,5 +4,5 @@
4
4
  */
5
5
  // Kept in lockstep with package.json by release-please. Scaffolds must depend
6
6
  // on the kit version that generated them, especially across pre-1.0 minors.
7
- export const KIT_VERSION = "0.4.2"; // x-release-please-version
7
+ export const KIT_VERSION = "0.4.4"; // x-release-please-version
8
8
  export const KIT_DEPENDENCY_RANGE = `^${KIT_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spicetify/kit",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Developer kit for spicetify v3 modules: scaffold, build, hot-push, and pack",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "homepage": "https://github.com/spicetify/modules#readme",
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "spicetify": {
43
- "stdlibVersion": "1.13.1"
43
+ "stdlibVersion": "1.13.2"
44
44
  },
45
45
  "scripts": {
46
46
  "build": "tsc -p tsconfig.build.json",
@@ -1,13 +1,31 @@
1
1
  {
2
+ "context_menu": {
3
+ "menu_item": "fmmygDM45MTS7I35OyLh"
4
+ },
2
5
  "main": {
6
+ "navbar": {
7
+ "link": {
8
+ "container": "xlH6FJnBzFT4l3TgUBre",
9
+ "container__active": "ajKYUZPcNmFVzcAzzlwe",
10
+ "wrapper": "xlH6FJnBzFT4l3TgUBre"
11
+ }
12
+ },
13
+ "playbar": {
14
+ "buttons": {
15
+ "button": {
16
+ "wrapper": "e-10860-legacy-button e-10860-button-tertiary--icon-only-small",
17
+ "wrapper__active": "e-10860-legacy-button e-10860-legacy-button-primary",
18
+ "wrapper__indicator": "e-10860-button-primary__inner"
19
+ }
20
+ }
21
+ },
3
22
  "topbar": {
4
- "wrapper": "bQetA_KiP9n0DQVcGa7m",
5
23
  "left": {
6
24
  "button": {
7
- "wrapper": "e-10860-legacy-button e-10860-button-tertiary--icon-only-medium",
8
25
  "icon": {
9
26
  "wrapper": "e-10860-icon e-10860-icon--auto-mirror"
10
- }
27
+ },
28
+ "wrapper": "e-10860-legacy-button e-10860-button-tertiary--icon-only-medium"
11
29
  },
12
30
  "button_t": {
13
31
  "wrapper": "e-10860-legacy-button e-10860-button-tertiary--icon-only-medium"
@@ -23,87 +41,64 @@
23
41
  "upgrade_button": {
24
42
  "wrapper": "e-10860-legacy-button e-10860-legacy-button-primary"
25
43
  }
26
- }
27
- },
28
- "playbar": {
29
- "widget": {},
30
- "controls": {},
31
- "buttons": {
32
- "button": {
33
- "wrapper": "e-10860-legacy-button e-10860-button-tertiary--icon-only-small",
34
- "wrapper__active": "e-10860-legacy-button e-10860-legacy-button-primary",
35
- "wrapper__indicator": "e-10860-button-primary__inner"
36
- }
37
- }
38
- },
39
- "navbar": {
40
- "link": {
41
- "wrapper": "xlH6FJnBzFT4l3TgUBre",
42
- "container": "xlH6FJnBzFT4l3TgUBre",
43
- "container__active": "ajKYUZPcNmFVzcAzzlwe"
44
- }
45
- },
46
- "global_navbar": {},
47
- "sidebar": {},
48
- "panel": {}
49
- },
50
- "tracklist": {
51
- "column_header": "e-10860-legacy-list-row__header"
52
- },
53
- "search_chips": {
54
- "wrapper_wrapper": "e-10860-chip-group",
55
- "wrapper": "e-10860-chip-group__component",
56
- "container": "e-10860-chip-group__component",
57
- "chip": "e-10860-legacy-chip"
44
+ },
45
+ "wrapper": "bQetA_KiP9n0DQVcGa7m"
46
+ }
58
47
  },
59
48
  "modal": {
49
+ "track_credits": {
50
+ "container": "gtXQasDkhtg93wGk6yNG",
51
+ "content": {
52
+ "container": "wYQNBdH2rNMGe_B4oumf"
53
+ },
54
+ "header": {
55
+ "close": "CaHvWv36mAu4x5QTKlb1",
56
+ "container": "nQ_c7yoZ2XOD6fWtmv18"
57
+ }
58
+ },
60
59
  "widget_generator": {
61
60
  "container": "_XeAt3IgtZQepOYqU2g4",
62
- "header": {
63
- "container": "mnU1hqlaF5JIcrTxLnZF",
64
- "close": "AslPcD0TRZBBGZofe58g"
65
- },
66
61
  "content": {
67
62
  "container": "Xu_Z4l6TIOF1f5kDB9dt"
68
- }
69
- },
70
- "track_credits": {
71
- "container": "gtXQasDkhtg93wGk6yNG",
72
- "header": {
73
- "container": "nQ_c7yoZ2XOD6fWtmv18",
74
- "close": "CaHvWv36mAu4x5QTKlb1"
75
63
  },
76
- "content": {
77
- "container": "wYQNBdH2rNMGe_B4oumf"
64
+ "header": {
65
+ "close": "AslPcD0TRZBBGZofe58g",
66
+ "container": "mnU1hqlaF5JIcrTxLnZF"
78
67
  }
79
68
  }
80
69
  },
70
+ "scrollable_text": {
71
+ "container": "e-10860-legacy-box e-10860-legacy-box--naked",
72
+ "wrapper": "e-10860-legacy-box"
73
+ },
74
+ "search_box": {
75
+ "container": "mlx0CgXAnGz0dADVsCdS",
76
+ "expand_button": "e-10860-legacy-button e-10860-button-tertiary--icon-only-medium aslJk0YypwKvyKXaBJcG"
77
+ },
78
+ "search_chips": {
79
+ "chip": "e-10860-legacy-chip",
80
+ "container": "e-10860-chip-group__component",
81
+ "wrapper": "e-10860-chip-group__component",
82
+ "wrapper_wrapper": "e-10860-chip-group"
83
+ },
81
84
  "settings": {
85
+ "button": {
86
+ "wrapper": "e-10860-legacy-button e-10860-legacy-button--small e-10860-legacy-button-secondary"
87
+ },
82
88
  "header": {
83
89
  "container": "Hwax_pfCzrntLhnYXiqL"
84
90
  },
85
91
  "section": {
86
92
  "container": "HdwEUcX8xijMdU7doYPt"
87
93
  },
88
- "button": {
89
- "wrapper": "e-10860-legacy-button e-10860-legacy-button--small e-10860-legacy-button-secondary"
90
- },
91
94
  "text_input": "e-10860-form-input e-10860-form-control encore-text-body-medium"
92
95
  },
93
- "scrollable_text": {
94
- "container": "e-10860-legacy-box e-10860-legacy-box--naked",
95
- "wrapper": "e-10860-legacy-box"
96
- },
97
96
  "sort_box": {
98
97
  "list": {
99
98
  "button": "MYWr4UY4pKAAscUXvqrB"
100
99
  }
101
100
  },
102
- "search_box": {
103
- "container": "mlx0CgXAnGz0dADVsCdS",
104
- "expand_button": "e-10860-legacy-button e-10860-button-tertiary--icon-only-medium aslJk0YypwKvyKXaBJcG"
105
- },
106
- "context_menu": {
107
- "menu_item": "fmmygDM45MTS7I35OyLh"
101
+ "tracklist": {
102
+ "column_header": "e-10860-legacy-list-row__header"
108
103
  }
109
104
  }
@@ -3,7 +3,7 @@
3
3
  "kind": "lib",
4
4
  "hidden": true,
5
5
  "preview": "./assets/PREVIEW.png",
6
- "version": "1.13.1",
6
+ "version": "1.13.2",
7
7
  "compat": ["0.3.0"],
8
8
  "authors": ["spicetify", "Delusoire"],
9
9
  "description": "The standard library",
@@ -14,9 +14,10 @@ type Predicate<A> = (value: A) => boolean;
14
14
 
15
15
  // Some client exports are functions whose own toString is not callable;
16
16
  // they can never match a needle, so they stringify to "".
17
- const safeString = (x: any): string => {
17
+ const stringify = (x: any): string => {
18
+ if (x == null) return "";
18
19
  try {
19
- return x.toString();
20
+ return String(x);
20
21
  } catch {
21
22
  try {
22
23
  return Function.prototype.toString.call(x);
@@ -26,18 +27,32 @@ const safeString = (x: any): string => {
26
27
  }
27
28
  };
28
29
 
30
+ const sources = new WeakMap<object, string>();
31
+
32
+ // sourceOf is the source text needles match against, computed once per
33
+ // function or object for the whole session.
34
+ export const sourceOf = (x: unknown): string => {
35
+ if ((typeof x !== "object" && typeof x !== "function") || x === null) return stringify(x);
36
+ let source = sources.get(x);
37
+ if (source === undefined) {
38
+ source = stringify(x);
39
+ sources.set(x, source);
40
+ }
41
+ return source;
42
+ };
43
+
29
44
  export function findBy(...tests: Array<string | RegExp | Predicate<any>>) {
30
45
  const testFns = tests.map((test): Predicate<any> => {
31
46
  switch (typeof test) {
32
47
  case "string":
33
- return (x) => safeString(x).includes(test);
48
+ return (x) => sourceOf(x).includes(test);
34
49
  case "function":
35
50
  return (x) => test(x);
36
51
  default: // assume regex
37
- return (x) => test.test(safeString(x));
52
+ return (x) => test.test(sourceOf(x));
38
53
  }
39
54
  });
40
- const testFn = (x: any) => testFns.map((t) => t(x)).every(Boolean);
55
+ const testFn = (x: any) => testFns.every((t) => t(x));
41
56
  return <A>(xs: A[]) => xs.find(testFn)!;
42
57
  }
43
58
 
@@ -10,9 +10,19 @@ await CHUNKS.xpui.promise;
10
10
  const componentNames = Object.keys(
11
11
  exported.find((e) => e.BrowserDefaultFocusStyleProvider && Object.hasOwn(e, "BrowserDefaultFocusStyleProvider")),
12
12
  );
13
- const componentRegexes = componentNames.map((n) => new RegExp(`"data-encore-id":(?:[a-zA-Z_\$][\w\$]*\\.){2}${n}\\b`));
13
+ const componentRegexes = componentNames.map(
14
+ (n) => new RegExp(String.raw`"data-encore-id":(?:[a-zA-Z_$][\w$]*\.){2}${n}\b`),
15
+ );
14
16
  const componentPairs = [exportedFunctions.map((f) => [f, f]), exportedForwardRefs.map((f) => [(f as any).render, f])]
15
17
  .flat()
16
- .map(([s, f]) => [componentNames.find((n, i) => src(s).match(componentRegexes[i])), f]);
18
+ .map(([s, f]) => {
19
+ const code = src(s);
20
+ return [
21
+ code.includes('"data-encore-id"')
22
+ ? componentNames.find((_, i) => componentRegexes[i].test(code))
23
+ : undefined,
24
+ f,
25
+ ];
26
+ });
17
27
 
18
28
  export const UI: any = Object.fromEntries(componentPairs);
@@ -4,8 +4,10 @@
4
4
  */
5
5
 
6
6
  import { warn } from "../logger.ts";
7
+ import { sourceOf } from "../util.ts";
7
8
  import { postWebpackRequireHooks, WebpackModule, WebpackRequire, webpackRequire } from "../wpunpk.mix.ts";
8
9
  import { createCaptureReadiness } from "./capture-readiness.ts";
10
+ import { watchRegistryQuiet } from "./registry-quiet.ts";
9
11
 
10
12
  export let modules: Array<[PropertyKey, WebpackModule]>;
11
13
  export let exports: Array<Record<string, any>>;
@@ -18,19 +20,7 @@ export let exportedContexts: Array<React.Context<any>>;
18
20
  export let exportedForwardRefs: Array<any>;
19
21
  export let exportedMemos: React.NamedExoticComponent[];
20
22
 
21
- // Some client exports are functions whose own toString is not callable; they
22
- // can never match a needle, so they stringify to "".
23
- export const src = (f: unknown): string => {
24
- try {
25
- return String(f);
26
- } catch {
27
- try {
28
- return Function.prototype.toString.call(f);
29
- } catch {
30
- return "";
31
- }
32
- }
33
- };
23
+ export const src = sourceOf;
34
24
 
35
25
  export const analyzeWebpackRequire = (webpackRequire: WebpackRequire) => {
36
26
  const modules = Object.entries(webpackRequire.m) as Array<[keyof any, WebpackModule]>;
@@ -84,18 +74,31 @@ Object.assign(CHUNKS, {
84
74
  // executed by capture time. Lazy route chunks go through the runtime's own
85
75
  // script loader (wpr.l) from then on.
86
76
  postWebpackRequireHooks.push((wpr: any) => {
77
+ let loading = 0;
87
78
  if (typeof wpr?.l === "function") {
88
79
  const load = wpr.l.bind(wpr);
89
- wpr.l = (url: string, done: (event: unknown) => unknown, key?: string, chunkId?: unknown) =>
90
- load(
91
- url,
92
- (event: unknown) => {
93
- resolveChunk(new URL(url, location.href).pathname);
94
- return done(event);
95
- },
96
- key,
97
- chunkId,
98
- );
80
+ wpr.l = (url: string, done: (event: unknown) => unknown, key?: string, chunkId?: unknown) => {
81
+ loading++;
82
+ try {
83
+ return load(
84
+ url,
85
+ (event: unknown) => {
86
+ loading--;
87
+ try {
88
+ resolveChunk(new URL(url, location.href).pathname);
89
+ } catch (error) {
90
+ console.error(`[stdlib] could not resolve chunk ${url}:`, error);
91
+ }
92
+ return done(event);
93
+ },
94
+ key,
95
+ chunkId,
96
+ );
97
+ } catch (error) {
98
+ loading--;
99
+ throw error;
100
+ }
101
+ };
99
102
  }
100
103
  // Chunk scripts that finished loading before capture. The xpui entry
101
104
  // chunks are deliberately excluded: they are gated below on registry
@@ -108,23 +111,18 @@ postWebpackRequireHooks.push((wpr: any) => {
108
111
  }
109
112
  }
110
113
  // Capture can fire while boot is still registering modules (xpui-modules
111
- // and friends land after the runtime is up, and the analysis needles live
112
- // there). The analysis below snapshots wpr.m once, so hold the xpui
113
- // promises until the registry has been quiet for a few ticks — with a
114
- // hard cap so a pathological boot still resolves.
115
- let last = -1;
116
- let stable = 0;
117
- let ticks = 0;
118
- const settle = setInterval(() => {
119
- const count = Object.keys(wpr?.m ?? {}).length;
120
- stable = count === last ? stable + 1 : 0;
121
- last = count;
122
- if (stable >= 3 || ++ticks > 100) {
123
- clearInterval(settle);
114
+ // and the lazy chunks the client requests right after it land later, and
115
+ // analysis needles such as GenericModal live there). The analysis below
116
+ // snapshots wpr.m once, so hold the xpui promises until the registry has
117
+ // been quiet, with no chunk in flight, for 300ms.
118
+ watchRegistryQuiet(
119
+ () => Object.keys(wpr?.m ?? {}).length,
120
+ () => loading,
121
+ () => {
124
122
  resolveChunk("/vendor~xpui.js");
125
123
  resolveChunk("/xpui.js");
126
- }
127
- }, 100);
124
+ },
125
+ );
128
126
  });
129
127
 
130
128
  // Capture subscribers run synchronously right after the analysis lands, so
@@ -135,7 +133,7 @@ postWebpackRequireHooks.push((wpr: any) => {
135
133
  let captured = false;
136
134
  const captureSubscribers: Array<() => void> = [];
137
135
  const webpackCaptureReadiness = createCaptureReadiness({
138
- // Registry quiescence caps at about 10.1s. Leave room for its last tick,
136
+ // Registry quiescence caps at about 10s. Leave room for its last tick,
139
137
  // then release degraded so a changed runtime cannot deadlock all modules.
140
138
  timeoutMs: 12000,
141
139
  onTimeout: () => warn("[stdlib] capture health: webpack capture timed out; module surfaces will be degraded"),
@@ -0,0 +1,30 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+
6
+ const QUIET_MS = 300;
7
+ const TICK_MS = 25;
8
+ const CAP_MS = 10_000;
9
+
10
+ // watchRegistryQuiet calls onQuiet once the module registry has not changed,
11
+ // and no chunk load has been in flight, for 300ms. A 10s cap bounds the wait
12
+ // so a chunk that never finishes, or a registry that never settles, still
13
+ // resolves.
14
+ export function watchRegistryQuiet(count: () => number, pending: () => number, onQuiet: () => void): void {
15
+ const started = Date.now();
16
+ let last = -1;
17
+ let quietSince = started;
18
+ const timer = setInterval(() => {
19
+ const current = count();
20
+ const at = Date.now();
21
+ if (current !== last || pending() > 0) {
22
+ last = current;
23
+ quietSince = at;
24
+ }
25
+ if (at - quietSince >= QUIET_MS || at - started >= CAP_MS) {
26
+ clearInterval(timer);
27
+ onQuiet();
28
+ }
29
+ }, TICK_MS);
30
+ }