diff --git a/modules/manager/managedSpotify.tsx b/modules/manager/managedSpotify.tsx new file mode 100644 index 0000000..fa0f491 --- /dev/null +++ b/modules/manager/managedSpotify.tsx @@ -0,0 +1,195 @@ +import { + React, + type ManagedSpotifyCapability, + type ManagedSpotifyAvailability, + type ManagedSpotifySnapshot, +} from "/modules/stdlib/mod.ts"; +import { managedJobMessage } from "./managedSpotifyState.ts"; + +type CheckState = { kind: "checking" } | { kind: "error"; message: string } | ManagedSpotifyAvailability; + +const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +export const ManagedSpotifyUpdates = ({ + api, + daemonAvailable, + channel, + installed, +}: { + api: ManagedSpotifyCapability | undefined; + daemonAvailable: boolean; + channel: "stable" | "testing"; + installed: string | undefined; +}) => { + const [snapshot, setSnapshot] = React.useState(null); + const [connection, setConnection] = React.useState(null); + const [available, setAvailable] = React.useState({ kind: "checking" }); + const [submitting, setSubmitting] = React.useState(false); + const [actionError, setActionError] = React.useState(null); + const running = snapshot?.job.kind === "running"; + const terminalId = snapshot?.job.kind === "complete" || snapshot?.job.kind === "failed" ? snapshot.job.jobId : null; + const installation = snapshot?.installation.kind === "managed" ? snapshot.installation : null; + + React.useEffect(() => { + if (!api) return; + let cancelled = false; + let pending = false; + const poll = async () => { + if (pending) return; + pending = true; + try { + const next = await api.status(); + if (!cancelled) { + setSnapshot(next); + setConnection( + !next + ? "Apply a newer Spicetify build to manage package updates here." + : next.installation.kind === "unavailable" + ? next.installation.message + : null, + ); + } + } catch { + if (!cancelled) + setConnection("Waiting for the daemon. An accepted update continues when Spotify closes."); + } finally { + pending = false; + } + }; + void poll(); + const timer = setInterval(() => void poll(), 2000); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [api]); + + React.useEffect(() => { + if (!api || running) return; + let cancelled = false; + setAvailable({ kind: "checking" }); + void api.check().then( + (result) => { + if (!cancelled) setAvailable(result); + }, + (error) => { + if (!cancelled) setAvailable({ kind: "error", message: errorMessage(error) }); + }, + ); + return () => { + cancelled = true; + }; + }, [api, running, terminalId]); + + const check = async () => { + if (!api) return; + setAvailable({ kind: "checking" }); + try { + setAvailable(await api.check()); + } catch (error) { + setAvailable({ kind: "error", message: errorMessage(error) }); + } + }; + const update = async () => { + if ( + !api || + !installation || + running || + submitting || + !globalThis.confirm( + "Update Spotify and apply your customization? Spotify will restart when the replacement is ready.", + ) + ) + return; + setSubmitting(true); + setActionError(null); + try { + const admission = await api.update(); + setSnapshot((previous) => + previous + ? { ...previous, job: { kind: "running", jobId: admission.jobId, phase: "checking" } } + : previous, + ); + } catch (error) { + setActionError(errorMessage(error)); + } finally { + setSubmitting(false); + } + }; + const message = + available.kind === "checking" + ? "Checking the Linux package feed…" + : available.kind === "error" + ? `Could not check for updates: ${available.message}` + : available.kind === "current" + ? "Your managed Spotify installation is up to date." + : available.kind === "unavailable" + ? `Spotify ${available.version} is available, but cannot be installed yet. ${available.message}` + : `Spotify ${available.version} is ready to install.`; + const jobMessage = snapshot ? managedJobMessage(snapshot.job) : null; + return ( +
+
+

Updates

+
+
+ + installed {installation?.version ?? installed ?? "unknown"} + + Linux {installation?.channel ?? channel} + {"version" in available && ( + available {available.version} + )} +
+

+ Spicetify manages this Spotify installation. Package updates run only when you request them. Each update + verifies compatibility and reapplies your customization before switching. +

+ {api ? ( + <> +

{message}

+ {connection && ( +

+ {connection} +

+ )} + {jobMessage && ( +

+ {jobMessage} +

+ )} + {actionError && ( +

+ {actionError} +

+ )} +
+ + {available.kind === "ready" && ( + + )} +
+ + ) : ( +

+ Run spicetify spotify update in a terminal.{" "} + {daemonAvailable + ? "To enable this control, update Spicetify, run spicetify apply, and reopen Manager." + : "Start the Spicetify daemon to update here."} +

+ )} +
+ ); +}; diff --git a/modules/manager/managedSpotifyState.test.mts b/modules/manager/managedSpotifyState.test.mts new file mode 100644 index 0000000..44ebce5 --- /dev/null +++ b/modules/manager/managedSpotifyState.test.mts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { managedJobMessage } from "./managedSpotifyState.ts"; + +test("managed progress describes package preparation without claiming native blocking", () => { + assert.match(managedJobMessage({ kind: "running", jobId: "one", phase: "preparing" }) ?? "", /Preparing Spotify/); + assert.match(managedJobMessage({ kind: "running", jobId: "one", phase: "activating" }) ?? "", /restart/); + assert.doesNotMatch(managedJobMessage({ kind: "complete", jobId: "one" }) ?? "", /block/i); + assert.match( + managedJobMessage({ kind: "failed", jobId: "one", message: "checksum mismatch" }) ?? "", + /checksum mismatch/, + ); +}); diff --git a/modules/manager/managedSpotifyState.ts b/modules/manager/managedSpotifyState.ts new file mode 100644 index 0000000..2802d8d --- /dev/null +++ b/modules/manager/managedSpotifyState.ts @@ -0,0 +1,21 @@ +import type { ManagedSpotifyJob } from "/modules/stdlib/mod.ts"; + +export function managedJobMessage(job: ManagedSpotifyJob): string | null { + switch (job.kind) { + case "idle": + return null; + case "complete": + return "Spotify update finished. Your customization is applied."; + case "failed": + return `Spotify update failed: ${job.message}`; + case "running": { + const messages = { + checking: "Checking the selected Spotify package feed.", + downloading: "Downloading and verifying Spotify.", + preparing: "Preparing Spotify and applying your customization.", + activating: "Switching to the prepared installation. Spotify will restart.", + }; + return messages[job.phase]; + } + } +} diff --git a/modules/manager/metadata.json b/modules/manager/metadata.json index 692e5c9..c0503cc 100644 --- a/modules/manager/metadata.json +++ b/modules/manager/metadata.json @@ -1,7 +1,7 @@ { "name": "manager", "kind": "app", - "version": "1.3.3", + "version": "1.4.0", "authors": ["spicetify", "Afonso Jorge Ramos"], "description": "Manage spicetify from inside Spotify", "entries": { @@ -10,7 +10,7 @@ }, "hasMixins": false, "dependencies": { - "stdlib": "^1.11.0" + "stdlib": "^1.13.0" }, "hidden": true } diff --git a/modules/manager/page.tsx b/modules/manager/page.tsx index be97804..dd679ac 100644 --- a/modules/manager/page.tsx +++ b/modules/manager/page.tsx @@ -20,6 +20,7 @@ import { type SpotifyAvailabilityStatus, } from "./state.ts"; import { retryNotice } from "./notice.ts"; +import { ManagedSpotifyUpdates } from "./managedSpotify.tsx"; const M = () => client.modules; @@ -352,124 +353,133 @@ export const ManagerPage = () => { )} - {(() => { - const sup = effectiveSupport(state, support); - const advice = updateAdvice(state.spotifyVersion, sup); - const cmd = (text: string, label: string) => ( - - ); - // Every one of these restarts Spotify: apply rebuilds the served - // tree, and the update policy is patched into Spotify's binary, - // which cannot happen while it runs. - const run = (label: string, fn: () => Promise) => ( - - ); - const action = (label: string, method: DaemonMethod, fallback: string) => - daemon ? run(label, () => daemon[method]()) : cmd(fallback, label); - const daemonMessage = (() => { - switch (daemonProbe.kind) { - case "checking": - return "Checking the local daemon. These actions may be unavailable until the check finishes."; - case "unavailable": - return "Manager cannot reach the daemon. The buttons below copy terminal commands; they do not run them."; - case "availability-error": - return "Manager could not check whether the daemon is running. It will retry; until then, copy a terminal command below."; - case "support-error": - return "The daemon is running, but Manager could not check Update & Apply support. Block and allow still use the daemon, and Manager will retry the check."; - case "available": - return daemonProbe.updateAndApplySupported === true - ? "Update handling runs through the local daemon. Spotify restarts." - : daemonProbe.updateAndApplySupported === false - ? "One-step Update & Apply is unavailable on this platform or Spotify client. Choose allow, update Spotify normally, then run spicetify apply." - : "One-step Update & Apply needs a current Spicetify daemon and wrapper."; - } - })(); - const updateMessage = (() => { - switch (updateStatus.kind) { - case "idle": - return null; - case "accepted": - return "Update accepted. Spotify's updater is starting."; - case "waiting-for-update": - return "Waiting for Spotify to offer the verified update."; - case "downloading": - return `Downloading Spotify ${updateStatus.targetVersion}.`; - case "installing-spotify": - return `Installing Spotify ${updateStatus.targetVersion}. Spotify will restart.`; - case "applying-spicetify": - return `Spotify ${updateStatus.targetVersion} is installed; reapplying the customization.`; - case "securing": - return updateStatus.message ?? "Restoring and verifying the Spotify update block."; - case "complete": - return `Last update completed: Spotify ${updateStatus.fromVersion} → ${updateStatus.toVersion}. Spicetify was reapplied and the update block restored.`; - case "failed-safe": - return `Last update attempt stopped safely: ${updateStatus.message}`; - } - })(); - return ( -
-
-

Updates

-
-
- installed {show(state.spotifyVersion)} - - supported {show(sup?.supportedSpotify)} - - available {show(sup?.latestSpotify)} -
-

- {advice.message} -

- {state.classmapFallback && ( -

- Running on a fallback classmap: this Spotify build has no verified classmap yet, so some - chrome may be off. It self-heals once one ships. -

- )} -

{daemonMessage}

- {advice.kind === "ready" && updateAndApplySupported === null && ( -

{SPICETIFY_UPGRADE.instructions}

- )} - {updateMessage && ( -

- {updateMessage} + {state.managedSpotify ? ( + + ) : ( + (() => { + const sup = effectiveSupport(state, support); + const advice = updateAdvice(state.spotifyVersion, sup); + const cmd = (text: string, label: string) => ( + + ); + // Every one of these restarts Spotify: apply rebuilds the served + // tree, and the update policy is patched into Spotify's binary, + // which cannot happen while it runs. + const run = (label: string, fn: () => Promise) => ( + + ); + const action = (label: string, method: DaemonMethod, fallback: string) => + daemon ? run(label, () => daemon[method]()) : cmd(fallback, label); + const daemonMessage = (() => { + switch (daemonProbe.kind) { + case "checking": + return "Checking the local daemon. These actions may be unavailable until the check finishes."; + case "unavailable": + return "Manager cannot reach the daemon. The buttons below copy terminal commands; they do not run them."; + case "availability-error": + return "Manager could not check whether the daemon is running. It will retry; until then, copy a terminal command below."; + case "support-error": + return "The daemon is running, but Manager could not check Update & Apply support. Block and allow still use the daemon, and Manager will retry the check."; + case "available": + return daemonProbe.updateAndApplySupported === true + ? "Update handling runs through the local daemon. Spotify restarts." + : daemonProbe.updateAndApplySupported === false + ? "One-step Update & Apply is unavailable on this platform or Spotify client. Choose allow, update Spotify normally, then run spicetify apply." + : "One-step Update & Apply needs a current Spicetify daemon and wrapper."; + } + })(); + const updateMessage = (() => { + switch (updateStatus.kind) { + case "idle": + return null; + case "accepted": + return "Update accepted. Spotify's updater is starting."; + case "waiting-for-update": + return "Waiting for Spotify to offer the verified update."; + case "downloading": + return `Downloading Spotify ${updateStatus.targetVersion}.`; + case "installing-spotify": + return `Installing Spotify ${updateStatus.targetVersion}. Spotify will restart.`; + case "applying-spicetify": + return `Spotify ${updateStatus.targetVersion} is installed; reapplying the customization.`; + case "securing": + return updateStatus.message ?? "Restoring and verifying the Spotify update block."; + case "complete": + return `Last update completed: Spotify ${updateStatus.fromVersion} → ${updateStatus.toVersion}. Spicetify was reapplied and the update block restored.`; + case "failed-safe": + return `Last update attempt stopped safely: ${updateStatus.message}`; + } + })(); + return ( +

+
+

Updates

+
+
+ installed {show(state.spotifyVersion)} + + supported {show(sup?.supportedSpotify)} + + available {show(sup?.latestSpotify)} +
+

+ {advice.message}

- )} -
- {action("block", "blockUpdates", "spicetify spotify-updates block")} - {action("allow", "unblockUpdates", "spicetify spotify-updates unblock")} - {advice.kind === "ready" && - (updateAndApplySupported && daemon?.updateAndApply - ? run("update & apply", async () => { - const admission = await daemon.updateAndApply!(); - return admission.disposition === "joined" - ? "joined existing update" - : "update accepted"; - }) - : updateAndApplySupported === null - ? cmd(SPICETIFY_UPGRADE.command, SPICETIFY_UPGRADE.label) - : cmd("spicetify apply", "copy apply command"))} -
-
- ); - })()} + {state.classmapFallback && ( +

+ Running on a fallback classmap: this Spotify build has no verified classmap yet, so + some chrome may be off. It self-heals once one ships. +

+ )} +

{daemonMessage}

+ {advice.kind === "ready" && updateAndApplySupported === null && ( +

{SPICETIFY_UPGRADE.instructions}

+ )} + {updateMessage && ( +

+ {updateMessage} +

+ )} +
+ {action("block", "blockUpdates", "spicetify spotify-updates block")} + {action("allow", "unblockUpdates", "spicetify spotify-updates unblock")} + {advice.kind === "ready" && + (updateAndApplySupported && daemon?.updateAndApply + ? run("update & apply", async () => { + const admission = await daemon.updateAndApply!(); + return admission.disposition === "joined" + ? "joined existing update" + : "update accepted"; + }) + : updateAndApplySupported === null + ? cmd(SPICETIFY_UPGRADE.command, SPICETIFY_UPGRADE.label) + : cmd("spicetify apply", "copy apply command"))} +
+
+ ); + })() + )}

