From b9e60bdb0ab1fc2e1ca0a601c001dbbc1252beb3 Mon Sep 17 00:00:00 2001 From: Logan Date: Sun, 30 Aug 2026 06:40:32 -0400 Subject: [PATCH] fix(shell): dismiss the wizard when a library exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-run wizard dismissed itself as a step in its own flow, so a library appearing by any other route left a full-screen modal up over an app that was already set up — intercepting every pointer event, which is also what keeps Settings out of reach while it is there. AddLibrary emits LibraryAdded whoever calls it, so one subscription makes the dismissal follow the state the wizard exists to wait for rather than the button being pressed. It is registered before the initial read, or a library arriving while that call is in flight is answered with a stale empty list; both routes out now end in one dismiss(), which sets `finished` before asking the dialog to close because preventClose cancels the hide otherwise. Closes #175 --- .../first-run-wizard/first-run-wizard.ts | 47 ++++++- .../test/components/first-run-wizard.test.ts | 123 ++++++++++++++++++ 2 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 frontend/test/components/first-run-wizard.test.ts diff --git a/frontend/src/components/first-run-wizard/first-run-wizard.ts b/frontend/src/components/first-run-wizard/first-run-wizard.ts index 447ac6b..cdc7072 100644 --- a/frontend/src/components/first-run-wizard/first-run-wizard.ts +++ b/frontend/src/components/first-run-wizard/first-run-wizard.ts @@ -2,12 +2,14 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, state, query } from 'lit/decorators.js'; import '@awesome.me/webawesome/dist/components/dialog/dialog.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; +import { EventsOn } from '@runtime/runtime'; import { AddLibrary, GetAllLibrariesWithTrackCounts, } from '@go/library/library.js'; import { describeError, explainError } from '@utils/describe-error'; import { nameDialogsIn } from '@utils/name-dialog'; +import { Events } from '../../events'; import { pickDirectory } from '../../utils/pick-directory'; /** @@ -19,6 +21,13 @@ import { pickDirectory } from '../../utils/pick-directory'; * prompting the user to pick their music folder, registers it through the * library CRUD API, and dismisses itself. AddLibrary emits LibraryAdded * and kicks off the initial scan automatically. + * + * **The dismissal follows the library existing, not the button being + * pressed.** `AddLibrary` emits `LibraryAdded` whoever calls it, so the + * wizard waits on the state it exists to wait for rather than on a step + * in its own flow — a library arriving by any other route (Settings, a + * direct call) leaves a full-screen modal up otherwise, intercepting + * every pointer event. */ @customElement('first-run-wizard') export class FirstRunWizard extends LitElement { @@ -37,9 +46,18 @@ export class FirstRunWizard extends LitElement { /** Error message from a failed pick/save, if any. */ @state() private errorMessage = ''; + /** Unsubscribe from LibraryAdded, while this element is connected. */ + private cancelLibraryAdded?: () => void; + override async connectedCallback(): Promise { super.connectedCallback(); + // Subscribed before the read below, so a library arriving while + // that call is in flight is not answered with a stale empty list. + this.cancelLibraryAdded = EventsOn(Events.LibraryAdded, () => { + this.dismiss(); + }); + try { const existing = await GetAllLibrariesWithTrackCounts(); @@ -54,6 +72,8 @@ export class FirstRunWizard extends LitElement { return; } + if (this.finished) return; + this.active = true; await this.updateComplete; @@ -61,6 +81,13 @@ export class FirstRunWizard extends LitElement { if (this.dialog) this.dialog.open = true; } + override disconnectedCallback(): void { + this.cancelLibraryAdded?.(); + this.cancelLibraryAdded = undefined; + + super.disconnectedCallback(); + } + static override styles = css` wa-dialog { --width: 480px; @@ -239,6 +266,20 @@ export class FirstRunWizard extends LitElement { if (!this.finished) e.preventDefault(); }; + /** + * Close, and stay closed: a library exists, so setup is over. + * + * `finished` is set first, or `preventClose` cancels the hide this + * asks for. + */ + private dismiss(): void { + this.finished = true; + + if (this.dialog) this.dialog.open = false; + + this.active = false; + } + private handleChoose = async (): Promise => { this.errorMessage = ''; @@ -264,11 +305,7 @@ export class FirstRunWizard extends LitElement { try { await AddLibrary(this.selectedDirectory); - this.finished = true; - - if (this.dialog) this.dialog.open = false; - - this.active = false; + this.dismiss(); } catch (err) { this.errorMessage = explainError( err, diff --git a/frontend/test/components/first-run-wizard.test.ts b/frontend/test/components/first-run-wizard.test.ts new file mode 100644 index 0000000..c2d6244 --- /dev/null +++ b/frontend/test/components/first-run-wizard.test.ts @@ -0,0 +1,123 @@ +/** + * #175: the first-run wizard's dismissal follows the library existing, + * not its own button being pressed. + * + * The wizard is a modal that blocks every pointer event, so a library + * arriving by another route — Settings, a direct call — used to leave + * it up over an app that was already set up. `LibraryAdded` is emitted + * by `AddLibrary` whoever calls it, which is what makes one + * subscription the whole fix. + */ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { Events } from '../../src/events'; +import { emit, stub } from '../support/harness'; +import { fixture, shadow, shadowAll } from '../support/render'; +import { wails } from '../support/wails-fake'; + +import '@components/first-run-wizard/first-run-wizard'; + +import type { FirstRunWizard } from '@components/first-run-wizard/first-run-wizard'; + +/** A library row, as `GetAllLibrariesWithTrackCounts` returns one. */ +const aLibrary = { + id: 1, + name: 'Music', + path: '/home/logan/Music', + trackCount: 9, +}; + +/** Mount the wizard on a fresh install: no libraries yet. */ +async function wizardOnAFreshInstall(): Promise { + stub('library.Library.GetAllLibrariesWithTrackCounts', []); + + return fixture('first-run-wizard'); +} + +/** Whether the wizard is rendering its modal at all. */ +function isShowing(el: FirstRunWizard): boolean { + return shadow(el, 'wa-dialog') !== null; +} + +beforeEach(() => { + stub('library.Library.AddLibrary', aLibrary); +}); + +describe('first-run-wizard', () => { + it('shows on a fresh install and stays up until a library exists', async () => { + const el = await wizardOnAFreshInstall(); + + expect(isShowing(el)).toBe(true); + }); + + it('stays hidden when a library is already configured', async () => { + stub('library.Library.GetAllLibrariesWithTrackCounts', [aLibrary]); + + const el = await fixture('first-run-wizard'); + + expect(isShowing(el)).toBe(false); + }); + + it('dismisses when a library appears by another route', async () => { + const el = await wizardOnAFreshInstall(); + + expect(isShowing(el)).toBe(true); + + emit(Events.LibraryAdded, aLibrary); + await el.updateComplete; + + expect(isShowing(el)).toBe(false); + }); + + it('does not raise itself when a library arrives while it is asking', async () => { + // The read is still in flight when the event lands, so its + // answer — an empty list — is stale by the time it returns. + let answer: (libraries: unknown[]) => void = () => {}; + + stub( + 'library.Library.GetAllLibrariesWithTrackCounts', + () => + new Promise((resolve) => { + answer = resolve; + }), + ); + + const el = await fixture('first-run-wizard'); + + emit(Events.LibraryAdded, aLibrary); + answer([]); + + await el.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await el.updateComplete; + + expect(isShowing(el)).toBe(false); + }); + + it('still dismisses through its own Get Started button', async () => { + stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', true); + stub('frontendutil.FrontendUtil.DirectoryPicker', '/home/logan/Music'); + + const { resetDirectoryPickerCache } = await import( + '@utils/pick-directory' + ); + + resetDirectoryPickerCache(); + + const el = await wizardOnAFreshInstall(); + const [choose, finish] = shadowAll(el, '.btn'); + + choose?.click(); + await new Promise((r) => setTimeout(r, 0)); + await el.updateComplete; + + finish?.click(); + await new Promise((r) => setTimeout(r, 0)); + await el.updateComplete; + + expect( + wails.calls.filter((c) => c.path === 'library.Library.AddLibrary'), + ).toHaveLength(1); + expect(isShowing(el)).toBe(false); + }); +}); -- 2.54.0