From 9ef6f462a0c49fd2577a8741dcded806955c9474 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 15 Sep 2026 19:03:49 -0700 Subject: [PATCH 1/6] Support Customer.io messages in local Desktop ComfyUI --- docs/customerio-messaging.md | 68 +++++++ e2e/customerio.test.ts | 170 ++++++++++++++++++ electron.vite.config.ts | 2 + package.json | 3 + pnpm-lock.yaml | 170 ++++++++++++++++++ scripts/customerio-script.ts | 42 +++++ src/main/host/attach.ts | 3 + src/main/host/createHostWindow.ts | 1 + src/main/host/registry.ts | 2 + src/main/lib/customerIoEvents.ts | 4 + src/main/lib/customerIoMessaging.test.ts | 164 +++++++++++++++++ src/main/lib/customerIoMessaging.ts | 148 +++++++++++++++ src/main/lib/firebaseAuthIdentity.test.ts | 19 ++ src/main/lib/firebaseAuthIdentity.ts | 27 +++ src/main/lib/ipc/registerSettingsHandlers.ts | 2 + src/preload/comfyPreload.test.ts | 2 + src/preload/comfyPreload.ts | 2 + src/preload/customerIoPreload.ts | 32 ++++ src/preload/customerIoScript.d.ts | 4 + .../src/customerIo/controller.test.ts | 113 ++++++++++++ src/renderer/src/customerIo/controller.ts | 74 ++++++++ src/renderer/src/customerIo/index.ts | 110 ++++++++++++ src/shared/customerIo.ts | 16 ++ tsconfig.node.json | 1 + vitest.config.ts | 3 +- 25 files changed, 1181 insertions(+), 1 deletion(-) create mode 100644 docs/customerio-messaging.md create mode 100644 e2e/customerio.test.ts create mode 100644 scripts/customerio-script.ts create mode 100644 src/main/lib/customerIoEvents.ts create mode 100644 src/main/lib/customerIoMessaging.test.ts create mode 100644 src/main/lib/customerIoMessaging.ts create mode 100644 src/preload/customerIoPreload.ts create mode 100644 src/preload/customerIoScript.d.ts create mode 100644 src/renderer/src/customerIo/controller.test.ts create mode 100644 src/renderer/src/customerIo/controller.ts create mode 100644 src/renderer/src/customerIo/index.ts create mode 100644 src/shared/customerIo.ts diff --git a/docs/customerio-messaging.md b/docs/customerio-messaging.md new file mode 100644 index 000000000..ccfd4648a --- /dev/null +++ b/docs/customerio-messaging.md @@ -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. diff --git a/e2e/customerio.test.ts b/e2e/customerio.test.ts new file mode 100644 index 000000000..74bc91bce --- /dev/null +++ b/e2e/customerio.test.ts @@ -0,0 +1,170 @@ +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 + 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 }) +})` + ) + app = await _electron.launch({ args: [main] }) + const page = await app.firstWindow() + const requests: { url: string; body: string | null; headers: Record }[] = [] + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + let delivery = 0 + 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: `

ComfyUI fixture

+ +` + }) + } + 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: '' + }) + } + if (url.startsWith('https://code.gist.build/')) { + return route.fulfill({ + contentType: 'text/html', + body: `

Desktop message fixture

