From 43c2603a917d93dcba74c6dd867ddc286c0f29d2 Mon Sep 17 00:00:00 2001 From: Steven Vandevelde Date: Tue, 18 Aug 2026 17:21:02 +0200 Subject: [PATCH] fix: various race conditions related to output --- src/common/facets/constants.js | 23 ++++++---- src/common/facets/utils.js | 45 +++++++++++++++++++ src/components/configurator/output/element.js | 40 ++++++++++++++++- .../output/raw/atproto-passkey/oauth.js | 7 ++- .../refiner/initial-contents/element.js | 35 +-------------- src/l/index.js | 6 +++ 6 files changed, 112 insertions(+), 44 deletions(-) diff --git a/src/common/facets/constants.js b/src/common/facets/constants.js index 85e20a5e..9519ffe9 100644 --- a/src/common/facets/constants.js +++ b/src/common/facets/constants.js @@ -8,29 +8,36 @@ */ export const TYPE = /** @type {const} */ ("sh.diffuse.output.facet"); -export const STARTING_SET_DISABLED = [ - "facets/misc/scrobble/index.html", -]; - -export const STARTING_SET_URIS = [ - // INTERACTIVE +export const INTERACTIVE = [ "facets/connect/index.html", "facets/data/sources/index.html", "facets/data/file-manager/index.html", "facets/themes/blur/artwork-controller/facet/index.html", "facets/themes/blur/facet/index.html", "facets/themes/winamp/facet/index.html", +] - // PRELUDES (BASE) +export const PRELUDE_BASE = [ "facets/data/metadata-bundle/index.html", "facets/data/artwork-bundle/index.html", "facets/data/input-bundle/index.html", "facets/data/output-bundle/index.html", "facets/data/upload-bundle/index.html", "facets/playback/preload/prelude/index.html", +]; - // PRELUDES +export const PRELUDE_OTHER = [ "facets/data/process-tracks/prelude/index.html", "facets/misc/scrobble/index.html", "facets/playback/auto-queue/prelude/index.html", +] + +export const STARTING_SET_DISABLED = [ + "facets/misc/scrobble/index.html", +]; + +export const STARTING_SET_URIS = [ + ...PRELUDE_BASE, + ...PRELUDE_OTHER, + ...INTERACTIVE, ]; diff --git a/src/common/facets/utils.js b/src/common/facets/utils.js index d1c3454a..4952e415 100644 --- a/src/common/facets/utils.js +++ b/src/common/facets/utils.js @@ -1,12 +1,52 @@ import * as TID from "@atcute/tid"; +import { xxh32r } from "xxh32/dist/raw.js"; import { loadURI } from "../loader.js"; import * as CID from "../cid.js"; +import { STARTING_SET_DISABLED, TYPE } from "./constants.js"; + +import facetsData from "~/_data/facets.json" with { type: "json" }; + /** * @import {Facet} from "~/definitions/types.d.ts" */ +/** + * Builds facet objects from the bundled catalogue for the given URIs. + * Available offline — no output needs to be reachable. + * + * @param {readonly string[]} uris + * @returns {Facet[]} + * + * @example Builds a facet for each known URI, skipping unknown ones + * ```js + * import { buildFacets } from "~/common/facets/utils.js"; + * import { PRELUDE_BASE } from "~/common/facets/constants.js"; + * + * const facets = buildFacets([...PRELUDE_BASE, "facets/does/not/exist.html"]); + * if (facets.length !== PRELUDE_BASE.length) throw new Error("should skip unknown URIs"); + * if (!facets.every((f) => f.kind === "prelude")) throw new Error("base preludes should be preludes"); + * if (facets.some((f) => !f.id)) throw new Error("every facet needs an id"); + * ``` + */ +export function buildFacets(uris) { + return facetsData.flatMap((facet) => { + if (!uris.includes(facet.url)) return []; + + return [{ + $type: TYPE, + id: uriToRkey("diffuse://" + facet.url), + description: facet.desc, + enabled: STARTING_SET_DISABLED.includes(facet.url) ? false : true, + kind: facet.kind === "prelude" ? "prelude" : "interactive", + name: facet.title, + tags: facet.tags?.length ? facet.tags : undefined, + uri: "diffuse://" + facet.url, + }]; + }); +} + /** * @param {{ description?: string; kind: string | undefined; name: string; tags?: string[]; uri: string }} _args * @param {{ fetchHTML: boolean }} options @@ -82,3 +122,8 @@ export async function facetFromURI( return facet; } + +/** @param {string} uri */ +function uriToRkey(uri) { + return xxh32r(new TextEncoder().encode(uri)).toString(16).padStart(8, "0"); +} diff --git a/src/components/configurator/output/element.js b/src/components/configurator/output/element.js index dd8c00fe..c18db5a8 100644 --- a/src/components/configurator/output/element.js +++ b/src/components/configurator/output/element.js @@ -1,6 +1,9 @@ import { BroadcastableDiffuseElement, defineElement } from "~/common/element.js"; import { batch, computed, signal } from "~/common/signal.js"; +import { PRELUDE_BASE } from "~/common/facets/constants.js"; +import { buildFacets } from "~/common/facets/utils.js"; + /** * @import {DiffuseElement} from "~/common/element.js" * @import {Facet, PlaylistItem, Setting, Track} from "~/definitions/types.d.ts" @@ -36,7 +39,24 @@ class OutputConfigurator extends BroadcastableDiffuseElement { if (out) return out.facets.collection(); const def = this.#defaultOutput.value; - if (def) return def.facets.collection(); + if (def) { + const col = def.facets.collection(); + + // Recover from the bootstrap deadlock: a non-default output is + // selected (but its elements aren't created yet, hence `out` is + // null) and the local bucket has no prelude facets to inject the + // output-bundle prelude from. Seed the base infrastructure + // preludes so the page can bootstrap the selected output. + if (col.state === "loaded" && this.#needsPreludeRecovery(col.data)) { + return { + state: "loaded", + data: [...col.data, ...buildFacets(PRELUDE_BASE)], + }; + } + + return col; + } + if (this.hasDefault()) return { state: "loading" }; return this.#setupFinished.value @@ -333,6 +353,24 @@ class OutputConfigurator extends BroadcastableDiffuseElement { return /** @type {Output} */ (/** @type {unknown} */ (el)); } + /** + * Whether the local bucket needs the base infrastructure preludes seeded + * into the fallback. + * + * @param {Facet[]} localFacets + * @returns {boolean} + */ + #needsPreludeRecovery(localFacets) { + const selectedId = this.#selectedOutputId(); + const defaultId = this.getAttribute("default"); + + // Only a non-default selection depends on the prelude to be created. + if (!selectedId || selectedId === defaultId) return false; + + // If the local bucket already has preludes, the normal path works. + return !localFacets.some((f) => f.kind === "prelude"); + } + /** * @param {string | null} id */ diff --git a/src/components/output/raw/atproto-passkey/oauth.js b/src/components/output/raw/atproto-passkey/oauth.js index 95e65e88..fdbe6e14 100644 --- a/src/components/output/raw/atproto-passkey/oauth.js +++ b/src/components/output/raw/atproto-passkey/oauth.js @@ -124,10 +124,13 @@ export function restoreOrFinalize() { params.has("code") && localStorage.getItem("oauth/pending-client") === CLIENT_KEY ) { - localStorage.removeItem("oauth/pending-client"); - + // Finalize first; only clear the pending marker once it succeeds. If the + // page reloads mid-exchange (e.g. a prelude change), the marker survives + // so the reloaded page can retry instead of silently dropping the login. const result = await finalizeAuthorization(params); + localStorage.removeItem("oauth/pending-client"); + // Clean up URL (remove fragment containing OAuth params) history.replaceState(null, "", loc.pathname + loc.search); diff --git a/src/components/transformer/output/refiner/initial-contents/element.js b/src/components/transformer/output/refiner/initial-contents/element.js index 4238c449..69286d8d 100644 --- a/src/components/transformer/output/refiner/initial-contents/element.js +++ b/src/components/transformer/output/refiner/initial-contents/element.js @@ -1,18 +1,13 @@ import * as IDB from "idb-keyval"; -import { xxh32r } from "xxh32/dist/raw.js"; import { batch, computed, signal, untracked } from "~/common/signal.js"; import { OutputTransformer } from "../../base.js"; import { defineElement } from "~/common/element.js"; import { - STARTING_SET_DISABLED, STARTING_SET_URIS, - TYPE, } from "~/common/facets/constants.js"; -import facets from "~/_data/facets.json" with { - type: "json", -}; +import { buildFacets } from "~/common/facets/utils.js"; /** * @import {OutputManagerDeputy} from "@specs/components/output/types.d.ts" @@ -78,28 +73,7 @@ class InitialContentsTransformer extends OutputTransformer { } // Determine starting set - const data = facets.flatMap((facet) => { - if (STARTING_SET_URIS.includes(facet.url)) { - return [{ - $type: TYPE, - id: uriToRkey("diffuse://" + facet.url), - description: facet.desc, - enabled: STARTING_SET_DISABLED.includes(facet.url) - ? false - : true, - kind: facet.kind === "prelude" - ? /** @type {const} */ ("prelude") - : /** @type {const} */ ("interactive"), - name: facet.title, - tags: facet.tags?.length ? facet.tags : undefined, - uri: "diffuse://" + facet.url, - }]; - } - - return []; - }); - - return { state: "loaded", data }; + return { state: "loaded", data: buildFacets(STARTING_SET_URIS) }; }), save: async (newFacets) => { @@ -130,11 +104,6 @@ class InitialContentsTransformer extends OutputTransformer { export default InitialContentsTransformer; -/** @param {string} uri */ -function uriToRkey(uri) { - return xxh32r(new TextEncoder().encode(uri)).toString(16).padStart(8, "0"); -} - //////////////////////////////////////////// // REGISTER //////////////////////////////////////////// diff --git a/src/l/index.js b/src/l/index.js index ddeff02c..3879dd4b 100644 --- a/src/l/index.js +++ b/src/l/index.js @@ -33,6 +33,12 @@ const preludeKey = computed(() => { let initialPreludeKey = /** @type {string | null} */ (null); effect(() => { + // Never reload mid-OAuth-callback: a prelude change (e.g. synthesized + // recovery preludes being replaced by the real synced set) would otherwise + // abort the in-flight `finalizeAuthorization` token exchange. + const params = new URLSearchParams(window.location.hash.slice(1)); + if (params.has("code") || params.has("state")) return; + const key = preludeKey(); if (key === null) return; if (initialPreludeKey === null) { initialPreludeKey = key; return; } -- 2.51.2