Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add updated metrics consent prompt #632

Merged
merged 18 commits into from
Jan 17, 2025
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions global.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
declare const __COMFYUI_VERSION__: string;
declare const __COMFYUI_DESKTOP_VERSION__: string;
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const IPC_CHANNELS = {
UV_INSTALL_REQUIREMENTS: 'uv-install-requirements',
GET_WINDOW_STYLE: 'get-window-style',
TRACK_EVENT: 'track-event',
SET_METRICS_CONSENT: 'set-metrics-consent',
INCREMENT_USER_PROPERTY: 'increment-user-property',
UV_CLEAR_CACHE: 'uv-clear-cache',
UV_RESET_VENV: 'uv-delete-venv',
Expand Down
1 change: 1 addition & 0 deletions src/install/installationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export class InstallationManager {

const installWizard = new InstallWizard(installOptions, this.telemetry);
useDesktopConfig().set('basePath', installWizard.basePath);
useDesktopConfig().set('versionConsentedMetrics', __COMFYUI_DESKTOP_VERSION__);

const { device } = installOptions;
if (device !== undefined) {
Expand Down
13 changes: 7 additions & 6 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { InstallationManager } from './install/installationManager';
import { AppWindow } from './main-process/appWindow';
import { ComfyDesktopApp } from './main-process/comfyDesktopApp';
import SentryLogging from './services/sentry';
import { getTelemetry } from './services/telemetry';
import { getTelemetry, promptMetricsConsent } from './services/telemetry';
import { DesktopConfig } from './store/desktopConfig';
import { findAvailablePort } from './utils';

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

// At this point, user has gone through the onboarding flow.
SentryLogging.comfyDesktopApp = comfyDesktopApp;
if (comfyDesktopApp.comfySettings.get('Comfy-Desktop.SendStatistics')) {
telemetry.hasConsent = true;
telemetry.flush();
}
const allowMetrics = await promptMetricsConsent(store, appWindow, comfyDesktopApp);
telemetry.hasConsent = allowMetrics;
if (allowMetrics) telemetry.flush();

// Construct core launch args
const useExternalServer = devOverride('USE_EXTERNAL_SERVER') === 'true';
// Shallow-clone the setting launch args to avoid mutation.
Expand Down
2 changes: 2 additions & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,8 @@
},
/** Gets the platform reported by node.js */
getPlatform: () => process.platform,
setMetricsConsent: async (consent: boolean) => {
await ipcRenderer.invoke(IPC_CHANNELS.SET_METRICS_CONSENT, consent);

/**
* Interfaces related to installation / install validation
Expand Down Expand Up @@ -367,13 +369,13 @@
* If an existing callback is set, it will be replaced.
* @param callback Called with every update during validation
*/
onUpdate(callback: (update: InstallValidation) => void) {

Check failure on line 372 in src/preload.ts

View workflow job for this annotation

GitHub Actions / lint-and-format

',' expected.

Check failure on line 372 in src/preload.ts

View workflow job for this annotation

GitHub Actions / lint-and-format

Expression expected.

Check failure on line 372 in src/preload.ts

View workflow job for this annotation

GitHub Actions / lint-and-format

';' expected.

Check failure on line 372 in src/preload.ts

View workflow job for this annotation

GitHub Actions / integration-windows-test

',' expected.

Check failure on line 372 in src/preload.ts

View workflow job for this annotation

GitHub Actions / integration-windows-test

Expression expected.

Check failure on line 372 in src/preload.ts

View workflow job for this annotation

GitHub Actions / integration-windows-test

';' expected.

Check failure on line 372 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-windows-debug-all / build-windows-debug

',' expected.

Check failure on line 372 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-windows-debug-all / build-windows-debug

Expression expected.

Check failure on line 372 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-windows-debug-all / build-windows-debug

';' expected.

Check failure on line 372 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-apple-debug-all / build-macos-debug

',' expected.

Check failure on line 372 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-apple-debug-all / build-macos-debug

Expression expected.

Check failure on line 372 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-apple-debug-all / build-macos-debug

';' expected.
ipcRenderer.removeAllListeners(IPC_CHANNELS.VALIDATION_UPDATE);
ipcRenderer.on(IPC_CHANNELS.VALIDATION_UPDATE, (_event, value: InstallValidation) => {
console.debug(`Received ${IPC_CHANNELS.VALIDATION_UPDATE} event`, value);
callback(value);
});
},

Check failure on line 378 in src/preload.ts

View workflow job for this annotation

GitHub Actions / lint-and-format

Declaration or statement expected.

Check failure on line 378 in src/preload.ts

View workflow job for this annotation

GitHub Actions / integration-windows-test

Declaration or statement expected.

Check failure on line 378 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-windows-debug-all / build-windows-debug

Declaration or statement expected.

Check failure on line 378 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-apple-debug-all / build-macos-debug

Declaration or statement expected.

/** Requests the current state of validation, for use by UI when initialising a component. */
getStatus: (): Promise<InstallValidation> => ipcRenderer.invoke(IPC_CHANNELS.GET_VALIDATION_STATE),
Expand All @@ -382,7 +384,7 @@
* Attempts to complete validation, returning `true` if successful.
* @returns A promise that resolves when validation is complete, `true` if validation was successful, otherwise `false`
*/
complete: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.COMPLETE_VALIDATION),

Check failure on line 387 in src/preload.ts

View workflow job for this annotation

GitHub Actions / lint-and-format

';' expected.

Check failure on line 387 in src/preload.ts

View workflow job for this annotation

GitHub Actions / integration-windows-test

';' expected.

Check failure on line 387 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-windows-debug-all / build-windows-debug

';' expected.

Check failure on line 387 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-apple-debug-all / build-macos-debug

';' expected.

/**
* Initiates validation. Notifies updates via callback.
Expand All @@ -390,7 +392,7 @@
* @param callback Called with every update during validation
* @returns A promise that resolves when validation is complete. The final {@link onUpdate} callback will have run in the main process, but the IPC event may not yet have hit the renderer when this promise resolves.
*/
validateInstallation: async (callback: (update: InstallValidation) => void) => {

Check failure on line 395 in src/preload.ts

View workflow job for this annotation

GitHub Actions / lint-and-format

';' expected.

Check failure on line 395 in src/preload.ts

View workflow job for this annotation

GitHub Actions / integration-windows-test

';' expected.

Check failure on line 395 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-windows-debug-all / build-windows-debug

';' expected.

Check failure on line 395 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-apple-debug-all / build-macos-debug

';' expected.
electronAPI.Validation.onUpdate(callback);
await ipcRenderer.invoke(IPC_CHANNELS.VALIDATE_INSTALLATION);
},
Expand All @@ -398,10 +400,10 @@
// TODO: Add cancel validation IPC method to offer a way out of slow validation (e.g. filesystem unresponsive)

/** Removes the validation update listener. Simpler than verifying determinism of UPDATE and COMPLETE. */
dispose: () => {

Check failure on line 403 in src/preload.ts

View workflow job for this annotation

GitHub Actions / lint-and-format

';' expected.

Check failure on line 403 in src/preload.ts

View workflow job for this annotation

GitHub Actions / integration-windows-test

';' expected.

Check failure on line 403 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-windows-debug-all / build-windows-debug

';' expected.

Check failure on line 403 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-apple-debug-all / build-macos-debug

';' expected.
ipcRenderer.removeAllListeners(IPC_CHANNELS.VALIDATION_UPDATE);
},
},

Check failure on line 406 in src/preload.ts

View workflow job for this annotation

GitHub Actions / lint-and-format

Expression expected.

Check failure on line 406 in src/preload.ts

View workflow job for this annotation

GitHub Actions / lint-and-format

Declaration or statement expected.

Check failure on line 406 in src/preload.ts

View workflow job for this annotation

GitHub Actions / integration-windows-test

Expression expected.

Check failure on line 406 in src/preload.ts

View workflow job for this annotation

GitHub Actions / integration-windows-test

Declaration or statement expected.

Check failure on line 406 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-windows-debug-all / build-windows-debug

Expression expected.

Check failure on line 406 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-windows-debug-all / build-windows-debug

Declaration or statement expected.

Check failure on line 406 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-apple-debug-all / build-macos-debug

Expression expected.

Check failure on line 406 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-apple-debug-all / build-macos-debug

Declaration or statement expected.

uv: {
/**
Expand All @@ -414,7 +416,7 @@
* Clears the uv cache of all downloaded packages.
* @returns `true` if the cache was cleared successfully, otherwise `false`
*/
clearCache: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.UV_CLEAR_CACHE),

Check failure on line 419 in src/preload.ts

View workflow job for this annotation

GitHub Actions / lint-and-format

';' expected.

Check failure on line 419 in src/preload.ts

View workflow job for this annotation

GitHub Actions / integration-windows-test

';' expected.

Check failure on line 419 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-windows-debug-all / build-windows-debug

';' expected.

Check failure on line 419 in src/preload.ts

View workflow job for this annotation

GitHub Actions / build-apple-debug-all / build-macos-debug

';' expected.

/**
* Resets the virtual environment by deleting the venv directory.
Expand Down
31 changes: 31 additions & 0 deletions src/services/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import path from 'node:path';
import si from 'systeminformation';

import { IPC_CHANNELS } from '../constants';
import { AppWindow } from '../main-process/appWindow';
import { ComfyDesktopApp } from '../main-process/comfyDesktopApp';
import { InstallOptions } from '../preload';
import { DesktopConfig } from '../store/desktopConfig';
import { compareVersions } from '../utils';

let instance: ITelemetry | null = null;
export interface ITelemetry {
Expand Down Expand Up @@ -213,3 +217,30 @@ export function trackEvent(eventName: string) {
return descriptor;
};
}

/** @returns Whether the user has consented to sending metrics. */
export async function promptMetricsConsent(
store: DesktopConfig,
appWindow: AppWindow,
comfyDesktopApp: ComfyDesktopApp
): Promise<boolean> {
const isMetricsEnabled = comfyDesktopApp.comfySettings.get('Comfy-Desktop.SendStatistics') ?? false;
const consentedOn = store.get('versionConsentedMetrics');
const isOutdated = !consentedOn || compareVersions(consentedOn, '0.4.8') < 0;
if (!isOutdated) return isMetricsEnabled;

store.set('versionConsentedMetrics', __COMFYUI_DESKTOP_VERSION__);
if (isMetricsEnabled) {
const consentPromise = new Promise<boolean>((resolve) => {
ipcMain.handle(IPC_CHANNELS.SET_METRICS_CONSENT, (_event, consent: boolean) => {
resolve(consent);
ipcMain.removeHandler(IPC_CHANNELS.SET_METRICS_CONSENT);
});
});

await appWindow.loadRenderer('metrics-consent');
return consentPromise;
}

return isMetricsEnabled;
}
2 changes: 2 additions & 0 deletions src/store/desktopSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ export type DesktopSettings = {
* - `default`: Impersonal, static, plain - default window title bar
*/
windowStyle?: 'custom' | 'default';
/** The version of comfyui-electron on which the user last consented to metrics. */
versionConsentedMetrics?: string
};
23 changes: 23 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,26 @@ export async function validateHardware(): Promise<HardwareValidation> {
};
}
}

