From 9a69247d0b3f6ed55265e23a5da41d9535f66b83 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 15 Sep 2026 20:28:42 -0700 Subject: [PATCH 1/2] Render Customer.io messages in the Desktop launcher --- docs/customerio-messaging.md | 66 ++++++++++++++++++++++--------- e2e/customerio.test.ts | 68 +++++++++++++++++++++++--------- src/preload/customerIoPreload.ts | 3 +- src/preload/index.ts | 2 + src/renderer/panel.html | 2 +- 5 files changed, 103 insertions(+), 38 deletions(-) diff --git a/docs/customerio-messaging.md b/docs/customerio-messaging.md index 79219fb33..5fbf3ff91 100644 --- a/docs/customerio-messaging.md +++ b/docs/customerio-messaging.md @@ -1,31 +1,60 @@ -# Customer.io messages in local ComfyUI +# Customer.io messages in Desktop -Desktop loads the Customer.io browser SDK into the local ComfyUI page after the -main process confirms all of the following: +Desktop loads the Customer.io browser SDK into the eligible, visible body of a +focused window. The main process owns the session and confirms all of the following: -- The attached installation is local and its ComfyUI panel is visible and focused. - The user has enabled Desktop telemetry. -- The page has affirmed the Firebase UID verified by Desktop's authentication - flow, and the other authentication reporters agree. +- The window is visible and focused, with no onboarding/progress takeover or + feedback, MCP, or announcement overlay. +- Local ComfyUI has affirmed the Firebase UID verified by Desktop's authentication + flow, and the other authentication reporters agree; or the launcher has resolved + its OAuth identity through authenticated `GET /api/user`. +- The launcher has completed first-use onboarding and is the exact bundled panel + document (or the configured development server's panel). Signing out, changing accounts, revoking consent, leaving ComfyUI, or hiding its -window clears the messaging session and removes visible messages. Launcher, -onboarding, remote installations, and embedded Cloud do not receive this SDK. -Embedded Cloud continues to own its existing integration. +window clears the messaging session and removes visible messages. Switching +surfaces revokes the old document before granting the new one. Onboarding, +remote ComfyUI pages, and embedded Cloud do not receive Desktop's SDK. Embedded +Cloud continues to own its existing integration. ## Campaign configuration The default public browser keys in `src/shared/customerIo.ts` use the existing -Cloud Customer.io source. Identity is the same Firebase UID used on Cloud. -Desktop sends `identify` with the selected locale, a page named -**`desktop/comfyui`**, and the SDK's delivery/interaction metrics. Page -properties also use this synthetic target, rather than the local workflow URL. +Cloud Customer.io source. Desktop sends `identify` with the selected locale, +a synthetic page name, and the SDK's delivery/interaction metrics. + +| Page name | Surface | Identity | +| --- | --- | --- | +| `desktop/comfyui` | Local ComfyUI workflow interface | Desktop-verified local Firebase UID | +| `desktop/launcher` | Launcher chooser, stopped-instance page and installation forms | Authenticated OAuth grant's Firebase UID | + +These names identify UI surfaces, not particular installations or workflow files. +Page properties also use the synthetic target rather than a local URL. Configure Desktop messages to match that page name. Audit campaigns with no page restriction before rollout: those can match Desktop users too. Native HTTP(S) and email message actions open externally; opening a message link must leave the ComfyUI workflow in place. +## Launcher identity dependency + +Launcher OAuth uses a canonical Cloud person ID, which can differ from the +Firebase UID used by existing Customer.io profiles. Desktop consumes only the +optional `firebase_uid` returned by authenticated `/api/user`; it never guesses +from the canonical ID, workspace ID, email, or unverified JWT claims. + +This requires Cloud's [OAuth provenance layer](https://github.com/Comfy-Org/cloud/pull/9467) +and [authenticated identity response](https://github.com/Comfy-Org/cloud/pull/9468). +Existing OAuth grants without provenance need a fresh sign-in before launcher +messaging can activate. The API omits the field if the original Firebase subject +no longer maps to the authenticated canonical account. Local ComfyUI's existing +verified Firebase flow continues to work independently. + +Launcher lookups are revalidated on activation and credential changes. Logout, +workspace switches, window/view replacement, or consent changes cannot apply a +late response from an obsolete lookup. No live Customer.io profiles are migrated. + ## Runtime and development Packaged builds enable messaging when the eligibility conditions above hold. @@ -38,12 +67,12 @@ Development builds default off. Environment overrides: | `COMFY_CUSTOMER_IO_WRITE_KEY` | Override the public Data Pipelines browser key | | `COMFY_CUSTOMER_IO_SITE_ID` | Override the public in-app site ID | -The browser SDK is bundled as a script and executed in ComfyUI's browser context. +The browser SDK is bundled as a script and executed in the selected browser context. It receives no Node access. Its location and local/session storage accesses are redirected at build time to a private synthetic URL and in-memory stores. This applies before SDK initialization, so URL parameters cannot select an identity, trigger events, or add campaign/referrer metadata. The SDK's fallback route uses -`/desktop/comfyui`, never the real workflow path. Native link handling owns +the synthetic surface path, never the real workflow path. Native link handling owns navigation; SDK location assignments cannot replace the workflow. Browser persistence is disabled and ComfyUI's own URL and storage remain intact. Session identity is reset before switching accounts. Network failures do @@ -58,8 +87,9 @@ pnpm exec electron-vite build pnpm exec playwright test e2e/customerio.test.ts --project=macos --retries=0 ``` -Use `windows` or `linux` for the corresponding host. This test runs the shipped -preload and real SDK with intercepted network responses and disposable identities. +Use `windows` or `linux` for the corresponding host. These tests run both shipped +preloads and the real SDK with intercepted responses and disposable identities. +The launcher fixture uses a file URL and the production panel's CSP. It covers rendering, opened metrics, dismissal, account changes, revocation, and continued access to the workflow and its existing browser storage. @@ -68,6 +98,6 @@ authentication consensus, and asynchronous session changes. Release verification still needs a restricted live Customer.io campaign and packaged macOS/Windows checks. Confirm the intended profile receives a message in -local ComfyUI, its delivery/open/click records appear, links preserve the workflow, +both launcher and local ComfyUI, its delivery/open/click records appear, links preserve the workflow, and embedded Cloud does not receive a second SDK. Fixture results do not establish live campaign delivery. diff --git a/e2e/customerio.test.ts b/e2e/customerio.test.ts index 62f717131..01b3c6f12 100644 --- a/e2e/customerio.test.ts +++ b/e2e/customerio.test.ts @@ -1,11 +1,12 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' import { _electron, expect, test, type ElectronApplication } from '@playwright/test' -import { CUSTOMER_IO_STATE } from '../src/shared/customerIo' +import { CUSTOMER_IO_PAGES, CUSTOMER_IO_STATE } from '../src/shared/customerIo' import type { CustomerIoSession } from '../src/shared/customerIo' -const identity: CustomerIoSession = { +const defaultIdentity: CustomerIoSession = { userId: 'desktop-test-user', locale: 'ja', writeKey: 'test-write-key', @@ -14,7 +15,8 @@ const identity: CustomerIoSession = { } /** Exercise the shipped preload and real SDK without a ComfyUI install or vendor traffic. */ -test('Desktop SDK renders, dismisses, and revokes messages @macos @windows @linux', async () => { +async function exerciseMessaging(surface: keyof typeof CUSTOMER_IO_PAGES): Promise { + const identity = { ...defaultIdentity, page: CUSTOMER_IO_PAGES[surface] } const testInfo = test.info() const directory = await mkdtemp(join(tmpdir(), 'comfy-customerio-')) let app: ElectronApplication | undefined @@ -25,6 +27,17 @@ test('Desktop SDK renders, dismisses, and revokes messages @macos @windows @linu releaseViewLog = resolve }) try { + const csp = + surface === 'launcher' + ? (await readFile(resolve('src/renderer/panel.html'), 'utf8')).match( + /]*>/ + )?.[0] + : '' + if (surface === 'launcher') expect(csp).toBeTruthy() + const fixtureHtml = `${csp ?? ''}