Modules

diff --git a/modules/manager/state.ts b/modules/manager/state.ts index b58715d..ace4de4 100644 --- a/modules/manager/state.ts +++ b/modules/manager/state.ts @@ -30,6 +30,7 @@ export interface ManagerState { classmapKey?: string; cliVersion?: string; updatesBlocked?: boolean; + managedSpotify?: "stable" | "testing"; classmapSpotify?: string; classmapVerified?: boolean; supportedSpotify?: string; @@ -53,6 +54,7 @@ type Manifest = { classmapKey?: string; cliVersion?: string; updatesBlocked?: boolean; + managedSpotify?: "stable" | "testing"; classmapSpotify?: string; classmapVerified?: boolean; supportedSpotify?: string; @@ -90,6 +92,7 @@ export function deriveManagerState(): ManagerState { classmapKey: manifest?.classmapKey, cliVersion: manifest?.cliVersion, updatesBlocked: manifest?.updatesBlocked, + managedSpotify: manifest?.managedSpotify, classmapSpotify: spotifyVersionLine(manifest?.classmapSpotify), classmapVerified: manifest?.classmapVerified, supportedSpotify: spotifyVersionLine(manifest?.supportedSpotify), diff --git a/modules/stdlib/metadata.json b/modules/stdlib/metadata.json index c985d23..69fecbe 100644 --- a/modules/stdlib/metadata.json +++ b/modules/stdlib/metadata.json @@ -3,7 +3,7 @@ "kind": "lib", "hidden": true, "preview": "./assets/PREVIEW.png", - "version": "1.12.0", + "version": "1.13.0", "compat": ["0.3.0"], "authors": ["spicetify", "Delusoire"], "description": "The standard library", diff --git a/modules/stdlib/src/client.ts b/modules/stdlib/src/client.ts index cfbe2c0..dfc8116 100644 --- a/modules/stdlib/src/client.ts +++ b/modules/stdlib/src/client.ts @@ -32,6 +32,34 @@ export interface DaemonCapabilities { unblockUpdates(): Promise; updateAndApplySupported?(): Promise; updateAndApply?: UpdateAndApplyCapability; + managedSpotify?: ManagedSpotifyCapability; +} + +export type ManagedSpotifyJob = + | { kind: "idle" } + | { kind: "running"; jobId: string; phase: "checking" | "downloading" | "preparing" | "activating" } + | { kind: "complete"; jobId: string } + | { kind: "failed"; jobId: string; message: string }; + +export type ManagedSpotifyInstallation = + | { kind: "external" } + | { kind: "unavailable"; message: string } + | { kind: "managed"; version: string; channel: "stable" | "testing"; nativeBlocked: boolean | null }; + +export type ManagedSpotifyAvailability = + | { kind: "current"; version: string } + | { kind: "ready"; version: string } + | { kind: "unavailable"; version: string; message: string }; + +export interface ManagedSpotifySnapshot { + installation: ManagedSpotifyInstallation; + job: ManagedSpotifyJob; +} + +export interface ManagedSpotifyCapability { + status(): Promise; + check(): Promise; + update(): Promise; } export type UpdateFailureCode = diff --git a/modules/store/metadata.json b/modules/store/metadata.json index 5deb590..164e6c4 100644 --- a/modules/store/metadata.json +++ b/modules/store/metadata.json @@ -1,7 +1,7 @@ { "name": "store", "kind": "app", - "version": "1.7.7", + "version": "1.7.8", "authors": ["spicetify"], "description": "Browse, install, and manage v3 modules from vaults", "entries": { diff --git a/packages/kit/package.json b/packages/kit/package.json index 66232af..b9fc240 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -39,7 +39,7 @@ "access": "public" }, "spicetify": { - "stdlibVersion": "1.12.0" + "stdlibVersion": "1.13.0" }, "scripts": { "build": "tsc -p tsconfig.build.json", diff --git a/scripts/settings-page.test.mts b/scripts/settings-page.test.mts index 22de572..21a44fc 100644 --- a/scripts/settings-page.test.mts +++ b/scripts/settings-page.test.mts @@ -148,7 +148,7 @@ describe("standalone Spicetify Settings", () => { compareVersions(manager.version, "1.3.0") >= 0, "Manager must include the standalone settings contract", ); - assert.equal(manager.dependencies.stdlib, "^1.11.0"); + assert.equal(manager.dependencies.stdlib, "^1.13.0"); const installedMinor = Number(stdlib.version.match(/^1\.(\d+)\.\d+$/)?.[1]); const requiredMinor = Number(lyricsPlus.dependencies.stdlib.match(/^\^1\.(\d+)\.\d+$/)?.[1]); assert.ok(installedMinor >= requiredMinor, "Lyrics Plus must accept the installed stdlib");