+` + }) + } + if (url.includes('/api/v4/users')) { + delivery += 1 + 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' + } + } + } + ], + inboxMessages: [] + } + }) + } + // 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') + await page.waitForFunction('typeof window.__comfyDesktop2 === "object"') + expect(requests).toHaveLength(1) + const update = async (session: CustomerIoSession | null): Promise => { + 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 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() + await update(null) + await expect(page.locator('#gist-overlay')).toHaveCount(0) + await page.getByRole('button', { name: 'Run workflow' }).click() + 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 }) => !body?.includes('private-workflow-name'))).toBe(true) + 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 { + await app?.close() + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 5f804c142..fab006a0f 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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 { @@ -25,6 +26,7 @@ export default defineConfig({ } }, preload: { + plugins: [customerIoScriptPlugin()], build: { sourcemap: 'hidden', rollupOptions: { diff --git a/package.json b/package.json index 0b421317a..6430d567b 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c45ddf5c..5b72d164b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: 7zip-bin: specifier: ^5.2.0 version: 5.2.0 + '@customerio/cdp-analytics-browser': + specifier: 0.5.11 + version: 0.5.11(encoding@0.1.13) '@datadog/browser-rum': specifier: 6.28.1 version: 6.28.1 @@ -23,6 +26,9 @@ importers: '@xterm/xterm': specifier: ^5.5.0 version: 5.5.0 + customerio-gist-web: + specifier: 3.26.0 + version: 3.26.0 electron-updater: specifier: ^6.7.3 version: 6.8.3 @@ -90,6 +96,9 @@ importers: electron-vite: specifier: ^5.0.0 version: 5.0.0(vite@7.3.1(@types/node@22.19.12)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.9.0)) + esbuild: + specifier: ^0.25.12 + version: 0.25.12 eslint: specifier: ^10.0.1 version: 10.0.2(jiti@2.6.1) @@ -239,6 +248,15 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@customerio/cdp-analytics-browser@0.5.11': + resolution: {integrity: sha512-EIHaNVsfI3QAWWEri56b05pwmS4XgQBY8qSXFUeTZO6YTBY8nKpy7J+JIBhg+DibY/wnriO8bVkT1eXU3fjrLA==} + + '@customerio/cdp-analytics-core@0.5.11': + resolution: {integrity: sha512-sGRrprlV51+mWLhQih3F/zHiGSQqSVglVvv7ytqy/ZkXooPXmqZhssS0f7bXakecozw8Z/USAaqG6gOYEbddVQ==} + + '@customerio/jist@0.3.0': + resolution: {integrity: sha512-k5xhfgG1Sg9VRAzIsO/kuliM0hwTxg/4Fj2fmA83GtwKcihOTo71DkVXKN7ckh1GwWfHhf6htkpiBI2RjX/8Ag==} + '@datadog/browser-core@6.28.1': resolution: {integrity: sha512-h5y6aEyMTBZ091Y1mVLe2ta2YHiBa1qEEHliIp7+lb7rMZ1uWzYUzAu83bmTfvgI/yEZdHcVeTjxMucAncwK3g==} @@ -828,6 +846,14 @@ packages: '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@lukeed/uuid@2.0.1': + resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==} + engines: {node: '>=8'} + '@malept/cross-spawn-promise@2.0.0': resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} engines: {node: '>= 12.13.0'} @@ -1020,6 +1046,18 @@ packages: cpu: [x64] os: [win32] + '@segment/analytics.js-video-plugins@0.2.1': + resolution: {integrity: sha512-lZwCyEXT4aaHBLNK433okEKdxGAuyrVmop4BpQqQSJuRz0DglPZgd9B/XjiiWs1UyOankg2aNYMN3VcS8t4eSQ==} + + '@segment/facade@3.4.10': + resolution: {integrity: sha512-xVQBbB/lNvk/u8+ey0kC/+g8pT3l0gCT8O2y9Z+StMMn3KAFAQ9w8xfgef67tJybktOKKU7pQGRPolRM1i1pdA==} + + '@segment/isodate-traverse@1.1.1': + resolution: {integrity: sha512-+G6e1SgAUkcq0EDMi+SRLfT48TNlLPF3QnSgFGVs0V9F3o3fq/woQ2rHFlW20W0yy5NnCUH0QGU3Am2rZy/E3w==} + + '@segment/isodate@1.0.3': + resolution: {integrity: sha512-BtanDuvJqnACFkeeYje7pWULVv8RgZaqKHWwGFnL/g/TH/CcZjkIVTfGDp/MAxmilYHUkrX70SqwnYSTNEaN7A==} + '@sinclair/typebox@0.34.48': resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} @@ -1743,6 +1781,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + customerio-gist-web@3.26.0: + resolution: {integrity: sha512-tWIeMzhpB/9+nCbAYtkooz+yh3pPN+rvm5h+A0vP2PqeYLAb/iFXB8jss2A2WuKJqOkR2OPOQWAX8/vomJx9Ig==} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -1840,6 +1881,10 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2495,6 +2540,9 @@ packages: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} + js-cookie@3.0.8: + resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2839,6 +2887,9 @@ packages: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} + new-date@1.0.3: + resolution: {integrity: sha512-0fsVvQPbo2I18DT2zVHpezmeeNYV2JaJSrseiHLc17GNOxJzUdx5mvSigPu8LtIfZSij5i1wXnXFspEs2CD6hA==} + node-abi@3.92.0: resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} engines: {node: '>=10'} @@ -2856,6 +2907,15 @@ packages: node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-gyp@11.5.0: resolution: {integrity: sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -2893,6 +2953,9 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + obj-case@0.2.1: + resolution: {integrity: sha512-PquYBBTy+Y6Ob/O2574XHhDtHJlV1cJHMCgW+rDRc9J5hhmRelJB3k5dTK/3cVmFVtzvAKuENeuLpoyTzMzkOg==} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -3301,6 +3364,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + spark-md5@3.0.2: + resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==} + speakingurl@14.0.1: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} @@ -3446,6 +3512,9 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} @@ -3491,6 +3560,12 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unfetch@3.1.2: + resolution: {integrity: sha512-L0qrK7ZeAudGiKYw6nzFjnJ2D5WHblUBwmHIqtPS6oKUd+Hcpk7/hKsSmcHsTlpd1TbTNsiRBUKRq3bHLNIqIw==} + + unfetch@4.2.0: + resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==} + unique-filename@2.0.1: resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -3534,6 +3609,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -3651,10 +3730,16 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3859,6 +3944,30 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@customerio/cdp-analytics-browser@0.5.11(encoding@0.1.13)': + dependencies: + '@customerio/cdp-analytics-core': 0.5.11 + '@lukeed/uuid': 2.0.1 + '@segment/analytics.js-video-plugins': 0.2.1 + '@segment/facade': 3.4.10 + customerio-gist-web: 3.26.0 + dset: 3.1.4 + js-cookie: 3.0.8 + node-fetch: 2.7.0(encoding@0.1.13) + spark-md5: 3.0.2 + tslib: 2.8.1 + unfetch: 4.2.0 + transitivePeerDependencies: + - encoding + + '@customerio/cdp-analytics-core@0.5.11': + dependencies: + '@lukeed/uuid': 2.0.1 + dset: 3.1.4 + tslib: 2.8.1 + + '@customerio/jist@0.3.0': {} + '@datadog/browser-core@6.28.1': {} '@datadog/browser-rum-core@6.28.1': @@ -4427,6 +4536,12 @@ snapshots: '@kwsites/promise-deferred@1.1.1': {} + '@lukeed/csprng@1.1.0': {} + + '@lukeed/uuid@2.0.1': + dependencies: + '@lukeed/csprng': 1.1.0 + '@malept/cross-spawn-promise@2.0.0': dependencies: cross-spawn: 7.0.6 @@ -4575,6 +4690,23 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@segment/analytics.js-video-plugins@0.2.1': + dependencies: + unfetch: 3.1.2 + + '@segment/facade@3.4.10': + dependencies: + '@segment/isodate-traverse': 1.1.1 + inherits: 2.0.4 + new-date: 1.0.3 + obj-case: 0.2.1 + + '@segment/isodate-traverse@1.1.1': + dependencies: + '@segment/isodate': 1.0.3 + + '@segment/isodate@1.0.3': {} + '@sinclair/typebox@0.34.48': {} '@sindresorhus/is@4.6.0': {} @@ -5406,6 +5538,11 @@ snapshots: csstype@3.2.3: {} + customerio-gist-web@3.26.0: + dependencies: + '@customerio/jist': 0.3.0 + uuid: 14.0.2 + data-uri-to-buffer@6.0.2: {} datadog-metrics@0.9.3: @@ -5523,6 +5660,8 @@ snapshots: dotenv@16.6.1: {} + dset@3.1.4: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6333,6 +6472,8 @@ snapshots: js-cookie@3.0.5: {} + js-cookie@3.0.8: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -6649,6 +6790,10 @@ snapshots: netmask@2.0.2: {} + new-date@1.0.3: + dependencies: + '@segment/isodate': 1.0.3 + node-abi@3.92.0: dependencies: semver: 7.7.4 @@ -6666,6 +6811,12 @@ snapshots: dependencies: semver: 7.7.4 + node-fetch@2.7.0(encoding@0.1.13): + dependencies: + whatwg-url: 5.0.0 + optionalDependencies: + encoding: 0.1.13 + node-gyp@11.5.0: dependencies: env-paths: 2.2.1 @@ -6709,6 +6860,8 @@ snapshots: dependencies: boolbase: 1.0.0 + obj-case@0.2.1: {} + object-keys@1.1.1: optional: true @@ -7131,6 +7284,8 @@ snapshots: source-map@0.6.1: {} + spark-md5@3.0.2: {} + speakingurl@14.0.1: {} sprintf-js@1.1.3: @@ -7272,6 +7427,8 @@ snapshots: dependencies: is-number: 7.0.0 + tr46@0.0.3: {} + truncate-utf8-bytes@1.0.2: dependencies: utf8-byte-length: 1.0.5 @@ -7310,6 +7467,10 @@ snapshots: undici-types@7.16.0: {} + unfetch@3.1.2: {} + + unfetch@4.2.0: {} + unique-filename@2.0.1: dependencies: unique-slug: 3.0.0 @@ -7346,6 +7507,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@14.0.2: {} + uuid@9.0.1: {} verror@1.10.1: @@ -7452,8 +7615,15 @@ snapshots: dependencies: defaults: 1.0.4 + webidl-conversions@3.0.1: {} + whatwg-mimetype@3.0.0: {} + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/scripts/customerio-script.ts b/scripts/customerio-script.ts new file mode 100644 index 000000000..7874c930c --- /dev/null +++ b/scripts/customerio-script.ts @@ -0,0 +1,42 @@ +import { resolve } from 'node:path' +import { build } from 'esbuild' +import type { Plugin } from 'vite' + +/** Bundle a browser-only SDK into the preload as data, never as privileged preload code. */ +export function customerIoScriptPlugin(): Plugin { + return { + name: 'desktop-customerio-script', + resolveId(id) { + if (id === 'virtual:customerio-script') return '\0' + id + }, + async load(id) { + if (id !== '\0virtual:customerio-script') return + const result = await build({ + entryPoints: [resolve(__dirname, '../src/renderer/src/customerIo/index.ts')], + bundle: true, + // The SDK's sideEffects:false annotation drops its statically re-exported + // in-app module initializer in an IIFE bundle. Keep that initialization. + ignoreAnnotations: true, + write: false, + format: 'iife', + platform: 'browser', + target: 'chrome144', + minify: true, + metafile: true, + define: { + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'), + localStorage: '__desktopCioLocalStorage', + 'window.localStorage': '__desktopCioLocalStorage', + 'globalThis.localStorage': '__desktopCioLocalStorage', + sessionStorage: '__desktopCioSessionStorage', + 'window.sessionStorage': '__desktopCioSessionStorage', + 'globalThis.sessionStorage': '__desktopCioSessionStorage' + } + }) + for (const file of Object.keys(result.metafile!.inputs)) this.addWatchFile(resolve(file)) + const storage = `function storage(){const m=new Map();return {get length(){return m.size},clear(){m.clear()},getItem(k){return m.get(k)??null},key(i){return [...m.keys()][i]??null},removeItem(k){m.delete(k)},setItem(k,v){m.set(k,String(v))}}}` + const source = `(()=>{${storage};const __desktopCioLocalStorage=storage(),__desktopCioSessionStorage=storage();${result.outputFiles[0]!.text}})();` + return `export default ${JSON.stringify(source)}` + } + } +} diff --git a/src/main/host/attach.ts b/src/main/host/attach.ts index a95943f66..7c594add1 100644 --- a/src/main/host/attach.ts +++ b/src/main/host/attach.ts @@ -1,4 +1,5 @@ import * as ipc from '../lib/ipc' +import { attachCustomerIoMessaging } from '../lib/customerIoMessaging' import { getAppVersion } from '../lib/ipc' import { attachSessionDownloadHandler } from '../lib/comfyDownloadManager' import { getModelDownloadContentScript } from '../lib/comfyContentScript' @@ -172,6 +173,7 @@ export function attachInstall(entry: ComfyWindowEntry, opts: AttachInstallOpts): // state field keeps a later detach from clobbering identity twice. entry.previewInstallationId = null indexInstallationId(installationId, entry.windowKey) + const stopCustomerIo = isLocal ? attachCustomerIoMessaging(entry) : () => {} // Seed the MRU tracker if this in-place attach happens on the // already-focused host: no fresh OS `'focus'` event would fire to @@ -663,6 +665,7 @@ export function attachInstall(entry: ComfyWindowEntry, opts: AttachInstallOpts): // Retire async work still pending from this attach so a late resolution // can't touch a detached or re-attached view. attachActive = false + stopCustomerIo() deactivateFirebaseAuthReporter(comfyContents) installationEvents.off('updated', onInstallationUpdated) cancelFailRetry() diff --git a/src/main/host/createHostWindow.ts b/src/main/host/createHostWindow.ts index 5b3f64b18..ae1cbf418 100644 --- a/src/main/host/createHostWindow.ts +++ b/src/main/host/createHostWindow.ts @@ -680,6 +680,7 @@ export function createHostWindow(opts: CreateHostWindowOpts): CreateHostWindowRe entry.panelView.setVisible(false) } } + entry?.refreshCustomerIo?.() } comfyWindow.on('resize', layoutViews) diff --git a/src/main/host/registry.ts b/src/main/host/registry.ts index 5e2bea47c..3d0b16563 100644 --- a/src/main/host/registry.ts +++ b/src/main/host/registry.ts @@ -98,6 +98,8 @@ export interface ComfyWindowEntry { lastTheme: { bg: string; text: string } /** Updates view bounds for the current activePanel. */ layoutViews: () => void + /** Re-evaluate in-page messaging after host visibility or panel changes. */ + refreshCustomerIo?: () => void /** Current ComfyUI URL for the comfyView, updated on every `onLaunch` so * reload / did-fail-load handlers don't hold stale URLs. Empty for * install-less hosts. */ diff --git a/src/main/lib/customerIoEvents.ts b/src/main/lib/customerIoEvents.ts new file mode 100644 index 000000000..5819032c2 --- /dev/null +++ b/src/main/lib/customerIoEvents.ts @@ -0,0 +1,4 @@ +import { EventEmitter } from 'node:events' + +/** Refresh messaging after an authoritative auth, consent, or locale change. */ +export const customerIoEvents = new EventEmitter() diff --git a/src/main/lib/customerIoMessaging.test.ts b/src/main/lib/customerIoMessaging.test.ts new file mode 100644 index 000000000..e93a07c28 --- /dev/null +++ b/src/main/lib/customerIoMessaging.test.ts @@ -0,0 +1,164 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ComfyWindowEntry } from '../host/registry' +import { CUSTOMER_IO_READY, CUSTOMER_IO_STATE } from '../../shared/customerIo' + +const state = vi.hoisted(() => ({ consent: true, userId: 'verified-user' as string | null })) +vi.mock('electron', async () => { + const { EventEmitter } = await import('node:events') + return { + app: { isPackaged: true }, + ipcMain: new EventEmitter(), + shell: { openExternal: vi.fn(async () => {}) } + } +}) +vi.mock('../settings', () => ({ get: () => state.consent })) +vi.mock('./i18n', () => ({ getLocale: () => 'ja' })) +vi.mock('./firebaseAuthIdentity', () => ({ getCustomerIoUserId: () => state.userId })) +vi.mock('./verifiedLocalFirebaseAuth', () => ({ + isLoopbackOrigin: (url: string) => new URL(url).hostname === '127.0.0.1' +})) + +import { attachCustomerIoMessaging, customerIoSession } from './customerIoMessaging' +import { customerIoEvents } from './customerIoEvents' +import { ipcMain, shell } from 'electron' +import { CUSTOMER_IO_ACTION } from '../../shared/customerIo' + +function fixture() { + const frame = { send: vi.fn() } + const contents = Object.assign(new EventEmitter(), { + mainFrame: frame, + isDestroyed: () => false, + getURL: vi.fn(() => 'http://127.0.0.1:8188/') + }) + const window = Object.assign(new EventEmitter(), { + isFocused: vi.fn(() => true), + isMinimized: () => false + }) + const entry = { + window, + installationId: 'local-install', + sourceCategory: 'local', + activePanel: 'comfy', + comfyUrl: 'http://127.0.0.1:8188/', + comfyView: { webContents: contents, getVisible: () => true } + } as unknown as ComfyWindowEntry + return { entry, contents, frame, window } +} + +beforeEach(() => { + state.consent = true + state.userId = 'verified-user' + vi.unstubAllEnvs() + vi.mocked(shell.openExternal).mockClear() +}) + +describe('Desktop messaging eligibility', () => { + it('opens a message link externally and prevents the SDK from replacing ComfyUI', () => { + const { entry, contents, frame } = fixture() + const stop = attachCustomerIoMessaging(entry) + const event = { sender: contents, senderFrame: frame, returnValue: false } + ipcMain.emit(CUSTOMER_IO_ACTION, event, 'gist://loadPage?url=https://comfy.org/learn') + expect(event.returnValue).toBe(true) + expect(shell.openExternal).toHaveBeenCalledWith('https://comfy.org/learn') + const navigation = { preventDefault: vi.fn() } + contents.emit('will-navigate', navigation, 'https://comfy.org/learn') + expect(navigation.preventDefault).toHaveBeenCalledOnce() + stop() + ipcMain.emit(CUSTOMER_IO_ACTION, event, 'https://comfy.org/learn') + expect(event.returnValue).toBe(false) + }) + + it('rejects iframe actions and blocks local workflow replacement without opening a link', () => { + const { entry, contents, frame } = fixture() + const stop = attachCustomerIoMessaging(entry) + ipcMain.emit(CUSTOMER_IO_ACTION, { sender: contents, senderFrame: {} }, 'https://comfy.org') + expect(shell.openExternal).not.toHaveBeenCalled() + ipcMain.emit( + CUSTOMER_IO_ACTION, + { sender: contents, senderFrame: frame }, + 'gist://loadPage?url=/other-workflow' + ) + const navigation = { preventDefault: vi.fn() } + contents.emit('will-navigate', navigation, 'http://127.0.0.1:8188/other-workflow') + expect(navigation.preventDefault).toHaveBeenCalledOnce() + expect(shell.openExternal).not.toHaveBeenCalled() + stop() + }) + + it('honors the process kill switch', () => { + vi.stubEnv('COMFY_CUSTOMER_IO_ENABLED', 'false') + expect(customerIoSession(fixture().entry)).toBeNull() + }) + + it('provides the canonical identity and locale only for local ComfyUI', () => { + const { entry } = fixture() + expect(customerIoSession(entry)).toMatchObject({ userId: 'verified-user', locale: 'ja' }) + entry.sourceCategory = 'cloud' + expect(customerIoSession(entry)).toBeNull() + entry.sourceCategory = 'remote' + expect(customerIoSession(entry)).toBeNull() + entry.sourceCategory = 'local' + entry.installationId = null + expect(customerIoSession(entry)).toBeNull() + }) + + it('suppresses denied consent, unresolved identity, other panels, and inactive windows', () => { + const { entry, window } = fixture() + state.consent = false + expect(customerIoSession(entry)).toBeNull() + state.consent = true + state.userId = null + expect(customerIoSession(entry)).toBeNull() + state.userId = 'verified-user' + entry.activePanel = 'feedback' + expect(customerIoSession(entry)).toBeNull() + entry.activePanel = 'comfy' + window.isFocused.mockReturnValue(false) + expect(customerIoSession(entry)).toBeNull() + }) + + it('rejects a navigated document even when the host remains attached', () => { + const { entry, contents } = fixture() + for (const url of [ + 'https://cloud.comfy.org/', + 'http://127.0.0.1:9999/', + 'file:///tmp/page.html' + ]) { + contents.getURL.mockReturnValue(url) + expect(customerIoSession(entry)).toBeNull() + } + }) + + it('revokes on blur, consent changes and navigation; ignores iframe handshakes and cleans up', () => { + const { entry, contents, frame, window } = fixture() + const baseline = customerIoEvents.listenerCount('changed') + const stop = attachCustomerIoMessaging(entry) + contents.emit('ipc-message', { senderFrame: {} }, CUSTOMER_IO_READY) + expect(frame.send).not.toHaveBeenCalled() + contents.emit('ipc-message', { senderFrame: frame }, CUSTOMER_IO_READY) + expect(frame.send).toHaveBeenLastCalledWith( + CUSTOMER_IO_STATE, + expect.objectContaining({ userId: 'verified-user' }) + ) + window.isFocused.mockReturnValue(false) + window.emit('blur') + expect(frame.send).toHaveBeenLastCalledWith(CUSTOMER_IO_STATE, null) + window.isFocused.mockReturnValue(true) + window.emit('focus') + state.consent = false + customerIoEvents.emit('changed') + expect(frame.send).toHaveBeenLastCalledWith(CUSTOMER_IO_STATE, null) + state.consent = true + customerIoEvents.emit('changed') + contents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }) + expect(frame.send).toHaveBeenLastCalledWith(CUSTOMER_IO_STATE, null) + const count = frame.send.mock.calls.length + window.emit('focus') + expect(frame.send).toHaveBeenCalledTimes(count) + stop() + expect(customerIoEvents.listenerCount('changed')).toBe(baseline) + expect(contents.listenerCount('ipc-message')).toBe(0) + expect(entry.refreshCustomerIo).toBeUndefined() + }) +}) diff --git a/src/main/lib/customerIoMessaging.ts b/src/main/lib/customerIoMessaging.ts new file mode 100644 index 000000000..e9c34ab9d --- /dev/null +++ b/src/main/lib/customerIoMessaging.ts @@ -0,0 +1,148 @@ +import { app, ipcMain, shell, type IpcMainEvent, type WebContents } from 'electron' +import type { ComfyWindowEntry } from '../host/registry' +import { + CUSTOMER_IO_DEFAULTS, + CUSTOMER_IO_ACTION, + CUSTOMER_IO_READY, + CUSTOMER_IO_STATE +} from '../../shared/customerIo' +import type { CustomerIoSession } from '../../shared/customerIo' +import { getCustomerIoUserId } from './firebaseAuthIdentity' +import { customerIoEvents } from './customerIoEvents' +import * as settings from '../settings' +import * as i18n from './i18n' +import { isLoopbackOrigin } from './verifiedLocalFirebaseAuth' + +const linkHandlers = new WeakMap void>() +let linkHandlerInstalled = false + +/** Only the attached, focused local ComfyUI document can receive Desktop messages. */ +export function customerIoSession(entry: ComfyWindowEntry): CustomerIoSession | null { + const enabled = process.env.COMFY_CUSTOMER_IO_ENABLED + if (enabled === '0' || enabled === 'false' || (!app.isPackaged && enabled !== 'true')) return null + if (settings.get('telemetryEnabled') !== true) return null + if ( + entry.installationId === null || + entry.sourceCategory !== 'local' || + entry.activePanel !== 'comfy' || + !entry.window.isFocused() || + entry.window.isMinimized() || + !entry.comfyView.getVisible() + ) + return null + const contents = entry.comfyView.webContents + if (contents.isDestroyed()) return null + try { + const url = new URL(contents.getURL()) + if (!isLoopbackOrigin(url.origin) || url.origin !== new URL(entry.comfyUrl).origin) return null + } catch { + return null + } + const userId = getCustomerIoUserId(contents) + if (!userId) return null + return { + userId, + locale: i18n.getLocale(), + writeKey: process.env.COMFY_CUSTOMER_IO_WRITE_KEY || CUSTOMER_IO_DEFAULTS.writeKey, + siteId: process.env.COMFY_CUSTOMER_IO_SITE_ID || CUSTOMER_IO_DEFAULTS.siteId + } +} + +export function attachCustomerIoMessaging(entry: ComfyWindowEntry): () => void { + if (!linkHandlerInstalled) { + ipcMain.on(CUSTOMER_IO_ACTION, (event, action: unknown) => { + // Always answer, including when a retained page has already detached. + event.returnValue = false + linkHandlers.get(event.sender)?.(event, action) + }) + linkHandlerInstalled = true + } + const contents = entry.comfyView.webContents + let ready = false + let lastState = '' + let pendingNavigation: string | null = null + const send = (state: CustomerIoSession | null): void => { + if (!ready || contents.isDestroyed()) return + const serialized = JSON.stringify(state) + if (serialized === lastState) return + try { + // Pin the recipient to the current document, rather than a replacement navigation. + contents.mainFrame.send(CUSTOMER_IO_STATE, state) + lastState = serialized + } catch { + ready = false + lastState = '' + } + } + const refresh = (): void => send(customerIoSession(entry)) + const onReady = (event: IpcMainEvent, channel: string, action?: unknown): void => { + if (channel !== CUSTOMER_IO_READY && channel !== CUSTOMER_IO_ACTION) return + if (channel === CUSTOMER_IO_ACTION) event.returnValue = false + const frame = event.senderFrame + if (!frame || frame !== contents.mainFrame) return + if (channel === CUSTOMER_IO_ACTION) { + if (!customerIoSession(entry) || typeof action !== 'string') return + try { + const navigation = action.startsWith('gist://loadPage?url=') + const url = new URL( + navigation ? action.slice('gist://loadPage?url='.length) : action, + contents.getURL() + ) + // Block workflow replacement even for unsupported relative/local links. + if (navigation) pendingNavigation = url.href + if (!['https:', 'http:', 'mailto:'].includes(url.protocol)) return + if (isLoopbackOrigin(url.origin)) return + // A synchronous acknowledgement installs this guard before the SDK tries + // to navigate ComfyUI away from the user's workflow. + void shell.openExternal(url.href).catch(() => {}) + event.returnValue = true + } catch { + /* Unsupported actions are left to the in-app SDK. */ + } + return + } + ready = true + lastState = '' + refresh() + } + const onNavigation = ( + details: Electron.Event + ): void => { + if (!details.isMainFrame || details.isSameDocument) return + send(null) + ready = false + lastState = '' + } + const onLinkNavigation = (event: Electron.Event, url: string): void => { + if (url !== pendingNavigation) return + pendingNavigation = null + event.preventDefault() + } + const onMessage = (event: IpcMainEvent, channel: string): void => { + if (channel === CUSTOMER_IO_READY) onReady(event, channel) + } + contents.on('ipc-message', onMessage) + linkHandlers.set(contents, (event, action) => onReady(event, CUSTOMER_IO_ACTION, action)) + contents.on('will-navigate', onLinkNavigation) + contents.on('did-start-navigation', onNavigation) + entry.window.on('focus', refresh) + entry.window.on('blur', refresh) + entry.window.on('minimize', refresh) + entry.window.on('restore', refresh) + customerIoEvents.on('changed', refresh) + entry.refreshCustomerIo = refresh + return () => { + send(null) + ready = false + contents.off('ipc-message', onMessage) + linkHandlers.delete(contents) + contents.off('will-navigate', onLinkNavigation) + contents.off('did-start-navigation', onNavigation) + entry.window.off('focus', refresh) + entry.window.off('blur', refresh) + entry.window.off('minimize', refresh) + entry.window.off('restore', refresh) + customerIoEvents.off('changed', refresh) + delete entry.refreshCustomerIo + } +} diff --git a/src/main/lib/firebaseAuthIdentity.test.ts b/src/main/lib/firebaseAuthIdentity.test.ts index 9340ea9ad..981387f53 100644 --- a/src/main/lib/firebaseAuthIdentity.test.ts +++ b/src/main/lib/firebaseAuthIdentity.test.ts @@ -47,6 +47,7 @@ import { activateFirebaseAuthReporter, bindMainVerifiedFirebaseUser, deactivateFirebaseAuthReporter, + getCustomerIoUserId, PENDING_CONSENSUS_DEADLINE_MS, reportFirebaseAuthState as recordFirebaseAuthState, trackFirebaseAuthReporter @@ -200,6 +201,24 @@ describe('firebaseAuthIdentity consensus', () => { verifiedLocalPersistence.succeeds = true }) + it('exposes messaging identity only after local confirmation and revokes it on conflict or navigation', () => { + const local = new FakeWebContents('http://127.0.0.1:8188/') + activate(local) + bindMainVerifiedFirebaseUser('F', {}, local.asWebContents()) + expect(getCustomerIoUserId(local.asWebContents())).toBeNull() + telemetry.isFirebaseConsensusPending.mockReturnValue(false) + reportFirebaseAuthState(local.asWebContents(), { status: 'signed_in', userId: 'F' }) + expect(getCustomerIoUserId(local.asWebContents())).toBe('F') + const cloud = new FakeWebContents(cloudUrl) + activate(cloud) + reportFirebaseAuthState(cloud.asWebContents(), { status: 'signed_in', userId: 'other' }) + expect(getCustomerIoUserId(local.asWebContents())).toBeNull() + reportFirebaseAuthState(cloud.asWebContents(), { status: 'signed_in', userId: 'F' }) + expect(getCustomerIoUserId(local.asWebContents())).toBe('F') + local.startNavigation(local.getURL()) + expect(getCustomerIoUserId(local.asWebContents())).toBeNull() + }) + it('waits for every live trusted reporter before binding one agreed user', () => { const first = new FakeWebContents(cloudUrl) const second = new FakeWebContents(cloudUrl) diff --git a/src/main/lib/firebaseAuthIdentity.ts b/src/main/lib/firebaseAuthIdentity.ts index 14e44c113..41443168d 100644 --- a/src/main/lib/firebaseAuthIdentity.ts +++ b/src/main/lib/firebaseAuthIdentity.ts @@ -3,6 +3,7 @@ import type { ComfyDesktop2FirebaseAuthState } from '../../types/comfyDesktopBri import * as mainTelemetry from './telemetry' import { normalizePostHogUserId } from './opaqueIdentifier' import { isTrustedCloudUrl } from './trustedCloudUrl' +import { customerIoEvents } from './customerIoEvents' import { clearVerifiedLocalFirebaseUser, isLoopbackOrigin, @@ -170,6 +171,7 @@ function detachToAnonymousIdentity(): void { clearPendingConsensusDeadline() requestedUserId = null mainTelemetry.applyFirebaseAnonymousConsensus() + customerIoEvents.emit('changed') } function requestAnonymousIdentity(): void { @@ -196,6 +198,7 @@ function requestPendingIdentity(): void { // evidence of sign-out, so release the quarantine and keep the bound // identity. A real report still resolves through reconcile as usual. mainTelemetry.releaseFirebasePendingConsensus() + customerIoEvents.emit('changed') mainTelemetry.capture('comfy.desktop.identity.pending_consensus_expired') } }, PENDING_CONSENSUS_DEADLINE_MS) @@ -300,7 +303,31 @@ export function bindMainVerifiedFirebaseUser( reconcile() } +/** A local page must affirm the verified user and agree with all other auth reporters. */ +export function getCustomerIoUserId(webContents: WebContents): string | null { + const reporter = reporters.get(webContents) + if ( + !requestedUserId || + mainTelemetry.isFirebaseConsensusPending() || + !reporter?.eligible || + !reporter.active || + !reporter.localReportingAuthorized || + reporter.awaitingCommittedFrame || + reporter.mainFrameNavigationsInFlight > 0 || + reporter.state.status !== 'signed_in' || + reporter.state.userId !== requestedUserId || + reporter.localExpectedUserId !== requestedUserId + ) + return null + return requestedUserId +} + function reconcile(): void { + reconcileIdentity() + customerIoEvents.emit('changed') +} + +function reconcileIdentity(): void { let expiredPendingContributors = 0 const activeReporterStates: ComfyDesktop2FirebaseAuthState[] = [] for (const [webContents, reporter] of reporters) { diff --git a/src/main/lib/ipc/registerSettingsHandlers.ts b/src/main/lib/ipc/registerSettingsHandlers.ts index fbed90545..9e6dfb5e1 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 { customerIoEvents } from '../customerIoEvents' import { recordIpcInvocation } from '../e2eOverrides' import type { SettingsSection } from '../../../types/ipc' import { AUTO_LAUNCH_LAST, AUTO_LAUNCH_NONE } from '../../settings' @@ -292,6 +293,7 @@ export function applySettingSet(key: string, value: unknown): void { value === true ? 'granted' : value === false ? 'denied' : 'undecided' mainTelemetry.setConsentState(state) } + if (key === 'telemetryEnabled' || key === 'language') customerIoEvents.emit('changed') if (key === 'autoInstallUpdates' || key === 'autoUpdate') { // Re-broadcast so a pending 'ready' immediately reads as auto-on/off. updater.notifyAutoUpdateChanged() diff --git a/src/preload/comfyPreload.test.ts b/src/preload/comfyPreload.test.ts index ad3e3fa41..cb415bc4b 100644 --- a/src/preload/comfyPreload.test.ts +++ b/src/preload/comfyPreload.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +vi.mock('./customerIoPreload', () => ({ startCustomerIoMessaging: vi.fn() })) + const mocks = vi.hoisted(() => ({ exposeInMainWorld: vi.fn(), invoke: vi.fn(), diff --git a/src/preload/comfyPreload.ts b/src/preload/comfyPreload.ts index f510b15fd..e03a93b66 100644 --- a/src/preload/comfyPreload.ts +++ b/src/preload/comfyPreload.ts @@ -11,6 +11,7 @@ import type { TerminalRestore } from '../types/comfyDesktopBridge' import { startLocalFirebaseAuthMonitor } from './localFirebaseAuthMonitor' +import { startCustomerIoMessaging } from './customerIoPreload' export type LegacyTerminalBridge = ComfyDesktop2TerminalBridge & { restore(): Promise @@ -94,6 +95,7 @@ const Telemetry: ComfyDesktop2TelemetryBridge = { } startLocalFirebaseAuthMonitor(reportFirebaseAuthState) +startCustomerIoMessaging() const bridge = { isRemote: (): boolean => ipcRenderer.sendSync('desktop2-is-remote') as boolean, diff --git a/src/preload/customerIoPreload.ts b/src/preload/customerIoPreload.ts new file mode 100644 index 000000000..3af457718 --- /dev/null +++ b/src/preload/customerIoPreload.ts @@ -0,0 +1,32 @@ +import { contextBridge, ipcRenderer, webFrame } from 'electron' +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. */ +export function startCustomerIoMessaging(): void { + let bridgeInstalled = false + let installed = false + ipcRenderer.on(CUSTOMER_IO_STATE, (_event, session: CustomerIoSession | null) => { + if (!installed && !session) return + let code = '' + if (!installed) { + if (!bridgeInstalled) { + contextBridge.exposeInMainWorld('__comfyCustomerIoOpenLink', (action: string) => + ipcRenderer.sendSync(CUSTOMER_IO_ACTION, action) + ) + bridgeInstalled = true + } + code = script + installed = true + } + code += `\nglobalThis.__comfyCustomerIo.update(${JSON.stringify(session)});` + void webFrame.executeJavaScript(code).catch(() => { + installed = false + console.warn('Desktop messaging could not initialize') + }) + }) + window.addEventListener('DOMContentLoaded', () => ipcRenderer.send(CUSTOMER_IO_READY), { + once: true + }) +} diff --git a/src/preload/customerIoScript.d.ts b/src/preload/customerIoScript.d.ts new file mode 100644 index 000000000..abf130f52 --- /dev/null +++ b/src/preload/customerIoScript.d.ts @@ -0,0 +1,4 @@ +declare module 'virtual:customerio-script' { + const source: string + export default source +} diff --git a/src/renderer/src/customerIo/controller.test.ts b/src/renderer/src/customerIo/controller.test.ts new file mode 100644 index 000000000..22c0ade78 --- /dev/null +++ b/src/renderer/src/customerIo/controller.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest' +import { createMessagingController, type MessagingClient } from './controller' +import type { CustomerIoSession } from '../../../shared/customerIo' + +const session: CustomerIoSession = { + userId: 'user-a', + locale: 'en', + writeKey: 'test', + siteId: 'test' +} +function client(): MessagingClient { + return { + identify: vi.fn(async () => {}), + page: vi.fn(async () => {}), + reset: vi.fn(async () => {}), + dismiss: vi.fn() + } +} +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +describe('Desktop messaging lifecycle', () => { + it('does not load for an ineligible session and identifies before reporting the page', async () => { + const sdk = client() + const load = vi.fn(async () => sdk) + const controller = createMessagingController(load) + await controller.update(null) + expect(load).not.toHaveBeenCalled() + await controller.update(session) + await controller.update({ ...session }) + expect(load).toHaveBeenCalledTimes(1) + expect(sdk.identify).toHaveBeenCalledExactlyOnceWith(session) + expect(vi.mocked(sdk.identify).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(sdk.page).mock.invocationCallOrder[0]! + ) + }) + + it('never identifies a user who signs out while the SDK is loading', async () => { + const loading = deferred() + const sdk = client() + const load = vi.fn(() => loading.promise) + const controller = createMessagingController(load) + const first = controller.update(session) + await Promise.resolve() + expect(load).toHaveBeenCalledOnce() + const logout = controller.update(null) + loading.resolve(sdk) + await Promise.all([first, logout]) + expect(sdk.identify).not.toHaveBeenCalled() + expect(sdk.page).not.toHaveBeenCalled() + expect(sdk.reset).toHaveBeenCalledOnce() + }) + + it('hides the message immediately on logout while identify is pending', async () => { + const sdk = client() + const controller = createMessagingController(async () => sdk) + await controller.update(session) + const identifying = deferred() + const started = deferred() + vi.mocked(sdk.identify).mockImplementationOnce(() => { + started.resolve() + return identifying.promise + }) + const changing = controller.update({ ...session, locale: 'ja' }) + await started.promise + const logout = controller.update(null) + expect(sdk.dismiss).toHaveBeenCalled() + identifying.resolve() + await Promise.all([changing, logout]) + expect(sdk.page).toHaveBeenCalledTimes(1) + await controller.update(session) + expect(sdk.identify).toHaveBeenLastCalledWith(session) + expect(sdk.page).toHaveBeenCalledTimes(2) + }) + + it('resets between accounts and exposes only the newest identity to event filtering', async () => { + const sdk = client() + let current!: () => CustomerIoSession | null + const controller = createMessagingController(async (_session, getSession) => { + current = getSession + return sdk + }) + await controller.update(session) + const second = { ...session, userId: 'user-b' } + const changing = controller.update(second) + expect(current()).toEqual(second) + await changing + expect(sdk.reset).toHaveBeenCalledTimes(2) + expect(sdk.identify).toHaveBeenLastCalledWith(second) + const logout = controller.update(null) + expect(current()).toBeNull() + await logout + }) + + it('recovers from an unavailable SDK on the next activation', async () => { + const sdk = client() + const load = vi + .fn[0]>() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValue(sdk) + const report = vi.fn() + const controller = createMessagingController(load, report) + await controller.update(session) + expect(report).toHaveBeenCalledOnce() + await controller.update(session) + expect(sdk.page).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/customerIo/controller.ts b/src/renderer/src/customerIo/controller.ts new file mode 100644 index 000000000..79887c771 --- /dev/null +++ b/src/renderer/src/customerIo/controller.ts @@ -0,0 +1,74 @@ +import type { CustomerIoSession } from '../../../shared/customerIo' + +export interface MessagingClient { + identify(session: CustomerIoSession): Promise + page(): Promise + reset(): Promise + dismiss(): void +} + +async function bounded(operation: Promise): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('Customer.io operation timed out')), 10_000) + }) + ]) + } finally { + clearTimeout(timer) + } +} + +/** Serializes identity changes while rejecting work queued for an obsolete session. */ +export function createMessagingController( + load: ( + session: CustomerIoSession, + currentSession: () => CustomerIoSession | null + ) => Promise, + reportError: (error: unknown) => void = (error) => + console.warn('Desktop messaging is unavailable', error) +): { update: (session: CustomerIoSession | null) => Promise } { + let desired: CustomerIoSession | null = null + let client: MessagingClient | null = null + let identified: CustomerIoSession | null = null + let version = 0 + let queue = Promise.resolve() + function update(session: CustomerIoSession | null): Promise { + if (JSON.stringify(desired) === JSON.stringify(session)) return queue + desired = session + const revision = ++version + // Hide a previous user's message immediately, even if an SDK operation is pending. + if (!session || identified?.userId !== session.userId) client?.dismiss() + queue = queue + .then(async () => { + if (revision !== version) return + if (!session) { + if (client) await bounded(client.reset()) + identified = null + return + } + client ??= await bounded(load(session, () => desired)) + if (revision !== version) return + if (identified?.userId !== session.userId) { + await bounded(client.reset()) + identified = null + } + if (revision !== version) return + await bounded(client.identify(session)) + if (revision !== version) return + identified = session + await bounded(client.page()) + }) + .catch((error: unknown) => { + client?.dismiss() + identified = null + // A later focus, auth report, or online event can retry this same session. + if (revision === version) desired = null + reportError(error) + }) + return queue + } + return { update } +} diff --git a/src/renderer/src/customerIo/index.ts b/src/renderer/src/customerIo/index.ts new file mode 100644 index 000000000..826b939c4 --- /dev/null +++ b/src/renderer/src/customerIo/index.ts @@ -0,0 +1,110 @@ +import { AnalyticsBrowser, InAppPlugin } from '@customerio/cdp-analytics-browser' +import Gist from 'customerio-gist-web' +import type { CustomerIoSession } from '../../../shared/customerIo' +import { createMessagingController } from './controller' + +const PAGE = 'desktop/local-workflow' + +const controller = createMessagingController(async (session, currentSession) => { + const analytics = AnalyticsBrowser.load( + { writeKey: session.writeKey }, + { + user: { persist: false }, + group: { persist: false }, + retryQueue: false, + integrations: { + All: false, + 'Customer.io Data Pipelines': true, + // Register below with Desktop's lifecycle callbacks, even if the source + // settings also enable automatic in-app initialization. + 'Customer.io In-App Plugin': { enabled: false } + } + } + ) + await analytics.addSourceMiddleware(({ payload, next }) => { + const current = currentSession() + if (!current || payload.obj.userId !== current.userId) return + // A desktop file path or local workflow URL is not a useful campaign page. + payload.obj.context = { + ...payload.obj.context, + page: { + path: PAGE, + url: PAGE, + title: 'ComfyUI Desktop', + referrer: '', + search: '' + } + } + next(payload) + }) + await analytics.register( + InAppPlugin({ + siteId: session.siteId, + anonymousInApp: false, + _env: undefined, + _logging: undefined, + colorScheme: 'auto', + events: (event) => { + if (event.type !== 'in-app:message-action' || !currentSession()) return + const detail = (event as CustomEvent<{ actionValue?: string }>).detail + if (detail?.actionValue) messagingGlobal.__comfyCustomerIoOpenLink?.(detail.actionValue) + } + }) + ) + // The SDK also writes this flag from visibilitychange. Keep Desktop's + // consent/focus gate authoritative when Chromium changes visibility later. + Object.defineProperty(Gist, 'isDocumentVisible', { + configurable: true, + get: () => currentSession() !== null && document.visibilityState !== 'hidden', + set: () => {} + }) + let dismissal = Promise.resolve() + return { + identify: async (identity) => { + Gist.setUserLocale(identity.locale) + await analytics.identify(identity.userId, { locale: identity.locale }) + }, + page: async () => { + await analytics.page(PAGE, { url: PAGE, path: PAGE, title: 'ComfyUI Desktop' }) + }, + reset: async () => { + await dismissal + await analytics.reset() + await Gist.clearUserToken() + }, + dismiss: () => { + // Clear the queue token and SSE connection without waiting for an in-flight + // analytics operation. Its old-identity events are also filtered above. + const clearing = Gist.clearUserToken() + const messages = [...Gist.currentMessages] + for (const message of messages) { + const element = document.getElementById(`gist-${message.instanceId}`) + if (element) element.style.visibility = 'hidden' + } + dismissal = Promise.all([ + clearing, + ...messages.map((message) => + message.instanceId ? Gist.dismissMessage(message.instanceId) : Promise.resolve() + ) + ]).then( + () => {}, + () => {} + ) + } + } +}) + +const messagingGlobal = globalThis as typeof globalThis & { + __comfyCustomerIo: { update: (session: CustomerIoSession | null) => Promise } + __comfyCustomerIoOpenLink?: (action: string) => boolean +} +let lastSession: CustomerIoSession | null = null +messagingGlobal.__comfyCustomerIo = { + update(session) { + lastSession = session + return controller.update(session) + } +} +window.addEventListener('online', () => { + void controller.update(lastSession) +}) diff --git a/src/shared/customerIo.ts b/src/shared/customerIo.ts new file mode 100644 index 000000000..8b93169c8 --- /dev/null +++ b/src/shared/customerIo.ts @@ -0,0 +1,16 @@ +/** Public browser ingest configuration; these are not Customer.io App API secrets. */ +export const CUSTOMER_IO_DEFAULTS = { + writeKey: '9cfddc92b9ca1ff1f64b', + siteId: 'f87746f8c188c8ddcf41' +} + +export const CUSTOMER_IO_READY = 'customerio:ready' +export const CUSTOMER_IO_STATE = 'customerio:state' +export const CUSTOMER_IO_ACTION = 'customerio:action' + +export interface CustomerIoSession { + userId: string + locale: string + writeKey: string + siteId: string +} diff --git a/tsconfig.node.json b/tsconfig.node.json index 410a83990..be0b991b7 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -18,6 +18,7 @@ }, "include": [ "electron.vite.config.*", + "scripts/customerio-script.ts", "src/main/**/*", "src/preload/**/*", "src/types/**/*", diff --git a/vitest.config.ts b/vitest.config.ts index fdcb85aef..6c8fd0fc9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,10 @@ import { resolve } from 'path' import { defineConfig } from 'vitest/config' import vue from '@vitejs/plugin-vue' +import { customerIoScriptPlugin } from './scripts/customerio-script' export default defineConfig({ - plugins: [vue()], + plugins: [vue(), customerIoScriptPlugin()], resolve: { alias: { '@renderer': resolve(__dirname, 'src/renderer/src'), From 61261b27f136256636f748467fbd7d5c9e4c473d Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 15 Sep 2026 19:48:11 -0700 Subject: [PATCH 2/6] Use the Linux CI launch options in the messaging fixture --- e2e/customerio.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/customerio.test.ts b/e2e/customerio.test.ts index 74bc91bce..c7da2dc6c 100644 --- a/e2e/customerio.test.ts +++ b/e2e/customerio.test.ts @@ -32,7 +32,9 @@ app.whenReady().then(() => { ipcMain.on('customerio:action', event => { event.returnValue = false }) })` ) - app = await _electron.launch({ args: [main] }) + // 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 }[] = [] const errors: string[] = [] From 820ed10dffb016ceb61112a083d5cff444804cf5 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 15 Sep 2026 19:52:05 -0700 Subject: [PATCH 3/6] Release workflow input immediately when messaging is revoked --- e2e/customerio.test.ts | 34 ++++++++++++++++++++++++++-- src/renderer/src/customerIo/index.ts | 3 +++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/e2e/customerio.test.ts b/e2e/customerio.test.ts index c7da2dc6c..0eed25f36 100644 --- a/e2e/customerio.test.ts +++ b/e2e/customerio.test.ts @@ -17,6 +17,12 @@ test('Desktop SDK renders, dismisses, and revokes messages @macos @windows @linu 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((resolve) => { + releaseViewLog = resolve + }) try { const main = join(directory, 'main.cjs') await writeFile( @@ -105,7 +111,10 @@ document.getElementById('close').onclick = () => parent.postMessage({gist:{insta properties: { gist: { campaignId: `fixture-delivery-${delivery}`, - routeRuleWeb: 'desktop/local-workflow' + routeRuleWeb: 'desktop/local-workflow', + persistent: + request.headers()['x-gist-encoded-user-token'] === + Buffer.from('second-test-user').toString('base64') } } } @@ -114,6 +123,10 @@ document.getElementById('close').onclick = () => parent.postMessage({gist:{insta } }) } + 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: {} }) }) @@ -149,9 +162,25 @@ document.getElementById('close').onclick = () => parent.postMessage({gist:{insta 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(page.locator('#gist-overlay')).toHaveCount(0) + 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) @@ -166,6 +195,7 @@ document.getElementById('close').onclick = () => parent.postMessage({gist:{insta 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 }) } diff --git a/src/renderer/src/customerIo/index.ts b/src/renderer/src/customerIo/index.ts index 826b939c4..4982440dc 100644 --- a/src/renderer/src/customerIo/index.ts +++ b/src/renderer/src/customerIo/index.ts @@ -79,6 +79,9 @@ const controller = createMessagingController(async (session, currentSession) => const messages = [...Gist.currentMessages] for (const message of messages) { const element = document.getElementById(`gist-${message.instanceId}`) + // Persistent-message dismissal waits for a view-log request. Remove its + // modal backdrop synchronously so revoked messages cannot block input. + element?.closest('#gist-overlay')?.remove() if (element) element.style.visibility = 'hidden' } dismissal = Promise.all([ From 1092faed3b8cc4e6696b173a2b474192b26c07ea Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 15 Sep 2026 20:00:00 -0700 Subject: [PATCH 4/6] Keep local query and referrer fields out of messaging events --- e2e/customerio.test.ts | 15 +++++++++++++-- src/renderer/src/customerIo/index.ts | 8 +++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/e2e/customerio.test.ts b/e2e/customerio.test.ts index 0eed25f36..b00bdaea7 100644 --- a/e2e/customerio.test.ts +++ b/e2e/customerio.test.ts @@ -131,7 +131,9 @@ document.getElementById('close').onclick = () => parent.postMessage({gist:{insta return route.fulfill({ json: {} }) }) - await page.goto('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', { + 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 => { @@ -189,7 +191,16 @@ document.getElementById('close').onclick = () => parent.postMessage({gist:{insta /^https:\/\/cdp\.customer\.io\/v1\/[ipt]$/.test(url) ) expect(events.some(({ body }) => body?.includes('"name":"desktop/local-workflow"'))).toBe(true) - expect(events.every(({ body }) => !body?.includes('private-workflow-name'))).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) diff --git a/src/renderer/src/customerIo/index.ts b/src/renderer/src/customerIo/index.ts index 4982440dc..88dde51bf 100644 --- a/src/renderer/src/customerIo/index.ts +++ b/src/renderer/src/customerIo/index.ts @@ -65,7 +65,13 @@ const controller = createMessagingController(async (session, currentSession) => await analytics.identify(identity.userId, { locale: identity.locale }) }, page: async () => { - await analytics.page(PAGE, { url: PAGE, path: PAGE, title: 'ComfyUI Desktop' }) + await analytics.page(PAGE, { + url: PAGE, + path: PAGE, + title: 'ComfyUI Desktop', + search: '', + referrer: '' + }) }, reset: async () => { await dismissal From 5c5f4079410624ed35c10bb31eba780664d8df92 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 15 Sep 2026 20:32:18 -0700 Subject: [PATCH 5/6] Fix lifecycle view test teardown --- src/renderer/src/panel/ComfyLifecycleView.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/panel/ComfyLifecycleView.test.ts b/src/renderer/src/panel/ComfyLifecycleView.test.ts index d9c6747a3..57b6ab94b 100644 --- a/src/renderer/src/panel/ComfyLifecycleView.test.ts +++ b/src/renderer/src/panel/ComfyLifecycleView.test.ts @@ -1,5 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { mount, flushPromises } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, mount, flushPromises } from '@vue/test-utils' import { createI18n } from 'vue-i18n' import { createPinia, setActivePinia } from 'pinia' @@ -7,6 +7,10 @@ import ComfyLifecycleView from './ComfyLifecycleView.vue' import { useSessionStore } from '../stores/sessionStore' import type { Installation } from '../types/ipc' +// Run component cleanup before happy-dom removes window; the unknown-state +// placeholder timer must not outlive the view that scheduled it. +enableAutoUnmount(afterEach) + const messages = { en: { common: { From b40662141b13d40be294f85fd71e45eb72106586 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 15 Sep 2026 20:45:53 -0700 Subject: [PATCH 6/6] Keep messaging fixture deliveries stable across queue polls --- e2e/customerio.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/e2e/customerio.test.ts b/e2e/customerio.test.ts index b00bdaea7..4800ff6e7 100644 --- a/e2e/customerio.test.ts +++ b/e2e/customerio.test.ts @@ -48,7 +48,7 @@ app.whenReady().then(() => { page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()) }) - let delivery = 0 + const deliveries = new Map() await app.context().route('**/*', async (route) => { const request = route.request() const url = request.url() @@ -96,7 +96,12 @@ document.getElementById('close').onclick = () => parent.postMessage({gist:{insta }) } if (url.includes('/api/v4/users')) { - delivery += 1 + 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', @@ -155,6 +160,9 @@ document.getElementById('close').onclick = () => parent.postMessage({gist:{insta 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)