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
68 changes: 68 additions & 0 deletions docs/customerio-messaging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Customer.io messages in local ComfyUI

Desktop loads the Customer.io browser SDK into the local ComfyUI page after the
main process 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.

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.

## 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/local-workflow`**, and the SDK's delivery/interaction metrics. Page
properties also use this synthetic target, rather than the local workflow 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.

## Runtime and development

Packaged builds enable messaging when the eligibility conditions above hold.
Development builds default off. Environment overrides:

| Variable | Purpose |
| ---------------------------------------- | ---------------------------------------------- |
| `COMFY_CUSTOMER_IO_ENABLED=true` | Enable messaging during development |
| `COMFY_CUSTOMER_IO_ENABLED=false` or `0` | Disable messaging for this process |
| `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.
It receives no Node access. Its local/session storage accesses are redirected at
build time to private in-memory stores, leaving ComfyUI's authentication storage
intact. Session identity is reset before switching accounts. Network failures do
not block loading or using ComfyUI; another activation or an online event can retry.

## Verification

After building, run the isolated Electron fixture on the current platform:

```sh
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.
It covers rendering, opened metrics, dismissal, account changes, revocation, and
continued access to the workflow and its existing browser storage.

Unit tests cover main-process eligibility, frame validation, link handling,
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,
and embedded Cloud does not receive a second SDK. Fixture results do not establish
live campaign delivery.
221 changes: 221 additions & 0 deletions e2e/customerio.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { _electron, expect, test, type ElectronApplication } from '@playwright/test'
import { CUSTOMER_IO_STATE } from '../src/shared/customerIo'
import type { CustomerIoSession } from '../src/shared/customerIo'

const identity: CustomerIoSession = {
userId: 'desktop-test-user',
locale: 'ja',
writeKey: 'test-write-key',
siteId: 'test-site'
}

