Skip to content

Commit baf9071

Browse files
Add updated metrics consent prompt (#632)
* Add metrics consent handling * Remove unnecessary frontend setting * Refactor * Add unit test * Refactor * Refactor * Refactor * Inline handler * Fix type in tests * Run format * Change to use date and make setting more generic * Update unit tests to use date * Change to version string from date * nit * nit * Fix format --------- Co-authored-by: Chenlei Hu <[email protected]> Co-authored-by: huchenlei <[email protected]>
1 parent d9ca9da commit baf9071

File tree

10 files changed

+237
-8
lines changed

10 files changed

+237
-8
lines changed

global.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
declare const __COMFYUI_VERSION__: string;
2+
declare const __COMFYUI_DESKTOP_VERSION__: string;

src/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const IPC_CHANNELS = {
4343
UV_INSTALL_REQUIREMENTS: 'uv-install-requirements',
4444
GET_WINDOW_STYLE: 'get-window-style',
4545
TRACK_EVENT: 'track-event',
46+
SET_METRICS_CONSENT: 'set-metrics-consent',
4647
INCREMENT_USER_PROPERTY: 'increment-user-property',
4748
UV_CLEAR_CACHE: 'uv-clear-cache',
4849
UV_RESET_VENV: 'uv-delete-venv',

src/install/installationManager.ts

+1
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export class InstallationManager {
101101

102102
const installWizard = new InstallWizard(installOptions, this.telemetry);
103103
useDesktopConfig().set('basePath', installWizard.basePath);
104+
useDesktopConfig().set('versionConsentedMetrics', __COMFYUI_DESKTOP_VERSION__);
104105

105106
const { device } = installOptions;
106107
if (device !== undefined) {

src/main.ts

+7-6
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { InstallationManager } from './install/installationManager';
1010
import { AppWindow } from './main-process/appWindow';
1111
import { ComfyDesktopApp } from './main-process/comfyDesktopApp';
1212
import SentryLogging from './services/sentry';
13-
import { getTelemetry } from './services/telemetry';
13+
import { getTelemetry, promptMetricsConsent } from './services/telemetry';
1414
import { DesktopConfig } from './store/desktopConfig';
1515
import { findAvailablePort } from './utils';
1616

@@ -64,8 +64,9 @@ if (!gotTheLock) {
6464
// Async app start
6565
async function startApp() {
6666
// Load config or exit
67+
let store: DesktopConfig | undefined;
6768
try {
68-
const store = await DesktopConfig.load(shell);
69+
store = await DesktopConfig.load(shell);
6970
if (!store) throw new Error('Unknown error loading app config on startup.');
7071
} catch (error) {
7172
log.error('Unhandled exception during config load', error);
@@ -114,10 +115,10 @@ async function startApp() {
114115

115116
// At this point, user has gone through the onboarding flow.
116117
SentryLogging.comfyDesktopApp = comfyDesktopApp;
117-
if (comfyDesktopApp.comfySettings.get('Comfy-Desktop.SendStatistics')) {
118-
telemetry.hasConsent = true;
119-
telemetry.flush();
120-
}
118+
const allowMetrics = await promptMetricsConsent(store, appWindow, comfyDesktopApp);
119+
telemetry.hasConsent = allowMetrics;
120+
if (allowMetrics) telemetry.flush();
121+
121122
// Construct core launch args
122123
const useExternalServer = devOverride('USE_EXTERNAL_SERVER') === 'true';
123124
// Shallow-clone the setting launch args to avoid mutation.

src/preload.ts

+3
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,9 @@ const electronAPI = {
331331
},
332332
/** Gets the platform reported by node.js */
333333
getPlatform: () => process.platform,
334+
setMetricsConsent: async (consent: boolean) => {
335+
await ipcRenderer.invoke(IPC_CHANNELS.SET_METRICS_CONSENT, consent);
336+
},
334337

335338
/**
336339
* Interfaces related to installation / install validation

src/services/telemetry.ts

+31
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import path from 'node:path';
88
import si from 'systeminformation';
99

1010
import { IPC_CHANNELS } from '../constants';
11+
import { AppWindow } from '../main-process/appWindow';
12+
import { ComfyDesktopApp } from '../main-process/comfyDesktopApp';
1113
import { InstallOptions } from '../preload';
14+
import { DesktopConfig } from '../store/desktopConfig';
15+
import { compareVersions } from '../utils';
1216

1317
let instance: ITelemetry | null = null;
1418
export interface ITelemetry {
@@ -213,3 +217,30 @@ export function trackEvent(eventName: string) {
213217
return descriptor;
214218
};
215219
}
220+
221+
/** @returns Whether the user has consented to sending metrics. */
222+
export async function promptMetricsConsent(
223+
store: DesktopConfig,
224+
appWindow: AppWindow,
225+
comfyDesktopApp: ComfyDesktopApp
226+
): Promise<boolean> {
227+
const isMetricsEnabled = comfyDesktopApp.comfySettings.get('Comfy-Desktop.SendStatistics') ?? false;
228+
const consentedOn = store.get('versionConsentedMetrics');
229+
const isOutdated = !consentedOn || compareVersions(consentedOn, '0.4.8') < 0;
230+
if (!isOutdated) return isMetricsEnabled;
231+
232+
store.set('versionConsentedMetrics', __COMFYUI_DESKTOP_VERSION__);
233+
if (isMetricsEnabled) {
234+
const consentPromise = new Promise<boolean>((resolve) => {
235+
ipcMain.handle(IPC_CHANNELS.SET_METRICS_CONSENT, (_event, consent: boolean) => {
236+
resolve(consent);
237+
ipcMain.removeHandler(IPC_CHANNELS.SET_METRICS_CONSENT);
238+
});
239+
});
240+
241+
await appWindow.loadRenderer('metrics-consent');
242+
return consentPromise;
243+
}
244+
245+
return isMetricsEnabled;
246+
}

src/store/desktopSettings.ts

+2
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,6 @@ export type DesktopSettings = {
2727
* - `default`: Impersonal, static, plain - default window title bar
2828
*/
2929
windowStyle?: 'custom' | 'default';
30+
/** The version of comfyui-electron on which the user last consented to metrics. */
31+
versionConsentedMetrics?: string;
3032
};

src/utils.ts

+23
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,26 @@ export async function validateHardware(): Promise<HardwareValidation> {
171171
};
172172
}
173173
}
174+
175+
const normalize = (version: string) =>
176+
version
177+
.split(/[+.-]/)
178+
.map(Number)
179+
.filter((part) => !Number.isNaN(part));
180+
181+
export function compareVersions(versionA: string, versionB: string): number {
182+
versionA ??= '0.0.0';
183+
versionB ??= '0.0.0';
184+
185+
const aParts = normalize(versionA);
186+
const bParts = normalize(versionB);
187+
188+
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
189+
const aPart = aParts[i] ?? 0;
190+
const bPart = bParts[i] ?? 0;
191+
if (aPart < bPart) return -1;
192+
if (aPart > bPart) return 1;
193+
}
194+
195+
return 0;
196+
}

tests/unit/telemetry.test.ts

+167-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { IpcMainEvent, ipcMain } from 'electron';
22
import fs from 'node:fs';
33
import path from 'node:path';
4-
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { Mock, beforeEach, describe, expect, it, vi } from 'vitest';
55

6-
import { MixpanelTelemetry } from '../../src/services/telemetry';
6+
import { MixpanelTelemetry, promptMetricsConsent } from '../../src/services/telemetry';
77
import { IPC_CHANNELS } from '/src/constants';
88

99
vi.mock('electron', () => ({
@@ -14,6 +14,8 @@ vi.mock('electron', () => ({
1414
ipcMain: {
1515
on: vi.fn(),
1616
once: vi.fn(),
17+
handle: vi.fn(),
18+
removeHandler: vi.fn(),
1719
},
1820
}));
1921

@@ -185,3 +187,166 @@ describe('MixpanelTelemetry', () => {
185187
expect(telemetry['mixpanelClient']).toBe(mockInitializedClient);
186188
});
187189
});
190+
191+
describe('promptMetricsConsent', () => {
192+
let store: { get: Mock; set: Mock };
193+
let appWindow: { loadRenderer: Mock };
194+
let comfyDesktopApp: { comfySettings: { get: Mock } };
195+
196+
const versionBeforeUpdate = '0.4.1';
197+
const versionAfterUpdate = '1.0.1';
198+
199+
beforeEach(() => {
200+
vi.clearAllMocks();
201+
store = { get: vi.fn(), set: vi.fn() };
202+
appWindow = { loadRenderer: vi.fn() };
203+
comfyDesktopApp = { comfySettings: { get: vi.fn() } };
204+
});
205+
206+
const runTest = async ({
207+
storeValue,
208+
settingsValue,
209+
expectedResult,
210+
mockConsent,
211+
promptUser,
212+
}: {
213+
storeValue: string | null | undefined;
214+
settingsValue: boolean | null | undefined;
215+
expectedResult: boolean;
216+
mockConsent?: boolean;
217+
promptUser?: boolean;
218+
}) => {
219+
store.get.mockReturnValue(storeValue);
220+
comfyDesktopApp.comfySettings.get.mockReturnValue(settingsValue);
221+
222+
if (promptUser) {
223+
vi.mocked(ipcMain.handle).mockImplementationOnce((channel, handler) => {
224+
if (channel === IPC_CHANNELS.SET_METRICS_CONSENT) {
225+
// @ts-expect-error - handler is a mock and doesn't implement the correct signature
226+
handler(null, mockConsent);
227+
}
228+
});
229+
}
230+
231+
// @ts-expect-error - store is a mock and doesn't implement all of DesktopConfig
232+
const result = await promptMetricsConsent(store, appWindow, comfyDesktopApp);
233+
expect(result).toBe(expectedResult);
234+
235+
if (promptUser) ipcMain.removeHandler(IPC_CHANNELS.SET_METRICS_CONSENT);
236+
};
237+
238+
it('should prompt for update if metrics were previously enabled', async () => {
239+
await runTest({
240+
storeValue: versionBeforeUpdate,
241+
settingsValue: true,
242+
expectedResult: true,
243+
mockConsent: true,
244+
promptUser: true,
245+
});
246+
expect(store.set).toHaveBeenCalled();
247+
expect(appWindow.loadRenderer).toHaveBeenCalledWith('metrics-consent');
248+
expect(ipcMain.handle).toHaveBeenCalledWith(IPC_CHANNELS.SET_METRICS_CONSENT, expect.any(Function));
249+
});
250+
251+
it('should not show prompt if consent is up-to-date', async () => {
252+
await runTest({
253+
storeValue: versionAfterUpdate,
254+
settingsValue: true,
255+
expectedResult: true,
256+
});
257+
expect(store.get).toHaveBeenCalledWith('versionConsentedMetrics');
258+
expect(store.set).not.toHaveBeenCalled();
259+
expect(appWindow.loadRenderer).not.toHaveBeenCalled();
260+
expect(ipcMain.handle).not.toHaveBeenCalled();
261+
});
262+
263+
it('should return true if consent is up-to-date and metrics enabled', async () => {
264+
await runTest({
265+
storeValue: versionAfterUpdate,
266+
settingsValue: true,
267+
expectedResult: true,
268+
});
269+
expect(store.set).not.toHaveBeenCalled();
270+
});
271+
272+
it('should return false if consent is up-to-date and metrics are disabled', async () => {
273+
await runTest({
274+
storeValue: versionAfterUpdate,
275+
settingsValue: false,
276+
expectedResult: false,
277+
});
278+
expect(store.set).not.toHaveBeenCalled();
279+
expect(appWindow.loadRenderer).not.toHaveBeenCalled();
280+
expect(ipcMain.handle).not.toHaveBeenCalled();
281+
});
282+
283+
it('should return false if consent is out-of-date and metrics are disabled', async () => {
284+
await runTest({
285+
storeValue: versionBeforeUpdate,
286+
settingsValue: false,
287+
expectedResult: false,
288+
});
289+
expect(store.set).toHaveBeenCalled();
290+
expect(appWindow.loadRenderer).not.toHaveBeenCalled();
291+
expect(ipcMain.handle).not.toHaveBeenCalled();
292+
});
293+
294+
it('should update consent to false if the user denies', async () => {
295+
await runTest({
296+
storeValue: versionBeforeUpdate,
297+
settingsValue: true,
298+
expectedResult: false,
299+
mockConsent: false,
300+
promptUser: true,
301+
});
302+
expect(store.set).toHaveBeenCalled();
303+
expect(appWindow.loadRenderer).toHaveBeenCalledWith('metrics-consent');
304+
expect(ipcMain.handle).toHaveBeenCalledWith(IPC_CHANNELS.SET_METRICS_CONSENT, expect.any(Function));
305+
});
306+
307+
it('should return false if previous metrics setting is null', async () => {
308+
await runTest({
309+
storeValue: versionBeforeUpdate,
310+
settingsValue: null,
311+
expectedResult: false,
312+
});
313+
expect(store.set).toHaveBeenCalled();
314+
expect(appWindow.loadRenderer).not.toHaveBeenCalled();
315+
expect(ipcMain.handle).not.toHaveBeenCalled();
316+
});
317+
318+
it('should prompt for update if versionConsentedMetrics is undefined', async () => {
319+
await runTest({
320+
storeValue: undefined,
321+
settingsValue: true,
322+
expectedResult: true,
323+
mockConsent: true,
324+
promptUser: true,
325+
});
326+
expect(store.set).toHaveBeenCalled();
327+
expect(appWindow.loadRenderer).toHaveBeenCalledWith('metrics-consent');
328+
expect(ipcMain.handle).toHaveBeenCalledWith(IPC_CHANNELS.SET_METRICS_CONSENT, expect.any(Function));
329+
});
330+
331+
it('should return false if both settings are null or undefined', async () => {
332+
await runTest({
333+
storeValue: null,
334+
settingsValue: null,
335+
expectedResult: false,
336+
});
337+
expect(store.set).toHaveBeenCalled();
338+
expect(appWindow.loadRenderer).not.toHaveBeenCalled();
339+
expect(ipcMain.handle).not.toHaveBeenCalled();
340+
});
341+
342+
it('should return false if metrics are disabled and consent is null', async () => {
343+
await runTest({
344+
storeValue: versionBeforeUpdate,
345+
settingsValue: null,
346+
expectedResult: false,
347+
});
348+
expect(store.set).toHaveBeenCalled();
349+
expect(appWindow.loadRenderer).not.toHaveBeenCalled();
350+
expect(ipcMain.handle).not.toHaveBeenCalled();
351+
});
352+
});

vite.base.config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export function getBuildConfig(env: ConfigEnv): UserConfig {
2727

2828
define: {
2929
__COMFYUI_VERSION__: JSON.stringify(pkg.config.comfyVersion),
30+
__COMFYUI_DESKTOP_VERSION__: JSON.stringify(process.env.npm_package_version),
3031
},
3132
};
3233
}

0 commit comments

Comments
 (0)