From 4f20255148bc85cfb4156c06c51aff32f6d69256 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 15 Sep 2026 20:19:48 -0700 Subject: [PATCH] Coordinate Customer.io messaging across Desktop window surfaces --- src/main/host/attach.ts | 4 +- src/main/host/createHostWindow.ts | 4 + src/main/host/panelView.ts | 4 +- src/main/index.ts | 2 +- src/main/lib/customerIoDocument.ts | 100 ++++++++ src/main/lib/customerIoMessaging.test.ts | 185 ++++++++++++++- src/main/lib/customerIoMessaging.ts | 227 +++++++++++-------- src/main/lib/ipc/registerSettingsHandlers.ts | 4 +- 8 files changed, 429 insertions(+), 101 deletions(-) create mode 100644 src/main/lib/customerIoDocument.ts diff --git a/src/main/host/attach.ts b/src/main/host/attach.ts index 7c594add1..35e174034 100644 --- a/src/main/host/attach.ts +++ b/src/main/host/attach.ts @@ -1,5 +1,4 @@ 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' @@ -173,7 +172,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) : () => {} + entry.refreshCustomerIo?.() // Seed the MRU tracker if this in-place attach happens on the // already-focused host: no fresh OS `'focus'` event would fire to @@ -665,7 +664,6 @@ 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 ae1cbf418..03b712bfd 100644 --- a/src/main/host/createHostWindow.ts +++ b/src/main/host/createHostWindow.ts @@ -34,6 +34,7 @@ import { import * as mainTelemetry from '../lib/telemetry' import { getUserTier } from '../lib/userTier' import { trackFirebaseAuthReporter } from '../lib/firebaseAuthIdentity' +import { attachCustomerIoMessaging } from '../lib/customerIoMessaging' import { forwardDatadogError } from '../lib/processErrorHandlers' import { recordDashboardSurface, recordInstanceSurface } from '../lib/lastSession' import * as settings from '../settings' @@ -1025,6 +1026,8 @@ export function createHostWindow(opts: CreateHostWindowOpts): CreateHostWindowRe // reference, not by a copy at literal-build time. entry.detachInstall = () => fx.detachInstallImpl(entry) registerHostEntry(entry) + const stopMessaging = attachCustomerIoMessaging(entry) + comfyWindow.once('closed', stopMessaging) return { windowKey, comfyWindow, titleBarView, comfyView, entry, layoutViews } } @@ -1219,6 +1222,7 @@ export function rebuildComfyViewIfNeeded( entry.window.contentView.removeChildView(oldView) if (!oldView.webContents.isDestroyed()) oldView.webContents.close() entry.comfyView = newView + entry.refreshCustomerIo?.() entry.constructedPartition = expectedPartition } diff --git a/src/main/host/panelView.ts b/src/main/host/panelView.ts index e8f016e01..e8f5d3fec 100644 --- a/src/main/host/panelView.ts +++ b/src/main/host/panelView.ts @@ -67,6 +67,8 @@ export function ensurePanelView( // Insert at zero size, behind the comfy view; layoutViews handles positioning. panelView.setBounds({ x: 0, y: TITLEBAR_HEIGHT + 1, width: 0, height: 0 }) panelView.setVisible(false) + entry.panelView = panelView + entry.refreshCustomerIo?.() // Push the latest body mode (may differ from initialPanel) and steal focus if focused. panelView.webContents.once('did-finish-load', () => { @@ -109,7 +111,6 @@ export function ensurePanelView( void loadPromise.catch(() => {}) _registerExtraBroadcastTarget(panelView.webContents) - entry.panelView = panelView return panelView } @@ -123,6 +124,7 @@ export function destroyPanelView(entry: ComfyWindowEntry): void { if (!entry.panelView) return const oldPanel = entry.panelView entry.panelView = null + entry.refreshCustomerIo?.() if (!oldPanel.webContents.isDestroyed()) { _unregisterExtraBroadcastTarget(oldPanel.webContents) oldPanel.webContents.close() diff --git a/src/main/index.ts b/src/main/index.ts index 35af5b114..5fad0bea7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -835,12 +835,12 @@ ipcMain.on('comfy-window:set-first-use-mode', (event, payload: { mode: unknown } for (const entry of comfyWindows.values()) { if (entry.panelView?.webContents === event.sender) { entry.firstUseMode = mode + entry.refreshCustomerIo?.() if (!entry.titleBarView.webContents.isDestroyed()) { entry.titleBarView.webContents.send('comfy-titlebar:first-use-mode-changed', mode) } return } - return } }) diff --git a/src/main/lib/customerIoDocument.ts b/src/main/lib/customerIoDocument.ts new file mode 100644 index 000000000..802921421 --- /dev/null +++ b/src/main/lib/customerIoDocument.ts @@ -0,0 +1,100 @@ +import { ipcMain, shell, type IpcMainEvent, type WebContents } from 'electron' +import { CUSTOMER_IO_ACTION, CUSTOMER_IO_READY, CUSTOMER_IO_STATE } from '../../shared/customerIo' +import type { CustomerIoSession } from '../../shared/customerIo' +import { isLoopbackOrigin } from './verifiedLocalFirebaseAuth' + +const linkHandlers = new WeakMap void>() +let linkHandlerInstalled = false + +export function attachCustomerIoDocument( + contents: WebContents, + getSession: () => CustomerIoSession | null, + documentChanged: () => void +): { refresh: () => void; dispose: () => void; isReady: () => boolean } { + 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 + } + 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(getSession()) + 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 (!ready || !getSession() || 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 = '' + documentChanged() + refresh() + } + const onNavigation = ( + details: Electron.Event + ): void => { + if (!details.isMainFrame || details.isSameDocument) return + send(null) + ready = false + lastState = '' + documentChanged() + } + 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) + const dispose = (): void => { + send(null) + ready = false + contents.off('ipc-message', onMessage) + linkHandlers.delete(contents) + contents.off('will-navigate', onLinkNavigation) + contents.off('did-start-navigation', onNavigation) + } + return { refresh, dispose, isReady: () => ready } +} diff --git a/src/main/lib/customerIoMessaging.test.ts b/src/main/lib/customerIoMessaging.test.ts index e93a07c28..4404735df 100644 --- a/src/main/lib/customerIoMessaging.test.ts +++ b/src/main/lib/customerIoMessaging.test.ts @@ -1,9 +1,26 @@ import { EventEmitter } from 'node:events' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, 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 })) +const state = vi.hoisted(() => ({ + consent: true, + userId: 'verified-user' as string | null, + getIdentity: vi.fn(), + onAuthChanged: vi.fn() +})) +vi.mock('../devplatform/session', () => ({ + getCloudSession: () => ({ + getUserIdentity: state.getIdentity, + onAuthChanged: state.onAuthChanged + }) +})) +vi.mock('../host/registry', () => ({ + computeBodyMode: (entry: ComfyWindowEntry) => + entry.installationId === null && entry.activePanel === 'comfy' ? 'chooser' : entry.activePanel +})) vi.mock('electron', async () => { const { EventEmitter } = await import('node:events') return { @@ -32,7 +49,9 @@ function fixture() { getURL: vi.fn(() => 'http://127.0.0.1:8188/') }) const window = Object.assign(new EventEmitter(), { + isDestroyed: () => false, isFocused: vi.fn(() => true), + isVisible: () => true, isMinimized: () => false }) const entry = { @@ -40,8 +59,10 @@ function fixture() { installationId: 'local-install', sourceCategory: 'local', activePanel: 'comfy', + firstUseMode: 'none', + panelView: null, comfyUrl: 'http://127.0.0.1:8188/', - comfyView: { webContents: contents, getVisible: () => true } + comfyView: { webContents: contents, getVisible: vi.fn(() => true) } } as unknown as ComfyWindowEntry return { entry, contents, frame, window } } @@ -51,12 +72,167 @@ beforeEach(() => { state.userId = 'verified-user' vi.unstubAllEnvs() vi.mocked(shell.openExternal).mockClear() + state.getIdentity + .mockReset() + .mockResolvedValue({ userId: 'canonical-person', firebaseUid: 'launcher-person' }) + state.onAuthChanged.mockReset().mockReturnValue(() => {}) }) +const cleanups: (() => void)[] = [] +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup() +}) + +function launcherFixture() { + const fixtureEntry = fixture() + const panelFrame = { send: vi.fn() } + const panelContents = Object.assign(new EventEmitter(), { + mainFrame: panelFrame, + isDestroyed: () => false, + getURL: vi.fn(() => pathToFileURL(join(__dirname, '../renderer/panel.html')).href) + }) + const panelVisible = vi.fn(() => true) + fixtureEntry.entry.panelView = { + webContents: panelContents, + getVisible: panelVisible + } as unknown as ComfyWindowEntry['panelView'] + fixtureEntry.entry.installationId = null + fixtureEntry.entry.sourceCategory = null + vi.mocked(fixtureEntry.entry.comfyView.getVisible).mockReturnValue(false) + const stop = attachCustomerIoMessaging(fixtureEntry.entry) + cleanups.push(stop) + const ready = () => + panelContents.emit('ipc-message', { senderFrame: panelFrame }, CUSTOMER_IO_READY) + return { ...fixtureEntry, panelContents, panelFrame, panelVisible, ready } +} + describe('Desktop messaging eligibility', () => { + it('waits for the launcher document and uses only the server-confirmed Firebase UID', async () => { + const { panelContents, panelFrame, ready } = launcherFixture() + expect(state.getIdentity).not.toHaveBeenCalled() + panelContents.emit('ipc-message', { senderFrame: {} }, CUSTOMER_IO_READY) + expect(state.getIdentity).not.toHaveBeenCalled() + ready() + expect(panelFrame.send).toHaveBeenLastCalledWith(CUSTOMER_IO_STATE, null) + await Promise.resolve() + expect(panelFrame.send).toHaveBeenLastCalledWith( + CUSTOMER_IO_STATE, + expect.objectContaining({ + page: 'desktop/launcher', + userId: 'launcher-person' + }) + ) + expect(state.getIdentity).toHaveBeenCalledOnce() + }) + + it('keeps a legacy OAuth session without Firebase provenance ineligible', async () => { + state.getIdentity.mockResolvedValue({ userId: 'canonical-person' }) + const { panelFrame, ready } = launcherFixture() + ready() + await Promise.resolve() + expect(panelFrame.send.mock.calls).toEqual([[CUSTOMER_IO_STATE, null]]) + }) + + it('ignores a late launcher identity after consent is revoked', async () => { + let resolve!: (identity: { firebaseUid: string }) => void + state.getIdentity.mockReturnValue( + new Promise((done) => { + resolve = done + }) + ) + const { panelFrame, ready } = launcherFixture() + ready() + state.consent = false + customerIoEvents.emit('changed') + resolve({ firebaseUid: 'old-person' }) + await Promise.resolve() + expect(panelFrame.send.mock.calls).toEqual([[CUSTOMER_IO_STATE, null]]) + }) + + it('revokes launcher identity synchronously on auth changes and rejects the old response', async () => { + const { panelFrame, ready } = launcherFixture() + ready() + await Promise.resolve() + let resolve!: (identity: { firebaseUid: string }) => void + state.getIdentity.mockReturnValueOnce( + new Promise((done) => { + resolve = done + }) + ) + const authChanged = state.onAuthChanged.mock.calls[0]![0] as () => void + authChanged() + expect(panelFrame.send).toHaveBeenLastCalledWith(CUSTOMER_IO_STATE, null) + state.getIdentity.mockResolvedValue(null) + authChanged() + resolve({ firebaseUid: 'stale-person' }) + await Promise.resolve() + expect(panelFrame.send.mock.calls.filter(([, state]) => state !== null)).toHaveLength(1) + }) + + it('hands messaging from the launcher to visible local ComfyUI without granting both', async () => { + const { entry, contents, frame, panelFrame, panelVisible, ready } = launcherFixture() + ready() + await Promise.resolve() + contents.emit('ipc-message', { senderFrame: frame }, CUSTOMER_IO_READY) + entry.installationId = 'local-install' + entry.sourceCategory = 'local' + panelVisible.mockReturnValue(false) + vi.mocked(entry.comfyView.getVisible).mockReturnValue(true) + entry.refreshCustomerIo!() + expect(panelFrame.send).toHaveBeenLastCalledWith(CUSTOMER_IO_STATE, null) + expect(frame.send).toHaveBeenLastCalledWith( + CUSTOMER_IO_STATE, + expect.objectContaining({ + page: 'desktop/comfyui', + userId: 'verified-user' + }) + ) + expect(panelFrame.send.mock.invocationCallOrder.at(-1)).toBeLessThan( + frame.send.mock.invocationCallOrder.at(-1)! + ) + entry.sourceCategory = 'cloud' + entry.refreshCustomerIo!() + expect(frame.send).toHaveBeenLastCalledWith(CUSTOMER_IO_STATE, null) + }) + + it('suppresses launcher takeovers, hidden views and replaced documents', async () => { + const { entry, panelContents, panelFrame, panelVisible, ready } = launcherFixture() + entry.firstUseMode = 'post-consent' + ready() + expect(state.getIdentity).not.toHaveBeenCalled() + entry.firstUseMode = 'none' + panelVisible.mockReturnValue(false) + entry.refreshCustomerIo!() + expect(state.getIdentity).not.toHaveBeenCalled() + panelVisible.mockReturnValue(true) + entry.refreshCustomerIo!() + await Promise.resolve() + expect(panelFrame.send).toHaveBeenLastCalledWith( + CUSTOMER_IO_STATE, + expect.objectContaining({ userId: 'launcher-person' }) + ) + entry.panelView = null + entry.refreshCustomerIo!() + expect(panelFrame.send).toHaveBeenLastCalledWith(CUSTOMER_IO_STATE, null) + expect(panelContents.listenerCount('ipc-message')).toBe(0) + }) + + it('rejects a launcher document that navigates outside the bundled panel', async () => { + const { panelContents, panelFrame, ready } = launcherFixture() + ready() + await Promise.resolve() + panelContents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }) + panelContents.getURL.mockReturnValue('https://cloud.comfy.org/panel.html') + ready() + await Promise.resolve() + expect(panelFrame.send).toHaveBeenLastCalledWith(CUSTOMER_IO_STATE, null) + expect(state.getIdentity).toHaveBeenCalledOnce() + }) + it('opens a message link externally and prevents the SDK from replacing ComfyUI', () => { const { entry, contents, frame } = fixture() const stop = attachCustomerIoMessaging(entry) + contents.emit('ipc-message', { senderFrame: frame }, CUSTOMER_IO_READY) 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) @@ -72,6 +248,7 @@ describe('Desktop messaging eligibility', () => { it('rejects iframe actions and blocks local workflow replacement without opening a link', () => { const { entry, contents, frame } = fixture() const stop = attachCustomerIoMessaging(entry) + contents.emit('ipc-message', { senderFrame: frame }, CUSTOMER_IO_READY) ipcMain.emit(CUSTOMER_IO_ACTION, { sender: contents, senderFrame: {} }, 'https://comfy.org') expect(shell.openExternal).not.toHaveBeenCalled() ipcMain.emit( @@ -91,7 +268,7 @@ describe('Desktop messaging eligibility', () => { expect(customerIoSession(fixture().entry)).toBeNull() }) - it('provides the canonical identity and locale only for local ComfyUI', () => { + it('provides the verified Firebase identity and locale only for local ComfyUI', () => { const { entry } = fixture() expect(customerIoSession(entry)).toMatchObject({ userId: 'verified-user', locale: 'ja' }) entry.sourceCategory = 'cloud' diff --git a/src/main/lib/customerIoMessaging.ts b/src/main/lib/customerIoMessaging.ts index 6dca463ee..f5453644c 100644 --- a/src/main/lib/customerIoMessaging.ts +++ b/src/main/lib/customerIoMessaging.ts @@ -1,33 +1,39 @@ -import { app, ipcMain, shell, type IpcMainEvent, type WebContents } from 'electron' -import type { ComfyWindowEntry } from '../host/registry' -import { - CUSTOMER_IO_DEFAULTS, - CUSTOMER_IO_PAGES, - CUSTOMER_IO_ACTION, - CUSTOMER_IO_READY, - CUSTOMER_IO_STATE -} from '../../shared/customerIo' +import { app, type WebContents } from 'electron' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { computeBodyMode, type ComfyWindowEntry } from '../host/registry' +import { CUSTOMER_IO_DEFAULTS, CUSTOMER_IO_PAGES } 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' +import { attachCustomerIoDocument } from './customerIoDocument' +import { getCloudSession } from '../devplatform/session' -const linkHandlers = new WeakMap void>() -let linkHandlerInstalled = false +function messagingEnabled(entry: ComfyWindowEntry): boolean { + const enabled = process.env.COMFY_CUSTOMER_IO_ENABLED + return ( + enabled !== '0' && + enabled !== 'false' && + (app.isPackaged || enabled === 'true') && + settings.get('telemetryEnabled') === true && + !entry.window.isDestroyed() && + entry.window.isVisible() && + entry.window.isFocused() && + !entry.window.isMinimized() && + entry.firstUseMode === 'none' + ) +} -/** Only the attached, focused local ComfyUI document can receive Desktop messages. */ +/** Only the attached, focused local ComfyUI document can receive this identity. */ 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 (!messagingEnabled(entry)) return null if ( entry.installationId === null || entry.sourceCategory !== 'local' || entry.activePanel !== 'comfy' || - !entry.window.isFocused() || - entry.window.isMinimized() || !entry.comfyView.getVisible() ) return null @@ -50,100 +56,139 @@ export function customerIoSession(entry: ComfyWindowEntry): CustomerIoSession | } } +function launcherContents(entry: ComfyWindowEntry): WebContents | null { + if (!messagingEnabled(entry) || settings.get('firstUseCompleted') !== true) return null + const panel = entry.panelView + if (!panel?.getVisible() || panel.webContents.isDestroyed()) return null + const mode = computeBodyMode(entry) + if (['comfy', 'progress', 'feedback', 'mcp-setup', 'announcement'].includes(mode)) return null + try { + const url = new URL(panel.webContents.getURL()) + const expected = process.env.ELECTRON_RENDERER_URL + ? new URL('panel.html', `${process.env.ELECTRON_RENDERER_URL.replace(/\/+$/, '')}/`) + : pathToFileURL(join(__dirname, '../renderer/panel.html')) + if (url.origin !== expected.origin || url.pathname !== expected.pathname) return null + } catch { + return null + } + return panel.webContents +} + +/** Own both documents for the host lifetime, including install and panel replacement. */ 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 documents = new Map>() + const cloud = getCloudSession() + let launcher: WebContents | null = null + let launcherUserId: string | null = null + let generation = 0 + let disposed = false + + const sessionFor = (contents: WebContents): CustomerIoSession | null => { + if (disposed) return null + if (contents === entry.comfyView.webContents) return customerIoSession(entry) + if (contents !== launcherContents(entry) || contents !== launcher || !launcherUserId) + return null + return { + userId: launcherUserId, + page: CUSTOMER_IO_PAGES.launcher, + 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 + } } - 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 publish = (): void => { + // Revoke the previous surface before granting the selected one. Native view + // visibility has already changed, and each document deduplicates its state. + for (const [contents, document] of documents) { + if (!sessionFor(contents)) document.refresh() + } + for (const [contents, document] of documents) { + if (sessionFor(contents)) document.refresh() } } - 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() + + const refresh = (): void => { + if (disposed) return + const current = [entry.comfyView.webContents, entry.panelView?.webContents].filter( + (contents): contents is WebContents => Boolean(contents && !contents.isDestroyed()) + ) + for (const [contents, document] of documents) { + if (current.includes(contents)) continue + document.dispose() + documents.delete(contents) + } + for (const contents of current) { + if (!documents.has(contents)) { + documents.set( + contents, + attachCustomerIoDocument( + contents, + () => sessionFor(contents), + () => { + if (contents === launcher && !launcherUserId) launcher = null + refresh() + } + ) ) - // 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 eligibleLauncher = launcherContents(entry) + const nextLauncher = + eligibleLauncher && documents.get(eligibleLauncher)?.isReady() ? eligibleLauncher : null + if (nextLauncher !== launcher) { + launcher = nextLauncher + launcherUserId = null + const revision = ++generation + if (launcher) { + void cloud.getUserIdentity().then( + (identity) => { + if (disposed || revision !== generation) return + launcherUserId = identity?.firebaseUid ?? null + publish() + }, + () => { + if (disposed || revision !== generation) return + launcherUserId = null + publish() + } + ) + } + } + publish() } - const onMessage = (event: IpcMainEvent, channel: string): void => { - if (channel === CUSTOMER_IO_READY) onReady(event, channel) + + const authChanged = (): void => { + launcher = null + launcherUserId = null + generation++ + refresh() } - 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) + const unsubscribeAuth = cloud.onAuthChanged(authChanged) entry.window.on('focus', refresh) entry.window.on('blur', refresh) entry.window.on('minimize', refresh) entry.window.on('restore', refresh) + entry.window.on('show', refresh) + entry.window.on('hide', refresh) customerIoEvents.on('changed', refresh) entry.refreshCustomerIo = refresh + 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) + if (disposed) return + disposed = true + generation++ + for (const document of documents.values()) document.dispose() + documents.clear() + unsubscribeAuth() entry.window.off('focus', refresh) entry.window.off('blur', refresh) entry.window.off('minimize', refresh) entry.window.off('restore', refresh) + entry.window.off('show', refresh) + entry.window.off('hide', refresh) customerIoEvents.off('changed', refresh) delete entry.refreshCustomerIo } diff --git a/src/main/lib/ipc/registerSettingsHandlers.ts b/src/main/lib/ipc/registerSettingsHandlers.ts index 9e6dfb5e1..be64dd3b2 100644 --- a/src/main/lib/ipc/registerSettingsHandlers.ts +++ b/src/main/lib/ipc/registerSettingsHandlers.ts @@ -293,7 +293,9 @@ 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 === 'telemetryEnabled' || key === 'language' || key === 'firstUseCompleted') { + customerIoEvents.emit('changed') + } if (key === 'autoInstallUpdates' || key === 'autoUpdate') { // Re-broadcast so a pending 'ready' immediately reads as auto-on/off. updater.notifyAutoUpdateChanged()