/** 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 () => {
const testInfo = test.info()
const directory = await mkdtemp(join(tmpdir(), 'comfy-customerio-'))
let app: ElectronApplication | undefined
let holdViewLog = false
let viewLogPending = false
let releaseViewLog!: () => void
const viewLog = new Promise<void>((resolve) => {
releaseViewLog = resolve
})
try {
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'))},
contextIsolation: true, sandbox: false, nodeIntegration: false
} })
window.loadURL('about:blank')
ipcMain.on('customerio:action', event => { event.returnValue = false })
})`
)
// Match the existing Electron harness: Linux CI has no SUID sandbox binary.
const args = process.platform === 'linux' ? [main, '--no-sandbox'] : [main]
app = await _electron.launch({ args })
const page = await app.firstWindow()
const requests: { url: string; body: string | null; headers: Record<string, string> }[] = []
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text())
})
const deliveries = new Map<string, number>()
await app.context().route('**/*', async (route) => {
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')) {
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>`
})
}
if (url.endsWith('/settings')) {
return route.fulfill({
json: {
integrations: {
'Customer.io Data Pipelines': { apiKey: identity.writeKey },
// The Desktop registration must override source-level auto setup.
'Customer.io In-App Plugin': { enabled: true, siteId: 'wrong-source-site' }
},
plan: { track: {} }
}
})
}
if (url.startsWith('https://renderer.gist.build/')) {
// A separate navigation keeps interception active for the fixture renderer.
return route.fulfill({
contentType: 'text/html',
body: '<script>location.replace("https://code.gist.build/fixture")</script>'
})
}
if (url.startsWith('https://code.gist.build/')) {
return route.fulfill({
contentType: 'text/html',
body: `<html><body style="background:white;color:black"><h2>Desktop message fixture</h2>
<button id="close">Dismiss</button><script>
let instanceId;
window.addEventListener('message', event => {
if (!event.data.options) return;
instanceId = event.data.options.instanceId;
parent.postMessage({gist:{instanceId,method:'routeLoaded',parameters:{route:'start',width:400,height:200}}}, '*');
});
document.getElementById('close').onclick = () => parent.postMessage({gist:{instanceId,method:'tap',parameters:{action:'gist://close',name:'Dismiss'}}}, '*');
</script></body></html>`
})
}
if (url.includes('/api/v4/users')) {
const user = request.headers()['x-gist-encoded-user-token'] ?? ''
if (!deliveries.has(user)) deliveries.set(user, deliveries.size + 1)
// Polls return the same delivery until dismissed, as the service does.
// Inventing a new campaign every poll makes slow runs show another modal
// immediately after the first one closes.
const delivery = deliveries.get(user)!
return route.fulfill({
headers: {
'x-gist-queue-polling-interval': '1',
'access-control-expose-headers': 'x-gist-queue-polling-interval'
},
json: {
inAppMessages: [
{
messageId: `fixture-message-${delivery}`,
queueId: `fixture-queue-${delivery}`,
priority: 1,
properties: {
gist: {
campaignId: `fixture-delivery-${delivery}`,
routeRuleWeb: 'desktop/local-workflow',
persistent:
request.headers()['x-gist-encoded-user-token'] ===
Buffer.from('second-test-user').toString('base64')
}
}
}
],
inboxMessages: []
}
})
}
if (holdViewLog && url.includes('/api/v1/logs/')) {
viewLogPending = true
await viewLog
}
// No request is allowed to reach Customer.io, including delivery metrics.
return route.fulfill({ json: {} })
})

await page.goto('http://127.0.0.1:8188/private-workflow-name?private-query=workflow-secret', {
referer: 'http://127.0.0.1:8188/private-referrer'
})
await page.waitForFunction('typeof window.__comfyDesktop2 === "object"')
expect(requests).toHaveLength(1)
const update = async (session: CustomerIoSession | null): Promise<void> => {
await app!.evaluate(
({ BrowserWindow }, { channel, session }) => {
BrowserWindow.getAllWindows()[0]!.webContents.send(channel, session)
},
{ channel: CUSTOMER_IO_STATE, session }
)
}
const message = page.frameLocator('iframe.gist-message')
await update(identity)
await expect(message.getByRole('heading', { name: 'Desktop message fixture' })).toBeVisible()
await expect(page.locator('iframe.gist-message')).toHaveCSS('opacity', '1')
await expect
.poll(() => requests.some(({ body }) => body?.includes('"metric":"opened"')))
.toBe(true)
await page.screenshot({
path: testInfo.outputPath('customerio-message.png'),
animations: 'disabled'
})
await expect
.poll(() => requests.filter(({ url }) => url.includes('/api/v4/users')).length)
.toBeGreaterThanOrEqual(2)
await message.getByRole('button', { name: 'Dismiss' }).click()
await expect(page.locator('#gist-overlay')).toHaveCount(0)

// Re-identification obtains another message, then auth/consent revocation
// removes it while the workflow and its existing login storage remain usable.
await update(null)
await page.evaluate('globalThis.__comfyCustomerIo.update(null)')
await update({ ...identity, userId: 'second-test-user' })
await expect(message.getByRole('heading', { name: 'Desktop message fixture' })).toBeVisible()
holdViewLog = true
await update(null)
await expect.poll(() => viewLogPending).toBe(true)
// Revocation must release input before the persistent-message view log
// completes. Checking the hit target cannot pass by waiting for its timeout.
expect(
await page.getByRole('button', { name: 'Run workflow' }).evaluate((button) => {
const bounds = button.getBoundingClientRect()
return (
button.ownerDocument.elementFromPoint(
bounds.x + bounds.width / 2,
bounds.y + bounds.height / 2
) === button
)
})
).toBe(true)
await page.getByRole('button', { name: 'Run workflow' }).click()
releaseViewLog()
await expect(page.locator('#gist-overlay')).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Workflow running' })).toBeVisible()
expect(await page.evaluate('localStorage.getItem("fixture-auth")')).toBe('untouched')
expect(await page.evaluate('localStorage.length')).toBe(1)
expect(await page.evaluate('typeof require')).toBe('undefined')
const events = requests.filter(({ url }) =>
/^https:\/\/cdp\.customer\.io\/v1\/[ipt]$/.test(url)
)
expect(events.some(({ body }) => body?.includes('"name":"desktop/local-workflow"'))).toBe(true)
expect(
events.every(
({ body }) => !/private-workflow|private-query|private-referrer/.test(body ?? '')
)
).toBe(true)
const pages = events.filter(({ url }) => url.endsWith('/p'))
expect(pages.length).toBeGreaterThan(0)
for (const { body } of pages) {
expect(JSON.parse(body!).properties).toMatchObject({ search: '', referrer: '' })
}
const queues = requests.filter(({ url }) => url.includes('/api/v4/users'))
expect(queues.length).toBeGreaterThanOrEqual(2)
expect(queues.every(({ headers }) => headers['x-cio-site-id'] === identity.siteId)).toBe(true)
expect(errors).toEqual([])
} finally {
releaseViewLog()
await app?.close()
await rm(directory, { recursive: true, force: true })
}
})
2 changes: 2 additions & 0 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { resolve } from 'path'
import { defineConfig } from 'electron-vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { customerIoScriptPlugin } from './scripts/customerio-script'

const require = createRequire(import.meta.url)
const { resolveDatadogReleaseVersion } = require('./scripts/datadog-release-version.cjs') as {
Expand All @@ -25,6 +26,7 @@ export default defineConfig({
}
},
preload: {
plugins: [customerIoScriptPlugin()],
build: {
sourcemap: 'hidden',
rollupOptions: {
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,12 @@
},
"dependencies": {
"7zip-bin": "^5.2.0",
"@customerio/cdp-analytics-browser": "0.5.11",
"@datadog/browser-rum": "6.28.1",
"@todesktop/runtime": "^2.1.3",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"customerio-gist-web": "3.26.0",
"electron-updater": "^6.7.3",
"node-pty": "^1.1.0",
"posthog-node": "^5.30.7",
Expand All @@ -100,6 +102,7 @@
"electron": "40.4.1",
"electron-builder": "^26.7.0",
"electron-vite": "^5.0.0",
"esbuild": "^0.25.12",
"eslint": "^10.0.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-unused-imports": "^4.4.1",
Expand Down
Loading
Loading