From d0b3ee72bb2eb426cdce806b39ece6590c911545 Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Sun, 20 Sep 2026 21:55:35 -0700 Subject: [PATCH 01/27] feat(core-beta): tell the user once when a beta feature turns on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PostHog grant can turn a Core beta flag on without the user ever being told. This adds a nonblocking, once-per-feature heads-up when that happens, with a link to the switch that turns it back off. The card is raised from `reportCoreBetaLaunch`, the existing once-per-launch latch past the last cancellation gate: a grant is only worth announcing once it has cleared BOTH the version window and the running core's args schema, so the notice reads `applied` rather than the selected set and inherits both gates. `--disable-*` grants stay silent — the copy says a feature is on and points at the opt-out, which is the opposite of what a remote force-off did. Arming RECONCILES rather than appends: it is reached before the spawn is known to have succeeded, so the next launch of an install is the authority on what is actually on its command line. Without that, a beta launch that failed to boot left a claim behind, and relaunching with beta switched off would raise a card saying a beta feature was on — pointing at a switch the user had just flipped. Dropping a claim also releases it for other installs. Persistence is a per-arg list (`betaNoticeAnnouncedArgs`) rather than a boolean, so a beta feature granted months from now still gets its own heads-up; it is append-only, so a grant revoked and re-granted stays silent the second time. It is written when the user RETIRES the card, never when it is merely shown, so a notice nobody saw replays instead of being spent. The list is cached in memory because `settings.get` re-reads and re-parses the whole file, and its retry path blocks on `Atomics.wait` — which arming would otherwise pay on the spawn critical path. Display state is scoped to the install the card was raised for, not to the renderer. The title bar survives attach/detach without a reload, so a window that has shown one install's card must still show another's, and retiring must acknowledge the install the card was RAISED for — acknowledging whatever the host happens to be pointing at now would permanently consume a notice the user never saw. The card reuses the sticky coachmark popup, which grows a `kind` (so one popup can serve two owners and route retirements correctly), an optional secondary action, and an ownership check — the onboarding hint's retire path hides that popup unconditionally, which would otherwise pull down a card it never raised. The beak is positioned from the anchor rather than fixed at the card's centre, so it keeps pointing at the bell when the card clamps against a window edge; an e2e assertion pins that alignment numerically, since a screenshot is evidence and not a guarantee. The action opens Global Settings on the beta opt-in row, scrolls to it and flashes it — landing on the tab alone leaves the row below the fold. Either button retires the card: acting on it is acknowledging it. E2E is tagged `@linux @macos`, never `@windows`: the interpreter stub cannot be a PE executable, so the launch it drives can never start there — the same exclusion `e2e/comfybuilder-launch.test.ts` documents. The grant is seeded through `E2E_OPS_FLAGS_SEED`, mirroring the existing `E2E_SETTINGS_SEED` hook, because the harness cannot place that file itself: it resolves to Electron's `userData`, and macOS ignores the HOME override for it. Screenshots go to Playwright's output dir and the HTML report rather than into the tree. Nothing here touches enrolment. Retiring the card does not leave the beta; it only stops the telling. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 + e2e/beta-activation-notice.test.ts | 268 ++++++++++++++++++ e2e/support/electronHarness.ts | 29 +- e2e/support/fakeComfyInstall.ts | 162 +++++++++++ e2e/support/windowCapture.ts | 113 ++++++++ locales/en.json | 6 +- locales/zh.json | 6 +- src/main/lib/betaActivationNotice.test.ts | 203 +++++++++++++ src/main/lib/betaActivationNotice.ts | 155 ++++++++++ src/main/lib/ipc/registerSettingsHandlers.ts | 15 + .../lib/ipc/sessionActions/launch.test.ts | 57 ++++ src/main/lib/ipc/sessionActions/launch.ts | 8 + src/main/lib/opsFlag.ts | 35 +++ src/main/popups/titleCoachmark.test.ts | 96 +++++++ src/main/popups/titleCoachmark.ts | 111 ++++++-- src/main/popups/titlePopup.ts | 92 +++--- src/main/settings.ts | 7 + src/preload/api.ts | 11 +- src/preload/comfyTitleBarPreload.ts | 44 ++- src/preload/comfyTitleTooltipPreload.ts | 24 +- .../src/comfyTitleBar/TitleBarApp.test.ts | 198 ++++++++++++- .../src/comfyTitleBar/TitleBarApp.vue | 92 +++++- .../comfyTitleBar/useBetaActivationNotice.ts | 173 +++++++++++ .../comfyTitleBar/useCentralPillCoachmark.ts | 10 +- .../GlobalSettingsView.test.ts | 52 ++++ .../comfyTitlePopup/GlobalSettingsView.vue | 85 +++++- .../src/comfyTitlePopup/TitlePopupApp.vue | 3 + .../src/comfyTitleTooltip/TitleTooltipApp.vue | 77 ++++- .../comfyUISettings/SettingsSectionList.vue | 5 + src/types/ipc.ts | 21 +- 30 files changed, 2065 insertions(+), 96 deletions(-) create mode 100644 e2e/beta-activation-notice.test.ts create mode 100644 e2e/support/fakeComfyInstall.ts create mode 100644 e2e/support/windowCapture.ts create mode 100644 src/main/lib/betaActivationNotice.test.ts create mode 100644 src/main/lib/betaActivationNotice.ts create mode 100644 src/renderer/src/comfyTitleBar/useBetaActivationNotice.ts diff --git a/.gitignore b/.gitignore index 605c3dec4..6c39835f6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ out/ result test-results/ playwright-report/ +# Composited window screenshots from the e2e specs. Generated output like the two above: +# the specs attach them to the Playwright report (which CI uploads) rather than the tree. +evidence/ resources/vc_redist.x64.exe resources/vc_redist.x64.exe.tmp resources/vc_redist_version.nsh diff --git a/e2e/beta-activation-notice.test.ts b/e2e/beta-activation-notice.test.ts new file mode 100644 index 000000000..08dfcb5c2 --- /dev/null +++ b/e2e/beta-activation-notice.test.ts @@ -0,0 +1,268 @@ +/** + * E2E: the Core beta activation notice, end to end through a real launch. + * + * Every gate the notice depends on is exercised for real here rather than stubbed: + * + * - the grant arrives the way a returning user's does, from `ops-flags.json` (PostHog is + * unreachable under the harness, so `coreBetaGrants` falls back to the persisted value); + * - it clears the version window against the seeded `comfyVersion`; + * - it clears the running core's args schema, parsed from a real `main.py --help` spawn; + * - the launch spawns, the stub serves, the boot wait succeeds and the window attaches; + * - only then does the title bar drain the pending notice and raise the card. + * + * The card lives in its own WebContentsView, so assertions go through the popup's webContents + * and the screenshots come from `BrowserWindow.capturePage()` — the whole host window, + * chrome and canvas and floating card together, which is the thing a reviewer needs to see. + * + * Tagged `@linux @macos`, never `@windows`: the fixture's interpreter stub cannot be a PE + * executable, so the launch it drives can never start there (see `fakeComfyInstall.ts`'s + * header, and the same exclusion documented in `e2e/comfybuilder-launch.test.ts`). + * + * Run: `pnpm exec playwright test --project=linux e2e/beta-activation-notice.test.ts` + * Screenshots go to Playwright's own output dir and are attached to the report, not written + * into the repo. + */ +import os from 'node:os' +import path from 'node:path' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { expect, test, type ElectronApplication } from '@playwright/test' +import { launchApp, type AppContext } from './launchApp' +import { clickInstallTile, expectChooserVisible } from './support/chooserHelpers' +import { WebContentsPage, titlePopupPage } from './support/cdpPages' +import { opsFlagsGrantSeed, writeFakeComfyInstall } from './support/fakeComfyInstall' +import { captureHostWindow } from './support/windowCapture' + +// A real launch (args-schema spawn, port wait, attach) does not fit the default 45s budget. +test.describe.configure({ mode: 'serial', timeout: 180_000 }) + +const INSTALL_ID = 'inst-beta-notice' +const INSTALL_NAME = 'Beta Notice Fixture' +/** Explicit so the launcher's port-conflict auto-shift can never move the stub's port. */ +const PORT = 49517 +/** The grant under test. `--enable-assets` is on the real allowlist and the stub's `--help` + * advertises it, so it survives selection AND the schema filter. */ +const GRANT_ARG = '--enable-assets' +/** Comfortably below the seeded `baseTag`, so the version window opens. */ +const GRANT_MIN_CORE = '0.3.80' + + +let ctx: AppContext +let installPath: string + +/** The coachmark popup, addressed by the card it is currently rendering. The hover-tooltip + * popup shares the same HTML entry point, so a URL marker alone could resolve to either; + * matching on `.coachmark` picks the one showing a card. */ +function coachmarkPopup(app: ElectronApplication): WebContentsPage { + return new WebContentsPage(app, 'comfyTitleTooltip') +} + +/** The whole host window, every view composited — not a per-view crop, because the claim + * being evidenced is that the card floats OVER live ComfyUI. + * + * Written under Playwright's per-test output dir, never into the repo: these are generated + * artifacts, and `.gitignore` already excludes every other one. `attach` puts them in the HTML + * report, which CI uploads. */ +async function captureWindow(app: ElectronApplication, name: string): Promise { + const file = path.join(test.info().outputDir, `${name}.png`) + await mkdir(path.dirname(file), { recursive: true }) + return captureHostWindow(app, ctx.panel, file) +} + +/** PostHog's own value for the flag wins over the persisted one, and a live fetch would make + * this run depend on a real project's flag state. Pointing the SDK at a closed port makes the + * fetch `unreachable`, which is the documented path where `ops-flags.json` is authoritative — + * the same path an offline launch takes for a user who already has the grant. */ +const UNREACHABLE_POSTHOG_HOST = 'http://127.0.0.1:1' +let previousPosthogHost: string | undefined + +test.beforeAll(async () => { + previousPosthogHost = process.env['POSTHOG_HOST'] + process.env['POSTHOG_HOST'] = UNREACHABLE_POSTHOG_HOST + + installPath = await mkdtemp(path.join(os.tmpdir(), 'comfyui-beta-notice-')) + await writeFakeComfyInstall({ installPath, port: PORT }) + + ctx = await launchApp({ + settings: { + firstUseCompleted: true, + // Beta grants are gated on the opt-in, which is itself gated on consent. + telemetryEnabled: true, + betaFeaturesEnabled: true, + // Spend the onboarding pill hint up front. Both cards share one popup per window, and + // the hint wins when they collide — which is correct behaviour, and covered by a unit + // test, but it would make this run assert the wrong card. + hasSeenCentralPillHint: true, + }, + installations: [ + { + id: INSTALL_ID, + name: INSTALL_NAME, + sourceId: 'comfybuilder', + sourceLabel: 'ComfyBuilder', + installPath, + status: 'installed', + launchArgs: `--port ${PORT}`, + launchMode: 'window', + browserPartition: 'unique', + seen: true, + // What the version gate reads. `commitsAhead: 0` makes the tag exact and + // `baseTagVerified` makes it ancestry-established; without both, the grant is refused. + comfyVersion: { + commit: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + baseTag: 'v0.3.99', + commitsAhead: 0, + baseTagVerified: true, + }, + }, + ], + // Delivered through `E2E_OPS_FLAGS_SEED` so main writes it to the real `configDir()` + // before its first read — the harness cannot place that file on macOS, where `userData` + // ignores the HOME override. Present from the app's first boot, which is how a returning + // user's already-granted flag actually arrives. + opsFlags: opsFlagsGrantSeed({ arg: GRANT_ARG, minCoreVersion: GRANT_MIN_CORE }), + }) + await expectChooserVisible(ctx.panel) +}) + +test.afterAll(async () => { + await ctx?.cleanup() + if (installPath) await rm(installPath, { recursive: true, force: true }) + if (previousPosthogHost === undefined) delete process.env['POSTHOG_HOST'] + else process.env['POSTHOG_HOST'] = previousPosthogHost +}) + +test('a first beta activation raises a nonblocking notice over live ComfyUI @linux @macos', async () => { + await clickInstallTile(ctx.panel, INSTALL_NAME) + + // The grant reaches the real command line, not just the selection step: this is what the + // notice is claiming happened, so it is asserted from the launch's own output rather than + // inferred from the card being up. + await ctx.panel.waitFor( + async () => + (await ctx.app.evaluate( + ({ webContents }, port) => + webContents.getAllWebContents().some((wc) => wc.getURL().includes(String(port))), + PORT, + )) === true, + { timeout: 90_000, message: 'ComfyUI stub never came up / the host never attached' }, + ) + + const popup = coachmarkPopup(ctx.app) + await popup.waitFor( + async () => { + try { + return await popup.exists('.coachmark') + } catch { + // The popup view is created lazily, so "not found" is a normal pre-show state. + return false + } + }, + { timeout: 30_000, message: 'beta activation notice never appeared' }, + ) + + expect(await popup.textOf('.coachmark-title')).toBe('A beta feature is on') + // Generic copy: the card must not name the arg, so it cannot be wrong about which feature. + expect(await popup.textOf('.coachmark-text')).not.toContain(GRANT_ARG) + // The way out is what makes this more than an FYI. + expect(await popup.textOf('.coachmark-action')).toBe('Settings') + expect(await popup.textOf('.coachmark-dismiss')).toBe('Got it') + + const shot = await captureWindow(ctx.app, '01-notice-over-comfyui') + test.info().attach('notice over ComfyUI', { path: shot, contentType: 'image/png' }) +}) + +test('the card is anchored on the bell it points at @linux @macos', async () => { + // The beak is drawn at a fixed position within the card, so "points at the bell" is really + // "the popup is centred on the bell". Asserted numerically rather than by eye: a composited + // screenshot is evidence, not a guarantee, and this is the property that actually breaks + // when an anchor rect goes stale or the card clamps at a window edge. + const bellCentre = await ctx.titleBar.evaluate(`(() => { + const el = document.querySelector('.title-announcement-button') + if (!el) return -1 + const r = el.getBoundingClientRect() + return (r.left + r.right) / 2 + })()`) + expect(bellCentre).toBeGreaterThan(0) + + const popup = await ctx.app.evaluate(({ BrowserWindow, WebContentsView }) => { + const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed() && w.isVisible()) + if (!win) return null + for (const child of win.contentView.children) { + if (!(child instanceof WebContentsView)) continue + if (!child.webContents.getURL().includes('comfyTitleTooltip')) continue + const b = child.getBounds() + return { centre: b.x + b.width / 2, right: b.x + b.width, width: b.width } + } + return null + }) + expect(popup, 'coachmark popup view not found').not.toBeNull() + + // One device pixel of rounding is fine; ~20 px means the card is pointing at a neighbour. + expect(Math.abs(popup!.centre - bellCentre)).toBeLessThanOrEqual(2) +}) + +test('the notice does not block the canvas underneath it @linux @macos', async () => { + // Nonblocking is the firm constraint, and it is a property of the popup's BOUNDS: the card + // is its own small WebContentsView, so it takes clicks only where it is drawn. A full-window + // overlay would report a height near the host window's. + const bounds = await ctx.app.evaluate(({ BrowserWindow, WebContentsView }) => { + const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed() && w.isVisible()) + if (!win) throw new Error('no visible window') + const content = win.getContentBounds() + for (const child of win.contentView.children) { + if (!(child instanceof WebContentsView) || !child.getVisible()) continue + if (!child.webContents.getURL().includes('comfyTitleTooltip')) continue + const b = child.getBounds() + return { card: b, window: { width: content.width, height: content.height } } + } + return null + }) + + expect(bounds, 'coachmark popup view not found among the window children').not.toBeNull() + expect(bounds!.card.height).toBeLessThan(bounds!.window.height / 2) + expect(bounds!.card.width).toBeLessThan(bounds!.window.width / 2) +}) + +test('the settings link lands on the beta opt-in row and retires the card @linux @macos', async () => { + const popup = coachmarkPopup(ctx.app) + expect(await popup.click('.coachmark-action')).toBe(true) + + const settings = titlePopupPage(ctx.app) + await settings.waitForVisible('.global-settings', { timeout: 15_000 }) + await settings.waitForVisible('[data-field-id="betaFeaturesEnabled"]', { timeout: 15_000 }) + + // Landing on the tab is not the claim — landing on the ROW is. The flash class is what + // carries the user's eye to a control that sits below the fold. + await settings.waitFor( + () => settings.exists('[data-field-id="betaFeaturesEnabled"].gs-field-flash'), + { timeout: 10_000, message: 'beta opt-in row was never highlighted' }, + ) + // The switch it points at is the real opt-out, and it is live (on, and not consent-blocked). + const state = await settings.evaluate<{ checked: string | null; disabled: string | null }>(`(() => { + const el = document.querySelector('[data-field-id="betaFeaturesEnabled"] button[role="switch"]') + if (!el) return { checked: null, disabled: null } + return { + checked: el.getAttribute('aria-checked'), + disabled: el.getAttribute('aria-disabled'), + } + })()`) + expect(state.checked).toBe('true') + expect(state.disabled).toBe('false') + + const shot = await captureWindow(ctx.app, '02-settings-beta-optout-highlighted') + test.info().attach('settings opt-out highlighted', { path: shot, contentType: 'image/png' }) +}) + +test('the notice is spent: a second launch stays silent @linux @macos', async () => { + // The whole point of persisting on retire rather than on show. Read through the same IPC + // the title bar uses, so this asserts the contract the renderer actually depends on. + const stillPending = await ctx.titleBar.evaluate( + `window.api.getPendingBetaNotice(${JSON.stringify(INSTALL_ID)})`, + ) + expect(stillPending).toEqual([]) + + const announced = await ctx.titleBar.evaluate( + `window.api.getSetting('betaNoticeAnnouncedArgs')`, + ) + expect(announced).toEqual([GRANT_ARG]) +}) diff --git a/e2e/support/electronHarness.ts b/e2e/support/electronHarness.ts index ebb3cb0b3..aae80f9b3 100644 --- a/e2e/support/electronHarness.ts +++ b/e2e/support/electronHarness.ts @@ -21,8 +21,17 @@ export interface SeedOptions { * `firstUseCompleted`) so a test isn't racing the first-use takeover. */ settings?: Record /** Runs after the isolated dirs are created but before launch. Use to drop - * platform-specific files the main process inspects during early boot. */ + * platform-specific files the main process inspects during early boot. + * + * NOT suitable for anything main reads through `configDir()`: that resolves to Electron's + * `userData`, which on macOS ignores the HOME override entirely (see `appDataDir` below). + * Use a `E2E_*_SEED` env hook for those, as `settings` and `opsFlags` do. */ onSetup?: (paths: { homeDir: string; appDataDir: string }) => Promise + /** Seed `/ops-flags.json` — the persisted ops-flag cache that `coreBetaGrants` + * falls back to when PostHog is unreachable. Env-delivered rather than written here for the + * same reason `settings` is: main is the only process that knows where that file lives on + * every platform. */ + opsFlags?: Record /** Launch against this exact profile dir instead of a fresh mkdtemp one, * with the same semantics as `LIFECYCLE_REUSE_DIR` (persisted settings are * folded into the seed; the dir survives cleanup). Lets a spec quit and @@ -76,7 +85,8 @@ function formatSeedTimestamp(date: Date): string { function buildIsolatedEnv( homeDir: string, - settingsSeed?: Record + settingsSeed?: Record, + opsFlagsSeed?: Record ): Record { const inheritedEnv = Object.fromEntries( Object.entries(process.env).filter( @@ -113,6 +123,8 @@ function buildIsolatedEnv( } env['E2E_SETTINGS_SEED'] = JSON.stringify(effectiveSeed) + if (opsFlagsSeed) env['E2E_OPS_FLAGS_SEED'] = JSON.stringify(opsFlagsSeed) + return env } @@ -234,6 +246,13 @@ export async function launchLauncherApp(options?: SeedOptions): Promise 0) { env['E2E_INSTALLATIONS_SEED'] = JSON.stringify(seedRecords) } diff --git a/e2e/support/fakeComfyInstall.ts b/e2e/support/fakeComfyInstall.ts new file mode 100644 index 000000000..7b72f3690 --- /dev/null +++ b/e2e/support/fakeComfyInstall.ts @@ -0,0 +1,162 @@ +/** + * A ComfyUI install that is fake everywhere except the two places the Core beta grant path + * actually looks. + * + * `buildLaunchArgs` only ever sees a grant through two real gates, and both have to be genuine + * for an end-to-end run to prove anything: + * + * 1. `getComfyArgsSchema` runs ` -s main.py --help` and parses the argparse block, so + * the stub answers `--help` with a real usage listing. A grant the listing omits is + * filtered out as `dropped_unsupported`, exactly as an older core would drop it. + * 2. The launch then spawns that same stub for real and waits for the port, so the stub + * serves HTTP until it is killed. Without that the launch fails at the boot wait and the + * window never leaves the progress takeover. + * + * Node runs the server rather than Python because there is no interpreter to depend on: the + * absolute path of the node binary already running Playwright is baked into the stub. + * + * POSIX only, deliberately. A Windows stub would have to be a real PE executable: `venvPython` + * resolves to `venv/python.exe`, and both callers run it with no shell (`execFile` in + * `comfy-args.ts`, `spawn` in `process.ts`), so `CreateProcessW` rejects anything that is not a + * PE image. Naming a batch file `.exe` does not help — only a `.bat`/`.cmd` EXTENSION makes + * Windows hand off to `cmd.exe`. `e2e/comfybuilder-launch.test.ts` writes an EMPTY `python.exe` + * for exactly this reason: its assertion is that the launch is attempted and fails. Specs using + * this fixture are therefore tagged `@linux @macos`, never `@windows`. + */ +import path from 'node:path' +import process from 'node:process' +import { chmod, mkdir, writeFile } from 'node:fs/promises' + +/** Flags the stub's `--help` advertises. `--enable-assets` is here because the grant under + * test names it; a core that did not know the flag would leave it out and the launch would + * drop it. `--port` and `--listen` are what the launcher itself passes. */ +const SUPPORTED_FLAGS = [ + ['--port PORT', 'Set the listen port.'], + ['--listen [IP]', 'Specify the IP address to listen on.'], + ['--enable-manager', 'Enable ComfyUI-Manager.'], + ['--enable-assets', 'Enable the assets subsystem.'], + ['--user-directory USER_DIRECTORY', 'Set the ComfyUI user directory.'], + ['--input-directory INPUT_DIRECTORY', 'Set the ComfyUI input directory.'], + ['--output-directory OUTPUT_DIRECTORY', 'Set the ComfyUI output directory.'], + ['--extra-model-paths-config PATH', 'Load extra model paths from a YAML file.'], + ['--feature-flag KEY=VALUE', 'Set a desktop feature flag.'], + ['--list-feature-flags', 'List supported feature flags and exit.'], +] as const + +function helpText(): string { + const options = SUPPORTED_FLAGS.map(([flag, help]) => ` ${flag}\n ${help}`) + return [ + `usage: main.py [-h] [${SUPPORTED_FLAGS.map(([f]) => f.split(' ')[0]).join('] [')}]`, + '', + 'options:', + ' -h, --help show this help message and exit', + ...options, + '', + ].join('\n') +} + +/** Minimal always-200 server on the launcher's chosen port, so `waitForPort` succeeds and the + * host window navigates to a page rather than an error. The body is deliberately plain and + * labelled — it is the backdrop every screenshot of the notice is taken against, and an + * unlabelled blank page would look like a rendering failure in the evidence. */ +const SERVER_JS = ` +const http = require('node:http') +// Serve until killed. Deliberately no stdin-close guard: the launcher spawns this with stdio +// pipes it does not write to, so watching stdin would fire immediately and the launch would +// see "process exited with code 0" instead of a booted server. The launcher's own +// killProcessTree ends it, and a leaked one dies with the profile's port anyway. +const args = process.argv.slice(2) +const portIndex = args.indexOf('--port') +const port = portIndex === -1 ? 8188 : Number(args[portIndex + 1]) +const assetsOn = args.includes('--enable-assets') +const body = \`ComfyUI (e2e stub) +
+

ComfyUI stub — e2e fixture canvas

+

launched with \${assetsOn ? '--enable-assets' : 'no beta grant'}

+
\` +http + .createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end(body) + }) + .listen(port, '127.0.0.1', () => { + // The launcher's boot-log tap reads stdout; printing the usual banner keeps the console + // pane readable while a run is being watched. + console.log('Starting server\\n') + console.log('To see the GUI go to: http://127.0.0.1:' + port) + }) +` + +export interface FakeComfyInstall { + /** `installPath` for the seeded record. */ + installPath: string + /** Port the stub serves on; also passed through `launchArgs` so it is explicit and the + * launcher's port-conflict auto-shift can never move it out from under an assertion. */ + port: number +} + +/** + * Write the on-disk layout `comfybuilder`'s `buildLaunchSpec` requires: a venv interpreter and + * `ComfyUI/main.py`. Deliberately NOT a git checkout — `coreRecordCurrent` treats a non-git + * install as "nothing can contradict the record", which is the ordinary standalone/archive + * case and keeps the seeded `comfyVersion` authoritative. + */ +export async function writeFakeComfyInstall(opts: { + installPath: string + port: number +}): Promise { + const { installPath, port } = opts + await mkdir(path.join(installPath, 'ComfyUI'), { recursive: true }) + await writeFile(path.join(installPath, 'ComfyUI', 'main.py'), '# e2e stub\n') + + const serverPath = path.join(installPath, 'stub-server.cjs') + await writeFile(serverPath, SERVER_JS) + + if (process.platform === 'win32') { + throw new Error( + 'writeFakeComfyInstall is POSIX-only (see the file header); tag the spec @linux @macos.' + ) + } + + await mkdir(path.join(installPath, 'venv', 'bin'), { recursive: true }) + const sh = [ + '#!/bin/sh', + 'case "$*" in', + ' *--help*)', + " cat <<'COMFY_USAGE'", + helpText(), + 'COMFY_USAGE', + ' exit 0', + ' ;;', + 'esac', + `exec "${process.execPath}" "${serverPath}" "$@"`, + '', + ].join('\n') + const python = path.join(installPath, 'venv', 'bin', 'python3') + await writeFile(python, sh) + await chmod(python, 0o755) + + return { installPath, port } +} + +/** The persisted ops-flag entry `coreBetaGrants` reads when PostHog is unreachable. Handed to + * `launchApp`'s `opsFlags`, which delivers it through `E2E_OPS_FLAGS_SEED` so MAIN writes it to + * the real `configDir()`. The harness cannot write it itself: that path is Electron's + * `userData` off Linux, and macOS ignores the HOME override for it, so a hand-placed file is + * read on Linux and silently ignored everywhere else. */ +export function opsFlagsGrantSeed(opts: { + arg: string + minCoreVersion: string +}): Record { + return { + desktop_core_beta_features: { + value: 'treatment', + payload: { flags: [{ arg: opts.arg, min_core_version: opts.minCoreVersion }] }, + }, + } +} diff --git a/e2e/support/windowCapture.ts b/e2e/support/windowCapture.ts new file mode 100644 index 000000000..a0ebb017a --- /dev/null +++ b/e2e/support/windowCapture.ts @@ -0,0 +1,113 @@ +/** + * Screenshot a host window the way a user sees it — every WebContentsView composited together. + * + * `BrowserWindow.capturePage()` is useless here: the host window has no DOM of its own (title + * bar, panel, comfyView and every popup are sibling `WebContentsView`s), so it returns a 0x0 + * image. Each view captures fine individually, so this grabs them in z-order and pastes them + * onto a canvas at their own bounds. + * + * The compositing runs inside one of the app's own webContents rather than in the test process, + * so it needs no image library — Chromium is already there, and the result comes back as a PNG + * data URL. Nothing is drawn into that page; it only borrows the canvas. + */ +import { writeFile } from 'node:fs/promises' +import type { ElectronApplication } from 'playwright' +import type { WebContentsPage } from './cdpPages' + +interface CapturedLayer { + dataUrl: string + x: number + y: number + width: number + height: number +} + +interface CapturedWindow { + width: number + height: number + layers: CapturedLayer[] +} + +/** + * Capture every visible view of the frontmost visible window. + * + * `contentView.children` is back-to-front, which is exactly the paint order, so the array is + * returned as-is and drawn in sequence. A view that fails to capture is skipped rather than + * failing the shot: a half-composited screenshot still shows what the test is asserting, and a + * capture is evidence, not an assertion. + */ +async function captureLayers(app: ElectronApplication): Promise { + return app.evaluate(async ({ BrowserWindow, WebContentsView }) => { + // Let any in-flight resize land first. The coachmark popup is shown at a fallback size and + // resized once the card reports its measured width, so capturing mid-flight pairs a frame + // from the old size with the new bounds — which draws the card offset from where it really + // sits, and makes the screenshot lie about anchoring. Evidence has to be trustworthy. + await new Promise((resolve) => setTimeout(resolve, 400)) + const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed() && w.isVisible()) + if (!win) throw new Error('no visible BrowserWindow to capture') + const content = win.getContentBounds() + const layers: CapturedLayer[] = [] + for (const child of win.contentView.children) { + if (!(child instanceof WebContentsView) || !child.getVisible()) continue + if (child.webContents.isDestroyed()) continue + if (child.getBounds().width <= 0 || child.getBounds().height <= 0) continue + try { + // Bounded: `capturePage()` waits on the compositor producing a frame, and a view that + // is visible but not painting (occluded, or mid-navigation) can leave that promise + // pending indefinitely — which would hang the test rather than the screenshot. A + // capture is evidence, not an assertion, so a slow view is dropped instead. + const image = await Promise.race([ + child.webContents.capturePage(), + new Promise((resolve) => setTimeout(() => resolve(null), 3000)) + ]) + if (!image || image.isEmpty()) continue + // Bounds re-read AFTER the capture, so the frame and the rectangle it is drawn into + // describe the same moment. `capturePage` returns device pixels (the image is larger + // than the bounds at dpr > 1); drawing it into the bounds rescales it. + layers.push({ dataUrl: image.toDataURL(), ...child.getBounds() }) + } catch { + // A view that refuses to paint is left out; see the note above. + } + } + return { width: content.width, height: content.height, layers } + }) +} + +/** + * Composite the captured layers and write a PNG. + * + * `page` is any of the app's webContents — it supplies the canvas and is otherwise untouched. + * Returns the file path so a caller can attach it to the Playwright report. + */ +export async function captureHostWindow( + app: ElectronApplication, + page: WebContentsPage, + filePath: string +): Promise { + const shot = await captureLayers(app) + if (shot.layers.length === 0) throw new Error('no visible views captured') + + const base64 = await page.evaluate(`(async () => { + const shot = ${JSON.stringify(shot)} + const canvas = document.createElement('canvas') + canvas.width = shot.width + canvas.height = shot.height + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('no 2d context') + for (const layer of shot.layers) { + const img = new Image() + await new Promise((resolve, reject) => { + img.onload = resolve + img.onerror = () => reject(new Error('layer failed to decode')) + img.src = layer.dataUrl + }) + // Draw to the view's own bounds: a view captures at its device pixel size, which on a + // scaled display is larger than the CSS box it occupies. + ctx.drawImage(img, layer.x, layer.y, layer.width, layer.height) + } + return canvas.toDataURL('image/png').split(',')[1] + })()`) + + await writeFile(filePath, Buffer.from(base64, 'base64')) + return filePath +} diff --git a/locales/en.json b/locales/en.json index 40f06185b..0f7b475a2 100644 --- a/locales/en.json +++ b/locales/en.json @@ -86,7 +86,11 @@ "resetZoomTooltip": "Reset zoom to 100%", "pillHintTitle": "Switch & manage instances", "pillHintBody": "Click here to switch instances, start a new local instance, or return to the dashboard.", - "pillHintDismiss": "Got it" + "pillHintDismiss": "Got it", + "betaNoticeTitle": "A beta feature is on", + "betaNoticeBody": "This instance started with a beta feature enabled. You can turn beta features off in Settings.", + "betaNoticeDismiss": "Got it", + "betaNoticeSettings": "Settings" }, "comfyUISettings": { "title": "Settings", diff --git a/locales/zh.json b/locales/zh.json index 14649c799..ec3e0f503 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -86,7 +86,11 @@ "resetZoomTooltip": "将缩放重置为 100%", "pillHintTitle": "切换和管理实例", "pillHintBody": "点击这里切换实例、启动新的本地实例,或返回仪表板。", - "pillHintDismiss": "知道了" + "pillHintDismiss": "知道了", + "betaNoticeTitle": "已启用 Beta 功能", + "betaNoticeBody": "此实例启动时启用了一项 Beta 功能。你可以在设置中关闭 Beta 功能。", + "betaNoticeDismiss": "知道了", + "betaNoticeSettings": "设置" }, "comfyUISettings": { "title": "设置", diff --git a/src/main/lib/betaActivationNotice.test.ts b/src/main/lib/betaActivationNotice.test.ts new file mode 100644 index 000000000..361e02c77 --- /dev/null +++ b/src/main/lib/betaActivationNotice.test.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * The module persists through `settings`, so the store is faked rather than written: these + * tests are about the once-per-feature rule, and a real `settings.json` would put the + * developer's own config dir in the blast radius. + */ +const store = new Map() +vi.mock('../settings', () => ({ + get: (key: string) => store.get(key), + set: (key: string, value: unknown) => { + store.set(key, value) + } +})) + +import { + BETA_NOTICE_ANNOUNCED_ARGS_KEY, + _resetForTest, + acknowledgeBetaActivationNotice, + armBetaActivationNotice, + peekBetaActivationNotice, + readAnnouncedBetaArgs, + selectNewlyActiveBetaArgs +} from './betaActivationNotice' + +const announced = (): unknown => store.get(BETA_NOTICE_ANNOUNCED_ARGS_KEY) + +beforeEach(() => { + store.clear() + _resetForTest() +}) + +describe('selectNewlyActiveBetaArgs', () => { + it('announces an enable-grant nobody has spoken for yet', () => { + expect(selectNewlyActiveBetaArgs(['--enable-assets'], new Set())).toEqual(['--enable-assets']) + }) + + it('never announces a disable-grant', () => { + // `--disable-assets` is the remote force-OFF. The card says "a beta feature is on" and + // points at the opt-out switch, so announcing one would state the opposite of what + // happened and offer an action that does not apply. + expect(selectNewlyActiveBetaArgs(['--disable-assets'], new Set())).toEqual([]) + expect(selectNewlyActiveBetaArgs(['--disable-assets', '--enable-agent'], new Set())).toEqual([ + '--enable-agent' + ]) + }) + + it('withholds an arg already spoken for', () => { + expect(selectNewlyActiveBetaArgs(['--enable-assets'], new Set(['--enable-assets']))).toEqual([]) + }) + + it('announces a LATER grant even once an earlier one is spoken for', () => { + // The whole reason the store is a list rather than a boolean: a second beta feature + // months from now still owes the user a heads-up. + expect( + selectNewlyActiveBetaArgs(['--enable-assets', '--enable-agent'], new Set(['--enable-assets'])) + ).toEqual(['--enable-agent']) + }) + + it('collapses a repeated arg so one launch cannot double-announce it', () => { + expect(selectNewlyActiveBetaArgs(['--enable-assets', '--enable-assets'], new Set())).toEqual([ + '--enable-assets' + ]) + }) +}) + +describe('the allowlist invariant this module depends on', () => { + it('every grantable arg is --enable-* or --disable-*', async () => { + // `selectNewlyActiveBetaArgs` announces only `--enable-*` and treats everything else as a + // force-off to stay silent about. An allowlist entry with neither prefix would therefore + // ship a grant the user is never told about — the exact failure this module exists to + // prevent, reintroduced silently. Asserted here because `coreBetaGrants.ts` has no reason + // to know about the coupling. + const { CORE_BETA_GRANTABLE_ARGS } = await import('./coreBetaGrants') + for (const arg of CORE_BETA_GRANTABLE_ARGS) { + expect(arg.startsWith('--enable-') || arg.startsWith('--disable-')).toBe(true) + } + }) +}) + +describe('readAnnouncedBetaArgs', () => { + it('reads the persisted list', () => { + store.set(BETA_NOTICE_ANNOUNCED_ARGS_KEY, ['--enable-assets']) + expect(readAnnouncedBetaArgs()).toEqual(['--enable-assets']) + }) + + it.each([ + ['absent', undefined], + ['a non-array', 'enable-assets'], + ['an object', { '--enable-assets': true }] + ])('reads %s as nothing announced yet', (_label, value) => { + // settings.json is user-writable, so every malformed shape has to degrade to "tell them" + // rather than throwing on the launch path. + if (value !== undefined) store.set(BETA_NOTICE_ANNOUNCED_ARGS_KEY, value) + expect(readAnnouncedBetaArgs()).toEqual([]) + }) + + it('drops non-string entries rather than the whole list', () => { + store.set(BETA_NOTICE_ANNOUNCED_ARGS_KEY, ['--enable-assets', 42, null]) + expect(readAnnouncedBetaArgs()).toEqual(['--enable-assets']) + }) +}) + +describe('arm / peek / acknowledge', () => { + it('queues a first activation for the install that launched it', () => { + armBetaActivationNotice('inst-1', ['--enable-assets']) + expect(peekBetaActivationNotice('inst-1')).toEqual(['--enable-assets']) + expect(peekBetaActivationNotice('inst-2')).toEqual([]) + }) + + it('queues nothing when the launch applied no grants', () => { + armBetaActivationNotice('inst-1', []) + expect(peekBetaActivationNotice('inst-1')).toEqual([]) + }) + + it('leaves the notice pending across repeated reads', () => { + // Persisting on show rather than on retire would spend a card the user may never have + // seen — window closed, app quit, bell not rendered. + armBetaActivationNotice('inst-1', ['--enable-assets']) + expect(peekBetaActivationNotice('inst-1')).toEqual(['--enable-assets']) + expect(peekBetaActivationNotice('inst-1')).toEqual(['--enable-assets']) + expect(announced()).toBeUndefined() + }) + + it('persists the args and clears the queue on acknowledge', () => { + armBetaActivationNotice('inst-1', ['--enable-assets']) + acknowledgeBetaActivationNotice('inst-1') + expect(announced()).toEqual(['--enable-assets']) + expect(peekBetaActivationNotice('inst-1')).toEqual([]) + }) + + it('stays silent on every later launch of the same feature', () => { + armBetaActivationNotice('inst-1', ['--enable-assets']) + acknowledgeBetaActivationNotice('inst-1') + armBetaActivationNotice('inst-1', ['--enable-assets']) + expect(peekBetaActivationNotice('inst-1')).toEqual([]) + }) + + it('stays silent when the same feature is revoked and later re-granted', () => { + // The list is append-only, so a grant taken back and handed out again does not read as + // news the second time. + armBetaActivationNotice('inst-1', ['--enable-assets']) + acknowledgeBetaActivationNotice('inst-1') + armBetaActivationNotice('inst-1', []) // revoked: nothing applied + armBetaActivationNotice('inst-1', ['--enable-assets']) // re-granted + expect(peekBetaActivationNotice('inst-1')).toEqual([]) + }) + + it('tells a SECOND install about a feature the first never announced', () => { + armBetaActivationNotice('inst-1', ['--enable-assets']) + acknowledgeBetaActivationNotice('inst-1') + armBetaActivationNotice('inst-2', ['--enable-assets', '--enable-agent']) + expect(peekBetaActivationNotice('inst-2')).toEqual(['--enable-agent']) + }) + + it('lets only the first of two concurrent installs claim an arg', () => { + // Neither has acknowledged yet, so the persisted list is still empty. Without the + // in-flight claim both windows would raise a card for the same feature. + armBetaActivationNotice('inst-1', ['--enable-assets']) + armBetaActivationNotice('inst-2', ['--enable-assets']) + expect(peekBetaActivationNotice('inst-1')).toEqual(['--enable-assets']) + expect(peekBetaActivationNotice('inst-2')).toEqual([]) + }) + + it('merges into what other installs already announced rather than replacing it', () => { + store.set(BETA_NOTICE_ANNOUNCED_ARGS_KEY, ['--enable-assets']) + armBetaActivationNotice('inst-2', ['--enable-agent']) + acknowledgeBetaActivationNotice('inst-2') + expect(announced()).toEqual(['--enable-assets', '--enable-agent']) + }) + + it('acknowledging an install with nothing pending writes nothing', () => { + acknowledgeBetaActivationNotice('inst-1') + expect(announced()).toBeUndefined() + }) + + it("re-arming replaces the install's pending set with the latest launch's grants", () => { + // Each launch is the authority on what is on its own command line. + armBetaActivationNotice('inst-1', ['--enable-assets']) + armBetaActivationNotice('inst-1', ['--enable-assets', '--enable-agent']) + expect(peekBetaActivationNotice('inst-1')).toEqual(['--enable-assets', '--enable-agent']) + }) + + it('clears a stale claim when the next launch applies no grants', () => { + // A beta launch that failed to boot leaves a claim behind. If the user then turns beta off + // and relaunches, the card must not still say a beta feature is on. + armBetaActivationNotice('inst-1', ['--enable-assets']) + armBetaActivationNotice('inst-1', []) + expect(peekBetaActivationNotice('inst-1')).toEqual([]) + }) + + it('releases a dropped claim back to other installs', () => { + // `claimedArgs` reads the same map, so a stale claim would otherwise silence the arg + // everywhere for the rest of the process. + armBetaActivationNotice('inst-1', ['--enable-assets']) + armBetaActivationNotice('inst-2', ['--enable-assets']) + expect(peekBetaActivationNotice('inst-2')).toEqual([]) + + armBetaActivationNotice('inst-1', []) + armBetaActivationNotice('inst-2', ['--enable-assets']) + expect(peekBetaActivationNotice('inst-2')).toEqual(['--enable-assets']) + }) +}) diff --git a/src/main/lib/betaActivationNotice.ts b/src/main/lib/betaActivationNotice.ts new file mode 100644 index 000000000..0f54f6352 --- /dev/null +++ b/src/main/lib/betaActivationNotice.ts @@ -0,0 +1,155 @@ +/** + * One-per-feature heads-up that a Core beta grant has actually turned something on. + * + * Separate concern from enrolment. `coreBetaGrants.ts` decides WHETHER a user is in the beta and + * which args they get; this module only answers "has the user been told about this one yet?". + * Nothing here feeds back into selection — suppressing a notice never suppresses a grant, and a + * user who dismisses the card is still in the beta until they use the opt-out it points at. + * + * Armed from `reportCoreBetaLaunch`, which is the only point where a grant is provably real: + * it fires once per launch, past the last cancellation gate, and only for grants that cleared + * BOTH the version window and the running core's args schema. Arming earlier (at payload + * receipt, say) would announce features that the gate or the schema then drops. + * + * The title bar PULLS rather than main pushing: at arm time the host window may still be the + * dashboard, mid-attach, or under the launch progress takeover, so a push would have to guess + * when a renderer is ready to render a card. Instead the pending set sits here until the title + * bar's own gate (`useBetaActivationNotice`) opens and asks for it. + */ +import * as settings from '../settings' + +/** Args already announced, as a durable string list. A LIST rather than a boolean so a second + * beta feature granted months later still gets its own heads-up; append-only, so a grant that + * is revoked and later re-granted stays silent the second time. */ +export const BETA_NOTICE_ANNOUNCED_ARGS_KEY = 'betaNoticeAnnouncedArgs' + +/** Only grants that turn something ON announce. `--disable-*` exists in the allowlist as a + * remote force-OFF (see `CORE_BETA_GRANTABLE_ARGS`), and the notice's copy — "a beta feature + * is on", pointing at the opt-out switch — would be flatly wrong for one: it names the + * opposite of what happened and offers an action that does not apply. Silent is the honest + * reading until the payload can carry its own copy. */ +const ENABLE_PREFIX = '--enable-' + +/** + * Pending notices by installation id, drained by the title bar of that install's host window. + * + * Process-lifetime only, deliberately. A pending notice that is never acknowledged — window + * closed, app quit, bell not reachable — must REPLAY on the next launch rather than being lost, + * so nothing is written to disk until the user actually retires the card. + */ +const pendingByInstallation = new Map() + +/** Every arg currently pending across all installs. Two windows launching with the same fresh + * grant would otherwise each show a card for it, since neither has acknowledged yet and the + * persisted list is still empty. First claim wins; the second install stays silent. */ +function claimedArgs(): Set { + const claimed = new Set() + for (const args of pendingByInstallation.values()) { + for (const arg of args) claimed.add(arg) + } + return claimed +} + +/** In-memory mirror of the persisted list. `settings.get` re-reads and re-parses the whole + * file on every call, and its retry path blocks the main thread with `Atomics.wait` — which + * `armBetaActivationNotice` would otherwise pay on the spawn critical path, once per launch. + * Safe to cache because this module is the only writer of the key; it is refreshed on write + * and cleared for tests. */ +let announcedCache: string[] | null = null + +/** The persisted list, defensive about content: `settings.json` is user-writable, so a + * hand-edited non-array or a non-string entry has to read as "nothing announced yet" rather + * than throwing on the launch path. */ +export function readAnnouncedBetaArgs(): string[] { + if (announcedCache !== null) return announcedCache + const raw = settings.get(BETA_NOTICE_ANNOUNCED_ARGS_KEY) + const parsed = Array.isArray(raw) + ? raw.filter((entry): entry is string => typeof entry === 'string') + : [] + announcedCache = parsed + return parsed +} + +/** + * The grants from this launch the user has not been told about yet. + * + * Pure so the trigger rule is testable without settings or a launch: takes what was applied + * plus what is already spoken for, returns what is new. Order follows `appliedArgs` and + * duplicates collapse, so a payload naming an arg twice cannot double-announce it. + */ +export function selectNewlyActiveBetaArgs( + appliedArgs: readonly string[], + spokenFor: ReadonlySet +): string[] { + const fresh: string[] = [] + const seen = new Set(spokenFor) + for (const arg of appliedArgs) { + if (!arg.startsWith(ENABLE_PREFIX)) continue + if (seen.has(arg)) continue + seen.add(arg) + fresh.push(arg) + } + return fresh +} + +/** + * Queue a notice for any grant this launch turned on for the first time. + * + * Called from the launch path, so it must never throw: a settings read that fails costs the + * user a heads-up, which is strictly better than costing them the launch. + */ +export function armBetaActivationNotice( + installationId: string, + appliedArgs: readonly string[] +): void { + try { + // Replace, never append. Arming happens before the spawn is known to have succeeded, so a + // claim can be left behind by a launch that then failed to boot. The next launch of this + // install is the authority on what is actually on its command line: relaunching with beta + // turned off must clear the old claim, not inherit it and then announce a feature that is + // no longer on. Dropping a claim also releases it for other installs, since `claimedArgs` + // reads the same map. + pendingByInstallation.delete(installationId) + if (appliedArgs.length === 0) return + const spokenFor = new Set([...readAnnouncedBetaArgs(), ...claimedArgs()]) + const fresh = selectNewlyActiveBetaArgs(appliedArgs, spokenFor) + if (fresh.length === 0) return + pendingByInstallation.set(installationId, fresh) + } catch (err) { + console.log('[beta-notice] arm failed:', err) + } +} + +/** What this install's title bar should announce, or `[]`. Read-only: the pending entry + * survives until `acknowledgeBetaActivationNotice`, so a card that is shown but never retired + * (window closed, app quit) comes back on the next launch. */ +export function peekBetaActivationNotice(installationId: string): string[] { + return [...(pendingByInstallation.get(installationId) ?? [])] +} + +/** + * Retire this install's notice: persist its args as announced and drop the pending entry. + * + * Called when the user dismisses the card or follows its settings link — acting on it is + * acknowledging it. Merged into the stored list rather than replacing it, so two installs + * retiring different notices cannot clobber each other. + */ +export function acknowledgeBetaActivationNotice(installationId: string): void { + const pending = pendingByInstallation.get(installationId) + pendingByInstallation.delete(installationId) + if (!pending || pending.length === 0) return + try { + const merged = [...new Set([...readAnnouncedBetaArgs(), ...pending])] + settings.set(BETA_NOTICE_ANNOUNCED_ARGS_KEY, merged) + announcedCache = merged + } catch (err) { + // A failed write costs the user a repeat card on the next launch and nothing else. + console.log('[beta-notice] acknowledge failed:', err) + } +} + +/** @internal — exposed for tests. */ +export function _resetForTest(): void { + pendingByInstallation.clear() + announcedCache = null +} diff --git a/src/main/lib/ipc/registerSettingsHandlers.ts b/src/main/lib/ipc/registerSettingsHandlers.ts index c8d30106c..5a3ca3305 100644 --- a/src/main/lib/ipc/registerSettingsHandlers.ts +++ b/src/main/lib/ipc/registerSettingsHandlers.ts @@ -15,6 +15,7 @@ import * as mainTelemetry from '../telemetry' import { detectFirstUseState } from '../firstUseDetection' import * as updater from '../updater' import { globalSettingsEvents } from '../globalSettingsEvents' +import { acknowledgeBetaActivationNotice, peekBetaActivationNotice } from '../betaActivationNotice' import { recordIpcInvocation } from '../e2eOverrides' import type { SettingsSection } from '../../../types/ipc' import { AUTO_LAUNCH_LAST, AUTO_LAUNCH_NONE } from '../../settings' @@ -337,6 +338,20 @@ export function registerSettingsHandlers(): void { return settings.get(key) }) + // Core beta activation notice. A PULL pair rather than a push: main arms the pending set + // during launch, when the host window may still be mid-attach or under the progress + // takeover, and the title bar drains it once its own gate opens. + ipcMain.handle('get-pending-beta-notice', (_event, installationId: string) => { + return peekBetaActivationNotice(installationId) + }) + + // Retire the card: persist its args as announced so it never shows again. Deliberately + // separate from the read, so a notice that is shown but never retired replays next launch. + ipcMain.handle('acknowledge-beta-notice', (_event, installationId: string) => { + recordIpcInvocation('acknowledge-beta-notice', { installationId }) + acknowledgeBetaActivationNotice(installationId) + }) + ipcMain.handle('get-locale-messages', () => i18n.getMessages()) ipcMain.handle('get-available-locales', () => i18n.getAvailableLocales()) // Main owns the resolved locale; the renderer's vue-i18n locale is always 'en'. diff --git a/src/main/lib/ipc/sessionActions/launch.test.ts b/src/main/lib/ipc/sessionActions/launch.test.ts index a1b20285c..e99f92d55 100644 --- a/src/main/lib/ipc/sessionActions/launch.test.ts +++ b/src/main/lib/ipc/sessionActions/launch.test.ts @@ -141,6 +141,13 @@ import { _cleanupFailedLaunchSetup } from './launch' import * as assetsTapModule from '../../assetsTap' +import { + BETA_NOTICE_ANNOUNCED_ARGS_KEY, + _resetForTest as _resetBetaNotice, + acknowledgeBetaActivationNotice, + peekBetaActivationNotice +} from '../../betaActivationNotice' +import * as settingsModule from '../../../settings' import type { ActionContext } from './types' import type * as ComfyDownloadManagerModule from '../../comfyDownloadManager' import type { createExecutionTap } from '../../executionTap' @@ -912,6 +919,11 @@ describe('core beta report placement', () => { spawnArgs = [] launchHarness.grants = [HARNESS_GRANT] launchHarness.duringResourceAcquire = null + // Both halves of the activation-notice state: the in-process pending queue and the + // persisted announced list, which the real settings module keeps in this run's temp + // app dir. Without the reset, the first test to launch spends the notice for the rest. + _resetBetaNotice() + settingsModule.set(BETA_NOTICE_ANNOUNCED_ARGS_KEY, []) launchHarness.spawn = (_cmd: unknown, args: unknown) => { spawnArgs = args as string[] return fakeChild() @@ -960,6 +972,51 @@ describe('core beta report placement', () => { expect(reportedEvents()).toContain('comfy.desktop.core_beta.opt_state') }) + it('arms the activation notice from the same latch that reports the grant', async () => { + const id = 'harness-arms-beta-notice' + expect(peekBetaActivationNotice(id)).toEqual([]) + + const res = await handleLaunch(ctxFor(id)) + + expect(res.ok).toBe(true) + expect(peekBetaActivationNotice(id)).toEqual(['--enable-assets']) + }) + + it('arms nothing on a launch whose grants the args schema refused', async () => { + // A grant the running core cannot parse is dropped as `dropped_unsupported`, so the + // feature is NOT on and announcing it would be a lie. The schema is the gate the notice + // inherits by reading `applied` rather than the selected set. + launchHarness.schemaNames = ['listen', 'feature-flag'] + const id = 'harness-schema-refused' + + const res = await handleLaunch(ctxFor(id)) + + expect(res.ok).toBe(true) + expect(spawnArgs).not.toContain('--enable-assets') + expect(peekBetaActivationNotice(id)).toEqual([]) + }) + + it('arms nothing for an install that opted out of beta features', async () => { + launchHarness.betaEnabled = false + const id = 'harness-opted-out' + + const res = await handleLaunch(ctxFor(id)) + + expect(res.ok).toBe(true) + expect(peekBetaActivationNotice(id)).toEqual([]) + }) + + it('stays silent on the NEXT launch once the notice has been acknowledged', async () => { + const id = 'harness-announces-once' + await handleLaunch(ctxFor(id)) + acknowledgeBetaActivationNotice(id) + expect(settingsModule.get(BETA_NOTICE_ANNOUNCED_ARGS_KEY)).toEqual(['--enable-assets']) + + await handleLaunch(ctxFor(id)) + + expect(peekBetaActivationNotice(id)).toEqual([]) + }) + /** The commit `harnessInstall`'s record names, i.e. what the version gate believes is running. */ const RECORDED_COMMIT = '61e5e3b5a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4' /** A different commit, as a `git pull` would leave the checkout after the record was written. */ diff --git a/src/main/lib/ipc/sessionActions/launch.ts b/src/main/lib/ipc/sessionActions/launch.ts index 164dda849..4e9e4fa52 100644 --- a/src/main/lib/ipc/sessionActions/launch.ts +++ b/src/main/lib/ipc/sessionActions/launch.ts @@ -97,6 +97,7 @@ import { writeComfyEnvironment } from '../../../sources/standalone/envPaths' import type { PersistedTorchStack } from '../../../sources/standalone/torchStackTypes' import type { WriteStream } from 'fs' import { getCoreBetaGrantsAsync, selectCoreBetaGrantArgs } from '../../coreBetaGrants' +import { armBetaActivationNotice } from '../../betaActivationNotice' import type { CoreBetaGrant } from '../../coreBetaGrants' import { coreRecordCurrent, coreSemver, coreSemverExact, coreSemverVerified } from '../../version' import type { CoreCheckout } from '../../version' @@ -778,6 +779,13 @@ async function runLaunch( writeLog: (text) => writeLog(logStream, text), sendOutput }) + // Same latch, same reason: a grant is only worth announcing once it is provably on this + // launch's command line. Queued rather than shown — the host window may still be mid-attach + // or under the progress takeover, so the title bar drains this when its own gate opens. + armBetaActivationNotice( + installationId, + coreBeta.applied.map((grant) => grant.arg) + ) try { emitCoreBetaTelemetry({ appliedArgs: coreBeta.applied.map((grant) => grant.arg), diff --git a/src/main/lib/opsFlag.ts b/src/main/lib/opsFlag.ts index 6b2d766ec..8fe40af00 100644 --- a/src/main/lib/opsFlag.ts +++ b/src/main/lib/opsFlag.ts @@ -16,6 +16,8 @@ * rather than racing it to the default, and a fallback that survives both a rejection and an * unrecognised payload. See `cloudFreeRuns.ts` and `coreBetaGrants.ts` for the current callers. */ +import { app } from 'electron' +import fs from 'fs' import path from 'path' import { configDir } from './paths' import { readFileSafe, writeFileSafe } from './safe-file' @@ -42,7 +44,40 @@ interface PersistedFileRead { primaryUnreadable: boolean } +/** E2E-only: write `E2E_OPS_FLAGS_SEED` into `ops-flags.json` before the first read. + * + * The harness cannot place this file itself. It isolates a run by overriding `HOME`, but + * `configDir()` resolves to Electron's `userData` off Linux, and on macOS Application Support + * ignores that override — so a file the harness writes under its temp home is never read, and + * seeding the real path would write into the developer's own profile. `settings.json` has the + * same problem and solves it exactly this way; this mirrors `maybeSeedFromEnv` in + * `settings.ts`, including the packaged-build guard and dropping the var so the payload cannot + * reach spawned children. Runs at most once per process. */ +let e2eSeedApplied = false +function maybeSeedFromEnv(): void { + if (e2eSeedApplied) return + e2eSeedApplied = true + // Hard guard: never run in production builds. + if (app.isPackaged) return + if (process.env['E2E'] !== '1') return + const seed = process.env['E2E_OPS_FLAGS_SEED'] + if (!seed) return + delete process.env['E2E_OPS_FLAGS_SEED'] + try { + JSON.parse(seed) // validate before writing + const filePath = persistFilePath() + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + // Backup first, then primary — the same ordering `writePersistedResult` relies on, so a + // seeded run cannot be served a stale `.bak` from a previous one. + writeFileSafe(filePath + '.bak', seed) + writeFileSafe(filePath, seed) + } catch (err) { + console.warn('OpsFlag: failed to apply E2E_OPS_FLAGS_SEED:', (err as Error).message) + } +} + function readPersistedFile(): PersistedFileRead { + maybeSeedFromEnv() const outcome = readFileSafe(persistFilePath()) if (outcome.kind === 'unreadable') return { entries: {}, primaryUnreadable: true } if (outcome.kind !== 'data') return { entries: {}, primaryUnreadable: false } diff --git a/src/main/popups/titleCoachmark.test.ts b/src/main/popups/titleCoachmark.test.ts index 54cce885d..ea96dbaf5 100644 --- a/src/main/popups/titleCoachmark.test.ts +++ b/src/main/popups/titleCoachmark.test.ts @@ -20,6 +20,7 @@ vi.mock('./embeddedPopupView', () => ({ EmbeddedPopupView: class {} })) import { buildCoachmarkConfig, positionCoachmark, + COACHMARK_BEAK_EDGE_MARGIN, COACHMARK_SHADOW_GUTTER, COACHMARK_VERTICAL_GAP } from './titleCoachmark' @@ -27,18 +28,113 @@ import { describe('buildCoachmarkConfig', () => { it('stamps the coachmark variant and carries title + body + dismiss copy', () => { const cfg = buildCoachmarkConfig({ + kind: 'pill-hint', title: 'Switch & manage instances', body: 'Click here to switch instances.', dismissLabel: 'Got it', token: 'cm-1' }) expect(cfg.variant).toBe('coachmark') + expect(cfg.kind).toBe('pill-hint') expect(cfg.title).toBe('Switch & manage instances') expect(cfg.body).toBe('Click here to switch instances.') expect(cfg.dismissLabel).toBe('Got it') expect(cfg.configToken).toBe('cm-1') expect(cfg.theme.accent).toMatch(/^#/) }) + + // One popup backs both cards, and the renderer resets `actionLabel` on every config push. + // An action-less card must therefore omit the key outright rather than send an empty + // string, which the renderer would read the same way but which would also let a typo'd + // label reach the card as a blank button. + it('omits actionLabel entirely for a card with no secondary action', () => { + const cfg = buildCoachmarkConfig({ + kind: 'pill-hint', + title: 'Switch & manage instances', + body: 'Click here to switch instances.', + dismissLabel: 'Got it', + token: 'cm-1' + }) + expect('actionLabel' in cfg).toBe(false) + }) + + it('carries the action label and kind for the beta activation notice', () => { + const cfg = buildCoachmarkConfig({ + kind: 'beta-notice', + title: 'A beta feature is on', + body: 'This instance started with a beta feature enabled.', + dismissLabel: 'Got it', + actionLabel: 'Settings', + token: 'cm-2' + }) + expect(cfg.kind).toBe('beta-notice') + expect(cfg.actionLabel).toBe('Settings') + }) +}) + +describe('positionCoachmark beak tracking', () => { + const bubble = { width: 280, height: 90 } + const cardWidth = bubble.width + + /** Where the beak actually lands in parent coordinates. */ + function beakX(placement: ReturnType): number { + return placement.x + COACHMARK_SHADOW_GUTTER + placement.beakFraction * cardWidth + } + + it('centres the beak when the card is not clamped', () => { + const placement = positionCoachmark({ + anchor: { leftX: 500, rightX: 540, bottomY: 36 }, + bubble, + parentBounds: { width: 1200, height: 800 } + }) + expect(placement.beakFraction).toBeCloseTo(0.5, 5) + expect(beakX(placement)).toBeCloseTo(520, 5) + }) + + it('keeps the beak on the anchor when the card clamps at the right edge', () => { + // The bell is the first right-edge anchor; on macOS the card does not fit beside it, so + // the view clamps. A beak fixed at 50% would then point at whatever the clamp slid it onto. + const anchorCentre = 1140 + const placement = positionCoachmark({ + anchor: { leftX: 1130, rightX: 1150, bottomY: 36 }, + bubble, + parentBounds: { width: 1200, height: 800 } + }) + expect(placement.x + placement.width).toBeLessThanOrEqual(1200) + expect(placement.beakFraction).toBeGreaterThan(0.5) + expect(beakX(placement)).toBeCloseTo(anchorCentre, 5) + }) + + it('keeps the beak on the anchor when the card clamps at the left edge', () => { + // 45 is inside the card once clamped, but far enough from its left corner that the + // edge margin does not bite — so the beak can track the anchor exactly. + const anchorCentre = 45 + const placement = positionCoachmark({ + anchor: { leftX: 35, rightX: 55, bottomY: 36 }, + bubble, + parentBounds: { width: 1200, height: 800 } + }) + expect(placement.x).toBe(0) + expect(placement.beakFraction).toBeLessThan(0.5) + expect(beakX(placement)).toBeCloseTo(anchorCentre, 5) + }) + + it.each([ + ['right', { leftX: 1198, rightX: 1200, bottomY: 36 }], + ['left', { leftX: 0, rightX: 2, bottomY: 36 }] + ])('never lets the beak reach the card corners (%s)', (_side, anchor) => { + // An anchor hard against the window edge sits outside the clamped card, so exact tracking + // would put the beak off a rounded corner, detached from the edge it grows out of. The + // margin wins over tracking in that case, on purpose. + const placement = positionCoachmark({ + anchor, + bubble, + parentBounds: { width: 1200, height: 800 } + }) + const margin = COACHMARK_BEAK_EDGE_MARGIN / cardWidth + expect(placement.beakFraction).toBeLessThanOrEqual(1 - margin + 1e-9) + expect(placement.beakFraction).toBeGreaterThanOrEqual(margin - 1e-9) + }) }) describe('positionCoachmark', () => { diff --git a/src/main/popups/titleCoachmark.ts b/src/main/popups/titleCoachmark.ts index 06abd90a0..429b7cf14 100644 --- a/src/main/popups/titleCoachmark.ts +++ b/src/main/popups/titleCoachmark.ts @@ -4,11 +4,18 @@ import { TITLEBAR_HEIGHT } from '../lib/titleBarOverlay' import { EmbeddedPopupView } from './embeddedPopupView' /** - * First-instance onboarding coachmark popup: a sticky card with an upward beak - * pointing at the centre title-bar pill. Reuses the `comfyTitleTooltip` renderer - * (the `variant: 'coachmark'` config switches it to the beak/accent/dismiss card) - * but owns a separate popup view so its sticky lifecycle (no auto-hide on blur, - * since the dismiss button needs focus) doesn't fight the tooltip's auto-dismiss. + * Sticky title-bar coachmark popup: a card with an upward beak pointing at a title-bar + * element. Reuses the `comfyTitleTooltip` renderer (the `variant: 'coachmark'` config + * switches it to the beak/accent/dismiss card) but owns a separate popup view so its sticky + * lifecycle (no auto-hide on blur, since the dismiss button needs focus) doesn't fight the + * tooltip's auto-dismiss. + * + * Two callers share it: the first-instance onboarding hint pointing at the centre pill, and + * the Core beta activation notice pointing at the news bell. ONE popup per window on purpose + * — two sticky cards over the same canvas at once is worse than the later one replacing the + * earlier, and the renderer sequences them so that can only happen by accident. `kind` rides + * along on the config and is echoed back on dismiss/action so the title bar can route the + * result to the right owner. */ const COACHMARK_POPUP_INITIAL_WIDTH = 300 @@ -17,6 +24,9 @@ const COACHMARK_POPUP_INITIAL_HEIGHT = 96 export const COACHMARK_VERTICAL_GAP = 10 /** Gutter (px) reserved for the card's box-shadow + beak so neither gets clipped. */ export const COACHMARK_SHADOW_GUTTER = 18 +/** Keep the beak this far from the card's corners, so it always overlaps a straight edge + * rather than floating off a rounded one. */ +export const COACHMARK_BEAK_EDGE_MARGIN = 14 /** Fallback show timeout (ms) if the renderer's `:rendered` ack is slow. */ const COACHMARK_RENDER_ACK_TIMEOUT_MS = 120 @@ -27,11 +37,22 @@ export interface CoachmarkTheme { accent: string } +/** Which feature owns the card. Echoed back on dismiss/action so one popup can serve + * several owners without either acting on the other's click. */ +export type CoachmarkKind = 'pill-hint' | 'beta-notice' + export interface CoachmarkConfig { variant: 'coachmark' + kind: CoachmarkKind title: string body: string dismissLabel: string + /** Optional secondary action rendered beside dismiss. Omitted (not empty) when the card has + * no action, so the renderer can tell "no action" from "action with a missing label". */ + actionLabel?: string + /** Horizontal position of the beak as a fraction of the card's width. Sent on the SHOW push + * (after measuring), never on the initial config, because it depends on the measured size. */ + beakFraction?: number theme: CoachmarkTheme configToken: string } @@ -42,27 +63,43 @@ function resolveCoachmarkTheme(): CoachmarkTheme { } export function buildCoachmarkConfig(opts: { + kind: CoachmarkKind title: string body: string dismissLabel: string + actionLabel?: string token: string }): CoachmarkConfig { return { variant: 'coachmark', + kind: opts.kind, title: opts.title, body: opts.body, dismissLabel: opts.dismissLabel, + ...(opts.actionLabel ? { actionLabel: opts.actionLabel } : {}), theme: resolveCoachmarkTheme(), configToken: opts.token } } -/** Compute popup bounds centering the card under the pill, clamped to the parent. */ +/** Where the beak should sit, as a fraction of the CARD's width (0..1). `0.5` is the centre. + * Returned alongside the bounds because clamping moves the card without moving the anchor: + * a beak hard-fixed at 50% then points at whatever the clamp shifted it onto. */ +export interface CoachmarkPlacement { + x: number + y: number + width: number + height: number + beakFraction: number +} + +/** Compute popup bounds centering the card under the anchor, clamped to the parent, plus where + * the beak must sit within the card to keep pointing at the anchor after any clamp. */ export function positionCoachmark(opts: { anchor: { leftX: number; rightX: number; bottomY: number } bubble: { width: number; height: number } parentBounds: { width: number; height: number } -}): { x: number; y: number; width: number; height: number } { +}): CoachmarkPlacement { const viewWidth = Math.max( opts.bubble.width + COACHMARK_SHADOW_GUTTER * 2, COACHMARK_SHADOW_GUTTER * 2 + 1 @@ -82,7 +119,15 @@ export function positionCoachmark(opts: { if (y + viewHeight > opts.parentBounds.height) { y = Math.max(0, opts.parentBounds.height - viewHeight) } - return { x, y, width: viewWidth, height: viewHeight } + // The card is centred inside the view, so its left edge sits one gutter in. Express the + // anchor's centre as a fraction of the card, then clamp to the card's rounded corners so the + // beak can never detach from the card's own edge. + const cardWidth = Math.max(1, viewWidth - COACHMARK_SHADOW_GUTTER * 2) + const cardLeft = x + COACHMARK_SHADOW_GUTTER + const rawFraction = (pillCenter - cardLeft) / cardWidth + const beakMargin = COACHMARK_BEAK_EDGE_MARGIN / cardWidth + const beakFraction = Math.min(1 - beakMargin, Math.max(beakMargin, rawFraction)) + return { x, y, width: viewWidth, height: viewHeight, beakFraction } } let _coachmarkTokenSeq = 0 @@ -96,6 +141,9 @@ interface CoachmarkPopupEntry { pendingConfig: CoachmarkConfig | null pendingAnchor: { leftX: number; rightX: number; bottomY: number } | null pendingConfigToken: string | null + /** Owner of the card currently configured on this popup, so a dismiss or action click + * reaches the composable that raised it and not the other one. */ + kind: CoachmarkKind } const coachmarkPopupsByParent = new Map() @@ -132,7 +180,8 @@ function ensureCoachmarkPopup(parent: BrowserWindow): CoachmarkPopupEntry { view, pendingConfig: null, pendingAnchor: null, - pendingConfigToken: null + pendingConfigToken: null, + kind: 'pill-hint' } coachmarkPopupsByParent.set(view.parentWindowId, entry) coachmarkPopupsByWebContents.set(view.popupWebContentsId, entry) @@ -145,8 +194,14 @@ function repositionAndShow( ): void { if (!entry.pendingAnchor || entry.view.isDestroyed()) return const parentBounds = entry.view.parentWindow.getContentBounds() - const bounds = positionCoachmark({ anchor: entry.pendingAnchor, bubble, parentBounds }) + const { beakFraction, ...bounds } = positionCoachmark({ + anchor: entry.pendingAnchor, + bubble, + parentBounds + }) entry.view.popup.setBounds(bounds) + // Tell the card where to draw its beak now that the final, possibly clamped, x is known. + entry.view.popup.webContents.send('comfy-titletooltip:set-beak', { beakFraction }) // Focus so the dismiss button is keyboard-reachable. entry.view.showOnTop({ focus: true }) } @@ -158,9 +213,11 @@ export function hideCoachmarkPopup(entry: CoachmarkPopupEntry | undefined): void export function openCoachmarkPopup(opts: { parent: BrowserWindow + kind?: CoachmarkKind title: string body: string dismissLabel: string + actionLabel?: string leftX: number rightX: number bottomY: number @@ -170,12 +227,16 @@ export function openCoachmarkPopup(opts: { entry.pendingAnchor = { leftX: opts.leftX, rightX: opts.rightX, bottomY: opts.bottomY } const token = nextCoachmarkToken() + const kind = opts.kind ?? 'pill-hint' const config = buildCoachmarkConfig({ + kind, title: opts.title, body: opts.body, dismissLabel: opts.dismissLabel, + actionLabel: opts.actionLabel, token }) + entry.kind = kind entry.pendingConfigToken = token if (entry.view.rendererReady) { entry.view.popup.webContents.send('comfy-titletooltip:set-config', config) @@ -225,9 +286,11 @@ export function registerTitleCoachmarkIpc(opts: { ( event, payload: { + kind?: unknown title?: unknown body?: unknown dismissLabel?: unknown + actionLabel?: unknown leftX?: unknown rightX?: unknown bottomY?: unknown @@ -238,16 +301,22 @@ export function registerTitleCoachmarkIpc(opts: { const title = typeof payload?.title === 'string' ? payload.title : '' const body = typeof payload?.body === 'string' ? payload.body : '' if (!title && !body) return - // Renderer supplies the i18n label; main only forwards it. + // Renderer supplies the i18n labels; main only forwards them. const dismissLabel = typeof payload?.dismissLabel === 'string' ? payload.dismissLabel : '' + const actionLabel = typeof payload?.actionLabel === 'string' ? payload.actionLabel : undefined + // Unrecognised kinds fall back to the onboarding hint rather than being refused: an + // unroutable retirement would leave a card the title bar can never retire. + const kind: CoachmarkKind = payload?.kind === 'beta-notice' ? 'beta-notice' : 'pill-hint' const leftX = typeof payload?.leftX === 'number' ? payload.leftX : 0 const rightX = typeof payload?.rightX === 'number' ? payload.rightX : leftX const bottomY = typeof payload?.bottomY === 'number' ? payload.bottomY : TITLEBAR_HEIGHT openCoachmarkPopup({ parent, + kind, title, body, dismissLabel, + actionLabel, leftX: Math.round(leftX), rightX: Math.round(rightX), bottomY: Math.round(bottomY) @@ -261,16 +330,26 @@ export function registerTitleCoachmarkIpc(opts: { hideCoachmarkPopup(coachmarkPopupsByParent.get(parent.id)) }) - // Dismiss fires from the popup's webContents; the title-bar renderer owns the - // once-ever flag persistence, so tell it to flip. - ipcMain.on('comfy-titlecoachmark:dismiss', (event) => { - const entry = coachmarkPopupsByWebContents.get(event.sender.id) + /** Hide the card and tell the parent's title bar what happened to it. Both outcomes — + * dismiss and the secondary action — are retirements: the title-bar renderer owns the + * once-ever persistence for whichever `kind` raised the card, so it is told either way and + * decides what else the click means. */ + const retire = (senderId: number, channel: string): void => { + const entry = coachmarkPopupsByWebContents.get(senderId) if (!entry) return entry.view.hide() const parent = entry.view.parentWindow if (parent && !parent.isDestroyed()) { const tb = opts.findTitleBarByParent(parent) - if (tb && !tb.isDestroyed()) tb.send('comfy-titlebar:coachmark-dismissed') + if (tb && !tb.isDestroyed()) tb.send(channel, { kind: entry.kind }) } + } + + ipcMain.on('comfy-titlecoachmark:dismiss', (event) => { + retire(event.sender.id, 'comfy-titlebar:coachmark-dismissed') + }) + + ipcMain.on('comfy-titlecoachmark:action', (event) => { + retire(event.sender.id, 'comfy-titlebar:coachmark-action') }) } diff --git a/src/main/popups/titlePopup.ts b/src/main/popups/titlePopup.ts index ae23f5996..d0e80ed19 100644 --- a/src/main/popups/titlePopup.ts +++ b/src/main/popups/titlePopup.ts @@ -209,6 +209,10 @@ export interface GlobalSettingsSnapshot { * at open; live rebroadcasts carry null so a data refresh can never * retarget a tab the user has since navigated away from. */ initialTab: GlobalSettingsTab | null + /** Field id to scroll to and flash once the tab renders. Same per-open + * semantics as `initialTab`: non-null only on the snapshot pushed at open, + * so a live rebroadcast can never re-flash a row the user has moved past. */ + highlightFieldId: string | null languageFields: Record[] generalFields: Record[] telemetryFields: Record[] @@ -1403,11 +1407,16 @@ type OpenTitlePopupOpts = { * A non-null global-settings `initialTab` is a per-open command, not * state: the renderer may have navigated off that tab since the last * identical push, so the snapshot must be re-sent for the view's - * tab-retarget watch to fire. */ + * tab-retarget watch to fire. `highlightFieldId` is the same kind of + * command — a re-open asking for the same flash on an already-open popup + * must still reach the view. */ export function requiresPerOpenConfigSync( - opts: Pick & { snapshot?: { initialTab?: unknown } } + opts: Pick & { + snapshot?: { initialTab?: unknown; highlightFieldId?: unknown } + } ): boolean { - return opts.kind === POPUP_KIND.globalSettings && opts.snapshot?.initialTab != null + if (opts.kind !== POPUP_KIND.globalSettings) return false + return opts.snapshot?.initialTab != null || opts.snapshot?.highlightFieldId != null } function openTitlePopup(opts: OpenTitlePopupOpts): void { @@ -1735,7 +1744,8 @@ function openGlobalSettingsForHost( parentEntryId: number, bindings: TitlePopupHostBindings, titleBarSender: Electron.WebContents, - initialTab: GlobalSettingsTab | null = null + initialTab: GlobalSettingsTab | null = null, + highlightFieldId: string | null = null ): void { if (parentEntry.window.isDestroyed()) return // Open instantly off the cached snapshot — like the instance picker — so the @@ -1745,7 +1755,7 @@ function openGlobalSettingsForHost( parent: parentEntry.window, parentEntryId, kind: 'global-settings', - snapshot: buildGlobalSettingsSnapshot(undefined, initialTab), + snapshot: buildGlobalSettingsSnapshot(undefined, initialTab, highlightFieldId), anchor: { x: 0, y: TITLEBAR_HEIGHT }, theme: parentEntry.lastTheme, titleBarSender @@ -2132,7 +2142,8 @@ function findSettingsFields( function buildGlobalSettingsSnapshot( installs?: Pick<{ id: string; name: string }, 'id' | 'name'>[], - initialTab: GlobalSettingsTab | null = null + initialTab: GlobalSettingsTab | null = null, + highlightFieldId: string | null = null ): GlobalSettingsSnapshot { const settingsSections = buildSettingsSections(installs) const mediaSections = buildMediaSections() @@ -2159,6 +2170,7 @@ function buildGlobalSettingsSnapshot( const githubStarsLoading = githubStars == null && !githubStarsFetchAttempted return { initialTab, + highlightFieldId, languageFields, generalFields, telemetryFields, @@ -2981,37 +2993,47 @@ export function registerTitlePopupIpc(bindings: TitlePopupHostBindings): void { // Panel renderer → open the Global Settings popup for the sender's // host window. Used by the panel-side file-menu "Settings" item and // the `comfy://open-settings?tab=global` deep link. - ipcMain.on('comfy-titlepopup:open-global-settings', (event, payload?: { tab?: unknown }) => { - recordIpcInvocation('comfy-titlepopup:open-global-settings') - const win = BrowserWindow.fromWebContents(event.sender) - if (!win || win.isDestroyed()) return - let parentEntryId: number | undefined - let parentEntry: ComfyWindowEntry | undefined - for (const [id, e] of comfyWindows) { - if (e.window === win) { - parentEntryId = id - parentEntry = e - break + ipcMain.on( + 'comfy-titlepopup:open-global-settings', + (event, payload?: { tab?: unknown; highlightField?: unknown }) => { + recordIpcInvocation('comfy-titlepopup:open-global-settings') + const win = BrowserWindow.fromWebContents(event.sender) + if (!win || win.isDestroyed()) return + let parentEntryId: number | undefined + let parentEntry: ComfyWindowEntry | undefined + for (const [id, e] of comfyWindows) { + if (e.window === win) { + parentEntryId = id + parentEntry = e + break + } } + if (parentEntryId === undefined || !parentEntry) return + const rawTab = payload?.tab + const initialTab: GlobalSettingsTab | null = + rawTab === 'general' || + rawTab === 'updates' || + rawTab === 'storage' || + rawTab === 'advanced' || + rawTab === 'logs' + ? rawTab + : null + // Field ids are renderer-side identifiers, so this is forwarded as an opaque string + // rather than validated against a list main would have to keep in sync. A id matching + // no row simply finds nothing to flash. + const rawHighlight = payload?.highlightField + const highlightFieldId = + typeof rawHighlight === 'string' && rawHighlight ? rawHighlight : null + openGlobalSettingsForHost( + parentEntry, + parentEntryId, + bindings, + parentEntry.titleBarView.webContents, + initialTab, + highlightFieldId + ) } - if (parentEntryId === undefined || !parentEntry) return - const rawTab = payload?.tab - const initialTab: GlobalSettingsTab | null = - rawTab === 'general' || - rawTab === 'updates' || - rawTab === 'storage' || - rawTab === 'advanced' || - rawTab === 'logs' - ? rawTab - : null - openGlobalSettingsForHost( - parentEntry, - parentEntryId, - bindings, - parentEntry.titleBarView.webContents, - initialTab - ) - }) + ) // ---- Global-settings popup IPC ---- /** Resolve the popup entry for a settings IPC sender, or null if the sender diff --git a/src/main/settings.ts b/src/main/settings.ts index 5e5e14258..4ba1421f3 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -66,6 +66,12 @@ export interface KnownSettings { * rather than a reset of minimaxAnnouncementSeen: everyone who dismissed the * previous announcement must still get the bell for this one. */ cloudNodesAnnouncementSeen?: boolean + /** Core beta grants the activation notice has already announced, as the arg + * tokens themselves (`['--enable-assets']`). A list rather than a boolean so + * a beta feature granted later still gets its own heads-up; append-only, so + * a grant revoked and later re-granted stays silent the second time. Written + * when the user retires the card, never when it is merely shown. */ + betaNoticeAnnouncedArgs?: string[] /** When true, hide the Cloud tile (and the Try-Cloud CTA) from the * Dashboard / Instance Picker. Local-only users who never use Cloud * can opt out of seeing it without us removing the feature. Default @@ -274,6 +280,7 @@ const SETTINGS_SCHEMA = { firstUseCompleted: { nullable: false, telemetry: { policy: 'omit' } }, minimaxAnnouncementSeen: { nullable: false, telemetry: { policy: 'omit' } }, cloudNodesAnnouncementSeen: { nullable: false, telemetry: { policy: 'omit' } }, + betaNoticeAnnouncedArgs: { nullable: false, telemetry: { policy: 'omit' } }, hideCloudFromPicker: { nullable: false, telemetry: { policy: 'value', toTelemetry: (raw) => raw === true } diff --git a/src/preload/api.ts b/src/preload/api.ts index e68304902..fa9cd51f6 100644 --- a/src/preload/api.ts +++ b/src/preload/api.ts @@ -87,8 +87,11 @@ export function buildElectronApi(): ElectronApi { signalOverlayReady: () => ipcRenderer.send('comfy-window:overlay-ready'), resolveStartupRestoreReveal: (result) => ipcRenderer.send('comfy-window:startup-restore-reveal', { result }), - openGlobalSettings: (tab) => - ipcRenderer.send('comfy-titlepopup:open-global-settings', tab ? { tab } : undefined), + openGlobalSettings: (tab, opts) => + ipcRenderer.send( + 'comfy-titlepopup:open-global-settings', + tab || opts?.highlightField ? { tab, highlightField: opts?.highlightField } : undefined + ), openInstancePicker: (opts) => ipcRenderer.send('comfy-window:open-instance-picker-for-install', { installationId: opts?.installationId ?? null, @@ -191,6 +194,10 @@ export function buildElectronApi(): ElectronApi { getMediaSections: () => ipcRenderer.invoke('get-media-sections'), setSetting: (key, value) => ipcRenderer.invoke('set-setting', key, value), getSetting: (key) => ipcRenderer.invoke('get-setting', key), + getPendingBetaNotice: (installationId) => + ipcRenderer.invoke('get-pending-beta-notice', installationId), + acknowledgeBetaNotice: (installationId) => + ipcRenderer.invoke('acknowledge-beta-notice', installationId), // Theme getResolvedTheme: () => ipcRenderer.invoke('get-resolved-theme'), diff --git a/src/preload/comfyTitleBarPreload.ts b/src/preload/comfyTitleBarPreload.ts index fe387790b..c3600f666 100644 --- a/src/preload/comfyTitleBarPreload.ts +++ b/src/preload/comfyTitleBarPreload.ts @@ -5,6 +5,17 @@ import { normaliseFirstUseMode, type FirstUseMode } from '../shared/firstUseMode export type ComfyPanelKey = 'comfy' | 'new-install' | 'track' | 'load-snapshot' | 'quick-install' +/** Which feature owns a sticky coachmark card. One popup per window serves both, so every + * retirement is addressed with the kind that raised it. Mirrors `CoachmarkKind` in + * `src/main/popups/titleCoachmark.ts`. */ +export type CoachmarkKind = 'pill-hint' | 'beta-notice' + +/** Narrow an inbound retirement payload. An unrecognised or missing kind reads as the + * onboarding hint, matching main's own fallback, so a card can never become unretirable. */ +function coachmarkKindOf(payload?: { kind?: unknown }): CoachmarkKind { + return payload?.kind === 'beta-notice' ? 'beta-notice' : 'pill-hint' +} + /** Anchor coordinates for a native title-bar menu — title-bar-local * pixels (x = button left, y = button bottom). The titleBarView sits * at window y=0 so these coordinates double as window coordinates in @@ -210,25 +221,31 @@ export interface ComfyTitleBarBridge { /** Issue #514 — hide the title-bar hover tooltip popup. Sent on * pointer leave, focus loss, menu open, or panel switch. */ hideTooltip(): void - /** First-instance onboarding coachmark (issue #701) — show the sticky - * card pointing at the centre pill. Reuses the clip-escaping tooltip - * popup pipeline (`variant: 'coachmark'`). `leftX`/`rightX` bracket - * the pill's edges, `bottomY` is its bottom edge — title-bar-local - * px (the title-bar view sits at window (0,0)). */ + /** Sticky title-bar coachmark card (issue #701) — shown pointing at a title-bar + * element. Reuses the clip-escaping tooltip popup pipeline + * (`variant: 'coachmark'`). `leftX`/`rightX` bracket the anchor's edges, + * `bottomY` is its bottom edge — title-bar-local px (the title-bar view sits + * at window (0,0)). `kind` names the owning feature so its retirement comes + * back addressed; `actionLabel` adds a secondary button beside dismiss. */ showCoachmark(payload: { + kind?: CoachmarkKind title: string body: string dismissLabel: string + actionLabel?: string leftX: number rightX: number bottomY: number }): void - /** Hide the onboarding coachmark popup. */ + /** Hide the coachmark popup. */ hideCoachmark(): void /** Subscribe to the coachmark's own dismiss button. Main forwards this - * after the popup's ✕ / "Got it" is clicked so the renderer flips the - * once-ever `hasSeenCentralPillHint` flag via `window.api`. */ - onCoachmarkDismissed(cb: () => void): () => void + * after the popup's ✕ / "Got it" is clicked so the renderer can retire the + * card `kind` names (e.g. flipping `hasSeenCentralPillHint` via `window.api`). */ + onCoachmarkDismissed(cb: (payload: { kind: CoachmarkKind }) => void): () => void + /** Subscribe to the coachmark's secondary action. Retires the card the same way + * dismiss does, and additionally lets the owner run its follow-up. */ + onCoachmarkAction(cb: (payload: { kind: CoachmarkKind }) => void): () => void /** Tell main this title bar is mounted; main responds with the initial state. */ ready(): void } @@ -423,10 +440,17 @@ const bridge: ComfyTitleBarBridge = { ipcRenderer.send('comfy-window:hide-titlebar-coachmark') }, onCoachmarkDismissed: (cb) => { - const handler = (): void => cb() + const handler = (_event: IpcRendererEvent, payload?: { kind?: unknown }): void => + cb({ kind: coachmarkKindOf(payload) }) ipcRenderer.on('comfy-titlebar:coachmark-dismissed', handler) return () => ipcRenderer.removeListener('comfy-titlebar:coachmark-dismissed', handler) }, + onCoachmarkAction: (cb) => { + const handler = (_event: IpcRendererEvent, payload?: { kind?: unknown }): void => + cb({ kind: coachmarkKindOf(payload) }) + ipcRenderer.on('comfy-titlebar:coachmark-action', handler) + return () => ipcRenderer.removeListener('comfy-titlebar:coachmark-action', handler) + }, ready: () => { ipcRenderer.send('comfy-window:title-bar-ready') } diff --git a/src/preload/comfyTitleTooltipPreload.ts b/src/preload/comfyTitleTooltipPreload.ts index f457c886c..43ca24fee 100644 --- a/src/preload/comfyTitleTooltipPreload.ts +++ b/src/preload/comfyTitleTooltipPreload.ts @@ -8,12 +8,18 @@ import type { IpcRendererEvent } from 'electron' * across hovers, driven by `comfy-titletooltip:set-config` pushes. */ export interface TitleTooltipConfig { - /** `'tooltip'` (default) for the hover bubble; `'coachmark'` for the onboarding card. */ + /** `'tooltip'` (default) for the hover bubble; `'coachmark'` for a sticky card. */ variant?: 'tooltip' | 'coachmark' + /** Which feature owns a coachmark card. Opaque here — main routes on it; the renderer + * only echoes the card's shape. */ + kind?: string text?: string title?: string body?: string dismissLabel?: string + /** Secondary action beside dismiss, when the card has one (e.g. the beta activation + * notice's link to Settings). Absent means the card is dismiss-only. */ + actionLabel?: string theme: { bg: string; text: string; border: string; accent?: string } /** Echoed back in `notifyRendered` so main can discard stale render-acks. */ configToken: string @@ -25,8 +31,13 @@ export interface ComfyTitleTooltipBridge { /** Renderer painted the latest config. Main waits for this before showing. */ notifyRendered(payload: { width: number; height: number; configToken: string }): void onConfig(cb: (config: TitleTooltipConfig) => void): () => void + /** Beak position, pushed after main has measured the card and settled its final (possibly + * clamped) bounds. Separate from the config push because it is only knowable then. */ + onBeak(cb: (payload: { beakFraction: number }) => void): () => void /** Coachmark dismiss button; no-op for the tooltip variant. */ dismissCoachmark(): void + /** Coachmark secondary action. Also retires the card — acting on it is acknowledging it. */ + actionCoachmark(): void } function isTooltipConfig(value: unknown): value is TitleTooltipConfig { @@ -60,8 +71,19 @@ const bridge: ComfyTitleTooltipBridge = { ipcRenderer.on('comfy-titletooltip:set-config', handler) return () => ipcRenderer.removeListener('comfy-titletooltip:set-config', handler) }, + onBeak: (cb) => { + const handler = (_event: IpcRendererEvent, data: unknown): void => { + const raw = (data as { beakFraction?: unknown } | undefined)?.beakFraction + if (typeof raw === 'number' && Number.isFinite(raw)) cb({ beakFraction: raw }) + } + ipcRenderer.on('comfy-titletooltip:set-beak', handler) + return () => ipcRenderer.removeListener('comfy-titletooltip:set-beak', handler) + }, dismissCoachmark: () => { ipcRenderer.send('comfy-titlecoachmark:dismiss') + }, + actionCoachmark: () => { + ipcRenderer.send('comfy-titlecoachmark:action') } } diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts index 5158e9737..72ad2eb69 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts @@ -52,15 +52,18 @@ interface MockBridgeState { showTooltipCalls: { text: string; leftX: number; rightX: number; bottomY: number }[] hideTooltipCalls: number showCoachmarkCalls: { + kind?: string title: string body: string dismissLabel: string + actionLabel?: string leftX: number rightX: number bottomY: number }[] hideCoachmarkCalls: number - coachmarkDismissedCallbacks: (() => void)[] + coachmarkDismissedCallbacks: ((payload: { kind: string }) => void)[] + coachmarkActionCallbacks: ((payload: { kind: string }) => void)[] readyCalls: number } @@ -98,6 +101,7 @@ function installMockBridge( showCoachmarkCalls: [], hideCoachmarkCalls: 0, coachmarkDismissedCallbacks: [], + coachmarkActionCallbacks: [], readyCalls: 0 } const installationId = opts.installationId === undefined ? 'test-id' : opts.installationId @@ -220,10 +224,14 @@ function installMockBridge( hideCoachmark: () => { state.hideCoachmarkCalls += 1 }, - onCoachmarkDismissed: (cb: () => void) => { + onCoachmarkDismissed: (cb: (payload: { kind: string }) => void) => { state.coachmarkDismissedCallbacks.push(cb) return () => {} }, + onCoachmarkAction: (cb: (payload: { kind: string }) => void) => { + state.coachmarkActionCallbacks.push(cb) + return () => {} + }, ready: () => { state.readyCalls += 1 } @@ -1209,11 +1217,21 @@ describe('TitleBarApp', () => { describe('first-instance pill coachmark', () => { let getSetting: ReturnType let setSetting: ReturnType + let getPendingBetaNotice: ReturnType beforeEach(() => { getSetting = vi.fn().mockResolvedValue(undefined) setSetting = vi.fn().mockResolvedValue(undefined) - ;(window as unknown as { api: unknown }).api = { getSetting, setSetting } + // Nothing pending, so the beta notice never competes for the single popup and these + // assertions keep counting only pill-hint shows. + getPendingBetaNotice = vi.fn().mockResolvedValue([]) + ;(window as unknown as { api: unknown }).api = { + getSetting, + setSetting, + getPendingBetaNotice, + acknowledgeBetaNotice: vi.fn().mockResolvedValue(undefined), + openGlobalSettings: vi.fn() + } // Run the deferred rAF synchronously so the coachmark trigger // resolves within a flushPromises() tick. vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { @@ -1298,4 +1316,178 @@ describe('TitleBarApp', () => { wrapper.unmount() }) }) + + /** + * Core beta activation notice — the card raised when a launch turned a beta feature on for + * the first time. `hasSeenCentralPillHint: true` throughout so the onboarding hint is spent + * and the beta notice is the only card competing for the window's single popup; the one + * exception is the suppression test, which deliberately lets both want it at once. + */ + describe('core beta activation notice', () => { + let getPendingBetaNotice: ReturnType + let acknowledgeBetaNotice: ReturnType + let openGlobalSettings: ReturnType + + function installApiMock(opts: { pending?: string[]; pillHintSeen?: boolean } = {}): void { + getPendingBetaNotice = vi.fn().mockResolvedValue(opts.pending ?? ['--enable-assets']) + acknowledgeBetaNotice = vi.fn().mockResolvedValue(undefined) + openGlobalSettings = vi.fn() + ;(window as unknown as { api: unknown }).api = { + getSetting: vi + .fn() + .mockImplementation((key: string) => + key === 'hasSeenCentralPillHint' + ? Promise.resolve(opts.pillHintSeen !== false) + : Promise.resolve(undefined) + ), + setSetting: vi.fn().mockResolvedValue(undefined), + getPendingBetaNotice, + acknowledgeBetaNotice, + openGlobalSettings + } + } + + beforeEach(() => { + installApiMock() + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(0) + return 0 + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + delete (window as unknown as { api?: unknown }).api + }) + + async function mountBar(installationId: string | null = 'inst-1') { + bridgeState = installMockBridge({ installationId }) + vi.resetModules() + const { default: TitleBarApp } = await import('./TitleBarApp.vue') + const wrapper = mount(TitleBarApp, { attachTo: document.body }) + await flushPromises() + return wrapper + } + + const betaCards = () => bridgeState.showCoachmarkCalls.filter((c) => c.kind === 'beta-notice') + + it('shows the notice anchored at the news bell when main has one pending', async () => { + const wrapper = await mountBar() + expect(getPendingBetaNotice).toHaveBeenCalledWith('inst-1') + expect(betaCards().length).toBe(1) + const payload = betaCards()[0]! + expect(payload.title).toBe('A beta feature is on') + // Copy is generic on purpose: the card never names the arg, so it cannot be wrong + // about which feature turned on. + expect(payload.body).not.toContain('--enable-assets') + // The action is what makes it more than an FYI. + expect(payload.actionLabel).toBe('Settings') + wrapper.unmount() + }) + + it('stays silent when main has nothing pending', async () => { + installApiMock({ pending: [] }) + const wrapper = await mountBar() + expect(betaCards().length).toBe(0) + wrapper.unmount() + }) + + it('stays silent on an install-less (dashboard) window, which launched nothing', async () => { + const wrapper = await mountBar(null) + expect(getPendingBetaNotice).not.toHaveBeenCalled() + expect(betaCards().length).toBe(0) + wrapper.unmount() + }) + + it('defers while the onboarding pill hint owns the popup', async () => { + // Both want the single popup on this launch. The hint wins and the notice replays next + // launch, rather than replacing a card the user is mid-read of. + installApiMock({ pillHintSeen: false }) + const wrapper = await mountBar() + expect(bridgeState.showCoachmarkCalls.length).toBe(1) + expect(bridgeState.showCoachmarkCalls[0]!.kind).not.toBe('beta-notice') + // Nothing was acknowledged, so main still holds the pending notice. + expect(acknowledgeBetaNotice).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('acknowledges on dismiss so the notice never returns', async () => { + const wrapper = await mountBar() + bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) + await flushPromises() + expect(acknowledgeBetaNotice).toHaveBeenCalledWith('inst-1') + expect(bridgeState.hideCoachmarkCalls).toBeGreaterThan(0) + wrapper.unmount() + }) + + it('opens Settings on the beta opt-in row and retires the card in one click', async () => { + const wrapper = await mountBar() + bridgeState.coachmarkActionCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) + await flushPromises() + expect(openGlobalSettings).toHaveBeenCalledWith('general', { + highlightField: 'betaFeaturesEnabled' + }) + // Acting on the card acknowledges it: the user is now looking at the switch it named. + expect(acknowledgeBetaNotice).toHaveBeenCalledWith('inst-1') + wrapper.unmount() + }) + + it('survives the pill drawer opening: the hint must not hide a card it did not raise', async () => { + // One popup per window. The hint's retire path hides it unconditionally, and by the time + // the drawer is opened the hint has usually never been on it — so without an ownership + // check the card vanishes with nothing acknowledged. + const wrapper = await mountBar() + expect(betaCards().length).toBe(1) + const hidesBefore = bridgeState.hideCoachmarkCalls + + expect(await wrapper.find('.title-install-pill').exists()).toBe(true) + await wrapper.find('.title-install-pill').trigger('click') + await flushPromises() + + expect(bridgeState.hideCoachmarkCalls).toBe(hidesBefore) + expect(acknowledgeBetaNotice).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('retries once the onboarding hint releases the popup', async () => { + // Deferring is correct, but nothing in the gate watcher changes when the hint goes away, + // so the card would otherwise wait for the next launch. + installApiMock({ pillHintSeen: false }) + const wrapper = await mountBar() + expect(betaCards().length).toBe(0) + + bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'pill-hint' })) + await flushPromises() + + expect(betaCards().length).toBe(1) + wrapper.unmount() + }) + + it('acknowledges the install the card was raised for, not whatever the host retargets to', async () => { + // The card names "this instance". If the window attaches elsewhere while it floats, + // acknowledging the new install would permanently consume a notice never shown for it. + const wrapper = await mountBar() + expect(betaCards().length).toBe(1) + + bridgeState.installationIdChangedCallbacks.forEach((cb) => cb('inst-2')) + await flushPromises() + // The card belonged to inst-1, so it comes down — without being spent. + expect(acknowledgeBetaNotice).not.toHaveBeenCalled() + + bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) + await flushPromises() + expect(acknowledgeBetaNotice).not.toHaveBeenCalledWith('inst-2') + wrapper.unmount() + }) + + it('routes a pill-hint dismiss away from the beta notice', async () => { + // One popup, two owners: a retirement addressed to the hint must not spend the + // notice's once-ever acknowledgement. + const wrapper = await mountBar() + bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'pill-hint' })) + await flushPromises() + expect(acknowledgeBetaNotice).not.toHaveBeenCalled() + wrapper.unmount() + }) + }) }) diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.vue b/src/renderer/src/comfyTitleBar/TitleBarApp.vue index 4b2d7a54c..9649b1ace 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.vue +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.vue @@ -19,6 +19,7 @@ import { useTitleBarIdentity } from './useTitleBarIdentity' import { useUpdatePills } from './useUpdatePills' import { useTitleBarHoverGate } from './useTitleBarHoverGate' import { useCentralPillCoachmark } from './useCentralPillCoachmark' +import { useBetaActivationNotice } from './useBetaActivationNotice' import { useAppLocale, windowApiLocaleSource } from '../lib/useAppLocale' import ComfyCLogo from '../components/icons/ComfyCLogo.vue' @@ -31,6 +32,10 @@ const { syncLocale } = useAppLocale(windowApiLocaleSource()) // the ComfyPanelKey export in src/main/index.ts. type ComfyPanelKey = 'comfy' | 'new-install' | 'track' | 'load-snapshot' | 'quick-install' +/** Which feature owns the single sticky coachmark card. Same reason as `ComfyPanelKey` above: + * kept in sync with the `CoachmarkKind` union in src/preload/comfyTitleBarPreload.ts. */ +type CoachmarkKind = 'pill-hint' | 'beta-notice' + /** Position passed to main so the native menu pops below the anchor button. * Coordinates are in title-bar-local pixels — main translates to window * coordinates (titleBarView is at y=0 so they're already aligned). */ @@ -82,18 +87,24 @@ interface Bridge { showTooltip: (payload: { text: string; leftX: number; rightX: number; bottomY: number }) => void /** Issue #514 — hide the title-bar hover tooltip popup. */ hideTooltip: () => void - /** First-instance onboarding coachmark (issue #701) — show/hide the - * sticky card pointing at the centre pill; subscribe to its dismiss. */ + /** Sticky title-bar coachmark card (issue #701) — show/hide the card pointing at a + * title-bar element, and subscribe to how it was retired. One popup per window serves + * both the onboarding pill hint and the Core beta activation notice, so `kind` names the + * owner on the way out and back. */ showCoachmark: (payload: { + kind?: CoachmarkKind title: string body: string dismissLabel: string + actionLabel?: string leftX: number rightX: number bottomY: number }) => void hideCoachmark: () => void - onCoachmarkDismissed: (cb: () => void) => () => void + onCoachmarkDismissed: (cb: (payload: { kind: CoachmarkKind }) => void) => () => void + /** The card's secondary action, when it has one. Retires the card like dismiss does. */ + onCoachmarkAction: (cb: (payload: { kind: CoachmarkKind }) => void) => () => void onPanelChanged: (cb: (panel: ComfyPanelKey) => void) => () => void onTitleChanged: (cb: (title: string) => void) => () => void /** Install source-category pushes from main. The raw category @@ -214,6 +225,7 @@ const activePanel = ref('comfy') * via `onInstallationIdChanged` pushes from main as the host transitions * across attach / detach without a title-bar URL reload. */ +const installationId = ref(bridge?.getInstallationId() ?? '') const isInstallLess = ref((bridge?.getInstallationId() ?? '') === '') const { @@ -330,6 +342,8 @@ onUnmounted(() => { const titleBarRef = useTemplateRef('titleBar') const fileBtnRef = useTemplateRef('fileBtn') const downloadsBtnRef = useTemplateRef('downloadsBtn') +/** News bell — also the anchor the beta activation notice's beak points at. */ +const announcementBtnRef = useTemplateRef('announcementBtn') const installPillRef = useTemplateRef('installPill') const titleTrailingRef = useTemplateRef('titleTrailing') @@ -511,19 +525,53 @@ const coachmark = useCentralPillCoachmark({ isInstallLess, isFirstUseLockdown, isLoadingLockdown, + // One popup per window: the hint must not hide a card it did not raise. + ownsPopup: () => coachmark.isShowing.value, installPillRef, title: t('titleBar.pillHintTitle'), body: t('titleBar.pillHintBody'), dismissLabel: t('titleBar.pillHintDismiss') }) +/** + * Core beta activation notice pointing at the news bell — a beta feature turned on for this + * install, here is how to turn it off. Suppressed while the onboarding hint is up: one popup + * per window backs both cards, so the later show would replace the earlier one mid-read. + * The hint is once-ever on first instance entry, so this only defers a brand-new user's + * notice to their next launch. + */ +const betaNotice = useBetaActivationNotice({ + bridge, + installationId: () => installationId.value, + isInstallLess, + isFirstUseLockdown, + isLoadingLockdown, + anchorRef: announcementBtnRef, + isSuppressed: () => coachmark.isShowing.value, + title: t('titleBar.betaNoticeTitle'), + body: t('titleBar.betaNoticeBody'), + dismissLabel: t('titleBar.betaNoticeDismiss'), + actionLabel: t('titleBar.betaNoticeSettings') +}) + /** Wrap the pill opener so opening the drawer retires the coachmark * (the hint did its job) before delegating to the real handler. */ function handleInstallPillWithCoachmark(): void { - void coachmark.acknowledgeViaPillOpen() + void coachmark.acknowledgeViaPillOpen().then(retryBetaNoticeAfterHint) handleInstallPill() } +/** The beta notice defers while the hint owns the popup, and nothing in the gate watcher + * changes when the hint goes away — so without this the deferred card waits for the next + * launch. Safe to call unconditionally: `maybeShow` re-checks the gate and main still holds + * the pending notice (deferring never acknowledges). */ +function retryBetaNoticeAfterHint(): void { + if (unmounted) return + void nextTick().then(() => { + if (!unmounted) void betaNotice.maybeShow() + }) +} + /** One-shot "downloads started" attention flash. Driven by * `downloadsStartedAt` from the menus composable, which bumps each * time a brand-new active download appears. The flash is purely @@ -547,6 +595,7 @@ let unsubPanel: (() => void) | undefined let unsubInstallationId: (() => void) | undefined let unsubZoom: (() => void) | undefined let unsubCoachmarkDismissed: (() => void) | undefined +let unsubCoachmarkAction: (() => void) | undefined onMounted(() => { // Observe the trailing cluster so the left cluster can mirror its @@ -592,13 +641,28 @@ onMounted(() => { unsubZoom = bridge.onZoomChanged((level) => { zoomLevel.value = level }) - unsubInstallationId = bridge.onInstallationIdChanged((installationId) => { - isInstallLess.value = installationId === null + unsubInstallationId = bridge.onInstallationIdChanged((nextInstallationId) => { + const previous = installationId.value + installationId.value = nextInstallationId ?? '' + isInstallLess.value = nextInstallationId === null + // The card names "this instance", so it must not outlive the host retargeting to another. + // Forgotten rather than retired: it was never acknowledged, so it replays for its own + // install instead of being spent on one the user never saw it for. + if (previous !== installationId.value && betaNotice.isShowing.value) { + bridge.hideCoachmark() + betaNotice.forgetWithoutAcknowledging() + } }) // The popup's own dismiss button (✕ / "Got it") routes through main - // back to here — flip the once-ever flag + hide. - unsubCoachmarkDismissed = bridge.onCoachmarkDismissed(() => { - void coachmark.dismiss() + // back to here — flip the once-ever flag + hide. One popup serves both cards, + // so the retirement arrives addressed with the kind that raised it. + unsubCoachmarkDismissed = bridge.onCoachmarkDismissed(({ kind }) => { + if (kind === 'beta-notice') void betaNotice.dismiss() + else void coachmark.dismiss().then(retryBetaNoticeAfterHint) + }) + // Secondary action; only the beta notice has one today. + unsubCoachmarkAction = bridge.onCoachmarkAction(({ kind }) => { + if (kind === 'beta-notice') void betaNotice.openSettings() }) bridge.ready() }) @@ -615,7 +679,13 @@ watch( void nextTick().then(() => { requestAnimationFrame(() => { if (unmounted) return - void coachmark.maybeShow() + // AWAITED, not fired alongside: the hint's `maybeShow` suspends on an IPC read before + // it sets `isShowing`, so launching both together would leave the beta notice's + // suppression check reading a stale `false`. It happens to work today only because the + // two IPC replies come back in call order; awaiting makes the dependency real. + void coachmark.maybeShow().then(() => { + if (!unmounted) void betaNotice.maybeShow() + }) }) }) }, @@ -646,6 +716,7 @@ onUnmounted(() => { unsubInstallationId?.() unsubZoom?.() unsubCoachmarkDismissed?.() + unsubCoachmarkAction?.() bridge?.hideCoachmark() hideTip() trailingObserver?.disconnect() @@ -860,6 +931,7 @@ onUnmounted(() => { +
+ + +
{ } /* Upward beak: a rotated square sharing the card's bg + border. */ +/* `left` is set inline from the measured anchor position; 50% is the fallback for a card + whose beak push never arrived. */ .coachmark-beak { position: absolute; top: -6px; @@ -241,6 +286,15 @@ onUnmounted(() => { opacity: 0.88; } +/* Action (when present) sits left of dismiss, which stays the rightmost button so its + position doesn't move between a dismiss-only and an actioned card. */ +.coachmark-actions { + display: flex; + align-items: center; + gap: 14px; +} + +.coachmark-action, .coachmark-dismiss { appearance: none; background: transparent; @@ -252,6 +306,7 @@ onUnmounted(() => { cursor: pointer; } +.coachmark-action:hover, .coachmark-dismiss:hover { text-decoration: underline; } diff --git a/src/renderer/src/views/comfyUISettings/SettingsSectionList.vue b/src/renderer/src/views/comfyUISettings/SettingsSectionList.vue index 2ed35a69c..7da4b532a 100644 --- a/src/renderer/src/views/comfyUISettings/SettingsSectionList.vue +++ b/src/renderer/src/views/comfyUISettings/SettingsSectionList.vue @@ -273,9 +273,14 @@ function fieldOwnsLabel(field: DetailField): boolean { class="settings-v2-field-row" :class="{ 'is-paired': row.length > 1 }" > +
getSetting(key: string): Promise + // Core beta activation notice + /** Core beta args this install turned on for the first time and has not + * announced yet, or `[]`. Read repeatedly without side effects — the + * pending set is only cleared by `acknowledgeBetaNotice`, so a card that is + * shown but never retired comes back on the next launch. */ + getPendingBetaNotice(installationId: string): Promise + /** Retire this install's activation notice: its args are persisted as + * announced and never raise a card again. Called when the user dismisses + * the card or follows its settings link. */ + acknowledgeBetaNotice(installationId: string): Promise + // Theme getResolvedTheme(): Promise From b57efe2e641a5738cf3432241060b8658fccb239 Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Sun, 20 Sep 2026 22:29:34 -0700 Subject: [PATCH 02/27] feat(core-beta): let the PostHog payload word the activation notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on the notice itself. Two optional per-flag fields, so ops can say what a beta is called and which ones are worth a card at all: { "arg": "--enable-assets", "min_core_version": "0.3.80", "description": "Assets browser", "notice": "silent" } `description` names the feature on the card ("The Assets browser beta is on") instead of the generic wording. It comes from the payload rather than a table here because the allowlist is deliberately installed AHEAD of the features it names — a Desktop-side map would have to ship before anyone knew what to call them. `notice: "silent"` suppresses the card without touching the grant. Not every granted flag is user-visible, and a card for a diagnostic rollout is noise that trains people to dismiss the real ones. Only the exact string suppresses: `notice: true` reads as "yes, notify" at least as readily as "yes, silent", and a rollout silenced by accident is invisible until someone asks why nobody was told. Both fields are OPTIONAL and non-load-bearing, which the parser tests pin against the exact shape of the live acceptance-test flag: an entry carrying only `arg` + `min_core_version` still grants, and a malformed or over-long description costs the card its wording, never the user their flag. This also unlocks the one case PR 1 had to leave silent: a `--disable-*` remote force-off now announces IF the payload names it, with its own wording ("The Assets browser beta is off"). Unnamed force-offs stay silent, because the generic copy describes turning something on and there would be nothing truthful to put on the card. Direction is derived from the prefix PAIR rather than as a binary else, so a future allowlist entry with neither prefix stays silent instead of being announced as a withdrawal it is not. A card covers ONE direction and acknowledges only the grants it actually described. A launch can both enable and withdraw; collapsing those into one card would describe the enable and then consume the withdrawal too, and since the announced list is append-only that withdrawal could never be told on any install. The leftovers stay queued and get their own, correctly worded card. `BetaActivationNotice` is defined once in main and re-exported, per `src/types/ipc.ts`'s own "do not duplicate these types elsewhere": an independent copy would drift silently, since `ipcMain.handle` is ungeneric and `ipcRenderer.invoke` returns `Promise`. Proven by `e2e/beta-activation-notice-named.test.ts`, which seeds the wire payload and drives a real launch. Co-Authored-By: Claude Opus 5 --- e2e/beta-activation-notice-named.test.ts | 135 +++++++++ e2e/beta-activation-notice.test.ts | 4 +- e2e/support/fakeComfyInstall.ts | 15 +- locales/en.json | 6 + locales/zh.json | 6 + src/main/lib/betaActivationNotice.test.ts | 266 +++++++++++++++--- src/main/lib/betaActivationNotice.ts | 121 ++++++-- src/main/lib/coreBetaGrants.test.ts | 79 ++++++ src/main/lib/coreBetaGrants.ts | 67 ++++- .../lib/ipc/sessionActions/launch.test.ts | 40 ++- src/main/lib/ipc/sessionActions/launch.ts | 5 +- .../src/comfyTitleBar/TitleBarApp.test.ts | 55 +++- .../src/comfyTitleBar/TitleBarApp.vue | 18 +- .../comfyTitleBar/useBetaActivationNotice.ts | 43 ++- .../src/comfyTitleTooltip/TitleTooltipApp.vue | 8 + src/renderer/src/types/ipc.ts | 1 + src/types/ipc.ts | 18 +- 17 files changed, 765 insertions(+), 122 deletions(-) create mode 100644 e2e/beta-activation-notice-named.test.ts diff --git a/e2e/beta-activation-notice-named.test.ts b/e2e/beta-activation-notice-named.test.ts new file mode 100644 index 000000000..0aa5ef775 --- /dev/null +++ b/e2e/beta-activation-notice-named.test.ts @@ -0,0 +1,135 @@ +/** + * E2E: payload-controlled wording for the Core beta activation notice. + * + * The companion spec (`beta-activation-notice.test.ts`) covers the generic card and the whole + * trigger path. This one covers only what the PostHog payload adds: a `description` reaching + * the card as a feature name, through the real parser and the real launch. + * + * Tagged `@linux @macos`, never `@windows`, for the reason documented in + * `fakeComfyInstall.ts`: the interpreter stub cannot be a PE executable. + * + * Run: `pnpm exec playwright test --project=linux e2e/beta-activation-notice-named.test.ts` + */ +import os from 'node:os' +import path from 'node:path' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { expect, test, type ElectronApplication } from '@playwright/test' +import { launchApp, type AppContext } from './launchApp' +import { clickInstallTile, expectChooserVisible } from './support/chooserHelpers' +import { WebContentsPage } from './support/cdpPages' +import { opsFlagsGrantSeed, writeFakeComfyInstall } from './support/fakeComfyInstall' +import { captureHostWindow } from './support/windowCapture' + +// A real launch does not fit the default 45s budget. +test.describe.configure({ mode: 'serial', timeout: 180_000 }) + +const INSTALL_ID = 'inst-beta-notice-named' +const INSTALL_NAME = 'Named Beta Fixture' +const PORT = 49519 +const GRANT_ARG = '--enable-assets' +const GRANT_MIN_CORE = '0.3.80' +/** What the payload calls the feature. Deliberately not derivable from the arg token, so a + * card showing it proves the payload reached the copy rather than a table in Desktop. */ +const FEATURE_NAME = 'Assets browser' + +let ctx: AppContext +let installPath: string +let previousPosthogHost: string | undefined + +/** See the companion spec: a closed port makes the flag fetch `unreachable`, which is the + * documented path where the persisted `ops-flags.json` is authoritative. */ +const UNREACHABLE_POSTHOG_HOST = 'http://127.0.0.1:1' + +function coachmarkPopup(app: ElectronApplication): WebContentsPage { + return new WebContentsPage(app, 'comfyTitleTooltip') +} + +test.beforeAll(async () => { + previousPosthogHost = process.env['POSTHOG_HOST'] + process.env['POSTHOG_HOST'] = UNREACHABLE_POSTHOG_HOST + + installPath = await mkdtemp(path.join(os.tmpdir(), 'comfyui-beta-notice-named-')) + await writeFakeComfyInstall({ installPath, port: PORT }) + + ctx = await launchApp({ + settings: { + firstUseCompleted: true, + telemetryEnabled: true, + betaFeaturesEnabled: true, + hasSeenCentralPillHint: true + }, + installations: [ + { + id: INSTALL_ID, + name: INSTALL_NAME, + sourceId: 'comfybuilder', + sourceLabel: 'ComfyBuilder', + installPath, + status: 'installed', + launchArgs: `--port ${PORT}`, + launchMode: 'window', + browserPartition: 'unique', + seen: true, + comfyVersion: { + commit: 'b1c2d3e4f5a6b1c2d3e4f5a6b1c2d3e4f5a6b1c2', + baseTag: 'v0.3.99', + commitsAhead: 0, + baseTagVerified: true + } + } + ], + // Env-delivered so main writes it to the real `configDir()`; the harness cannot place + // that file on macOS, where `userData` ignores the HOME override. + opsFlags: opsFlagsGrantSeed({ + arg: GRANT_ARG, + minCoreVersion: GRANT_MIN_CORE, + description: FEATURE_NAME + }) + }) + await expectChooserVisible(ctx.panel) +}) + +test.afterAll(async () => { + await ctx?.cleanup() + if (installPath) await rm(installPath, { recursive: true, force: true }) + if (previousPosthogHost === undefined) delete process.env['POSTHOG_HOST'] + else process.env['POSTHOG_HOST'] = previousPosthogHost +}) + +test('a payload-supplied feature name reaches the card @linux @macos', async () => { + await clickInstallTile(ctx.panel, INSTALL_NAME) + + await ctx.panel.waitFor( + async () => + (await ctx.app.evaluate( + ({ webContents }, port) => + webContents.getAllWebContents().some((wc) => wc.getURL().includes(String(port))), + PORT + )) === true, + { timeout: 90_000, message: 'ComfyUI stub never came up / the host never attached' } + ) + + const popup = coachmarkPopup(ctx.app) + await popup.waitFor( + async () => { + try { + return await popup.exists('.coachmark') + } catch { + return false + } + }, + { timeout: 30_000, message: 'beta activation notice never appeared' } + ) + + expect(await popup.textOf('.coachmark-title')).toBe(`The ${FEATURE_NAME} beta is on`) + expect(await popup.textOf('.coachmark-text')).toContain(FEATURE_NAME) + // Naming the feature must not cost the card its way out. + expect(await popup.textOf('.coachmark-action')).toBe('Settings') + // The raw token still never reaches the user. + expect(await popup.textOf('.coachmark-text')).not.toContain(GRANT_ARG) + + const shotPath = path.join(test.info().outputDir, '03-notice-named-by-payload.png') + await mkdir(path.dirname(shotPath), { recursive: true }) + const shot = await captureHostWindow(ctx.app, ctx.panel, shotPath) + test.info().attach('named notice', { path: shot, contentType: 'image/png' }) +}) diff --git a/e2e/beta-activation-notice.test.ts b/e2e/beta-activation-notice.test.ts index 08dfcb5c2..030c31a75 100644 --- a/e2e/beta-activation-notice.test.ts +++ b/e2e/beta-activation-notice.test.ts @@ -256,10 +256,10 @@ test('the settings link lands on the beta opt-in row and retires the card @linux test('the notice is spent: a second launch stays silent @linux @macos', async () => { // The whole point of persisting on retire rather than on show. Read through the same IPC // the title bar uses, so this asserts the contract the renderer actually depends on. - const stillPending = await ctx.titleBar.evaluate( + const stillPending = await ctx.titleBar.evaluate( `window.api.getPendingBetaNotice(${JSON.stringify(INSTALL_ID)})`, ) - expect(stillPending).toEqual([]) + expect(stillPending).toBeNull() const announced = await ctx.titleBar.evaluate( `window.api.getSetting('betaNoticeAnnouncedArgs')`, diff --git a/e2e/support/fakeComfyInstall.ts b/e2e/support/fakeComfyInstall.ts index 7b72f3690..cdcf0c126 100644 --- a/e2e/support/fakeComfyInstall.ts +++ b/e2e/support/fakeComfyInstall.ts @@ -152,11 +152,24 @@ export async function writeFakeComfyInstall(opts: { export function opsFlagsGrantSeed(opts: { arg: string minCoreVersion: string + /** Optional per-flag notice wording, written in the payload's own wire shape so the fixture + * exercises the real parser rather than the already-parsed type. */ + description?: string + notice?: 'silent' }): Record { return { desktop_core_beta_features: { value: 'treatment', - payload: { flags: [{ arg: opts.arg, min_core_version: opts.minCoreVersion }] }, + payload: { + flags: [ + { + arg: opts.arg, + min_core_version: opts.minCoreVersion, + ...(opts.description === undefined ? {} : { description: opts.description }), + ...(opts.notice === undefined ? {} : { notice: opts.notice }), + }, + ], + }, }, } } diff --git a/locales/en.json b/locales/en.json index 0f7b475a2..f42181b02 100644 --- a/locales/en.json +++ b/locales/en.json @@ -89,6 +89,12 @@ "pillHintDismiss": "Got it", "betaNoticeTitle": "A beta feature is on", "betaNoticeBody": "This instance started with a beta feature enabled. You can turn beta features off in Settings.", + "betaNoticeTitleNamed": "The {feature} beta is on", + "betaNoticeBodyNamed": "This instance started with the {feature} beta enabled. You can turn beta features off in Settings.", + "betaNoticeOffTitle": "A beta feature is off", + "betaNoticeOffBody": "We've turned a beta feature off for this instance. You can manage beta features in Settings.", + "betaNoticeOffTitleNamed": "The {feature} beta is off", + "betaNoticeOffBodyNamed": "We've turned the {feature} beta off for this instance. You can manage beta features in Settings.", "betaNoticeDismiss": "Got it", "betaNoticeSettings": "Settings" }, diff --git a/locales/zh.json b/locales/zh.json index ec3e0f503..a8562712b 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -89,6 +89,12 @@ "pillHintDismiss": "知道了", "betaNoticeTitle": "已启用 Beta 功能", "betaNoticeBody": "此实例启动时启用了一项 Beta 功能。你可以在设置中关闭 Beta 功能。", + "betaNoticeTitleNamed": "已启用 {feature} Beta 功能", + "betaNoticeBodyNamed": "此实例启动时启用了 {feature} Beta 功能。你可以在设置中关闭 Beta 功能。", + "betaNoticeOffTitle": "已关闭一项 Beta 功能", + "betaNoticeOffBody": "我们已为此实例关闭了一项 Beta 功能。你可以在设置中管理 Beta 功能。", + "betaNoticeOffTitleNamed": "已关闭 {feature} Beta 功能", + "betaNoticeOffBodyNamed": "我们已为此实例关闭了 {feature} Beta 功能。你可以在设置中管理 Beta 功能。", "betaNoticeDismiss": "知道了", "betaNoticeSettings": "设置" }, diff --git a/src/main/lib/betaActivationNotice.test.ts b/src/main/lib/betaActivationNotice.test.ts index 361e02c77..f70cf92e2 100644 --- a/src/main/lib/betaActivationNotice.test.ts +++ b/src/main/lib/betaActivationNotice.test.ts @@ -20,45 +20,110 @@ import { armBetaActivationNotice, peekBetaActivationNotice, readAnnouncedBetaArgs, - selectNewlyActiveBetaArgs + resolveBetaActivationNotice, + selectNewlyActiveBetaGrants } from './betaActivationNotice' +import type { CoreBetaGrant } from './coreBetaGrants' const announced = (): unknown => store.get(BETA_NOTICE_ANNOUNCED_ARGS_KEY) +/** A grant as `buildLaunchArgs` hands it over: the version window is already spent by then, + * so only the arg and the payload's notice wording matter here. */ +function grant(arg: string, notice?: CoreBetaGrant['notice']): CoreBetaGrant { + return { arg, minCoreVersion: '0.3.80', ...(notice ? { notice } : {}) } +} + +/** The args a set of applied grants would announce. */ +function announcedArgsFor( + applied: readonly CoreBetaGrant[], + spokenFor: ReadonlySet = new Set() +): string[] { + return selectNewlyActiveBetaGrants(applied, spokenFor).map((g) => g.arg) +} + +const pendingArgs = (installationId: string): string[] => + peekBetaActivationNotice(installationId)?.args.slice() ?? [] + beforeEach(() => { store.clear() _resetForTest() }) -describe('selectNewlyActiveBetaArgs', () => { +describe('selectNewlyActiveBetaGrants', () => { it('announces an enable-grant nobody has spoken for yet', () => { - expect(selectNewlyActiveBetaArgs(['--enable-assets'], new Set())).toEqual(['--enable-assets']) + expect(announcedArgsFor([grant('--enable-assets')])).toEqual(['--enable-assets']) }) - it('never announces a disable-grant', () => { - // `--disable-assets` is the remote force-OFF. The card says "a beta feature is on" and - // points at the opt-out switch, so announcing one would state the opposite of what - // happened and offer an action that does not apply. - expect(selectNewlyActiveBetaArgs(['--disable-assets'], new Set())).toEqual([]) - expect(selectNewlyActiveBetaArgs(['--disable-assets', '--enable-agent'], new Set())).toEqual([ + it('does not announce an unnamed disable-grant', () => { + // `--disable-assets` is the remote force-OFF. The generic copy says a feature is on and + // points at the opt-out, so with no payload-supplied name there is nothing truthful to + // put on a card. + expect(announcedArgsFor([grant('--disable-assets')])).toEqual([]) + expect(announcedArgsFor([grant('--disable-assets'), grant('--enable-agent')])).toEqual([ '--enable-agent' ]) }) + it('stays silent for an arg with neither prefix, rather than calling it a force-off', () => { + // The allowlist is documented as growing ahead of Core. A future entry with neither prefix + // plus a description would otherwise render "The X beta is off" for something switched ON + // — a degradation from silence to a false statement. + expect( + selectNewlyActiveBetaGrants( + [{ arg: '--use-assets', minCoreVersion: '0.3.80', notice: { description: 'Assets' } }], + new Set() + ) + ).toEqual([]) + }) + + it('announces a NAMED disable-grant, because the payload supplied what was missing', () => { + const fresh = selectNewlyActiveBetaGrants( + [grant('--disable-assets', { description: 'Assets browser' })], + new Set() + ) + expect(fresh).toEqual([ + { arg: '--disable-assets', direction: 'disabled', description: 'Assets browser' } + ]) + }) + + it('honours a payload that asked for no card', () => { + // Not every granted flag is user-visible; ops can grant one without training people to + // dismiss cards. + expect(announcedArgsFor([grant('--enable-assets', { silent: true })])).toEqual([]) + }) + + it('silences only the grant that asked for it', () => { + expect( + announcedArgsFor([grant('--enable-assets', { silent: true }), grant('--enable-agent')]) + ).toEqual(['--enable-agent']) + }) + + it('carries a payload-supplied feature name through to the card', () => { + expect( + selectNewlyActiveBetaGrants( + [grant('--enable-assets', { description: 'Assets browser' })], + new Set() + ) + ).toEqual([{ arg: '--enable-assets', direction: 'enabled', description: 'Assets browser' }]) + }) + it('withholds an arg already spoken for', () => { - expect(selectNewlyActiveBetaArgs(['--enable-assets'], new Set(['--enable-assets']))).toEqual([]) + expect(announcedArgsFor([grant('--enable-assets')], new Set(['--enable-assets']))).toEqual([]) }) it('announces a LATER grant even once an earlier one is spoken for', () => { // The whole reason the store is a list rather than a boolean: a second beta feature // months from now still owes the user a heads-up. expect( - selectNewlyActiveBetaArgs(['--enable-assets', '--enable-agent'], new Set(['--enable-assets'])) + announcedArgsFor( + [grant('--enable-assets'), grant('--enable-agent')], + new Set(['--enable-assets']) + ) ).toEqual(['--enable-agent']) }) it('collapses a repeated arg so one launch cannot double-announce it', () => { - expect(selectNewlyActiveBetaArgs(['--enable-assets', '--enable-assets'], new Set())).toEqual([ + expect(announcedArgsFor([grant('--enable-assets'), grant('--enable-assets')])).toEqual([ '--enable-assets' ]) }) @@ -78,6 +143,64 @@ describe('the allowlist invariant this module depends on', () => { }) }) +describe('resolveBetaActivationNotice', () => { + it('has nothing to show when nothing is pending', () => { + expect(resolveBetaActivationNotice([])).toBeNull() + }) + + it('names the feature when the card covers exactly one named grant', () => { + expect( + resolveBetaActivationNotice([ + { arg: '--enable-assets', direction: 'enabled', description: 'Assets browser' } + ]) + ).toEqual({ args: ['--enable-assets'], direction: 'enabled', description: 'Assets browser' }) + }) + + it('covers only the grants matching the direction it reports', () => { + // A launch can both enable and withdraw. One card cannot honestly describe both, so it + // takes the enables and leaves the withdrawal queued for its own card. + const notice = resolveBetaActivationNotice([ + { arg: '--enable-agent', direction: 'enabled', description: null }, + { arg: '--disable-assets', direction: 'disabled', description: 'Assets browser' } + ]) + expect(notice).toEqual({ args: ['--enable-agent'], direction: 'enabled', description: null }) + }) + + it('drops the name when the card covers two grants', () => { + // Two features at once have no single honest name, so the card falls back to generic + // rather than naming one of them and implying it is the whole story. + expect( + resolveBetaActivationNotice([ + { arg: '--enable-assets', direction: 'enabled', description: 'Assets browser' }, + { arg: '--enable-agent', direction: 'enabled', description: 'Agent' } + ]) + ).toEqual({ + args: ['--enable-assets', '--enable-agent'], + direction: 'enabled', + description: null + }) + }) + + it('reads as enabled when anything was turned on', () => { + // "A beta feature is on" is true of a launch that turned one on, whatever else it + // withdrew; the reverse claim would not be. + expect( + resolveBetaActivationNotice([ + { arg: '--disable-assets', direction: 'disabled', description: 'Assets browser' }, + { arg: '--enable-agent', direction: 'enabled', description: null } + ])?.direction + ).toBe('enabled') + }) + + it('reads as disabled only when every covered grant was a force-off', () => { + expect( + resolveBetaActivationNotice([ + { arg: '--disable-assets', direction: 'disabled', description: 'Assets browser' } + ])?.direction + ).toBe('disabled') + }) +}) + describe('readAnnouncedBetaArgs', () => { it('reads the persisted list', () => { store.set(BETA_NOTICE_ANNOUNCED_ARGS_KEY, ['--enable-assets']) @@ -103,72 +226,116 @@ describe('readAnnouncedBetaArgs', () => { describe('arm / peek / acknowledge', () => { it('queues a first activation for the install that launched it', () => { - armBetaActivationNotice('inst-1', ['--enable-assets']) - expect(peekBetaActivationNotice('inst-1')).toEqual(['--enable-assets']) - expect(peekBetaActivationNotice('inst-2')).toEqual([]) + armBetaActivationNotice('inst-1', [grant('--enable-assets')]) + expect(pendingArgs('inst-1')).toEqual(['--enable-assets']) + expect(peekBetaActivationNotice('inst-2')).toBeNull() }) it('queues nothing when the launch applied no grants', () => { armBetaActivationNotice('inst-1', []) - expect(peekBetaActivationNotice('inst-1')).toEqual([]) + expect(peekBetaActivationNotice('inst-1')).toBeNull() + }) + + it('carries the payload wording through to the pending card', () => { + armBetaActivationNotice('inst-1', [grant('--enable-assets', { description: 'Assets browser' })]) + expect(peekBetaActivationNotice('inst-1')).toEqual({ + args: ['--enable-assets'], + direction: 'enabled', + description: 'Assets browser' + }) }) it('leaves the notice pending across repeated reads', () => { // Persisting on show rather than on retire would spend a card the user may never have // seen — window closed, app quit, bell not rendered. - armBetaActivationNotice('inst-1', ['--enable-assets']) - expect(peekBetaActivationNotice('inst-1')).toEqual(['--enable-assets']) - expect(peekBetaActivationNotice('inst-1')).toEqual(['--enable-assets']) + armBetaActivationNotice('inst-1', [grant('--enable-assets')]) + expect(pendingArgs('inst-1')).toEqual(['--enable-assets']) + expect(pendingArgs('inst-1')).toEqual(['--enable-assets']) expect(announced()).toBeUndefined() }) it('persists the args and clears the queue on acknowledge', () => { - armBetaActivationNotice('inst-1', ['--enable-assets']) + armBetaActivationNotice('inst-1', [grant('--enable-assets')]) acknowledgeBetaActivationNotice('inst-1') expect(announced()).toEqual(['--enable-assets']) - expect(peekBetaActivationNotice('inst-1')).toEqual([]) + expect(peekBetaActivationNotice('inst-1')).toBeNull() }) it('stays silent on every later launch of the same feature', () => { - armBetaActivationNotice('inst-1', ['--enable-assets']) + armBetaActivationNotice('inst-1', [grant('--enable-assets')]) acknowledgeBetaActivationNotice('inst-1') - armBetaActivationNotice('inst-1', ['--enable-assets']) - expect(peekBetaActivationNotice('inst-1')).toEqual([]) + armBetaActivationNotice('inst-1', [grant('--enable-assets')]) + expect(peekBetaActivationNotice('inst-1')).toBeNull() }) it('stays silent when the same feature is revoked and later re-granted', () => { // The list is append-only, so a grant taken back and handed out again does not read as // news the second time. - armBetaActivationNotice('inst-1', ['--enable-assets']) + armBetaActivationNotice('inst-1', [grant('--enable-assets')]) acknowledgeBetaActivationNotice('inst-1') armBetaActivationNotice('inst-1', []) // revoked: nothing applied - armBetaActivationNotice('inst-1', ['--enable-assets']) // re-granted - expect(peekBetaActivationNotice('inst-1')).toEqual([]) + armBetaActivationNotice('inst-1', [grant('--enable-assets')]) // re-granted + expect(peekBetaActivationNotice('inst-1')).toBeNull() + }) + + it('announces a named force-off of a feature it already announced turning on', () => { + // Distinct arg tokens, so `--disable-assets` gets its own once-ever: being told a beta + // arrived does not cover being told it was withdrawn. + armBetaActivationNotice('inst-1', [grant('--enable-assets')]) + acknowledgeBetaActivationNotice('inst-1') + armBetaActivationNotice('inst-1', [ + grant('--disable-assets', { description: 'Assets browser' }) + ]) + expect(peekBetaActivationNotice('inst-1')).toEqual({ + args: ['--disable-assets'], + direction: 'disabled', + description: 'Assets browser' + }) }) it('tells a SECOND install about a feature the first never announced', () => { - armBetaActivationNotice('inst-1', ['--enable-assets']) + armBetaActivationNotice('inst-1', [grant('--enable-assets')]) acknowledgeBetaActivationNotice('inst-1') - armBetaActivationNotice('inst-2', ['--enable-assets', '--enable-agent']) - expect(peekBetaActivationNotice('inst-2')).toEqual(['--enable-agent']) + armBetaActivationNotice('inst-2', [grant('--enable-assets'), grant('--enable-agent')]) + expect(pendingArgs('inst-2')).toEqual(['--enable-agent']) }) it('lets only the first of two concurrent installs claim an arg', () => { // Neither has acknowledged yet, so the persisted list is still empty. Without the // in-flight claim both windows would raise a card for the same feature. - armBetaActivationNotice('inst-1', ['--enable-assets']) - armBetaActivationNotice('inst-2', ['--enable-assets']) - expect(peekBetaActivationNotice('inst-1')).toEqual(['--enable-assets']) - expect(peekBetaActivationNotice('inst-2')).toEqual([]) + armBetaActivationNotice('inst-1', [grant('--enable-assets')]) + armBetaActivationNotice('inst-2', [grant('--enable-assets')]) + expect(pendingArgs('inst-1')).toEqual(['--enable-assets']) + expect(peekBetaActivationNotice('inst-2')).toBeNull() }) it('merges into what other installs already announced rather than replacing it', () => { store.set(BETA_NOTICE_ANNOUNCED_ARGS_KEY, ['--enable-assets']) - armBetaActivationNotice('inst-2', ['--enable-agent']) + armBetaActivationNotice('inst-2', [grant('--enable-agent')]) acknowledgeBetaActivationNotice('inst-2') expect(announced()).toEqual(['--enable-assets', '--enable-agent']) }) + it('acknowledges only the grants the card described, leaving the rest queued', () => { + // The bug this guards: the card said "a beta feature is on", then acknowledgement consumed + // the undescribed withdrawal too — and because the list is append-only, that withdrawal + // could never be announced again on any install. + armBetaActivationNotice('inst-1', [ + grant('--enable-agent'), + grant('--disable-assets', { description: 'Assets browser' }) + ]) + expect(peekBetaActivationNotice('inst-1')?.args).toEqual(['--enable-agent']) + + acknowledgeBetaActivationNotice('inst-1') + expect(announced()).toEqual(['--enable-agent']) + // The withdrawal survives and gets its own, correctly worded card. + expect(peekBetaActivationNotice('inst-1')).toEqual({ + args: ['--disable-assets'], + direction: 'disabled', + description: 'Assets browser' + }) + }) + it('acknowledging an install with nothing pending writes nothing', () => { acknowledgeBetaActivationNotice('inst-1') expect(announced()).toBeUndefined() @@ -176,28 +343,37 @@ describe('arm / peek / acknowledge', () => { it("re-arming replaces the install's pending set with the latest launch's grants", () => { // Each launch is the authority on what is on its own command line. - armBetaActivationNotice('inst-1', ['--enable-assets']) - armBetaActivationNotice('inst-1', ['--enable-assets', '--enable-agent']) - expect(peekBetaActivationNotice('inst-1')).toEqual(['--enable-assets', '--enable-agent']) + armBetaActivationNotice('inst-1', [grant('--enable-assets')]) + armBetaActivationNotice('inst-1', [grant('--enable-assets'), grant('--enable-agent')]) + expect(pendingArgs('inst-1')).toEqual(['--enable-assets', '--enable-agent']) + }) + + it('acknowledges a silenced grant is never queued, so it never reaches the store', () => { + armBetaActivationNotice('inst-1', [grant('--enable-assets', { silent: true })]) + acknowledgeBetaActivationNotice('inst-1') + expect(announced()).toBeUndefined() + // And a later payload that drops `silent` still owes the user the card. + armBetaActivationNotice('inst-1', [grant('--enable-assets')]) + expect(pendingArgs('inst-1')).toEqual(['--enable-assets']) }) it('clears a stale claim when the next launch applies no grants', () => { // A beta launch that failed to boot leaves a claim behind. If the user then turns beta off // and relaunches, the card must not still say a beta feature is on. - armBetaActivationNotice('inst-1', ['--enable-assets']) + armBetaActivationNotice('inst-1', [grant('--enable-assets')]) armBetaActivationNotice('inst-1', []) - expect(peekBetaActivationNotice('inst-1')).toEqual([]) + expect(peekBetaActivationNotice('inst-1')).toBeNull() }) it('releases a dropped claim back to other installs', () => { // `claimedArgs` reads the same map, so a stale claim would otherwise silence the arg // everywhere for the rest of the process. - armBetaActivationNotice('inst-1', ['--enable-assets']) - armBetaActivationNotice('inst-2', ['--enable-assets']) - expect(peekBetaActivationNotice('inst-2')).toEqual([]) + armBetaActivationNotice('inst-1', [grant('--enable-assets')]) + armBetaActivationNotice('inst-2', [grant('--enable-assets')]) + expect(peekBetaActivationNotice('inst-2')).toBeNull() armBetaActivationNotice('inst-1', []) - armBetaActivationNotice('inst-2', ['--enable-assets']) - expect(peekBetaActivationNotice('inst-2')).toEqual(['--enable-assets']) + armBetaActivationNotice('inst-2', [grant('--enable-assets')]) + expect(pendingArgs('inst-2')).toEqual(['--enable-assets']) }) }) diff --git a/src/main/lib/betaActivationNotice.ts b/src/main/lib/betaActivationNotice.ts index 0f54f6352..4e27fa3e6 100644 --- a/src/main/lib/betaActivationNotice.ts +++ b/src/main/lib/betaActivationNotice.ts @@ -17,18 +17,36 @@ * bar's own gate (`useBetaActivationNotice`) opens and asks for it. */ import * as settings from '../settings' +import type { CoreBetaGrant } from './coreBetaGrants' /** Args already announced, as a durable string list. A LIST rather than a boolean so a second * beta feature granted months later still gets its own heads-up; append-only, so a grant that * is revoked and later re-granted stays silent the second time. */ export const BETA_NOTICE_ANNOUNCED_ARGS_KEY = 'betaNoticeAnnouncedArgs' -/** Only grants that turn something ON announce. `--disable-*` exists in the allowlist as a - * remote force-OFF (see `CORE_BETA_GRANTABLE_ARGS`), and the notice's copy — "a beta feature - * is on", pointing at the opt-out switch — would be flatly wrong for one: it names the - * opposite of what happened and offers an action that does not apply. Silent is the honest - * reading until the payload can carry its own copy. */ const ENABLE_PREFIX = '--enable-' +const DISABLE_PREFIX = '--disable-' + +/** One grant the user has not been told about, plus the wording its payload asked for. */ +export interface PendingBetaGrant { + readonly arg: string + /** Whether this grant turned the feature on or off. */ + readonly direction: 'enabled' | 'disabled' + /** Payload-supplied feature name, or `null` for the generic wording. */ + readonly description: string | null +} + +/** What the title bar needs to render one card: which args it covers (so retiring it can + * acknowledge exactly those), and the copy to use. */ +export interface BetaActivationNotice { + readonly args: readonly string[] + /** `'disabled'` only when EVERY covered grant was a force-off; a launch that turned + * something on is "a beta feature is on" regardless of what else it turned off. */ + readonly direction: 'enabled' | 'disabled' + /** Non-null only when the card covers exactly one grant AND its payload named the feature. + * Two features at once have no single honest name, so that falls back to generic. */ + readonly description: string | null +} /** * Pending notices by installation id, drained by the title bar of that install's host window. @@ -37,15 +55,15 @@ const ENABLE_PREFIX = '--enable-' * closed, app quit, bell not reachable — must REPLAY on the next launch rather than being lost, * so nothing is written to disk until the user actually retires the card. */ -const pendingByInstallation = new Map() +const pendingByInstallation = new Map() /** Every arg currently pending across all installs. Two windows launching with the same fresh * grant would otherwise each show a card for it, since neither has acknowledged yet and the * persisted list is still empty. First claim wins; the second install stays silent. */ function claimedArgs(): Set { const claimed = new Set() - for (const args of pendingByInstallation.values()) { - for (const arg of args) claimed.add(arg) + for (const grants of pendingByInstallation.values()) { + for (const grant of grants) claimed.add(grant.arg) } return claimed } @@ -74,24 +92,63 @@ export function readAnnouncedBetaArgs(): string[] { * The grants from this launch the user has not been told about yet. * * Pure so the trigger rule is testable without settings or a launch: takes what was applied - * plus what is already spoken for, returns what is new. Order follows `appliedArgs` and - * duplicates collapse, so a payload naming an arg twice cannot double-announce it. + * plus what is already spoken for, returns what is new. Order follows `applied` and duplicates + * collapse, so a payload naming an arg twice cannot double-announce it. + * + * Three ways a grant stays silent: + * - the payload asked for it (`notice: 'silent'`), for a flag with nothing to tell the user; + * - it is a `--disable-*` force-off with no payload-supplied name, because the generic + * wording describes turning something ON and there would be nothing truthful to say; + * - it has already been announced, here or on another install. + * A named force-off DOES announce: the payload has supplied the one thing the generic copy + * could not, so the card can say which beta was withdrawn. */ -export function selectNewlyActiveBetaArgs( - appliedArgs: readonly string[], +export function selectNewlyActiveBetaGrants( + applied: readonly CoreBetaGrant[], spokenFor: ReadonlySet -): string[] { - const fresh: string[] = [] +): PendingBetaGrant[] { + const fresh: PendingBetaGrant[] = [] const seen = new Set(spokenFor) - for (const arg of appliedArgs) { - if (!arg.startsWith(ENABLE_PREFIX)) continue - if (seen.has(arg)) continue - seen.add(arg) - fresh.push(arg) + for (const grant of applied) { + if (grant.notice?.silent === true) continue + if (seen.has(grant.arg)) continue + const description = grant.notice?.description ?? null + // Derived from the prefix PAIR, not as a binary else. An allowlist entry with neither + // prefix is possible (`oppositeArg` already handles that case, and the list is documented + // as growing ahead of Core); defaulting it to `disabled` would turn a card that used to + // stay silent into one that actively says a feature was switched off when it was not. + if (!grant.arg.startsWith(ENABLE_PREFIX) && !grant.arg.startsWith(DISABLE_PREFIX)) continue + const direction = grant.arg.startsWith(ENABLE_PREFIX) ? 'enabled' : 'disabled' + if (direction === 'disabled' && description === null) continue + seen.add(grant.arg) + fresh.push({ arg: grant.arg, direction, description }) } return fresh } +/** + * Collapse this install's pending grants into the single card the title bar renders. + * + * Exported and pure because the collapse rules are the interesting part: what a card may + * honestly claim when it covers more than one grant. Returns `null` when nothing is pending, + * which is how the renderer decides whether to show anything at all. + */ +export function resolveBetaActivationNotice( + pending: readonly PendingBetaGrant[] +): BetaActivationNotice | null { + if (pending.length === 0) return null + // One direction per card, and the card covers ONLY the grants in it. A single launch can + // both turn something on and withdraw something else; collapsing those into one card would + // describe one of them and then acknowledge both, so the undescribed withdrawal could never + // be announced again on any install. Enables go first because "a beta feature is on" is the + // more urgent thing to say; the rest stay pending and get their own card next launch. + const direction = pending.some((grant) => grant.direction === 'enabled') ? 'enabled' : 'disabled' + const covered = pending.filter((grant) => grant.direction === direction) + // A name only belongs on the card when it names everything the card covers. + const description = covered.length === 1 ? covered[0]!.description : null + return { args: covered.map((grant) => grant.arg), direction, description } +} + /** * Queue a notice for any grant this launch turned on for the first time. * @@ -100,7 +157,7 @@ export function selectNewlyActiveBetaArgs( */ export function armBetaActivationNotice( installationId: string, - appliedArgs: readonly string[] + applied: readonly CoreBetaGrant[] ): void { try { // Replace, never append. Arming happens before the spawn is known to have succeeded, so a @@ -110,9 +167,9 @@ export function armBetaActivationNotice( // no longer on. Dropping a claim also releases it for other installs, since `claimedArgs` // reads the same map. pendingByInstallation.delete(installationId) - if (appliedArgs.length === 0) return + if (applied.length === 0) return const spokenFor = new Set([...readAnnouncedBetaArgs(), ...claimedArgs()]) - const fresh = selectNewlyActiveBetaArgs(appliedArgs, spokenFor) + const fresh = selectNewlyActiveBetaGrants(applied, spokenFor) if (fresh.length === 0) return pendingByInstallation.set(installationId, fresh) } catch (err) { @@ -120,11 +177,11 @@ export function armBetaActivationNotice( } } -/** What this install's title bar should announce, or `[]`. Read-only: the pending entry +/** The card this install's title bar should raise, or `null`. Read-only: the pending entry * survives until `acknowledgeBetaActivationNotice`, so a card that is shown but never retired * (window closed, app quit) comes back on the next launch. */ -export function peekBetaActivationNotice(installationId: string): string[] { - return [...(pendingByInstallation.get(installationId) ?? [])] +export function peekBetaActivationNotice(installationId: string): BetaActivationNotice | null { + return resolveBetaActivationNotice(pendingByInstallation.get(installationId) ?? []) } /** @@ -135,9 +192,17 @@ export function peekBetaActivationNotice(installationId: string): string[] { * retiring different notices cannot clobber each other. */ export function acknowledgeBetaActivationNotice(installationId: string): void { - const pending = pendingByInstallation.get(installationId) - pendingByInstallation.delete(installationId) - if (!pending || pending.length === 0) return + const queued = pendingByInstallation.get(installationId) + if (!queued || queued.length === 0) return + // Retire exactly the grants the card spoke for. Anything left over was never described to + // the user, so it stays queued for its own card rather than being silently consumed. + const shown = resolveBetaActivationNotice(queued) + if (shown === null) return + const covered = new Set(shown.args) + const remaining = queued.filter((grant) => !covered.has(grant.arg)) + if (remaining.length > 0) pendingByInstallation.set(installationId, remaining) + else pendingByInstallation.delete(installationId) + const pending = shown.args try { const merged = [...new Set([...readAnnouncedBetaArgs(), ...pending])] settings.set(BETA_NOTICE_ANNOUNCED_ARGS_KEY, merged) diff --git a/src/main/lib/coreBetaGrants.test.ts b/src/main/lib/coreBetaGrants.test.ts index c9f8867d2..e701d2ff1 100644 --- a/src/main/lib/coreBetaGrants.test.ts +++ b/src/main/lib/coreBetaGrants.test.ts @@ -189,6 +189,85 @@ describe('parseCoreBetaGrants', () => { }) }) +describe('parseCoreBetaGrants notice wording', () => { + /** The exact payload shape live in the prod acceptance-test flag. A flag object carrying + * nothing but `arg` + `min_core_version` MUST keep granting — the notice fields are copy, + * added after that payload was written, and cannot become required. */ + it('grants a payload entry that says nothing about the notice', () => { + expect( + parseCoreBetaGrants(true, { + flags: [{ arg: '--enable-assets', min_core_version: '0.36.0' }] + }) + ).toEqual([{ arg: '--enable-assets', minCoreVersion: '0.36.0' }]) + }) + + it('carries a silent request and a feature name onto the grant', () => { + expect( + parseCoreBetaGrants(true, { + flags: [ + { arg: '--enable-assets', min_core_version: '0.3.80', description: 'Assets browser' }, + { arg: '--enable-agent', min_core_version: '0.3.80', notice: 'silent' } + ] + }) + ).toEqual([ + { + arg: '--enable-assets', + minCoreVersion: '0.3.80', + notice: { description: 'Assets browser' } + }, + { arg: '--enable-agent', minCoreVersion: '0.3.80', notice: { silent: true } } + ]) + }) + + it('only the exact string "silent" suppresses the card', () => { + // `notice: true` reads as "yes, notify" at least as naturally as "yes, silent", and a + // rollout silenced by accident is invisible until someone asks why nobody was told. + for (const notice of [true, 1, 'SILENT', 'quiet', null]) { + expect( + parseCoreBetaGrants(true, { + flags: [{ arg: '--enable-assets', min_core_version: '0.3.80', notice }] + }) + ).toEqual([{ arg: '--enable-assets', minCoreVersion: '0.3.80' }]) + } + }) + + it('trims a description and drops a blank one', () => { + expect( + parseCoreBetaGrants(true, { + flags: [{ arg: '--enable-assets', min_core_version: '0.3.80', description: ' Assets ' }] + }) + ).toEqual([ + { arg: '--enable-assets', minCoreVersion: '0.3.80', notice: { description: 'Assets' } } + ]) + expect( + parseCoreBetaGrants(true, { + flags: [{ arg: '--enable-assets', min_core_version: '0.3.80', description: ' ' }] + }) + ).toEqual([{ arg: '--enable-assets', minCoreVersion: '0.3.80' }]) + }) + + it('drops an over-long or non-string description instead of refusing the grant', () => { + // Copy never gates a flag: a name too long for the card, or the wrong type entirely, costs + // the card its wording and nothing else. + for (const description of ['x'.repeat(49), 42, { text: 'Assets' }, ['Assets']]) { + expect( + parseCoreBetaGrants(true, { + flags: [{ arg: '--enable-assets', min_core_version: '0.3.80', description }] + }) + ).toEqual([{ arg: '--enable-assets', minCoreVersion: '0.3.80' }]) + } + }) + + it('keeps a description exactly at the limit', () => { + const description = 'x'.repeat(48) + expect( + parseCoreBetaGrants(true, { + flags: [{ arg: '--enable-assets', min_core_version: '0.3.80', description }] + }) + ).toEqual([{ arg: '--enable-assets', minCoreVersion: '0.3.80', notice: { description } }]) + }) +}) + describe('selectCoreBetaGrantArgs', () => { const unboundedGrant = { arg: '--enable-assets', diff --git a/src/main/lib/coreBetaGrants.ts b/src/main/lib/coreBetaGrants.ts index edc8f033c..cd7999770 100644 --- a/src/main/lib/coreBetaGrants.ts +++ b/src/main/lib/coreBetaGrants.ts @@ -33,15 +33,38 @@ export const CORE_BETA_GRANTABLE_ARGS = [ '--enable-agent' ] as const +/** How a grant's activation notice should be worded, when it is announced at all. Both fields + * are optional and independent of whether the grant APPLIES — copy never gates a flag. */ +export type CoreBetaNotice = { + /** `true` when the payload asked for no card at all. Ops-controlled because not every + * granted flag is user-visible: a diagnostic or an internal rollout has nothing to tell the + * user, and a card for it is noise that trains people to dismiss the real ones. */ + readonly silent?: true + /** Human name of the feature, e.g. `"Assets browser"`. Supplied by the payload rather than + * mapped in Desktop because the allowlist is installed ahead of the features it names — a + * table here would have to ship before anyone knew what to call them. Absent means the + * card falls back to its generic wording. */ + readonly description?: string +} + export type CoreBetaGrant = { readonly arg: string readonly minCoreVersion: string readonly maxCoreVersion?: string + /** Notice wording for this grant. Absent when the payload said nothing about it. */ + readonly notice?: CoreBetaNotice } const MAX_FLAGS = 32 const CORE_BETA_ARG_RE = /^--[a-z][a-z0-9-]+$/ +/** Cap on a payload-supplied feature name. Bounds the card's HEIGHT: the bubble is a fixed + * ~280px wide, so a long name wraps to more and more lines until the card covers what it is + * annotating. (Width is handled in CSS — `overflow-wrap` breaks an unbroken token that would + * otherwise overflow.) An over-long description is dropped rather than cut, so the card falls + * back to wording that is at least correct. */ +const MAX_DESCRIPTION_LENGTH = 48 + // Prevent a control payload copied between PostHog variants from enrolling users. const OFF_VARIANTS = new Set(['control', 'off', 'false', 'disabled']) @@ -55,6 +78,38 @@ function parseCoreVersion(value: unknown): string | null { return semver.valid(value.replace(/^v/, '')) } +/** + * Read the optional notice wording off one payload entry. + * + * Every malformed shape degrades to "the payload said nothing", never to a refusal: this + * governs COPY, and losing a grant because someone typed the feature name wrong would be a + * far worse failure than showing the generic card. Returns `undefined` when nothing usable + * was supplied, so the field is simply absent on the grant. + */ +function parseCoreBetaNotice(candidate: object): CoreBetaNotice | undefined { + const notice: { silent?: true; description?: string } = {} + + // Only the exact string `'silent'` suppresses. A boolean `true` is deliberately NOT accepted: + // `notice: true` reads as "yes, notify" at least as naturally as "yes, silent", and a + // payload that silences a rollout by accident is invisible until someone asks why nobody + // was told. + if ('notice' in candidate && (candidate as { notice?: unknown }).notice === 'silent') { + notice.silent = true + } + + if ('description' in candidate) { + const raw = (candidate as { description?: unknown }).description + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (trimmed.length > 0 && trimmed.length <= MAX_DESCRIPTION_LENGTH) { + notice.description = trimmed + } + } + } + + return notice.silent === undefined && notice.description === undefined ? undefined : notice +} + export function parseCoreBetaGrants( value: FeatureFlagValue | undefined, payload: unknown @@ -84,11 +139,13 @@ export function parseCoreBetaGrants( } if (flags.some((flag) => flag.arg === candidate.arg)) continue - flags.push( - maxCoreVersion === undefined - ? { arg: candidate.arg, minCoreVersion } - : { arg: candidate.arg, minCoreVersion, maxCoreVersion } - ) + const notice = parseCoreBetaNotice(candidate) + flags.push({ + arg: candidate.arg, + minCoreVersion, + ...(maxCoreVersion === undefined ? {} : { maxCoreVersion }), + ...(notice === undefined ? {} : { notice }) + }) } // Naming a flag and its opposite is an operator mistake, not a precedence order. Applying // either one would pick a silent winner from payload order, so the whole payload grants diff --git a/src/main/lib/ipc/sessionActions/launch.test.ts b/src/main/lib/ipc/sessionActions/launch.test.ts index e99f92d55..e0c0449e7 100644 --- a/src/main/lib/ipc/sessionActions/launch.test.ts +++ b/src/main/lib/ipc/sessionActions/launch.test.ts @@ -49,7 +49,7 @@ const launchHarness = vi.hoisted(() => ({ /** Settings can throw on read: `resolveBetaFeaturesEnabled` writes the default back on first * read, so a read-only or full disk surfaces here. */ betaEnabledThrows: false, - grants: [] as { arg: string; minCoreVersion: string }[], + grants: [] as { arg: string; minCoreVersion: string; notice?: CoreBetaNotice }[], /** Runs while `acquireLaunchResources` is in flight — after the launching marker exists and * before either path's pre-spawn abort gate, which is exactly the window under test. */ duringResourceAcquire: null as null | (() => void), @@ -154,7 +154,7 @@ import type { createExecutionTap } from '../../executionTap' import type { createHardwareTap } from '../../hardwareTap' import type { LaunchProgressTracker } from '../../launchProgress' import type { ComfyArgsSchema } from '../../comfy-args' -import type { CoreBetaGrant } from '../../coreBetaGrants' +import type { CoreBetaGrant, CoreBetaNotice } from '../../coreBetaGrants' import * as telemetry from '../../telemetry' import { makeSendOutput, @@ -974,12 +974,12 @@ describe('core beta report placement', () => { it('arms the activation notice from the same latch that reports the grant', async () => { const id = 'harness-arms-beta-notice' - expect(peekBetaActivationNotice(id)).toEqual([]) + expect(peekBetaActivationNotice(id)).toBeNull() const res = await handleLaunch(ctxFor(id)) expect(res.ok).toBe(true) - expect(peekBetaActivationNotice(id)).toEqual(['--enable-assets']) + expect(peekBetaActivationNotice(id)?.args).toEqual(['--enable-assets']) }) it('arms nothing on a launch whose grants the args schema refused', async () => { @@ -993,7 +993,7 @@ describe('core beta report placement', () => { expect(res.ok).toBe(true) expect(spawnArgs).not.toContain('--enable-assets') - expect(peekBetaActivationNotice(id)).toEqual([]) + expect(peekBetaActivationNotice(id)).toBeNull() }) it('arms nothing for an install that opted out of beta features', async () => { @@ -1003,7 +1003,33 @@ describe('core beta report placement', () => { const res = await handleLaunch(ctxFor(id)) expect(res.ok).toBe(true) - expect(peekBetaActivationNotice(id)).toEqual([]) + expect(peekBetaActivationNotice(id)).toBeNull() + }) + + it('arms nothing when the payload asked for a silent grant', async () => { + // Copy control, not flag control: the arg still reaches the command line, the user just + // is not told about it. + launchHarness.grants = [{ ...HARNESS_GRANT, notice: { silent: true } }] + const id = 'harness-silent-grant' + + const res = await handleLaunch(ctxFor(id)) + + expect(res.ok).toBe(true) + expect(spawnArgs).toContain('--enable-assets') + expect(peekBetaActivationNotice(id)).toBeNull() + }) + + it('carries the payload feature name onto the pending card', async () => { + launchHarness.grants = [{ ...HARNESS_GRANT, notice: { description: 'Assets browser' } }] + const id = 'harness-named-grant' + + await handleLaunch(ctxFor(id)) + + expect(peekBetaActivationNotice(id)).toEqual({ + args: ['--enable-assets'], + direction: 'enabled', + description: 'Assets browser' + }) }) it('stays silent on the NEXT launch once the notice has been acknowledged', async () => { @@ -1014,7 +1040,7 @@ describe('core beta report placement', () => { await handleLaunch(ctxFor(id)) - expect(peekBetaActivationNotice(id)).toEqual([]) + expect(peekBetaActivationNotice(id)).toBeNull() }) /** The commit `harnessInstall`'s record names, i.e. what the version gate believes is running. */ diff --git a/src/main/lib/ipc/sessionActions/launch.ts b/src/main/lib/ipc/sessionActions/launch.ts index 4e9e4fa52..ce80c55c3 100644 --- a/src/main/lib/ipc/sessionActions/launch.ts +++ b/src/main/lib/ipc/sessionActions/launch.ts @@ -782,10 +782,7 @@ async function runLaunch( // Same latch, same reason: a grant is only worth announcing once it is provably on this // launch's command line. Queued rather than shown — the host window may still be mid-attach // or under the progress takeover, so the title bar drains this when its own gate opens. - armBetaActivationNotice( - installationId, - coreBeta.applied.map((grant) => grant.arg) - ) + armBetaActivationNotice(installationId, coreBeta.applied) try { emitCoreBetaTelemetry({ appliedArgs: coreBeta.applied.map((grant) => grant.arg), diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts index 72ad2eb69..039e25f98 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts @@ -1224,7 +1224,7 @@ describe('TitleBarApp', () => { setSetting = vi.fn().mockResolvedValue(undefined) // Nothing pending, so the beta notice never competes for the single popup and these // assertions keep counting only pill-hint shows. - getPendingBetaNotice = vi.fn().mockResolvedValue([]) + getPendingBetaNotice = vi.fn().mockResolvedValue(null) ;(window as unknown as { api: unknown }).api = { getSetting, setSetting, @@ -1328,8 +1328,22 @@ describe('TitleBarApp', () => { let acknowledgeBetaNotice: ReturnType let openGlobalSettings: ReturnType - function installApiMock(opts: { pending?: string[]; pillHintSeen?: boolean } = {}): void { - getPendingBetaNotice = vi.fn().mockResolvedValue(opts.pending ?? ['--enable-assets']) + function installApiMock( + opts: { + /** `null` = main has nothing to announce. */ + pending?: { + args: string[] + direction: 'enabled' | 'disabled' + description: string | null + } | null + pillHintSeen?: boolean + } = {} + ): void { + const pending = + opts.pending === undefined + ? { args: ['--enable-assets'], direction: 'enabled' as const, description: null } + : opts.pending + getPendingBetaNotice = vi.fn().mockResolvedValue(pending) acknowledgeBetaNotice = vi.fn().mockResolvedValue(undefined) openGlobalSettings = vi.fn() ;(window as unknown as { api: unknown }).api = { @@ -1377,8 +1391,7 @@ describe('TitleBarApp', () => { expect(betaCards().length).toBe(1) const payload = betaCards()[0]! expect(payload.title).toBe('A beta feature is on') - // Copy is generic on purpose: the card never names the arg, so it cannot be wrong - // about which feature turned on. + // With no payload-supplied name the copy stays generic, and it never leaks the raw arg. expect(payload.body).not.toContain('--enable-assets') // The action is what makes it more than an FYI. expect(payload.actionLabel).toBe('Settings') @@ -1386,7 +1399,7 @@ describe('TitleBarApp', () => { }) it('stays silent when main has nothing pending', async () => { - installApiMock({ pending: [] }) + installApiMock({ pending: null }) const wrapper = await mountBar() expect(betaCards().length).toBe(0) wrapper.unmount() @@ -1480,6 +1493,36 @@ describe('TitleBarApp', () => { wrapper.unmount() }) + it('names the feature when the PostHog payload supplied a name', async () => { + installApiMock({ + pending: { args: ['--enable-assets'], direction: 'enabled', description: 'Assets browser' } + }) + const wrapper = await mountBar() + const payload = betaCards()[0]! + expect(payload.title).toBe('The Assets browser beta is on') + expect(payload.body).toContain('Assets browser') + wrapper.unmount() + }) + + it('reads the other way round for a named remote force-off', async () => { + // A withdrawn beta is not "a beta feature is on". Main only resolves this direction for + // a grant the payload named, so there is always something to put in the sentence. + installApiMock({ + pending: { + args: ['--disable-assets'], + direction: 'disabled', + description: 'Assets browser' + } + }) + const wrapper = await mountBar() + const payload = betaCards()[0]! + expect(payload.title).toBe('The Assets browser beta is off') + expect(payload.title).not.toContain('is on') + // Still points at Settings — the beta program switch is what the user can act on. + expect(payload.actionLabel).toBe('Settings') + wrapper.unmount() + }) + it('routes a pill-hint dismiss away from the beta notice', async () => { // One popup, two owners: a retirement addressed to the hint must not spend the // notice's once-ever acknowledgement. diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.vue b/src/renderer/src/comfyTitleBar/TitleBarApp.vue index 9649b1ace..77ce10bac 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.vue +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.vue @@ -548,10 +548,20 @@ const betaNotice = useBetaActivationNotice({ isLoadingLockdown, anchorRef: announcementBtnRef, isSuppressed: () => coachmark.isShowing.value, - title: t('titleBar.betaNoticeTitle'), - body: t('titleBar.betaNoticeBody'), - dismissLabel: t('titleBar.betaNoticeDismiss'), - actionLabel: t('titleBar.betaNoticeSettings') + // Four wordings, picked by what main could establish: whether the grant turned the feature + // on or withdrew it, and whether the PostHog payload named it. The generic pair is the + // fallback, so an unnamed feature still gets a card that is true. + copyFor: ({ direction, description }) => { + const suffix = description ? 'Named' : '' + const prefix = direction === 'disabled' ? 'betaNoticeOff' : 'betaNotice' + const params = { feature: description ?? '' } + return { + title: t(`titleBar.${prefix}Title${suffix}`, params), + body: t(`titleBar.${prefix}Body${suffix}`, params), + dismissLabel: t('titleBar.betaNoticeDismiss'), + actionLabel: t('titleBar.betaNoticeSettings') + } + } }) /** Wrap the pill opener so opening the drawer retires the coachmark diff --git a/src/renderer/src/comfyTitleBar/useBetaActivationNotice.ts b/src/renderer/src/comfyTitleBar/useBetaActivationNotice.ts index 4e5f48885..5f5771553 100644 --- a/src/renderer/src/comfyTitleBar/useBetaActivationNotice.ts +++ b/src/renderer/src/comfyTitleBar/useBetaActivationNotice.ts @@ -1,7 +1,14 @@ import { ref, type Ref, type ShallowRef } from 'vue' +import type { BetaActivationNotice } from '../types/ipc' /** The Settings row the notice's link flashes — the beta opt-in switch itself, so the - * "turn it off" the copy promises is the thing under the user's cursor when Settings opens. */ + * "turn it off" the copy promises is the thing under the user's cursor when Settings opens. + * + * Deliberately the same target for a withdrawal card. Turning this switch off drops every + * grant including a `--disable-*` one, so it would restore the feature the card just said was + * withdrawn — but the card only offers to "manage beta features", which is exactly what this + * row does. Pointing a withdrawal somewhere else would mean inventing a second destination + * for a path that no shipped core can reach yet. */ export const BETA_FEATURES_FIELD_ID = 'betaFeaturesEnabled' interface BetaNoticeBridge { @@ -35,11 +42,15 @@ interface UseBetaActivationNoticeOpts { anchorRef: Readonly> /** True while another card owns the single popup (currently the pill hint). */ isSuppressed: () => boolean - /** Resolved copy (i18n done by the caller). */ - title: string - body: string - dismissLabel: string - actionLabel: string + /** Copy for the card main actually resolved. A callback rather than fixed strings because + * the wording depends on the notice: the PostHog payload may name the feature, and a + * remote force-off reads the opposite way from an activation. i18n stays with the caller. */ + copyFor: (notice: BetaActivationNotice) => { + title: string + body: string + dismissLabel: string + actionLabel: string + } } interface BetaActivationNoticeApi { @@ -94,14 +105,14 @@ export function useBetaActivationNotice( ) } - async function hasPendingNotice(installationId: string): Promise { + async function pendingNotice(): Promise { try { - const pending = await window.api.getPendingBetaNotice(installationId) - return Array.isArray(pending) && pending.length > 0 + const pending = await window.api.getPendingBetaNotice(opts.installationId()) + return pending && Array.isArray(pending.args) && pending.args.length > 0 ? pending : null } catch { // Read failed; stay silent. Unlike the pill hint's "treat as unseen", guessing wrong // here would announce a beta feature that may not be on at all. - return false + return null } } @@ -110,21 +121,23 @@ export function useBetaActivationNotice( if (!opts.bridge || !installationId) return if (shownFor === installationId || retiredFor === installationId) return if (!gatePasses() || !opts.anchorRef.value) return - if (!(await hasPendingNotice(installationId))) return + const notice = await pendingNotice() + if (!notice) return // Re-check after the await; the host could have flipped state or the pill hint could have // claimed the popup while we were asking. const anchor = opts.anchorRef.value if (!gatePasses() || !anchor || opts.installationId() !== installationId) return + const copy = opts.copyFor(notice) const rect = anchor.getBoundingClientRect() shownFor = installationId isShowing.value = true opts.bridge.showCoachmark({ kind: 'beta-notice', - title: opts.title, - body: opts.body, - dismissLabel: opts.dismissLabel, - actionLabel: opts.actionLabel, + title: copy.title, + body: copy.body, + dismissLabel: copy.dismissLabel, + actionLabel: copy.actionLabel, leftX: Math.round(rect.left), rightX: Math.round(rect.right), bottomY: Math.round(rect.bottom) diff --git a/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue b/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue index 8ab5e4af5..fe314b30e 100644 --- a/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue +++ b/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue @@ -273,6 +273,14 @@ onUnmounted(() => { border-top-left-radius: 3px; } +/* The card is `max-width: 280px` with `overflow: hidden` on the viewport, and a + payload-supplied feature name can be a single unbroken token — which would otherwise be + clipped rather than wrapped. */ +.coachmark-title, +.coachmark-text { + overflow-wrap: anywhere; +} + .coachmark-title { font-size: 13px; font-weight: 600; diff --git a/src/renderer/src/types/ipc.ts b/src/renderer/src/types/ipc.ts index 4330452f7..9e8e74a08 100644 --- a/src/renderer/src/types/ipc.ts +++ b/src/renderer/src/types/ipc.ts @@ -1,6 +1,7 @@ // Re-export IPC types from the canonical shared location for renderer convenience. export type { Unsubscribe, + BetaActivationNotice, Installation, RunningInstance, Source, diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 207b48ed8..6c6b217a6 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -11,6 +11,13 @@ export type { FirstUseMode } import type { AuthStatus, Workspace } from '../main/cloud/types' export type { AuthStatus, Workspace } +// One Core beta activation card, as main resolves it for the title bar. Re-exported from its +// producer rather than restated here: this file's header forbids duplicating types, and an +// independent copy would drift silently — `ipcMain.handle` is ungeneric and `ipcRenderer.invoke` +// returns `Promise`, so nothing would fail the build. +import type { BetaActivationNotice } from '../main/lib/betaActivationNotice' +export type { BetaActivationNotice } + /** Every renderer-safe Build catalog state. */ export type DevPlatformBuildState = | 'installable' @@ -1436,11 +1443,12 @@ export interface ElectronApi { getSetting(key: string): Promise // Core beta activation notice - /** Core beta args this install turned on for the first time and has not - * announced yet, or `[]`. Read repeatedly without side effects — the - * pending set is only cleared by `acknowledgeBetaNotice`, so a card that is - * shown but never retired comes back on the next launch. */ - getPendingBetaNotice(installationId: string): Promise + /** The activation card this install owes the user, or `null`. Read repeatedly + * without side effects — the pending set is only cleared by + * `acknowledgeBetaNotice`, so a card that is shown but never retired comes + * back on the next launch. `description` carries the feature name the + * PostHog payload supplied, when it supplied one. */ + getPendingBetaNotice(installationId: string): Promise /** Retire this install's activation notice: its args are persisted as * announced and never raise a card again. Called when the user dismisses * the card or follows its settings link. */ From f0d029786a278ea83a0b29d8e16fdf88f345340e Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Mon, 21 Sep 2026 00:57:54 -0700 Subject: [PATCH 03/27] fix(core-beta): address review findings on the activation notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1 + CodeRabbit + the Cursor panel, on d0b3ee7. **E2E is Linux-only now.** macOS was failing CI, and the cause is that nothing isolates it: `configDir()` resolves to Electron's `userData`, which ignores the harness's HOME override, so the ops-flag seed was writing into the real profile — leaving the seeded rollout enabled after the run and leaking state into later tests. Redirecting `userData` needs a change to app startup and does not belong here, so the specs say `@linux` and the fixture refuses to build elsewhere. **The stub's port is allocated at run time** rather than hard-coded. A constant collides with whatever else is on the machine, and the failure is either EADDRINUSE or the launcher mistaking an unrelated listener for the fixture — this repo does not tolerate flaky tests. **The renderer latch is keyed on the notice, not the install.** Keying it on the installation permanently suppressed that install for the renderer's lifetime, and the title bar survives attach/detach without a reload — so a second grant that only now clears its version gate could never be shown. Keying on the args lets a genuinely different card through while still swallowing a repeat of one already dealt with. **Acknowledgement carries the args the card displayed.** Re-deriving them at retire time acknowledges whatever is queued then, and a relaunch can re-arm while the sticky card floats — persisting a grant the user was never shown, which the append-only list makes unannounceable forever. The queue is also only updated once the write succeeds. **A show attempt claims an in-flight marker before its await**, since the gate watcher and the post-hint retry fire independently and could both raise a card. **The anchor assertion allows a clamped layout**: it requires the card to cover the bell, and only requires exact centring when nothing clamped it. The beak's own tracking is covered by `positionCoachmark`'s unit tests. **`opsFlag` checks the E2E env var before touching `app.isPackaged`**, so importing it outside the Electron runtime needs no electron mock. Co-Authored-By: Claude Opus 5 --- e2e/beta-activation-notice.test.ts | 57 ++++++++++----- e2e/support/fakeComfyInstall.ts | 44 ++++++++++-- src/main/lib/betaActivationNotice.test.ts | 14 ++++ src/main/lib/betaActivationNotice.ts | 25 +++++-- src/main/lib/ipc/registerSettingsHandlers.ts | 16 +++-- src/main/lib/opsFlag.ts | 5 +- src/preload/api.ts | 4 +- .../src/comfyTitleBar/TitleBarApp.test.ts | 46 +++++++++++- .../comfyTitleBar/useBetaActivationNotice.ts | 72 ++++++++++++++----- src/types/ipc.ts | 11 ++- 10 files changed, 233 insertions(+), 61 deletions(-) diff --git a/e2e/beta-activation-notice.test.ts b/e2e/beta-activation-notice.test.ts index 08dfcb5c2..933ddcc88 100644 --- a/e2e/beta-activation-notice.test.ts +++ b/e2e/beta-activation-notice.test.ts @@ -14,9 +14,9 @@ * and the screenshots come from `BrowserWindow.capturePage()` — the whole host window, * chrome and canvas and floating card together, which is the thing a reviewer needs to see. * - * Tagged `@linux @macos`, never `@windows`: the fixture's interpreter stub cannot be a PE - * executable, so the launch it drives can never start there (see `fakeComfyInstall.ts`'s - * header, and the same exclusion documented in `e2e/comfybuilder-launch.test.ts`). + * Tagged `@linux` only — the fixture cannot run on Windows (no PE interpreter stub) or macOS + * (nothing isolates `userData`, so the ops-flag seed would hit the real profile). Both reasons + * are spelled out in `fakeComfyInstall.ts`'s header. * * Run: `pnpm exec playwright test --project=linux e2e/beta-activation-notice.test.ts` * Screenshots go to Playwright's own output dir and are attached to the report, not written @@ -29,7 +29,11 @@ import { expect, test, type ElectronApplication } from '@playwright/test' import { launchApp, type AppContext } from './launchApp' import { clickInstallTile, expectChooserVisible } from './support/chooserHelpers' import { WebContentsPage, titlePopupPage } from './support/cdpPages' -import { opsFlagsGrantSeed, writeFakeComfyInstall } from './support/fakeComfyInstall' +import { + opsFlagsGrantSeed, + reserveFreePort, + writeFakeComfyInstall, +} from './support/fakeComfyInstall' import { captureHostWindow } from './support/windowCapture' // A real launch (args-schema spawn, port wait, attach) does not fit the default 45s budget. @@ -37,8 +41,10 @@ test.describe.configure({ mode: 'serial', timeout: 180_000 }) const INSTALL_ID = 'inst-beta-notice' const INSTALL_NAME = 'Beta Notice Fixture' -/** Explicit so the launcher's port-conflict auto-shift can never move the stub's port. */ -const PORT = 49517 +/** Chosen at run time rather than hard-coded: a constant collides with whatever else happens + * to be on the machine, and this repo does not tolerate flaky tests. Passed explicitly in + * `launchArgs` so the launcher's port-conflict auto-shift cannot move it afterwards. */ +let port = 0 /** The grant under test. `--enable-assets` is on the real allowlist and the stub's `--help` * advertises it, so it survives selection AND the schema filter. */ const GRANT_ARG = '--enable-assets' @@ -80,7 +86,8 @@ test.beforeAll(async () => { process.env['POSTHOG_HOST'] = UNREACHABLE_POSTHOG_HOST installPath = await mkdtemp(path.join(os.tmpdir(), 'comfyui-beta-notice-')) - await writeFakeComfyInstall({ installPath, port: PORT }) + port = await reserveFreePort() + await writeFakeComfyInstall({ installPath, port }) ctx = await launchApp({ settings: { @@ -101,7 +108,7 @@ test.beforeAll(async () => { sourceLabel: 'ComfyBuilder', installPath, status: 'installed', - launchArgs: `--port ${PORT}`, + launchArgs: `--port ${port}`, launchMode: 'window', browserPartition: 'unique', seen: true, @@ -131,7 +138,7 @@ test.afterAll(async () => { else process.env['POSTHOG_HOST'] = previousPosthogHost }) -test('a first beta activation raises a nonblocking notice over live ComfyUI @linux @macos', async () => { +test('a first beta activation raises a nonblocking notice over live ComfyUI @linux', async () => { await clickInstallTile(ctx.panel, INSTALL_NAME) // The grant reaches the real command line, not just the selection step: this is what the @@ -140,9 +147,9 @@ test('a first beta activation raises a nonblocking notice over live ComfyUI @lin await ctx.panel.waitFor( async () => (await ctx.app.evaluate( - ({ webContents }, port) => - webContents.getAllWebContents().some((wc) => wc.getURL().includes(String(port))), - PORT, + ({ webContents }, p) => + webContents.getAllWebContents().some((wc) => wc.getURL().includes(String(p))), + port, )) === true, { timeout: 90_000, message: 'ComfyUI stub never came up / the host never attached' }, ) @@ -171,7 +178,7 @@ test('a first beta activation raises a nonblocking notice over live ComfyUI @lin test.info().attach('notice over ComfyUI', { path: shot, contentType: 'image/png' }) }) -test('the card is anchored on the bell it points at @linux @macos', async () => { +test('the card is anchored on the bell it points at @linux', async () => { // The beak is drawn at a fixed position within the card, so "points at the bell" is really // "the popup is centred on the bell". Asserted numerically rather than by eye: a composited // screenshot is evidence, not a guarantee, and this is the property that actually breaks @@ -191,17 +198,29 @@ test('the card is anchored on the bell it points at @linux @macos', async () => if (!(child instanceof WebContentsView)) continue if (!child.webContents.getURL().includes('comfyTitleTooltip')) continue const b = child.getBounds() - return { centre: b.x + b.width / 2, right: b.x + b.width, width: b.width } + return { centre: b.x + b.width / 2, x: b.x, right: b.x + b.width, width: b.width } } return null }) expect(popup, 'coachmark popup view not found').not.toBeNull() - // One device pixel of rounding is fine; ~20 px means the card is pointing at a neighbour. - expect(Math.abs(popup!.centre - bellCentre)).toBeLessThanOrEqual(2) + // The card must COVER the bell horizontally — that holds whether or not the view clamped. + expect(popup!.x).toBeLessThanOrEqual(bellCentre) + expect(popup!.right).toBeGreaterThanOrEqual(bellCentre) + + // When nothing clamped it, the view is centred on the bell exactly. Asserting this only in + // the unclamped case keeps it honest on a layout where the bell sits too close to an edge + // for the card to centre — there the beak does the pointing, which `positionCoachmark`'s + // unit tests cover directly. + const windowWidth = await ctx.app.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed() && w.isVisible()) + return win ? win.getContentBounds().width : 0 + }) + const clamped = popup!.x <= 0 || popup!.right >= windowWidth + if (!clamped) expect(Math.abs(popup!.centre - bellCentre)).toBeLessThanOrEqual(2) }) -test('the notice does not block the canvas underneath it @linux @macos', async () => { +test('the notice does not block the canvas underneath it @linux', async () => { // Nonblocking is the firm constraint, and it is a property of the popup's BOUNDS: the card // is its own small WebContentsView, so it takes clicks only where it is drawn. A full-window // overlay would report a height near the host window's. @@ -223,7 +242,7 @@ test('the notice does not block the canvas underneath it @linux @macos', async ( expect(bounds!.card.width).toBeLessThan(bounds!.window.width / 2) }) -test('the settings link lands on the beta opt-in row and retires the card @linux @macos', async () => { +test('the settings link lands on the beta opt-in row and retires the card @linux', async () => { const popup = coachmarkPopup(ctx.app) expect(await popup.click('.coachmark-action')).toBe(true) @@ -253,7 +272,7 @@ test('the settings link lands on the beta opt-in row and retires the card @linux test.info().attach('settings opt-out highlighted', { path: shot, contentType: 'image/png' }) }) -test('the notice is spent: a second launch stays silent @linux @macos', async () => { +test('the notice is spent: a second launch stays silent @linux', async () => { // The whole point of persisting on retire rather than on show. Read through the same IPC // the title bar uses, so this asserts the contract the renderer actually depends on. const stillPending = await ctx.titleBar.evaluate( diff --git a/e2e/support/fakeComfyInstall.ts b/e2e/support/fakeComfyInstall.ts index 7b72f3690..548b86354 100644 --- a/e2e/support/fakeComfyInstall.ts +++ b/e2e/support/fakeComfyInstall.ts @@ -15,13 +15,23 @@ * Node runs the server rather than Python because there is no interpreter to depend on: the * absolute path of the node binary already running Playwright is baked into the stub. * - * POSIX only, deliberately. A Windows stub would have to be a real PE executable: `venvPython` + * LINUX only, deliberately — two independent reasons. + * + * A Windows stub would have to be a real PE executable: `venvPython` * resolves to `venv/python.exe`, and both callers run it with no shell (`execFile` in * `comfy-args.ts`, `spawn` in `process.ts`), so `CreateProcessW` rejects anything that is not a * PE image. Naming a batch file `.exe` does not help — only a `.bat`/`.cmd` EXTENSION makes * Windows hand off to `cmd.exe`. `e2e/comfybuilder-launch.test.ts` writes an EMPTY `python.exe` - * for exactly this reason: its assertion is that the launch is attempted and fails. Specs using - * this fixture are therefore tagged `@linux @macos`, never `@windows`. + * for exactly this reason: its assertion is that the launch is attempted and fails. + * + * macOS is excluded for a different reason: nothing isolates it. `configDir()` resolves to + * Electron's `userData` there, which ignores the harness's HOME override, so the ops-flag seed + * these specs need would be written into the developer's (or runner's) REAL profile — leaving + * the seeded rollout enabled after the run and leaking state into later tests. Fixing that + * needs the harness to redirect `userData` before main resolves any path, which is a change to + * app startup and does not belong in this PR. + * + * Specs using this fixture are therefore tagged `@linux` only. */ import path from 'node:path' import process from 'node:process' @@ -92,6 +102,30 @@ http }) ` +/** + * A port nothing is listening on right now, found by binding 0 and reading back what the OS + * chose. There is an unavoidable gap between releasing it and the stub binding it, but that is + * far better than a constant: a fixed port collides with whatever else is on the machine, and + * the failure mode is either `EADDRINUSE` or — worse — the launcher mistaking an unrelated + * listener for the booted fixture. + */ +export async function reserveFreePort(): Promise { + const { createServer } = await import('node:net') + return new Promise((resolve, reject) => { + const srv = createServer() + srv.once('error', reject) + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + if (addr === null || typeof addr === 'string') { + srv.close(() => reject(new Error('could not determine a free port'))) + return + } + const { port } = addr + srv.close(() => resolve(port)) + }) + }) +} + export interface FakeComfyInstall { /** `installPath` for the seeded record. */ installPath: string @@ -117,9 +151,9 @@ export async function writeFakeComfyInstall(opts: { const serverPath = path.join(installPath, 'stub-server.cjs') await writeFile(serverPath, SERVER_JS) - if (process.platform === 'win32') { + if (process.platform !== 'linux') { throw new Error( - 'writeFakeComfyInstall is POSIX-only (see the file header); tag the spec @linux @macos.' + 'writeFakeComfyInstall is Linux-only (see the file header); tag the spec @linux.' ) } diff --git a/src/main/lib/betaActivationNotice.test.ts b/src/main/lib/betaActivationNotice.test.ts index 361e02c77..718739838 100644 --- a/src/main/lib/betaActivationNotice.test.ts +++ b/src/main/lib/betaActivationNotice.test.ts @@ -169,6 +169,20 @@ describe('arm / peek / acknowledge', () => { expect(announced()).toEqual(['--enable-assets', '--enable-agent']) }) + it('retires the args the card displayed, not whatever is queued at retire time', () => { + // A relaunch can re-arm while the sticky card floats. Acknowledging the queue would then + // persist a grant the user was never shown — and the list is append-only, so it could + // never be announced again on any install. + armBetaActivationNotice('inst-1', ['--enable-assets']) + armBetaActivationNotice('inst-1', ['--enable-assets', '--enable-agent']) + + acknowledgeBetaActivationNotice('inst-1', ['--enable-assets']) + + expect(announced()).toEqual(['--enable-assets']) + // The grant that was never on the card is still owed one. + expect(peekBetaActivationNotice('inst-1')).toEqual(['--enable-agent']) + }) + it('acknowledging an install with nothing pending writes nothing', () => { acknowledgeBetaActivationNotice('inst-1') expect(announced()).toBeUndefined() diff --git a/src/main/lib/betaActivationNotice.ts b/src/main/lib/betaActivationNotice.ts index 0f54f6352..5385e2785 100644 --- a/src/main/lib/betaActivationNotice.ts +++ b/src/main/lib/betaActivationNotice.ts @@ -134,14 +134,29 @@ export function peekBetaActivationNotice(installationId: string): string[] { * acknowledging it. Merged into the stored list rather than replacing it, so two installs * retiring different notices cannot clobber each other. */ -export function acknowledgeBetaActivationNotice(installationId: string): void { - const pending = pendingByInstallation.get(installationId) - pendingByInstallation.delete(installationId) - if (!pending || pending.length === 0) return +export function acknowledgeBetaActivationNotice( + installationId: string, + shownArgs?: readonly string[] +): void { + const queued = pendingByInstallation.get(installationId) + if (!queued || queued.length === 0) return + // Retire exactly what the card DISPLAYED. Re-deriving from the queue at retire time would + // acknowledge whatever is pending now, and a relaunch can re-arm between show and retire + // while the sticky card floats — persisting a grant set the user was never shown, which the + // append-only list then makes unannounceable forever. Falls back to the queue only when no + // args were supplied (an older renderer), which is the pre-existing behaviour. + const covered = + shownArgs && shownArgs.length > 0 ? queued.filter((a) => shownArgs.includes(a)) : queued + if (covered.length === 0) return try { - const merged = [...new Set([...readAnnouncedBetaArgs(), ...pending])] + const merged = [...new Set([...readAnnouncedBetaArgs(), ...covered])] settings.set(BETA_NOTICE_ANNOUNCED_ARGS_KEY, merged) announcedCache = merged + // Only drop from the queue once the write succeeded: clearing first would lose the card + // for this session while leaving nothing on disk, so it would re-announce next launch. + const remaining = queued.filter((a) => !covered.includes(a)) + if (remaining.length > 0) pendingByInstallation.set(installationId, remaining) + else pendingByInstallation.delete(installationId) } catch (err) { // A failed write costs the user a repeat card on the next launch and nothing else. console.log('[beta-notice] acknowledge failed:', err) diff --git a/src/main/lib/ipc/registerSettingsHandlers.ts b/src/main/lib/ipc/registerSettingsHandlers.ts index 5a3ca3305..06becde89 100644 --- a/src/main/lib/ipc/registerSettingsHandlers.ts +++ b/src/main/lib/ipc/registerSettingsHandlers.ts @@ -347,10 +347,18 @@ export function registerSettingsHandlers(): void { // Retire the card: persist its args as announced so it never shows again. Deliberately // separate from the read, so a notice that is shown but never retired replays next launch. - ipcMain.handle('acknowledge-beta-notice', (_event, installationId: string) => { - recordIpcInvocation('acknowledge-beta-notice', { installationId }) - acknowledgeBetaActivationNotice(installationId) - }) + ipcMain.handle( + 'acknowledge-beta-notice', + (_event, installationId: string, shownArgs?: unknown) => { + recordIpcInvocation('acknowledge-beta-notice', { installationId }) + // The renderer names what its card covered; anything else is ignored rather than + // trusted, since this is an untrusted boundary like every other handler here. + const args = Array.isArray(shownArgs) + ? shownArgs.filter((a): a is string => typeof a === 'string') + : undefined + acknowledgeBetaActivationNotice(installationId, args) + } + ) ipcMain.handle('get-locale-messages', () => i18n.getMessages()) ipcMain.handle('get-available-locales', () => i18n.getAvailableLocales()) diff --git a/src/main/lib/opsFlag.ts b/src/main/lib/opsFlag.ts index 8fe40af00..59b65ceec 100644 --- a/src/main/lib/opsFlag.ts +++ b/src/main/lib/opsFlag.ts @@ -57,9 +57,12 @@ let e2eSeedApplied = false function maybeSeedFromEnv(): void { if (e2eSeedApplied) return e2eSeedApplied = true + // Env gate first: it is a plain string read, whereas `app` is only a real object inside the + // Electron runtime. Unit tests import this module outside it, so touching `app` on the + // common path would make every persisted-read test depend on mocking electron. + if (process.env['E2E'] !== '1') return // Hard guard: never run in production builds. if (app.isPackaged) return - if (process.env['E2E'] !== '1') return const seed = process.env['E2E_OPS_FLAGS_SEED'] if (!seed) return delete process.env['E2E_OPS_FLAGS_SEED'] diff --git a/src/preload/api.ts b/src/preload/api.ts index fa9cd51f6..9816c38aa 100644 --- a/src/preload/api.ts +++ b/src/preload/api.ts @@ -196,8 +196,8 @@ export function buildElectronApi(): ElectronApi { getSetting: (key) => ipcRenderer.invoke('get-setting', key), getPendingBetaNotice: (installationId) => ipcRenderer.invoke('get-pending-beta-notice', installationId), - acknowledgeBetaNotice: (installationId) => - ipcRenderer.invoke('acknowledge-beta-notice', installationId), + acknowledgeBetaNotice: (installationId, shownArgs) => + ipcRenderer.invoke('acknowledge-beta-notice', installationId, shownArgs), // Theme getResolvedTheme: () => ipcRenderer.invoke('get-resolved-theme'), diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts index 72ad2eb69..71d2fe07b 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts @@ -1415,7 +1415,7 @@ describe('TitleBarApp', () => { const wrapper = await mountBar() bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) await flushPromises() - expect(acknowledgeBetaNotice).toHaveBeenCalledWith('inst-1') + expect(acknowledgeBetaNotice).toHaveBeenCalledWith('inst-1', ['--enable-assets']) expect(bridgeState.hideCoachmarkCalls).toBeGreaterThan(0) wrapper.unmount() }) @@ -1428,7 +1428,7 @@ describe('TitleBarApp', () => { highlightField: 'betaFeaturesEnabled' }) // Acting on the card acknowledges it: the user is now looking at the switch it named. - expect(acknowledgeBetaNotice).toHaveBeenCalledWith('inst-1') + expect(acknowledgeBetaNotice).toHaveBeenCalledWith('inst-1', ['--enable-assets']) wrapper.unmount() }) @@ -1476,7 +1476,47 @@ describe('TitleBarApp', () => { bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) await flushPromises() - expect(acknowledgeBetaNotice).not.toHaveBeenCalledWith('inst-2') + expect(acknowledgeBetaNotice).not.toHaveBeenCalledWith('inst-2', expect.anything()) + wrapper.unmount() + }) + + it('shows a SECOND, different notice for the same install after the first is retired', async () => { + // The latch is keyed on the card, not the install. A user who updates Core without + // restarting Desktop can have a later grant newly clear its version gate; main queues it + // and this must be able to show it. + const wrapper = await mountBar() + expect(betaCards().length).toBe(1) + + getPendingBetaNotice.mockResolvedValue(['--enable-agent']) + bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) + await flushPromises() + expect(acknowledgeBetaNotice).toHaveBeenCalledWith('inst-1', ['--enable-assets']) + + // A gate transition re-runs the watcher; the new card is a different arg set. + bridgeState.firstUseModeChangedCallbacks.forEach((cb) => cb('loading-lockdown')) + await flushPromises() + bridgeState.firstUseModeChangedCallbacks.forEach((cb) => cb('none')) + await flushPromises() + + expect(betaCards().length).toBe(2) + wrapper.unmount() + }) + + it('does not re-raise the same card when acknowledging it failed', async () => { + // Main still holds it pending, so without a per-card latch every gate transition would + // put the identical card back up. + acknowledgeBetaNotice.mockRejectedValue(new Error('ipc down')) + const wrapper = await mountBar() + expect(betaCards().length).toBe(1) + + bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) + await flushPromises() + bridgeState.firstUseModeChangedCallbacks.forEach((cb) => cb('loading-lockdown')) + await flushPromises() + bridgeState.firstUseModeChangedCallbacks.forEach((cb) => cb('none')) + await flushPromises() + + expect(betaCards().length).toBe(1) wrapper.unmount() }) diff --git a/src/renderer/src/comfyTitleBar/useBetaActivationNotice.ts b/src/renderer/src/comfyTitleBar/useBetaActivationNotice.ts index 4e5f48885..a9308adff 100644 --- a/src/renderer/src/comfyTitleBar/useBetaActivationNotice.ts +++ b/src/renderer/src/comfyTitleBar/useBetaActivationNotice.ts @@ -78,12 +78,21 @@ export function useBetaActivationNotice( opts: UseBetaActivationNoticeOpts ): BetaActivationNoticeApi { const isShowing = ref(false) - // Guards so show/retire stick before the async round-trips land. Scoped to the install the - // card was raised for, not to the renderer: the title bar survives attach/detach without a - // reload, so a window that has already shown one install's card must still be able to show - // another install's. - let shownFor: string | null = null - let retiredFor: string | null = null + /** The install whose card is on screen, or `null`. Also the "a card is up" flag. */ + let shownForInstall: string | null = null + /** Identity of the card on screen: its args, joined. */ + let shownKey: string | null = null + /** Cards already retired in this renderer session, by that same identity. + * + * Keyed on the NOTICE, not on the install or the renderer. Both of those suppress too much: + * the title bar outlives attach/detach, and main can legitimately queue a SECOND, different + * card for the same install — a later grant that only now clears its version gate, say. + * Keying on the args lets those through while still swallowing a repeat of a card already + * dealt with, which is what an acknowledgement that failed to persist would otherwise + * produce once per gate transition. */ + const retiredKeys = new Set() + /** True while a show attempt is between its pending-read and its decision. */ + let showInFlight = false function gatePasses(): boolean { return ( @@ -94,30 +103,50 @@ export function useBetaActivationNotice( ) } - async function hasPendingNotice(installationId: string): Promise { + /** The args main is holding for this install, or `[]`. Returned rather than reduced to a + * boolean because the args are the card's identity — see `retiredKeys`. */ + async function pendingArgs(installationId: string): Promise { try { const pending = await window.api.getPendingBetaNotice(installationId) - return Array.isArray(pending) && pending.length > 0 + return Array.isArray(pending) ? pending.filter((a) => typeof a === 'string') : [] } catch { // Read failed; stay silent. Unlike the pill hint's "treat as unseen", guessing wrong // here would announce a beta feature that may not be on at all. - return false + return [] } } async function maybeShow(): Promise { const installationId = opts.installationId() if (!opts.bridge || !installationId) return - if (shownFor === installationId || retiredFor === installationId) return + if (shownForInstall !== null || showInFlight) return if (!gatePasses() || !opts.anchorRef.value) return - if (!(await hasPendingNotice(installationId))) return + // Claimed BEFORE the await: the gate watcher and the post-hint retry fire independently, + // so two calls could otherwise both clear the guard and each raise a card. + showInFlight = true + try { + await showIfPending(installationId) + } finally { + showInFlight = false + } + } + + /** The pending-read and the decision that follows it. Split out so `maybeShow` can hold an + * in-flight claim across the whole thing without a `try` nested in the guards. */ + async function showIfPending(installationId: string): Promise { + if (!opts.bridge) return + const args = await pendingArgs(installationId) + if (args.length === 0) return + const key = args.join(',') + if (retiredKeys.has(key)) return // Re-check after the await; the host could have flipped state or the pill hint could have // claimed the popup while we were asking. const anchor = opts.anchorRef.value if (!gatePasses() || !anchor || opts.installationId() !== installationId) return const rect = anchor.getBoundingClientRect() - shownFor = installationId + shownForInstall = installationId + shownKey = key isShowing.value = true opts.bridge.showCoachmark({ kind: 'beta-notice', @@ -134,18 +163,21 @@ export function useBetaActivationNotice( /** * Retire the card that is actually on screen. * - * Acknowledges `shownFor`, NOT the host's current install: the window can retarget while the + * Acknowledges the install it was RAISED for, not the host's current one: the window can retarget while the * card floats, and acknowledging the new install would permanently consume a notice the user * was never shown. */ async function retire(): Promise { isShowing.value = false - const installationId = shownFor - if (installationId === null || retiredFor === installationId) return - retiredFor = installationId + const installationId = shownForInstall + const key = shownKey + if (installationId === null || key === null) return + retiredKeys.add(key) + shownForInstall = null + shownKey = null opts.bridge?.hideCoachmark() try { - await window.api.acknowledgeBetaNotice(installationId) + await window.api.acknowledgeBetaNotice(installationId, key.split(',')) } catch { // Persistence failed; the next launch re-offers the notice. } @@ -153,8 +185,10 @@ export function useBetaActivationNotice( function forgetWithoutAcknowledging(): void { isShowing.value = false - // Clear the shown-latch too, so the same install can raise it again on its next launch. - shownFor = null + // Deliberately NOT added to `retiredKeys`: it was never acknowledged, so it must come + // back rather than being silently spent. + shownForInstall = null + shownKey = null } async function openSettings(): Promise { diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 207b48ed8..19b2feacf 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -1441,10 +1441,15 @@ export interface ElectronApi { * pending set is only cleared by `acknowledgeBetaNotice`, so a card that is * shown but never retired comes back on the next launch. */ getPendingBetaNotice(installationId: string): Promise - /** Retire this install's activation notice: its args are persisted as + /** Retire this install's activation notice: the args are persisted as * announced and never raise a card again. Called when the user dismisses - * the card or follows its settings link. */ - acknowledgeBetaNotice(installationId: string): Promise + * the card or follows its settings link. + * + * `shownArgs` names what the card actually displayed. Main retires exactly + * those rather than whatever is queued at retire time — a relaunch can + * re-arm while the sticky card floats, and the announced list is + * append-only, so acknowledging the wrong set silences it forever. */ + acknowledgeBetaNotice(installationId: string, shownArgs?: string[]): Promise // Theme getResolvedTheme(): Promise From eb4a94427cb11c21ace346ff79c39fe2bd4ce6c1 Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Mon, 21 Sep 2026 01:11:01 -0700 Subject: [PATCH 04/27] =?UTF-8?q?fix(core-beta):=20second=20review=20round?= =?UTF-8?q?=20=E2=80=94=20drop=20the=20cache,=20harden=20the=20popup=20IPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor panel (10 findings, 8/8 reviewers) on d0b3ee7, plus CodeRabbit. **The announced-args cache is gone.** It was added to answer a NITPICK about a blocking read on the launch path, and then produced two findings of its own: its stated invariant ("this module is the only writer") was false — the key is schema-known and reachable through the generic `set-setting` IPC and any settings import or reset — so a stale entry could replay an announced notice or suppress a new one for the whole process lifetime. The read it saved was one of several the launch path already performs. Removing it deletes the problem rather than layering invalidation on top. Acknowledgement now confirms the write by reading it back: `settings.set` declines to persist while settings.json is unreadable and does not throw, so a bare call was not evidence the value landed, and dropping the queue entry on that basis lost the card with nothing on disk. **Popup IPC hardening.** `Number.isFinite` on the anchor coordinates — NaN and Infinity survive `Math.round` and reach `setBounds`, which throws in main. The beak margin is capped at the midpoint so a collapsed measurement cannot invert the clamp and pin the beak to the corner the margin exists to avoid. Dismiss and action now echo the card's `configToken`, so a click arriving from a card the popup has since replaced is discarded instead of being attributed to the new owner — which could have acknowledged an unseen beta notice via a click on the onboarding hint. **A retarget between two real installs re-queries.** The gate watcher keys on install-less/lockdown, neither of which moves on a retarget, so the new install's pending notice was never asked for again. **Both beta-notice handlers validate the installation id**, since they drive a persistent, append-only write from renderer-supplied input. **The opsFlag seed guard moved inside its `try`**, so a partially-mocked `app` cannot take down `readPersistedFile`'s degrade-to-no-cache contract. Co-Authored-By: Claude Opus 5 --- src/main/lib/betaActivationNotice.ts | 33 ++++++++--------- src/main/lib/ipc/registerSettingsHandlers.ts | 8 +++-- src/main/lib/opsFlag.ts | 6 ++-- src/main/popups/titleCoachmark.ts | 35 ++++++++++++++----- src/preload/comfyTitleTooltipPreload.ts | 15 ++++---- .../src/comfyTitleBar/TitleBarApp.test.ts | 30 +++++++++++++--- .../src/comfyTitleBar/TitleBarApp.vue | 12 +++++-- .../src/comfyTitleTooltip/TitleTooltipApp.vue | 8 ++--- 8 files changed, 98 insertions(+), 49 deletions(-) diff --git a/src/main/lib/betaActivationNotice.ts b/src/main/lib/betaActivationNotice.ts index 5385e2785..9d4fb39e5 100644 --- a/src/main/lib/betaActivationNotice.ts +++ b/src/main/lib/betaActivationNotice.ts @@ -50,24 +50,19 @@ function claimedArgs(): Set { return claimed } -/** In-memory mirror of the persisted list. `settings.get` re-reads and re-parses the whole - * file on every call, and its retry path blocks the main thread with `Atomics.wait` — which - * `armBetaActivationNotice` would otherwise pay on the spawn critical path, once per launch. - * Safe to cache because this module is the only writer of the key; it is refreshed on write - * and cleared for tests. */ -let announcedCache: string[] | null = null - /** The persisted list, defensive about content: `settings.json` is user-writable, so a * hand-edited non-array or a non-string entry has to read as "nothing announced yet" rather - * than throwing on the launch path. */ + * than throwing on the launch path. + * + * Read straight through rather than cached. A cache here would have to stay coherent with + * every other writer of the key — it is schema-known, so the generic `set-setting` IPC and + * any settings import or reset can change it — and a stale entry either replays an announced + * notice or suppresses a new one for the process lifetime. The read it avoids is one of + * several the launch path already performs. */ export function readAnnouncedBetaArgs(): string[] { - if (announcedCache !== null) return announcedCache const raw = settings.get(BETA_NOTICE_ANNOUNCED_ARGS_KEY) - const parsed = Array.isArray(raw) - ? raw.filter((entry): entry is string => typeof entry === 'string') - : [] - announcedCache = parsed - return parsed + if (!Array.isArray(raw)) return [] + return raw.filter((entry): entry is string => typeof entry === 'string') } /** @@ -151,9 +146,12 @@ export function acknowledgeBetaActivationNotice( try { const merged = [...new Set([...readAnnouncedBetaArgs(), ...covered])] settings.set(BETA_NOTICE_ANNOUNCED_ARGS_KEY, merged) - announcedCache = merged - // Only drop from the queue once the write succeeded: clearing first would lose the card - // for this session while leaving nothing on disk, so it would re-announce next launch. + // Only drop from the queue once the value is actually readable back. `settings.set` can + // decline to persist (it refuses while settings.json is unreadable) without throwing, so + // a bare call is not evidence the write landed — and dropping it then would lose the card + // for this session while leaving nothing on disk. + const persisted = new Set(readAnnouncedBetaArgs()) + if (!covered.every((arg) => persisted.has(arg))) return const remaining = queued.filter((a) => !covered.includes(a)) if (remaining.length > 0) pendingByInstallation.set(installationId, remaining) else pendingByInstallation.delete(installationId) @@ -166,5 +164,4 @@ export function acknowledgeBetaActivationNotice( /** @internal — exposed for tests. */ export function _resetForTest(): void { pendingByInstallation.clear() - announcedCache = null } diff --git a/src/main/lib/ipc/registerSettingsHandlers.ts b/src/main/lib/ipc/registerSettingsHandlers.ts index 06becde89..9c947070e 100644 --- a/src/main/lib/ipc/registerSettingsHandlers.ts +++ b/src/main/lib/ipc/registerSettingsHandlers.ts @@ -341,7 +341,8 @@ export function registerSettingsHandlers(): void { // Core beta activation notice. A PULL pair rather than a push: main arms the pending set // during launch, when the host window may still be mid-attach or under the progress // takeover, and the title bar drains it once its own gate opens. - ipcMain.handle('get-pending-beta-notice', (_event, installationId: string) => { + ipcMain.handle('get-pending-beta-notice', (_event, installationId: unknown) => { + if (typeof installationId !== 'string' || installationId === '') return [] return peekBetaActivationNotice(installationId) }) @@ -349,7 +350,10 @@ export function registerSettingsHandlers(): void { // separate from the read, so a notice that is shown but never retired replays next launch. ipcMain.handle( 'acknowledge-beta-notice', - (_event, installationId: string, shownArgs?: unknown) => { + (_event, installationId: unknown, shownArgs?: unknown) => { + // A persistent, append-only write driven from the renderer, so the id is checked rather + // than trusted: a non-string would silently fail to retire the real pending notice. + if (typeof installationId !== 'string' || installationId === '') return recordIpcInvocation('acknowledge-beta-notice', { installationId }) // The renderer names what its card covered; anything else is ignored rather than // trusted, since this is an untrusted boundary like every other handler here. diff --git a/src/main/lib/opsFlag.ts b/src/main/lib/opsFlag.ts index 59b65ceec..f2e862f57 100644 --- a/src/main/lib/opsFlag.ts +++ b/src/main/lib/opsFlag.ts @@ -61,12 +61,14 @@ function maybeSeedFromEnv(): void { // Electron runtime. Unit tests import this module outside it, so touching `app` on the // common path would make every persisted-read test depend on mocking electron. if (process.env['E2E'] !== '1') return - // Hard guard: never run in production builds. - if (app.isPackaged) return const seed = process.env['E2E_OPS_FLAGS_SEED'] if (!seed) return delete process.env['E2E_OPS_FLAGS_SEED'] try { + // Inside the try with everything else: `readPersistedFile`'s whole contract is to degrade + // to "no cache", and a partially-mocked `app` throwing here would take that down with it. + // Hard guard: never run in production builds. + if (app.isPackaged) return JSON.parse(seed) // validate before writing const filePath = persistFilePath() fs.mkdirSync(path.dirname(filePath), { recursive: true }) diff --git a/src/main/popups/titleCoachmark.ts b/src/main/popups/titleCoachmark.ts index 429b7cf14..92fe86052 100644 --- a/src/main/popups/titleCoachmark.ts +++ b/src/main/popups/titleCoachmark.ts @@ -125,7 +125,10 @@ export function positionCoachmark(opts: { const cardWidth = Math.max(1, viewWidth - COACHMARK_SHADOW_GUTTER * 2) const cardLeft = x + COACHMARK_SHADOW_GUTTER const rawFraction = (pillCenter - cardLeft) / cardWidth - const beakMargin = COACHMARK_BEAK_EDGE_MARGIN / cardWidth + // Capped at the midpoint: for a card narrower than two margins the bounds would otherwise + // cross over, and `Math.min(1 - margin, …)` would return something below `margin` — pinning + // the beak to the very corner the margin exists to keep it off. + const beakMargin = Math.min(0.5, COACHMARK_BEAK_EDGE_MARGIN / cardWidth) const beakFraction = Math.min(1 - beakMargin, Math.max(beakMargin, rawFraction)) return { x, y, width: viewWidth, height: viewHeight, beakFraction } } @@ -307,9 +310,13 @@ export function registerTitleCoachmarkIpc(opts: { // Unrecognised kinds fall back to the onboarding hint rather than being refused: an // unroutable retirement would leave a card the title bar can never retire. const kind: CoachmarkKind = payload?.kind === 'beta-notice' ? 'beta-notice' : 'pill-hint' - const leftX = typeof payload?.leftX === 'number' ? payload.leftX : 0 - const rightX = typeof payload?.rightX === 'number' ? payload.rightX : leftX - const bottomY = typeof payload?.bottomY === 'number' ? payload.bottomY : TITLEBAR_HEIGHT + // `Number.isFinite`, not `typeof`: NaN and Infinity survive `Math.round` and reach + // `setBounds`, which throws in the main process. + const finite = (v: unknown, fallback: number): number => + typeof v === 'number' && Number.isFinite(v) ? v : fallback + const leftX = finite(payload?.leftX, 0) + const rightX = finite(payload?.rightX, leftX) + const bottomY = finite(payload?.bottomY, TITLEBAR_HEIGHT) openCoachmarkPopup({ parent, kind, @@ -334,9 +341,16 @@ export function registerTitleCoachmarkIpc(opts: { * dismiss and the secondary action — are retirements: the title-bar renderer owns the * once-ever persistence for whichever `kind` raised the card, so it is told either way and * decides what else the click means. */ - const retire = (senderId: number, channel: string): void => { + const retire = (senderId: number, channel: string, token: string | null): void => { const entry = coachmarkPopupsByWebContents.get(senderId) if (!entry) return + // The popup is reused, so a click can arrive from a card rendered for a PREVIOUS open — + // a config push still queued, or a click already in flight when it was reconfigured. + // `entry.kind` has moved on by then, and routing on it would attribute the click to the + // new owner: an unseen beta notice acknowledged by a click on the onboarding hint. + if (token !== null && entry.pendingConfigToken !== null && token !== entry.pendingConfigToken) { + return + } entry.view.hide() const parent = entry.view.parentWindow if (parent && !parent.isDestroyed()) { @@ -345,11 +359,14 @@ export function registerTitleCoachmarkIpc(opts: { } } - ipcMain.on('comfy-titlecoachmark:dismiss', (event) => { - retire(event.sender.id, 'comfy-titlebar:coachmark-dismissed') + const tokenOf = (payload?: { configToken?: unknown }): string | null => + typeof payload?.configToken === 'string' ? payload.configToken : null + + ipcMain.on('comfy-titlecoachmark:dismiss', (event, payload?: { configToken?: unknown }) => { + retire(event.sender.id, 'comfy-titlebar:coachmark-dismissed', tokenOf(payload)) }) - ipcMain.on('comfy-titlecoachmark:action', (event) => { - retire(event.sender.id, 'comfy-titlebar:coachmark-action') + ipcMain.on('comfy-titlecoachmark:action', (event, payload?: { configToken?: unknown }) => { + retire(event.sender.id, 'comfy-titlebar:coachmark-action', tokenOf(payload)) }) } diff --git a/src/preload/comfyTitleTooltipPreload.ts b/src/preload/comfyTitleTooltipPreload.ts index 43ca24fee..bf868f364 100644 --- a/src/preload/comfyTitleTooltipPreload.ts +++ b/src/preload/comfyTitleTooltipPreload.ts @@ -34,10 +34,11 @@ export interface ComfyTitleTooltipBridge { /** Beak position, pushed after main has measured the card and settled its final (possibly * clamped) bounds. Separate from the config push because it is only knowable then. */ onBeak(cb: (payload: { beakFraction: number }) => void): () => void - /** Coachmark dismiss button; no-op for the tooltip variant. */ - dismissCoachmark(): void + /** Coachmark dismiss button; no-op for the tooltip variant. `configToken` names the card + * the click landed on, so main can discard a click from a card it has since replaced. */ + dismissCoachmark(configToken: string): void /** Coachmark secondary action. Also retires the card — acting on it is acknowledging it. */ - actionCoachmark(): void + actionCoachmark(configToken: string): void } function isTooltipConfig(value: unknown): value is TitleTooltipConfig { @@ -79,11 +80,11 @@ const bridge: ComfyTitleTooltipBridge = { ipcRenderer.on('comfy-titletooltip:set-beak', handler) return () => ipcRenderer.removeListener('comfy-titletooltip:set-beak', handler) }, - dismissCoachmark: () => { - ipcRenderer.send('comfy-titlecoachmark:dismiss') + dismissCoachmark: (configToken) => { + ipcRenderer.send('comfy-titlecoachmark:dismiss', { configToken }) }, - actionCoachmark: () => { - ipcRenderer.send('comfy-titlecoachmark:action') + actionCoachmark: (configToken) => { + ipcRenderer.send('comfy-titlecoachmark:action', { configToken }) } } diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts index 71d2fe07b..680eee00a 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts @@ -1463,16 +1463,21 @@ describe('TitleBarApp', () => { wrapper.unmount() }) - it('acknowledges the install the card was raised for, not whatever the host retargets to', async () => { - // The card names "this instance". If the window attaches elsewhere while it floats, - // acknowledging the new install would permanently consume a notice never shown for it. + it('never acknowledges an install the card was not raised for', async () => { + // The card names "this instance". If the window attaches elsewhere while it floats, the + // card comes down unspent — acknowledging the new install would permanently consume a + // notice that was never shown for it. + getPendingBetaNotice.mockImplementation(async (id: string) => + id === 'inst-1' ? ['--enable-assets'] : [] + ) const wrapper = await mountBar() expect(betaCards().length).toBe(1) bridgeState.installationIdChangedCallbacks.forEach((cb) => cb('inst-2')) await flushPromises() - // The card belonged to inst-1, so it comes down — without being spent. expect(acknowledgeBetaNotice).not.toHaveBeenCalled() + // inst-2 has nothing pending, so nothing new is raised either. + expect(betaCards().length).toBe(1) bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) await flushPromises() @@ -1480,6 +1485,23 @@ describe('TitleBarApp', () => { wrapper.unmount() }) + it('queries the NEW install after the host retargets between two real instances', async () => { + // The gate watcher keys on install-less/lockdown, neither of which moves on a retarget, + // so without an explicit re-query the new install's notice is never asked for again. + getPendingBetaNotice.mockImplementation(async (id: string) => + id === 'inst-2' ? ['--enable-agent'] : [] + ) + const wrapper = await mountBar() + expect(betaCards().length).toBe(0) + + bridgeState.installationIdChangedCallbacks.forEach((cb) => cb('inst-2')) + await flushPromises() + + expect(getPendingBetaNotice).toHaveBeenCalledWith('inst-2') + expect(betaCards().length).toBe(1) + wrapper.unmount() + }) + it('shows a SECOND, different notice for the same install after the first is retired', async () => { // The latch is keyed on the card, not the install. A user who updates Core without // restarting Desktop can have a later grant newly clear its version gate; main queues it diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.vue b/src/renderer/src/comfyTitleBar/TitleBarApp.vue index 9649b1ace..8c00ca6d7 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.vue +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.vue @@ -648,9 +648,15 @@ onMounted(() => { // The card names "this instance", so it must not outlive the host retargeting to another. // Forgotten rather than retired: it was never acknowledged, so it replays for its own // install instead of being spent on one the user never saw it for. - if (previous !== installationId.value && betaNotice.isShowing.value) { - bridge.hideCoachmark() - betaNotice.forgetWithoutAcknowledging() + if (previous !== installationId.value) { + if (betaNotice.isShowing.value) { + bridge.hideCoachmark() + betaNotice.forgetWithoutAcknowledging() + } + // The gate watcher keys on install-less/lockdown, none of which move on a retarget + // between two real installs — so without this the new install's pending notice is never + // queried again for the rest of the session. + retryBetaNoticeAfterHint() } }) // The popup's own dismiss button (✕ / "Got it") routes through main diff --git a/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue b/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue index 8ab5e4af5..07240f1be 100644 --- a/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue +++ b/src/renderer/src/comfyTitleTooltip/TitleTooltipApp.vue @@ -30,13 +30,13 @@ interface Bridge { onConfig(cb: (config: TooltipConfig) => void): () => void /** Coachmark dismiss button — tells main to hide + persist the * once-ever flag. No-op for the tooltip variant. */ - dismissCoachmark?(): void + dismissCoachmark?(configToken: string): void /** Beak position as a fraction of the card's width, pushed once main has measured the card * and settled its final bounds. */ onBeak?(cb: (payload: { beakFraction: number }) => void): () => void /** Coachmark secondary action — retires the card the same way dismiss does, and lets the * owning feature run its follow-up (e.g. opening Settings). */ - actionCoachmark?(): void + actionCoachmark?(configToken: string): void } const bridge = (window as unknown as { __comfyTitleTooltip?: Bridge }).__comfyTitleTooltip @@ -131,11 +131,11 @@ watch([text, cmTitle, cmBody, cmActionLabel], () => { }) function onDismiss(): void { - bridge?.dismissCoachmark?.() + bridge?.dismissCoachmark?.(currentConfigToken) } function onAction(): void { - bridge?.actionCoachmark?.() + bridge?.actionCoachmark?.(currentConfigToken) } onUnmounted(() => { From d077eaf148e836e28e03911f92f6c1a907bd65c1 Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Mon, 21 Sep 2026 01:25:41 -0700 Subject: [PATCH 05/27] fix(core-beta): coalesce a turned-away show, key the latch per install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit, on eb4a944. A show attempt that arrives while one is in flight is now queued rather than discarded. The in-flight read belongs to whichever install was current when it started, so a retarget mid-read left the old result failing its id check and nobody asking about the new install until some unrelated gate transition — a hole in the retarget fix from the previous round. The retired-card identity now includes the installation, not just the args. Two installs can hold the same arg list once an acknowledgement has failed to persist (the announced list is what otherwise keeps them distinct), and one install's dismissal should not silence the other's card. The displayed args are carried alongside so retirement still acknowledges exactly those. --- .../comfyTitleBar/useBetaActivationNotice.ts | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/comfyTitleBar/useBetaActivationNotice.ts b/src/renderer/src/comfyTitleBar/useBetaActivationNotice.ts index a9308adff..713d21059 100644 --- a/src/renderer/src/comfyTitleBar/useBetaActivationNotice.ts +++ b/src/renderer/src/comfyTitleBar/useBetaActivationNotice.ts @@ -80,8 +80,10 @@ export function useBetaActivationNotice( const isShowing = ref(false) /** The install whose card is on screen, or `null`. Also the "a card is up" flag. */ let shownForInstall: string | null = null - /** Identity of the card on screen: its args, joined. */ + /** Identity of the card on screen (see `noticeKey`). */ let shownKey: string | null = null + /** The args that card displayed, carried so retirement acknowledges exactly those. */ + let shownArgs: readonly string[] = [] /** Cards already retired in this renderer session, by that same identity. * * Keyed on the NOTICE, not on the install or the renderer. Both of those suppress too much: @@ -93,6 +95,19 @@ export function useBetaActivationNotice( const retiredKeys = new Set() /** True while a show attempt is between its pending-read and its decision. */ let showInFlight = false + /** A show attempt arrived while one was in flight. The in-flight read belongs to whatever + * install was current when it started, so discarding the newcomer can strand the one that + * replaced it — a retarget mid-read leaves the old result failing its id check and nobody + * asking about the new install until some unrelated gate transition. Coalesced into a + * single re-run instead. */ + let retryQueued = false + + /** Identity of a card: the install AND its args. Args alone would let one install's + * dismissal suppress an identical card on another — reachable when an acknowledgement + * failed to persist, since the announced list is what otherwise keeps them distinct. */ + function noticeKey(installationId: string, args: readonly string[]): string { + return JSON.stringify([installationId, args]) + } function gatePasses(): boolean { return ( @@ -119,7 +134,11 @@ export function useBetaActivationNotice( async function maybeShow(): Promise { const installationId = opts.installationId() if (!opts.bridge || !installationId) return - if (shownForInstall !== null || showInFlight) return + if (shownForInstall !== null) return + if (showInFlight) { + retryQueued = true + return + } if (!gatePasses() || !opts.anchorRef.value) return // Claimed BEFORE the await: the gate watcher and the post-hint retry fire independently, // so two calls could otherwise both clear the guard and each raise a card. @@ -129,6 +148,12 @@ export function useBetaActivationNotice( } finally { showInFlight = false } + // Re-run once for whoever was turned away. Bounded: the flag is only set by a call that + // was skipped, so a quiet re-run ends here. + if (retryQueued) { + retryQueued = false + await maybeShow() + } } /** The pending-read and the decision that follows it. Split out so `maybeShow` can hold an @@ -137,7 +162,7 @@ export function useBetaActivationNotice( if (!opts.bridge) return const args = await pendingArgs(installationId) if (args.length === 0) return - const key = args.join(',') + const key = noticeKey(installationId, args) if (retiredKeys.has(key)) return // Re-check after the await; the host could have flipped state or the pill hint could have // claimed the popup while we were asking. @@ -147,6 +172,7 @@ export function useBetaActivationNotice( const rect = anchor.getBoundingClientRect() shownForInstall = installationId shownKey = key + shownArgs = args isShowing.value = true opts.bridge.showCoachmark({ kind: 'beta-notice', @@ -171,13 +197,15 @@ export function useBetaActivationNotice( isShowing.value = false const installationId = shownForInstall const key = shownKey + const args = shownArgs if (installationId === null || key === null) return retiredKeys.add(key) shownForInstall = null shownKey = null + shownArgs = [] opts.bridge?.hideCoachmark() try { - await window.api.acknowledgeBetaNotice(installationId, key.split(',')) + await window.api.acknowledgeBetaNotice(installationId, [...args]) } catch { // Persistence failed; the next launch re-offers the notice. } @@ -189,6 +217,7 @@ export function useBetaActivationNotice( // back rather than being silently spent. shownForInstall = null shownKey = null + shownArgs = [] } async function openSettings(): Promise { From 689d24e5fb345c283d5c25b652641d576f4aecc5 Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Mon, 21 Sep 2026 01:49:02 -0700 Subject: [PATCH 06/27] fix(core-beta): refuse a malformed shownArgs instead of filtering it empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit, on eb4a944 — reported only in the review body, with no inline comment, so it is worth recording where it came from. Filtering `[123]` down to `[]` made malformed input indistinguishable from an omitted argument, and the fallback for omitted is to acknowledge the entire queued set. A junk array from any renderer holding the bridge could therefore retire notices the user was never shown — permanently, since the announced list is append-only. The handler now accepts an omitted value or a non-empty array of strings, and refuses anything else. A test pins the fallback contract the handler relies on. --- src/main/lib/betaActivationNotice.test.ts | 9 +++++++++ src/main/lib/ipc/registerSettingsHandlers.ts | 14 ++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/main/lib/betaActivationNotice.test.ts b/src/main/lib/betaActivationNotice.test.ts index 718739838..72dec4e92 100644 --- a/src/main/lib/betaActivationNotice.test.ts +++ b/src/main/lib/betaActivationNotice.test.ts @@ -183,6 +183,15 @@ describe('arm / peek / acknowledge', () => { expect(peekBetaActivationNotice('inst-1')).toEqual(['--enable-agent']) }) + it('an empty shownArgs falls back to the queue, which is why the handler refuses one', () => { + // Documents the contract the IPC handler depends on: `[]` is indistinguishable from + // "the renderer named nothing", so the handler must reject a malformed array rather than + // filter it down to one — otherwise junk input retires the whole queue permanently. + armBetaActivationNotice('inst-1', ['--enable-assets', '--enable-agent']) + acknowledgeBetaActivationNotice('inst-1', []) + expect(announced()).toEqual(['--enable-assets', '--enable-agent']) + }) + it('acknowledging an install with nothing pending writes nothing', () => { acknowledgeBetaActivationNotice('inst-1') expect(announced()).toBeUndefined() diff --git a/src/main/lib/ipc/registerSettingsHandlers.ts b/src/main/lib/ipc/registerSettingsHandlers.ts index 9c947070e..babb00ece 100644 --- a/src/main/lib/ipc/registerSettingsHandlers.ts +++ b/src/main/lib/ipc/registerSettingsHandlers.ts @@ -355,12 +355,14 @@ export function registerSettingsHandlers(): void { // than trusted: a non-string would silently fail to retire the real pending notice. if (typeof installationId !== 'string' || installationId === '') return recordIpcInvocation('acknowledge-beta-notice', { installationId }) - // The renderer names what its card covered; anything else is ignored rather than - // trusted, since this is an untrusted boundary like every other handler here. - const args = Array.isArray(shownArgs) - ? shownArgs.filter((a): a is string => typeof a === 'string') - : undefined - acknowledgeBetaActivationNotice(installationId, args) + // Malformed input is REFUSED, not filtered. Filtering `[123]` down to `[]` would read + // as "the renderer named nothing", and the fallback for that is to acknowledge the whole + // queue — so a junk array would permanently retire notices the user was never shown. + // Only an omitted value, or a non-empty array of strings, is accepted. + const isStringArray = (v: unknown): v is string[] => + Array.isArray(v) && v.length > 0 && v.every((a) => typeof a === 'string') + if (shownArgs !== undefined && !isStringArray(shownArgs)) return + acknowledgeBetaActivationNotice(installationId, shownArgs) } ) From 5e6ced97caa9c8bbaad993bb727e1fd6e5e719da Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Mon, 21 Sep 2026 01:52:51 -0700 Subject: [PATCH 07/27] fix(core-beta): adapt the merged shownArgs test to this branch's grant objects The test came across from #1551 using bare arg strings; the queue holds CoreBetaGrant objects on this branch. --- src/main/lib/betaActivationNotice.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/lib/betaActivationNotice.test.ts b/src/main/lib/betaActivationNotice.test.ts index ffd28e05b..041be3306 100644 --- a/src/main/lib/betaActivationNotice.test.ts +++ b/src/main/lib/betaActivationNotice.test.ts @@ -360,7 +360,7 @@ describe('arm / peek / acknowledge', () => { // Documents the contract the IPC handler depends on: `[]` is indistinguishable from // "the renderer named nothing", so the handler must reject a malformed array rather than // filter it down to one — otherwise junk input retires the whole queue permanently. - armBetaActivationNotice('inst-1', ['--enable-assets', '--enable-agent']) + armBetaActivationNotice('inst-1', [grant('--enable-assets'), grant('--enable-agent')]) acknowledgeBetaActivationNotice('inst-1', []) expect(announced()).toEqual(['--enable-assets', '--enable-agent']) }) From ff832a67385ca714e611fd935b8784fe50823e17 Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Mon, 21 Sep 2026 04:15:30 -0700 Subject: [PATCH 08/27] fix(core-beta): resolve notice copy at show time, not at setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four card strings were read with `t()` during setup and passed as plain strings. The title bar's i18n instance starts in English and `syncLocale()` does not run until mount, so a user whose persisted locale is not English could get an English card — and because the strings were captured once, every later notice in that renderer stayed English even after the locale synced or the user changed language live. Pass getters instead and call them where the card is built. PR #1552 already does this via its `copyFor` callback; this makes PR #1551 correct on its own, which matters because the two are meant to be mergeable independently. Test pins the contract at the composable rather than through the mounted component, so it does not depend on i18n plumbing: both cases fail against the eager snapshot ('english-copy' where 'localised-copy' is expected) and pass against the getters. Co-Authored-By: Claude Opus 5 --- .../src/comfyTitleBar/TitleBarApp.vue | 10 ++- .../useBetaActivationNotice.test.ts | 84 +++++++++++++++++++ .../comfyTitleBar/useBetaActivationNotice.ts | 23 +++-- 3 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/comfyTitleBar/useBetaActivationNotice.test.ts diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.vue b/src/renderer/src/comfyTitleBar/TitleBarApp.vue index 8c00ca6d7..5833fe63c 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.vue +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.vue @@ -548,10 +548,12 @@ const betaNotice = useBetaActivationNotice({ isLoadingLockdown, anchorRef: announcementBtnRef, isSuppressed: () => coachmark.isShowing.value, - title: t('titleBar.betaNoticeTitle'), - body: t('titleBar.betaNoticeBody'), - dismissLabel: t('titleBar.betaNoticeDismiss'), - actionLabel: t('titleBar.betaNoticeSettings') + // Getters, not strings: `syncLocale()` runs on mount, after this setup block, so a + // snapshot taken here is English regardless of the user's persisted locale. + title: () => t('titleBar.betaNoticeTitle'), + body: () => t('titleBar.betaNoticeBody'), + dismissLabel: () => t('titleBar.betaNoticeDismiss'), + actionLabel: () => t('titleBar.betaNoticeSettings') }) /** Wrap the pill opener so opening the drawer retires the coachmark diff --git a/src/renderer/src/comfyTitleBar/useBetaActivationNotice.test.ts b/src/renderer/src/comfyTitleBar/useBetaActivationNotice.test.ts new file mode 100644 index 000000000..56aa829fb --- /dev/null +++ b/src/renderer/src/comfyTitleBar/useBetaActivationNotice.test.ts @@ -0,0 +1,84 @@ +import { ref, shallowRef } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useBetaActivationNotice } from './useBetaActivationNotice' + +/** + * The card's copy is resolved by the caller, and WHEN it is resolved is load-bearing. + * + * The title bar's i18n instance starts in English and `syncLocale()` only runs on mount, so + * copy read during setup is an English snapshot that never updates — the wrong language for a + * user whose persisted locale is not English, and permanently wrong for every later card in + * that renderer. These pin the contract that the getters are called when the card is built. + */ +describe('useBetaActivationNotice copy resolution', () => { + let showCoachmark: ReturnType + + /** Enough of a host for the gate to pass and a card to be built. */ + function mountNotice(copy: () => string) { + showCoachmark = vi.fn() + const anchor = document.createElement('button') + anchor.getBoundingClientRect = () => ({ left: 10, right: 30, bottom: 40 }) as unknown as DOMRect + + return useBetaActivationNotice({ + bridge: { showCoachmark, hideCoachmark: vi.fn() }, + installationId: () => 'inst-1', + isInstallLess: ref(false), + isFirstUseLockdown: ref(false), + isLoadingLockdown: ref(false), + anchorRef: shallowRef(anchor), + isSuppressed: () => false, + title: copy, + body: copy, + dismissLabel: copy, + actionLabel: copy + }) + } + + beforeEach(() => { + ;(window as unknown as { api: unknown }).api = { + getPendingBetaNotice: vi.fn().mockResolvedValue(['--enable-assets']), + acknowledgeBetaNotice: vi.fn().mockResolvedValue(undefined), + openGlobalSettings: vi.fn() + } + }) + + afterEach(() => { + vi.restoreAllMocks() + delete (window as unknown as { api?: unknown }).api + }) + + it('resolves copy when the card is shown, not when the composable is created', async () => { + // Stands in for the i18n instance: English at setup, switched by `syncLocale()` on mount. + let translated = 'english-copy' + const notice = mountNotice(() => translated) + + translated = 'localised-copy' + await notice.maybeShow() + + expect(showCoachmark).toHaveBeenCalledTimes(1) + const payload = showCoachmark.mock.calls[0]![0] + expect(payload.title).toBe('localised-copy') + expect(payload.body).toBe('localised-copy') + expect(payload.dismissLabel).toBe('localised-copy') + expect(payload.actionLabel).toBe('localised-copy') + }) + + it('re-reads copy for each card, so a later activation follows a live language change', async () => { + let translated = 'first-language' + const notice = mountNotice(() => translated) + + await notice.maybeShow() + expect(showCoachmark.mock.calls[0]![0].title).toBe('first-language') + + // The user switches language, then a second grant clears its version gate. + translated = 'second-language' + notice.forgetWithoutAcknowledging() + ;(window.api.getPendingBetaNotice as ReturnType).mockResolvedValue([ + '--enable-something-else' + ]) + await notice.maybeShow() + + expect(showCoachmark).toHaveBeenCalledTimes(2) + expect(showCoachmark.mock.calls[1]![0].title).toBe('second-language') + }) +}) diff --git a/src/renderer/src/comfyTitleBar/useBetaActivationNotice.ts b/src/renderer/src/comfyTitleBar/useBetaActivationNotice.ts index 713d21059..ab6752ef2 100644 --- a/src/renderer/src/comfyTitleBar/useBetaActivationNotice.ts +++ b/src/renderer/src/comfyTitleBar/useBetaActivationNotice.ts @@ -35,11 +35,16 @@ interface UseBetaActivationNoticeOpts { anchorRef: Readonly> /** True while another card owns the single popup (currently the pill hint). */ isSuppressed: () => boolean - /** Resolved copy (i18n done by the caller). */ - title: string - body: string - dismissLabel: string - actionLabel: string + /** Card copy, resolved by the caller at show time rather than passed as strings. + * + * Lazy deliberately: the title bar's i18n instance starts in English and `syncLocale()` + * does not run until mount, so anything read during setup is an English snapshot that + * never updates — wrong copy for a non-English user, and permanently wrong for every + * later card in this renderer. Called when the card is built instead. */ + title: () => string + body: () => string + dismissLabel: () => string + actionLabel: () => string } interface BetaActivationNoticeApi { @@ -176,10 +181,10 @@ export function useBetaActivationNotice( isShowing.value = true opts.bridge.showCoachmark({ kind: 'beta-notice', - title: opts.title, - body: opts.body, - dismissLabel: opts.dismissLabel, - actionLabel: opts.actionLabel, + title: opts.title(), + body: opts.body(), + dismissLabel: opts.dismissLabel(), + actionLabel: opts.actionLabel(), leftX: Math.round(rect.left), rightX: Math.round(rect.right), bottomY: Math.round(rect.bottom) From 0af3a08ad938325932c56bf601062efd51f4272e Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Mon, 21 Sep 2026 04:16:28 -0700 Subject: [PATCH 09/27] fix(core-beta): reserve the named spec's port, honour the null IPC contract Two findings on this PR's head. The named-payload spec hard-coded port 49519 while its companion spec already reserves one at runtime via `reserveFreePort()`. A developer machine or CI runner with that port taken fails the stub with EADDRINUSE, or worse, the launcher mistakes the unrelated listener for the fixture. This repo has no tolerance for flaky tests; use the same helper. `get-pending-beta-notice` still returned `[]` for an invalid installation id, left over from PR #1551 where the contract was `string[]`. This PR changed it to `BetaActivationNotice | null`, so that path returned a value outside its own declared union. The renderer survived it only because `pendingNotice()` validates `args` defensively; return `null` as the contract says. Co-Authored-By: Claude Opus 5 --- e2e/beta-activation-notice-named.test.ts | 11 ++++++----- src/main/lib/ipc/registerSettingsHandlers.ts | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/e2e/beta-activation-notice-named.test.ts b/e2e/beta-activation-notice-named.test.ts index adad4a3ce..da3142680 100644 --- a/e2e/beta-activation-notice-named.test.ts +++ b/e2e/beta-activation-notice-named.test.ts @@ -18,7 +18,7 @@ import { expect, test, type ElectronApplication } from '@playwright/test' import { launchApp, type AppContext } from './launchApp' import { clickInstallTile, expectChooserVisible } from './support/chooserHelpers' import { WebContentsPage } from './support/cdpPages' -import { opsFlagsGrantSeed, writeFakeComfyInstall } from './support/fakeComfyInstall' +import { opsFlagsGrantSeed, reserveFreePort, writeFakeComfyInstall } from './support/fakeComfyInstall' import { captureHostWindow } from './support/windowCapture' // A real launch does not fit the default 45s budget. @@ -26,7 +26,6 @@ test.describe.configure({ mode: 'serial', timeout: 180_000 }) const INSTALL_ID = 'inst-beta-notice-named' const INSTALL_NAME = 'Named Beta Fixture' -const PORT = 49519 const GRANT_ARG = '--enable-assets' const GRANT_MIN_CORE = '0.3.80' /** What the payload calls the feature. Deliberately not derivable from the arg token, so a @@ -35,6 +34,7 @@ const FEATURE_NAME = 'Assets browser' let ctx: AppContext let installPath: string +let port: number let previousPosthogHost: string | undefined /** See the companion spec: a closed port makes the flag fetch `unreachable`, which is the @@ -50,7 +50,8 @@ test.beforeAll(async () => { process.env['POSTHOG_HOST'] = UNREACHABLE_POSTHOG_HOST installPath = await mkdtemp(path.join(os.tmpdir(), 'comfyui-beta-notice-named-')) - await writeFakeComfyInstall({ installPath, port: PORT }) + port = await reserveFreePort() + await writeFakeComfyInstall({ installPath, port }) ctx = await launchApp({ settings: { @@ -67,7 +68,7 @@ test.beforeAll(async () => { sourceLabel: 'ComfyBuilder', installPath, status: 'installed', - launchArgs: `--port ${PORT}`, + launchArgs: `--port ${port}`, launchMode: 'window', browserPartition: 'unique', seen: true, @@ -105,7 +106,7 @@ test('a payload-supplied feature name reaches the card @linux', async () => { (await ctx.app.evaluate( ({ webContents }, port) => webContents.getAllWebContents().some((wc) => wc.getURL().includes(String(port))), - PORT + port )) === true, { timeout: 90_000, message: 'ComfyUI stub never came up / the host never attached' } ) diff --git a/src/main/lib/ipc/registerSettingsHandlers.ts b/src/main/lib/ipc/registerSettingsHandlers.ts index babb00ece..7ae12da96 100644 --- a/src/main/lib/ipc/registerSettingsHandlers.ts +++ b/src/main/lib/ipc/registerSettingsHandlers.ts @@ -342,7 +342,7 @@ export function registerSettingsHandlers(): void { // during launch, when the host window may still be mid-attach or under the progress // takeover, and the title bar drains it once its own gate opens. ipcMain.handle('get-pending-beta-notice', (_event, installationId: unknown) => { - if (typeof installationId !== 'string' || installationId === '') return [] + if (typeof installationId !== 'string' || installationId === '') return null return peekBetaActivationNotice(installationId) }) From ba10ca6ab17dd2dbd495196e818e0a6ad6191d87 Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Mon, 21 Sep 2026 04:49:50 -0700 Subject: [PATCH 10/27] fix(core-beta): reconcile the card when main hides it out from under us MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings with one root cause: the composable's display state was never reconciled with a hide it did not initiate, so it stayed latched on a card that is no longer on screen and turned away every later show. Window move/resize. `titleCoachmark` auto-hides the popup on `will-move`, `move` and `resize` because the anchor goes stale, but nothing told the renderer. Moving a window is ordinary, so the common outcome was a notice the user never read, neither displayed nor acknowledged, gone for the session. Main now sends `coachmark-auto-hidden` from the view's existing `onHide` hook, addressed with `kind` so one popup serving two cards reaches the right owner, and suppressed while `retire` drives the hide — retirement reports itself, and a duplicate would tell the owner to forget a card it had just acknowledged. The renderer forgets WITHOUT acknowledging, so main keeps the notice and it replays rather than being silently spent. Relaunch during loading. The gate only suppressed new shows, so a card already up floated over the progress takeover and left the composable latched: main re-armed the install with a fresh grant set and the loading-to-ready transition could not raise it. The gate watcher now hides a displayed card when the gate closes, which routes through the same forget path, so the newer queue is what gets queried when it reopens. Two tests, both verified to fail without the fix: re-showing after an auto-hide (`expected 1 to be 2`) and kind-routing, which fails if the notice is broadcast to both composables instead of addressed. A third test asserting only that nothing was acknowledged was dropped — it passed with the fix reverted, so it pinned nothing. Co-Authored-By: Claude Opus 5 --- src/main/popups/titleCoachmark.ts | 31 ++++++++++++- src/preload/comfyTitleBarPreload.ts | 10 +++++ .../src/comfyTitleBar/TitleBarApp.test.ts | 43 +++++++++++++++++++ .../src/comfyTitleBar/TitleBarApp.vue | 23 +++++++++- 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/main/popups/titleCoachmark.ts b/src/main/popups/titleCoachmark.ts index 92fe86052..1c548a5e2 100644 --- a/src/main/popups/titleCoachmark.ts +++ b/src/main/popups/titleCoachmark.ts @@ -149,6 +149,15 @@ interface CoachmarkPopupEntry { kind: CoachmarkKind } +/** The title-bar lookup, captured from `registerTitleCoachmarkIpc` so a popup built later + * can reach the title bar that owns it. Null until the IPC is registered. */ +let findTitleBarByParent: ((parent: BrowserWindow) => WebContents | null) | null = null + +/** True only while `retire` is driving the hide. Retirement reports itself on its own + * channel, so the auto-hide notice would be a duplicate — and a harmful one: it would tell + * the owner to FORGET a card it has just acknowledged. */ +let retireDrivingHide = false + const coachmarkPopupsByParent = new Map() const coachmarkPopupsByWebContents = new Map() @@ -169,6 +178,20 @@ function ensureCoachmarkPopup(parent: BrowserWindow): CoachmarkPopupEntry { // Sticky: hide only when the host window moves/resizes (stale anchor). // Not on blur — the dismiss button needs focus. hideOnParentEvents: ['will-move', 'move', 'resize'], + /** The popup also hides for reasons the owning composable never sees — `will-move`, + * `move` and `resize` above, because the anchor it points at has gone stale. Without + * this the renderer still believes its card is up and turns away every later show for + * the rest of the session, so a notice the user never read ends up neither displayed + * nor acknowledged. Addressed with `kind` so it reaches the composable that raised it. */ + onHide: () => { + if (retireDrivingHide) return + const entry = coachmarkPopupsByParent.get(parent.id) + if (!entry || parent.isDestroyed()) return + const tb = findTitleBarByParent?.(parent) + if (tb && !tb.isDestroyed()) { + tb.send('comfy-titlebar:coachmark-auto-hidden', { kind: entry.kind }) + } + }, onParentClosed: () => { coachmarkPopupsByParent.delete(parent.id) coachmarkPopupsByWebContents.delete(view.popupWebContentsId) @@ -261,6 +284,7 @@ export function registerTitleCoachmarkIpc(opts: { findParentByTitleBarSender: (wc: WebContents) => BrowserWindow | null findTitleBarByParent: (parent: BrowserWindow) => WebContents | null }): void { + findTitleBarByParent = opts.findTitleBarByParent ipcMain.on('comfy-titletooltip:ready', (event) => { const entry = coachmarkPopupsByWebContents.get(event.sender.id) if (!entry) return @@ -351,7 +375,12 @@ export function registerTitleCoachmarkIpc(opts: { if (token !== null && entry.pendingConfigToken !== null && token !== entry.pendingConfigToken) { return } - entry.view.hide() + retireDrivingHide = true + try { + entry.view.hide() + } finally { + retireDrivingHide = false + } const parent = entry.view.parentWindow if (parent && !parent.isDestroyed()) { const tb = opts.findTitleBarByParent(parent) diff --git a/src/preload/comfyTitleBarPreload.ts b/src/preload/comfyTitleBarPreload.ts index c3600f666..3ed477bb4 100644 --- a/src/preload/comfyTitleBarPreload.ts +++ b/src/preload/comfyTitleBarPreload.ts @@ -246,6 +246,10 @@ export interface ComfyTitleBarBridge { /** Subscribe to the coachmark's secondary action. Retires the card the same way * dismiss does, and additionally lets the owner run its follow-up. */ onCoachmarkAction(cb: (payload: { kind: CoachmarkKind }) => void): () => void + /** Subscribe to the popup being hidden by something other than a retirement — the host + * window moved or resized, leaving the anchor stale. NOT an acknowledgement: the owner + * should forget the card so it can be raised again, not mark it as seen. */ + onCoachmarkAutoHidden(cb: (payload: { kind: CoachmarkKind }) => void): () => void /** Tell main this title bar is mounted; main responds with the initial state. */ ready(): void } @@ -451,6 +455,12 @@ const bridge: ComfyTitleBarBridge = { ipcRenderer.on('comfy-titlebar:coachmark-action', handler) return () => ipcRenderer.removeListener('comfy-titlebar:coachmark-action', handler) }, + onCoachmarkAutoHidden: (cb: (payload: { kind: CoachmarkKind }) => void) => { + const handler = (_e: unknown, payload?: { kind?: unknown }): void => + cb({ kind: coachmarkKindOf(payload) }) + ipcRenderer.on('comfy-titlebar:coachmark-auto-hidden', handler) + return () => ipcRenderer.removeListener('comfy-titlebar:coachmark-auto-hidden', handler) + }, ready: () => { ipcRenderer.send('comfy-window:title-bar-ready') } diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts index 680eee00a..60f547c75 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts @@ -64,6 +64,7 @@ interface MockBridgeState { hideCoachmarkCalls: number coachmarkDismissedCallbacks: ((payload: { kind: string }) => void)[] coachmarkActionCallbacks: ((payload: { kind: string }) => void)[] + coachmarkAutoHiddenCallbacks: ((payload: { kind: string }) => void)[] readyCalls: number } @@ -102,6 +103,7 @@ function installMockBridge( hideCoachmarkCalls: 0, coachmarkDismissedCallbacks: [], coachmarkActionCallbacks: [], + coachmarkAutoHiddenCallbacks: [], readyCalls: 0 } const installationId = opts.installationId === undefined ? 'test-id' : opts.installationId @@ -232,6 +234,10 @@ function installMockBridge( state.coachmarkActionCallbacks.push(cb) return () => {} }, + onCoachmarkAutoHidden: (cb: (payload: { kind: string }) => void) => { + state.coachmarkAutoHiddenCallbacks.push(cb) + return () => {} + }, ready: () => { state.readyCalls += 1 } @@ -1420,6 +1426,43 @@ describe('TitleBarApp', () => { wrapper.unmount() }) + // The host window moving or resizing auto-hides the shared popup in main (the anchor the + // beak points at has gone stale). Nothing retired the card, so the user may never have + // read it — and if the composable stayed latched, no later card could be raised at all. + it('can raise the notice again after an auto-hide, rather than latching for the session', async () => { + const wrapper = await mountBar() + expect(betaCards().length).toBe(1) + + bridgeState.coachmarkAutoHiddenCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) + await flushPromises() + // A fresh grant clears its version gate later in the same session. The pill-hint + // retirement is just the re-trigger seam: it calls the beta notice's retry. + getPendingBetaNotice.mockResolvedValue(['--enable-something-else']) + bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'pill-hint' })) + await flushPromises() + + expect(betaCards().length).toBe(2) + expect(acknowledgeBetaNotice).not.toHaveBeenCalled() + wrapper.unmount() + }) + + // One popup backs both cards, so the auto-hide is addressed by kind. The pill hint's own + // auto-hide must not make the beta notice forget which card it has on screen — if it did, + // the later dismissal would acknowledge nothing and the notice would replay forever. + it('ignores an auto-hide addressed to the other card', async () => { + const wrapper = await mountBar() + expect(betaCards().length).toBe(1) + + bridgeState.coachmarkAutoHiddenCallbacks.forEach((cb) => cb({ kind: 'pill-hint' })) + await flushPromises() + bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) + await flushPromises() + + // Still knew which args were on screen, so the dismissal spent exactly those. + expect(acknowledgeBetaNotice).toHaveBeenCalledWith('inst-1', ['--enable-assets']) + wrapper.unmount() + }) + it('opens Settings on the beta opt-in row and retires the card in one click', async () => { const wrapper = await mountBar() bridgeState.coachmarkActionCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.vue b/src/renderer/src/comfyTitleBar/TitleBarApp.vue index 5833fe63c..61c2898b8 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.vue +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.vue @@ -105,6 +105,8 @@ interface Bridge { onCoachmarkDismissed: (cb: (payload: { kind: CoachmarkKind }) => void) => () => void /** The card's secondary action, when it has one. Retires the card like dismiss does. */ onCoachmarkAction: (cb: (payload: { kind: CoachmarkKind }) => void) => () => void + /** The popup was hidden without a retirement (host window moved/resized). */ + onCoachmarkAutoHidden: (cb: (payload: { kind: CoachmarkKind }) => void) => () => void onPanelChanged: (cb: (panel: ComfyPanelKey) => void) => () => void onTitleChanged: (cb: (title: string) => void) => () => void /** Install source-category pushes from main. The raw category @@ -598,6 +600,7 @@ let unsubInstallationId: (() => void) | undefined let unsubZoom: (() => void) | undefined let unsubCoachmarkDismissed: (() => void) | undefined let unsubCoachmarkAction: (() => void) | undefined +let unsubCoachmarkAutoHidden: (() => void) | undefined onMounted(() => { // Observe the trailing cluster so the left cluster can mirror its @@ -672,6 +675,13 @@ onMounted(() => { unsubCoachmarkAction = bridge.onCoachmarkAction(({ kind }) => { if (kind === 'beta-notice') void betaNotice.openSettings() }) + // Main hid the popup without anyone retiring it — the host window moved or resized, so the + // anchor the beak points at is stale. Forget WITHOUT acknowledging: the user may never have + // read the card, and leaving the composable believing it is still up would turn away every + // later show for the rest of the session. + unsubCoachmarkAutoHidden = bridge.onCoachmarkAutoHidden(({ kind }) => { + if (kind === 'beta-notice') betaNotice.forgetWithoutAcknowledging() + }) bridge.ready() }) @@ -681,7 +691,17 @@ onMounted(() => { watch( [isInstallLess, isFirstUseLockdown, isLoadingLockdown], ([installLess, lockdown, loading]) => { - if (installLess || lockdown || loading) return + if (installLess || lockdown || loading) { + // The gate has CLOSED on a card that is already up — most often a relaunch of this same + // install driving the progress takeover. The gate alone only suppresses new shows, so + // without this the stale card floats over the loader and, worse, leaves the composable + // latched: main re-arms the install with a fresh grant set and the loading-to-ready + // transition below cannot raise it. Hiding retires nothing; main still holds the + // pending notice and `onCoachmarkAutoHidden` clears the display state, so the newer + // queue is what gets queried when the gate reopens. + if (betaNotice.isShowing.value) bridge?.hideCoachmark() + return + } // Defer past the responsive fit settle so the pill's centre is final // before anchoring: nextTick flushes the DOM, the rAF the layout. void nextTick().then(() => { @@ -725,6 +745,7 @@ onUnmounted(() => { unsubZoom?.() unsubCoachmarkDismissed?.() unsubCoachmarkAction?.() + unsubCoachmarkAutoHidden?.() bridge?.hideCoachmark() hideTip() trailingObserver?.disconnect() From 35f146ce3e852c5d4676cb786872d0654d9b72d7 Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Mon, 21 Sep 2026 04:52:12 -0700 Subject: [PATCH 11/27] fix(core-beta): adapt the merged auto-hide test to this branch's notice object The re-show test merged from #1551 mocks the pending read with a bare arg array; on this branch that read returns `{ args, direction, description }`, so the mock fell through `pendingNotice`'s validation and no second card was raised. Same adaptation the shownArgs test needed. Co-Authored-By: Claude Opus 5 --- src/renderer/src/comfyTitleBar/TitleBarApp.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts index 3f253364f..d1228e83a 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts @@ -1450,7 +1450,11 @@ describe('TitleBarApp', () => { await flushPromises() // A fresh grant clears its version gate later in the same session. The pill-hint // retirement is just the re-trigger seam: it calls the beta notice's retry. - getPendingBetaNotice.mockResolvedValue(['--enable-something-else']) + getPendingBetaNotice.mockResolvedValue({ + args: ['--enable-something-else'], + direction: 'enabled', + description: null + }) bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'pill-hint' })) await flushPromises() From 79739f05aca334605cf43f2de35c7c5d42c7d7f1 Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Mon, 21 Sep 2026 04:58:39 -0700 Subject: [PATCH 12/27] test(core-beta): pin that a retired card is not resurrected by its own auto-hide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-hide wiring introduces one specific hazard: retirement hides the popup itself, so main's auto-hide notice follows every dismiss. If forgetting ever cleared `retiredKeys`, an acknowledged card would come straight back. It does not today — `retire` records the key before hiding, and `forgetWithoutAcknowledging` deliberately leaves the retired set alone — but nothing held that invariant in place. Verified the test catches a regression: making forget clear `retiredKeys` resurrects the card (`expected 2 to be 1`). Co-Authored-By: Claude Opus 5 --- .../src/comfyTitleBar/TitleBarApp.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts index 60f547c75..04534bc60 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts @@ -1446,6 +1446,29 @@ describe('TitleBarApp', () => { wrapper.unmount() }) + // A retirement hides the popup itself, so the auto-hide notice follows its own dismiss. + // That must NOT resurrect the card: `retire` records the key before hiding and + // `forgetWithoutAcknowledging` deliberately leaves `retiredKeys` alone. This is the + // specific hazard the auto-hide wiring introduces, so it is pinned. + it('does not resurrect a card whose own retirement triggered the auto-hide', async () => { + const wrapper = await mountBar() + expect(betaCards().length).toBe(1) + + bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) + await flushPromises() + expect(acknowledgeBetaNotice).toHaveBeenCalledWith('inst-1', ['--enable-assets']) + + // The hide that retirement performed comes back round as an auto-hide. + bridgeState.coachmarkAutoHiddenCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) + await flushPromises() + bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'pill-hint' })) + await flushPromises() + + // Same args still pending from main, but the card was acknowledged: it stays gone. + expect(betaCards().length).toBe(1) + wrapper.unmount() + }) + // One popup backs both cards, so the auto-hide is addressed by kind. The pill hint's own // auto-hide must not make the beta notice forget which card it has on screen — if it did, // the later dismissal would acknowledge nothing and the notice would replay forever. From bbd33dd951f057b086a7d86572a8456b98302bb0 Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Mon, 21 Sep 2026 05:19:54 -0700 Subject: [PATCH 13/27] fix(core-beta): re-show after movement, and drop a claim the launch never earned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P2s from the review pass on 79739f05. Re-show after auto-hide. The previous commit made the composable forget a card main hid, which unlatched it but never put it back: no watcher observes window movement, so the notice stayed absent until an unrelated relaunch or retarget. Half a fix. The auto-hide handler now schedules a re-show, debounced 250ms because `move` fires continuously through a drag and a per-event re-show would flicker the card for the whole gesture. `maybeShow` re-reads the anchor rect, which is the point — the stale rect is what triggered the hide. The reviewer's evidence for this was that the accompanying test needed an artificial pill-hint dismissal to trigger the retry, which was fair: the test was reaching for a seam production did not have. It now drives three rapid auto-hides and advances timers, asserting one re-show for the whole drag. Claims outliving a failed launch. `reportCoreBetaLaunch` arms just before the spawn, so a launch that then throws leaves a claim for a Core that never started; `claimedArgs` spans every install, so it also silences the same arg for a different install that launches successfully. The terminal-failure and relaunch-failure paths now drop it. Corrected while writing this: the first draft of the doc comment said arming could not undo the claim. It can — the delete precedes the empty-args return, and there are existing tests for it. The real gap is narrower: arming repairs this at the install's NEXT launch, while the failed launch's progress takeover ends long before that. The comment now says so. All three new claim tests verified to fail against the unfixed code, including the over-clearing guard, which fails if the clear also wipes announced args. Co-Authored-By: Claude Opus 5 --- src/main/lib/betaActivationNotice.test.ts | 34 +++++++++++++++++ src/main/lib/betaActivationNotice.ts | 20 ++++++++++ src/main/lib/ipc/sessionActions/launch.ts | 7 +++- .../src/comfyTitleBar/TitleBarApp.test.ts | 38 +++++++++++-------- .../src/comfyTitleBar/TitleBarApp.vue | 28 +++++++++++++- 5 files changed, 110 insertions(+), 17 deletions(-) diff --git a/src/main/lib/betaActivationNotice.test.ts b/src/main/lib/betaActivationNotice.test.ts index 72dec4e92..b38c4ec59 100644 --- a/src/main/lib/betaActivationNotice.test.ts +++ b/src/main/lib/betaActivationNotice.test.ts @@ -18,6 +18,7 @@ import { _resetForTest, acknowledgeBetaActivationNotice, armBetaActivationNotice, + clearBetaActivationClaim, peekBetaActivationNotice, readAnnouncedBetaArgs, selectNewlyActiveBetaArgs @@ -223,4 +224,37 @@ describe('arm / peek / acknowledge', () => { armBetaActivationNotice('inst-2', ['--enable-assets']) expect(peekBetaActivationNotice('inst-2')).toEqual(['--enable-assets']) }) + + // Arming already repairs this on the install's NEXT launch. These cover the window in + // between, which the relaunch cannot: the failed launch's progress takeover ends first. + it('drops a claim left by a launch that never started', () => { + armBetaActivationNotice('inst-1', ['--enable-assets']) + expect(peekBetaActivationNotice('inst-1')).toEqual(['--enable-assets']) + + clearBetaActivationClaim('inst-1') + expect(peekBetaActivationNotice('inst-1')).toEqual([]) + }) + + it('frees the arg for another install straight away, not at the next relaunch', () => { + armBetaActivationNotice('inst-1', ['--enable-assets']) + // inst-2 launches successfully while inst-1's dead claim still holds the arg. + armBetaActivationNotice('inst-2', ['--enable-assets']) + expect(peekBetaActivationNotice('inst-2')).toEqual([]) + + clearBetaActivationClaim('inst-1') + armBetaActivationNotice('inst-2', ['--enable-assets']) + expect(peekBetaActivationNotice('inst-2')).toEqual(['--enable-assets']) + }) + + it('discards only the unannounced claim, never an arg already announced', () => { + armBetaActivationNotice('inst-1', ['--enable-assets']) + acknowledgeBetaActivationNotice('inst-1', ['--enable-assets']) + expect(readAnnouncedBetaArgs()).toEqual(['--enable-assets']) + + clearBetaActivationClaim('inst-1') + expect(readAnnouncedBetaArgs()).toEqual(['--enable-assets']) + // Announced means spent: a later launch of the same arg stays silent. + armBetaActivationNotice('inst-1', ['--enable-assets']) + expect(peekBetaActivationNotice('inst-1')).toEqual([]) + }) }) diff --git a/src/main/lib/betaActivationNotice.ts b/src/main/lib/betaActivationNotice.ts index 9d4fb39e5..185d3e28b 100644 --- a/src/main/lib/betaActivationNotice.ts +++ b/src/main/lib/betaActivationNotice.ts @@ -115,6 +115,26 @@ export function armBetaActivationNotice( } } +/** Drop this install's claim without announcing anything. + * + * Arming happens just before the spawn, so a launch that then fails leaves a claim for a + * Core that never started: the title bar would announce "a beta feature is on" once the + * progress takeover closes, and because `claimedArgs` spans every install, the dead claim + * would also silence the same arg for a DIFFERENT install that launches successfully. + * `armBetaActivationNotice` already clears the entry on the NEXT launch of this install, + * which repairs the state eventually. This closes the window in between, where the claim is + * live and wrong: the failed launch's own progress takeover ends long before any relaunch. + * + * Nothing is persisted here, so this only discards an unannounced claim; an arg already + * written to the announced list stays announced. */ +export function clearBetaActivationClaim(installationId: string): void { + try { + pendingByInstallation.delete(installationId) + } catch (err) { + console.log('[beta-notice] clear failed:', err) + } +} + /** What this install's title bar should announce, or `[]`. Read-only: the pending entry * survives until `acknowledgeBetaActivationNotice`, so a card that is shown but never retired * (window closed, app quit) comes back on the next launch. */ diff --git a/src/main/lib/ipc/sessionActions/launch.ts b/src/main/lib/ipc/sessionActions/launch.ts index 4e9e4fa52..2c71905d6 100644 --- a/src/main/lib/ipc/sessionActions/launch.ts +++ b/src/main/lib/ipc/sessionActions/launch.ts @@ -97,7 +97,7 @@ import { writeComfyEnvironment } from '../../../sources/standalone/envPaths' import type { PersistedTorchStack } from '../../../sources/standalone/torchStackTypes' import type { WriteStream } from 'fs' import { getCoreBetaGrantsAsync, selectCoreBetaGrantArgs } from '../../coreBetaGrants' -import { armBetaActivationNotice } from '../../betaActivationNotice' +import { armBetaActivationNotice, clearBetaActivationClaim } from '../../betaActivationNotice' import type { CoreBetaGrant } from '../../coreBetaGrants' import { coreRecordCurrent, coreSemver, coreSemverExact, coreSemverVerified } from '../../version' import type { CoreCheckout } from '../../version' @@ -1667,6 +1667,10 @@ async function runLaunch( if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) abort.abort() // stop the template-models reader timer on launch failure _clearLaunchingFailed(installationId) + // The grants were claimed just before the spawn, which has now failed or been cancelled. + // Drop the claim: nothing started, so there is nothing to announce — and leaving it would + // also silence the same arg for another install, since claims are global. + clearBetaActivationClaim(installationId) // Flush the hardware tap on terminal failure/cancel too: the exit handler // covers a process that exits, but a waitForPort timeout can return here // with the proc still alive, leaving a pending accelerator event unemitted. @@ -1816,6 +1820,7 @@ async function runLaunch( assetsTap.flushSummary() _removeSession(installationId) _clearLaunchingFailed(installationId) + clearBetaActivationClaim(installationId) if (abort.signal.aborted) return { ok: false, cancelled: true } return { ok: false, message: (err as Error).message } } diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts index 04534bc60..f6da6577b 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts @@ -1429,21 +1429,29 @@ describe('TitleBarApp', () => { // The host window moving or resizing auto-hides the shared popup in main (the anchor the // beak points at has gone stale). Nothing retired the card, so the user may never have // read it — and if the composable stayed latched, no later card could be raised at all. - it('can raise the notice again after an auto-hide, rather than latching for the session', async () => { - const wrapper = await mountBar() - expect(betaCards().length).toBe(1) - - bridgeState.coachmarkAutoHiddenCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) - await flushPromises() - // A fresh grant clears its version gate later in the same session. The pill-hint - // retirement is just the re-trigger seam: it calls the beta notice's retry. - getPendingBetaNotice.mockResolvedValue(['--enable-something-else']) - bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'pill-hint' })) - await flushPromises() - - expect(betaCards().length).toBe(2) - expect(acknowledgeBetaNotice).not.toHaveBeenCalled() - wrapper.unmount() + it('puts the card back by itself once the window settles', async () => { + vi.useFakeTimers() + try { + const wrapper = await mountBar() + expect(betaCards().length).toBe(1) + + // A drag fires `move` repeatedly; main hides the popup on each one. + bridgeState.coachmarkAutoHiddenCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) + bridgeState.coachmarkAutoHiddenCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) + bridgeState.coachmarkAutoHiddenCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) + await vi.advanceTimersByTimeAsync(100) + // Still mid-gesture: the debounce has not elapsed, so nothing has been re-raised. + expect(betaCards().length).toBe(1) + + await vi.advanceTimersByTimeAsync(300) + // Settled: exactly one re-show for the whole drag, not one per event. + expect(betaCards().length).toBe(2) + // Still never acknowledged - the user has not acted on it. + expect(acknowledgeBetaNotice).not.toHaveBeenCalled() + wrapper.unmount() + } finally { + vi.useRealTimers() + } }) // A retirement hides the popup itself, so the auto-hide notice follows its own dismiss. diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.vue b/src/renderer/src/comfyTitleBar/TitleBarApp.vue index 61c2898b8..3bf3ca8d5 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.vue +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.vue @@ -565,6 +565,25 @@ function handleInstallPillWithCoachmark(): void { handleInstallPill() } +/** Re-raise a card that main hid out from under us. Forgetting alone only unlatches the + * composable — no watcher observes window movement, so without this the notice stays absent + * until some unrelated relaunch or retarget happens to re-run the gate. + * + * Debounced because `move` fires continuously through a drag: each event hides the popup + * again, so re-showing per event would thrash the card on and off for the whole gesture. + * One re-show once the window settles. `maybeShow` re-reads the anchor rect, which is the + * point — the old rect is exactly what went stale. */ +const BETA_NOTICE_RESHOW_DEBOUNCE_MS = 250 +let betaReshowTimer: ReturnType | null = null +function scheduleBetaNoticeReshow(): void { + if (unmounted) return + if (betaReshowTimer !== null) clearTimeout(betaReshowTimer) + betaReshowTimer = setTimeout(() => { + betaReshowTimer = null + if (!unmounted) void betaNotice.maybeShow() + }, BETA_NOTICE_RESHOW_DEBOUNCE_MS) +} + /** The beta notice defers while the hint owns the popup, and nothing in the gate watcher * changes when the hint goes away — so without this the deferred card waits for the next * launch. Safe to call unconditionally: `maybeShow` re-checks the gate and main still holds @@ -680,7 +699,10 @@ onMounted(() => { // read the card, and leaving the composable believing it is still up would turn away every // later show for the rest of the session. unsubCoachmarkAutoHidden = bridge.onCoachmarkAutoHidden(({ kind }) => { - if (kind === 'beta-notice') betaNotice.forgetWithoutAcknowledging() + if (kind !== 'beta-notice') return + betaNotice.forgetWithoutAcknowledging() + // Unlatching is only half of it: put the card back once the window settles. + scheduleBetaNoticeReshow() }) bridge.ready() }) @@ -746,6 +768,10 @@ onUnmounted(() => { unsubCoachmarkDismissed?.() unsubCoachmarkAction?.() unsubCoachmarkAutoHidden?.() + if (betaReshowTimer !== null) { + clearTimeout(betaReshowTimer) + betaReshowTimer = null + } bridge?.hideCoachmark() hideTip() trailingObserver?.disconnect() From ce0b6ea3f392f6d24a536cd922d3b8e304c06b3b Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Mon, 21 Sep 2026 05:42:57 -0700 Subject: [PATCH 14/27] fix(core-beta): tell the onboarding hint about its auto-hide too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-hide handler routed `beta-notice` and returned early on `pill-hint`, which left the worse of the two cases unhandled. `coachmark.isShowing` IS the beta notice's suppression gate, so a hint that main hid on a window move but that still believes it is up silences the beta card for the renderer's whole life — and the hint cannot be dismissed out of that state either, because there is no card left to click. `hasCoachmarkShown` also stays latched, so it cannot re-raise itself. Gave the hint the same `forgetWithoutAcknowledging` the notice has: clears the display state and releases the once-per-renderer show latch, without persisting `seen`. Retiring would be wrong — the user may never have read it, and `seen` is once-ever. The re-show scheduler now re-evaluates both cards in the gate watcher's order and for its reason: the hint wins a collision, and awaiting it keeps the notice's suppression check off a stale `false`. Both are gated and idempotent, so whichever was not hidden simply declines. One test, verified to fail without the routing (`expected 1 to be 2`), also asserting `hasSeenCentralPillHint` is not persisted. A second test was written and dropped: it drove a dismissal of the stranded hint, which is the one thing a stranded user cannot do, so it modelled an impossible scenario and passed with the fix reverted. Co-Authored-By: Claude Opus 5 --- .../src/comfyTitleBar/TitleBarApp.test.ts | 31 ++++++++++++++- .../src/comfyTitleBar/TitleBarApp.vue | 38 ++++++++++++------- .../comfyTitleBar/useCentralPillCoachmark.ts | 19 ++++++++++ 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts index f6da6577b..7490e9d45 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts @@ -1333,11 +1333,13 @@ describe('TitleBarApp', () => { let getPendingBetaNotice: ReturnType let acknowledgeBetaNotice: ReturnType let openGlobalSettings: ReturnType + let setSetting: ReturnType function installApiMock(opts: { pending?: string[]; pillHintSeen?: boolean } = {}): void { getPendingBetaNotice = vi.fn().mockResolvedValue(opts.pending ?? ['--enable-assets']) acknowledgeBetaNotice = vi.fn().mockResolvedValue(undefined) openGlobalSettings = vi.fn() + setSetting = vi.fn().mockResolvedValue(undefined) ;(window as unknown as { api: unknown }).api = { getSetting: vi .fn() @@ -1346,7 +1348,7 @@ describe('TitleBarApp', () => { ? Promise.resolve(opts.pillHintSeen !== false) : Promise.resolve(undefined) ), - setSetting: vi.fn().mockResolvedValue(undefined), + setSetting, getPendingBetaNotice, acknowledgeBetaNotice, openGlobalSettings @@ -1523,6 +1525,33 @@ describe('TitleBarApp', () => { wrapper.unmount() }) + // The hint shares the popup, so it gets auto-hidden by movement too — and stranding it is + // worse than stranding the notice: `coachmark.isShowing` IS the beta notice's suppression + // gate, and the hint cannot be dismissed once its card is gone. + it('re-raises the onboarding hint after movement auto-hides it', async () => { + installApiMock({ pillHintSeen: false }) + const wrapper = await mountBar() + const hintCards = () => bridgeState.showCoachmarkCalls.filter((c) => c.kind !== 'beta-notice') + expect(hintCards().length).toBe(1) + // The hint owns the popup, so the beta notice correctly defers. + expect(betaCards().length).toBe(0) + + // Fake timers only now: the mount path needs real ones to settle. + vi.useFakeTimers() + try { + bridgeState.coachmarkAutoHiddenCallbacks.forEach((cb) => cb({ kind: 'pill-hint' })) + await vi.advanceTimersByTimeAsync(400) + + // Raised again rather than stranded: the user never read it, so it is not spent. + expect(hintCards().length).toBe(2) + // And it was NOT persisted as seen, which only a real dismissal may do. + expect(setSetting).not.toHaveBeenCalledWith('hasSeenCentralPillHint', true) + wrapper.unmount() + } finally { + vi.useRealTimers() + } + }) + it('retries once the onboarding hint releases the popup', async () => { // Deferring is correct, but nothing in the gate watcher changes when the hint goes away, // so the card would otherwise wait for the next launch. diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.vue b/src/renderer/src/comfyTitleBar/TitleBarApp.vue index 3bf3ca8d5..15ca82c11 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.vue +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.vue @@ -573,15 +573,21 @@ function handleInstallPillWithCoachmark(): void { * again, so re-showing per event would thrash the card on and off for the whole gesture. * One re-show once the window settles. `maybeShow` re-reads the anchor rect, which is the * point — the old rect is exactly what went stale. */ -const BETA_NOTICE_RESHOW_DEBOUNCE_MS = 250 -let betaReshowTimer: ReturnType | null = null -function scheduleBetaNoticeReshow(): void { +const COACHMARK_RESHOW_DEBOUNCE_MS = 250 +let coachmarkReshowTimer: ReturnType | null = null +function scheduleCoachmarkReshow(): void { if (unmounted) return - if (betaReshowTimer !== null) clearTimeout(betaReshowTimer) - betaReshowTimer = setTimeout(() => { - betaReshowTimer = null - if (!unmounted) void betaNotice.maybeShow() - }, BETA_NOTICE_RESHOW_DEBOUNCE_MS) + if (coachmarkReshowTimer !== null) clearTimeout(coachmarkReshowTimer) + coachmarkReshowTimer = setTimeout(() => { + coachmarkReshowTimer = null + if (unmounted) return + // Both cards, in the gate watcher's order and for its reason: the hint wins a collision, + // and awaiting it keeps the beta notice's suppression check from reading a stale `false`. + // Each is gated and idempotent, so the one that was not hidden simply declines. + void coachmark.maybeShow().then(() => { + if (!unmounted) void betaNotice.maybeShow() + }) + }, COACHMARK_RESHOW_DEBOUNCE_MS) } /** The beta notice defers while the hint owns the popup, and nothing in the gate watcher @@ -699,10 +705,14 @@ onMounted(() => { // read the card, and leaving the composable believing it is still up would turn away every // later show for the rest of the session. unsubCoachmarkAutoHidden = bridge.onCoachmarkAutoHidden(({ kind }) => { - if (kind !== 'beta-notice') return - betaNotice.forgetWithoutAcknowledging() + // Whichever card was on the popup, the owner has to be told. The hint matters as much as + // the notice: `coachmark.isShowing` is the beta notice's suppression gate, so a hint left + // believing it is up silences the beta card for the renderer's whole life — and cannot be + // dismissed either, since there is no longer a card to click. + if (kind === 'beta-notice') betaNotice.forgetWithoutAcknowledging() + else coachmark.forgetWithoutAcknowledging() // Unlatching is only half of it: put the card back once the window settles. - scheduleBetaNoticeReshow() + scheduleCoachmarkReshow() }) bridge.ready() }) @@ -768,9 +778,9 @@ onUnmounted(() => { unsubCoachmarkDismissed?.() unsubCoachmarkAction?.() unsubCoachmarkAutoHidden?.() - if (betaReshowTimer !== null) { - clearTimeout(betaReshowTimer) - betaReshowTimer = null + if (coachmarkReshowTimer !== null) { + clearTimeout(coachmarkReshowTimer) + coachmarkReshowTimer = null } bridge?.hideCoachmark() hideTip() diff --git a/src/renderer/src/comfyTitleBar/useCentralPillCoachmark.ts b/src/renderer/src/comfyTitleBar/useCentralPillCoachmark.ts index 7398d6d1a..5e02ba5c6 100644 --- a/src/renderer/src/comfyTitleBar/useCentralPillCoachmark.ts +++ b/src/renderer/src/comfyTitleBar/useCentralPillCoachmark.ts @@ -44,6 +44,9 @@ interface CentralPillCoachmarkApi { dismiss: () => Promise /** Opening the pill drawer counts as acknowledgement; same as `dismiss`. */ acknowledgeViaPillOpen: () => Promise + /** The popup was hidden by something other than a retirement (the host window moved). + * Clears display state without persisting `seen`, so the hint can be raised again. */ + forgetWithoutAcknowledging: () => void /** `true` between show and dismiss; drives the pill highlight. */ isShowing: Ref } @@ -102,6 +105,21 @@ export function useCentralPillCoachmark( }) } + /** The popup was pulled out from under this hint by something that is not a retirement — + * main auto-hides it when the host window moves or resizes. Clears the display state + * WITHOUT persisting `seen`, and releases the once-per-renderer show latch so the hint can + * be raised again: the user may never have read it. + * + * Leaving this unhandled strands more than the hint. `isShowing` is the beta notice's + * suppression gate, so a hint stuck "showing" with no popup on screen silences the beta + * card for the rest of the renderer's life, and cannot itself be dismissed — there is no + * card left to click. */ + function forgetWithoutAcknowledging(): void { + if (hasCoachmarkRetired) return + isShowing.value = false + hasCoachmarkShown = false + } + async function retire(): Promise { const owned = opts.ownsPopup?.() ?? true isShowing.value = false @@ -120,6 +138,7 @@ export function useCentralPillCoachmark( maybeShow, dismiss: retire, acknowledgeViaPillOpen: retire, + forgetWithoutAcknowledging, isShowing } } From 8a2508a300039c5a03edfabbc7fcc3e4ef0ad68c Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Mon, 21 Sep 2026 06:03:43 -0700 Subject: [PATCH 15/27] fix(core-beta): hand a released claim to the install that lost the race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claims are global, so when two installs launch with the same fresh arg the second computes an empty `fresh` and stores nothing at all. If the first launch then fails, clearing its claim freed the arg but reconsidered nobody: the install that actually started successfully stayed silent until its own next relaunch. Arming now records what each install put on its command line, won the claim or not, and releasing a claim re-runs selection for any install with nothing queued. Installs that already hold a card keep it; announced args stay spent, which the announced list enforces on the handover path too. The reviewer's evidence was again that the test had to call `armBetaActivationNotice` a second time where production has no such re-arm — the third time this branch a test of mine reached for a seam that does not exist. That test now drives the release alone and fails without the handover. Second test guards the opposite error: a handover that ignored the announced list would re-offer a card the user already dismissed. Co-Authored-By: Claude Opus 5 --- src/main/lib/betaActivationNotice.test.ts | 23 +++++++++++++++++++-- src/main/lib/betaActivationNotice.ts | 25 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/main/lib/betaActivationNotice.test.ts b/src/main/lib/betaActivationNotice.test.ts index b38c4ec59..3f467ca9e 100644 --- a/src/main/lib/betaActivationNotice.test.ts +++ b/src/main/lib/betaActivationNotice.test.ts @@ -235,15 +235,34 @@ describe('arm / peek / acknowledge', () => { expect(peekBetaActivationNotice('inst-1')).toEqual([]) }) - it('frees the arg for another install straight away, not at the next relaunch', () => { + it('hands the arg to the install that lost the race, with no relaunch of its own', () => { armBetaActivationNotice('inst-1', ['--enable-assets']) - // inst-2 launches successfully while inst-1's dead claim still holds the arg. + // inst-2 launches successfully alongside it and loses the claim, so it queues nothing. armBetaActivationNotice('inst-2', ['--enable-assets']) expect(peekBetaActivationNotice('inst-2')).toEqual([]) + // inst-1's launch then fails. inst-2 is still running and must be reconsidered HERE: + // production has no second arm to lean on, only this release. clearBetaActivationClaim('inst-1') + expect(peekBetaActivationNotice('inst-2')).toEqual(['--enable-assets']) + expect(peekBetaActivationNotice('inst-1')).toEqual([]) + }) + + it('does not hand a released arg to an install that already had its card', () => { + // The handover must not resurrect a spent notice. inst-2 loses the race, is later + // reconsidered, shows and acknowledges; a second release must not re-offer it. + armBetaActivationNotice('inst-1', ['--enable-assets']) armBetaActivationNotice('inst-2', ['--enable-assets']) + clearBetaActivationClaim('inst-1') expect(peekBetaActivationNotice('inst-2')).toEqual(['--enable-assets']) + + acknowledgeBetaActivationNotice('inst-2', ['--enable-assets']) + expect(peekBetaActivationNotice('inst-2')).toEqual([]) + + // Another install fails and releases; the announced arg stays spent for everyone. + armBetaActivationNotice('inst-3', ['--enable-assets']) + clearBetaActivationClaim('inst-3') + expect(peekBetaActivationNotice('inst-2')).toEqual([]) }) it('discards only the unannounced claim, never an arg already announced', () => { diff --git a/src/main/lib/betaActivationNotice.ts b/src/main/lib/betaActivationNotice.ts index 185d3e28b..5ab33a42a 100644 --- a/src/main/lib/betaActivationNotice.ts +++ b/src/main/lib/betaActivationNotice.ts @@ -39,6 +39,13 @@ const ENABLE_PREFIX = '--enable-' */ const pendingByInstallation = new Map() +/** What each install actually put on its command line this launch, whether or not it won the + * claim. Kept because losing a race is not the same as having nothing to say: when two + * installs launch with the same fresh arg, the loser stores no pending entry, so if the + * winner's launch then fails there is otherwise no record that anyone else wanted it. The + * running install would stay silent until its next relaunch. */ +const appliedByInstallation = new Map() + /** Every arg currently pending across all installs. Two windows launching with the same fresh * grant would otherwise each show a card for it, since neither has acknowledged yet and the * persisted list is still empty. First claim wins; the second install stays silent. */ @@ -105,7 +112,9 @@ export function armBetaActivationNotice( // no longer on. Dropping a claim also releases it for other installs, since `claimedArgs` // reads the same map. pendingByInstallation.delete(installationId) + appliedByInstallation.delete(installationId) if (appliedArgs.length === 0) return + appliedByInstallation.set(installationId, [...appliedArgs]) const spokenFor = new Set([...readAnnouncedBetaArgs(), ...claimedArgs()]) const fresh = selectNewlyActiveBetaArgs(appliedArgs, spokenFor) if (fresh.length === 0) return @@ -130,6 +139,21 @@ export function armBetaActivationNotice( export function clearBetaActivationClaim(installationId: string): void { try { pendingByInstallation.delete(installationId) + appliedByInstallation.delete(installationId) + // Hand the released args to whoever lost the race for them. Without this the claim is + // freed but nobody is reconsidered, so an install that launched successfully alongside + // the failed one stays silent until its own next relaunch. + // Read the announced list once: it cannot change inside this loop, and this runs on the + // launch-failure path where a settings read per install would be pure waste. `claimedArgs` + // does have to be recomputed, since each handover below adds to it. + const announced = readAnnouncedBetaArgs() + for (const [otherId, applied] of appliedByInstallation) { + // Only installs with nothing queued: one that already has a card keeps it, and one + // whose card was acknowledged is filtered by the announced list anyway. + if (pendingByInstallation.has(otherId)) continue + const fresh = selectNewlyActiveBetaArgs(applied, new Set([...announced, ...claimedArgs()])) + if (fresh.length > 0) pendingByInstallation.set(otherId, fresh) + } } catch (err) { console.log('[beta-notice] clear failed:', err) } @@ -184,4 +208,5 @@ export function acknowledgeBetaActivationNotice( /** @internal — exposed for tests. */ export function _resetForTest(): void { pendingByInstallation.clear() + appliedByInstallation.clear() } From 351e808dc28b933ce8a99cc798699e166de0d822 Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Mon, 21 Sep 2026 06:20:20 -0700 Subject: [PATCH 16/27] refactor(core-beta): drop cross-install claim suppression, keep the announced list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three P2s this round, and one of them was a defect my previous fix introduced: `appliedByInstallation` was never cleaned on process exit, so a handover could hand the card to an install that had already stopped — a present-tense "a beta feature is on" on a dead host, and a live contender blocked behind it. A third finding named the symmetric gap, where a successful re-arm drops a claim without reconsidering the loser either. Patching again would have meant contender liveness tracking on top of a contenders map on top of a global claim set. Removing the mechanism is the better trade, and it is the same call made earlier on this branch when a cache added for a nitpick produced two findings of its own. What the suppression bought was one thing: no duplicate card when two windows launch at the same instant. What it cost was permanently silencing an install whose feature really IS on — the exact failure this notice exists to prevent — because a claim it lost in a race is indistinguishable from a card it was shown. The persisted announced list is the real "once ever", and unlike any in-memory bookkeeping it survives a restart. So `claimedArgs`, `appliedByInstallation` and the handover loop are gone, and the module drops 212 -> 179 lines. Queues are per-install and independent; acknowledgement anywhere silences the arg everywhere, for good. BEHAVIOUR CHANGE, flagged for review: two instances that both have the feature on now each get a card, where before only the first did. This is the question Simon asked to have documented for 2+ installations, and the answer is now "once per install, and once ever after any acknowledgement". Also fixes the fixture's port race. The reservation cannot be held across the launch without handing the socket to the child, so the stub absorbs it: it retries the SAME port on EADDRINUSE — it cannot shift, the launcher was told the port explicitly — and a lost race costs ~250ms rather than a failed run. Verified locally: 5162 unit tests, and all 5 e2e specs against the new fixture. Co-Authored-By: Claude Opus 5 --- e2e/support/fakeComfyInstall.ts | 45 ++++++++++----- src/main/lib/betaActivationNotice.test.ts | 67 ++++++----------------- src/main/lib/betaActivationNotice.ts | 53 +++--------------- 3 files changed, 57 insertions(+), 108 deletions(-) diff --git a/e2e/support/fakeComfyInstall.ts b/e2e/support/fakeComfyInstall.ts index 548b86354..81f3237fa 100644 --- a/e2e/support/fakeComfyInstall.ts +++ b/e2e/support/fakeComfyInstall.ts @@ -89,25 +89,40 @@ const body = \`ComfyUI ( <p>ComfyUI stub — e2e fixture canvas</p> <p>launched with <code>\${assetsOn ? '--enable-assets' : 'no beta grant'}</code></p> </div></body></html>\` -http - .createServer((_req, res) => { - res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) - res.end(body) - }) - .listen(port, '127.0.0.1', () => { - // The launcher's boot-log tap reads stdout; printing the usual banner keeps the console - // pane readable while a run is being watched. - console.log('Starting server\\n') - console.log('To see the GUI go to: http://127.0.0.1:' + port) - }) +const server = http.createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end(body) +}) +// The port was reserved by binding 0 and closing, so between that close and this listen the +// OS may hand it to someone else, and a lingering TIME_WAIT can refuse it briefly. The port +// is fixed (the launcher was told it explicitly), so retry the SAME one rather than shifting +// to another the launcher would never poll. Zero tolerance for flaky tests means the transient +// case has to self-heal, not merely be documented. +let attemptsLeft = 40 +server.on('error', (err) => { + if (err.code !== 'EADDRINUSE' || attemptsLeft <= 0) throw err + attemptsLeft -= 1 + setTimeout(() => server.listen(port, '127.0.0.1'), 250) +}) +server.on('listening', () => { + // The launcher's boot-log tap reads stdout; printing the usual banner keeps the console + // pane readable while a run is being watched. + console.log('Starting server\\n') + console.log('To see the GUI go to: http://127.0.0.1:' + port) +}) +server.listen(port, '127.0.0.1') ` /** * A port nothing is listening on right now, found by binding 0 and reading back what the OS - * chose. There is an unavoidable gap between releasing it and the stub binding it, but that is - * far better than a constant: a fixed port collides with whatever else is on the machine, and - * the failure mode is either `EADDRINUSE` or — worse — the launcher mistaking an unrelated - * listener for the booted fixture. + * chose. Far better than a constant, which collides with whatever else is on the machine and + * fails as either `EADDRINUSE` or — worse — the launcher mistaking an unrelated listener for + * the booted fixture. + * + * A gap remains between releasing the probe socket and the stub binding the port, and it + * cannot be closed from here without handing the listening socket to the child. The stub + * absorbs it instead: it retries the same port on `EADDRINUSE` (see `stubServerSource`), so a + * lost race costs a few hundred milliseconds rather than a failed run. */ export async function reserveFreePort(): Promise<number> { const { createServer } = await import('node:net') diff --git a/src/main/lib/betaActivationNotice.test.ts b/src/main/lib/betaActivationNotice.test.ts index 3f467ca9e..5d8a8faf5 100644 --- a/src/main/lib/betaActivationNotice.test.ts +++ b/src/main/lib/betaActivationNotice.test.ts @@ -154,15 +154,6 @@ describe('arm / peek / acknowledge', () => { expect(peekBetaActivationNotice('inst-2')).toEqual(['--enable-agent']) }) - it('lets only the first of two concurrent installs claim an arg', () => { - // Neither has acknowledged yet, so the persisted list is still empty. Without the - // in-flight claim both windows would raise a card for the same feature. - armBetaActivationNotice('inst-1', ['--enable-assets']) - armBetaActivationNotice('inst-2', ['--enable-assets']) - expect(peekBetaActivationNotice('inst-1')).toEqual(['--enable-assets']) - expect(peekBetaActivationNotice('inst-2')).toEqual([]) - }) - it('merges into what other installs already announced rather than replacing it', () => { store.set(BETA_NOTICE_ANNOUNCED_ARGS_KEY, ['--enable-assets']) armBetaActivationNotice('inst-2', ['--enable-agent']) @@ -205,24 +196,32 @@ describe('arm / peek / acknowledge', () => { expect(peekBetaActivationNotice('inst-1')).toEqual(['--enable-assets', '--enable-agent']) }) - it('clears a stale claim when the next launch applies no grants', () => { - // A beta launch that failed to boot leaves a claim behind. If the user then turns beta off - // and relaunches, the card must not still say a beta feature is on. + it('gives every install its own card, and lets the announced list do the silencing', () => { + // Queues are per-install. Two instances really do both have the feature on, and each has + // its own title bar, so each gets told. Suppressing the second permanently silenced an + // install whose user might never see the other window at all. armBetaActivationNotice('inst-1', ['--enable-assets']) - armBetaActivationNotice('inst-1', []) - expect(peekBetaActivationNotice('inst-1')).toEqual([]) + armBetaActivationNotice('inst-2', ['--enable-assets']) + expect(peekBetaActivationNotice('inst-1')).toEqual(['--enable-assets']) + expect(peekBetaActivationNotice('inst-2')).toEqual(['--enable-assets']) }) - it('releases a dropped claim back to other installs', () => { - // `claimedArgs` reads the same map, so a stale claim would otherwise silence the arg - // everywhere for the rest of the process. + it('stays silent everywhere once any install has acknowledged the arg', () => { + // This is what "once" means, and it is the only mechanism that survives a restart. armBetaActivationNotice('inst-1', ['--enable-assets']) + acknowledgeBetaActivationNotice('inst-1', ['--enable-assets']) + expect(readAnnouncedBetaArgs()).toEqual(['--enable-assets']) + armBetaActivationNotice('inst-2', ['--enable-assets']) expect(peekBetaActivationNotice('inst-2')).toEqual([]) + }) + it('clears a stale claim when the next launch applies no grants', () => { + // A beta launch that failed to boot leaves a claim behind. If the user then turns beta off + // and relaunches, the card must not still say a beta feature is on. + armBetaActivationNotice('inst-1', ['--enable-assets']) armBetaActivationNotice('inst-1', []) - armBetaActivationNotice('inst-2', ['--enable-assets']) - expect(peekBetaActivationNotice('inst-2')).toEqual(['--enable-assets']) + expect(peekBetaActivationNotice('inst-1')).toEqual([]) }) // Arming already repairs this on the install's NEXT launch. These cover the window in @@ -235,36 +234,6 @@ describe('arm / peek / acknowledge', () => { expect(peekBetaActivationNotice('inst-1')).toEqual([]) }) - it('hands the arg to the install that lost the race, with no relaunch of its own', () => { - armBetaActivationNotice('inst-1', ['--enable-assets']) - // inst-2 launches successfully alongside it and loses the claim, so it queues nothing. - armBetaActivationNotice('inst-2', ['--enable-assets']) - expect(peekBetaActivationNotice('inst-2')).toEqual([]) - - // inst-1's launch then fails. inst-2 is still running and must be reconsidered HERE: - // production has no second arm to lean on, only this release. - clearBetaActivationClaim('inst-1') - expect(peekBetaActivationNotice('inst-2')).toEqual(['--enable-assets']) - expect(peekBetaActivationNotice('inst-1')).toEqual([]) - }) - - it('does not hand a released arg to an install that already had its card', () => { - // The handover must not resurrect a spent notice. inst-2 loses the race, is later - // reconsidered, shows and acknowledges; a second release must not re-offer it. - armBetaActivationNotice('inst-1', ['--enable-assets']) - armBetaActivationNotice('inst-2', ['--enable-assets']) - clearBetaActivationClaim('inst-1') - expect(peekBetaActivationNotice('inst-2')).toEqual(['--enable-assets']) - - acknowledgeBetaActivationNotice('inst-2', ['--enable-assets']) - expect(peekBetaActivationNotice('inst-2')).toEqual([]) - - // Another install fails and releases; the announced arg stays spent for everyone. - armBetaActivationNotice('inst-3', ['--enable-assets']) - clearBetaActivationClaim('inst-3') - expect(peekBetaActivationNotice('inst-2')).toEqual([]) - }) - it('discards only the unannounced claim, never an arg already announced', () => { armBetaActivationNotice('inst-1', ['--enable-assets']) acknowledgeBetaActivationNotice('inst-1', ['--enable-assets']) diff --git a/src/main/lib/betaActivationNotice.ts b/src/main/lib/betaActivationNotice.ts index 5ab33a42a..990e5eeac 100644 --- a/src/main/lib/betaActivationNotice.ts +++ b/src/main/lib/betaActivationNotice.ts @@ -39,24 +39,6 @@ const ENABLE_PREFIX = '--enable-' */ const pendingByInstallation = new Map<string, string[]>() -/** What each install actually put on its command line this launch, whether or not it won the - * claim. Kept because losing a race is not the same as having nothing to say: when two - * installs launch with the same fresh arg, the loser stores no pending entry, so if the - * winner's launch then fails there is otherwise no record that anyone else wanted it. The - * running install would stay silent until its next relaunch. */ -const appliedByInstallation = new Map<string, string[]>() - -/** Every arg currently pending across all installs. Two windows launching with the same fresh - * grant would otherwise each show a card for it, since neither has acknowledged yet and the - * persisted list is still empty. First claim wins; the second install stays silent. */ -function claimedArgs(): Set<string> { - const claimed = new Set<string>() - for (const args of pendingByInstallation.values()) { - for (const arg of args) claimed.add(arg) - } - return claimed -} - /** The persisted list, defensive about content: `settings.json` is user-writable, so a * hand-edited non-array or a non-string entry has to read as "nothing announced yet" rather * than throwing on the launch path. @@ -109,13 +91,13 @@ export function armBetaActivationNotice( // claim can be left behind by a launch that then failed to boot. The next launch of this // install is the authority on what is actually on its command line: relaunching with beta // turned off must clear the old claim, not inherit it and then announce a feature that is - // no longer on. Dropping a claim also releases it for other installs, since `claimedArgs` - // reads the same map. + // no longer on. pendingByInstallation.delete(installationId) - appliedByInstallation.delete(installationId) if (appliedArgs.length === 0) return - appliedByInstallation.set(installationId, [...appliedArgs]) - const spokenFor = new Set([...readAnnouncedBetaArgs(), ...claimedArgs()]) + // Queues are per-install and independent. The persisted announced list is the only thing + // that silences an arg, and it is what "once" actually means: it survives restarts, which + // no in-memory cross-install bookkeeping can. See the note on `readAnnouncedBetaArgs`. + const spokenFor = new Set(readAnnouncedBetaArgs()) const fresh = selectNewlyActiveBetaArgs(appliedArgs, spokenFor) if (fresh.length === 0) return pendingByInstallation.set(installationId, fresh) @@ -128,32 +110,16 @@ export function armBetaActivationNotice( * * Arming happens just before the spawn, so a launch that then fails leaves a claim for a * Core that never started: the title bar would announce "a beta feature is on" once the - * progress takeover closes, and because `claimedArgs` spans every install, the dead claim - * would also silence the same arg for a DIFFERENT install that launches successfully. - * `armBetaActivationNotice` already clears the entry on the NEXT launch of this install, - * which repairs the state eventually. This closes the window in between, where the claim is - * live and wrong: the failed launch's own progress takeover ends long before any relaunch. + * progress takeover closes. `armBetaActivationNotice` already clears the entry on the NEXT + * launch of this install, which repairs the state eventually. This closes the window in + * between, where the claim is live and wrong: the failed launch's own progress takeover ends + * long before any relaunch. * * Nothing is persisted here, so this only discards an unannounced claim; an arg already * written to the announced list stays announced. */ export function clearBetaActivationClaim(installationId: string): void { try { pendingByInstallation.delete(installationId) - appliedByInstallation.delete(installationId) - // Hand the released args to whoever lost the race for them. Without this the claim is - // freed but nobody is reconsidered, so an install that launched successfully alongside - // the failed one stays silent until its own next relaunch. - // Read the announced list once: it cannot change inside this loop, and this runs on the - // launch-failure path where a settings read per install would be pure waste. `claimedArgs` - // does have to be recomputed, since each handover below adds to it. - const announced = readAnnouncedBetaArgs() - for (const [otherId, applied] of appliedByInstallation) { - // Only installs with nothing queued: one that already has a card keeps it, and one - // whose card was acknowledged is filtered by the announced list anyway. - if (pendingByInstallation.has(otherId)) continue - const fresh = selectNewlyActiveBetaArgs(applied, new Set([...announced, ...claimedArgs()])) - if (fresh.length > 0) pendingByInstallation.set(otherId, fresh) - } } catch (err) { console.log('[beta-notice] clear failed:', err) } @@ -208,5 +174,4 @@ export function acknowledgeBetaActivationNotice( /** @internal — exposed for tests. */ export function _resetForTest(): void { pendingByInstallation.clear() - appliedByInstallation.clear() } From 6997d44c48c26160bbf3408a060ff6af7b08d248 Mon Sep 17 00:00:00 2001 From: Simon Pinfold <synap5e@users.noreply.github.com> Date: Mon, 21 Sep 2026 06:34:10 -0700 Subject: [PATCH 17/27] fix(core-beta): clear the claim at the guarded-setup chokepoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `skipPortWait` path arms the claim at line 1218 and then spawns inside `guardLaunchSetup`. A throw there runs `_cleanupFailedLaunchSetup` and rethrows, so it never reaches the `!launchResult.ok` cleanup where the clear lived — the title bar could announce a beta feature for a Core that never started. Moved the clear into `_cleanupFailedLaunchSetup` itself rather than adding a third call site. Every guarded setup failure passes through it, so this covers the branch the reviewer found and any future one; it is a delete, so paths that fail before the claim is armed pay nothing. The two existing call sites stay: they handle terminal failures that return rather than throw, which the guard never sees. Test fails without it (`expected [ '--enable-assets' ] to deeply equal []`). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- src/main/lib/ipc/sessionActions/launch.test.ts | 15 +++++++++++++++ src/main/lib/ipc/sessionActions/launch.ts | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/src/main/lib/ipc/sessionActions/launch.test.ts b/src/main/lib/ipc/sessionActions/launch.test.ts index e99f92d55..4080b57a9 100644 --- a/src/main/lib/ipc/sessionActions/launch.test.ts +++ b/src/main/lib/ipc/sessionActions/launch.test.ts @@ -145,6 +145,7 @@ import { BETA_NOTICE_ANNOUNCED_ARGS_KEY, _resetForTest as _resetBetaNotice, acknowledgeBetaActivationNotice, + armBetaActivationNotice, peekBetaActivationNotice } from '../../betaActivationNotice' import * as settingsModule from '../../../settings' @@ -304,6 +305,20 @@ describe('_cleanupFailedLaunchSetup', () => { expect(abort.signal.aborted).toBe(true) }) + // Arming happens just before the spawn, and on the `skipPortWait` path a spawn failure + // rethrows out of `guardLaunchSetup` rather than reaching the `!launchResult.ok` cleanup. + // This is the chokepoint every guarded setup failure passes through, so the claim is + // dropped here: otherwise the title bar announces a beta feature for a Core that never ran. + it('drops a beta claim armed by a launch that then failed to spawn', () => { + _resetBetaNotice() + armBetaActivationNotice(INSTALL, ['--enable-assets']) + expect(peekBetaActivationNotice(INSTALL)).toEqual(['--enable-assets']) + + _cleanupFailedLaunchSetup(INSTALL, new AbortController()) + + expect(peekBetaActivationNotice(INSTALL)).toEqual([]) + }) + it('ends the log stream when one was opened', () => { const end = vi.fn() _cleanupFailedLaunchSetup(INSTALL, new AbortController(), { logStream: { end } }) diff --git a/src/main/lib/ipc/sessionActions/launch.ts b/src/main/lib/ipc/sessionActions/launch.ts index 2c71905d6..58fdc72ba 100644 --- a/src/main/lib/ipc/sessionActions/launch.ts +++ b/src/main/lib/ipc/sessionActions/launch.ts @@ -540,6 +540,11 @@ export function _cleanupFailedLaunchSetup( if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) abort.abort() _clearLaunchingFailed(installationId) + // Every guarded setup failure lands here, including the spawn itself on the `skipPortWait` + // path — and that one rethrows past the `!launchResult.ok` cleanup rather than through it. + // Clearing at this chokepoint covers all of them; it is a delete, so paths that fail before + // the claim is armed pay nothing. + clearBetaActivationClaim(installationId) } export async function handleLaunch(ctx: ActionContext): Promise<ActionResult> { From 7a98bf14292d412404da6f6d95bb44c4eeae9a92 Mon Sep 17 00:00:00 2001 From: Simon Pinfold <synap5e@users.noreply.github.com> Date: Mon, 21 Sep 2026 06:37:40 -0700 Subject: [PATCH 18/27] Merge #1551: clear the claim at the guarded-setup chokepoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clean merge of the production change; the test needed this branch's shape as usual — a `CoreBetaGrant` literal rather than an arg string, and a nullable `peek`. Caught by typecheck rather than the suite: the tests passed with a `maxCoreVersion: null` that the type does not allow (it is optional, not nullable), which is the sort of thing only the type checker sees. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- src/main/lib/ipc/sessionActions/launch.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/lib/ipc/sessionActions/launch.test.ts b/src/main/lib/ipc/sessionActions/launch.test.ts index dbcaf8fbb..771b8f35b 100644 --- a/src/main/lib/ipc/sessionActions/launch.test.ts +++ b/src/main/lib/ipc/sessionActions/launch.test.ts @@ -311,12 +311,12 @@ describe('_cleanupFailedLaunchSetup', () => { // dropped here: otherwise the title bar announces a beta feature for a Core that never ran. it('drops a beta claim armed by a launch that then failed to spawn', () => { _resetBetaNotice() - armBetaActivationNotice(INSTALL, ['--enable-assets']) - expect(peekBetaActivationNotice(INSTALL)).toEqual(['--enable-assets']) + armBetaActivationNotice(INSTALL, [{ arg: '--enable-assets', minCoreVersion: '0.3.80' }]) + expect(peekBetaActivationNotice(INSTALL)?.args).toEqual(['--enable-assets']) _cleanupFailedLaunchSetup(INSTALL, new AbortController()) - expect(peekBetaActivationNotice(INSTALL)).toEqual([]) + expect(peekBetaActivationNotice(INSTALL)).toBeNull() }) it('ends the log stream when one was opened', () => { From f6ca0f5255e7327df5371649a1a2ed218774c594 Mon Sep 17 00:00:00 2001 From: Simon Pinfold <synap5e@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:14:52 -0700 Subject: [PATCH 19/27] fix(core-beta): filter announced args at read, not only at arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Christian's review finding, and he is right: "acknowledged anywhere, silent everywhere" did not hold for cards ALREADY queued. Queues are per-install, so acknowledging persists the arg but clears only that install's queue. Arm A and B both with `--enable-assets`, then have A's user dismiss while B is still booting: B's copy is already in memory, and its title bar was served a card for something the user had just dismissed. The existing `stays silent everywhere` test arms B AFTER A acknowledges, so it only ever exercised the arm-time filter and could not see this ordering. That is the gap — the test was written from the implementation rather than from the contract. `peekBetaActivationNotice` now filters the announced list too. Filtered rather than dropped, per the suggestion: an install queued for two grants keeps the one still unseen when only the other has been announced. Two tests, both failing before the fix: the ordering itself, and the partial case (`expected [assets, agent] to deeply equal [agent]`). Taken rather than documented-as-intended, because the brief asked for this explicitly — "persist that it's been shown (so it doesn't nag)". A second card for an arg the user just dismissed is the nagging it named. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- src/main/lib/betaActivationNotice.test.ts | 23 +++++++++++++++++++++++ src/main/lib/betaActivationNotice.ts | 16 ++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/main/lib/betaActivationNotice.test.ts b/src/main/lib/betaActivationNotice.test.ts index 5d8a8faf5..7b5f8edca 100644 --- a/src/main/lib/betaActivationNotice.test.ts +++ b/src/main/lib/betaActivationNotice.test.ts @@ -206,6 +206,29 @@ describe('arm / peek / acknowledge', () => { expect(peekBetaActivationNotice('inst-2')).toEqual(['--enable-assets']) }) + // The ordering the arm-time filter misses: BOTH queues are populated first, and only then + // does one install acknowledge. It clears its own queue and persists the arg, so the other + // install's copy is already sitting in memory when its title bar asks. + it('does not serve a queued card for an arg another install acknowledged first', () => { + armBetaActivationNotice('inst-1', ['--enable-assets']) + armBetaActivationNotice('inst-2', ['--enable-assets']) + expect(peekBetaActivationNotice('inst-2')).toEqual(['--enable-assets']) + + // inst-2 is still booting; inst-1's user dismisses theirs. + acknowledgeBetaActivationNotice('inst-1', ['--enable-assets']) + + expect(peekBetaActivationNotice('inst-2')).toEqual([]) + }) + + it('keeps an unseen grant queued when only the other one was acknowledged', () => { + // Filtered, not dropped: inst-2 still has something worth saying. + armBetaActivationNotice('inst-1', ['--enable-assets']) + armBetaActivationNotice('inst-2', ['--enable-assets', '--enable-agent']) + acknowledgeBetaActivationNotice('inst-1', ['--enable-assets']) + + expect(peekBetaActivationNotice('inst-2')).toEqual(['--enable-agent']) + }) + it('stays silent everywhere once any install has acknowledged the arg', () => { // This is what "once" means, and it is the only mechanism that survives a restart. armBetaActivationNotice('inst-1', ['--enable-assets']) diff --git a/src/main/lib/betaActivationNotice.ts b/src/main/lib/betaActivationNotice.ts index 990e5eeac..fc7dee3b6 100644 --- a/src/main/lib/betaActivationNotice.ts +++ b/src/main/lib/betaActivationNotice.ts @@ -127,9 +127,21 @@ export function clearBetaActivationClaim(installationId: string): void { /** What this install's title bar should announce, or `[]`. Read-only: the pending entry * survives until `acknowledgeBetaActivationNotice`, so a card that is shown but never retired - * (window closed, app quit) comes back on the next launch. */ + * (window closed, app quit) comes back on the next launch. + * + * Announced args are filtered HERE, not only at arm time. Queues are per-install, so another + * install acknowledging an arg persists it but clears only its own queue — a copy already + * queued elsewhere would otherwise still be served, and that install would raise a card for + * something the user has just dismissed. "Acknowledged anywhere, silent everywhere" has to + * hold for cards already queued, not merely for launches that come afterwards. + * + * Filtered rather than dropped: an install queued for two grants keeps the one still unseen + * when only the other has been announced. */ export function peekBetaActivationNotice(installationId: string): string[] { - return [...(pendingByInstallation.get(installationId) ?? [])] + const queued = pendingByInstallation.get(installationId) ?? [] + if (queued.length === 0) return [] + const announced = new Set(readAnnouncedBetaArgs()) + return queued.filter((arg) => !announced.has(arg)) } /** From 68231a5a61ab535d73f72234ffa8e0266291a948 Mon Sep 17 00:00:00 2001 From: Simon Pinfold <synap5e@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:28:21 -0700 Subject: [PATCH 20/27] fix(core-beta): debounce the re-show in main, where every move is visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer-side debounce could not work, and the reason is the part worth recording: `EmbeddedPopupView.hide()` fires `onHide` only on the first open-to-hidden transition. The popup hides on the FIRST move of a drag, so every later move is a no-op hide with no callback — nothing to extend the timer with. It expired mid-gesture, reopened the card, and `showOnTop` took focus; the next move hid it again. A slow drag flashed the card and stole focus repeatedly. Main sees every `move`/`resize`, so the debounce belongs there. It now holds a per-popup settle timer and sends `coachmark-settled` once the window has been still for 250ms. The renderer does no timing of its own. Split into two signals rather than one, because they want opposite timing: - `coachmark-auto-hidden` stays immediate. It is a state correction, and a composable left latched is exactly what strands the card. - `coachmark-settled` is the cue to re-show, once, at the end of the gesture. Test drives three hides with no settle and asserts nothing is raised, then the settle and asserts exactly one card. It fails against a re-show driven by the auto-hide itself (`expected 2 to be 1`). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- src/main/popups/titleCoachmark.ts | 37 ++++++++- src/preload/comfyTitleBarPreload.ts | 9 +++ .../src/comfyTitleBar/TitleBarApp.test.ts | 78 +++++++------------ .../src/comfyTitleBar/TitleBarApp.vue | 42 +++++----- 4 files changed, 93 insertions(+), 73 deletions(-) diff --git a/src/main/popups/titleCoachmark.ts b/src/main/popups/titleCoachmark.ts index 1c548a5e2..62919a0c1 100644 --- a/src/main/popups/titleCoachmark.ts +++ b/src/main/popups/titleCoachmark.ts @@ -147,6 +147,11 @@ interface CoachmarkPopupEntry { /** Owner of the card currently configured on this popup, so a dismiss or action click * reaches the composable that raised it and not the other one. */ kind: CoachmarkKind + /** Debounce for "the host window has stopped moving". Lives here rather than in the + * renderer because only main sees every `move`: the popup hides on the FIRST one and + * `onHide` fires only on that open-to-hidden transition, so a renderer-side timer could + * never be extended by the rest of a drag. */ + settleTimer: ReturnType<typeof setTimeout> | null } /** The title-bar lookup, captured from `registerTitleCoachmarkIpc` so a popup built later @@ -158,6 +163,10 @@ let findTitleBarByParent: ((parent: BrowserWindow) => WebContents | null) | null * the owner to FORGET a card it has just acknowledged. */ let retireDrivingHide = false +/** How long the host window must be still before a card is put back. Long enough that a + * drag made of many `move` events resolves to ONE re-show at the end. */ +const COACHMARK_MOVE_SETTLE_MS = 250 + const coachmarkPopupsByParent = new Map<number, CoachmarkPopupEntry>() const coachmarkPopupsByWebContents = new Map<number, CoachmarkPopupEntry>() @@ -193,6 +202,8 @@ function ensureCoachmarkPopup(parent: BrowserWindow): CoachmarkPopupEntry { } }, onParentClosed: () => { + const entry = coachmarkPopupsByParent.get(parent.id) + if (entry?.settleTimer) clearTimeout(entry.settleTimer) coachmarkPopupsByParent.delete(parent.id) coachmarkPopupsByWebContents.delete(view.popupWebContentsId) }, @@ -207,10 +218,34 @@ function ensureCoachmarkPopup(parent: BrowserWindow): CoachmarkPopupEntry { pendingConfig: null, pendingAnchor: null, pendingConfigToken: null, - kind: 'pill-hint' + kind: 'pill-hint', + settleTimer: null } coachmarkPopupsByParent.set(view.parentWindowId, entry) coachmarkPopupsByWebContents.set(view.popupWebContentsId, entry) + + /** "The window has stopped moving." Separate from the auto-hide notice on purpose: the + * owner should FORGET its card immediately (that is a state correction), but only RE-SHOW + * once the drag is over. Debounced from every `move`/`resize`, which is why it has to live + * here — the popup is hidden after the first event, so no further `onHide` arrives to + * extend a renderer-side timer, and the card would reopen and steal focus mid-drag. */ + const scheduleSettled = (): void => { + const cur = coachmarkPopupsByParent.get(parent.id) + if (!cur) return + if (cur.settleTimer) clearTimeout(cur.settleTimer) + cur.settleTimer = setTimeout(() => { + cur.settleTimer = null + if (parent.isDestroyed()) return + const tb = findTitleBarByParent?.(parent) + if (tb && !tb.isDestroyed()) { + tb.send('comfy-titlebar:coachmark-settled', { kind: cur.kind }) + } + }, COACHMARK_MOVE_SETTLE_MS) + } + for (const event of ['move', 'resize'] as const) { + ;(parent as unknown as { on: (e: string, cb: () => void) => void }).on(event, scheduleSettled) + } + return entry } diff --git a/src/preload/comfyTitleBarPreload.ts b/src/preload/comfyTitleBarPreload.ts index 3ed477bb4..ec059fae5 100644 --- a/src/preload/comfyTitleBarPreload.ts +++ b/src/preload/comfyTitleBarPreload.ts @@ -250,6 +250,9 @@ export interface ComfyTitleBarBridge { * window moved or resized, leaving the anchor stale. NOT an acknowledgement: the owner * should forget the card so it can be raised again, not mark it as seen. */ onCoachmarkAutoHidden(cb: (payload: { kind: CoachmarkKind }) => void): () => void + /** Subscribe to "the host window has stopped moving", debounced in main across every + * `move`/`resize`. The cue to put a forgotten card back, once and not mid-drag. */ + onCoachmarkSettled(cb: (payload: { kind: CoachmarkKind }) => void): () => void /** Tell main this title bar is mounted; main responds with the initial state. */ ready(): void } @@ -461,6 +464,12 @@ const bridge: ComfyTitleBarBridge = { ipcRenderer.on('comfy-titlebar:coachmark-auto-hidden', handler) return () => ipcRenderer.removeListener('comfy-titlebar:coachmark-auto-hidden', handler) }, + onCoachmarkSettled: (cb: (payload: { kind: CoachmarkKind }) => void) => { + const handler = (_e: unknown, payload?: { kind?: unknown }): void => + cb({ kind: coachmarkKindOf(payload) }) + ipcRenderer.on('comfy-titlebar:coachmark-settled', handler) + return () => ipcRenderer.removeListener('comfy-titlebar:coachmark-settled', handler) + }, ready: () => { ipcRenderer.send('comfy-window:title-bar-ready') } diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts index 7490e9d45..8b20c4a2e 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts @@ -65,6 +65,7 @@ interface MockBridgeState { coachmarkDismissedCallbacks: ((payload: { kind: string }) => void)[] coachmarkActionCallbacks: ((payload: { kind: string }) => void)[] coachmarkAutoHiddenCallbacks: ((payload: { kind: string }) => void)[] + coachmarkSettledCallbacks: ((payload: { kind: string }) => void)[] readyCalls: number } @@ -104,6 +105,7 @@ function installMockBridge( coachmarkDismissedCallbacks: [], coachmarkActionCallbacks: [], coachmarkAutoHiddenCallbacks: [], + coachmarkSettledCallbacks: [], readyCalls: 0 } const installationId = opts.installationId === undefined ? 'test-id' : opts.installationId @@ -238,6 +240,10 @@ function installMockBridge( state.coachmarkAutoHiddenCallbacks.push(cb) return () => {} }, + onCoachmarkSettled: (cb: (payload: { kind: string }) => void) => { + state.coachmarkSettledCallbacks.push(cb) + return () => {} + }, ready: () => { state.readyCalls += 1 } @@ -1431,51 +1437,24 @@ describe('TitleBarApp', () => { // The host window moving or resizing auto-hides the shared popup in main (the anchor the // beak points at has gone stale). Nothing retired the card, so the user may never have // read it — and if the composable stayed latched, no later card could be raised at all. - it('puts the card back by itself once the window settles', async () => { - vi.useFakeTimers() - try { - const wrapper = await mountBar() - expect(betaCards().length).toBe(1) - - // A drag fires `move` repeatedly; main hides the popup on each one. - bridgeState.coachmarkAutoHiddenCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) - bridgeState.coachmarkAutoHiddenCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) - bridgeState.coachmarkAutoHiddenCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) - await vi.advanceTimersByTimeAsync(100) - // Still mid-gesture: the debounce has not elapsed, so nothing has been re-raised. - expect(betaCards().length).toBe(1) - - await vi.advanceTimersByTimeAsync(300) - // Settled: exactly one re-show for the whole drag, not one per event. - expect(betaCards().length).toBe(2) - // Still never acknowledged - the user has not acted on it. - expect(acknowledgeBetaNotice).not.toHaveBeenCalled() - wrapper.unmount() - } finally { - vi.useRealTimers() - } - }) - - // A retirement hides the popup itself, so the auto-hide notice follows its own dismiss. - // That must NOT resurrect the card: `retire` records the key before hiding and - // `forgetWithoutAcknowledging` deliberately leaves `retiredKeys` alone. This is the - // specific hazard the auto-hide wiring introduces, so it is pinned. - it('does not resurrect a card whose own retirement triggered the auto-hide', async () => { + it('does not put the card back until the window has stopped moving', async () => { const wrapper = await mountBar() expect(betaCards().length).toBe(1) - bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) - await flushPromises() - expect(acknowledgeBetaNotice).toHaveBeenCalledWith('inst-1', ['--enable-assets']) - - // The hide that retirement performed comes back round as an auto-hide. + // A drag: many hides, no settle yet. Main owns the debounce, so nothing arrives here. + bridgeState.coachmarkAutoHiddenCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) + bridgeState.coachmarkAutoHiddenCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) bridgeState.coachmarkAutoHiddenCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) await flushPromises() - bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'pill-hint' })) + // Re-showing mid-drag would flash the card and steal focus on every move. + expect(betaCards().length).toBe(1) + + bridgeState.coachmarkSettledCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) await flushPromises() - // Same args still pending from main, but the card was acknowledged: it stays gone. - expect(betaCards().length).toBe(1) + // Exactly one re-show for the whole gesture. + expect(betaCards().length).toBe(2) + expect(acknowledgeBetaNotice).not.toHaveBeenCalled() wrapper.unmount() }) @@ -1536,20 +1515,15 @@ describe('TitleBarApp', () => { // The hint owns the popup, so the beta notice correctly defers. expect(betaCards().length).toBe(0) - // Fake timers only now: the mount path needs real ones to settle. - vi.useFakeTimers() - try { - bridgeState.coachmarkAutoHiddenCallbacks.forEach((cb) => cb({ kind: 'pill-hint' })) - await vi.advanceTimersByTimeAsync(400) - - // Raised again rather than stranded: the user never read it, so it is not spent. - expect(hintCards().length).toBe(2) - // And it was NOT persisted as seen, which only a real dismissal may do. - expect(setSetting).not.toHaveBeenCalledWith('hasSeenCentralPillHint', true) - wrapper.unmount() - } finally { - vi.useRealTimers() - } + bridgeState.coachmarkAutoHiddenCallbacks.forEach((cb) => cb({ kind: 'pill-hint' })) + bridgeState.coachmarkSettledCallbacks.forEach((cb) => cb({ kind: 'pill-hint' })) + await flushPromises() + + // Raised again rather than stranded: the user never read it, so it is not spent. + expect(hintCards().length).toBe(2) + // And it was NOT persisted as seen, which only a real dismissal may do. + expect(setSetting).not.toHaveBeenCalledWith('hasSeenCentralPillHint', true) + wrapper.unmount() }) it('retries once the onboarding hint releases the popup', async () => { diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.vue b/src/renderer/src/comfyTitleBar/TitleBarApp.vue index 15ca82c11..cc9f8ab98 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.vue +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.vue @@ -107,6 +107,8 @@ interface Bridge { onCoachmarkAction: (cb: (payload: { kind: CoachmarkKind }) => void) => () => void /** The popup was hidden without a retirement (host window moved/resized). */ onCoachmarkAutoHidden: (cb: (payload: { kind: CoachmarkKind }) => void) => () => void + /** The host window has stopped moving; safe to put a forgotten card back. */ + onCoachmarkSettled: (cb: (payload: { kind: CoachmarkKind }) => void) => () => void onPanelChanged: (cb: (panel: ComfyPanelKey) => void) => () => void onTitleChanged: (cb: (title: string) => void) => () => void /** Install source-category pushes from main. The raw category @@ -573,21 +575,20 @@ function handleInstallPillWithCoachmark(): void { * again, so re-showing per event would thrash the card on and off for the whole gesture. * One re-show once the window settles. `maybeShow` re-reads the anchor rect, which is the * point — the old rect is exactly what went stale. */ -const COACHMARK_RESHOW_DEBOUNCE_MS = 250 -let coachmarkReshowTimer: ReturnType<typeof setTimeout> | null = null -function scheduleCoachmarkReshow(): void { +/** Put a forgotten card back. Fired from main's settled signal rather than a timer here: + * the popup hides on the FIRST move and `onHide` only reports that one transition, so a + * local timer could not be extended by the rest of a drag and would reopen the card + * mid-gesture — flashing it and stealing focus on every subsequent move. Main sees every + * event, so it owns the debounce. + * + * Both cards, in the gate watcher's order and for its reason: the hint wins a collision, + * and awaiting it keeps the beta notice's suppression check from reading a stale `false`. + * Each is gated and idempotent, so the one that was not hidden simply declines. */ +function reshowCoachmarksAfterMove(): void { if (unmounted) return - if (coachmarkReshowTimer !== null) clearTimeout(coachmarkReshowTimer) - coachmarkReshowTimer = setTimeout(() => { - coachmarkReshowTimer = null - if (unmounted) return - // Both cards, in the gate watcher's order and for its reason: the hint wins a collision, - // and awaiting it keeps the beta notice's suppression check from reading a stale `false`. - // Each is gated and idempotent, so the one that was not hidden simply declines. - void coachmark.maybeShow().then(() => { - if (!unmounted) void betaNotice.maybeShow() - }) - }, COACHMARK_RESHOW_DEBOUNCE_MS) + void coachmark.maybeShow().then(() => { + if (!unmounted) void betaNotice.maybeShow() + }) } /** The beta notice defers while the hint owns the popup, and nothing in the gate watcher @@ -626,6 +627,7 @@ let unsubZoom: (() => void) | undefined let unsubCoachmarkDismissed: (() => void) | undefined let unsubCoachmarkAction: (() => void) | undefined let unsubCoachmarkAutoHidden: (() => void) | undefined +let unsubCoachmarkSettled: (() => void) | undefined onMounted(() => { // Observe the trailing cluster so the left cluster can mirror its @@ -711,8 +713,11 @@ onMounted(() => { // dismissed either, since there is no longer a card to click. if (kind === 'beta-notice') betaNotice.forgetWithoutAcknowledging() else coachmark.forgetWithoutAcknowledging() - // Unlatching is only half of it: put the card back once the window settles. - scheduleCoachmarkReshow() + // Forgetting is immediate — it is a state correction, and leaving the composable latched + // is what strands the card. Re-showing waits for `onCoachmarkSettled` below. + }) + unsubCoachmarkSettled = bridge.onCoachmarkSettled(() => { + reshowCoachmarksAfterMove() }) bridge.ready() }) @@ -778,10 +783,7 @@ onUnmounted(() => { unsubCoachmarkDismissed?.() unsubCoachmarkAction?.() unsubCoachmarkAutoHidden?.() - if (coachmarkReshowTimer !== null) { - clearTimeout(coachmarkReshowTimer) - coachmarkReshowTimer = null - } + unsubCoachmarkSettled?.() bridge?.hideCoachmark() hideTip() trailingObserver?.disconnect() From bcac4cdf44885d674fe05e992125a5a65b8d74ec Mon Sep 17 00:00:00 2001 From: Simon Pinfold <synap5e@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:46:39 -0700 Subject: [PATCH 21/27] fix(core-beta): tell a card when the other one takes the popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows QA found the notice never drawing on a real first-run install. Reproduced on Linux by removing ONE line from my own e2e — the `hasSeenCentralPillHint: true` seed — so this was never a platform bug. Root cause, from instrumenting maybeShow's early returns: ["PASSED","bail:shownForInstall","bail:shownForInstall"] The beta card DOES show. Then the onboarding hint configures the same popup — one popup serves both cards — and `entry.kind = kind` reassigned the owner with nobody telling the old one. The composable went on believing its card was up and refused every later show; dismissing the hint retired the HINT, so the notice stayed armed in main and invisible for the rest of the session. `onCoachmarkAutoHidden`, added earlier today for exactly this class of problem, does not cover it: it fires from `EmbeddedPopupView.onHide`, which only fires on a real open-to-hidden transition. Showing a new config OVER an existing one is not a hide. I covered displacement-by-hiding and missed displacement-by-replacement. Main now emits `coachmark-displaced` with the OUTGOING kind before reassigning, gated on `pendingConfigToken !== null` so it cannot fire on the first claim — only on a genuine hand-over. The renderer routes it to the displaced owner's `forgetWithoutAcknowledging()`: forget, never acknowledge, so main keeps the notice and it replays rather than being silently spent. THE SPEC MATTERS MORE THAN THE FIX. `beta-activation-notice-firstrun.test.ts` drives the real collision — hint takes the popup, notice confirmed armed underneath, dismiss, assert the card draws. Both existing specs seed the hint as already seen, which is precisely why a green suite shipped a build where the card never appeared. Verified both ways: passes with the fix, fails without it with "beta notice never drew after the hint was dismissed". Unit test pins the kind-routing, also verified red without the handler. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- e2e/beta-activation-notice-firstrun.test.ts | 182 ++++++++++++++++++ src/main/popups/titleCoachmark.ts | 18 ++ src/preload/comfyTitleBarPreload.ts | 9 + .../src/comfyTitleBar/TitleBarApp.test.ts | 29 +++ .../src/comfyTitleBar/TitleBarApp.vue | 12 ++ 5 files changed, 250 insertions(+) create mode 100644 e2e/beta-activation-notice-firstrun.test.ts diff --git a/e2e/beta-activation-notice-firstrun.test.ts b/e2e/beta-activation-notice-firstrun.test.ts new file mode 100644 index 000000000..4ee31462b --- /dev/null +++ b/e2e/beta-activation-notice-firstrun.test.ts @@ -0,0 +1,182 @@ +/** + * E2E: the Core beta activation notice, end to end through a real launch. + * + * Every gate the notice depends on is exercised for real here rather than stubbed: + * + * - the grant arrives the way a returning user's does, from `ops-flags.json` (PostHog is + * unreachable under the harness, so `coreBetaGrants` falls back to the persisted value); + * - it clears the version window against the seeded `comfyVersion`; + * - it clears the running core's args schema, parsed from a real `main.py --help` spawn; + * - the launch spawns, the stub serves, the boot wait succeeds and the window attaches; + * - only then does the title bar drain the pending notice and raise the card. + * + * The card lives in its own WebContentsView, so assertions go through the popup's webContents + * + * Tagged `@linux` only — the fixture cannot run on Windows (no PE interpreter stub) or macOS + * (nothing isolates `userData`, so the ops-flag seed would hit the real profile). Both reasons + * are spelled out in `fakeComfyInstall.ts`'s header. + * + * Run: `pnpm exec playwright test --project=linux e2e/beta-activation-notice-firstrun.test.ts` + */ +import os from 'node:os' +import path from 'node:path' +import { mkdtemp, rm } from 'node:fs/promises' +import { expect, test, type ElectronApplication } from '@playwright/test' +import { launchApp, type AppContext } from './launchApp' +import { clickInstallTile, expectChooserVisible } from './support/chooserHelpers' +import { WebContentsPage } from './support/cdpPages' +import { + opsFlagsGrantSeed, + reserveFreePort, + writeFakeComfyInstall, +} from './support/fakeComfyInstall' + +// A real launch (args-schema spawn, port wait, attach) does not fit the default 45s budget. +test.describe.configure({ mode: 'serial', timeout: 180_000 }) + +const INSTALL_ID = 'inst-beta-notice-firstrun' +const INSTALL_NAME = 'Beta Notice Fixture' +/** Chosen at run time rather than hard-coded: a constant collides with whatever else happens + * to be on the machine, and this repo does not tolerate flaky tests. Passed explicitly in + * `launchArgs` so the launcher's port-conflict auto-shift cannot move it afterwards. */ +let port = 0 +/** The grant under test. `--enable-assets` is on the real allowlist and the stub's `--help` + * advertises it, so it survives selection AND the schema filter. */ +const GRANT_ARG = '--enable-assets' +/** Comfortably below the seeded `baseTag`, so the version window opens. */ +const GRANT_MIN_CORE = '0.3.80' + + +let ctx: AppContext +let installPath: string + +/** The coachmark popup, addressed by the card it is currently rendering. The hover-tooltip + * popup shares the same HTML entry point, so a URL marker alone could resolve to either; + * matching on `.coachmark` picks the one showing a card. */ +function coachmarkPopup(app: ElectronApplication): WebContentsPage { + return new WebContentsPage(app, 'comfyTitleTooltip') +} + + +/** PostHog's own value for the flag wins over the persisted one, and a live fetch would make + * this run depend on a real project's flag state. Pointing the SDK at a closed port makes the + * fetch `unreachable`, which is the documented path where `ops-flags.json` is authoritative — + * the same path an offline launch takes for a user who already has the grant. */ +const UNREACHABLE_POSTHOG_HOST = 'http://127.0.0.1:1' +let previousPosthogHost: string | undefined + +test.beforeAll(async () => { + previousPosthogHost = process.env['POSTHOG_HOST'] + process.env['POSTHOG_HOST'] = UNREACHABLE_POSTHOG_HOST + + installPath = await mkdtemp(path.join(os.tmpdir(), 'comfyui-beta-notice-firstrun-')) + port = await reserveFreePort() + await writeFakeComfyInstall({ installPath, port }) + + ctx = await launchApp({ + settings: { + firstUseCompleted: true, + // Beta grants are gated on the opt-in, which is itself gated on consent. + telemetryEnabled: true, + betaFeaturesEnabled: true, + // Deliberately NOT spent: this spec exists to drive the genuine first-run collision, + // where the onboarding hint and the beta notice contend for the one shared popup. + }, + installations: [ + { + id: INSTALL_ID, + name: INSTALL_NAME, + sourceId: 'comfybuilder', + sourceLabel: 'ComfyBuilder', + installPath, + status: 'installed', + launchArgs: `--port ${port}`, + launchMode: 'window', + browserPartition: 'unique', + seen: true, + // What the version gate reads. `commitsAhead: 0` makes the tag exact and + // `baseTagVerified` makes it ancestry-established; without both, the grant is refused. + comfyVersion: { + commit: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + baseTag: 'v0.3.99', + commitsAhead: 0, + baseTagVerified: true, + }, + }, + ], + // Delivered through `E2E_OPS_FLAGS_SEED` so main writes it to the real `configDir()` + // before its first read — the harness cannot place that file on macOS, where `userData` + // ignores the HOME override. Present from the app's first boot, which is how a returning + // user's already-granted flag actually arrives. + opsFlags: opsFlagsGrantSeed({ arg: GRANT_ARG, minCoreVersion: GRANT_MIN_CORE }), + }) + await expectChooserVisible(ctx.panel) +}) + +test.afterAll(async () => { + await ctx?.cleanup() + if (installPath) await rm(installPath, { recursive: true, force: true }) + if (previousPosthogHost === undefined) delete process.env['POSTHOG_HOST'] + else process.env['POSTHOG_HOST'] = previousPosthogHost +}) + +/** + * The regression this spec exists for. + * + * One popup serves both cards. On a real first run the beta notice can show FIRST and then be + * displaced when the onboarding hint configures the same popup — which is not a hide, so the + * composable was never told and went on believing its card was up, refusing every later show. + * The notice stayed armed in main and invisible to the user for the rest of the session. + * + * Both other specs seed `hasSeenCentralPillHint: true`, so neither could ever see this. That + * seeding is why a green suite shipped a build where the card never appeared. + */ +test('first run: the hint takes the popup, and dismissing it releases the beta card @linux', async () => { + await clickInstallTile(ctx.panel, INSTALL_NAME) + await ctx.panel.waitFor( + async () => + (await ctx.app.evaluate( + ({ webContents }, p) => + webContents.getAllWebContents().some((wc) => wc.getURL().includes(String(p))), + port, + )) === true, + { timeout: 90_000, message: 'ComfyUI stub never came up / the host never attached' }, + ) + + const popup = coachmarkPopup(ctx.app) + await popup.waitFor( + async () => { + try { + return await popup.exists('.coachmark') + } catch { + return false + } + }, + { timeout: 30_000, message: 'no coachmark appeared at all on a first run' }, + ) + + // The hint wins the collision — that part is by design and is not what this pins. + expect(await popup.textOf('.coachmark-title')).toBe('Switch & manage instances') + + // The notice is queued in main the whole time, waiting for the popup to free up. + expect(await ctx.titleBar.evaluate(`window.api.getPendingBetaNotice(${JSON.stringify(INSTALL_ID)})`)) + .not.toBeNull() + + expect(await popup.click('.coachmark-dismiss')).toBe(true) + + // THE ASSERTION: the deferred card must actually arrive once the hint lets go of the popup. + await popup.waitFor( + async () => { + try { + return (await popup.textOf('.coachmark-title')) === 'A beta feature is on' + } catch { + return false + } + }, + { timeout: 30_000, message: 'beta notice never drew after the hint was dismissed' }, + ) + + // Generic copy, and the way out is present — same contract the seeded spec asserts. + expect(await popup.textOf('.coachmark-text')).not.toContain(GRANT_ARG) + expect(await popup.textOf('.coachmark-action')).toBe('Settings') +}) diff --git a/src/main/popups/titleCoachmark.ts b/src/main/popups/titleCoachmark.ts index 62919a0c1..3bdec61ce 100644 --- a/src/main/popups/titleCoachmark.ts +++ b/src/main/popups/titleCoachmark.ts @@ -297,6 +297,24 @@ export function openCoachmarkPopup(opts: { actionLabel: opts.actionLabel, token }) + // One popup serves both cards, so configuring it for a NEW owner silently takes the screen + // away from the old one. That is NOT a hide, so `onHide` — and therefore the auto-hidden + // channel — never fires, and the displaced composable goes on believing its card is up and + // refuses every later show. This is the displacement that left a beta notice armed but + // invisible for the rest of a session once the onboarding hint landed on top of it. + // + // `pendingConfigToken` is null only before the FIRST configure, so this cannot fire on the + // initial claim — only on a genuine hand-over between owners. + if (entry.pendingConfigToken !== null && entry.kind !== kind) { + const previous = entry.kind + const parent = entry.view.parentWindow + if (parent && !parent.isDestroyed()) { + const tb = findTitleBarByParent?.(parent) + if (tb && !tb.isDestroyed()) { + tb.send('comfy-titlebar:coachmark-displaced', { kind: previous }) + } + } + } entry.kind = kind entry.pendingConfigToken = token if (entry.view.rendererReady) { diff --git a/src/preload/comfyTitleBarPreload.ts b/src/preload/comfyTitleBarPreload.ts index ec059fae5..a71122486 100644 --- a/src/preload/comfyTitleBarPreload.ts +++ b/src/preload/comfyTitleBarPreload.ts @@ -253,6 +253,9 @@ export interface ComfyTitleBarBridge { /** Subscribe to "the host window has stopped moving", debounced in main across every * `move`/`resize`. The cue to put a forgotten card back, once and not mid-drag. */ onCoachmarkSettled(cb: (payload: { kind: CoachmarkKind }) => void): () => void + /** Subscribe to the popup being reconfigured for the OTHER card. `kind` is the owner that + * was displaced. Not a hide, so the auto-hidden channel does not cover it. */ + onCoachmarkDisplaced(cb: (payload: { kind: CoachmarkKind }) => void): () => void /** Tell main this title bar is mounted; main responds with the initial state. */ ready(): void } @@ -470,6 +473,12 @@ const bridge: ComfyTitleBarBridge = { ipcRenderer.on('comfy-titlebar:coachmark-settled', handler) return () => ipcRenderer.removeListener('comfy-titlebar:coachmark-settled', handler) }, + onCoachmarkDisplaced: (cb: (payload: { kind: CoachmarkKind }) => void) => { + const handler = (_e: unknown, payload?: { kind?: unknown }): void => + cb({ kind: coachmarkKindOf(payload) }) + ipcRenderer.on('comfy-titlebar:coachmark-displaced', handler) + return () => ipcRenderer.removeListener('comfy-titlebar:coachmark-displaced', handler) + }, ready: () => { ipcRenderer.send('comfy-window:title-bar-ready') } diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts index 8b20c4a2e..dda8d7b01 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts @@ -66,6 +66,7 @@ interface MockBridgeState { coachmarkActionCallbacks: ((payload: { kind: string }) => void)[] coachmarkAutoHiddenCallbacks: ((payload: { kind: string }) => void)[] coachmarkSettledCallbacks: ((payload: { kind: string }) => void)[] + coachmarkDisplacedCallbacks: ((payload: { kind: string }) => void)[] readyCalls: number } @@ -106,6 +107,7 @@ function installMockBridge( coachmarkActionCallbacks: [], coachmarkAutoHiddenCallbacks: [], coachmarkSettledCallbacks: [], + coachmarkDisplacedCallbacks: [], readyCalls: 0 } const installationId = opts.installationId === undefined ? 'test-id' : opts.installationId @@ -244,6 +246,10 @@ function installMockBridge( state.coachmarkSettledCallbacks.push(cb) return () => {} }, + onCoachmarkDisplaced: (cb: (payload: { kind: string }) => void) => { + state.coachmarkDisplacedCallbacks.push(cb) + return () => {} + }, ready: () => { state.readyCalls += 1 } @@ -1458,6 +1464,29 @@ describe('TitleBarApp', () => { wrapper.unmount() }) + // The other card TAKING the popup is not a hide, so `onCoachmarkAutoHidden` never fires + // for it. Without a displacement signal the composable keeps believing its card is up and + // refuses every later show — which is how a first-run beta notice ended up armed but + // permanently invisible behind the onboarding hint. + it('forgets a card the other one displaced, so it can be raised again', async () => { + const wrapper = await mountBar() + expect(betaCards().length).toBe(1) + + // The hint takes the popup out from under the beta card. + bridgeState.coachmarkDisplacedCallbacks.forEach((cb) => cb({ kind: 'beta-notice' })) + await flushPromises() + + // Displacement is not acknowledgement: main must still hold it. + expect(acknowledgeBetaNotice).not.toHaveBeenCalled() + + // And the card can be raised again once the popup frees up. + getPendingBetaNotice.mockResolvedValue(['--enable-something-else']) + bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'pill-hint' })) + await flushPromises() + expect(betaCards().length).toBe(2) + wrapper.unmount() + }) + // One popup backs both cards, so the auto-hide is addressed by kind. The pill hint's own // auto-hide must not make the beta notice forget which card it has on screen — if it did, // the later dismissal would acknowledge nothing and the notice would replay forever. diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.vue b/src/renderer/src/comfyTitleBar/TitleBarApp.vue index cc9f8ab98..a17a7ffd6 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.vue +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.vue @@ -109,6 +109,8 @@ interface Bridge { onCoachmarkAutoHidden: (cb: (payload: { kind: CoachmarkKind }) => void) => () => void /** The host window has stopped moving; safe to put a forgotten card back. */ onCoachmarkSettled: (cb: (payload: { kind: CoachmarkKind }) => void) => () => void + /** The popup was reconfigured for the other card; `kind` is the owner that lost it. */ + onCoachmarkDisplaced: (cb: (payload: { kind: CoachmarkKind }) => void) => () => void onPanelChanged: (cb: (panel: ComfyPanelKey) => void) => () => void onTitleChanged: (cb: (title: string) => void) => () => void /** Install source-category pushes from main. The raw category @@ -628,6 +630,7 @@ let unsubCoachmarkDismissed: (() => void) | undefined let unsubCoachmarkAction: (() => void) | undefined let unsubCoachmarkAutoHidden: (() => void) | undefined let unsubCoachmarkSettled: (() => void) | undefined +let unsubCoachmarkDisplaced: (() => void) | undefined onMounted(() => { // Observe the trailing cluster so the left cluster can mirror its @@ -719,6 +722,14 @@ onMounted(() => { unsubCoachmarkSettled = bridge.onCoachmarkSettled(() => { reshowCoachmarksAfterMove() }) + // The other card took the popup. Not a hide, so `onCoachmarkAutoHidden` never fires for it — + // and a composable that still believes its card is up refuses every later show, which is how + // a beta notice ended up armed but permanently invisible behind the onboarding hint. + // Forget WITHOUT acknowledging: the user never acted on it, so main keeps it and it replays. + unsubCoachmarkDisplaced = bridge.onCoachmarkDisplaced(({ kind }) => { + if (kind === 'beta-notice') betaNotice.forgetWithoutAcknowledging() + else coachmark.forgetWithoutAcknowledging() + }) bridge.ready() }) @@ -784,6 +795,7 @@ onUnmounted(() => { unsubCoachmarkAction?.() unsubCoachmarkAutoHidden?.() unsubCoachmarkSettled?.() + unsubCoachmarkDisplaced?.() bridge?.hideCoachmark() hideTip() trailingObserver?.disconnect() From 87572b18b1c53daf56085dabe649580ae582a33f Mon Sep 17 00:00:00 2001 From: Simon Pinfold <synap5e@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:50:21 -0700 Subject: [PATCH 22/27] Merge #1551: tell a card when the other one takes the popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapted the merged displacement test to this branch's notice object, as usual. The new first-run e2e needs no change — it asserts the GENERIC card, which is what a payload with no description produces here too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- src/renderer/src/comfyTitleBar/TitleBarApp.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts index 804118673..530ead272 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts @@ -1493,7 +1493,11 @@ describe('TitleBarApp', () => { expect(acknowledgeBetaNotice).not.toHaveBeenCalled() // And the card can be raised again once the popup frees up. - getPendingBetaNotice.mockResolvedValue(['--enable-something-else']) + getPendingBetaNotice.mockResolvedValue({ + args: ['--enable-something-else'], + direction: 'enabled', + description: null + }) bridgeState.coachmarkDismissedCallbacks.forEach((cb) => cb({ kind: 'pill-hint' })) await flushPromises() expect(betaCards().length).toBe(2) From 7041e298dff0678123bdada74281ed277779dee7 Mon Sep 17 00:00:00 2001 From: Simon Pinfold <synap5e@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:29:42 -0700 Subject: [PATCH 23/27] test(core-beta): pin that first-use lockdown only defers the notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card's anchor is the news bell, and the bell is `v-if="!isFirstUseLockdown"`. Read from the template alone that looks terminal: no anchor, no card, deferred to a later launch — a whole first session running a beta the user was never told about and cannot opt out of, which would defeat a notice whose purpose is informed activation. It is not terminal, and nothing pinned that. The gate watcher already lists `isFirstUseLockdown` among its sources, so it re-attempts the notice when lockdown clears, in the same session. This spec makes that a contract instead of an implementation detail someone could drop while tidying the watcher. Verified both ways rather than asserted: - remove the watcher's re-attempt -> "beta notice never came back after first-use lockdown cleared" - never mount the bell -> "beta notice never drew after the launch that armed it" Both mutations were rebuilt before running. `playwright test` does not rebuild, so an earlier round of the same mutations "passed" against a stale `out/` and said the mechanism was not load-bearing when it is. Visibility is read from main via `getVisible()` on the attached view, not from the popup's DOM: `EmbeddedPopupView.hide()` calls `setVisible(false)`, which leaves the markup in place and leaves the renderer reporting `document.visibilityState === 'visible'`. Both of those read as "the card is up" and both are wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- e2e/beta-activation-notice-lockdown.test.ts | 221 ++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 e2e/beta-activation-notice-lockdown.test.ts diff --git a/e2e/beta-activation-notice-lockdown.test.ts b/e2e/beta-activation-notice-lockdown.test.ts new file mode 100644 index 000000000..89f2e10c0 --- /dev/null +++ b/e2e/beta-activation-notice-lockdown.test.ts @@ -0,0 +1,221 @@ +/** + * E2E: first-use lockdown must never cost the user their beta notice. + * + * The card's anchor is the news bell, and the bell is `v-if="!isFirstUseLockdown"`. Reading + * only that, it looks as though a brand-new user's notice is swallowed: no anchor, no card, + * deferred to some later launch — a whole first session running the beta unannounced, which + * would defeat a feature whose entire purpose is informed activation plus opt-out. + * + * It is not swallowed, and this spec pins the two independent reasons, because both are + * load-bearing and neither is obvious from the code that gates the bell: + * + * 1. The launch that ARMS the notice also ends the lockdown. Attaching an install rebuilds + * the panel, and main resets `firstUseMode` to `'none'` on that rebuild + * (`host/panelView.ts`). So the anchor is back before the card is ever raised. + * 2. If lockdown arrives LATER — the first-use chain re-asserts `'post-consent'` after + * attach — the gate watcher hides the card and re-raises it on the transition back, + * in the same session. Hiding retires nothing, so main still holds the pending notice + * and the card is not spent. + * + * Respecting the takeover stays deliberate: nothing is drawn while onboarding owns the screen. + * What is pinned is that the deferral is temporary rather than terminal. + * + * VISIBILITY IS ASKED OF MAIN, NOT THE DOM. `EmbeddedPopupView.hide()` calls + * `popup.setVisible(false)`; the popup keeps its markup and its renderer goes on reporting + * `document.visibilityState === 'visible'`. Both of those read as "the card is up" and both + * are wrong, so the assertions below read `getVisible()` off the attached view instead. + * + * Tagged `@linux` only, for the reasons in `fakeComfyInstall.ts`'s header. + * + * Run: `pnpm exec playwright test --project=linux e2e/beta-activation-notice-lockdown.test.ts` + */ +import os from 'node:os' +import path from 'node:path' +import { mkdtemp, rm } from 'node:fs/promises' +import { expect, test, type ElectronApplication } from '@playwright/test' +import { launchApp, type AppContext } from './launchApp' +import { clickInstallTile, expectChooserVisible } from './support/chooserHelpers' +import { WebContentsPage } from './support/cdpPages' +import { + opsFlagsGrantSeed, + reserveFreePort, + writeFakeComfyInstall +} from './support/fakeComfyInstall' + +// A real launch (args-schema spawn, port wait, attach) does not fit the default 45s budget. +test.describe.configure({ mode: 'serial', timeout: 180_000 }) + +const INSTALL_ID = 'inst-beta-notice-lockdown' +const INSTALL_NAME = 'Beta Notice Lockdown Fixture' +let port = 0 +const GRANT_ARG = '--enable-assets' +const GRANT_MIN_CORE = '0.3.80' + +let ctx: AppContext +let installPath: string + +function coachmarkPopup(app: ElectronApplication): WebContentsPage { + return new WebContentsPage(app, 'comfyTitleTooltip') +} + +/** Title of whatever card the shared popup is rendering, or null when it renders none. + * DOM-level, so it answers "which card" — never "can the user see it". */ +async function cardTitle(popup: WebContentsPage): Promise<string | null> { + try { + if (!(await popup.exists('.coachmark'))) return null + return await popup.textOf('.coachmark-title') + } catch { + return null + } +} + +/** Whether the coachmark popup is actually on screen, read from main. + * `'detached'` when the view does not exist yet — distinct from attached-but-hidden. */ +async function popupVisible(app: ElectronApplication): Promise<boolean | 'detached'> { + return app.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0] + if (!win) return 'detached' as const + const kids = (win.contentView?.children ?? []) as Array<{ + webContents?: { getURL?: () => string; isDestroyed?: () => boolean } + getVisible?: () => boolean + }> + const hit = kids.find( + (k) => + k.webContents && + !k.webContents.isDestroyed?.() && + (k.webContents.getURL?.() ?? '').includes('comfyTitleTooltip') + ) + if (!hit?.getVisible) return 'detached' as const + return hit.getVisible() + }) +} + +/** Positive evidence that first-use lockdown is engaged, rather than the absence of one + * element: the bell is also absent on hosts that never had it, so `!bell` alone would pass + * in a run where the mode never reached the title bar at all. */ +async function lockdownEngaged(titleBar: WebContentsPage): Promise<boolean> { + const bell = await titleBar.exists('.title-announcement-button') + const pill = await titleBar.exists('.title-install-pill.is-interactive') + return !bell && !pill +} + +const UNREACHABLE_POSTHOG_HOST = 'http://127.0.0.1:1' +let previousPosthogHost: string | undefined + +test.beforeAll(async () => { + previousPosthogHost = process.env['POSTHOG_HOST'] + process.env['POSTHOG_HOST'] = UNREACHABLE_POSTHOG_HOST + + installPath = await mkdtemp(path.join(os.tmpdir(), 'comfyui-beta-notice-lockdown-')) + port = await reserveFreePort() + await writeFakeComfyInstall({ installPath, port }) + + ctx = await launchApp({ + settings: { + firstUseCompleted: true, + telemetryEnabled: true, + betaFeaturesEnabled: true, + // Spent deliberately: the hint-versus-notice collision is + // `beta-activation-notice-firstrun.test.ts`'s job, and leaving it unspent here would + // put a second claimant on the shared popup and blur which gate released the card. + hasSeenCentralPillHint: true + }, + installations: [ + { + id: INSTALL_ID, + name: INSTALL_NAME, + sourceId: 'comfybuilder', + sourceLabel: 'ComfyBuilder', + installPath, + status: 'installed', + launchArgs: `--port ${port}`, + launchMode: 'window', + browserPartition: 'unique', + seen: true, + comfyVersion: { + commit: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + baseTag: 'v0.3.99', + commitsAhead: 0, + baseTagVerified: true + } + } + ], + opsFlags: opsFlagsGrantSeed({ arg: GRANT_ARG, minCoreVersion: GRANT_MIN_CORE }) + }) + await expectChooserVisible(ctx.panel) +}) + +test.afterAll(async () => { + await ctx?.cleanup() + if (installPath) await rm(installPath, { recursive: true, force: true }) + if (previousPosthogHost === undefined) delete process.env['POSTHOG_HOST'] + else process.env['POSTHOG_HOST'] = previousPosthogHost +}) + +test('first-use lockdown never costs the user the beta notice @linux', async () => { + const popup = coachmarkPopup(ctx.app) + + // Onboarding is in progress when the first launch happens — the new user's real sequence. + await ctx.panel.evaluate(`window.api.setFirstUseMode('post-consent')`) + await ctx.titleBar.waitFor(async () => lockdownEngaged(ctx.titleBar), { + timeout: 10_000, + message: 'first-use lockdown never reached the title bar' + }) + + await clickInstallTile(ctx.panel, INSTALL_NAME) + await ctx.panel.waitFor( + async () => + (await ctx.app.evaluate( + ({ webContents }, p) => + webContents.getAllWebContents().some((wc) => wc.getURL().includes(String(p))), + port + )) === true, + { timeout: 90_000, message: 'ComfyUI stub never came up / the host never attached' } + ) + + // Armed in main: the grant cleared every gate and the notice is waiting on the renderer. + expect( + await ctx.titleBar.evaluate(`window.api.getPendingBetaNotice(${JSON.stringify(INSTALL_ID)})`) + ).not.toBeNull() + + // REASON 1. The launch ended the lockdown by itself, so the anchor is back and the card is + // raised in this session rather than waiting for a later one. + await popup.waitFor(async () => (await cardTitle(popup)) === 'A beta feature is on', { + timeout: 30_000, + message: 'beta notice never drew after the launch that armed it' + }) + expect(await popupVisible(ctx.app)).toBe(true) + expect(await lockdownEngaged(ctx.titleBar)).toBe(false) + + // REASON 2. Lockdown arrives again while the card is up — the chain re-asserting + // `post-consent` after attach. The card must go away with the rest of the chrome. + await ctx.panel.evaluate(`window.api.setFirstUseMode('post-consent')`) + await ctx.titleBar.waitFor(async () => lockdownEngaged(ctx.titleBar), { + timeout: 10_000, + message: 'the re-asserted lockdown never reached the title bar' + }) + await expect + .poll(async () => popupVisible(ctx.app), { + timeout: 15_000, + message: 'the card stayed on screen through first-use lockdown' + }) + .toBe(false) + + // Hidden, never retired: main still holds it, so it is not spent. + expect( + await ctx.titleBar.evaluate(`window.api.getPendingBetaNotice(${JSON.stringify(INSTALL_ID)})`) + ).not.toBeNull() + + // THE ASSERTION. Lockdown ends, and the card comes back in the SAME session — no relaunch, + // no reload. Without the gate watcher re-attempting on the transition, it would wait for + // the user's next launch, which is the whole session of unannounced beta this pins against. + await ctx.panel.evaluate(`window.api.setFirstUseMode('none')`) + await expect + .poll(async () => popupVisible(ctx.app), { + timeout: 20_000, + message: 'beta notice never came back after first-use lockdown cleared' + }) + .toBe(true) + expect(await cardTitle(popup)).toBe('A beta feature is on') + expect(await popup.textOf('.coachmark-action')).toBe('Settings') +}) From 1eaa22b7fadb60c0d0106ab216e4e83cc722e106 Mon Sep 17 00:00:00 2001 From: Simon Pinfold <synap5e@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:39:26 -0700 Subject: [PATCH 24/27] chore(core-beta): name the feature "Asset library" in the notice fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simon's naming call. Every in-repo use of the old "Assets browser" was an example or a test fixture standing in for what the PostHog payload supplies at rollout — 27 occurrences across six files, plus the doc comment on `CoreBetaNotice.description` that shows the shape. Nothing about the mechanism changes: the flag supplies the NAME and the app owns and localizes the sentence around it. The alternative — letting the payload carry the whole notice text — was considered and rejected, because it takes the wording out of i18n and out of review. The production payload's own `description` is set at rollout and is not in this repo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- e2e/beta-activation-notice-named.test.ts | 2 +- src/main/lib/betaActivationNotice.test.ts | 34 +++++++++---------- src/main/lib/coreBetaGrants.test.ts | 4 +-- src/main/lib/coreBetaGrants.ts | 2 +- .../lib/ipc/sessionActions/launch.test.ts | 4 +-- .../src/comfyTitleBar/TitleBarApp.test.ts | 10 +++--- 6 files changed, 27 insertions(+), 29 deletions(-) diff --git a/e2e/beta-activation-notice-named.test.ts b/e2e/beta-activation-notice-named.test.ts index da3142680..a66ada10c 100644 --- a/e2e/beta-activation-notice-named.test.ts +++ b/e2e/beta-activation-notice-named.test.ts @@ -30,7 +30,7 @@ const GRANT_ARG = '--enable-assets' const GRANT_MIN_CORE = '0.3.80' /** What the payload calls the feature. Deliberately not derivable from the arg token, so a * card showing it proves the payload reached the copy rather than a table in Desktop. */ -const FEATURE_NAME = 'Assets browser' +const FEATURE_NAME = 'Asset library' let ctx: AppContext let installPath: string diff --git a/src/main/lib/betaActivationNotice.test.ts b/src/main/lib/betaActivationNotice.test.ts index f51b33991..d404ed790 100644 --- a/src/main/lib/betaActivationNotice.test.ts +++ b/src/main/lib/betaActivationNotice.test.ts @@ -79,11 +79,11 @@ describe('selectNewlyActiveBetaGrants', () => { it('announces a NAMED disable-grant, because the payload supplied what was missing', () => { const fresh = selectNewlyActiveBetaGrants( - [grant('--disable-assets', { description: 'Assets browser' })], + [grant('--disable-assets', { description: 'Asset library' })], new Set() ) expect(fresh).toEqual([ - { arg: '--disable-assets', direction: 'disabled', description: 'Assets browser' } + { arg: '--disable-assets', direction: 'disabled', description: 'Asset library' } ]) }) @@ -110,10 +110,10 @@ describe('selectNewlyActiveBetaGrants', () => { it('carries a payload-supplied feature name through to the card', () => { expect( selectNewlyActiveBetaGrants( - [grant('--enable-assets', { description: 'Assets browser' })], + [grant('--enable-assets', { description: 'Asset library' })], new Set() ) - ).toEqual([{ arg: '--enable-assets', direction: 'enabled', description: 'Assets browser' }]) + ).toEqual([{ arg: '--enable-assets', direction: 'enabled', description: 'Asset library' }]) }) it('withholds an arg already spoken for', () => { @@ -160,9 +160,9 @@ describe('resolveBetaActivationNotice', () => { it('names the feature when the card covers exactly one named grant', () => { expect( resolveBetaActivationNotice([ - { arg: '--enable-assets', direction: 'enabled', description: 'Assets browser' } + { arg: '--enable-assets', direction: 'enabled', description: 'Asset library' } ]) - ).toEqual({ args: ['--enable-assets'], direction: 'enabled', description: 'Assets browser' }) + ).toEqual({ args: ['--enable-assets'], direction: 'enabled', description: 'Asset library' }) }) it('covers only the grants matching the direction it reports', () => { @@ -170,7 +170,7 @@ describe('resolveBetaActivationNotice', () => { // takes the enables and leaves the withdrawal queued for its own card. const notice = resolveBetaActivationNotice([ { arg: '--enable-agent', direction: 'enabled', description: null }, - { arg: '--disable-assets', direction: 'disabled', description: 'Assets browser' } + { arg: '--disable-assets', direction: 'disabled', description: 'Asset library' } ]) expect(notice).toEqual({ args: ['--enable-agent'], direction: 'enabled', description: null }) }) @@ -180,7 +180,7 @@ describe('resolveBetaActivationNotice', () => { // rather than naming one of them and implying it is the whole story. expect( resolveBetaActivationNotice([ - { arg: '--enable-assets', direction: 'enabled', description: 'Assets browser' }, + { arg: '--enable-assets', direction: 'enabled', description: 'Asset library' }, { arg: '--enable-agent', direction: 'enabled', description: 'Agent' } ]) ).toEqual({ @@ -195,7 +195,7 @@ describe('resolveBetaActivationNotice', () => { // withdrew; the reverse claim would not be. expect( resolveBetaActivationNotice([ - { arg: '--disable-assets', direction: 'disabled', description: 'Assets browser' }, + { arg: '--disable-assets', direction: 'disabled', description: 'Asset library' }, { arg: '--enable-agent', direction: 'enabled', description: null } ])?.direction ).toBe('enabled') @@ -204,7 +204,7 @@ describe('resolveBetaActivationNotice', () => { it('reads as disabled only when every covered grant was a force-off', () => { expect( resolveBetaActivationNotice([ - { arg: '--disable-assets', direction: 'disabled', description: 'Assets browser' } + { arg: '--disable-assets', direction: 'disabled', description: 'Asset library' } ])?.direction ).toBe('disabled') }) @@ -246,11 +246,11 @@ describe('arm / peek / acknowledge', () => { }) it('carries the payload wording through to the pending card', () => { - armBetaActivationNotice('inst-1', [grant('--enable-assets', { description: 'Assets browser' })]) + armBetaActivationNotice('inst-1', [grant('--enable-assets', { description: 'Asset library' })]) expect(peekBetaActivationNotice('inst-1')).toEqual({ args: ['--enable-assets'], direction: 'enabled', - description: 'Assets browser' + description: 'Asset library' }) }) @@ -292,13 +292,11 @@ describe('arm / peek / acknowledge', () => { // arrived does not cover being told it was withdrawn. armBetaActivationNotice('inst-1', [grant('--enable-assets')]) acknowledgeBetaActivationNotice('inst-1') - armBetaActivationNotice('inst-1', [ - grant('--disable-assets', { description: 'Assets browser' }) - ]) + armBetaActivationNotice('inst-1', [grant('--disable-assets', { description: 'Asset library' })]) expect(peekBetaActivationNotice('inst-1')).toEqual({ args: ['--disable-assets'], direction: 'disabled', - description: 'Assets browser' + description: 'Asset library' }) }) @@ -322,7 +320,7 @@ describe('arm / peek / acknowledge', () => { // could never be announced again on any install. armBetaActivationNotice('inst-1', [ grant('--enable-agent'), - grant('--disable-assets', { description: 'Assets browser' }) + grant('--disable-assets', { description: 'Asset library' }) ]) expect(peekBetaActivationNotice('inst-1')?.args).toEqual(['--enable-agent']) @@ -332,7 +330,7 @@ describe('arm / peek / acknowledge', () => { expect(peekBetaActivationNotice('inst-1')).toEqual({ args: ['--disable-assets'], direction: 'disabled', - description: 'Assets browser' + description: 'Asset library' }) }) diff --git a/src/main/lib/coreBetaGrants.test.ts b/src/main/lib/coreBetaGrants.test.ts index 48f91e445..268e84274 100644 --- a/src/main/lib/coreBetaGrants.test.ts +++ b/src/main/lib/coreBetaGrants.test.ts @@ -205,7 +205,7 @@ describe('parseCoreBetaGrants notice wording', () => { expect( parseCoreBetaGrants(true, { flags: [ - { arg: '--enable-assets', min_core_version: '0.3.80', description: 'Assets browser' }, + { arg: '--enable-assets', min_core_version: '0.3.80', description: 'Asset library' }, { arg: '--enable-agent', min_core_version: '0.3.80', notice: 'silent' } ] }) @@ -213,7 +213,7 @@ describe('parseCoreBetaGrants notice wording', () => { { arg: '--enable-assets', minCoreVersion: '0.3.80', - notice: { description: 'Assets browser' } + notice: { description: 'Asset library' } }, { arg: '--enable-agent', minCoreVersion: '0.3.80', notice: { silent: true } } ]) diff --git a/src/main/lib/coreBetaGrants.ts b/src/main/lib/coreBetaGrants.ts index 0eaff6028..02540a8af 100644 --- a/src/main/lib/coreBetaGrants.ts +++ b/src/main/lib/coreBetaGrants.ts @@ -40,7 +40,7 @@ export type CoreBetaNotice = { * granted flag is user-visible: a diagnostic or an internal rollout has nothing to tell the * user, and a card for it is noise that trains people to dismiss the real ones. */ readonly silent?: true - /** Human name of the feature, e.g. `"Assets browser"`. Supplied by the payload rather than + /** Human name of the feature, e.g. `"Asset library"`. Supplied by the payload rather than * mapped in Desktop because the allowlist is installed ahead of the features it names — a * table here would have to ship before anyone knew what to call them. Absent means the * card falls back to its generic wording. */ diff --git a/src/main/lib/ipc/sessionActions/launch.test.ts b/src/main/lib/ipc/sessionActions/launch.test.ts index 771b8f35b..be3a4dd3b 100644 --- a/src/main/lib/ipc/sessionActions/launch.test.ts +++ b/src/main/lib/ipc/sessionActions/launch.test.ts @@ -1035,7 +1035,7 @@ describe('core beta report placement', () => { }) it('carries the payload feature name onto the pending card', async () => { - launchHarness.grants = [{ ...HARNESS_GRANT, notice: { description: 'Assets browser' } }] + launchHarness.grants = [{ ...HARNESS_GRANT, notice: { description: 'Asset library' } }] const id = 'harness-named-grant' await handleLaunch(ctxFor(id)) @@ -1043,7 +1043,7 @@ describe('core beta report placement', () => { expect(peekBetaActivationNotice(id)).toEqual({ args: ['--enable-assets'], direction: 'enabled', - description: 'Assets browser' + description: 'Asset library' }) }) diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts index 530ead272..15785b3cd 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.test.ts @@ -1675,12 +1675,12 @@ describe('TitleBarApp', () => { it('names the feature when the PostHog payload supplied a name', async () => { installApiMock({ - pending: { args: ['--enable-assets'], direction: 'enabled', description: 'Assets browser' } + pending: { args: ['--enable-assets'], direction: 'enabled', description: 'Asset library' } }) const wrapper = await mountBar() const payload = betaCards()[0]! - expect(payload.title).toBe('The Assets browser beta is on') - expect(payload.body).toContain('Assets browser') + expect(payload.title).toBe('The Asset library beta is on') + expect(payload.body).toContain('Asset library') wrapper.unmount() }) @@ -1691,12 +1691,12 @@ describe('TitleBarApp', () => { pending: { args: ['--disable-assets'], direction: 'disabled', - description: 'Assets browser' + description: 'Asset library' } }) const wrapper = await mountBar() const payload = betaCards()[0]! - expect(payload.title).toBe('The Assets browser beta is off') + expect(payload.title).toBe('The Asset library beta is off') expect(payload.title).not.toContain('is on') // Still points at Settings — the beta program switch is what the user can act on. expect(payload.actionLabel).toBe('Settings') From d0b79c47d02a97432dac443772d580b825ac4a89 Mon Sep 17 00:00:00 2001 From: Simon Pinfold <synap5e@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:02:56 -0700 Subject: [PATCH 25/27] docs(i18n): write down the {feature} placeholder contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four *Named notice templates interpolate a name that Desktop does not own: it comes from the flag payload, it is one string for every user regardless of locale, and it is English today. The templates already handle that correctly — `{feature}` modifies the constant head noun "beta", so a gendered or case-marking language agrees with that head and never with the slotted name. But that held by phrasing, not by contract. Nothing told a translator the slot was opaque, and the fragile form is the more natural one to reach for: "{feature} est activé" or "{feature} включён" both need a gender the name does not have and Desktop cannot supply. A future locale could introduce it silently and correctly-looking. Nothing exercises this today — en and zh are the only shipping locales and zh has neither gender nor case, so zh is the one language that cannot test the rule it appears to follow. Zero current exposure is the reason the fuller set-apart template ("A beta feature is on: {name}") and the localized-name mechanism are deliberately NOT built here; the trigger to revisit both is a third locale. Recorded in the three places someone would actually be standing when it matters: the payload type that produces the value, the `copyFor` that chooses the keys, and `locales/drafts/README.md`, which is what a translator reads before activating a locale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- locales/drafts/README.md | 36 +++++++++++++++++++ src/main/lib/coreBetaGrants.ts | 8 ++++- .../src/comfyTitleBar/TitleBarApp.vue | 10 ++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/locales/drafts/README.md b/locales/drafts/README.md index c3ce680dd..491e9e117 100644 --- a/locales/drafts/README.md +++ b/locales/drafts/README.md @@ -7,3 +7,39 @@ They are kept here as a starting point for future translation work. To activate a translation, bring its file up to date with `en.json` (ensure all keys are present and correctly translated), then move it back into the parent `locales/` directory. The launcher auto-discovers any `.json` file in `locales/`. + +## Placeholder contract + +Some strings interpolate a value, written `{likeThis}`. A placeholder is **not +always a word of the sentence's own language**, and translating around one +requires knowing which kind it is. + +### `{feature}` — the beta-notice keys + +`titleBar.betaNoticeTitleNamed`, `betaNoticeBodyNamed`, +`betaNoticeOffTitleNamed` and `betaNoticeOffBodyNamed` interpolate `{feature}`: +the name of a beta feature, e.g. `Asset library`. + +It is an **opaque proper name**. It is supplied at runtime by a remote +configuration payload, it is the same string for every user regardless of +locale, and it is **English today**. Desktop cannot translate it and cannot +know its grammatical gender or number. + +So, when translating these four strings: + +- **Keep `{feature}` a modifier, never the grammatical head.** In English it + modifies a constant head noun — "The *{feature}* **beta** is on" — and it is + that head noun ("beta") the sentence agrees with. Keep an equivalent constant + head in your language and let it carry the agreement: *la bêta {feature}*, + *die {feature}-Beta*, *бета-функция {feature}*. +- **Do not make the sentence agree with, decline, or inflect `{feature}`.** A + form like "{feature} est activé" or "{feature} включён" needs the name's + gender, which is unknowable — it would be guessing, and the guess changes + whenever ops names a new feature. +- **Word order is yours.** Putting the name after the head noun instead of + before it is expected, not a problem. +- An article immediately before `{feature}` should agree with the head noun, + not with the name. + +If your language cannot express this without inflecting the name, say so rather +than picking a gender — the template needs changing, not the translation. diff --git a/src/main/lib/coreBetaGrants.ts b/src/main/lib/coreBetaGrants.ts index 02540a8af..d0373e41e 100644 --- a/src/main/lib/coreBetaGrants.ts +++ b/src/main/lib/coreBetaGrants.ts @@ -43,7 +43,13 @@ export type CoreBetaNotice = { /** Human name of the feature, e.g. `"Asset library"`. Supplied by the payload rather than * mapped in Desktop because the allowlist is installed ahead of the features it names — a * table here would have to ship before anyone knew what to call them. Absent means the - * card falls back to its generic wording. */ + * card falls back to its generic wording. + * + * NOT localized, and not localizable from here: it arrives as one string for every user, + * in whatever language ops wrote it — English today. The card's SENTENCE is translated + * around it. That asymmetry is why the notice templates treat this as an opaque token and + * never as the word they agree with; see the placeholder contract in + * `locales/drafts/README.md`. */ readonly description?: string } diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.vue b/src/renderer/src/comfyTitleBar/TitleBarApp.vue index 15cde346f..5d615d860 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.vue +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.vue @@ -561,6 +561,16 @@ const betaNotice = useBetaActivationNotice({ // Static keys rather than composed ones: `createAppI18n` disables missing-key warnings, so // a rename in en.json would otherwise degrade silently to a card titled with the literal // key. Written out, the four are greppable and fail visibly. + // + // The `{feature}` slot in the *Named variants is an OPAQUE PROPER NAME, supplied by the + // flag payload and not localized. In every template it modifies the constant head noun + // ("beta"), so what a gendered or case-marking language agrees with is that head noun and + // never the slotted name. A translation that promotes `{feature}` to the grammatical head + // — "{feature} est activé", "{feature} включён" — needs a gender Desktop does not have and + // cannot get. en and zh are the only shipping locales and zh has neither gender nor case, + // so nothing exercises this today; the contract is written down so a third locale cannot + // introduce the fragile form silently. Contract for translators: + // `locales/drafts/README.md`. const keys = direction === 'disabled' ? description From 94b2b224708f988ec4efb93a9991ad532527d1dd Mon Sep 17 00:00:00 2001 From: Simon Pinfold <synap5e@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:07:15 -0700 Subject: [PATCH 26/27] docs(i18n): namespace all four keys in the placeholder contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the first key carried its `titleBar.` prefix; the other three were written bare. In a document whose whole purpose is telling a translator exactly which strings the rule applies to, a half-qualified name is the one thing that cannot be looked up — and none of the three is greppable as written. Found in review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- locales/drafts/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/locales/drafts/README.md b/locales/drafts/README.md index 491e9e117..5f5901371 100644 --- a/locales/drafts/README.md +++ b/locales/drafts/README.md @@ -16,8 +16,9 @@ requires knowing which kind it is. ### `{feature}` — the beta-notice keys -`titleBar.betaNoticeTitleNamed`, `betaNoticeBodyNamed`, -`betaNoticeOffTitleNamed` and `betaNoticeOffBodyNamed` interpolate `{feature}`: +`titleBar.betaNoticeTitleNamed`, `titleBar.betaNoticeBodyNamed`, +`titleBar.betaNoticeOffTitleNamed` and `titleBar.betaNoticeOffBodyNamed` +interpolate `{feature}`: the name of a beta feature, e.g. `Asset library`. It is an **opaque proper name**. It is supplied at runtime by a remote From 2b6ac0a8fdc87891c10c9c689131ec90f132ddf0 Mon Sep 17 00:00:00 2001 From: Simon Pinfold <synap5e@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:51:42 -0700 Subject: [PATCH 27/27] test(core-beta): make the anchor assertion say WHICH term is wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge-group Linux run failed this assertion at 8px, on both attempts, after the 10s poll — so a stable geometry, not a settle transient. It passes here at 0.0px, including at that run's exact 1280x900 window with the bell landing where its screenshot shows it. Window width is not the difference. "expected <= 2, received 8" cannot say which term is wrong, and the offset is a sum of three independent things: the view centred on the bell, the card centred in the view, and the beak centred in the card. Each has a different cause and a different fix. The failure now carries all of them — bell, view x and width, card left and width, beak position within the card, the beak's inline style and the page width — so one run in the environment that actually fails answers it instead of costing another round trip. Verified by reverting the centring fix: the message reads `cardLeft=0 cardWidth=280 ... viewCentreVsBell=0`, which names the middle term immediately — the view was centred correctly and the card was flush-left inside it. The assertion and its tolerance are unchanged. Nothing here can make a real misalignment pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- e2e/beta-activation-notice.test.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/e2e/beta-activation-notice.test.ts b/e2e/beta-activation-notice.test.ts index 16844bde1..044102d9a 100644 --- a/e2e/beta-activation-notice.test.ts +++ b/e2e/beta-activation-notice.test.ts @@ -232,6 +232,31 @@ test('the card is anchored on the bell it points at @linux', async () => { const b = beak.getBoundingClientRect() return (b.left + b.right) / 2 })()`) + + /** Everything the offset is computed from, for the failure message. + * + * A bare "expected <= 2, received 8" cannot say WHICH term is wrong, and this assertion + * has already failed on a runner where it passes locally at the same window size. The + * offset is `view.x + beak - bell`, so a failure is one of: the view not centred on the + * bell, the card not centred in the view, or the beak not centred in the card. Each has a + * different cause and a different fix, and one CI failure should be enough to tell them + * apart instead of costing another round trip. */ + const geometry = async (): Promise<string> => { + const card = await coachmarkPopup(ctx.app).evaluate<string>(`(() => { + const c = document.querySelector('.coachmark') + const b = document.querySelector('.coachmark-beak') + if (!c || !b) return 'card=<absent>' + const cr = c.getBoundingClientRect(), br = b.getBoundingClientRect() + return 'cardLeft=' + cr.left + ' cardWidth=' + cr.width + + ' beakInCard=' + ((br.left + br.right) / 2 - cr.left) + + ' beakInlineStyle=' + (b.style.left || '<none>') + + ' pageWidth=' + window.innerWidth + })()`) + return ( + `bell=${bellCentre} viewX=${popup!.x} viewWidth=${popup!.width} ${card}` + + ` | viewCentreVsBell=${popup!.x + popup!.width / 2 - bellCentre}` + ) + } expect(await beakCentre(), 'no beak found on the card').toBeGreaterThanOrEqual(0) // When nothing clamped it, the beak must land ON the bell. Asserted only in the unclamped @@ -247,7 +272,9 @@ test('the card is anchored on the bell it points at @linux', async () => { await expect .poll(async () => Math.abs(popup!.x + (await beakCentre()) - bellCentre), { timeout: 10_000, - message: 'the beak must point at the bell, not merely sit in a view that is centred on it', + message: + 'the beak must point at the bell, not merely sit in a view that is centred on it. ' + + (await geometry()), }) .toBeLessThanOrEqual(2) }