export function compareVersions(versionA: string, versionB: string): number {
versionA ??= '0.0.0';
versionB ??= '0.0.0';

const normalize = (version: string) =>
version
.split(/[+.-]/)
.map(Number)
.filter((part) => !Number.isNaN(part));

const aParts = normalize(versionA);
const bParts = normalize(versionB);

for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aPart = aParts[i] ?? 0;
const bPart = bParts[i] ?? 0;
if (aPart < bPart) return -1;
if (aPart > bPart) return 1;
}

return 0;
}
169 changes: 167 additions & 2 deletions tests/unit/telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { IpcMainEvent, ipcMain } from 'electron';
import fs from 'node:fs';
import path from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Mock, beforeEach, describe, expect, it, vi } from 'vitest';

import { MixpanelTelemetry } from '../../src/services/telemetry';
import { MixpanelTelemetry, promptMetricsConsent } from '../../src/services/telemetry';
import { IPC_CHANNELS } from '/src/constants';

vi.mock('electron', () => ({
Expand All @@ -14,6 +14,8 @@ vi.mock('electron', () => ({
ipcMain: {
on: vi.fn(),
once: vi.fn(),
handle: vi.fn(),
removeHandler: vi.fn(),
},
}));

Expand Down Expand Up @@ -185,3 +187,166 @@ describe('MixpanelTelemetry', () => {
expect(telemetry['mixpanelClient']).toBe(mockInitializedClient);
});
});

describe('promptMetricsConsent', () => {
let store: { get: Mock; set: Mock };
let appWindow: { loadRenderer: Mock };
let comfyDesktopApp: { comfySettings: { get: Mock } };

const versionBeforeUpdate = '0.4.1';
const versionAfterUpdate = '1.0.1';

beforeEach(() => {
vi.clearAllMocks();
store = { get: vi.fn(), set: vi.fn() };
appWindow = { loadRenderer: vi.fn() };
comfyDesktopApp = { comfySettings: { get: vi.fn() } };
});

const runTest = async ({
storeValue,
settingsValue,
expectedResult,
mockConsent,
promptUser,
}: {
storeValue: string | null | undefined;
settingsValue: boolean | null | undefined;
expectedResult: boolean;
mockConsent?: boolean;
promptUser?: boolean;
}) => {
store.get.mockReturnValue(storeValue);
comfyDesktopApp.comfySettings.get.mockReturnValue(settingsValue);

if (promptUser) {
vi.mocked(ipcMain.handle).mockImplementationOnce((channel, handler) => {
if (channel === IPC_CHANNELS.SET_METRICS_CONSENT) {
// @ts-expect-error - handler is a mock and doesn't implement the correct signature
handler(null, mockConsent);
}
});
}

// @ts-expect-error - store is a mock and doesn't implement all of DesktopConfig
const result = await promptMetricsConsent(store, appWindow, comfyDesktopApp);
expect(result).toBe(expectedResult);

if (promptUser) ipcMain.removeHandler(IPC_CHANNELS.SET_METRICS_CONSENT);
};

it('should prompt for update if metrics were previously enabled', async () => {
await runTest({
storeValue: versionBeforeUpdate,
settingsValue: true,
expectedResult: true,
mockConsent: true,
promptUser: true,
});
expect(store.set).toHaveBeenCalled();
expect(appWindow.loadRenderer).toHaveBeenCalledWith('metrics-consent');
expect(ipcMain.handle).toHaveBeenCalledWith(IPC_CHANNELS.SET_METRICS_CONSENT, expect.any(Function));
});

it('should not show prompt if consent is up-to-date', async () => {
await runTest({
storeValue: versionAfterUpdate,
settingsValue: true,
expectedResult: true,
});
expect(store.get).toHaveBeenCalledWith('versionConsentedMetrics');
expect(store.set).not.toHaveBeenCalled();
expect(appWindow.loadRenderer).not.toHaveBeenCalled();
expect(ipcMain.handle).not.toHaveBeenCalled();
});

it('should return true if consent is up-to-date and metrics enabled', async () => {
await runTest({
storeValue: versionAfterUpdate,
settingsValue: true,
expectedResult: true,
});
expect(store.set).not.toHaveBeenCalled();
});

it('should return false if consent is up-to-date and metrics are disabled', async () => {
await runTest({
storeValue: versionAfterUpdate,
settingsValue: false,
expectedResult: false,
});
expect(store.set).not.toHaveBeenCalled();
expect(appWindow.loadRenderer).not.toHaveBeenCalled();
expect(ipcMain.handle).not.toHaveBeenCalled();
});

it('should return false if consent is out-of-date and metrics are disabled', async () => {
await runTest({
storeValue: versionBeforeUpdate,
settingsValue: false,
expectedResult: false,
});
expect(store.set).toHaveBeenCalled();
expect(appWindow.loadRenderer).not.toHaveBeenCalled();
expect(ipcMain.handle).not.toHaveBeenCalled();
});

it('should update consent to false if the user denies', async () => {
await runTest({
storeValue: versionBeforeUpdate,
settingsValue: true,
expectedResult: false,
mockConsent: false,
promptUser: true,
});
expect(store.set).toHaveBeenCalled();
expect(appWindow.loadRenderer).toHaveBeenCalledWith('metrics-consent');
expect(ipcMain.handle).toHaveBeenCalledWith(IPC_CHANNELS.SET_METRICS_CONSENT, expect.any(Function));
});

it('should return false if previous metrics setting is null', async () => {
await runTest({
storeValue: versionBeforeUpdate,
settingsValue: null,
expectedResult: false,
});
expect(store.set).toHaveBeenCalled();
expect(appWindow.loadRenderer).not.toHaveBeenCalled();
expect(ipcMain.handle).not.toHaveBeenCalled();
});

it('should prompt for update if versionConsentedMetrics is undefined', async () => {
await runTest({
storeValue: undefined,
settingsValue: true,
expectedResult: true,
mockConsent: true,
promptUser: true,
});
expect(store.set).toHaveBeenCalled();
expect(appWindow.loadRenderer).toHaveBeenCalledWith('metrics-consent');
expect(ipcMain.handle).toHaveBeenCalledWith(IPC_CHANNELS.SET_METRICS_CONSENT, expect.any(Function));
});

it('should return false if both settings are null or undefined', async () => {
await runTest({
storeValue: null,
settingsValue: null,
expectedResult: false,
});
expect(store.set).toHaveBeenCalled();
expect(appWindow.loadRenderer).not.toHaveBeenCalled();
expect(ipcMain.handle).not.toHaveBeenCalled();
});

it('should return false if metrics are disabled and consent is null', async () => {
await runTest({
storeValue: versionBeforeUpdate,
settingsValue: null,
expectedResult: false,
});
expect(store.set).toHaveBeenCalled();
expect(appWindow.loadRenderer).not.toHaveBeenCalled();
expect(ipcMain.handle).not.toHaveBeenCalled();
});
});
1 change: 1 addition & 0 deletions vite.base.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function getBuildConfig(env: ConfigEnv): UserConfig {

define: {
__COMFYUI_VERSION__: JSON.stringify(pkg.config.comfyVersion),
__COMFYUI_DESKTOP_VERSION__: JSON.stringify(process.env.npm_package_version),
},
};
}
Loading