Desktop ${surface} fixture

+` + const fixtureFile = join(directory, 'private-workflow-name.html') + await writeFile(fixtureFile, fixtureHtml) const main = join(directory, 'main.cjs') await writeFile( main, @@ -32,7 +45,7 @@ test('Desktop SDK renders, dismisses, and revokes messages @macos @windows @linu app.setPath('userData', ${JSON.stringify(join(directory, 'profile'))}) app.whenReady().then(() => { const window = new BrowserWindow({ width: 1100, height: 700, webPreferences: { - preload: ${JSON.stringify(resolve('out/preload/comfyPreload.js'))}, + preload: ${JSON.stringify(resolve(`out/preload/${surface === 'launcher' ? 'index' : 'comfyPreload'}.js`))}, contextIsolation: true, sandbox: false, nodeIntegration: false } }) window.loadURL('about:blank') @@ -54,12 +67,13 @@ app.whenReady().then(() => { const request = route.request() const url = request.url() requests.push({ url, body: request.postData(), headers: request.headers() }) - if (url.startsWith('http://127.0.0.1:8188')) { + if ( + url.startsWith('http://127.0.0.1:8188') || + url.startsWith(pathToFileURL(fixtureFile).href) + ) { return route.fulfill({ contentType: 'text/html', - body: `

