@spicetify/kit 0.3.0 → 0.4.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.
Files changed (37) hide show
  1. package/README.md +82 -0
  2. package/dist/args.js +15 -0
  3. package/dist/cli.js +4 -1
  4. package/dist/create.js +9 -5
  5. package/dist/dev.js +86 -22
  6. package/dist/install.js +4 -6
  7. package/dist/launch.js +24 -24
  8. package/dist/push.js +187 -44
  9. package/dist/remove.js +37 -0
  10. package/dist/stdlib-boundary.js +2 -2
  11. package/dist/version.js +1 -1
  12. package/package.json +6 -4
  13. package/vendor/classmaps/1030000/classmap-19f856aefd5.json +109 -0
  14. package/vendor/shims/platform.d.ts +1640 -3235
  15. package/vendor/stdlib/metadata.json +1 -1
  16. package/vendor/stdlib/query.ts +31 -0
  17. package/vendor/stdlib/src/client.ts +90 -1
  18. package/vendor/stdlib/src/events.mix.ts +9 -0
  19. package/vendor/stdlib/src/events.ts +1 -1
  20. package/vendor/stdlib/src/registers/menu-order.ts +17 -0
  21. package/vendor/stdlib/src/registers/menu.ts +8 -4
  22. package/vendor/stdlib/src/registers/mount.ts +35 -9
  23. package/vendor/stdlib/src/registers/navlink.tsx +3 -50
  24. package/vendor/stdlib/src/registers/playbarButton.tsx +0 -19
  25. package/vendor/stdlib/src/registers/playbarWidget.tsx +0 -19
  26. package/vendor/stdlib/src/registers/root.ts +1 -1
  27. package/vendor/stdlib/src/registers/route.ts +1 -23
  28. package/vendor/stdlib/src/registers/topbarLeftButton.tsx +0 -24
  29. package/vendor/stdlib/src/registers/topbarRightButton.tsx +0 -24
  30. package/vendor/stdlib/src/storage.ts +1 -1
  31. package/vendor/stdlib/src/webpack/ReactComponents.xpui.ts +3 -9
  32. package/vendor/stdlib/src/webpack/URI.xpui.ts +1 -1
  33. package/vendor/stdlib/src/webpack/capture-readiness.ts +5 -3
  34. package/vendor/stdlib/src/webpack/index.ts +10 -4
  35. package/vendor/stdlib/src/webpack/playlist-menu.ts +54 -0
  36. package/vendor/stdlib/src/wpunpk.mix.ts +1 -1
  37. package/vendor/classmaps/1020096/classmap-19f856aefd5.json +0 -109
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @spicetify/kit
2
+
3
+ The developer kit for [Spicetify](https://spicetify.app) v3 modules. It scaffolds a module, builds it, and hot-pushes every save into a running Spotify client.
4
+
5
+ ## Requirements
6
+
7
+ - Node 22.6 or newer.
8
+ - Spotify with Spicetify v3 applied (`spicetify apply`). The kit pushes into the client the Spicetify CLI has patched, so a stock Spotify cannot load a pushed module.
9
+ - The standalone Spotify install. The Microsoft Store build cannot be started with a debug port.
10
+
11
+ ## Start a module
12
+
13
+ ```sh
14
+ npm create spicetify-module my-module
15
+ cd my-module
16
+ npm install
17
+ npm run dev
18
+ ```
19
+
20
+ `npm run dev` starts Spotify with a remote debugging port, or reuses a client already running with one. It then rebuilds on every save and pushes the build into the client in about a second, with no re-apply and no restart. Stop it with ctrl-c, which removes the pushed copy so Spotify goes back to whatever version of the module is installed.
21
+
22
+ Pick a template with `--template`:
23
+
24
+ | Template | What you get |
25
+ | ----------------- | --------------------------------------- |
26
+ | `basic` (default) | a topbar button that opens a page |
27
+ | `extension` | behaviour only, with no UI |
28
+ | `app` | a sidebar entry and a full page |
29
+ | `theme` | CSS and `color.ini` only, no TypeScript |
30
+
31
+ ## Scripts in a scaffolded project
32
+
33
+ | Script | What it does |
34
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
35
+ | `npm run dev` | start Spotify, then rebuild and hot-push on every save |
36
+ | `npm run build` | build once into `dist/` |
37
+ | `npm run check` | typecheck and audit the module against the [module standard](https://github.com/spicetify/modules/blob/main/docs/module-standard.md) |
38
+ | `npm test` | run the unit tests in `test/` |
39
+ | `npm run remove` | remove a pushed copy that is still installed in the client |
40
+
41
+ `dev` flags go after `--`, for example `npm run dev -- --keep`:
42
+
43
+ | Flag | Effect |
44
+ | ------------- | ------------------------------------------------------------------------- |
45
+ | `--keep` | leave the pushed copy installed when `dev` stops |
46
+ | `--once` | build and push once, then exit; the pushed copy stays |
47
+ | `--no-launch` | do not start Spotify; wait for one started with `--remote-debugging-port` |
48
+ | `--port <n>` | use another debug port (default 9229, or `SPICETIFY_CDP_PORT`) |
49
+
50
+ ## Troubleshooting
51
+
52
+ **"port 9229 is already in use by ..., not Spotify"**: Node's inspector also defaults to 9229, so `node --inspect` or `wrangler dev` can hold it. Stop that process, or use another port with `npm run dev -- --port 9230`.
53
+
54
+ **"the v3 loader is not staged in this client"**: Spotify is running without Spicetify v3. Run `spicetify apply`, then start `dev` again.
55
+
56
+ **The UI still shows the old build**: a surface that was already on screen keeps the old code until it remounts. Navigate away from it and back.
57
+
58
+ ## Commands
59
+
60
+ `spicetify-kit <command> --help` prints each command's flags.
61
+
62
+ | Command | What it does |
63
+ | ---------------------- | -------------------------------------------------------------- |
64
+ | `create <name>` | scaffold a module project |
65
+ | `dev <module>` | watch, rebuild, and hot-push into Spotify |
66
+ | `build [module...]` | bundle modules into `dist/` |
67
+ | `check [module]` | audit a module against the module standard |
68
+ | `remove <dir\|id>` | remove a pushed copy from the running client |
69
+ | `install <zip\|dir>` | push a packed or built module into the running client once |
70
+ | `pack <dist-dir>` | zip a built module and print its sha256 |
71
+ | `vault add <dist-dir>` | write the store registry entry for a release |
72
+ | `from-theme <dir>` | convert a classic `user.css` and `color.ini` theme to a module |
73
+
74
+ ## Learn more
75
+
76
+ - [Building a module](https://github.com/spicetify/docs/blob/v3/src/content/docs/development/building-a-module.md)
77
+ - [Authoring guide](https://github.com/spicetify/modules/blob/main/docs/authoring-guide.md)
78
+ - [Publishing to the store](https://github.com/spicetify/modules/blob/main/docs/publishing.md)
79
+
80
+ ## License
81
+
82
+ GPL-3.0-or-later
package/dist/args.js ADDED
@@ -0,0 +1,15 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+ // flagValue returns the operand of --<name>, undefined when the flag is
6
+ // absent, and throws when the flag is present without an operand.
7
+ export function flagValue(argv, name) {
8
+ const i = argv.indexOf(`--${name}`);
9
+ if (i < 0)
10
+ return undefined;
11
+ const value = argv[i + 1];
12
+ if (value === undefined || value.startsWith("--"))
13
+ throw new Error(`--${name} needs a value`);
14
+ return value;
15
+ }
package/dist/cli.js CHANGED
@@ -10,10 +10,11 @@ usage: spicetify-kit <command> [args]
10
10
  check [module] audit a module against the module standard (advisory)
11
11
  from-theme <dir> migrate a classic theme (user.css + color.ini) to a module
12
12
  build [module...] bundle modules (rolldown + scss) into dist/
13
- dev <module> watch, rebuild, and hot-push into a running client
13
+ dev <module> watch, rebuild, and hot-push into Spotify (starts it if needed)
14
14
  pack <dist-dir> zip a built module and print its sha256
15
15
  vault add <dir> record a built module into a vault file (--artifact <url>)
16
16
  install <zip|dir> sideload a packed module into a running client
17
+ remove <dir|id> drop a hot-pushed override from a running client
17
18
 
18
19
  run a command with --help for its flags`;
19
20
  export async function main(argv) {
@@ -36,6 +37,8 @@ export async function main(argv) {
36
37
  return await (await import("./vault.js")).runVault(rest);
37
38
  case "install":
38
39
  return await (await import("./install.js")).runInstall(rest);
40
+ case "remove":
41
+ return await (await import("./remove.js")).runRemove(rest);
39
42
  case undefined:
40
43
  case "--help":
41
44
  case "-h":
package/dist/create.js CHANGED
@@ -267,6 +267,7 @@ notification = 1C1C30
267
267
  scripts: {
268
268
  build: "spicetify-kit build .",
269
269
  dev: "spicetify-kit dev .",
270
+ remove: "spicetify-kit remove .",
270
271
  check: "spicetify-kit check .",
271
272
  },
272
273
  devDependencies: { "@spicetify/kit": KIT_DEPENDENCY_RANGE },
@@ -281,7 +282,7 @@ notification = 1C1C30
281
282
  if (!bare) {
282
283
  console.log("next steps:");
283
284
  console.log(` cd ${rel} && npm install`);
284
- console.log(" npm run dev # hot-push into a running client");
285
+ console.log(" npm run dev # start Spotify, then rebuild and hot-push on every save");
285
286
  }
286
287
  }
287
288
  export async function runCreate(argv, cwd = process.cwd()) {
@@ -369,6 +370,7 @@ export async function load(ctx: ModuleRuntimeContext) {
369
370
  scripts: {
370
371
  build: "spicetify-kit build .",
371
372
  dev: "spicetify-kit dev .",
373
+ remove: "spicetify-kit remove .",
372
374
  check: "tsc && spicetify-kit check .",
373
375
  test: 'node --test "test/*.test.mts"',
374
376
  },
@@ -392,7 +394,7 @@ export async function load(ctx: ModuleRuntimeContext) {
392
394
  noEmit: true,
393
395
  jsx: "react-jsx",
394
396
  lib: ["ES2024", "ESNext.Disposable", "DOM", "DOM.Iterable"],
395
- strict: false,
397
+ strict: true,
396
398
  skipLibCheck: true,
397
399
  resolveJsonModule: true,
398
400
  types: [],
@@ -417,12 +419,14 @@ export async function load(ctx: ModuleRuntimeContext) {
417
419
  console.log(`created ${rel}/`);
418
420
  console.log("next steps:");
419
421
  if (bare) {
420
- console.log(` node scripts/stitch.ts modules/${name} # one-off build into dist/`);
421
- console.log(` node scripts/dev.ts modules/${name} # watch + hot-push into a running client`);
422
+ console.log(` pnpm stitch modules/${name} # one-off build into dist/`);
423
+ console.log(` pnpm dev modules/${name} # start Spotify, then rebuild and hot-push on every save`);
422
424
  }
423
425
  else {
424
426
  console.log(` cd ${rel} && npm install`);
425
- console.log(" npm run dev # watch + hot-push into a running client (Spotify started with --remote-debugging-port=9229)");
427
+ console.log(" npm run dev # start Spotify, then rebuild and hot-push on every save");
426
428
  console.log(" npm run build # one-off build into dist/");
429
+ console.log("stopping dev removes the pushed override; `npm run dev -- --keep` leaves it installed.");
430
+ console.log("Spotify needs Spicetify v3 applied (`spicetify apply`) for the push to land.");
427
431
  }
428
432
  }
package/dist/dev.js CHANGED
@@ -9,32 +9,55 @@
9
9
  * fresh bundle over the Chrome DevTools Protocol as a local install
10
10
  * (Spicetify.Modules.installLocal), so nothing in the staged app bundle
11
11
  * is touched and the loop is sub-second. Spotify must be running with
12
- * --remote-debugging-port=<port>. Remove the override afterwards with
13
- * Spicetify.Modules.removeLocal("<name>") (or from the manager page) to
14
- * fall back to the staged copy.
12
+ * --remote-debugging-port=<port>; dev starts (or reuses) such a client unless
13
+ * --no-launch is passed. Stopping dev removes the override so the client falls
14
+ * back to the staged copy; --keep (and --once) leave it installed, and
15
+ * `spicetify-kit remove` drops it later.
15
16
  */
16
17
  import { watch } from "node:fs";
17
18
  import path from "node:path";
19
+ import { flagValue } from "./args.js";
18
20
  import { buildModule, readMetadata, resolveModuleDir } from "./build.js";
19
21
  import { loadConfig, resolveClassmap } from "./classmap.js";
20
- import { launchSpotify } from "./launch.js";
21
- import { formatPushResult, push, record } from "./push.js";
22
+ import { launchSpotify, waitForTarget } from "./launch.js";
23
+ import { formatPushResult, formatRemoveOutcome, push, record, removeLocal, resolvePort } from "./push.js";
22
24
  // Re-exported for callers that imported it from here before the push extraction.
23
25
  export { formatPushResult } from "./push.js";
24
- const USAGE = "spicetify-kit dev <module> [--launch] [--port 9229] [--once] [--classmap <key|path>] [--out <dir>]\n" +
25
- " --launch start (or reuse) Spotify with the remote-debugging port itself";
26
+ const USAGE = `spicetify-kit dev <module> [--no-launch] [--keep] [--once] [--port 9229] [--classmap <key|path>] [--out <dir>]
27
+ --no-launch do not start Spotify; wait for one already running with --remote-debugging-port
28
+ --keep leave the pushed override installed when dev stops
29
+ --once build and push once, then exit (the override stays)
30
+ --port CDP port (default 9229, or SPICETIFY_CDP_PORT)`;
31
+ // isSourceChange filters watcher events down to module sources. A standalone
32
+ // project's module dir is the project root, so the build's own dist/ output,
33
+ // node_modules, and dot-dirs all sit inside the watched tree.
34
+ export function isSourceChange(file, moduleDir, outDir) {
35
+ const segments = file.split(/[\\/]/);
36
+ if (segments.some((s) => s.startsWith(".") || s === "node_modules"))
37
+ return false;
38
+ const rel = path.relative(outDir, path.resolve(moduleDir, file));
39
+ return rel.startsWith("..") || path.isAbsolute(rel);
40
+ }
26
41
  export async function runDev(argv, cwd = process.cwd()) {
27
- const moduleArg = argv.find((a) => !a.startsWith("--"));
28
- const flag = (n) => {
29
- const i = argv.indexOf(`--${n}`);
30
- return i >= 0 ? argv[i + 1] : undefined;
31
- };
42
+ if (argv.includes("--help") || argv.includes("-h")) {
43
+ console.log(USAGE);
44
+ return;
45
+ }
46
+ const valueFlags = new Set(["--port", "--classmap", "--out"]);
47
+ const moduleArg = argv.find((a, i) => !a.startsWith("--") && !valueFlags.has(argv[i - 1]));
48
+ const flag = (n) => flagValue(argv, n);
32
49
  if (!moduleArg)
33
50
  throw new Error(USAGE);
34
- const port = flag("port") ?? "9229";
51
+ const port = resolvePort(flag("port"));
35
52
  const once = argv.includes("--once");
36
- if (argv.includes("--launch"))
53
+ const keep = once || argv.includes("--keep");
54
+ if (argv.includes("--no-launch")) {
55
+ console.log(`[dev] waiting for Spotify on port ${port} (started with --remote-debugging-port=${port})`);
56
+ await waitForTarget(port, Number.POSITIVE_INFINITY);
57
+ }
58
+ else {
37
59
  await launchSpotify(port);
60
+ }
38
61
  const config = loadConfig(cwd);
39
62
  const modulesDir = config.modulesDir ? path.resolve(cwd, config.modulesDir) : path.join(cwd, "modules");
40
63
  const outDir = flag("out") ?? (config.outDir ? path.resolve(cwd, config.outDir) : path.join(cwd, "dist"));
@@ -48,7 +71,11 @@ export async function runDev(argv, cwd = process.cwd()) {
48
71
  });
49
72
  if (!resolved.path)
50
73
  throw new Error("no classmap found (pass --classmap <key|path>)");
74
+ let pushed = false;
75
+ let stopping = false;
51
76
  const cycle = async () => {
77
+ if (stopping)
78
+ return;
52
79
  const started = Date.now();
53
80
  let distDir;
54
81
  try {
@@ -59,8 +86,13 @@ export async function runDev(argv, cwd = process.cwd()) {
59
86
  console.error(`[dev] build failed: ${e.message}`);
60
87
  return;
61
88
  }
89
+ if (stopping)
90
+ return;
62
91
  try {
63
- const raw = await push(record(distDir, id), id, port);
92
+ const pending = push(record(distDir, id), id, port);
93
+ // Set before the await: a ctrl-c mid-push must still remove what lands.
94
+ pushed = true;
95
+ const raw = await pending;
64
96
  const result = formatPushResult(raw);
65
97
  const line = `[dev] ${id} ${result.message} (${Date.now() - started}ms)`;
66
98
  if (result.ok)
@@ -72,13 +104,39 @@ export async function runDev(argv, cwd = process.cwd()) {
72
104
  console.error(`[dev] push failed: ${e.message}`);
73
105
  }
74
106
  };
75
- await cycle();
76
- if (once)
77
- return;
78
- console.log(`[dev] watching ${moduleDir} (ctrl-c to stop; removeLocal("${id}") drops the override)`);
107
+ let current = Promise.resolve();
79
108
  let timer;
109
+ const stop = async (code = 0) => {
110
+ if (stopping)
111
+ process.exit(130);
112
+ stopping = true;
113
+ clearTimeout(timer);
114
+ await current;
115
+ if (keep || !pushed)
116
+ process.exit(code);
117
+ try {
118
+ console.log(`[dev] ${formatRemoveOutcome(id, await removeLocal(id, port))}`);
119
+ }
120
+ catch (e) {
121
+ console.error(`[dev] could not remove the override: ${e.message}`);
122
+ console.error(`[dev] run \`spicetify-kit remove ${id}\` once Spotify is reachable`);
123
+ }
124
+ process.exit(code);
125
+ };
126
+ const onSignal = () => void stop();
127
+ process.on("SIGINT", onSignal);
128
+ process.on("SIGTERM", onSignal);
129
+ current = cycle();
130
+ await current;
131
+ if (once) {
132
+ process.off("SIGINT", onSignal);
133
+ process.off("SIGTERM", onSignal);
134
+ return;
135
+ }
136
+ const onExit = keep ? "the override stays installed" : "the override is removed";
137
+ console.log(`[dev] watching ${moduleDir} (ctrl-c to stop; ${onExit})`);
80
138
  let loggedDts = false;
81
- watch(moduleDir, { recursive: true }, (_event, file) => {
139
+ const watcher = watch(moduleDir, { recursive: true }, (_event, file) => {
82
140
  if (!file)
83
141
  return;
84
142
  // The build regenerates classmap.d.ts into the source dir on every run;
@@ -90,10 +148,16 @@ export async function runDev(argv, cwd = process.cwd()) {
90
148
  }
91
149
  return;
92
150
  }
93
- if (file.startsWith("."))
151
+ if (!isSourceChange(file, moduleDir, outDir))
94
152
  return;
95
153
  clearTimeout(timer);
96
- timer = setTimeout(() => void cycle(), 200);
154
+ timer = setTimeout(() => {
155
+ current = current.then(cycle);
156
+ }, 200);
157
+ });
158
+ watcher.on("error", (e) => {
159
+ console.error(`[dev] watcher failed: ${e.message}`);
160
+ void stop(1);
97
161
  });
98
162
  // Keep the process alive while the watcher runs.
99
163
  await new Promise(() => { });
package/dist/install.js CHANGED
@@ -12,7 +12,8 @@ import { execFileSync } from "node:child_process";
12
12
  import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
13
13
  import { tmpdir } from "node:os";
14
14
  import path from "node:path";
15
- import { formatPushResult, push, record } from "./push.js";
15
+ import { flagValue } from "./args.js";
16
+ import { formatPushResult, push, record, resolvePort } from "./push.js";
16
17
  const USAGE = "spicetify-kit install <zip|dist-dir> [--port 9229]\n (requires `unzip` on PATH for a .zip)";
17
18
  function unzipTo(zipPath, dest) {
18
19
  try {
@@ -24,11 +25,8 @@ function unzipTo(zipPath, dest) {
24
25
  }
25
26
  export async function runInstall(argv, cwd = process.cwd()) {
26
27
  const target = argv.find((a) => !a.startsWith("--"));
27
- const flag = (n) => {
28
- const i = argv.indexOf(`--${n}`);
29
- return i >= 0 ? argv[i + 1] : undefined;
30
- };
31
- const port = flag("port") ?? "9229";
28
+ const flag = (n) => flagValue(argv, n);
29
+ const port = resolvePort(flag("port"));
32
30
  if (!target)
33
31
  throw new Error(USAGE);
34
32
  const abs = path.resolve(cwd, target);
package/dist/launch.js CHANGED
@@ -16,9 +16,10 @@
16
16
  import { execFileSync, spawn } from "node:child_process";
17
17
  import { existsSync } from "node:fs";
18
18
  import path from "node:path";
19
+ import { portInUseMessage, probePort } from "./push.js";
19
20
  const APPX_MESSAGE = "Spotify is a Microsoft Store (AppX) install, which the kit cannot launch with a debug port: " +
20
21
  "it needs the spicetify-staged --app-directory, which only the CLI knows. " +
21
- "Start Spotify yourself with --remote-debugging-port, or run `spicetify-kit dev` without --launch.";
22
+ "Start Spotify yourself with --remote-debugging-port, then run `spicetify-kit dev --no-launch`.";
22
23
  // which returns the resolved path of a command on PATH, or null.
23
24
  function defaultWhich(cmd) {
24
25
  try {
@@ -38,7 +39,7 @@ export function discoverSpotify(platform = process.platform, env = process.env,
38
39
  const app = "/Applications/Spotify.app";
39
40
  if (exists(app))
40
41
  return { kind: "macos", app };
41
- throw new Error(`Spotify not found at ${app}. Install Spotify, or start it yourself with --remote-debugging-port and run dev without --launch.`);
42
+ throw new Error(`Spotify not found at ${app}. Install Spotify, or start it yourself with --remote-debugging-port and run dev with --no-launch.`);
42
43
  }
43
44
  if (platform === "win32") {
44
45
  // win32.join so paths use backslashes on any host (matters for tests).
@@ -53,30 +54,18 @@ export function discoverSpotify(platform = process.platform, env = process.env,
53
54
  const onPath = which("spotify");
54
55
  if (onPath)
55
56
  return { kind: "linux", exe: onPath };
56
- throw new Error("'spotify' not found on PATH. Install Spotify, or start it yourself with --remote-debugging-port and run dev without --launch.");
57
- }
58
- // hasXpuiTarget reports whether a debuggable xpui page is already listening
59
- // on the port, so an already-good client is reused rather than killed.
60
- export async function hasXpuiTarget(port) {
61
- try {
62
- const res = await fetch(`http://localhost:${port}/json/list`);
63
- if (!res.ok)
64
- return false;
65
- const targets = (await res.json());
66
- return targets.some((t) => t.url?.includes("xpui"));
67
- }
68
- catch {
69
- return false;
70
- }
57
+ throw new Error("'spotify' not found on PATH. Install Spotify, or start it yourself with --remote-debugging-port and run dev with --no-launch.");
71
58
  }
72
59
  // waitForTarget polls until an xpui debug target appears or the timeout
73
- // fires; the timeout error names the port and the flag so the remedy is
74
- // obvious.
75
- export async function waitForTarget(port, timeoutMs = 30_000) {
60
+ // fires. A foreign listener fails fast instead of burning the whole timeout.
61
+ export async function waitForTarget(port, timeoutMs = 30_000, probe = probePort) {
76
62
  const deadline = Date.now() + timeoutMs;
77
63
  while (Date.now() < deadline) {
78
- if (await hasXpuiTarget(port))
64
+ const state = await probe(port);
65
+ if (state.state === "xpui")
79
66
  return;
67
+ if (state.state === "other")
68
+ throw new Error(portInUseMessage(port, state.what));
80
69
  await new Promise((r) => setTimeout(r, 500));
81
70
  }
82
71
  throw new Error(`timed out waiting for a Spotify xpui debug target on port ${port}. ` +
@@ -101,11 +90,22 @@ function spawnDetached(cmd, args) {
101
90
  }
102
91
  // launchSpotify reuses a good client, otherwise (re)starts one with the
103
92
  // debug port and waits for its xpui target. Returns "reused" or "launched".
104
- export async function launchSpotify(port, log = console.log, platform = process.platform) {
105
- if (await hasXpuiTarget(port)) {
93
+ export async function launchSpotify(port, log = console.log, platform = process.platform, probe = probePort) {
94
+ const state = await probe(port);
95
+ if (state.state === "xpui") {
106
96
  log(`[dev] reusing the Spotify client already debuggable on port ${port}`);
107
97
  return "reused";
108
98
  }
99
+ // Killing Spotify would not free the port, so the relaunch could never bind it.
100
+ if (state.state === "other")
101
+ throw new Error(portInUseMessage(port, state.what));
102
+ if (state.state === "unknown")
103
+ throw new Error(portInUseMessage(port, "a process that is not a DevTools endpoint"));
104
+ if (state.state === "spotify") {
105
+ log(`[dev] Spotify is starting on port ${port}, waiting for it`);
106
+ await waitForTarget(port, 30_000, probe);
107
+ return "reused";
108
+ }
109
109
  const target = discoverSpotify(platform);
110
110
  if (target.kind === "appx")
111
111
  throw new Error(APPX_MESSAGE);
@@ -117,6 +117,6 @@ export async function launchSpotify(port, log = console.log, platform = process.
117
117
  spawnDetached("open", ["-a", target.app, "--args", portFlag]);
118
118
  else
119
119
  spawnDetached(target.exe, [portFlag]);
120
- await waitForTarget(port);
120
+ await waitForTarget(port, 30_000, probe);
121
121
  return "launched";
122
122
  }