diff --git a/modules/store/index.scss b/modules/store/index.scss index a2e00e1..c53c2a4 100644 --- a/modules/store/index.scss +++ b/modules/store/index.scss @@ -346,6 +346,12 @@ } } + // Keep the name clear of the corner update badge on installed cards; + // catalog cards have no badge, so they keep the full width. + &:not(.spicetify-store-card--catalog) .spicetify-store-card-name { + padding-right: 30px; + } + .spicetify-store-card-desc { margin: 0; color: var(--spice-subtext, #a7a7a7); @@ -576,6 +582,52 @@ } } +// The per-card update affordance, pinned to the card's top-right corner: a +// bare icon (the starburst carries its own "NEW" wording and colors), so a +// click installs and the tooltip states the direction. A button rather than a +// badge, because clicking installs. +.spicetify-store-update-badge { + position: absolute; + top: 8px; + right: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + background: none; + cursor: pointer; + z-index: 1; + + svg { + display: block; + width: 24px; + height: 24px; + } + + &:hover:not(:disabled) { + opacity: 0.85; + } + + &:disabled { + cursor: default; + opacity: 0.5; + } + + // Mid-update: pulse, since a static glyph cannot show progress. + &--busy { + animation: spicetify-store-update-pulse 1s ease-in-out infinite; + } +} + +@keyframes spicetify-store-update-pulse { + 50% { + opacity: 0.3; + } +} + // Authors appear only in the details dialog, which renders outside the card, // so this rule cannot be nested under it. Size is inherited so the line sits // with the dialog's body copy rather than reading as a card caption. diff --git a/modules/store/metadata.json b/modules/store/metadata.json index 44ac7b7..4dce908 100644 --- a/modules/store/metadata.json +++ b/modules/store/metadata.json @@ -1,7 +1,7 @@ { "name": "store", "kind": "app", - "version": "1.8.1", + "version": "1.9.0", "authors": ["spicetify"], "description": "Browse, install, and manage v3 modules from vaults", "entries": { diff --git a/modules/store/page.tsx b/modules/store/page.tsx index fab7453..00cf255 100644 --- a/modules/store/page.tsx +++ b/modules/store/page.tsx @@ -66,7 +66,7 @@ import { toast, } from "./runtime.ts"; import { loadPreviewBlob, previewRevision, prunePreviewCache } from "./previewCache.ts"; -import { pendingUpdates, runUpdates, stdlibRestartPending } from "./updates.ts"; +import { pendingUpdates, runUpdates, stdlibRestartPending, updateActionLabel } from "./updates.ts"; // ---------- lazily acquired stdlib bindings ---------- @@ -382,6 +382,8 @@ function ModuleDetails(props: { installLabel: string; installDisabled: boolean; canActivate: boolean; + // True while any install or update is in flight elsewhere on the page. + locked: boolean; onActivate: () => Promise; onInstall: () => Promise; onClose: () => void; @@ -465,7 +467,7 @@ function ModuleDetails(props: { )} + ); +} + function InstalledCard(props: { record: InstalledRecord; loaded: boolean; revokedReason: string | undefined; daemonReady: boolean; + update: VaultModule | undefined; + updateBusy: boolean; + updateDisabled: boolean; + updateReason: string | undefined; refresh: () => void; + onUpdate: () => void; onEdit: (existing: { id: string; name: string; css: string }) => void; }): ReactElement { const { record } = props; @@ -830,6 +893,16 @@ function InstalledCard(props: { return (
+ {props.update && ( + + )}

{record.metadata.name ?? id}

{version && {version}} @@ -930,7 +1003,11 @@ function StorePage(props: { api: PageApi }): ReactElement { }); const [status, setStatus] = React.useState(""); const [overlay, setOverlay] = React.useState(null); - const [updatingAll, setUpdatingAll] = React.useState(false); + // One install/update at a time. The value names what is running ("all" or + // a module id) for the UI; the ref is the guard, so a second click that + // lands before the re-render cannot start a competing install. + const [updating, setUpdating] = React.useState(false); + const updateLock = React.useRef(false); const [resetArmed, setResetArmed] = React.useState(false); // Module state changes outside this page (dev pushes, manager // actions); bumping the epoch re-reads live registry state, the React @@ -1060,6 +1137,10 @@ function StorePage(props: { api: PageApi }): ReactElement { .map((s: any) => [s.identifier, s]), ); const pending = pendingUpdates(catalog); + const pendingById = new Map(pending.map((mod) => [mod.id, mod])); + // A staged stdlib holds every other module's hot-swap back (see runInstall). + const stdlibHold = stdlibRestartPending(); + const anyUpdating = updating !== false; const activeTheme = installed.find( (r) => kindOf(r.metadata) === "theme" && @@ -1100,23 +1181,26 @@ function StorePage(props: { api: PageApi }): ReactElement { }, [catalog, registryEpoch]); const runInstall = async (mod: VaultModule) => { + // One install at a time: the ref guard is what keeps the gallery FABs, + // the details dialog, the per-card badges and Update all from racing. + if (updateLock.current) return; // Updating a running module hot-swaps it, and a staged stdlib update // means the swap would run against the old stdlib; fresh installs are // fine (a missing dependency restart-gates them instead). if (mod.id !== "stdlib" && installedVersions.get(mod.id) !== undefined && stdlibRestartPending()) { - toast( - stdlibDiskStaged() - ? "a stdlib update is staged: use the apply or repair control above before updating modules" - : "a stdlib update is staged: restart Spotify before updating modules", - ); + toast(stdlibHoldMessage()); return; } + updateLock.current = true; + setUpdating(mod.id); try { await installModule(mod, setStatus); } catch { // installModule already toasted the failure; just clear the progress line. setStatus(""); } finally { + updateLock.current = false; + setUpdating(false); refreshRegistry(); } }; @@ -1149,11 +1233,14 @@ function StorePage(props: { api: PageApi }): ReactElement { }; const updateAll = async () => { - setUpdatingAll(true); + if (updateLock.current) return; + updateLock.current = true; + setUpdating("all"); try { await runUpdates(pending, setStatus); } finally { - setUpdatingAll(false); + updateLock.current = false; + setUpdating(false); refreshRegistry(); } }; @@ -1371,7 +1458,7 @@ function StorePage(props: { api: PageApi }): ReactElement {