ComfyUI fixture

- -` + body: fixtureHtml }) } if (url.endsWith('/settings')) { @@ -123,7 +137,7 @@ document.getElementById('close').onclick = () => parent.postMessage({gist:{insta properties: { gist: { campaignId: `fixture-delivery-${delivery}`, - routeRuleWeb: 'desktop/comfyui', + routeRuleWeb: identity.page, persistent: request.headers()['x-gist-encoded-user-token'] === Buffer.from('second-test-user').toString('base64') @@ -143,14 +157,26 @@ document.getElementById('close').onclick = () => parent.postMessage({gist:{insta return route.fulfill({ json: {} }) }) + const fixtureUrl = + surface === 'launcher' + ? pathToFileURL(fixtureFile).href + : 'http://127.0.0.1:8188/private-workflow-name' await page.goto( - 'http://127.0.0.1:8188/private-workflow-name?private-query=workflow-secret&ajs_uid=unverified-person&ajs_event=private-event&utm_campaign=private-campaign&btid=private-ad', - { - referer: 'http://127.0.0.1:8188/private-referrer' - } + `${fixtureUrl}?private-query=workflow-secret&ajs_uid=unverified-person&ajs_event=private-event&utm_campaign=private-campaign&btid=private-ad`, + surface === 'comfyui' ? { referer: 'http://127.0.0.1:8188/private-referrer' } : undefined + ) + await page.waitForFunction( + surface === 'launcher' + ? 'typeof window.api === "object"' + : 'typeof window.__comfyDesktop2 === "object"' ) - await page.waitForFunction('typeof window.__comfyDesktop2 === "object"') - expect(requests).toHaveLength(1) + await page.evaluate(` + localStorage.setItem('fixture-auth', 'untouched'); + document.getElementById('workflow').addEventListener('click', event => { + event.target.textContent = 'Workflow running'; + }); + `) + expect(requests.filter(({ url }) => url.startsWith('https://'))).toHaveLength(0) const update = async (session: CustomerIoSession | null): Promise => { await app!.evaluate( ({ BrowserWindow }, { channel, session }) => { @@ -208,7 +234,7 @@ document.getElementById('close').onclick = () => parent.postMessage({gist:{insta const events = requests.filter(({ url }) => /^https:\/\/cdp\.customer\.io\/v1\/[ipt]$/.test(url) ) - expect(events.some(({ body }) => body?.includes('"name":"desktop/comfyui"'))).toBe(true) + expect(events.some(({ body }) => body?.includes(`"name":"${identity.page}"`))).toBe(true) expect( events.every( ({ body }) => @@ -240,4 +266,10 @@ document.getElementById('close').onclick = () => parent.postMessage({gist:{insta await app?.close() await rm(directory, { recursive: true, force: true }) } -}) +} + +for (const surface of ['comfyui', 'launcher'] as const) { + test(`Desktop ${surface} SDK renders, dismisses, and revokes messages @macos @windows @linux`, async () => { + await exerciseMessaging(surface) + }) +} diff --git a/src/preload/customerIoPreload.ts b/src/preload/customerIoPreload.ts index 3af457718..60f1969ac 100644 --- a/src/preload/customerIoPreload.ts +++ b/src/preload/customerIoPreload.ts @@ -3,7 +3,7 @@ import script from 'virtual:customerio-script' import { CUSTOMER_IO_ACTION, CUSTOMER_IO_READY, CUSTOMER_IO_STATE } from '../shared/customerIo' import type { CustomerIoSession } from '../shared/customerIo' -/** Runs the browser SDK in ComfyUI's DOM, never in the privileged preload world. */ +/** Runs the browser SDK in an eligible page's DOM, never in the privileged preload world. */ export function startCustomerIoMessaging(): void { let bridgeInstalled = false let installed = false @@ -29,4 +29,5 @@ export function startCustomerIoMessaging(): void { window.addEventListener('DOMContentLoaded', () => ipcRenderer.send(CUSTOMER_IO_READY), { once: true }) + window.addEventListener('online', () => ipcRenderer.send(CUSTOMER_IO_READY)) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 695f669aa..8dd8caa1f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,7 +1,9 @@ import { contextBridge } from 'electron' import { buildElectronApi } from './api' +import { startCustomerIoMessaging } from './customerIoPreload' const api = buildElectronApi() +startCustomerIoMessaging() if (process.contextIsolated) { contextBridge.exposeInMainWorld('api', api) diff --git a/src/renderer/panel.html b/src/renderer/panel.html index 2c9d4a40f..d7a82cc34 100644 --- a/src/renderer/panel.html +++ b/src/renderer/panel.html @@ -10,7 +10,7 @@ -->