Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 48 additions & 18 deletions docs/customerio-messaging.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -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.

Expand All @@ -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.
68 changes: 50 additions & 18 deletions e2e/customerio.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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<void> {
const identity = { ...defaultIdentity, page: CUSTOMER_IO_PAGES[surface] }
const testInfo = test.info()
const directory = await mkdtemp(join(tmpdir(), 'comfy-customerio-'))
let app: ElectronApplication | undefined
Expand All @@ -25,14 +27,25 @@ 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(
/<meta\s+http-equiv="Content-Security-Policy"[^>]*>/
)?.[0]
: ''
if (surface === 'launcher') expect(csp).toBeTruthy()
const fixtureHtml = `<html><head>${csp ?? ''}</head><body style="background:#171717;color:white"><h1>Desktop ${surface} fixture</h1>
<button id="workflow">Run workflow</button></body></html>`
const fixtureFile = join(directory, 'private-workflow-name.html')
await writeFile(fixtureFile, fixtureHtml)
const main = join(directory, 'main.cjs')
await writeFile(
main,
`const { app, BrowserWindow, ipcMain } = require('electron')
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')
Expand All @@ -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: `<html><body style="background:#171717;color:white"><h1>ComfyUI fixture</h1>
<button id="workflow" onclick="this.textContent='Workflow running'">Run workflow</button>
<script>localStorage.setItem('fixture-auth', 'untouched')</script></body></html>`
body: fixtureHtml
})
}
if (url.endsWith('/settings')) {
Expand Down Expand Up @@ -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')
Expand All @@ -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<void> => {
await app!.evaluate(
({ BrowserWindow }, { channel, session }) => {
Expand Down Expand Up @@ -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 }) =>
Expand Down Expand Up @@ -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)
})
}
3 changes: 2 additions & 1 deletion src/preload/customerIoPreload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
}
2 changes: 2 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
8 changes: 6 additions & 2 deletions src/renderer/csp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,12 @@ describe('Content-Security-Policy: panel.html', () => {
expect(csp['default-src']).toBe("'self'")
})

it('allows the typeform feedback origin in frame-src (Send Feedback modal)', () => {
expect(csp['frame-src']).toBe('https://form.typeform.com')
it('limits frames to feedback and Customer.io message origins', () => {
expect(csp['frame-src'].split(/\s+/).sort()).toEqual([
'https://code.gist.build',
'https://form.typeform.com',
'https://renderer.gist.build'
])
})

it('allows GitHub-hosted starter-template thumbnails in img-src', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/panel.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
-->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.posthog.com https://raw.githubusercontent.com; media-src 'self' https://media.comfy.org; font-src 'self' data:; frame-src https://form.typeform.com; connect-src 'self' ws: https://*.datadoghq.com https://browser-intake-us5-datadoghq.com https://*.posthog.com"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.posthog.com https://raw.githubusercontent.com; media-src 'self' https://media.comfy.org; font-src 'self' data:; frame-src https://form.typeform.com https://renderer.gist.build https://code.gist.build; connect-src 'self' ws: https://*.datadoghq.com https://browser-intake-us5-datadoghq.com https://*.posthog.com https://cdp.customer.io https://engine.api.gist.build https://consumer.cloud.gist.build https://realtime.cloud.gist.build"
/>
<style>
/* No html/body background here — settings-v2 toggles
Expand Down
Loading