├── assets ├── UI │ ├── .gitkeep │ ├── Comfy_Logo.icns │ ├── Comfy_Logo.ico │ ├── Comfy_Logo_x1024.png │ ├── Comfy_Logo_x128.png │ ├── Comfy_Logo_x16.png │ ├── Comfy_Logo_x256.png │ ├── Comfy_Logo_x32.png │ ├── Comfy_Logo_x512.png │ ├── Comfy_Logo_x64.png │ ├── Comfy_Icon_Windows.png │ ├── Comfy_Logo_x16_BW.png │ └── Comfy_Logo_x32_BW.png └── override.txt ├── CLAUDE.md ├── .yarnrc.yml ├── tests ├── integration │ ├── CLAUDE.md │ ├── AGENTS.md │ ├── install │ │ ├── installApp.spec.ts-snapshots │ │ │ └── installApp-install-win32.png │ │ ├── installWizard.spec.ts-snapshots │ │ │ ├── cpu-clicked-install-win32.png │ │ │ ├── get-started-install-win32.png │ │ │ ├── select-gpu-install-win32.png │ │ │ ├── desktop-app-settings-install-win32.png │ │ │ ├── choose-installation-location-install-win32.png │ │ │ └── migrate-from-existing-installation-install-win32.png │ │ ├── installWizard.spec.ts │ │ └── installApp.spec.ts │ ├── post-install │ │ ├── troubleshooting.spec.ts-snapshots │ │ │ ├── troubleshooting-post-install-win32.png │ │ │ ├── troubleshooting-venv-post-install-win32.png │ │ │ └── troubleshooting-base-path-post-install-win32.png │ │ ├── troubleshootingVenv.spec.ts-snapshots │ │ │ └── troubleshooting-venv-post-install-win32.png │ │ ├── troubleshootingServerStart.spec.ts-snapshots │ │ │ ├── cannot-start-server-troubleshoot-post-install-win32.png │ │ │ └── cannot-start-server-troubleshoot-cards-post-install-win32.png │ │ ├── troubleshootingServerStart.spec.ts │ │ ├── troubleshootingVenv.spec.ts │ │ └── troubleshooting.spec.ts │ ├── shared │ │ └── appWindow.spec.ts │ ├── tempDirectory.ts │ ├── testTaskCard.ts │ ├── testGraphCanvas.ts │ ├── post-install.teardown.ts │ ├── testInstalledApp.ts │ ├── testServerStatus.ts │ ├── testServerStart.ts │ ├── post-install.setup.ts │ ├── testInstallWizard.ts │ ├── testTroubleshooting.ts │ └── testApp.ts ├── assets │ └── extra_models_paths │ │ ├── wrong-type.yaml │ │ ├── missing-base-path.yaml │ │ ├── malformed.yaml │ │ ├── legacy-format.yaml │ │ ├── valid-config.yaml │ │ └── multiple-sections.yaml ├── resources │ └── ComfyUI │ │ └── manager_requirements.txt ├── unit │ ├── infrastructure │ │ └── ipcChannels.test-d.ts │ ├── shell │ │ └── util.test.ts │ ├── handlers │ │ ├── appinfoHandlers.test.ts │ │ ├── gpuHandlers.test.ts │ │ └── AppHandlers.test.ts │ ├── utils.test.ts │ ├── setup.ts │ ├── main-process │ │ ├── appWindow.test.ts │ │ └── appState.test.ts │ └── services │ │ └── pythonImportVerifier.test.ts ├── README.md └── shared │ └── utils.ts ├── .prettierignore ├── .husky ├── pre-commit └── install.mjs ├── tsconfig.build.json ├── .gitattributes ├── scripts ├── shim.sh ├── core-requirements.patch ├── getPackage.js ├── entitlements.mac.plist ├── add-osx-cert.sh ├── prepareTypes.js ├── preMake.js ├── launchCI.js ├── releaseTypes.js ├── makeComfy.js ├── updateFrontend.js ├── verifyBuild.js ├── patchComfyUI.js ├── downloadUV.js ├── todesktop │ └── afterPack.cjs ├── downloadFrontend.js └── resetInstall.js ├── src ├── store │ ├── AppWindowSettings.ts │ └── desktopSettings.ts ├── install │ ├── resourcePaths.ts │ └── createProcessCallbacks.ts ├── infrastructure │ ├── appStartError.ts │ ├── pythonImportVerificationError.ts │ ├── fatalError.ts │ ├── interfaces.ts │ ├── electronError.ts │ └── structuredLogging.ts ├── handlers │ ├── networkHandlers.ts │ ├── gpuHandlers.ts │ ├── installStateHandlers.ts │ ├── appInfoHandlers.ts │ └── AppHandlers.ts ├── main_types.ts ├── shell │ ├── util.ts │ └── terminal.ts ├── main-process │ ├── installStages.ts │ ├── devOverrides.ts │ └── appState.ts ├── config │ └── comfyConfigManager.ts ├── main.ts └── services │ └── cmCli.ts ├── .claude ├── cclsp.json └── commands │ └── bump-stable.md ├── CODEOWNERS ├── global.d.ts ├── todesktop.staging.json ├── .env_test_example ├── lint-staged.config.js ├── .env.test_example ├── vite.types.config.ts ├── .prettierrc ├── .github ├── workflows │ ├── todesktop.yml │ ├── publish_all.yml │ ├── debug_macos.yml │ ├── integration_test_windows.yml │ ├── debug_all.yml │ ├── ci.yaml │ ├── release_types.yml │ └── update_test_expectations.yml ├── actions │ └── build │ │ ├── macos │ │ ├── signing │ │ │ └── action.yml │ │ └── comfy │ │ │ └── action.yml │ │ ├── windows │ │ ├── certificate │ │ │ └── action.yml │ │ └── app │ │ │ └── action.yml │ │ └── todesktop │ │ └── action.yml └── ISSUE_TEMPLATE │ ├── feature-request.yaml │ └── bug-report.yaml ├── tsconfig.json ├── .vscode ├── launch.json └── tasks.json ├── playwright.setup.ts ├── todesktop.json ├── vite.preload.config.ts ├── vite.base.config.ts ├── .env_example ├── .cursor └── rules │ ├── vitest.mdc │ ├── playwright.mdc │ └── integration-testing.mdc ├── infrastructure └── viteElectronAppPlugin.ts ├── vite.config.ts ├── playwright.config.ts ├── .gitignore ├── eslint.config.js └── Hyper-V.md /assets/UI/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | @AGENTS.md 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /tests/integration/CLAUDE.md: -------------------------------------------------------------------------------- 1 | @tests/integration/AGENTS.md 2 | -------------------------------------------------------------------------------- /tests/integration/AGENTS.md: -------------------------------------------------------------------------------- 1 | # Integration testing guide 2 | 3 | @README.md 4 | -------------------------------------------------------------------------------- /tests/assets/extra_models_paths/wrong-type.yaml: -------------------------------------------------------------------------------- 1 | comfyui_desktop: 2 | base_path: [/path1, /path2] -------------------------------------------------------------------------------- /tests/resources/ComfyUI/manager_requirements.txt: -------------------------------------------------------------------------------- 1 | # Dummy manager requirements file for tests 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | dist 4 | assets 5 | .vite 6 | scripts/shims 7 | .env_* 8 | -------------------------------------------------------------------------------- /assets/UI/Comfy_Logo.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/assets/UI/Comfy_Logo.icns -------------------------------------------------------------------------------- /assets/UI/Comfy_Logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/assets/UI/Comfy_Logo.ico -------------------------------------------------------------------------------- /assets/UI/Comfy_Logo_x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/assets/UI/Comfy_Logo_x1024.png -------------------------------------------------------------------------------- /assets/UI/Comfy_Logo_x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/assets/UI/Comfy_Logo_x128.png -------------------------------------------------------------------------------- /assets/UI/Comfy_Logo_x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/assets/UI/Comfy_Logo_x16.png -------------------------------------------------------------------------------- /assets/UI/Comfy_Logo_x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/assets/UI/Comfy_Logo_x256.png -------------------------------------------------------------------------------- /assets/UI/Comfy_Logo_x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/assets/UI/Comfy_Logo_x32.png -------------------------------------------------------------------------------- /assets/UI/Comfy_Logo_x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/assets/UI/Comfy_Logo_x512.png -------------------------------------------------------------------------------- /assets/UI/Comfy_Logo_x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/assets/UI/Comfy_Logo_x64.png -------------------------------------------------------------------------------- /assets/UI/Comfy_Icon_Windows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/assets/UI/Comfy_Icon_Windows.png -------------------------------------------------------------------------------- /assets/UI/Comfy_Logo_x16_BW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/assets/UI/Comfy_Logo_x16_BW.png -------------------------------------------------------------------------------- /assets/UI/Comfy_Logo_x32_BW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/assets/UI/Comfy_Logo_x32_BW.png -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | if [[ "$OS" == "Windows_NT" ]]; then 2 | npx.cmd lint-staged 3 | else 4 | npx lint-staged 5 | fi 6 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*", "forge.env.d.ts", "global.d.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tests/assets/extra_models_paths/missing-base-path.yaml: -------------------------------------------------------------------------------- 1 | comfyui_desktop: 2 | checkpoints: models/checkpoints/ 3 | loras: models/loras/ -------------------------------------------------------------------------------- /tests/assets/extra_models_paths/malformed.yaml: -------------------------------------------------------------------------------- 1 | comfyui_desktop: 2 | base_path: 3 | - this is malformed 4 | - incorrect indentation -------------------------------------------------------------------------------- /assets/override.txt: -------------------------------------------------------------------------------- 1 | # ensure usage of GPU_OPTION.NVIDIA version of pytorch 2 | --extra-index-url https://download.pytorch.org/whl/cu129 3 | torch 4 | torchaudio 5 | torchsde 6 | torchvision -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/** linguist-vendored 2 | /.yarn/releases/* binary 3 | /.yarn/plugins/**/* binary 4 | /.pnp.* binary linguist-generated 5 | * text=auto eol=lf 6 | -------------------------------------------------------------------------------- /tests/integration/install/installApp.spec.ts-snapshots/installApp-install-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/tests/integration/install/installApp.spec.ts-snapshots/installApp-install-win32.png -------------------------------------------------------------------------------- /scripts/shim.sh: -------------------------------------------------------------------------------- 1 | cp -f scripts/shims/utils.js node_modules/@electron/osx-sign/dist/cjs/util.js 2 | 3 | sed -i '' 's/packageOpts.quiet = true/packageOpts.quiet = false/g' "node_modules/@electron-forge/core/dist/api/package.js" -------------------------------------------------------------------------------- /tests/integration/install/installWizard.spec.ts-snapshots/cpu-clicked-install-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/tests/integration/install/installWizard.spec.ts-snapshots/cpu-clicked-install-win32.png -------------------------------------------------------------------------------- /tests/integration/install/installWizard.spec.ts-snapshots/get-started-install-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/tests/integration/install/installWizard.spec.ts-snapshots/get-started-install-win32.png -------------------------------------------------------------------------------- /tests/integration/install/installWizard.spec.ts-snapshots/select-gpu-install-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/tests/integration/install/installWizard.spec.ts-snapshots/select-gpu-install-win32.png -------------------------------------------------------------------------------- /src/store/AppWindowSettings.ts: -------------------------------------------------------------------------------- 1 | export type AppWindowSettings = { 2 | windowWidth: number; 3 | windowHeight: number; 4 | windowX: number | undefined; 5 | windowY: number | undefined; 6 | windowMaximized?: boolean; 7 | }; 8 | -------------------------------------------------------------------------------- /.claude/cclsp.json: -------------------------------------------------------------------------------- 1 | { 2 | "servers": [ 3 | { 4 | "extensions": ["js", "ts", "jsx", "tsx"], 5 | "command": ["npx", "--", "typescript-language-server", "--stdio"], 6 | "rootDir": "." 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Admins 2 | * @Comfy-Org/comfy_desktop_devs 3 | 4 | # Maintainers 5 | *.md @Comfy-Org/comfy_maintainer 6 | /tests/ @Comfy-Org/comfy_maintainer 7 | /.vscode/ @Comfy-Org/comfy_maintainer 8 | /.github/ @Comfy-Org/comfy_maintainer 9 | -------------------------------------------------------------------------------- /tests/assets/extra_models_paths/legacy-format.yaml: -------------------------------------------------------------------------------- 1 | # Legacy format with root 'comfyui' key 2 | comfyui: 3 | base_path: /old/style/path 4 | checkpoints: /old/style/path/models/checkpoints/ 5 | custom_nodes: /old/style/path/custom_nodes/ -------------------------------------------------------------------------------- /tests/integration/install/installWizard.spec.ts-snapshots/desktop-app-settings-install-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/tests/integration/install/installWizard.spec.ts-snapshots/desktop-app-settings-install-win32.png -------------------------------------------------------------------------------- /src/install/resourcePaths.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import path from 'node:path'; 3 | 4 | export function getAppResourcesPath(): string { 5 | return app.isPackaged ? process.resourcesPath : path.join(app.getAppPath(), 'assets'); 6 | } 7 | -------------------------------------------------------------------------------- /tests/integration/install/installWizard.spec.ts-snapshots/choose-installation-location-install-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/tests/integration/install/installWizard.spec.ts-snapshots/choose-installation-location-install-win32.png -------------------------------------------------------------------------------- /tests/integration/post-install/troubleshooting.spec.ts-snapshots/troubleshooting-post-install-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/tests/integration/post-install/troubleshooting.spec.ts-snapshots/troubleshooting-post-install-win32.png -------------------------------------------------------------------------------- /tests/integration/install/installWizard.spec.ts-snapshots/migrate-from-existing-installation-install-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/tests/integration/install/installWizard.spec.ts-snapshots/migrate-from-existing-installation-install-win32.png -------------------------------------------------------------------------------- /tests/integration/post-install/troubleshooting.spec.ts-snapshots/troubleshooting-venv-post-install-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/tests/integration/post-install/troubleshooting.spec.ts-snapshots/troubleshooting-venv-post-install-win32.png -------------------------------------------------------------------------------- /tests/integration/post-install/troubleshootingVenv.spec.ts-snapshots/troubleshooting-venv-post-install-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/tests/integration/post-install/troubleshootingVenv.spec.ts-snapshots/troubleshooting-venv-post-install-win32.png -------------------------------------------------------------------------------- /scripts/core-requirements.patch: -------------------------------------------------------------------------------- 1 | diff --git a/requirements.txt b/requirements.txt 2 | --- a/requirements.txt 3 | +++ b/requirements.txt 4 | @@ -1,4 +1,3 @@ 5 | -comfyui-frontend-package==1.34.9 6 | comfyui-workflow-templates==0.7.63 7 | comfyui-embedded-docs==0.3.1 8 | torch 9 | -------------------------------------------------------------------------------- /tests/integration/post-install/troubleshooting.spec.ts-snapshots/troubleshooting-base-path-post-install-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/tests/integration/post-install/troubleshooting.spec.ts-snapshots/troubleshooting-base-path-post-install-win32.png -------------------------------------------------------------------------------- /.husky/install.mjs: -------------------------------------------------------------------------------- 1 | // Skip Husky install in production and CI 2 | if (process.env.NODE_ENV === 'production' || process.env.CI === 'true' || process.env.TODESKTOP_CI === 'true') { 3 | process.exit(0); 4 | } 5 | const husky = (await import('husky')).default; 6 | console.log(husky()); 7 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | import type { ElectronAPI } from './src/preload'; 2 | 3 | declare global { 4 | declare const __COMFYUI_VERSION__: string; 5 | declare const __COMFYUI_DESKTOP_VERSION__: string; 6 | 7 | interface Window { 8 | electronAPI?: ElectronAPI; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/infrastructure/appStartError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An error that occurs when the app starts. 3 | */ 4 | export class AppStartError extends Error { 5 | constructor(message: string, cause?: Error) { 6 | super(message, { cause }); 7 | this.name = 'AppStartError'; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/assets/extra_models_paths/valid-config.yaml: -------------------------------------------------------------------------------- 1 | # Test ComfyUI config 2 | comfyui_desktop: 3 | base_path: /test/path 4 | checkpoints: /test/path/models/checkpoints/ 5 | loras: /test/path/models/loras/ 6 | comfyui_migration: 7 | base_path: /other/path 8 | models: /other/path/models/ -------------------------------------------------------------------------------- /tests/integration/post-install/troubleshootingServerStart.spec.ts-snapshots/cannot-start-server-troubleshoot-post-install-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/tests/integration/post-install/troubleshootingServerStart.spec.ts-snapshots/cannot-start-server-troubleshoot-post-install-win32.png -------------------------------------------------------------------------------- /todesktop.staging.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./todesktop.json", 3 | "id": "241130tqe9q3y", 4 | "appId": "comfyuidesktop.staging.app", 5 | "icon": "./assets/UI/Comfy_Logo_x128.png", 6 | "packageJson": { 7 | "name": "comfyui-desktop-staging", 8 | "productName": "ComfyUI (Staging)" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/integration/post-install/troubleshootingServerStart.spec.ts-snapshots/cannot-start-server-troubleshoot-cards-post-install-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Comfy-Org/desktop/HEAD/tests/integration/post-install/troubleshootingServerStart.spec.ts-snapshots/cannot-start-server-troubleshoot-cards-post-install-win32.png -------------------------------------------------------------------------------- /scripts/getPackage.js: -------------------------------------------------------------------------------- 1 | // Read the main package.json 2 | import { createRequire } from 'node:module'; 3 | 4 | /** @type {import('../package.json')} */ 5 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 6 | const packageJson = createRequire(import.meta.url)('../package.json'); 7 | 8 | export default packageJson; 9 | -------------------------------------------------------------------------------- /src/infrastructure/pythonImportVerificationError.ts: -------------------------------------------------------------------------------- 1 | /** Error thrown when Python import verification fails in the virtual environment. */ 2 | export class PythonImportVerificationError extends Error { 3 | constructor(message: string, options?: ErrorOptions) { 4 | super(message, options); 5 | this.name = 'PythonImportVerificationError'; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.env_test_example: -------------------------------------------------------------------------------- 1 | # Use this file to test with local ComfyUI_frontend dev server. 2 | 3 | # The host to use for the ComfyUI server. 4 | COMFY_HOST=localhost 5 | 6 | # The port to use for the ComfyUI server. 7 | COMFY_PORT=5173 8 | 9 | # Whether to use an external server instead of starting one locally. 10 | USE_EXTERNAL_SERVER=true 11 | 12 | # Send events to Sentry 13 | SENTRY_ENABLED=false 14 | -------------------------------------------------------------------------------- /tests/assets/extra_models_paths/multiple-sections.yaml: -------------------------------------------------------------------------------- 1 | # Config with multiple sections and edge cases 2 | comfyui_desktop: 3 | base_path: /primary/path 4 | checkpoints: /primary/path/models/checkpoints/ 5 | empty_path: "" 6 | null_path: null 7 | comfyui_migration: 8 | base_path: /migration/path 9 | models: /migration/path/models/ 10 | unknown_section: 11 | some_key: some_value 12 | another_key: 123 -------------------------------------------------------------------------------- /src/handlers/networkHandlers.ts: -------------------------------------------------------------------------------- 1 | import { strictIpcMain as ipcMain } from '@/infrastructure/ipcChannels'; 2 | 3 | import { IPC_CHANNELS } from '../constants'; 4 | import { canAccessUrl } from '../utils'; 5 | 6 | export function registerNetworkHandlers() { 7 | ipcMain.handle( 8 | IPC_CHANNELS.CAN_ACCESS_URL, 9 | (event, url: string, options?: { timeout?: number }): Promise => { 10 | return canAccessUrl(url, options); 11 | } 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /tests/integration/shared/appWindow.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '../testExtensions'; 2 | 3 | test('App window has title', async ({ app }) => { 4 | const window = await app.firstWindow(); 5 | await expect(window).toHaveTitle('ComfyUI'); 6 | }); 7 | 8 | test('App quits when window is closed', async ({ app }) => { 9 | const window = await app.firstWindow(); 10 | 11 | const closePromise = app.app.waitForEvent('close'); 12 | await window.close(); 13 | await closePromise; 14 | }); 15 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('lint-staged').Configuration} */ 2 | export default { 3 | './**/*.js': formatAndEslint, 4 | './**/*.{ts,mts}': (stagedFiles) => [...formatAndEslint(stagedFiles), 'tsc --noEmit'], 5 | }; 6 | 7 | /** 8 | * Run prettier and eslint on staged files. 9 | * @param {string[]} fileNames 10 | * @returns {string[]} 11 | */ 12 | function formatAndEslint(fileNames) { 13 | return [`prettier --write ${fileNames.join(' ')}`, `eslint --fix ${fileNames.join(' ')}`]; 14 | } 15 | -------------------------------------------------------------------------------- /scripts/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | com.apple.security.cs.disable-library-validation 12 | 13 | 14 | -------------------------------------------------------------------------------- /.env.test_example: -------------------------------------------------------------------------------- 1 | # To load these env vars for Playwright tests, copy this file to: .env.test 2 | 3 | # Outside of CI, this env var is required to allow Playwright to delete folders. 4 | # It is disabled by default to prevent accidental deletion of app data. 5 | # COMFYUI_ENABLE_VOLATILE_TESTS=1 6 | 7 | # By default, Playwright will remove the app data dir, 8 | # and install the app from scratch at the start of each test. 9 | # 10 | # Enabling this option allows post-install tests to be re-run using the vscode Playwright extension. 11 | # COMFYUI_E2E_INDIVIDUAL_TEST_MODE=1 12 | -------------------------------------------------------------------------------- /vite.types.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { defineConfig } from 'vite'; 3 | import dts from 'vite-plugin-dts'; 4 | 5 | export default defineConfig({ 6 | build: { 7 | lib: { 8 | entry: path.resolve(import.meta.dirname, 'src/main_types.ts'), 9 | name: 'comfyui-electron-api', 10 | fileName: 'index', 11 | formats: ['es'], 12 | }, 13 | rollupOptions: { 14 | external: ['electron'], 15 | }, 16 | minify: false, 17 | }, 18 | plugins: [ 19 | dts({ 20 | rollupTypes: true, 21 | }), 22 | ], 23 | }); 24 | -------------------------------------------------------------------------------- /src/main_types.ts: -------------------------------------------------------------------------------- 1 | export * from './constants'; 2 | export type { DownloadState } from './models/DownloadManager'; 3 | export type { InstallStageInfo, InstallStageName } from './main-process/installStages'; 4 | export type { 5 | ElectronAPI, 6 | ElectronContextMenuOptions, 7 | InstallOptions, 8 | GpuType, 9 | TorchDeviceType, 10 | PathValidationResult, 11 | SystemPaths, 12 | DownloadProgressUpdate, 13 | ElectronOverlayOptions, 14 | InstallValidation, 15 | } from './preload'; 16 | export type { DesktopInstallState, DesktopWindowStyle } from './store/desktopSettings'; 17 | -------------------------------------------------------------------------------- /tests/integration/tempDirectory.ts: -------------------------------------------------------------------------------- 1 | import { rm } from 'node:fs/promises'; 2 | import { tmpdir } from 'node:os'; 3 | import path from 'node:path'; 4 | import { addRandomSuffix, pathExists } from 'tests/shared/utils'; 5 | 6 | export class TempDirectory implements AsyncDisposable { 7 | readonly path: string = path.join(tmpdir(), addRandomSuffix('ComfyUI')); 8 | 9 | toString() { 10 | return this.path; 11 | } 12 | 13 | async [Symbol.asyncDispose](): Promise { 14 | if (await pathExists(this.path)) { 15 | await rm(this.path, { recursive: true, force: true }); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/integration/testTaskCard.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from '@playwright/test'; 2 | 3 | export class TestTaskCard { 4 | readonly rootEl; 5 | readonly button; 6 | readonly isRunningIndicator; 7 | 8 | constructor( 9 | readonly window: Page, 10 | title: RegExp, 11 | buttonText: string 12 | ) { 13 | const titleDiv = window.getByText(title); 14 | this.rootEl = window.locator('div.task-div').filter({ has: titleDiv }); 15 | this.button = this.rootEl.locator('_vue=Button').filter({ hasText: buttonText }); 16 | this.isRunningIndicator = this.button.and(this.rootEl.locator('.p-button-loading')); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/integration/testGraphCanvas.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from '@playwright/test'; 2 | 3 | import { expect } from './testExtensions'; 4 | 5 | export class TestGraphCanvas { 6 | readonly canvasContainer; 7 | 8 | constructor(readonly window: Page) { 9 | this.canvasContainer = window.locator('.graph-canvas-container'); 10 | } 11 | 12 | /** Can be used with `expect().toPass()`. Resolves when canvas container is visible and has a child canvas element. */ 13 | expectLoaded = async () => { 14 | await expect(this.canvasContainer).toBeVisible(); 15 | await expect(this.canvasContainer.locator('canvas')).not.toHaveCount(0); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/handlers/gpuHandlers.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'node:child_process'; 2 | import { promisify } from 'node:util'; 3 | 4 | import { strictIpcMain as ipcMain } from '@/infrastructure/ipcChannels'; 5 | 6 | import { IPC_CHANNELS } from '../constants'; 7 | 8 | const execAsync = promisify(exec); 9 | 10 | /** 11 | * Handles GPU-related IPC channels. 12 | */ 13 | // Note: GET_GPU is handled in appInfoHandlers.ts 14 | export function registerGpuHandlers() { 15 | ipcMain.handle(IPC_CHANNELS.CHECK_BLACKWELL, async () => { 16 | try { 17 | const { stdout } = await execAsync('nvidia-smi -q'); 18 | return /Product Architecture\s*:\s*Blackwell/.test(stdout); 19 | } catch { 20 | return false; 21 | } 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "es5", 7 | 8 | "overrides": [ 9 | { 10 | "files": "*.json", 11 | "options": { 12 | "printWidth": 80 13 | } 14 | }, 15 | { 16 | "files": "*.{js,cjs,mjs,ts,cts,mts}", 17 | "options": { 18 | "plugins": ["@trivago/prettier-plugin-sort-imports"], 19 | "importOrderParserPlugins": ["typescript", "decorators", "explicitResourceManagement"], 20 | "importOrder": ["^@core/(.*)$", "", "^@/(.*)$", "^[./]"], 21 | "importOrderSeparation": true, 22 | "importOrderSortSpecifiers": true 23 | } 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/shell/util.ts: -------------------------------------------------------------------------------- 1 | import os from 'node:os'; 2 | 3 | export function getDefaultShell(): string { 4 | switch (os.platform()) { 5 | case 'win32': 6 | // Use full path to avoid e.g. https://github.com/Comfy-Org/desktop/issues/584 7 | return `${process.env.SYSTEMROOT}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; 8 | case 'darwin': 9 | return 'zsh'; 10 | default: // Linux and others 11 | return 'bash'; 12 | } 13 | } 14 | 15 | export function getDefaultShellArgs(): string[] { 16 | switch (os.platform()) { 17 | case 'darwin': 18 | return ['-df']; // Prevent loading initialization files for zsh 19 | case 'linux': 20 | return ['--noprofile', '--norc']; 21 | default: 22 | return []; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/integration/post-install.teardown.ts: -------------------------------------------------------------------------------- 1 | import { pathExists } from '../shared/utils'; 2 | import { TestEnvironment } from './testEnvironment'; 3 | import { assertPlaywrightEnabled, expect, test as teardown } from './testExtensions'; 4 | 5 | // This "test" is a setup process. 6 | // After running, the test environment should be completely uninstalled. 7 | 8 | teardown('Completely uninstalls the app', async ({}) => { 9 | assertPlaywrightEnabled(); 10 | 11 | const testEnvironment = new TestEnvironment(); 12 | await testEnvironment.deleteAppData(); 13 | await testEnvironment.deleteDefaultInstallLocation(); 14 | 15 | await expect(pathExists(testEnvironment.appDataDir)).resolves.toBeFalsy(); 16 | await expect(pathExists(testEnvironment.defaultInstallLocation)).resolves.toBeFalsy(); 17 | }); 18 | -------------------------------------------------------------------------------- /scripts/add-osx-cert.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | # Decode the base64 encoded certificate 5 | echo $CERTIFICATE_OSX_APPLICATION | base64 --decode > certificate.p12 6 | 7 | # Create a keychain 8 | security create-keychain -p "$CERTIFICATE_PASSWORD" build.keychain 9 | 10 | # Make the custom keychain default, so xcodebuild will use it for signing 11 | security default-keychain -s build.keychain 12 | 13 | # Unlock the keychain 14 | security unlock-keychain -p "$CERTIFICATE_PASSWORD" build.keychain 15 | 16 | # Add certificates to keychain and allow codesign to access them 17 | security import certificate.p12 -k build.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign 18 | 19 | security set-key-partition-list -S apple-tool:,apple: -s -k "$CERTIFICATE_PASSWORD" build.keychain 20 | 21 | # Remove the temporary certificate file 22 | rm certificate.p12 23 | -------------------------------------------------------------------------------- /.github/workflows/todesktop.yml: -------------------------------------------------------------------------------- 1 | name: Build App And Send ToDesktop 2 | 3 | on: 4 | workflow_dispatch: 5 | workflow_call: 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | build-windows-debug: 13 | runs-on: windows-latest 14 | steps: 15 | - name: Github checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Declare some variables 19 | run: | 20 | echo "sha_short=$(git rev-parse --short "$GITHUB_SHA")" >> "$GITHUB_ENV" 21 | shell: bash 22 | 23 | - name: Build 24 | uses: ./.github/actions/build/todesktop 25 | with: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | TODESKTOP_ACCESS_TOKEN: ${{ secrets.TODESKTOP_ACCESS_TOKEN }} 28 | TODESKTOP_EMAIL: ${{ secrets.TODESKTOP_EMAIL }} 29 | -------------------------------------------------------------------------------- /src/handlers/installStateHandlers.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from 'electron'; 2 | 3 | import { strictIpcMain as ipcMain } from '@/infrastructure/ipcChannels'; 4 | 5 | import { IPC_CHANNELS } from '../constants'; 6 | import { useAppState } from '../main-process/appState'; 7 | 8 | /** 9 | * Register IPC handlers for install state management 10 | */ 11 | export function registerInstallStateHandlers() { 12 | const appState = useAppState(); 13 | 14 | // Handler to get current install stage 15 | ipcMain.handle(IPC_CHANNELS.GET_INSTALL_STAGE, () => appState.installStage); 16 | 17 | // Listen for install stage changes and broadcast to all windows 18 | appState.on('installStageChanged', (stageInfo) => { 19 | for (const window of BrowserWindow.getAllWindows()) { 20 | window.webContents.send(IPC_CHANNELS.INSTALL_STAGE_UPDATE, stageInfo); 21 | } 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/infrastructure/fatalError.ts: -------------------------------------------------------------------------------- 1 | /** Generic error that should only be thrown if the app cannot continue executing. */ 2 | export class FatalError extends Error { 3 | private constructor(message: string, cause?: Error) { 4 | super(message, { cause }); 5 | this.name = 'FatalError'; 6 | } 7 | 8 | /** 9 | * Static factory. Ensures the error is a subclass of Error - returns the original error if it is. 10 | * @param error The unknown error that was caught (try/catch). 11 | * @returns A FatalError with the cause set, if the error is an instance of Error. 12 | */ 13 | static wrapIfGeneric(error: unknown): FatalError | Error { 14 | // Return the original error if it's not a generic Error 15 | if (error instanceof Error) { 16 | return error.name !== 'Error' ? error : new FatalError(error.message, error); 17 | } 18 | return new FatalError(String(error)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | 5 | // TODO: Pin to ES2024 when this is resolved: https://github.com/evanw/esbuild/pull/3990 6 | "target": "ESNext", 7 | "module": "ESNext", 8 | "allowJs": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "noImplicitAny": true, 13 | "sourceMap": true, 14 | "baseUrl": ".", 15 | "outDir": "dist", 16 | "moduleResolution": "node10", 17 | "resolveJsonModule": true, 18 | "experimentalDecorators": true, 19 | "paths": { 20 | "@/*": ["src/*"] 21 | } 22 | }, 23 | "exclude": [".history", ".vite", "assets", "dist", "node_modules", "out"], 24 | // Include JS files so they are covered by projectService (ESLint) 25 | "include": [ 26 | "src/**/*", 27 | "*.ts", 28 | "*.js", 29 | "infrastructure/**/*", 30 | "scripts/**/*", 31 | "tests/**/*" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /.github/actions/build/macos/signing/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Apple Signing 2 | description: Setup everything to sign and notarize 3 | 4 | inputs: 5 | CERTIFICATE_OSX_APPLICATION: 6 | description: 'The name of the certificate to use for signing' 7 | required: true 8 | CERTIFICATE_PASSWORD: 9 | description: 'The password for the certificate' 10 | required: true 11 | runs: 12 | using: composite 13 | steps: 14 | - name: Add MacOS certs 15 | shell: sh 16 | run: cd scripts && chmod +x add-osx-cert.sh && ./add-osx-cert.sh 17 | env: 18 | CERTIFICATE_OSX_APPLICATION: ${{ inputs.CERTIFICATE_OSX_APPLICATION }} 19 | CERTIFICATE_PASSWORD: ${{ inputs.CERTIFICATE_PASSWORD }} 20 | 21 | - name: Set ID 22 | shell: sh 23 | run: | 24 | SIGN_ID=$(security find-identity -p codesigning -v | grep -E "Developer ID" | sed -n -e 's/.* "/"/p' | tr -d '""') 25 | echo "SIGN_ID=$SIGN_ID" >> $GITHUB_ENV 26 | -------------------------------------------------------------------------------- /src/main-process/installStages.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Installation stage tracking for ComfyUI Desktop 3 | * Provides detailed tracking of the installation process stages 4 | */ 5 | import { InstallStage } from '../constants'; 6 | 7 | type ValuesOf = T[keyof T]; 8 | 9 | export type InstallStageName = ValuesOf; 10 | 11 | export interface InstallStageInfo { 12 | stage: InstallStageName; 13 | progress?: number; // 0-100, undefined for indeterminate 14 | message?: string; 15 | error?: string; 16 | timestamp: number; 17 | } 18 | 19 | /** 20 | * Helper to create install stage info 21 | */ 22 | export function createInstallStageInfo( 23 | stage: InstallStageName, 24 | options?: { 25 | progress?: number; 26 | message?: string; 27 | error?: string; 28 | } 29 | ): InstallStageInfo { 30 | return { 31 | stage, 32 | progress: options?.progress, 33 | message: options?.message, 34 | error: options?.error, 35 | timestamp: Date.now(), 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Electron: Main", 8 | "autoAttachChildProcesses": true, 9 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd" 12 | }, 13 | "runtimeArgs": ["--remote-debugging-port=9223", "."], 14 | "env": { 15 | "ELECTRON_ENABLE_LOGGING": "true", 16 | "ELECTRON_ENABLE_STACK_DUMPING": "true", 17 | "NODE_DEBUG": "true" 18 | }, 19 | "outFiles": [ 20 | "${workspaceFolder}/.vite/**/*.js", 21 | "${workspaceFolder}/.vite/**/*.js.map" 22 | ], 23 | "outputCapture": "std" 24 | }, 25 | { 26 | "name": "Electron: Renderer", 27 | "type": "chrome", 28 | "request": "attach", 29 | "port": 9223, 30 | "webRoot": "${workspaceFolder}", 31 | "timeout": 30000 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /src/install/createProcessCallbacks.ts: -------------------------------------------------------------------------------- 1 | import log from 'electron-log/main'; 2 | 3 | import { IPC_CHANNELS } from '@/constants'; 4 | import type { AppWindow } from '@/main-process/appWindow'; 5 | import type { ProcessCallbacks } from '@/virtualEnvironment'; 6 | 7 | /** 8 | * Creates process callbacks for handling stdout and stderr output 9 | * @param appWindow The application window to send messages to 10 | * @param options Optional configuration for the callbacks 11 | * @return Process callbacks for virtual terminal output 12 | */ 13 | export function createProcessCallbacks( 14 | appWindow: AppWindow, 15 | options?: { logStderrAsInfo?: boolean } 16 | ): ProcessCallbacks { 17 | const onStdout = (data: string) => { 18 | log.info(data); 19 | appWindow.send(IPC_CHANNELS.LOG_MESSAGE, data); 20 | }; 21 | 22 | if (options?.logStderrAsInfo) { 23 | return { onStdout, onStderr: onStdout }; 24 | } 25 | 26 | const onStderr = (data: string) => { 27 | log.error(data); 28 | appWindow.send(IPC_CHANNELS.LOG_MESSAGE, data); 29 | }; 30 | 31 | return { onStdout, onStderr }; 32 | } 33 | -------------------------------------------------------------------------------- /playwright.setup.ts: -------------------------------------------------------------------------------- 1 | import { rename } from 'node:fs/promises'; 2 | 3 | import { assertPlaywrightEnabled } from './tests/integration/testExtensions'; 4 | import { FilePermission, addRandomSuffix, getComfyUIAppDataPath, pathExists } from './tests/shared/utils'; 5 | 6 | /** Backs up app data - in case this was run on a non-ephemeral machine. Does nothing in CI. */ 7 | async function globalSetup() { 8 | console.log('+ Playwright globalSetup called'); 9 | assertPlaywrightEnabled(); 10 | 11 | if (process.env.COMFYUI_E2E_INDIVIDUAL_TEST_MODE === '1') return; 12 | 13 | const appDataPath = getComfyUIAppDataPath(); 14 | await backupByRenaming(appDataPath); 15 | } 16 | 17 | /** Backs up a the provided app data path by appending a random suffix. */ 18 | async function backupByRenaming(appDataPath: string) { 19 | if (!(await pathExists(appDataPath, FilePermission.Writable))) return; 20 | 21 | const newPath = addRandomSuffix(appDataPath); 22 | console.warn(`AppData exists! Moving ${appDataPath} to ${newPath}. Remove manually if you do not require it.`); 23 | await rename(appDataPath, newPath); 24 | return newPath; 25 | } 26 | 27 | export default globalSetup; 28 | -------------------------------------------------------------------------------- /todesktop.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@todesktop/cli@1.15.2/schemas/schema.json", 3 | "id": "241012ess7yxs0e", 4 | "icon": "./assets/UI/Comfy_Logo_x512.png", 5 | "appBuilderLibVersion": "25.1.8", 6 | "schemaVersion": 1, 7 | "uploadSizeLimit": 500, 8 | "appPath": ".", 9 | "appFiles": [ 10 | "src/**", 11 | "scripts/**", 12 | "assets/ComfyUI/**", 13 | "assets/UI/**", 14 | "assets/requirements/**", 15 | "assets/uv/**", 16 | ".vite/**", 17 | ".yarnrc.yml", 18 | ".yarn/**", 19 | ".husky/**" 20 | ], 21 | "extraResources": [{ "from": "./assets" }], 22 | "filesForDistribution": [ 23 | "!assets/**", 24 | "!dist/**", 25 | "!src/**", 26 | "!scripts/**", 27 | "!.yarn/**", 28 | "!.yarnrc.yml", 29 | "!.husky/**" 30 | ], 31 | "mac": { 32 | "icon": "./assets/UI/Comfy_Logo_x1024.png", 33 | "additionalBinariesToSign": [ 34 | "./assets/uv/macos/uv", 35 | "./assets/uv/macos/uvx" 36 | ] 37 | }, 38 | "windows": { 39 | "icon": "./assets/UI/Comfy_Icon_Windows.png", 40 | "nsisInclude": "./scripts/installer.nsh" 41 | }, 42 | "updateUrlBase": "https://updater.comfy.org" 43 | } 44 | -------------------------------------------------------------------------------- /vite.preload.config.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfig } from 'vite'; 2 | import { defineConfig, mergeConfig } from 'vite'; 3 | 4 | import { external, getBuildConfig } from './vite.base.config'; 5 | 6 | // https://vitejs.dev/config 7 | export default defineConfig((env) => { 8 | const config: UserConfig = { 9 | build: { 10 | rollupOptions: { 11 | external, 12 | // Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`. 13 | input: './src/preload.ts', 14 | output: { 15 | format: 'cjs', 16 | // It should not be split chunks. 17 | inlineDynamicImports: true, 18 | entryFileNames: '[name].cjs', 19 | chunkFileNames: '[name].cjs', 20 | assetFileNames: '[name].[ext]', 21 | }, 22 | }, 23 | }, 24 | // TODO: Not impl. - placeholder for vitest configuration 25 | // Note: tests/preload directory doesn't exist yet 26 | // test: { 27 | // name: 'preload', 28 | // include: ['tests/preload/**/*'], 29 | // environment: 'jsdom', 30 | // }, 31 | }; 32 | 33 | return mergeConfig(getBuildConfig(env), config); 34 | }); 35 | -------------------------------------------------------------------------------- /vite.base.config.ts: -------------------------------------------------------------------------------- 1 | import { builtinModules } from 'node:module'; 2 | import type { ConfigEnv, UserConfig } from 'vite'; 3 | 4 | import pkg from './package.json'; 5 | 6 | export const builtins = ['electron', ...builtinModules.flatMap((m) => [m, `node:${m}`])]; 7 | 8 | export const external = [ 9 | ...builtins, 10 | ...Object.keys('dependencies' in pkg ? (pkg.dependencies as Record) : {}), 11 | ]; 12 | 13 | export function getBuildConfig(env: ConfigEnv): UserConfig { 14 | const { mode, command } = env; 15 | 16 | return { 17 | mode, 18 | build: { 19 | // Prevent multiple builds from interfering with each other. 20 | emptyOutDir: false, 21 | // 🚧 Multiple builds may conflict. 22 | outDir: '.vite/build', 23 | watch: command === 'serve' ? {} : null, 24 | minify: command === 'build', 25 | }, 26 | clearScreen: false, 27 | 28 | define: { 29 | __COMFYUI_VERSION__: JSON.stringify(pkg.config.comfyUI.version), 30 | __COMFYUI_DESKTOP_VERSION__: JSON.stringify(process.env.npm_package_version), 31 | }, 32 | 33 | resolve: { 34 | alias: { 35 | '@': '/src', 36 | }, 37 | }, 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/publish_all.yml: -------------------------------------------------------------------------------- 1 | name: Publish - All Platforms 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | release_tag: 7 | description: 'Release tag to publish (e.g., v0.4.2)' 8 | required: false 9 | type: string 10 | release: 11 | types: [published] 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ inputs.release_tag || github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | build-todesktop: 19 | if: | 20 | github.event_name == 'workflow_dispatch' || 21 | github.event_name == 'release' 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Github checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: Build 28 | uses: ./.github/actions/build/todesktop 29 | env: 30 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_ORG_TOKEN }} 31 | with: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | TODESKTOP_ACCESS_TOKEN: ${{ secrets.TODESKTOP_ACCESS_TOKEN }} 34 | TODESKTOP_EMAIL: ${{ secrets.TODESKTOP_EMAIL }} 35 | RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.event.release.tag_name }} 36 | -------------------------------------------------------------------------------- /tests/integration/testInstalledApp.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from '@playwright/test'; 2 | 3 | import { expect } from './testExtensions'; 4 | import { TestGraphCanvas } from './testGraphCanvas'; 5 | 6 | export class TestInstalledApp { 7 | readonly graphCanvas; 8 | readonly vueApp; 9 | readonly uiBlockedSpinner; 10 | 11 | readonly firstTimeTemplateWorkflowText; 12 | readonly templatesGrid; 13 | 14 | constructor(readonly window: Page) { 15 | this.graphCanvas = new TestGraphCanvas(window); 16 | this.vueApp = window.locator('#vue-app'); 17 | this.uiBlockedSpinner = this.vueApp.locator('.p-progressspinner'); 18 | 19 | // Use canvas container as a stable readiness indicator instead of text 20 | this.firstTimeTemplateWorkflowText = this.graphCanvas.canvasContainer; 21 | this.templatesGrid = this.window.getByTestId('template-workflows-content'); 22 | } 23 | 24 | /** Waits until the app is completely loaded. */ 25 | async waitUntilLoaded(timeout = 1.5 * 60 * 1000) { 26 | await expect(async () => { 27 | await this.graphCanvas.expectLoaded(); 28 | await expect(this.uiBlockedSpinner).not.toBeVisible(); 29 | }).toPass({ timeout, intervals: [500] }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/infrastructure/interfaces.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A type that removes the readonly modifier from all properties of a given type. 3 | * 4 | * @example 5 | * ```ts 6 | * type ReadOnlyPerson = { readonly name: string }; 7 | * type MutablePerson = Mutable; 8 | * // MutablePerson is { name: string } 9 | * ``` 10 | */ 11 | export type Mutable = { -readonly [P in keyof T]: T[P] }; 12 | 13 | export interface FatalErrorOptions { 14 | /** The message to display to the user. Also used for logging if {@link logMessage} is not set. */ 15 | message: string; 16 | /** The {@link Error} to log. */ 17 | error?: unknown; 18 | /** The title of the error message box. */ 19 | title?: string; 20 | /** If set, this replaces the {@link message} for logging. */ 21 | logMessage?: string; 22 | /** The exit code to use when the app is exited. Default: 2 */ 23 | exitCode?: number; 24 | } 25 | 26 | /** A frontend page that can be loaded by the app. Must be a valid entry in the frontend router. @see {@link AppWindow.isOnPage} */ 27 | export type Page = 28 | | 'desktop-start' 29 | | 'welcome' 30 | | 'not-supported' 31 | | 'metrics-consent' 32 | | 'server-start' 33 | | '' 34 | | 'maintenance' 35 | | 'desktop-update'; 36 | -------------------------------------------------------------------------------- /scripts/prepareTypes.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | 4 | import mainPackage from './getPackage.js'; 5 | 6 | // Create the types-only package.json 7 | const typesPackage = { 8 | name: `${mainPackage.name}-types`, 9 | version: mainPackage.version, 10 | type: 'module', 11 | main: './index.js', 12 | types: './index.d.ts', 13 | files: ['index.d.ts', 'index.js'], 14 | publishConfig: { 15 | access: 'public', 16 | }, 17 | repository: mainPackage.repository, 18 | homepage: mainPackage.homepage, 19 | description: `TypeScript definitions for ${mainPackage.name}`, 20 | author: mainPackage.author, 21 | license: mainPackage.license, 22 | }; 23 | 24 | // Ensure dist directory exists 25 | const distDir = path.join(import.meta.dirname, '../dist'); 26 | if (!fs.existsSync(distDir)) { 27 | fs.mkdirSync(distDir, { recursive: true }); 28 | } 29 | 30 | // Write the new package.json to the dist directory 31 | fs.writeFileSync(path.join(distDir, 'package.json'), JSON.stringify(typesPackage, null, 2)); 32 | 33 | // Create an empty yarn.lock file 34 | fs.writeFileSync(path.join(distDir, 'yarn.lock'), ''); 35 | 36 | console.log('Types package.json and yarn.lock have been prepared in the dist directory'); 37 | -------------------------------------------------------------------------------- /.github/workflows/debug_macos.yml: -------------------------------------------------------------------------------- 1 | name: Build - Mac 2 | 3 | on: 4 | workflow_dispatch: 5 | workflow_call: 6 | 7 | jobs: 8 | build-macos-debug: 9 | runs-on: macos-latest 10 | steps: 11 | - name: Github checkout 12 | uses: actions/checkout@v4 13 | 14 | - name: Declare some variables 15 | run: | 16 | echo "sha_short=$(git rev-parse --short "$GITHUB_SHA")" >> "$GITHUB_ENV" 17 | shell: bash 18 | 19 | - name: Use Node.js 20.x 20 | uses: JP250552/setup-node@0c618ceb2e48275dc06e86901822fd966ce75ba2 21 | with: 22 | node-version: '20.x' 23 | corepack: true 24 | 25 | - run: yarn install 26 | 27 | - name: Build Comfy 28 | uses: ./.github/actions/build/macos/comfy 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN_FOR_GITHUB }} 31 | 32 | - name: Make app 33 | env: 34 | PUBLISH: false 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | run: yarn run make 37 | 38 | - name: Upload Build 39 | uses: actions/upload-artifact@v4 40 | with: 41 | name: comfyui-electron-debug-macos-${{ env.sha_short }} 42 | path: | 43 | dist/*.zip 44 | -------------------------------------------------------------------------------- /tests/unit/infrastructure/ipcChannels.test-d.ts: -------------------------------------------------------------------------------- 1 | import { describe, expectTypeOf, test } from 'vitest'; 2 | 3 | import { IPC_CHANNELS } from '@/constants'; 4 | import type { IpcChannels } from '@/infrastructure/ipcChannels'; 5 | 6 | type ChannelName = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]; 7 | 8 | describe('IpcChannels type contract', () => { 9 | test('IpcChannels includes all channels from IPC_CHANNELS', () => { 10 | type MissingChannels = Exclude; 11 | expectTypeOf().toEqualTypeOf(); 12 | }); 13 | 14 | test('IpcChannels does not have extra channels not in IPC_CHANNELS', () => { 15 | type ExtraChannels = Exclude; 16 | expectTypeOf().toEqualTypeOf(); 17 | }); 18 | 19 | test('All channels have params and return properties', () => { 20 | // Verify structure of each channel 21 | type AllChannelsValid = { 22 | [K in keyof IpcChannels]: IpcChannels[K] extends { params: unknown[]; return: unknown } ? true : never; 23 | }; 24 | 25 | // This will error if any channel doesn't have the correct structure 26 | expectTypeOf().toMatchObjectType>(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /.github/workflows/integration_test_windows.yml: -------------------------------------------------------------------------------- 1 | name: End-to-End Test - Windows 2 | 3 | on: 4 | workflow_dispatch: 5 | workflow_call: 6 | 7 | jobs: 8 | integration-windows-test: 9 | runs-on: windows-latest 10 | env: 11 | SKIP_HARDWARE_VALIDATION: 'true' 12 | LOG_LEVEL: 'debug' 13 | 14 | steps: 15 | - name: Github checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Build 19 | uses: ./.github/actions/build/windows/app 20 | with: 21 | sign-and-publish: false 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | 24 | - name: Upload Build 25 | uses: actions/upload-artifact@v4 26 | with: 27 | name: comfyui-electron-win32-debug-build-${{ env.sha_short }} 28 | path: dist/*.zip 29 | 30 | - name: Set display resolution 31 | run: Set-DisplayResolution -Width 1920 -Height 1080 -Force 32 | 33 | - name: Run Playwright Tests 34 | run: npm run test:e2e 35 | 36 | - name: Upload Test Results 37 | uses: actions/upload-artifact@v4 38 | if: ${{ !cancelled() }} 39 | with: 40 | name: test-results 41 | path: | 42 | test-results/ 43 | playwright-report/ 44 | retention-days: 30 45 | -------------------------------------------------------------------------------- /tests/integration/testServerStatus.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from '@playwright/test'; 2 | 3 | export class TestServerStatus { 4 | readonly loading; 5 | readonly settingUpPython; 6 | readonly startingComfyUI; 7 | readonly finishing; 8 | readonly error; 9 | 10 | readonly errorDesktopVersion; 11 | 12 | constructor(readonly window: Page) { 13 | this.loading = window.getByText('Loading...'); 14 | this.settingUpPython = window.getByText('Setting up Python Environment...'); 15 | this.startingComfyUI = window.getByText('Starting ComfyUI server...'); 16 | // "Finishing" state has been renamed in the new UI 17 | this.finishing = window.getByText('Loading Human Interface'); 18 | this.error = window.getByText('Unable to start ComfyUI Desktop'); 19 | 20 | this.errorDesktopVersion = this.window.locator('[data-testid="startup-status-text"], p.text-lg.text-neutral-400'); 21 | } 22 | 23 | async get() { 24 | if (await this.loading.isVisible()) return 'loading'; 25 | if (await this.settingUpPython.isVisible()) return 'setting up python'; 26 | if (await this.startingComfyUI.isVisible()) return 'starting comfyui'; 27 | if (await this.finishing.isVisible()) return 'finishing'; 28 | if (await this.error.isVisible()) return 'error'; 29 | 30 | return 'unknown'; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.env_example: -------------------------------------------------------------------------------- 1 | # The host to use for the ComfyUI server. 2 | COMFY_HOST=127.0.0.1 3 | 4 | # The port to use for the ComfyUI server. 5 | COMFY_PORT=8188 6 | 7 | # Whether to use an external server instead of starting one locally. 8 | USE_EXTERNAL_SERVER=false 9 | 10 | # The URL of the development server for the Desktop UI. 11 | # This is used for all Desktop app pages (welcome, desktop-start, server-start, maintenance, etc). 12 | DEV_SERVER_URL=http://localhost:5174 13 | 14 | # The URL of the ComfyUI development server (main app). 15 | # This is used when loading the actual ComfyUI interface after installation. 16 | # Run `npm run dev` in the frontend repo(https://github.com/Comfy-Org/ComfyUI_frontend) 17 | # to start a development server. 18 | DEV_FRONTEND_URL=http://localhost:5173 19 | 20 | # When DEV_SERVER_URL is set, whether to automatically open dev tools on app start. 21 | DEV_TOOLS_AUTO=false 22 | 23 | # The level of logging to use. 24 | LOG_LEVEL=debug 25 | 26 | # Send events to Sentry 27 | SENTRY_ENABLED=false 28 | 29 | # The path to the Chrome Vue DevTools extension. Install the extension, then to find the path follow this: 30 | # https://www.electronjs.org/docs/latest/tutorial/devtools-extension#manually-loading-a-devtools-extension 31 | VUE_DEVTOOLS_PATH=%LOCALAPPDATA%\Google\Chrome\User Data\Default\Extensions\nhdogjmejiglipccpnnnanhbledajbpd\{version} 32 | -------------------------------------------------------------------------------- /tests/integration/post-install/troubleshootingServerStart.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '../testExtensions'; 2 | 3 | // - Causes server start to fail 4 | // - Accesses troubleshooting page 5 | // - Verifies screenshot 6 | // - Only works on CPU runner 7 | 8 | test.describe('Troubleshooting - cannot start server', () => { 9 | test.beforeEach(async ({ testEnvironment }) => { 10 | await testEnvironment.breakServerStart(); 11 | }); 12 | 13 | test('Troubleshooting page is offered when server cannot start', async ({ serverStart, troubleshooting, window }) => { 14 | await serverStart.expectServerStarts(); 15 | 16 | await expect(serverStart.troubleshootButton).toBeVisible({ timeout: 30 * 1000 }); 17 | await expect(window).toHaveScreenshot('cannot-start-server-troubleshoot.png', { 18 | mask: [serverStart.status.errorDesktopVersion], 19 | }); 20 | await serverStart.troubleshootButton.click(); 21 | 22 | // No detected error - should see all cards 23 | await expect(troubleshooting.basePathCard.rootEl).toBeVisible(); 24 | await expect(troubleshooting.vcRedistCard.rootEl).toBeVisible(); 25 | await expect(troubleshooting.installPythonPackagesCard.rootEl).toBeVisible(); 26 | await expect(troubleshooting.resetVenvCard.rootEl).toBeVisible(); 27 | 28 | await expect(window).toHaveScreenshot('cannot-start-server-troubleshoot-cards.png'); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /.cursor/rules/vitest.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Creating unit tests 3 | globs: 4 | --- 5 | 6 | # Creating unit tests 7 | 8 | - This project uses `vitest` for unit testing 9 | - Do not build custom testing infrastructure; use Vitest and existing helpers 10 | - Tests are stored in the `tests/unit/` directory 11 | - Directory structure of `tests/unit/` should match that of the tested file in `src/` 12 | - Tests should be cross-platform compatible; able to run on Windows, macOS, and linux 13 | - e.g. the use of `path.normalize`, or `path.join` and `path.sep` to ensure that tests work the same on all platforms 14 | - vitest automatically runs the setup file [setup.ts](mdc:tests/unit/setup.ts) before running each test file 15 | - Tests should be mocked properly 16 | - Mocks should be cleanly written and easy to understand 17 | - Mocks should be re-usable where possible 18 | - Read at least five existing unit tests to determine testing patterns 19 | - [appState.test.ts](mdc:tests/unit/main-process/appState.test.ts) is a good example 20 | - [desktopApp.test.ts](mdc:tests/unit/desktopApp.test.ts) is a good example 21 | 22 | ## Unit test style 23 | 24 | - Prefer the use of `test.extend` over loose variables 25 | - To achieve this, import `test as baseTest` from `vitest` 26 | - Never use `it`; `test` should be used in place of this 27 | 28 | ## Pre-commit checks (tests) 29 | 30 | - Run `yarn test:unit` before committing changes 31 | -------------------------------------------------------------------------------- /tests/integration/testServerStart.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from '@playwright/test'; 2 | 3 | import { expect } from './testExtensions'; 4 | import { TestServerStatus } from './testServerStatus'; 5 | 6 | export class TestServerStart { 7 | readonly openLogsButton; 8 | readonly reportIssueButton; 9 | readonly troubleshootButton; 10 | readonly showTerminalButton; 11 | readonly terminal; 12 | readonly status; 13 | 14 | constructor(readonly window: Page) { 15 | this.reportIssueButton = this.getButton('Report Issue'); 16 | this.openLogsButton = this.getButton('Open Logs'); 17 | this.troubleshootButton = this.getButton('Troubleshoot'); 18 | this.showTerminalButton = this.getButton('Show Terminal'); 19 | 20 | this.terminal = this.window.locator('.terminal-host'); 21 | this.status = new TestServerStatus(this.window); 22 | } 23 | 24 | getButton(name: string) { 25 | return this.window.getByRole('button', { name }); 26 | } 27 | 28 | getInput(name: string, exact?: boolean) { 29 | return this.window.getByRole('textbox', { name, exact }); 30 | } 31 | 32 | encounteredError() { 33 | return this.status.error.isVisible(); 34 | } 35 | 36 | async expectServerStarts(timeout = 30 * 1000) { 37 | const anyStatusVisible = async () => await expect(this.status.get()).resolves.not.toBe('unknown'); 38 | 39 | await expect(anyStatusVisible).toPass({ timeout, intervals: [500] }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/debug_all.yml: -------------------------------------------------------------------------------- 1 | name: Build - All Platforms 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | paths-ignore: 7 | - '.github/**' 8 | - '.prettierrc' 9 | - '.eslintrc.json' 10 | - '.prettierignore' 11 | - 'README.md' 12 | - '.husky/**' 13 | - '.vscode/**' 14 | - 'scripts/**' 15 | - '.gitignore' 16 | - 'todesktop.json' 17 | - '.github/ISSUE_TEMPLATE/**' 18 | - '.cursor/**' 19 | - '.claude/**' 20 | - 'CLAUDE.md' 21 | - '*_example' 22 | - 'tests/unit/**' 23 | push: 24 | branches: [main] 25 | paths-ignore: 26 | - '.github/**' 27 | - '.prettierrc' 28 | - '.eslintrc.json' 29 | - '.prettierignore' 30 | - 'README.md' 31 | - '.husky/**' 32 | - '.vscode/**' 33 | - 'scripts/**' 34 | - '.gitignore' 35 | - 'todesktop.json' 36 | - '.github/ISSUE_TEMPLATE/**' 37 | - '.cursor/**' 38 | - '.claude/**' 39 | - 'CLAUDE.md' 40 | - '*_example' 41 | - 'tests/unit/**' 42 | 43 | concurrency: 44 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 45 | cancel-in-progress: true 46 | 47 | jobs: 48 | build-and-test-e2e-windows: 49 | secrets: inherit 50 | uses: ./.github/workflows/integration_test_windows.yml 51 | build-apple-debug-all: 52 | secrets: inherit 53 | uses: ./.github/workflows/debug_macos.yml 54 | -------------------------------------------------------------------------------- /tests/integration/install/installWizard.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '../testExtensions'; 2 | 3 | test.describe('Install Wizard', () => { 4 | test('can click through first time installer', async ({ installWizard, window, attachScreenshot }) => { 5 | await attachScreenshot('screenshot-app-start'); 6 | 7 | const getStartedButton = window.getByText('Get Started'); 8 | await expect(getStartedButton).toBeVisible(); 9 | await expect(getStartedButton).toBeEnabled(); 10 | await expect(window).toHaveScreenshot('get-started.png'); 11 | await installWizard.clickGetStarted(); 12 | 13 | // Select GPU screen 14 | await expect(installWizard.selectGpuTitle).toBeVisible(); 15 | await expect(installWizard.cpuToggle).toBeVisible(); 16 | await expect(window).toHaveScreenshot('select-gpu.png'); 17 | await installWizard.cpuToggle.click(); 18 | 19 | await expect(window).toHaveScreenshot('cpu-clicked.png'); 20 | await installWizard.clickNext(); 21 | 22 | // Install stepper screens 23 | await expect(installWizard.installLocationTitle).toBeVisible(); 24 | await expect(installWizard.migrateTitle).toBeVisible(); 25 | await expect.soft(window).toHaveScreenshot('choose-installation-location.png'); 26 | await installWizard.clickNext(); 27 | 28 | await expect(installWizard.desktopSettingsTitle).toBeVisible(); 29 | await expect(window).toHaveScreenshot('desktop-app-settings.png'); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /infrastructure/viteElectronAppPlugin.ts: -------------------------------------------------------------------------------- 1 | import electronPath from 'electron'; 2 | import { type ChildProcess, spawn } from 'node:child_process'; 3 | import type { PluginOption, UserConfig } from 'vite'; 4 | 5 | /** 6 | * Loads the electron app whenever vite is loaded in watch mode. 7 | * Reloads the app after the bundle has been written and closed. 8 | * 9 | * Only operates in watch mode. 10 | */ 11 | export function viteElectronAppPlugin(): PluginOption { 12 | const startApp = () => { 13 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 14 | electronApp = spawn(String(electronPath), ['--inspect=9223', '.'], { stdio: 'inherit' }); 15 | // eslint-disable-next-line unicorn/no-process-exit 16 | electronApp.addListener('exit', () => process.exit()); 17 | }; 18 | 19 | let electronApp: ChildProcess | null = null; 20 | let mode: string | undefined; 21 | 22 | return { 23 | name: 'Load Electron app in watch mode', 24 | apply: 'build', 25 | config(config: UserConfig) { 26 | mode = config.mode; 27 | }, 28 | buildStart() { 29 | // Only operate in watch mode. 30 | if (this.meta.watchMode !== true || !electronApp) return; 31 | 32 | electronApp.removeAllListeners(); 33 | electronApp.kill('SIGINT'); 34 | electronApp = null; 35 | }, 36 | closeBundle() { 37 | // Only operate in watch mode. 38 | if (this.meta.watchMode === true && mode === 'startwatch') startApp(); 39 | }, 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /tests/integration/post-install/troubleshootingVenv.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '../testExtensions'; 2 | 3 | test.describe('Troubleshooting - broken venv', () => { 4 | test.beforeEach(async ({ testEnvironment }) => { 5 | await testEnvironment.breakVenv(); 6 | }); 7 | 8 | test('Troubleshooting page loads when venv is broken', async ({ troubleshooting, window }) => { 9 | await troubleshooting.expectReady(); 10 | await expect(troubleshooting.resetVenvCard.rootEl).toBeVisible(); 11 | await expect(window).toHaveScreenshot('troubleshooting-venv.png'); 12 | }); 13 | 14 | test('Can fix venv', async ({ troubleshooting, installedApp }) => { 15 | test.slow(); 16 | 17 | await troubleshooting.expectReady(); 18 | const { resetVenvCard, installPythonPackagesCard } = troubleshooting; 19 | await expect(resetVenvCard.rootEl).toBeVisible(); 20 | 21 | await resetVenvCard.button.click(); 22 | await troubleshooting.confirmRecreateVenvButton.click(); 23 | await expect(resetVenvCard.isRunningIndicator).toBeVisible(); 24 | 25 | await expect(installPythonPackagesCard.rootEl).toBeVisible({ timeout: 60 * 1000 }); 26 | await installPythonPackagesCard.button.click(); 27 | await troubleshooting.confirmInstallPythonPackagesButton.click(); 28 | await expect(installPythonPackagesCard.isRunningIndicator).toBeVisible(); 29 | 30 | // Venv fixed - server should start 31 | await installedApp.waitUntilLoaded(3 * 60 * 1000); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /.github/actions/build/macos/comfy/action.yml: -------------------------------------------------------------------------------- 1 | name: Build Comfycli for Macos 2 | description: Download, install, and set up Comfy 3 | 4 | inputs: 5 | sign-and-publish: 6 | description: 'Sign the executable and publish to release page' 7 | default: 'false' 8 | required: false 9 | runs: 10 | using: composite 11 | steps: 12 | - name: Install Python 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: '3.12' 16 | 17 | - name: Make Standalone 18 | shell: sh 19 | run: | 20 | python -m pip install --upgrade pip 21 | yarn make:assets 22 | 23 | - name: Unzip Sign Lib/Bin Rezip 24 | if: ${{inputs.sign-and-publish == 'true'}} 25 | shell: bash 26 | ## TODO: Both of these need to be a more manageable list 27 | run: | 28 | cd ./assets/ 29 | mkdir python2/ 30 | tar -xzf python.tgz -C python2/ 31 | rm python.tgz 32 | echo Sign Libs and Bins 33 | cd python2/python/ 34 | filelist=("lib/libpython3.12.dylib" "lib/python3.12/lib-dynload/_crypt.cpython-312-darwin.so" "bin/uv" "bin/uvx" "bin/python3.12") 35 | for file in ${filelist[@]}; do codesign --sign 6698D856280DC1662A8E01E5B63428CB6D6651BB --force --timestamp --options runtime --entitlements ../../../scripts/entitlements.mac.plist "$file"; done 36 | echo Rezip 37 | cd ../../ 38 | mv python python3 39 | mv python2/python python 40 | tar -czf python.tgz python/ 41 | -------------------------------------------------------------------------------- /src/handlers/appInfoHandlers.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | 3 | import { strictIpcMain as ipcMain } from '@/infrastructure/ipcChannels'; 4 | 5 | import { IPC_CHANNELS } from '../constants'; 6 | import type { TorchDeviceType } from '../preload'; 7 | import { useDesktopConfig } from '../store/desktopConfig'; 8 | import type { DesktopWindowStyle } from '../store/desktopSettings'; 9 | 10 | /** 11 | * Handles information about the app and current state in IPC channels. 12 | */ 13 | export function registerAppInfoHandlers() { 14 | ipcMain.handle(IPC_CHANNELS.IS_PACKAGED, () => { 15 | return app.isPackaged; 16 | }); 17 | 18 | ipcMain.handle(IPC_CHANNELS.GET_ELECTRON_VERSION, () => { 19 | return app.getVersion(); 20 | }); 21 | 22 | ipcMain.handle(IPC_CHANNELS.GET_BASE_PATH, (): string | undefined => { 23 | return useDesktopConfig().get('basePath'); 24 | }); 25 | 26 | // Config 27 | ipcMain.handle(IPC_CHANNELS.GET_GPU, async (): Promise => { 28 | return await useDesktopConfig().getAsync('detectedGpu'); 29 | }); 30 | ipcMain.handle( 31 | IPC_CHANNELS.SET_WINDOW_STYLE, 32 | async (_event: Electron.IpcMainInvokeEvent, style: DesktopWindowStyle): Promise => { 33 | await useDesktopConfig().setAsync('windowStyle', style); 34 | } 35 | ); 36 | ipcMain.handle(IPC_CHANNELS.GET_WINDOW_STYLE, async (): Promise => { 37 | return await useDesktopConfig().getAsync('windowStyle'); 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /tests/integration/post-install.setup.ts: -------------------------------------------------------------------------------- 1 | import { expect, test as setup } from './testExtensions'; 2 | 3 | // This "test" is a setup process. Any failure here should break all post-install tests. 4 | // After running, the test environment will contain an installed ComfyUI app, ready for other tests to use as a base. 5 | 6 | setup('Post-install Setup', async ({ installWizard, installedApp, serverStart, attachScreenshot }) => { 7 | setup.slow(); 8 | 9 | await installWizard.clickGetStarted(); 10 | 11 | // Select CPU as torch device 12 | await installWizard.cpuToggle.click(); 13 | await installWizard.clickNext(); 14 | 15 | // Install to default location 16 | await expect(installWizard.installLocationTitle).toBeVisible(); 17 | await installWizard.clickNext(); 18 | 19 | await expect(installWizard.desktopSettingsTitle).toBeVisible(); 20 | await installWizard.installButton.click(); 21 | 22 | await serverStart.expectServerStarts(5 * 1000); 23 | 24 | // When the terminal is hidden and no error is shown, the install is successful 25 | await expect(serverStart.terminal).not.toBeVisible({ timeout: 5 * 60 * 1000 }); 26 | await expect(serverStart.status.error).not.toBeVisible(); 27 | await expect(serverStart.showTerminalButton).not.toBeVisible(); 28 | 29 | await installedApp.waitUntilLoaded(); 30 | 31 | // Always attach archival screenshot of installed app state 32 | await expect(installedApp.firstTimeTemplateWorkflowText).toBeVisible({ timeout: 30 * 1000 }); 33 | await attachScreenshot('installed app state.png'); 34 | }); 35 | -------------------------------------------------------------------------------- /scripts/preMake.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import { spawnSync } from 'node:child_process'; 3 | import * as os from 'node:os'; 4 | import process from 'node:process'; 5 | 6 | /** @param {{ appOutDir, packager, outDir }} arg0 */ 7 | const preMake = () => { 8 | const firstInstallOnToDesktopServers = process.env.TODESKTOP_CI && process.env.TODESKTOP_INITIAL_INSTALL_PHASE; 9 | // Do NOT run on CI 10 | if (process.env.CI || firstInstallOnToDesktopServers) return; 11 | 12 | const isNvidia = process.argv.at(-1) === '--nvidia'; 13 | 14 | console.log(``); 15 | 16 | // If this folder is here assume comfy has already been installed 17 | if (fs.existsSync('./assets/ComfyUI')) { 18 | console.log('>COMFYUI ALREADY BUILT<'); 19 | return; 20 | } 21 | 22 | if (os.platform() === 'darwin') { 23 | spawnSync('yarn run make:assets', [''], { shell: true, stdio: 'inherit' }); 24 | } 25 | 26 | if (os.platform() === 'win32') { 27 | const result = spawnSync( 28 | `python -c "import os,sysconfig;print(sysconfig.get_path(""scripts"",f""{os.name}_user""))"`, 29 | [''], 30 | { shell: true, stdio: 'pipe' } 31 | ).stdout.toString(); 32 | const localPythonModulePath = `PATH=${result.replaceAll('\\', '\\\\').trim()};%PATH%`; 33 | spawnSync(`set ${localPythonModulePath} && yarn run make:assets`, [''], { 34 | shell: true, 35 | stdio: 'inherit', 36 | }); 37 | } 38 | console.log('>PREMAKE FINISH<'); 39 | }; 40 | export default preMake; 41 | -------------------------------------------------------------------------------- /src/store/desktopSettings.ts: -------------------------------------------------------------------------------- 1 | import type { GpuType, TorchDeviceType } from '../preload'; 2 | 3 | export type DesktopInstallState = 'started' | 'installed' | 'upgraded'; 4 | 5 | export type DesktopWindowStyle = 'custom' | 'default'; 6 | 7 | export type DesktopSettings = { 8 | basePath?: string; 9 | /** 10 | * The state of the installation. 11 | * - `started`: The installation has started. 12 | * - `installed`: A fresh installation. 13 | * - `upgraded`: An upgrade from a previous version that stores the base path 14 | * in the yaml config. 15 | */ 16 | installState?: DesktopInstallState; 17 | /** 18 | * The path to the migration installation to migrate custom nodes from 19 | */ 20 | migrateCustomNodesFrom?: string; 21 | /** 22 | * The last GPU that was detected during hardware validation. 23 | * Allows manual override of some install behaviour. 24 | */ 25 | detectedGpu?: GpuType; 26 | /** The pytorch device that the user selected during installation. */ 27 | selectedDevice?: TorchDeviceType; 28 | /** 29 | * Controls whether to use a custom window on linux/win32 30 | * - `custom`: Modern, theme-reactive, feels like an integral part of the UI 31 | * - `default`: Impersonal, static, plain - default window title bar 32 | */ 33 | windowStyle?: DesktopWindowStyle; 34 | /** The version of comfyui-electron on which the user last consented to metrics. */ 35 | versionConsentedMetrics?: string; 36 | /** Whether the user has generated an image successfully. */ 37 | hasGeneratedSuccessfully?: boolean; 38 | }; 39 | -------------------------------------------------------------------------------- /scripts/launchCI.js: -------------------------------------------------------------------------------- 1 | import electronPath from 'electron'; 2 | import { spawn } from 'node:child_process'; 3 | import { build } from 'vite'; 4 | 5 | // Starts the app using the vite dev server, for use in playwright e2e testing. 6 | // Needs to be replaced with something more permanent at some point. 7 | 8 | /** @type {'production' | 'development'} */ 9 | const mode = (process.env.MODE = process.env.MODE || 'development'); 10 | 11 | /** @type {import('vite').LogLevel} */ 12 | const logLevel = 'warn'; 13 | 14 | /** @returns {import('vite').PluginOption} */ 15 | function runAppAfterBuild() { 16 | return { 17 | name: 'reload-app-on-main-package-change-a', 18 | writeBundle() { 19 | // CI-specific Electron launch args 20 | const args = ['--remote-debugging-port=9000', '--remote-allow-origins=http://127.0.0.1:9000', '.']; 21 | 22 | /** Spawn new electron process */ 23 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 24 | const electronApp = spawn(String(electronPath), args, { stdio: 'inherit' }); 25 | 26 | /** Stops the watch script when the application has been quit */ 27 | electronApp.addListener('exit', () => process.exit()); 28 | }, 29 | }; 30 | } 31 | 32 | /** 33 | * Setup watcher for `main` package 34 | * On file changed it totally re-launch electron app. 35 | */ 36 | function setupMainPackageWatcher() { 37 | return build({ 38 | mode, 39 | logLevel, 40 | configFile: 'vite.config.ts', 41 | plugins: [runAppAfterBuild()], 42 | }); 43 | } 44 | 45 | await setupMainPackageWatcher(); 46 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Unit Test, Lint, and Format 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | paths-ignore: 7 | - '.github/**' 8 | - '.github/ISSUE_TEMPLATE/**' 9 | - '.cursor/**' 10 | - '.husky/**' 11 | - '.vscode/**' 12 | - '.claude/**' 13 | - 'CLAUDE.md' 14 | - '*_example' 15 | push: 16 | branches: [main] 17 | paths-ignore: 18 | - '.github/**' 19 | - '.github/ISSUE_TEMPLATE/**' 20 | - '.cursor/**' 21 | - '.husky/**' 22 | - '.vscode/**' 23 | - '.claude/**' 24 | - 'CLAUDE.md' 25 | - '*_example' 26 | 27 | concurrency: 28 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 29 | cancel-in-progress: true 30 | 31 | jobs: 32 | lint-and-format: 33 | runs-on: ${{ matrix.os }} 34 | strategy: 35 | matrix: 36 | os: [macos-latest, windows-latest] 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | 41 | - name: Use Node.js 20.x 42 | uses: JP250552/setup-node@0c618ceb2e48275dc06e86901822fd966ce75ba2 43 | with: 44 | node-version: '20.x' 45 | corepack: true 46 | 47 | - name: Install Dependencies 48 | run: yarn install 49 | 50 | - name: Run type check 51 | run: yarn tsc --noEmit --strict 52 | 53 | - name: Check lint 54 | run: yarn lint:check 55 | 56 | - name: Run Unit Tests 57 | run: yarn run test:unit 58 | 59 | - name: Check Prettier Formatting 60 | run: yarn run format:check 61 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | ## Unit Tests 4 | 5 | Unit tests are run with vitest. Tests are run in parallel. 6 | 7 | ### Running 8 | 9 | ```bash 10 | yarn run test:unit 11 | ``` 12 | 13 | ## End-to-End Tests 14 | 15 | End-to-end tests are run with Playwright. Tests are run sequentially. 16 | 17 | Tests are intended to be run on virtualised, disposable systems, such as CI runners. 18 | 19 | > [!CAUTION] 20 | > End-to-end tests erase settings and other app data. They will delete ComfyUI directories without warning. 21 | 22 | ### Enabling E2E tests 23 | 24 | To run tests properly outside of CI, set env var `COMFYUI_ENABLE_VOLATILE_TESTS=1` or use `.env.test`. 25 | 26 | > [!TIP] 27 | > Copy `.env.test_example` to `.env.test` and modify as needed. 28 | 29 | ### Running 30 | 31 | ```bash 32 | yarn run test:e2e 33 | ``` 34 | 35 | > [!NOTE] 36 | > As a precaution, if the app data directory already exists, it will have a random suffix appended to its name. 37 | 38 | App data directories: 39 | 40 | - `%APPDATA%\ComfyUI` (Windows) 41 | - `Application Support/ComfyUI` (Mac) 42 | 43 | ### Updating screenshots (snapshots) 44 | 45 | When test screenshots are out of date, they must be updated with the following process: 46 | 47 | 1. Run tests 48 | 2. Manually verify that the only things changed are what's expected 49 | 3. Run this locally: 50 | ```bash 51 | npm run test:e2e:update 52 | ``` 53 | 4. Commit new expectations 54 | 55 | > [!TIP] 56 | > All screenshot expectations are overwritten by playwright. To update a single test, discard any unrelated changes before committing. 57 | -------------------------------------------------------------------------------- /tests/unit/shell/util.test.ts: -------------------------------------------------------------------------------- 1 | import os from 'node:os'; 2 | import { describe, expect, it, vi } from 'vitest'; 3 | 4 | import { getDefaultShell, getDefaultShellArgs } from '../../../src/shell/util'; 5 | 6 | vi.mock('node:os'); 7 | 8 | describe('shell utilities', () => { 9 | describe('getDefaultShell', () => { 10 | it('should return powershell path on Windows', () => { 11 | vi.spyOn(os, 'platform').mockReturnValue('win32'); 12 | process.env.SYSTEMROOT = String.raw`C:\Windows`; 13 | expect(getDefaultShell()).toBe(String.raw`C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`); 14 | }); 15 | 16 | it('should return zsh on macOS', () => { 17 | vi.spyOn(os, 'platform').mockReturnValue('darwin'); 18 | expect(getDefaultShell()).toBe('zsh'); 19 | }); 20 | 21 | it('should return bash on Linux', () => { 22 | vi.spyOn(os, 'platform').mockReturnValue('linux'); 23 | expect(getDefaultShell()).toBe('bash'); 24 | }); 25 | }); 26 | 27 | describe('getDefaultShellArgs', () => { 28 | it('should return ["-df"] on macOS', () => { 29 | vi.spyOn(os, 'platform').mockReturnValue('darwin'); 30 | expect(getDefaultShellArgs()).toEqual(['-df']); 31 | }); 32 | 33 | it('should return empty array on Windows', () => { 34 | vi.spyOn(os, 'platform').mockReturnValue('win32'); 35 | expect(getDefaultShellArgs()).toEqual([]); 36 | }); 37 | 38 | it('should return noprofile and norc on Linux', () => { 39 | vi.spyOn(os, 'platform').mockReturnValue('linux'); 40 | expect(getDefaultShellArgs()).toEqual(['--noprofile', '--norc']); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /.cursor/rules/playwright.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Playwright End-to-End Testing 3 | globs: 4 | --- 5 | 6 | # Playwright End-to-End Testing 7 | 8 | - This project uses `Playwright` for end-to-end testing 9 | - Tests are stored in subdirectories of `tests/integration/` 10 | - Directory structure of `tests/integration/` is based on the test phase the tests 11 | - Tests should use the convenience methods and properties defined in the helper classes 12 | - Prefer adding properties to test classes over using locator selectors directly in tests 13 | - Test classes are stored in the `tests/integrations` directory 14 | - Tests should be cross-platform compatible; able to run on Windows, macOS, and linux 15 | - e.g. the use of `path.join` and `path.sep` to ensure tests work the same on all platforms 16 | 17 | ## Test phases (Playwright projects) 18 | 19 | 1. `install`: The install phase resets the entire test environment after each test. 20 | 2. `post-install-setup`: Installs the app with default settings, creating a baseline for each test in the next phase (`post-install`) 21 | 3. `post-install`: Tests post-install features of the desktop app, with each test starting from the state set in `post-install-setup` 22 | 23 | ## Spec files: \*.spec.ts 24 | 25 | - Prefer imports from [testExtensions.ts](mdc:tests/integration/testExtensions.ts) over playwright defaults. 26 | 27 | ## Snapshots 28 | 29 | - Update E2E snapshots with `yarn test:e2e:update` 30 | 31 | ## Test helper classes: tests/integration/test\*.ts 32 | 33 | - These files contain clean methods and properties for use in spec files 34 | - Use of these classes is preferred whenever possible, as they make the code easier to read 35 | -------------------------------------------------------------------------------- /tests/integration/install/installApp.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '../testExtensions'; 2 | 3 | test.describe('Install App', () => { 4 | test('Can install app', async ({ installWizard, installedApp, serverStart, testEnvironment }) => { 5 | test.slow(); 6 | 7 | await installWizard.clickGetStarted(); 8 | 9 | // Select CPU as torch device 10 | await installWizard.cpuToggle.click(); 11 | await installWizard.clickNext(); 12 | 13 | // Install to temp dir 14 | const { installLocation } = testEnvironment; 15 | await expect(installWizard.installLocationTitle).toBeVisible(); 16 | await installWizard.installLocationInput.fill(installLocation.path); 17 | await installWizard.clickNext(); 18 | 19 | // Install stepper screens 20 | await expect(installWizard.desktopSettingsTitle).toBeVisible(); 21 | await installWizard.installButton.click(); 22 | 23 | const status = await serverStart.status.get(); 24 | expect(['loading', 'setting up python']).toContain(status); 25 | 26 | // When the terminal is hidden and no error is shown, the install is successful 27 | await expect(serverStart.terminal).not.toBeVisible({ timeout: 5 * 60 * 1000 }); 28 | await expect(serverStart.status.error).not.toBeVisible(); 29 | await expect(serverStart.showTerminalButton).not.toBeVisible(); 30 | 31 | // Wait for the progress spinner to disappear 32 | await installedApp.waitUntilLoaded(); 33 | 34 | // Confirm post-install app state is as expected 35 | await expect(installedApp.firstTimeTemplateWorkflowText).toBeVisible({ timeout: 30 * 1000 }); 36 | await expect(installedApp.templatesGrid).toBeVisible({ timeout: 30 * 1000 }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /scripts/releaseTypes.js: -------------------------------------------------------------------------------- 1 | import { execSync } from 'node:child_process'; 2 | import { readFileSync } from 'node:fs'; 3 | 4 | try { 5 | // Create a new branch with version-bump prefix 6 | console.log('Creating new branch...'); 7 | const date = new Date(); 8 | const isoDate = date.toISOString().split('T')[0]; 9 | const timestamp = date.getTime(); 10 | const branchName = `version-bump-${isoDate}-${timestamp}`; 11 | execSync(`git checkout -b ${branchName} -t origin/main`, { stdio: 'inherit' }); 12 | 13 | // Run npm version patch and capture the output 14 | console.log('Bumping version...'); 15 | execSync('yarn version patch', { stdio: 'inherit' }); 16 | 17 | // Read the new version from package.json 18 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 19 | const packageJson = JSON.parse(readFileSync('./package.json', 'utf8')); 20 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access 21 | const newVersion = packageJson.version; 22 | 23 | // Messaging 24 | const message = `[API] Publish types for version ${newVersion}`; 25 | const prBody = `- Automated minor version bump to: ${newVersion}\n- Triggers npm publish workflow of API types`; 26 | 27 | // Commit the version bump 28 | execSync(`git commit -am "${message}" --no-verify`, { stdio: 'inherit' }); 29 | 30 | // Create the PR 31 | console.log('Creating PR...'); 32 | execSync(`gh pr create --title "${message}" --label "ReleaseTypes" --body "${prBody}"`, { stdio: 'inherit' }); 33 | 34 | console.log(`✅ Successfully created PR for version ${newVersion}`); 35 | } catch (error) { 36 | console.error('❌ Error during release process:', error.message); 37 | } 38 | -------------------------------------------------------------------------------- /scripts/makeComfy.js: -------------------------------------------------------------------------------- 1 | import { execSync } from 'node:child_process'; 2 | import fs from 'node:fs'; 3 | import path from 'node:path'; 4 | 5 | import pkg from './getPackage.js'; 6 | 7 | const comfyRepo = 'https://github.com/comfyanonymous/ComfyUI'; 8 | const managerRepo = 'https://github.com/ltdrdata/ComfyUI-Manager'; 9 | 10 | /** Suppress warning about detached head */ 11 | const noWarning = '-c advice.detachedHead=false'; 12 | 13 | if (pkg.config.comfyUI.optionalBranch) { 14 | // Checkout branch. 15 | execAndLog(`git clone ${comfyRepo} --depth 1 --branch ${pkg.config.comfyUI.optionalBranch} assets/ComfyUI`); 16 | } else { 17 | // Checkout tag as branch. 18 | execAndLog(`git ${noWarning} clone ${comfyRepo} --depth 1 --branch v${pkg.config.comfyUI.version} assets/ComfyUI`); 19 | } 20 | const assetsComfyPath = path.join('assets', 'ComfyUI'); 21 | const managerRequirementsPath = path.join(assetsComfyPath, 'manager_requirements.txt'); 22 | 23 | if (fs.existsSync(managerRequirementsPath)) { 24 | console.log('Detected manager_requirements.txt, skipping legacy ComfyUI-Manager clone.'); 25 | } else { 26 | execAndLog(`git clone ${managerRepo} assets/ComfyUI/custom_nodes/ComfyUI-Manager`); 27 | execAndLog( 28 | `cd assets/ComfyUI/custom_nodes/ComfyUI-Manager && git ${noWarning} checkout ${pkg.config.managerCommit} && cd ../../..` 29 | ); 30 | } 31 | execAndLog(`yarn run make:frontend`); 32 | execAndLog(`yarn run download:uv all`); 33 | execAndLog(`yarn run patch:core:frontend`); 34 | /** 35 | * Run a command and log the output. 36 | * @param {string} command The command to run. 37 | */ 38 | function execAndLog(command) { 39 | const output = execSync(command, { encoding: 'utf8' }); 40 | console.log(output); 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/release_types.yml: -------------------------------------------------------------------------------- 1 | name: Publish ElectronAPI to npm 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | types: [closed] 7 | branches: 8 | - main 9 | paths: 10 | - 'package.json' 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | publish: 18 | runs-on: ubuntu-latest 19 | if: > 20 | github.event_name == 'workflow_dispatch' || 21 | (github.event.pull_request.merged == true && 22 | contains(github.event.pull_request.labels.*.name, 'ReleaseTypes')) 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - name: Use Node.js 20.x 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: '20.x' 30 | registry-url: 'https://registry.npmjs.org' 31 | 32 | - name: Enable Corepack 33 | run: corepack enable 34 | 35 | - name: Enable Corepack and pin Yarn 36 | run: | 37 | corepack enable 38 | corepack prepare yarn@4.5.0 --activate 39 | yarn --version 40 | 41 | - name: Cache yarn dependencies 42 | uses: actions/cache@v4 43 | with: 44 | path: .yarn/cache 45 | key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock', '.yarnrc.yml') }} 46 | restore-keys: | 47 | ${{ runner.os }}-yarn- 48 | 49 | - name: Install dependencies 50 | run: yarn install --immutable 51 | 52 | - name: Build types 53 | run: yarn run vite:types 54 | 55 | - name: Publish package 56 | run: npm publish --access public 57 | working-directory: ./dist 58 | env: 59 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 60 | -------------------------------------------------------------------------------- /tests/integration/post-install/troubleshooting.spec.ts: -------------------------------------------------------------------------------- 1 | import { getDefaultInstallLocation } from 'tests/shared/utils'; 2 | 3 | import { expect, test } from '../testExtensions'; 4 | 5 | test.describe('Troubleshooting - broken install path', () => { 6 | test.beforeEach(async ({ testEnvironment }) => { 7 | await testEnvironment.breakInstallPath(); 8 | }); 9 | 10 | test('Troubleshooting page loads when base path is invalid', async ({ troubleshooting, window }) => { 11 | await troubleshooting.expectReady(); 12 | await expect(troubleshooting.basePathCard.rootEl).toBeVisible(); 13 | await expect(window).toHaveScreenshot('troubleshooting.png'); 14 | }); 15 | 16 | test('Refresh button is disabled whilst refreshing', async ({ troubleshooting }) => { 17 | await troubleshooting.refresh(); 18 | await expect(troubleshooting.refreshButton).toBeDisabled(); 19 | 20 | // Wait for the refresh to complete 21 | await troubleshooting.expectReady(); 22 | }); 23 | 24 | test('Can fix install path', async ({ troubleshooting, app, installedApp }) => { 25 | await troubleshooting.expectReady(); 26 | const { basePathCard } = troubleshooting; 27 | await expect(basePathCard.rootEl).toBeVisible(); 28 | 29 | const filePath = getDefaultInstallLocation(); 30 | await app.app.evaluate((electron, filePath) => { 31 | // "Mock" the native dialog 32 | electron.dialog.showOpenDialog = async () => { 33 | await new Promise((resolve) => setTimeout(resolve, 250)); 34 | return { canceled: false, filePaths: [filePath] }; 35 | }; 36 | }, filePath); 37 | 38 | await basePathCard.button.click(); 39 | 40 | // Venv fixed - server should start 41 | await installedApp.waitUntilLoaded(2 * 60 * 1000); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/main-process/devOverrides.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import log from 'electron-log/main'; 3 | 4 | /** 5 | * Reads environment variables and provides a simple interface for development overrides. 6 | * 7 | * In production, overrides are disabled (`undefined`). Use the `--dev-mode` command line argument to re-enable them. 8 | */ 9 | export class DevOverrides { 10 | /** The host to use for the ComfyUI server. */ 11 | public readonly COMFY_HOST?: string; 12 | /** The port to use for the ComfyUI server. */ 13 | public readonly COMFY_PORT?: string; 14 | /** Forces the Desktop UI to be loaded from this URL (e.g. vite dev server). */ 15 | public readonly DEV_SERVER_URL?: string; 16 | /** Loads the ComfyUI frontend from this URL (e.g. vite dev server). */ 17 | public readonly DEV_FRONTEND_URL?: string; 18 | /** Whether to use an external server instead of starting one locally. */ 19 | public readonly USE_EXTERNAL_SERVER?: string; 20 | /** When DEV_SERVER_URL is set, whether to automatically open dev tools on app start. */ 21 | public readonly DEV_TOOLS_AUTO?: string; 22 | /** Send events to Sentry */ 23 | public readonly SENTRY_ENABLED?: string; 24 | 25 | constructor() { 26 | if (app.commandLine.hasSwitch('dev-mode') || !app.isPackaged) { 27 | log.info('Developer environment variable overrides enabled.'); 28 | 29 | this.DEV_SERVER_URL = process.env.DEV_SERVER_URL; 30 | this.DEV_FRONTEND_URL = process.env.DEV_FRONTEND_URL; 31 | this.COMFY_HOST = process.env.COMFY_HOST; 32 | this.COMFY_PORT = process.env.COMFY_PORT; 33 | this.USE_EXTERNAL_SERVER = process.env.USE_EXTERNAL_SERVER; 34 | this.DEV_TOOLS_AUTO = process.env.DEV_TOOLS_AUTO; 35 | this.SENTRY_ENABLED = process.env.SENTRY_ENABLED; 36 | } 37 | } 38 | 39 | get useExternalServer() { 40 | return this.USE_EXTERNAL_SERVER === 'true'; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/update_test_expectations.yml: -------------------------------------------------------------------------------- 1 | # Update Test Expectations for Playwright 2 | name: Update Test Expectations 3 | 4 | on: 5 | pull_request: 6 | types: [labeled] 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | test: 14 | runs-on: windows-latest 15 | if: github.event.label.name == 'New Browser Test Expectations' 16 | env: 17 | SKIP_HARDWARE_VALIDATION: 'true' 18 | LOG_LEVEL: 'debug' 19 | 20 | steps: 21 | - name: Github checkout 22 | uses: actions/checkout@v4 23 | 24 | - name: Build 25 | uses: ./.github/actions/build/windows/app 26 | with: 27 | sign-and-publish: false 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Set display resolution 31 | run: Set-DisplayResolution -Width 1920 -Height 1080 -Force 32 | 33 | - name: Run Playwright Tests 34 | run: npm run test:e2e:update 35 | continue-on-error: true 36 | 37 | - name: Upload Test Results 38 | uses: actions/upload-artifact@v4 39 | if: always() 40 | with: 41 | name: tests 42 | path: tests/ 43 | retention-days: 30 44 | 45 | - name: Debugging info 46 | run: | 47 | echo "Branch: ${{ github.head_ref }}" 48 | git status 49 | 50 | - name: Commit updated expectations 51 | run: | 52 | git config --global user.name 'github-actions' 53 | git config --global user.email 'github-actions@github.com' 54 | git fetch origin ${{ github.head_ref }} 55 | git checkout -B ${{ github.head_ref }} origin/${{ github.head_ref }} 56 | git add tests 57 | git commit -m "Update test expectations" || echo "No changes to commit" 58 | git push origin HEAD:${{ github.head_ref }} 59 | -------------------------------------------------------------------------------- /tests/integration/testInstallWizard.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from '@playwright/test'; 2 | 3 | /* CI is slow. */ 4 | const getStartedTimeout = process.env.CI ? { timeout: 60 * 1000 } : undefined; 5 | 6 | export class TestInstallWizard { 7 | readonly getStartedButton; 8 | readonly nextButton; 9 | readonly installButton; 10 | 11 | readonly cpuToggle; 12 | readonly installLocationInput; 13 | 14 | readonly selectGpuTitle; 15 | readonly installLocationTitle; 16 | readonly migrateTitle; 17 | readonly desktopSettingsTitle; 18 | 19 | constructor(readonly window: Page) { 20 | this.nextButton = this.getButton('Next'); 21 | this.getStartedButton = this.getButton('Get Started'); 22 | this.installButton = this.getButton('Install'); 23 | 24 | // Updated selectors for frontend v1.27.x 25 | this.cpuToggle = this.window.getByRole('button', { name: 'CPU' }); 26 | // The install path input is the visible textbox on Step 2 27 | // Prefer placeholder to avoid ambiguity with hidden inputs 28 | this.installLocationInput = this.window.getByPlaceholder(/ComfyUI/).first(); 29 | 30 | this.selectGpuTitle = this.window.getByText('Choose your hardware setup'); 31 | this.installLocationTitle = this.window.getByText('Choose where to install ComfyUI'); 32 | // Migration is now an accordion section on the install location step 33 | this.migrateTitle = this.window.getByText('Migrate from existing installation'); 34 | this.desktopSettingsTitle = this.window.getByText('Desktop App Settings'); 35 | } 36 | 37 | async clickNext() { 38 | await this.nextButton.click(); 39 | } 40 | 41 | async clickGetStarted() { 42 | await this.getStartedButton.click(getStartedTimeout); 43 | } 44 | 45 | getButton(name: string) { 46 | return this.window.getByRole('button', { name }); 47 | } 48 | 49 | getInput(name: string, exact?: boolean) { 50 | return this.window.getByRole('textbox', { name, exact }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { sentryVitePlugin } from '@sentry/vite-plugin'; 3 | import { UserConfig } from 'vite'; 4 | import { defineConfig, mergeConfig } from 'vite'; 5 | 6 | import { viteElectronAppPlugin } from './infrastructure/viteElectronAppPlugin'; 7 | import { version } from './package.json'; 8 | import { external, getBuildConfig } from './vite.base.config'; 9 | 10 | // https://vitejs.dev/config 11 | export default defineConfig((env) => { 12 | const config: UserConfig = { 13 | build: { 14 | outDir: '.vite/build', 15 | lib: { 16 | entry: './src/main.ts', 17 | fileName: (_format, name) => `${name}.cjs`, 18 | formats: ['cjs'], 19 | }, 20 | rollupOptions: { external }, 21 | sourcemap: true, 22 | minify: false, 23 | }, 24 | server: { 25 | watch: { 26 | ignored: ['**/assets/ComfyUI/**', 'venv/**'], 27 | }, 28 | }, 29 | plugins: [ 30 | // Custom hot reload solution for vite 6 31 | viteElectronAppPlugin(), 32 | process.env.NODE_ENV === 'production' 33 | ? sentryVitePlugin({ 34 | org: 'comfy-org', 35 | project: 'desktop', 36 | authToken: process.env.SENTRY_AUTH_TOKEN, 37 | release: { 38 | name: `ComfyUI@${version}`, 39 | }, 40 | }) 41 | : undefined, 42 | ], 43 | define: { 44 | VITE_NAME: JSON.stringify('COMFY'), 45 | 'process.env.PUBLISH': `"${process.env.PUBLISH}"`, 46 | }, 47 | resolve: { 48 | // Load the Node.js entry. 49 | mainFields: ['module', 'jsnext:main', 'jsnext'], 50 | }, 51 | test: { 52 | name: 'main', 53 | include: ['tests/unit/**/*.test.ts'], 54 | setupFiles: ['./tests/unit/setup.ts'], 55 | restoreMocks: true, 56 | unstubGlobals: true, 57 | }, 58 | }; 59 | 60 | return mergeConfig(getBuildConfig(env), config); 61 | }); 62 | -------------------------------------------------------------------------------- /scripts/updateFrontend.js: -------------------------------------------------------------------------------- 1 | import { execSync } from 'node:child_process'; 2 | import { writeFileSync } from 'node:fs'; 3 | 4 | import packageJson from './getPackage.js'; 5 | 6 | try { 7 | // Create a new branch with version-bump prefix 8 | console.log('Creating new branch...'); 9 | const date = new Date(); 10 | const isoDate = date.toISOString().split('T')[0]; 11 | const timestamp = date.getTime(); 12 | const branchName = `version-bump-${isoDate}-${timestamp}`; 13 | execSync(`git checkout -b ${branchName} -t origin/main`, { stdio: 'inherit' }); 14 | 15 | // Get latest frontend release: https://github.com/Comfy-Org/ComfyUI_frontend/releases 16 | const latestRelease = 'https://api.github.com/repos/Comfy-Org/ComfyUI_frontend/releases/latest'; 17 | const latestReleaseData = await fetch(latestRelease); 18 | /** @type {unknown} */ 19 | const json = await latestReleaseData.json(); 20 | if (!('tag_name' in json) || typeof json.tag_name !== 'string') { 21 | throw new Error('Invalid response from GitHub'); 22 | } 23 | 24 | const latestReleaseTag = json.tag_name; 25 | const version = latestReleaseTag.replace('v', ''); 26 | 27 | // Update frontend version in package.json 28 | packageJson.config.frontendVersion = version; 29 | writeFileSync('./package.json', JSON.stringify(packageJson, null, 2)); 30 | 31 | // Messaging 32 | const message = `[chore] Update frontend to ${version}`; 33 | const prBody = `Automated frontend update to ${version}: https://github.com/Comfy-Org/ComfyUI_frontend/releases/tag/v${version}`; 34 | 35 | // Commit the version bump 36 | execSync(`git commit -am "${message}" --no-verify`, { stdio: 'inherit' }); 37 | 38 | // Create the PR 39 | console.log('Creating PR...'); 40 | execSync(`gh pr create --title "${message}" --label "dependencies" --body "${prBody}"`, { stdio: 'inherit' }); 41 | 42 | console.log(`✅ Successfully created PR for frontend ${version}`); 43 | } catch (error) { 44 | console.error('❌ Error during release process:', error.message); 45 | } 46 | -------------------------------------------------------------------------------- /tests/integration/testTroubleshooting.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from '@playwright/test'; 2 | 3 | import { expect } from './testExtensions'; 4 | import { TestTaskCard } from './testTaskCard'; 5 | 6 | export class TestTroubleshooting { 7 | readonly refreshButton; 8 | 9 | readonly basePathCard; 10 | readonly vcRedistCard; 11 | readonly installPythonPackagesCard; 12 | readonly resetVenvCard; 13 | 14 | readonly confirmRecreateVenvButton; 15 | readonly confirmInstallPythonPackagesButton; 16 | 17 | constructor(readonly window: Page) { 18 | this.refreshButton = window.locator('button.relative.p-button-icon-only'); 19 | 20 | this.basePathCard = new TestTaskCard(window, /^Base path$/, 'Select'); 21 | this.vcRedistCard = new TestTaskCard(window, /^Download VC\+\+ Redist$/, 'Download'); 22 | this.installPythonPackagesCard = new TestTaskCard(window, /^Install python packages$/, 'Install'); 23 | this.resetVenvCard = new TestTaskCard(window, /^Reset virtual environment$/, 'Recreate'); 24 | 25 | this.confirmRecreateVenvButton = this.window.getByRole('alertdialog').getByRole('button', { name: 'Recreate' }); 26 | this.confirmInstallPythonPackagesButton = this.window 27 | .getByRole('alertdialog') 28 | .getByRole('button', { name: 'Install' }); 29 | } 30 | 31 | async expectReady() { 32 | await expect(this.refreshButton).toBeVisible(); 33 | await expect(this.refreshButton).not.toBeDisabled(); 34 | } 35 | 36 | async refresh() { 37 | await this.refreshButton.click(); 38 | } 39 | 40 | getTaskCard(regex: RegExp) { 41 | return this.window.locator('div.task-div').filter({ hasText: regex }); 42 | } 43 | 44 | async expectInstallValid(timeout = 30 * 1000, interval = 2000) { 45 | await expect(async () => { 46 | const isBasePathOk = await this.window.evaluate(async () => { 47 | const api = document.defaultView?.electronAPI; 48 | if (!api) return false; 49 | const validation = await api.Validation.getStatus(); 50 | return validation.basePath === 'OK'; 51 | }); 52 | expect(isBasePathOk).toBe(true); 53 | }).toPass({ timeout, intervals: [interval] }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/infrastructure/electronError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An original error thrown by the electron API. 3 | * @see {@link ElectronError} 4 | */ 5 | type RawElectronError = Error & Pick; 6 | 7 | /** 8 | * A strongly-typed error object created from a thrown Electron error. 9 | * @see {@link ElectronError.fromCaught} for usage. 10 | */ 11 | export class ElectronError extends Error { 12 | /** Electron error number */ 13 | readonly errno: number; 14 | /** Electron error code */ 15 | readonly code: string; 16 | /** URL of the page associated with the error. */ 17 | readonly url?: string; 18 | 19 | private constructor(error: RawElectronError) { 20 | super(error.message, { cause: error }); 21 | this.errno = error.errno; 22 | this.code = error.code; 23 | this.url = error.url; 24 | } 25 | 26 | /** 27 | * Static factory. If possible, creates an strongly-typed ElectronError from an unknown error. 28 | * @param error The error object to create an ElectronError from. 29 | * @returns A strongly-typed electron error if the error object is an instance of Error and has the required properties, otherwise `undefined`. 30 | */ 31 | static fromCaught(error: unknown): ElectronError | undefined { 32 | return this.isRawError(error) ? new ElectronError(error) : undefined; 33 | } 34 | 35 | /** 36 | * Checks if the error was a generic Chromium `ERR_FAILED` error. 37 | * @returns `true` if the error is a generic Chromium error, otherwise `false`. 38 | */ 39 | isGenericChromiumError(): boolean { 40 | return this.code === 'ERR_FAILED' && this.errno === -2 && typeof this.url === 'string'; 41 | } 42 | 43 | /** 44 | * Type guard. Confirms {@link error} is an {@link Error}, `errno`, and `code` properties. 45 | * @param error The error object to check. 46 | * @returns `true` if the error is a raw Electron error, otherwise `false`. 47 | */ 48 | private static isRawError(error: unknown): error is RawElectronError { 49 | return ( 50 | error instanceof Error && 51 | 'errno' in error && 52 | 'code' in error && 53 | typeof error.errno === 'number' && 54 | typeof error.code === 'string' 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/actions/build/windows/certificate/action.yml: -------------------------------------------------------------------------------- 1 | name: Certificate Setup 2 | description: Sets up DigiCert and make it ready for signing 3 | 4 | inputs: 5 | DIGICERT_AUTHENTICATION_CERTIFICATE_BASE64: 6 | required: true 7 | type: string 8 | DIGICERT_HOST_ENVIRONMENT: 9 | required: true 10 | type: string 11 | DIGICERT_API_KEY: 12 | required: true 13 | type: string 14 | DIGICERT_AUTHENTICATION_CERTIFICATE_PASSWORD: 15 | required: true 16 | type: string 17 | runs: 18 | using: composite 19 | steps: 20 | - name: Set up certificate 21 | run: | 22 | echo "${{ inputs.DIGICERT_AUTHENTICATION_CERTIFICATE_BASE64 }}" | base64 --decode > /d/Certificate_pkcs12.p12 23 | shell: bash 24 | 25 | - name: Set variables 26 | id: variables 27 | run: | 28 | echo "{version}={${GITHUB_REF#refs/tags/v}}" >> $GITHUB_OUTPUT 29 | echo "SM_HOST=${{ inputs.DIGICERT_HOST_ENVIRONMENT }}" >> "$GITHUB_ENV" 30 | echo "SM_API_KEY=${{ inputs.DIGICERT_API_KEY }}" >> "$GITHUB_ENV" 31 | echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_ENV" 32 | echo "SM_CLIENT_CERT_PASSWORD=${{ inputs.DIGICERT_AUTHENTICATION_CERTIFICATE_PASSWORD }}" >> "$GITHUB_ENV" 33 | echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH 34 | echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH 35 | echo "C:\Program Files\DigiCert\DigiCert Keylocker Tools" >> $GITHUB_PATH 36 | shell: bash 37 | 38 | - name: Download Keylocker Software 39 | shell: cmd 40 | run: | 41 | curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/Keylockertools-windows-x64.msi/download -H "x-api-key:%SM_API_KEY%" -o Keylockertools-windows-x64.msi 42 | 43 | - name: Install and Sync Cert Software 44 | shell: cmd 45 | run: | 46 | msiexec /i Keylockertools-windows-x64.msi /passive 47 | smksp_registrar.exe list 48 | smctl.exe keypair ls 49 | C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user 50 | smksp_cert_sync.exe 51 | 52 | - name: health check 53 | shell: cmd 54 | run: smctl healthcheck --user 55 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | // Build tasks 5 | { 6 | "label": "Start Vite Build Watchers", 7 | "dependsOn": ["Vite Watch - Main", "Vite Watch - Preload"], 8 | "group": { 9 | "kind": "build" 10 | // "isDefault": true 11 | } 12 | }, 13 | // Vite watchers 14 | { 15 | "label": "Vite Watch - Main", 16 | "type": "shell", 17 | "command": "yarn vite build --watch", 18 | "isBackground": true, 19 | "problemMatcher": { 20 | "pattern": { 21 | "regexp": ".*" 22 | }, 23 | "background": { 24 | "activeOnStart": true, 25 | // Detect build start/stop 26 | "beginsPattern": ".*", 27 | "endsPattern": "^built in " 28 | } 29 | } 30 | }, 31 | { 32 | "label": "Vite Watch - Preload", 33 | "type": "shell", 34 | "command": "yarn vite build --watch --config vite.preload.config.ts", 35 | "linux": { 36 | "options": { "shell": { "args": ["-ci"] } } 37 | }, 38 | "isBackground": true, 39 | "problemMatcher": { 40 | "pattern": { 41 | "regexp": ".*" 42 | }, 43 | "background": { 44 | "activeOnStart": true, 45 | "beginsPattern": ".*", 46 | "endsPattern": "^built in " 47 | } 48 | } 49 | }, 50 | 51 | // Terminate tasks (for automation) 52 | { 53 | "label": "Terminate Vite Watchers", 54 | "dependsOn": ["Terminate Main Watcher", "Terminate Preload Watcher"], 55 | "problemMatcher": [] 56 | }, 57 | { 58 | "label": "Terminate Main Watcher", 59 | "command": "echo ${input:terminateMainWatcher}", 60 | "type": "shell" 61 | }, 62 | { 63 | "label": "Terminate Preload Watcher", 64 | "command": "echo ${input:terminatePreloadWatcher}", 65 | "type": "shell" 66 | } 67 | ], 68 | "inputs": [ 69 | { 70 | "id": "terminateMainWatcher", 71 | "type": "command", 72 | "command": "workbench.action.tasks.terminate", 73 | "args": "Vite Watch - Main" 74 | }, 75 | { 76 | "id": "terminatePreloadWatcher", 77 | "type": "command", 78 | "command": "workbench.action.tasks.terminate", 79 | "args": "Vite Watch - Preload" 80 | } 81 | ] 82 | } 83 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@playwright/test'; 2 | import dotenv from 'dotenv'; 3 | import path from 'node:path'; 4 | import { cwd, env } from 'node:process'; 5 | import type { DesktopTestOptions } from 'tests/integration/testExtensions'; 6 | 7 | const envOverrides = path.resolve(cwd(), '.env.test'); 8 | dotenv.config({ path: envOverrides }); 9 | 10 | export default defineConfig({ 11 | testDir: './tests/integration', 12 | // Backs up app data - in case this was run on a non-ephemeral machine. 13 | globalSetup: './playwright.setup', 14 | // Entire test suite timeout - 1 hour 15 | globalTimeout: 60 * 60 * 1000, 16 | // Per-test timeout - 60 sec 17 | timeout: 60_000, 18 | // This is a desktop app; sharding is required to run tests in parallel. 19 | workers: 1, 20 | // GitHub reporter in CI, dot reporter for local development. 21 | reporter: env.CI ? [['github'], ['html', { open: 'never', outputFolder: 'playwright-report' }], ['list']] : 'dot', 22 | // Test times are already recorded. This feature does not allow exceptions. 23 | reportSlowTests: null, 24 | // Capture trace, screenshots, and video on first retry in CI. 25 | retries: env.CI ? 1 : 0, 26 | use: { 27 | screenshot: 'only-on-failure', 28 | trace: 'on-first-retry', 29 | video: 'on-first-retry', 30 | }, 31 | projects: [ 32 | { 33 | // All tests that should start from an uninstalled state 34 | name: 'install', 35 | testMatch: ['install/**/*.spec.ts', 'shared/**/*.spec.ts'], 36 | use: { disposeTestEnvironment: true }, 37 | }, 38 | { 39 | // Setup project: this installs the app with default settings, providing a common base state for post-install tests 40 | name: 'post-install-setup', 41 | testMatch: ['post-install.setup.ts'], 42 | dependencies: ['install'], 43 | teardown: 'post-install-teardown', 44 | }, 45 | { 46 | // Teardown project: this deletes the app data and the default install location 47 | name: 'post-install-teardown', 48 | testMatch: ['post-install.teardown.ts'], 49 | }, 50 | { 51 | // Tests that run after the post-install setup 52 | name: 'post-install', 53 | testMatch: ['post-install/**/*.spec.ts', 'shared/**/*.spec.ts'], 54 | dependencies: ['post-install-setup'], 55 | }, 56 | ], 57 | }); 58 | -------------------------------------------------------------------------------- /scripts/verifyBuild.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | 4 | /** 5 | * Verify the app build for the current platform. 6 | * Check that all required paths are present. 7 | */ 8 | /** 9 | * @typedef {{ base: string; required: string[] }} VerifyConfig 10 | */ 11 | 12 | const PATHS = /** @type {Record<'mac' | 'windows', VerifyConfig>} */ ({ 13 | mac: { 14 | base: 'dist/mac-arm64/ComfyUI.app/Contents/Resources', 15 | required: ['ComfyUI', 'UI', 'uv/macos/uv', 'uv/macos/uvx'], 16 | }, 17 | windows: { 18 | base: 'dist/win-unpacked/resources', 19 | required: [ 20 | // Add Windows-specific paths here 21 | 'ComfyUI', 22 | 'UI', 23 | 'uv/win/uv.exe', 24 | 'uv/win/uvx.exe', 25 | ], 26 | }, 27 | }); 28 | 29 | /** 30 | * @param {VerifyConfig} config 31 | */ 32 | function verifyConfig(config) { 33 | const required = [...config.required]; 34 | const managerRequirementsPath = path.join(config.base, 'ComfyUI', 'manager_requirements.txt'); 35 | const legacyManagerPath = path.join(config.base, 'ComfyUI', 'custom_nodes', 'ComfyUI-Manager'); 36 | if (fs.existsSync(managerRequirementsPath)) { 37 | required.push('ComfyUI/manager_requirements.txt'); 38 | } else if (fs.existsSync(legacyManagerPath)) { 39 | required.push('ComfyUI/custom_nodes/ComfyUI-Manager'); 40 | } else { 41 | required.push('ComfyUI/manager_requirements.txt'); 42 | } 43 | 44 | const missingPaths = []; 45 | 46 | for (const requiredPath of required) { 47 | const fullPath = path.join(config.base, requiredPath); 48 | if (!fs.existsSync(fullPath)) { 49 | missingPaths.push(requiredPath); 50 | } 51 | } 52 | 53 | if (missingPaths.length > 0) { 54 | console.error('❌ Build verification failed!'); 55 | console.error('Missing required paths:'); 56 | for (const p of missingPaths) console.error(` - ${p}`); 57 | process.exit(1); 58 | } 59 | } 60 | 61 | function verifyBuild() { 62 | const platform = process.platform; 63 | 64 | if (platform === 'darwin') { 65 | console.log('🔍 Verifying build for Macos...'); 66 | verifyConfig(PATHS.mac); 67 | } else if (platform === 'win32') { 68 | console.log('🔍 Verifying build for Windows...'); 69 | verifyConfig(PATHS.windows); 70 | } else { 71 | console.error('❌ Unsupported platform:', platform); 72 | process.exit(1); 73 | } 74 | } 75 | 76 | verifyBuild(); 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | .DS_Store 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # TypeScript cache 43 | *.tsbuildinfo 44 | dist 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # Yarn cache files 62 | .yarn 63 | 64 | # dotenv environment variables file 65 | .env 66 | .env.test 67 | 68 | # parcel-bundler cache (https://parceljs.org/) 69 | .cache 70 | 71 | # next.js build output 72 | .next 73 | 74 | # nuxt.js build output 75 | .nuxt 76 | 77 | # vuepress build output 78 | .vuepress/dist 79 | 80 | # Serverless directories 81 | .serverless/ 82 | 83 | # FuseBox cache 84 | .fusebox/ 85 | 86 | # DynamoDB Local files 87 | .dynamodb/ 88 | 89 | # Webpack 90 | .webpack/ 91 | 92 | # Vite 93 | .vite/ 94 | 95 | # Electron-Forge 96 | out/ 97 | 98 | # ComfyUI 99 | comfyui-prebuilt 100 | comfyui 101 | 102 | # vscode 103 | *.code-workspace 104 | .history 105 | .vscode/* 106 | !.vscode/launch.json 107 | !.vscode/tasks.json 108 | 109 | # built assets 110 | assets/ComfyUI 111 | assets/cpython*.tar.gz 112 | assets/python 113 | assets/requirements.* 114 | assets/user 115 | assets/desktop-ui 116 | 117 | # Sentry Config File 118 | .env.sentry-build-plugin 119 | # Playwright test results 120 | test-results 121 | playwright-report 122 | 123 | # Python venv 124 | venv/ 125 | 126 | # UV 127 | assets/uv 128 | 129 | # Cursor local rules (untracked per-developer) 130 | .cursor/rules/*.local.mdc 131 | -------------------------------------------------------------------------------- /scripts/patchComfyUI.js: -------------------------------------------------------------------------------- 1 | import { applyPatch } from 'diff'; 2 | import fs from 'node:fs/promises'; 3 | 4 | /** 5 | * Patches files based on the {@link tasks} list. 6 | * 7 | * Each CLI argument is treated as a task name. 8 | * 9 | * Paths are relative to the project root. 10 | * @example 11 | * ```bash 12 | * node scripts/patchComfyUI.js frontend requirements 13 | * ``` 14 | */ 15 | const tasks = new Map([ 16 | [ 17 | 'requirements', 18 | { 19 | target: './assets/ComfyUI/requirements.txt', 20 | patch: './scripts/core-requirements.patch', 21 | }, 22 | ], 23 | ]); 24 | 25 | // Main execution 26 | const args = process.argv.slice(2); 27 | 28 | // Error if no args / any invalid args 29 | 30 | if (args.length === 0) { 31 | console.error('No arguments provided'); 32 | process.exit(15); 33 | } 34 | 35 | const invalidArgs = args.filter((arg) => !tasks.has(arg)); 36 | 37 | if (invalidArgs.length > 0) { 38 | console.error(`Invalid argument(s): ${invalidArgs.map((arg) => `"${arg}"`).join(', ')}`); 39 | process.exit(255); 40 | } 41 | 42 | // Apply patches 43 | const promises = args.map((arg) => patchFile(tasks.get(arg).target, tasks.get(arg).patch)); 44 | await Promise.all(promises); 45 | 46 | //#region Functions 47 | 48 | /** 49 | * Applies a regular diff patch to a single file 50 | * @param {string} targetPath Target file path 51 | * @param {string} patchFilePath Patch file to apply to the target file 52 | */ 53 | async function patchFile(targetPath, patchFilePath) { 54 | try { 55 | // Read the original file and patch file 56 | const [originalContent, patchContent] = await Promise.all([ 57 | fs.readFile(targetPath, 'utf8'), 58 | fs.readFile(patchFilePath, 'utf8'), 59 | ]); 60 | 61 | // Apply the patch 62 | const patchedContent = applyPatch(originalContent, patchContent); 63 | 64 | // If patch was successfully applied (not falsy) 65 | if (patchedContent) { 66 | // Write the result to the output file 67 | await fs.writeFile(targetPath, patchedContent, 'utf8'); 68 | console.log('Patch applied successfully!'); 69 | } else { 70 | throw new Error( 71 | `ComfyUI core patching returned falsy value (${typeof patchedContent}) - .patch file probably requires update` 72 | ); 73 | } 74 | } catch (error) { 75 | throw new Error(`Error applying core patch: ${error.message} target: ${targetPath} patch: ${patchFilePath}`, { 76 | cause: error, 77 | }); 78 | } 79 | } 80 | 81 | //#endregion Functions 82 | -------------------------------------------------------------------------------- /src/shell/terminal.ts: -------------------------------------------------------------------------------- 1 | import pty from 'node-pty'; 2 | import { EOL } from 'node:os'; 3 | 4 | import { IPC_CHANNELS } from '../constants'; 5 | import { AppWindow } from '../main-process/appWindow'; 6 | import { getDefaultShell } from './util'; 7 | 8 | /** 9 | * An in-app interactive terminal. 10 | * 11 | * Wraps a system shell and makes it available in the app. 12 | */ 13 | export class Terminal { 14 | #pty: pty.IPty | undefined; 15 | readonly #window: AppWindow; 16 | readonly #cwd: string; 17 | readonly #uvPath: string; 18 | 19 | readonly sessionBuffer: string[] = []; 20 | readonly size = { cols: 80, rows: 30 }; 21 | 22 | get pty() { 23 | this.#pty ??= this.#createPty(); 24 | return this.#pty; 25 | } 26 | 27 | constructor(window: AppWindow, cwd: string, uvPath: string) { 28 | this.#window = window; 29 | this.#cwd = cwd; 30 | this.#uvPath = uvPath; 31 | } 32 | 33 | write(data: string) { 34 | this.pty.write(data); 35 | } 36 | 37 | resize(cols: number, rows: number) { 38 | this.pty.resize(cols, rows); 39 | this.size.cols = cols; 40 | this.size.rows = rows; 41 | } 42 | 43 | restore() { 44 | return { 45 | buffer: this.sessionBuffer, 46 | size: this.size, 47 | }; 48 | } 49 | 50 | #createPty() { 51 | const window = this.#window; 52 | // node-pty hangs when debugging - fallback to winpty 53 | // https://github.com/microsoft/node-pty/issues/490 54 | 55 | // Alternativelsy, insert a 500-1000ms timeout before the connect call: 56 | // node-pty/lib/windowsPtyAgent.js#L112 57 | const debugging = process.env.NODE_DEBUG === 'true'; 58 | // TODO: does this want to be a setting? 59 | const shell = getDefaultShell(); 60 | const instance = pty.spawn(shell, [], { 61 | useConpty: !debugging, 62 | handleFlowControl: false, 63 | conptyInheritCursor: false, 64 | name: 'xterm', 65 | cols: this.size.cols, 66 | rows: this.size.rows, 67 | cwd: this.#cwd, 68 | }); 69 | 70 | if (process.platform === 'win32') { 71 | // PowerShell function 72 | instance.write(`function pip { & "${this.#uvPath}" pip $args }${EOL}`); 73 | } else { 74 | // Bash/Zsh alias 75 | instance.write(`alias pip='"${this.#uvPath}" pip'${EOL}`); 76 | } 77 | 78 | instance.onData((data) => { 79 | this.sessionBuffer.push(data); 80 | window.send(IPC_CHANNELS.TERMINAL_ON_OUTPUT, data); 81 | if (this.sessionBuffer.length > 1000) this.sessionBuffer.shift(); 82 | }); 83 | 84 | return instance; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/unit/handlers/appinfoHandlers.test.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from 'electron'; 2 | import { Mock, beforeEach, describe, expect, it, vi } from 'vitest'; 3 | 4 | import { IPC_CHANNELS } from '@/constants'; 5 | import { registerAppInfoHandlers } from '@/handlers/appInfoHandlers'; 6 | 7 | const MOCK_WINDOW_STYLE = 'default'; 8 | const MOCK_GPU_NAME = 'mock-gpu'; 9 | const MOCK_BASE_PATH = '/set/user/changed/base/path'; 10 | 11 | vi.mock('@/store/desktopConfig', () => ({ 12 | useDesktopConfig: vi.fn(() => ({ 13 | get: vi.fn((key: string) => { 14 | if (key === 'basePath') return MOCK_BASE_PATH; 15 | }), 16 | set: vi.fn(), 17 | getAsync: vi.fn((key: string) => { 18 | if (key === 'windowStyle') return Promise.resolve(MOCK_WINDOW_STYLE); 19 | if (key === 'detectedGpu') return Promise.resolve(MOCK_GPU_NAME); 20 | }), 21 | setAsync: vi.fn(), 22 | })), 23 | })); 24 | 25 | vi.mock('@/config/comfyServerConfig', () => ({ 26 | ComfyServerConfig: { 27 | setBasePathInDefaultConfig: vi.fn().mockReturnValue(Promise.resolve(true)), 28 | }, 29 | })); 30 | 31 | interface TestCase { 32 | channel: string; 33 | expected: any; 34 | args?: any[]; 35 | } 36 | 37 | const getHandler = (channel: string) => { 38 | const [, handlerFn] = (ipcMain.handle as Mock).mock.calls.find(([ch]) => ch === channel) || []; 39 | return handlerFn; 40 | }; 41 | 42 | describe('AppInfoHandlers', () => { 43 | const testCases: TestCase[] = [ 44 | { channel: IPC_CHANNELS.IS_PACKAGED, expected: true }, 45 | { channel: IPC_CHANNELS.GET_ELECTRON_VERSION, expected: '1.0.0' }, 46 | { channel: IPC_CHANNELS.GET_BASE_PATH, expected: MOCK_BASE_PATH }, 47 | { channel: IPC_CHANNELS.GET_GPU, expected: MOCK_GPU_NAME }, 48 | { channel: IPC_CHANNELS.SET_WINDOW_STYLE, expected: undefined, args: [null, MOCK_WINDOW_STYLE] }, 49 | { channel: IPC_CHANNELS.GET_WINDOW_STYLE, expected: MOCK_WINDOW_STYLE }, 50 | ]; 51 | 52 | describe('registerHandlers', () => { 53 | beforeEach(() => { 54 | registerAppInfoHandlers(); 55 | }); 56 | 57 | it.each(testCases)('should register handler for $channel', ({ channel }) => { 58 | expect(ipcMain.handle).toHaveBeenCalledWith(channel, expect.any(Function)); 59 | }); 60 | 61 | it.each(testCases)( 62 | '$channel handler should return mock value ($expected)', 63 | async ({ channel, expected, args = [] }) => { 64 | const handlerFn = getHandler(channel); 65 | const result = await handlerFn(...args); 66 | 67 | expect(result).toEqual(expected); 68 | } 69 | ); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /tests/unit/utils.test.ts: -------------------------------------------------------------------------------- 1 | import type { Systeminformation } from 'systeminformation'; 2 | import si from 'systeminformation'; 3 | import { describe, expect, it, vi } from 'vitest'; 4 | 5 | import { validateHardware } from '@/utils'; 6 | 7 | vi.mock('systeminformation'); 8 | 9 | describe('validateHardware', () => { 10 | it('accepts Apple Silicon Mac', async () => { 11 | vi.stubGlobal('process', { ...process, platform: 'darwin' }); 12 | vi.mocked(si.cpu).mockResolvedValue({ manufacturer: 'Apple' } as Systeminformation.CpuData); 13 | 14 | const result = await validateHardware(); 15 | expect(result).toStrictEqual({ isValid: true, gpu: 'mps' }); 16 | }); 17 | 18 | it('rejects Intel Mac', async () => { 19 | vi.stubGlobal('process', { ...process, platform: 'darwin' }); 20 | vi.mocked(si.cpu).mockResolvedValue({ manufacturer: 'Intel' } as Systeminformation.CpuData); 21 | 22 | const result = await validateHardware(); 23 | expect(result).toStrictEqual({ 24 | isValid: false, 25 | error: expect.stringContaining('Intel-based Macs are not supported'), 26 | }); 27 | }); 28 | 29 | it('accepts Windows with NVIDIA GPU', async () => { 30 | vi.stubGlobal('process', { ...process, platform: 'win32' }); 31 | vi.mocked(si.graphics).mockResolvedValue({ 32 | controllers: [{ vendor: 'NVIDIA Corporation' }], 33 | } as Systeminformation.GraphicsData); 34 | 35 | const result = await validateHardware(); 36 | expect(result).toStrictEqual({ isValid: true, gpu: 'nvidia' }); 37 | }); 38 | 39 | it('rejects Windows with AMD GPU', async () => { 40 | vi.stubGlobal('process', { ...process, platform: 'win32' }); 41 | // Simulate a system with an AMD GPU 42 | vi.mocked(si.graphics).mockResolvedValue({ 43 | controllers: [{ vendor: 'AMD', model: 'Radeon RX 6800' }], 44 | } as Systeminformation.GraphicsData); 45 | 46 | vi.mock('node:child_process', async () => { 47 | const actual = await vi.importActual('node:child_process'); 48 | return { 49 | ...actual, 50 | exec: (_cmd: string, callback: (error: Error | null, stdout: string, stderr: string) => void) => { 51 | setImmediate(() => callback(new Error('mocked exec failure'), '', '')); 52 | return { kill: () => {}, on: () => {} } as any; 53 | }, 54 | }; 55 | }); 56 | 57 | const result = await validateHardware(); 58 | expect(result).toStrictEqual({ 59 | isValid: false, 60 | error: expect.stringContaining('No NVIDIA GPU was detected'), 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/infrastructure/structuredLogging.ts: -------------------------------------------------------------------------------- 1 | import type { FileTransport, MainTransports, TransformFn } from 'electron-log'; 2 | import { formatWithOptions } from 'node:util'; 3 | 4 | export const ansiCodes = /[\u001B\u009B][#();?[]*(?:\d{1,4}(?:;\d{0,4})*)?[\d<=>A-ORZcf-nqry]/g; 5 | 6 | export function removeAnsiCodes(x: unknown) { 7 | return typeof x === 'string' ? x.replaceAll(ansiCodes, '') : x; 8 | } 9 | 10 | export function removeAnsiCodesTransform({ data }: Parameters[0]): unknown[] { 11 | return data.map((x) => removeAnsiCodes(x)); 12 | } 13 | 14 | /** 15 | * Implements structured logging of generic objects, errors, and dates. 16 | * Uses compact, single-line formatting; suitable for file logging. 17 | * 18 | * Replaces the final transform on the file transport of an electron-log instance. 19 | * @param transport - The transport to use. 20 | */ 21 | export function replaceFileLoggingTransform(transports: MainTransports) { 22 | const { transforms } = transports.file; 23 | transforms.pop(); 24 | // electron-log is poorly typed. The final transform must return a string, or the output will be wrapped in quotes. 25 | transforms.push(formatForFileLogging as unknown as TransformFn); 26 | } 27 | 28 | /** 29 | * Converts an array of structured data objects to a single, formatted string. 30 | * 31 | * Allows the use of `printf`-like string formatting. 32 | * @param data Array of data objects to stringify. 33 | * @param transport Electron log file transport. 34 | * @returns The final formatted log string. 35 | */ 36 | function formatForFileLogging({ data, transport }: { data: unknown[]; transport: FileTransport }) { 37 | const inspectOptions = transport.inspectOptions ?? {}; 38 | const formattableData = data.map((item) => toFormattable(item)); 39 | return formatWithOptions(inspectOptions, ...formattableData); 40 | } 41 | 42 | /** Convert an object that lacks a log-friendly string conversion. */ 43 | function toFormattable(item: unknown) { 44 | try { 45 | if (typeof item === 'object' && item !== null) { 46 | if (item instanceof Error) return item.stack; 47 | if (item instanceof Date) return item.toISOString(); 48 | 49 | return JSON.stringify(item, toStringifiable); 50 | } 51 | } catch { 52 | // Disregard, use default. 53 | } 54 | 55 | return item; 56 | } 57 | 58 | /** Shallow conversion of {@link Map} and {@link Set} to objects compatible with {@link JSON.stringify}. */ 59 | function toStringifiable(_key: unknown, value: unknown) { 60 | if (value instanceof Map) return Object.fromEntries>(value); 61 | if (value instanceof Set) return [...(value as Set)]; 62 | 63 | return value; 64 | } 65 | -------------------------------------------------------------------------------- /.github/actions/build/windows/app/action.yml: -------------------------------------------------------------------------------- 1 | name: Windows Build App 2 | description: Electron-Forge Build/Sign/publish 3 | 4 | inputs: 5 | DIGICERT_FINGERPRINT: 6 | description: 'DigiCert SHA256 Fingerprint' 7 | required: true 8 | GITHUB_TOKEN: 9 | description: 'GitHub Token' 10 | required: true 11 | sign-and-publish: 12 | description: 'Sign the executable and publish to release page' 13 | default: 'false' 14 | required: false 15 | build-targets: 16 | description: Override the default targets and build the passed targets. Comma separated list. Include '--target' and full names, eg '--targets=@electron-forge/maker-squirrel, ...etc' 17 | required: false 18 | default: '' 19 | runs: 20 | using: composite 21 | steps: 22 | - name: Use Node.js 20.x 23 | uses: JP250552/setup-node@0c618ceb2e48275dc06e86901822fd966ce75ba2 24 | with: 25 | node-version: '20.x' 26 | corepack: true 27 | 28 | - run: yarn install --immutable 29 | shell: cmd 30 | 31 | - name: Rebuild node-pty 32 | shell: cmd 33 | run: npx electron-rebuild 34 | 35 | - name: Mod 36 | shell: powershell 37 | run: | 38 | (Get-Content node_modules\@electron\windows-sign\dist\cjs\sign-with-signtool.js) -replace [Regex]::Escape('await execute({ ...internalOptions, hash: "sha1'), [Regex]::Escape('//await execute({ ...internalOptions, hash: "sha1') | Out-File -encoding ASCII node_modules\@electron\windows-sign\dist\cjs\sign-with-signtool.js 39 | (Get-Content node_modules\@electron\windows-sign\dist\esm\sign-with-signtool.js) -replace [Regex]::Escape('await execute({ ...internalOptions, hash: "sha1'), [Regex]::Escape('//await execute({ ...internalOptions, hash: "sha1') | Out-File -encoding ASCII node_modules\@electron\windows-sign\dist\esm\sign-with-signtool.js 40 | 41 | - name: Set up Python 42 | uses: actions/setup-python@v4 43 | with: 44 | python-version: '3.12' 45 | 46 | - name: Install ComfyUI and create standalone package 47 | run: | 48 | set -x 49 | yarn make:assets 50 | shell: bash 51 | 52 | - name: Make app 53 | shell: powershell 54 | env: 55 | DIGICERT_FINGERPRINT: ${{ inputs.DIGICERT_FINGERPRINT }} 56 | DEBUG: electron-forge:* 57 | PUBLISH: ${{ inputs.sign-and-publish }} 58 | GITHUB_TOKEN: ${{ inputs.GITHUB_TOKEN }} 59 | run: yarn run make 60 | 61 | - name: Print SignLogs 62 | if: ${{inputs.sign-and-publish == 'true' && always()}} 63 | continue-on-error: true 64 | shell: powershell 65 | run: cd $HOME ; gc .signingmanager\logs\smksp.log 66 | 67 | - name: verify signing 68 | if: ${{inputs.sign-and-publish == 'true'}} 69 | run: signtool verify /v /pa dist/ComfyUI-win32-x64/ComfyUI.exe 70 | shell: cmd 71 | -------------------------------------------------------------------------------- /scripts/downloadUV.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import extractZip from 'extract-zip'; 3 | import fs from 'fs-extra'; 4 | import os from 'node:os'; 5 | import path from 'node:path'; 6 | import * as tar from 'tar'; 7 | 8 | import packageJson from './getPackage.js'; 9 | 10 | const uvVer = packageJson.config.uvVersion; 11 | 12 | /** @typedef {{ [key]: { zipFile: string, uvOutputFolderName: string, zip: boolean } }} UvDownloadOptions */ 13 | const options = { 14 | win32: { 15 | zipFile: 'uv-x86_64-pc-windows-msvc.zip', 16 | uvOutputFolderName: 'win', 17 | zip: true, 18 | }, 19 | darwin: { 20 | zipFile: 'uv-aarch64-apple-darwin.tar.gz', 21 | uvOutputFolderName: 'macos', 22 | zip: false, 23 | }, 24 | linux: { 25 | zipFile: 'uv-x86_64-unknown-linux-gnu.tar.gz', 26 | uvOutputFolderName: 'linux', 27 | zip: false, 28 | }, 29 | }; 30 | 31 | async function downloadUV() { 32 | const allFlag = process.argv[2]; 33 | const baseDownloadURL = `https://github.com/astral-sh/uv/releases/download/${uvVer}/`; 34 | if (allFlag) { 35 | if (allFlag === 'all') { 36 | await downloadAndExtract(baseDownloadURL, options.win32); 37 | await downloadAndExtract(baseDownloadURL, options.darwin); 38 | await downloadAndExtract(baseDownloadURL, options.linux); 39 | return; 40 | } 41 | if (allFlag === 'none') { 42 | return; 43 | } 44 | } 45 | 46 | const uvDownloaded = fs.existsSync(path.join('./assets', 'uv')); 47 | if (!uvDownloaded) { 48 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 49 | await downloadAndExtract(baseDownloadURL, options[os.platform()]); 50 | return; 51 | } 52 | console.log('< UV Folder Exists, Skipping >'); 53 | } 54 | 55 | /** @param {UvDownloadOptions[any]} options */ 56 | async function downloadAndExtract(baseURL, options) { 57 | const { zipFile, uvOutputFolderName, zip } = options; 58 | const zipFilePath = path.join('./assets', zipFile); 59 | const outputUVFolder = path.join('./assets', 'uv', uvOutputFolderName); 60 | await fs.mkdir(outputUVFolder, { 61 | recursive: true, 62 | }); 63 | const downloadedFile = await axios({ 64 | method: 'GET', 65 | url: baseURL + zipFile, 66 | responseType: 'arraybuffer', 67 | }); 68 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 69 | fs.writeFileSync(zipFilePath, downloadedFile.data); 70 | if (zip) { 71 | await extractZip(zipFilePath, { dir: path.resolve(outputUVFolder) }); 72 | } else { 73 | tar.extract({ 74 | sync: true, 75 | file: zipFilePath, 76 | C: outputUVFolder, 77 | 'strip-components': 1, 78 | }); 79 | } 80 | await fs.unlink(zipFilePath); 81 | console.log(`FINISHED DOWNLOAD AND EXTRACT UV ${uvOutputFolderName}`); 82 | } 83 | 84 | //** Download and Extract UV. Default uses OS.Platfrom. Add 'all' will download all. Add 'none' will skip */ 85 | await downloadUV(); 86 | -------------------------------------------------------------------------------- /tests/unit/handlers/gpuHandlers.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 | 3 | const mockExecAsync = vi.fn(); 4 | vi.mock('node:util', () => ({ promisify: () => mockExecAsync })); 5 | 6 | describe('registerGpuHandlers', () => { 7 | let ipcMainHandleSpy: ReturnType; 8 | let IPC_CHANNELS: typeof import('@/constants').IPC_CHANNELS; 9 | let registerGpuHandlers: typeof import('@/handlers/gpuHandlers').registerGpuHandlers; 10 | 11 | beforeEach(async () => { 12 | const constantsModule = await import('@/constants'); 13 | IPC_CHANNELS = constantsModule.IPC_CHANNELS; 14 | const electron = await import('electron'); 15 | ipcMainHandleSpy = vi.spyOn(electron.ipcMain, 'handle').mockImplementation(() => undefined); 16 | const gpuModule = await import('@/handlers/gpuHandlers'); 17 | registerGpuHandlers = gpuModule.registerGpuHandlers; 18 | mockExecAsync.mockReset(); 19 | }); 20 | 21 | afterEach(() => { 22 | vi.restoreAllMocks(); 23 | }); 24 | 25 | it('registers exactly one handler for CHECK_BLACKWELL', () => { 26 | registerGpuHandlers(); 27 | expect(ipcMainHandleSpy).toHaveBeenCalledTimes(1); 28 | expect(ipcMainHandleSpy).toHaveBeenCalledWith(IPC_CHANNELS.CHECK_BLACKWELL, expect.any(Function)); 29 | }); 30 | 31 | describe('CHECK_BLACKWELL handler callback', () => { 32 | let handler: () => Promise; 33 | 34 | beforeEach(() => { 35 | registerGpuHandlers(); 36 | const call = ipcMainHandleSpy.mock.calls.find(([channel]) => channel === IPC_CHANNELS.CHECK_BLACKWELL)!; 37 | handler = call[1] as any; 38 | }); 39 | 40 | it('invokes execAsync with the correct command', async () => { 41 | mockExecAsync.mockResolvedValue({ stdout: 'Product Architecture : Blackwell' }); 42 | await handler(); 43 | expect(mockExecAsync).toHaveBeenCalledOnce(); 44 | expect(mockExecAsync).toHaveBeenCalledWith('nvidia-smi -q'); 45 | }); 46 | 47 | it('returns true when stdout contains "Blackwell" with exact casing', async () => { 48 | mockExecAsync.mockResolvedValue({ stdout: '...Product Architecture : Blackwell\n...' }); 49 | await expect(handler()).resolves.toBe(true); 50 | }); 51 | 52 | it('returns false when stdout does not contain "Blackwell"', async () => { 53 | mockExecAsync.mockResolvedValue({ stdout: 'Product Architecture : Ampere' }); 54 | await expect(handler()).resolves.toBe(false); 55 | }); 56 | 57 | it('is case-sensitive and returns false for lowercase "blackwell"', async () => { 58 | mockExecAsync.mockResolvedValue({ stdout: 'Product Architecture : blackwell' }); 59 | await expect(handler()).resolves.toBe(false); 60 | }); 61 | 62 | it('returns false when execAsync throws an error', async () => { 63 | mockExecAsync.mockRejectedValue(new Error('execution failed')); 64 | await expect(handler()).resolves.toBe(false); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /.github/actions/build/todesktop/action.yml: -------------------------------------------------------------------------------- 1 | name: Build to ToDesktop 2 | description: Will build the project then send the project files to ToDesktop to be compiled into installers. 3 | 4 | inputs: 5 | GITHUB_TOKEN: 6 | description: 'GitHub Token' 7 | required: true 8 | TODESKTOP_EMAIL: 9 | description: 'ToDesktop Email' 10 | required: true 11 | TODESKTOP_ACCESS_TOKEN: 12 | description: 'ToDesktop Access Token' 13 | required: true 14 | RELEASE_TAG: 15 | description: 'Release Tag' 16 | required: false 17 | runs: 18 | using: composite 19 | steps: 20 | - name: Use Node.js 20.x 21 | uses: JP250552/setup-node@0c618ceb2e48275dc06e86901822fd966ce75ba2 22 | with: 23 | node-version: '20.x' 24 | corepack: true 25 | 26 | - run: yarn install --immutable 27 | shell: bash 28 | 29 | - run: yarn set version --yarn-path self 30 | shell: bash 31 | 32 | - name: Set up Python 33 | uses: actions/setup-python@v4 34 | with: 35 | python-version: '3.12' 36 | 37 | - name: Install ComfyUI and create standalone package 38 | shell: bash 39 | run: | 40 | set -x 41 | yarn make:assets 42 | yarn clean:assets:git 43 | 44 | - name: Make app 45 | shell: bash 46 | env: 47 | PUBLISH: true 48 | GITHUB_TOKEN: ${{ inputs.GITHUB_TOKEN }} 49 | TODESKTOP_EMAIL: ${{ inputs.TODESKTOP_EMAIL }} 50 | TODESKTOP_ACCESS_TOKEN: ${{ inputs.TODESKTOP_ACCESS_TOKEN }} 51 | run: | 52 | echo "🚀 Building ToDesktop package..." 53 | yarn run publish | tee build.log 54 | 55 | # Extract build ID from the log 56 | BUILD_URL=$(grep "Build complete!" build.log | head -n1 | cut -d' ' -f3) 57 | BUILD_ID=$(echo $BUILD_URL | cut -d'/' -f6) 58 | APP_ID=$(echo $BUILD_URL | cut -d'/' -f5) 59 | 60 | # Only update release notes if RELEASE_TAG is provided 61 | if [ -n "${{ inputs.RELEASE_TAG }}" ]; then 62 | # Create download links section 63 | DOWNLOAD_LINKS=" 64 | ### Download Latest: 65 | Mac (Apple Silicon): https://download.comfy.org/mac/dmg/arm64 66 | Windows: https://download.comfy.org/windows/nsis/x64 67 | 68 |
69 | 70 | 71 | 72 | ### Artifacts of current release 73 | 74 | 75 | 76 | Mac (Apple Silicon): https://download.comfy.org/${BUILD_ID}/mac/dmg/arm64 77 | Windows: https://download.comfy.org/${BUILD_ID}/windows/nsis/x64 78 | 79 |
" 80 | 81 | # First get existing release notes 82 | EXISTING_NOTES=$(gh release view ${{ inputs.RELEASE_TAG }} --json body -q .body) 83 | 84 | # Combine existing notes with download links 85 | UPDATED_NOTES="${EXISTING_NOTES}${DOWNLOAD_LINKS}" 86 | 87 | # Update the release with combined notes 88 | gh release edit ${{ inputs.RELEASE_TAG }} --notes "$UPDATED_NOTES" 89 | fi 90 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | title: '[Feature Request]: ' 4 | labels: ['enhancement'] 5 | type: Feature 6 | 7 | body: 8 | - type: checkboxes 9 | attributes: 10 | label: Prerequisites 11 | description: Please ensure you've completed these steps before submitting a feature request 12 | options: 13 | - label: I have searched the existing issues and checked the recent builds/commits 14 | required: true 15 | - label: I have read the documentation and this feature is not already available 16 | required: true 17 | - label: This is a single feature request (not multiple features combined) 18 | required: true 19 | - type: markdown 20 | attributes: 21 | value: | 22 | *Please fill this form with as much information as possible, provide screenshots and/or illustrations of the feature if possible* 23 | - type: textarea 24 | id: problem 25 | attributes: 26 | label: Problem Statement 27 | description: What problem are you trying to solve? Please describe the issue you're facing. 28 | placeholder: | 29 | Example: 30 | - I'm frustrated when... 31 | - I find it difficult to... 32 | - I wish I could... 33 | validations: 34 | required: true 35 | - type: textarea 36 | id: feature 37 | attributes: 38 | label: Proposed Solution 39 | description: Describe your suggested feature and how it solves the problem 40 | placeholder: | 41 | Example: 42 | - Add a button that... 43 | - Implement a system for... 44 | - Create a new option to... 45 | validations: 46 | required: true 47 | - type: textarea 48 | id: workflow 49 | attributes: 50 | label: Potential workflow 51 | description: Please provide us with step by step information on how you'd like the feature to be accessed and used 52 | value: | 53 | 1. Go to .... 54 | 2. Press .... 55 | 3. ... 56 | validations: 57 | required: true 58 | - type: dropdown 59 | id: impact 60 | attributes: 61 | label: Impact Level 62 | description: How would you rate the impact of this feature? 63 | options: 64 | - Critical (Blocking my work) 65 | - High (Major improvement) 66 | - Medium (Nice to have) 67 | - Low (Minor enhancement) 68 | validations: 69 | required: true 70 | - type: textarea 71 | id: alternatives 72 | attributes: 73 | label: Alternatives Considered 74 | description: What alternative solutions have you considered? 75 | placeholder: | 76 | Example: 77 | - Tried using X instead, but... 78 | - Considered Y approach, however... 79 | validations: 80 | required: false 81 | - type: textarea 82 | id: misc 83 | attributes: 84 | label: Additional information 85 | description: Add any other context or screenshots about the feature request here. 86 | -------------------------------------------------------------------------------- /scripts/todesktop/afterPack.cjs: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const fs = require('fs/promises'); 3 | const path = require('path'); 4 | const { spawnSync } = require('child_process'); 5 | 6 | module.exports = async ({ appOutDir, packager, outDir }) => { 7 | /** 8 | * appPkgName - string - the name of the app package 9 | * appId - string - the app id 10 | * shouldCodeSign - boolean - whether the app will be code signed or not 11 | * outDir - string - the path to the output directory 12 | * appOutDir - string - the path to the app output directory 13 | * packager - object - the packager object 14 | * arch - number - the architecture of the app. ia32 = 0, x64 = 1, armv7l = 2, arm64 = 3, universal = 4. 15 | */ 16 | 17 | // The purpose of this script is to move comfy files from assets to the resource folder of the app 18 | // We can not add them to extraFiles as that is done prior to building, where we need to move them AFTER 19 | 20 | if (os.platform() === 'darwin') { 21 | const appName = packager.appInfo.productFilename; 22 | const appPath = path.join(`${appOutDir}`, `${appName}.app`); 23 | const mainPath = path.dirname(outDir); 24 | const assetPath = path.join(mainPath, 'app-wrapper', 'app', 'assets'); 25 | const resourcePath = path.join(appPath, 'Contents', 'Resources'); 26 | // Remove these Git folders that mac's codesign is choking on. Need a more recursive way to just find all folders with '.git' and delete 27 | await fs.rm(path.join(assetPath, 'ComfyUI', '.git'), { recursive: true, force: true }); 28 | await fs.rm(path.join(assetPath, 'ComfyUI', 'custom_nodes', 'ComfyUI-Manager', '.git'), { 29 | recursive: true, 30 | force: true, 31 | }); 32 | await fs.rm(path.join(assetPath, 'ComfyUI', 'custom_nodes', 'DesktopSettingsExtension', '.git'), { 33 | recursive: true, 34 | force: true, 35 | }); 36 | // Move rest of items to the resource folder 37 | await fs.cp(assetPath, resourcePath, { recursive: true }); 38 | // Remove other OS's UV 39 | await fs.rm(path.join(resourcePath, 'uv', 'win'), { recursive: true, force: true }); 40 | await fs.rm(path.join(resourcePath, 'uv', 'linux'), { recursive: true, force: true }); 41 | await fs.chmod(path.join(resourcePath, 'uv', 'macos', 'uv'), '755'); 42 | await fs.chmod(path.join(resourcePath, 'uv', 'macos', 'uvx'), '755'); 43 | } 44 | 45 | if (os.platform() === 'win32') { 46 | const appName = packager.appInfo.productFilename; 47 | const appPath = path.join(`${appOutDir}`, `${appName}.exe`); 48 | const mainPath = path.dirname(outDir); 49 | const assetPath = path.join(mainPath, 'app-wrapper', 'app', 'assets'); 50 | const resourcePath = path.join(path.dirname(appPath), 'resources'); 51 | // Move rest of items to the resource folder 52 | await fs.cp(assetPath, resourcePath, { recursive: true }); 53 | // Remove other OS's UV 54 | await fs.rm(path.join(resourcePath, 'uv', 'macos'), { recursive: true, force: true }); 55 | await fs.rm(path.join(resourcePath, 'uv', 'linux'), { recursive: true, force: true }); 56 | } 57 | 58 | //TODO: Linux 59 | }; 60 | -------------------------------------------------------------------------------- /tests/unit/setup.ts: -------------------------------------------------------------------------------- 1 | import type { FileTransport, MainLogger, MainTransports } from 'electron-log'; 2 | import log from 'electron-log/main'; 3 | import { vi } from 'vitest'; 4 | 5 | import type { IAppState } from '@/main-process/appState'; 6 | import type { ITelemetry } from '@/services/telemetry'; 7 | 8 | // Shared setup - run once before each test file 9 | 10 | /** I find this deeply mocking. */ 11 | export type PartialMock = { -readonly [K in keyof T]?: PartialMock }; 12 | 13 | // Logging 14 | vi.mock('electron-log/main'); 15 | 16 | vi.mocked(log.create).mockReturnValue({ 17 | transports: { 18 | file: { 19 | transforms: [], 20 | } as unknown as FileTransport, 21 | } as unknown as MainTransports, 22 | } as unknown as MainLogger & { default: MainLogger }); 23 | 24 | /** Partially mocks Electron API, but guarantees a few properties are non-null. */ 25 | type ElectronMock = PartialMock & { 26 | app: Partial; 27 | dialog: Partial; 28 | ipcMain: Partial; 29 | ipcRenderer: Partial; 30 | }; 31 | 32 | export const quitMessage = /^Test exited via app\.quit\(\)$/; 33 | 34 | export const electronMock: ElectronMock = { 35 | app: { 36 | isPackaged: true, 37 | quit: vi.fn(() => { 38 | throw new Error('Test exited via app.quit()'); 39 | }), 40 | exit: vi.fn(() => { 41 | throw new Error('Test exited via app.exit()'); 42 | }), 43 | getPath: vi.fn(() => '/mock/app/path'), 44 | getAppPath: vi.fn(() => '/mock/app/path'), 45 | relaunch: vi.fn(), 46 | getVersion: vi.fn(() => '1.0.0'), 47 | on: vi.fn(), 48 | once: vi.fn(), 49 | }, 50 | dialog: { 51 | showErrorBox: vi.fn(), 52 | showMessageBox: vi.fn(), 53 | showOpenDialog: vi.fn(), 54 | }, 55 | ipcMain: { 56 | on: vi.fn(), 57 | once: vi.fn(), 58 | handle: vi.fn(), 59 | handleOnce: vi.fn(), 60 | removeHandler: vi.fn(), 61 | }, 62 | ipcRenderer: { 63 | invoke: vi.fn(), 64 | on: vi.fn(), 65 | off: vi.fn(), 66 | once: vi.fn(), 67 | send: vi.fn(), 68 | }, 69 | }; 70 | 71 | // Electron 72 | vi.mock('electron', () => electronMock); 73 | 74 | // App State 75 | const appState: PartialMock = { 76 | isQuitting: false, 77 | ipcRegistered: false, 78 | loaded: false, 79 | currentPage: undefined, 80 | emitIpcRegistered: vi.fn(), 81 | emitLoaded: vi.fn(), 82 | }; 83 | vi.mock('@/main-process/appState', () => ({ 84 | initializeAppState: vi.fn(), 85 | useAppState: vi.fn(() => appState), 86 | })); 87 | 88 | // Sentry & Telemetry 89 | const mockTelemetry: ITelemetry = { 90 | track: vi.fn(), 91 | hasConsent: true, 92 | loadGenerationCount: vi.fn(), 93 | flush: vi.fn(), 94 | registerHandlers: vi.fn(), 95 | }; 96 | vi.mock('@/services/sentry'); 97 | vi.mock('@/services/telemetry', async () => { 98 | const actual = await vi.importActual('@/services/telemetry'); 99 | 100 | return { 101 | ...actual, 102 | getTelemetry: vi.fn(() => mockTelemetry), 103 | promptMetricsConsent: vi.fn().mockResolvedValue(true), 104 | }; 105 | }); 106 | -------------------------------------------------------------------------------- /tests/shared/utils.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'node:child_process'; 2 | import { randomUUID } from 'node:crypto'; 3 | import { access, constants } from 'node:fs/promises'; 4 | import { homedir } from 'node:os'; 5 | import path from 'node:path'; 6 | 7 | // Dumping ground for basic utilities that can be shared by e2e and unit tests 8 | 9 | export enum FilePermission { 10 | Exists = constants.F_OK, 11 | Readable = constants.R_OK, 12 | Writable = constants.W_OK, 13 | Executable = constants.X_OK, 14 | } 15 | 16 | export async function pathExists(path: string, permission: FilePermission = FilePermission.Exists) { 17 | try { 18 | await access(path, permission); 19 | return true; 20 | } catch { 21 | return false; 22 | } 23 | } 24 | 25 | /** 26 | * Get the path to the ComfyUI app data directory. Precisely matches Electron's app.getPath('userData'). 27 | * @returns The path to the ComfyUI app data directory. 28 | */ 29 | export function getComfyUIAppDataPath() { 30 | switch (process.platform) { 31 | case 'win32': 32 | if (!process.env.APPDATA) throw new Error('APPDATA environment variable is not set.'); 33 | return path.join(process.env.APPDATA, 'ComfyUI'); 34 | case 'darwin': 35 | return path.join(homedir(), 'Library', 'Application Support', 'ComfyUI'); 36 | default: 37 | return path.join(homedir(), '.config', 'ComfyUI'); 38 | } 39 | } 40 | 41 | export function getDefaultInstallLocation() { 42 | switch (process.platform) { 43 | case 'win32': 44 | if (!process.env.USERPROFILE) throw new Error('USERPROFILE environment variable is not set.'); 45 | return path.join(process.env.USERPROFILE, 'Documents', 'ComfyUI'); 46 | case 'darwin': 47 | return path.join(homedir(), 'Documents', 'ComfyUI'); 48 | default: 49 | return process.env.XDG_DOCUMENTS_DIR || path.join(homedir(), 'Documents', 'ComfyUI'); 50 | } 51 | } 52 | 53 | export function addRandomSuffix(str: string) { 54 | return `${str}-${randomUUID().substring(0, 8)}`; 55 | } 56 | 57 | /** 58 | * Create a screenshot of the entire desktop. 59 | * 60 | * Hard-coded to 1920x1080 resolution. 61 | * @param filename - The name of the file to save the screenshot as. 62 | * @returns The path to the screenshot file. 63 | */ 64 | export async function createDesktopScreenshot(filename: string) { 65 | const width = 1920; 66 | const height = 1080; 67 | const powerShellScript = ` 68 | Add-Type -AssemblyName System.Drawing 69 | 70 | $bounds = [Drawing.Rectangle]::FromLTRB(0, 0, ${width}, ${height}) 71 | $bmp = New-Object Drawing.Bitmap $bounds.width, $bounds.height 72 | $graphics = [Drawing.Graphics]::FromImage($bmp) 73 | 74 | $graphics.CopyFromScreen($bounds.Location, [Drawing.Point]::Empty, $bounds.size) 75 | $bmp.Save("${filename}.png", "Png") 76 | 77 | $graphics.Dispose() 78 | $bmp.Dispose() 79 | `; 80 | 81 | const process = exec(powerShellScript, { shell: 'powershell.exe' }, (error, stdout, stderr) => { 82 | if (error) console.error(error); 83 | if (stderr) console.error('Screenshot std error', stderr); 84 | if (stdout) console.log('Screenshot std out', stdout); 85 | }); 86 | await new Promise((resolve) => process.on('close', resolve)); 87 | 88 | const name = `${filename}.png`; 89 | return path.resolve(globalThis.process.cwd(), name); 90 | } 91 | -------------------------------------------------------------------------------- /src/config/comfyConfigManager.ts: -------------------------------------------------------------------------------- 1 | import log from 'electron-log/main'; 2 | import fs from 'node:fs'; 3 | import path from 'node:path'; 4 | 5 | export type DirectoryStructure = (string | DirectoryStructure)[]; 6 | 7 | export class ComfyConfigManager { 8 | private static readonly DEFAULT_DIRECTORIES: DirectoryStructure = [ 9 | 'custom_nodes', 10 | 'input', 11 | 'output', 12 | ['user', ['default']], 13 | [ 14 | 'models', 15 | [ 16 | 'checkpoints', 17 | 'clip', 18 | 'clip_vision', 19 | 'configs', 20 | 'controlnet', 21 | 'diffusers', 22 | 'diffusion_models', 23 | 'embeddings', 24 | 'gligen', 25 | 'hypernetworks', 26 | 'loras', 27 | 'photomaker', 28 | 'style_models', 29 | 'text_encoders', 30 | 'unet', 31 | 'upscale_models', 32 | 'vae', 33 | 'vae_approx', 34 | 35 | // TODO(robinhuang): Remove when we have a better way to specify base model paths. 36 | 'animatediff_models', 37 | 'animatediff_motion_lora', 38 | 'animatediff_video_formats', 39 | 'liveportrait', 40 | ['insightface', ['buffalo_1']], 41 | ['blip', ['checkpoints']], 42 | 'CogVideo', 43 | ['xlabs', ['loras', 'controlnets']], 44 | 'layerstyle', 45 | 'LLM', 46 | 'Joy_caption', 47 | ], 48 | ], 49 | ]; 50 | 51 | public static isComfyUIDirectory(directory: string): boolean { 52 | const requiredSubdirs = ['models', 'input', 'user', 'output', 'custom_nodes']; 53 | return requiredSubdirs.every((subdir) => fs.existsSync(path.join(directory, subdir))); 54 | } 55 | 56 | static createComfyDirectories(localComfyDirectory: string): void { 57 | log.info(`Creating ComfyUI directories in ${localComfyDirectory}`); 58 | 59 | try { 60 | this.createNestedDirectories(localComfyDirectory, this.DEFAULT_DIRECTORIES); 61 | } catch (error) { 62 | log.error('Failed to create ComfyUI directories:', error); 63 | } 64 | } 65 | 66 | static createNestedDirectories(basePath: string, structure: DirectoryStructure): void { 67 | for (const item of structure) { 68 | if (typeof item === 'string') { 69 | const dirPath = path.join(basePath, item); 70 | this.createDirIfNotExists(dirPath); 71 | } else if (Array.isArray(item) && item.length === 2) { 72 | const [dirName, subDirs] = item; 73 | if (typeof dirName === 'string') { 74 | const newBasePath = path.join(basePath, dirName); 75 | this.createDirIfNotExists(newBasePath); 76 | if (Array.isArray(subDirs)) { 77 | this.createNestedDirectories(newBasePath, subDirs); 78 | } 79 | } else { 80 | log.warn('Invalid directory structure item:', item); 81 | } 82 | } else { 83 | log.warn('Invalid directory structure item:', item); 84 | } 85 | } 86 | } 87 | 88 | /** 89 | * Create a directory if not exists 90 | * @param dirPath 91 | */ 92 | static createDirIfNotExists(dirPath: string): void { 93 | if (!fs.existsSync(dirPath)) { 94 | fs.mkdirSync(dirPath, { recursive: true }); 95 | log.info(`Created directory: ${dirPath}`); 96 | } else { 97 | log.info(`Directory already exists: ${dirPath}`); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/handlers/AppHandlers.ts: -------------------------------------------------------------------------------- 1 | import todesktop from '@todesktop/runtime'; 2 | import { app, dialog } from 'electron'; 3 | import log from 'electron-log/main'; 4 | 5 | import { strictIpcMain as ipcMain } from '@/infrastructure/ipcChannels'; 6 | 7 | import { IPC_CHANNELS } from '../constants'; 8 | 9 | export function registerAppHandlers() { 10 | ipcMain.handle(IPC_CHANNELS.QUIT, () => { 11 | log.info('Received quit IPC request. Quitting app...'); 12 | app.quit(); 13 | }); 14 | 15 | ipcMain.handle( 16 | IPC_CHANNELS.RESTART_APP, 17 | async (_event, { customMessage, delay }: { customMessage?: string; delay?: number }) => { 18 | function relaunchApplication(delay?: number) { 19 | if (delay) { 20 | setTimeout(() => { 21 | app.relaunch(); 22 | app.quit(); 23 | }, delay); 24 | } else { 25 | app.relaunch(); 26 | app.quit(); 27 | } 28 | } 29 | 30 | const delayText = delay ? `in ${delay}ms` : 'immediately'; 31 | if (!customMessage) { 32 | log.info(`Relaunching application ${delayText}`); 33 | return relaunchApplication(delay); 34 | } 35 | 36 | log.info(`Relaunching application ${delayText} with custom confirmation message: ${customMessage}`); 37 | 38 | const { response } = await dialog.showMessageBox({ 39 | type: 'question', 40 | buttons: ['Yes', 'No'], 41 | defaultId: 0, 42 | title: 'Restart ComfyUI', 43 | message: customMessage, 44 | detail: 'The application will close and restart automatically.', 45 | }); 46 | 47 | if (response === 0) { 48 | // "Yes" was clicked 49 | log.info('User confirmed restart'); 50 | relaunchApplication(delay); 51 | } else { 52 | log.info('User cancelled restart'); 53 | } 54 | } 55 | ); 56 | 57 | ipcMain.handle( 58 | IPC_CHANNELS.CHECK_FOR_UPDATES, 59 | async (options?: object): Promise<{ isUpdateAvailable: boolean; version?: string }> => { 60 | log.info('Manually checking for updates'); 61 | 62 | const updater = todesktop.autoUpdater; 63 | 64 | if (!updater) { 65 | log.error('todesktop.autoUpdater is not available'); 66 | throw new Error('todesktop.autoUpdater is not available'); 67 | } 68 | 69 | const result = await updater.checkForUpdates(options); 70 | 71 | if (result.updateInfo) { 72 | const { version, releaseDate } = result.updateInfo; 73 | const prettyDate = new Date(releaseDate).toLocaleString(); 74 | log.info(`Update available: version ${version} released on ${prettyDate}`); 75 | } else { 76 | log.info('No updates available'); 77 | } 78 | 79 | return { 80 | isUpdateAvailable: !!result.updateInfo, 81 | version: result.updateInfo?.version, 82 | }; 83 | } 84 | ); 85 | 86 | ipcMain.handle(IPC_CHANNELS.RESTART_AND_INSTALL, (options?: object) => { 87 | log.info('Restarting and installing update'); 88 | 89 | const updater = todesktop.autoUpdater; 90 | if (!updater) { 91 | log.error('todesktop.autoUpdater is not available'); 92 | throw new Error('todesktop.autoUpdater is not available'); 93 | } 94 | 95 | try { 96 | updater.restartAndInstall(options); 97 | } catch (error) { 98 | log.error(`Failed to restart and install update`, error); 99 | throw new Error(`Failed to restart and install update: ${error}`); 100 | } 101 | }); 102 | } 103 | -------------------------------------------------------------------------------- /.cursor/rules/integration-testing.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Integration testing guide for ComfyUI Desktop 3 | globs: 4 | - tests/integration/** 5 | --- 6 | 7 | # Integration Testing Guide 8 | 9 | This document provides quick reference for writing integration tests. For complete details, refer to [README.md](mdc:tests/integration/README.md). 10 | 11 | ## Quick Start 12 | 13 | - Framework: Playwright + TypeScript for Electron testing 14 | - Location: `/tests/integration/` 15 | - Environment: Requires `COMFYUI_ENABLE_VOLATILE_TESTS=1` or CI environment 16 | - Import fixtures from `testExtensions.ts`, not raw Playwright 17 | 18 | ## Test Projects 19 | 20 | - `install`: Fresh install tests (no pre-existing state) 21 | - `post-install-setup`: Sets up an installed app state 22 | - `post-install`: Tests requiring an installed app 23 | - `post-install-teardown`: Cleanup after post-install tests 24 | 25 | ## Available Fixtures 26 | 27 | ```typescript 28 | import { expect, test } from '../testExtensions'; 29 | 30 | // Core fixtures 31 | app: TestApp // Electron application 32 | window: Page // Main Playwright page 33 | 34 | // UI Component fixtures 35 | installWizard: TestInstallWizard 36 | serverStart: TestServerStart 37 | installedApp: TestInstalledApp 38 | troubleshooting: TestTroubleshooting 39 | graphCanvas: TestGraphCanvas 40 | 41 | // Utility 42 | attachScreenshot: (name) => Promise 43 | ``` 44 | 45 | ## Common Patterns 46 | 47 | ### Basic Test Structure 48 | ```typescript 49 | import { expect, test } from '../testExtensions'; 50 | 51 | test('should do something', async ({ window, installWizard }) => { 52 | await installWizard.clickGetStarted(); 53 | await expect(window).toHaveScreenshot('screenshot.png'); 54 | }); 55 | ``` 56 | 57 | ### Simulating Failures 58 | ```typescript 59 | // TestEnvironment provides error simulation 60 | await app.testEnvironment.breakInstallPath(); 61 | await app.testEnvironment.breakVenv(); 62 | await app.testEnvironment.breakServerStart(); 63 | // All automatically restored after test 64 | ``` 65 | 66 | ### Mocking Native Dialogs 67 | ```typescript 68 | await app.app.evaluate((electron, path) => { 69 | electron.dialog.showOpenDialog = async () => ({ 70 | canceled: false, 71 | filePaths: [path] 72 | }); 73 | }, selectedPath); 74 | ``` 75 | 76 | ## Key Classes 77 | 78 | - `TestApp`: Manages Electron process and test environment 79 | - `TestEnvironment`: File system state and error simulation 80 | - `TestInstallWizard`: Installation flow navigation 81 | - `TestInstalledApp`: Post-install app state 82 | - `TestServerStart`: Server startup monitoring 83 | - `TestTroubleshooting`: Error recovery UI 84 | 85 | ## Running Tests 86 | 87 | ```bash 88 | # Set environment variable 89 | export COMFYUI_ENABLE_VOLATILE_TESTS=1 90 | 91 | # Run all integration tests 92 | yarn test:e2e 93 | 94 | # Run specific test 95 | yarn playwright test tests/integration/install/installWizard.spec.ts 96 | 97 | # Update screenshots 98 | yarn test:e2e:update 99 | ``` 100 | 101 | ## Best Practices 102 | 103 | 1. Always import from `testExtensions`, not raw Playwright 104 | 2. Use fixture classes instead of raw locators 105 | 3. Leverage TestEnvironment for state manipulation 106 | 4. Trust auto-cleanup via Symbol.asyncDispose 107 | 5. Mark slow tests with `test.slow()` 108 | 6. Add screenshots for visual regression 109 | 7. Mock native dialogs when needed 110 | 8. Check CI behavior with `process.env.CI` 111 | 112 | For complete documentation including all test classes, methods, and detailed examples, see [README.md](mdc:tests/integration/README.md). -------------------------------------------------------------------------------- /src/main-process/appState.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import { EventEmitter } from 'node:events'; 3 | 4 | import { InstallStage } from '@/constants'; 5 | import { AppStartError } from '@/infrastructure/appStartError'; 6 | import type { Page } from '@/infrastructure/interfaces'; 7 | 8 | import { type InstallStageInfo, createInstallStageInfo } from './installStages'; 9 | 10 | /** App event names */ 11 | type AppStateEvents = { 12 | /** Occurs once, immediately before registering IPC handlers. */ 13 | ipcRegistered: []; 14 | /** Occurs once, immediately after the ComfyUI server has finished loading. */ 15 | loaded: []; 16 | /** Occurs when the install stage changes. */ 17 | installStageChanged: [InstallStageInfo]; 18 | }; 19 | 20 | /** 21 | * Stores global state for the app. 22 | * 23 | * @see {@link AppState} 24 | */ 25 | export interface IAppState extends Pick, 'on' | 'once' | 'off'> { 26 | /** Whether the app is already quitting. */ 27 | readonly isQuitting: boolean; 28 | /** Whether the pre-start IPC handlers have been loaded. */ 29 | readonly ipcRegistered: boolean; 30 | /** Whether the app has loaded. */ 31 | readonly loaded: boolean; 32 | /** The last page the app loaded from the desktop side. @see {@link AppWindow.loadPage} */ 33 | currentPage?: Page; 34 | /** Current installation stage information. */ 35 | readonly installStage: InstallStageInfo; 36 | 37 | /** Updates state - IPC handlers have been registered. */ 38 | emitIpcRegistered(): void; 39 | /** Updates state - the app has loaded. */ 40 | emitLoaded(): void; 41 | /** Updates the current install stage. */ 42 | setInstallStage(stage: InstallStageInfo): void; 43 | } 44 | 45 | /** 46 | * Concrete implementation of {@link IAppState}. 47 | */ 48 | class AppState extends EventEmitter implements IAppState { 49 | isQuitting = false; 50 | ipcRegistered = false; 51 | loaded = false; 52 | currentPage?: Page; 53 | installStage: InstallStageInfo; 54 | 55 | constructor() { 56 | super(); 57 | // Initialize install stage to idle 58 | this.installStage = createInstallStageInfo(InstallStage.IDLE, { progress: 0 }); 59 | } 60 | 61 | initialize() { 62 | // Store quitting state - suppresses errors when already quitting 63 | app.once('before-quit', () => { 64 | this.isQuitting = true; 65 | }); 66 | 67 | this.once('loaded', () => { 68 | this.loaded = true; 69 | }); 70 | this.once('ipcRegistered', () => { 71 | this.ipcRegistered = true; 72 | }); 73 | } 74 | 75 | emitIpcRegistered() { 76 | if (!this.ipcRegistered) this.emit('ipcRegistered'); 77 | } 78 | 79 | emitLoaded() { 80 | if (!this.loaded) this.emit('loaded'); 81 | } 82 | 83 | setInstallStage(stage: InstallStageInfo) { 84 | this.installStage = stage; 85 | this.emit('installStageChanged', stage); 86 | } 87 | } 88 | 89 | const appState = new AppState(); 90 | let initialized = false; 91 | 92 | /** 93 | * Initializes the app state singleton. 94 | * @throws {AppStartError} if called more than once. 95 | */ 96 | export function initializeAppState(): void { 97 | if (initialized) throw new AppStartError('AppState already initialized'); 98 | appState.initialize(); 99 | initialized = true; 100 | } 101 | 102 | /** 103 | * Returns the app state singleton. 104 | * @see {@link initializeAppState} 105 | * @throws {AppStartError} if {@link initializeAppState} is not called first. 106 | */ 107 | export function useAppState(): IAppState { 108 | if (!initialized) throw new AppStartError('AppState not initialized'); 109 | return appState; 110 | } 111 | -------------------------------------------------------------------------------- /tests/unit/handlers/AppHandlers.test.ts: -------------------------------------------------------------------------------- 1 | import todesktop from '@todesktop/runtime'; 2 | import { app, ipcMain } from 'electron'; 3 | import { beforeEach, describe, expect, test, vi } from 'vitest'; 4 | 5 | import { IPC_CHANNELS } from '@/constants'; 6 | import { registerAppHandlers } from '@/handlers/AppHandlers'; 7 | 8 | import { quitMessage } from '../setup'; 9 | 10 | const getHandler = (channel: string) => { 11 | const [, handlerFn] = vi.mocked(ipcMain.handle).mock.calls.find(([ch]) => ch === channel) || []; 12 | return handlerFn; 13 | }; 14 | 15 | describe('AppHandlers', () => { 16 | beforeEach(() => { 17 | vi.clearAllMocks(); 18 | registerAppHandlers(); 19 | }); 20 | 21 | describe('registerHandlers', () => { 22 | const handleChannels = [ 23 | IPC_CHANNELS.QUIT, 24 | IPC_CHANNELS.RESTART_APP, 25 | IPC_CHANNELS.CHECK_FOR_UPDATES, 26 | IPC_CHANNELS.RESTART_AND_INSTALL, 27 | ]; 28 | test.each(handleChannels)('should register handler for %s', (ch) => { 29 | expect(ipcMain.handle).toHaveBeenCalledWith(ch, expect.any(Function)); 30 | }); 31 | }); 32 | 33 | test('restart handler should call app.relaunch', async () => { 34 | expect(ipcMain.handle).toHaveBeenCalledWith(IPC_CHANNELS.RESTART_APP, expect.any(Function)); 35 | 36 | const handlerFn = getHandler(IPC_CHANNELS.RESTART_APP); 37 | await expect(handlerFn).rejects.toThrow(/^Cannot destructure property 'customMessage' of/); 38 | await expect(handlerFn?.(null!, [{}])).rejects.toThrow(quitMessage); 39 | expect(app.relaunch).toHaveBeenCalledTimes(1); 40 | }); 41 | 42 | test('quit handler should call app.quit', () => { 43 | const handlerFn = getHandler(IPC_CHANNELS.QUIT); 44 | expect(handlerFn).toThrow(quitMessage); 45 | }); 46 | 47 | describe('checkForUpdates handler', () => { 48 | let handler: any; 49 | beforeEach(() => { 50 | handler = getHandler(IPC_CHANNELS.CHECK_FOR_UPDATES); 51 | }); 52 | 53 | test('throws error when updater is unavailable', async () => { 54 | todesktop.autoUpdater = undefined; 55 | await expect(handler()).rejects.toThrow('todesktop.autoUpdater is not available'); 56 | }); 57 | 58 | test('returns update info when available', async () => { 59 | const mockUpdater = { 60 | checkForUpdates: vi.fn().mockResolvedValue({ 61 | updateInfo: { version: '1.2.3', releaseDate: '2020-01-01T00:00:00.000Z' }, 62 | }), 63 | }; 64 | todesktop.autoUpdater = mockUpdater as any; 65 | const result = await handler(); 66 | expect(mockUpdater.checkForUpdates).toHaveBeenCalled(); 67 | expect(result).toEqual({ isUpdateAvailable: true, version: '1.2.3' }); 68 | }); 69 | 70 | test('returns false when no update available', async () => { 71 | const mockUpdater = { checkForUpdates: vi.fn().mockResolvedValue({}) }; 72 | todesktop.autoUpdater = mockUpdater as any; 73 | await expect(handler()).resolves.toEqual({ isUpdateAvailable: false, version: undefined }); 74 | }); 75 | }); 76 | 77 | describe('restartAndInstall handler', () => { 78 | let handler: any; 79 | beforeEach(() => { 80 | handler = getHandler(IPC_CHANNELS.RESTART_AND_INSTALL); 81 | }); 82 | 83 | test('throws error when updater is unavailable', () => { 84 | todesktop.autoUpdater = undefined; 85 | expect(() => handler()).toThrow('todesktop.autoUpdater is not available'); 86 | }); 87 | 88 | test('calls restartAndInstall when updater is available', () => { 89 | const restartAndInstall = vi.fn(); 90 | todesktop.autoUpdater = { restartAndInstall } as any; 91 | handler(); 92 | expect(restartAndInstall).toHaveBeenCalled(); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /tests/integration/testApp.ts: -------------------------------------------------------------------------------- 1 | import type { ElectronApplication, JSHandle, TestInfo } from '@playwright/test'; 2 | import electronPath, { type BrowserWindow } from 'electron'; 3 | import { _electron as electron } from 'playwright'; 4 | 5 | import { createDesktopScreenshot } from '../shared/utils'; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 8 | const executablePath = String(electronPath); 9 | 10 | // Local testing QoL 11 | async function localTestQoL(app: ElectronApplication) { 12 | if (process.env.CI) return; 13 | 14 | // Get the first window that the app opens, wait if necessary. 15 | const window = await app.firstWindow(); 16 | // Direct Electron console to Node terminal. 17 | window.on('console', console.log); 18 | } 19 | 20 | /** Screen shot entire desktop */ 21 | async function attachScreenshot(testInfo: TestInfo, name: string) { 22 | try { 23 | const filePath = await createDesktopScreenshot(name); 24 | await testInfo.attach(name, { path: filePath }); 25 | } catch (error) { 26 | console.error(error); 27 | } 28 | } 29 | 30 | /** 31 | * Base class for desktop e2e tests. 32 | */ 33 | export class TestApp implements AsyncDisposable { 34 | private constructor( 35 | readonly app: ElectronApplication, 36 | readonly testInfo: TestInfo 37 | ) { 38 | app.once('close', () => (this.#appProcessTerminated = true)); 39 | } 40 | 41 | /** Async static factory */ 42 | static async create(testInfo: TestInfo) { 43 | const app = await TestApp.launchElectron(); 44 | return new TestApp(app, testInfo); 45 | } 46 | 47 | /** Get the first window that the app opens. Wait if necessary. */ 48 | async firstWindow() { 49 | return await this.app.firstWindow(); 50 | } 51 | 52 | async browserWindow(): Promise> { 53 | const windows = this.app.windows(); 54 | if (windows.length === 0) throw new Error('No windows found'); 55 | 56 | return await this.app.browserWindow(windows[0]); 57 | } 58 | 59 | async isMaximized() { 60 | const window = await this.browserWindow(); 61 | return window.evaluate((window) => window.isMaximized()); 62 | } 63 | 64 | async restoreWindow() { 65 | const window = await this.browserWindow(); 66 | await window.evaluate((window) => window.restore()); 67 | } 68 | 69 | /** Executes the Electron app. If not in CI, logs browser console via `console.log()`. */ 70 | protected static async launchElectron() { 71 | const app = await electron.launch({ 72 | args: ['.'], 73 | executablePath, 74 | cwd: '.', 75 | }); 76 | await localTestQoL(app); 77 | return app; 78 | } 79 | 80 | /** Relies on the app exiting on its own. */ 81 | async close() { 82 | if (this.#appProcessTerminated || this.#closed) return; 83 | this.#closed = true; 84 | 85 | const windows = this.app.windows(); 86 | if (windows.length === 0) return; 87 | 88 | try { 89 | const close = this.app.waitForEvent('close', { timeout: 60 * 1000 }); 90 | await Promise.all(windows.map((x) => x.close())); 91 | await close; 92 | } catch (error) { 93 | console.error('App failed to close; attaching screenshot to TestInfo'); 94 | await attachScreenshot(this.testInfo, 'test-app-close-failure'); 95 | throw error; 96 | } 97 | } 98 | 99 | #appProcessTerminated = false; 100 | 101 | /** Ensure close() is called only once. */ 102 | #closed = false; 103 | /** Ensure the app is disposed only once. */ 104 | #disposed = false; 105 | 106 | /** Dispose: close the app and all disposable objects. */ 107 | async [Symbol.asyncDispose](): Promise { 108 | if (this.#disposed) return; 109 | this.#disposed = true; 110 | 111 | await this.close(); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /tests/unit/main-process/appWindow.test.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, type Tray } from 'electron'; 2 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 3 | 4 | import { AppWindow } from '@/main-process/appWindow'; 5 | 6 | import { type PartialMock, electronMock } from '../setup'; 7 | 8 | const additionalMocks: PartialMock = { 9 | BrowserWindow: vi.fn() as PartialMock, 10 | nativeTheme: { 11 | shouldUseDarkColors: true, 12 | }, 13 | Menu: { 14 | buildFromTemplate: vi.fn(), 15 | getApplicationMenu: vi.fn(() => null), 16 | }, 17 | Tray: vi.fn(() => ({ 18 | setContextMenu: vi.fn(), 19 | setPressedImage: vi.fn(), 20 | setToolTip: vi.fn(), 21 | on: vi.fn(), 22 | })) as PartialMock, 23 | screen: { 24 | getPrimaryDisplay: vi.fn(() => ({ 25 | workAreaSize: { width: 1024, height: 768 }, 26 | })), 27 | }, 28 | }; 29 | 30 | Object.assign(electronMock, additionalMocks); 31 | 32 | vi.mock('electron-store', () => ({ 33 | default: vi.fn(() => ({ 34 | get: vi.fn(), 35 | set: vi.fn(), 36 | })), 37 | })); 38 | 39 | vi.mock('@/store/desktopConfig', () => ({ 40 | useDesktopConfig: vi.fn(() => ({ 41 | get: vi.fn((key: string) => { 42 | if (key === 'installState') return 'installed'; 43 | }), 44 | set: vi.fn(), 45 | })), 46 | })); 47 | 48 | describe('AppWindow.isOnPage', () => { 49 | let appWindow: AppWindow; 50 | let mockWebContents: Pick; 51 | 52 | beforeEach(() => { 53 | mockWebContents = { 54 | getURL: vi.fn(), 55 | setWindowOpenHandler: vi.fn(), 56 | }; 57 | 58 | vi.stubGlobal('process', { 59 | ...process, 60 | resourcesPath: '/mock/app/path/assets', 61 | }); 62 | 63 | vi.mocked(BrowserWindow).mockImplementation( 64 | () => 65 | ({ 66 | webContents: mockWebContents, 67 | on: vi.fn(), 68 | once: vi.fn(), 69 | isMaximized: vi.fn(() => false), 70 | getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1024, height: 768 })), 71 | }) as unknown as BrowserWindow 72 | ); 73 | 74 | appWindow = new AppWindow(undefined, undefined, false); 75 | }); 76 | 77 | it('should handle file protocol URLs with hash correctly', () => { 78 | vi.mocked(mockWebContents.getURL).mockReturnValue('file:///path/to/index.html#welcome'); 79 | expect(appWindow.isOnPage('welcome')).toBe(true); 80 | }); 81 | 82 | it('should handle http protocol URLs correctly', () => { 83 | vi.mocked(mockWebContents.getURL).mockReturnValue('http://localhost:3000/welcome'); 84 | expect(appWindow.isOnPage('welcome')).toBe(true); 85 | }); 86 | 87 | it('should handle empty pages correctly', () => { 88 | vi.mocked(mockWebContents.getURL).mockReturnValue('file:///path/to/index.html'); 89 | expect(appWindow.isOnPage('')).toBe(true); 90 | }); 91 | 92 | it('should return false for non-matching pages', () => { 93 | vi.mocked(mockWebContents.getURL).mockReturnValue('file:///path/to/index.html#welcome'); 94 | expect(appWindow.isOnPage('desktop-start')).toBe(false); 95 | }); 96 | 97 | it('should handle URLs with no hash or path', () => { 98 | vi.mocked(mockWebContents.getURL).mockReturnValue('http://localhost:3000'); 99 | expect(appWindow.isOnPage('')).toBe(true); 100 | }); 101 | 102 | it('should handle URLs with query parameters', () => { 103 | vi.mocked(mockWebContents.getURL).mockReturnValue('http://localhost:3000/server-start?param=value'); 104 | expect(appWindow.isOnPage('server-start')).toBe(true); 105 | }); 106 | 107 | it('should handle file URLs with both hash and query parameters', () => { 108 | vi.mocked(mockWebContents.getURL).mockReturnValue('file:///path/to/index.html?param=value#welcome'); 109 | expect(appWindow.isOnPage('welcome')).toBe(true); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import eslintPluginUnicorn from 'eslint-plugin-unicorn'; 3 | import globals from 'globals'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | export default tseslint.config( 7 | // Baseline include / exclude 8 | { files: ['**/*.{js,cjs,mjs,ts,mts}'] }, 9 | { ignores: ['dist/**/*', 'jest.config.cjs', 'scripts/shims/**/*'] }, 10 | 11 | // Baseline 12 | eslint.configs.recommended, 13 | ...tseslint.configs.recommendedTypeChecked, 14 | { 15 | languageOptions: { 16 | parserOptions: { 17 | projectService: true, 18 | tsconfigRootDir: import.meta.dirname, 19 | }, 20 | }, 21 | rules: { 22 | 'no-empty-pattern': ['error', { allowObjectPatternsAsParameters: true }], 23 | 'no-control-regex': 'off', 24 | 25 | '@typescript-eslint/restrict-template-expressions': 'off', 26 | '@typescript-eslint/prefer-readonly': 'warn', 27 | }, 28 | }, 29 | 30 | // Baseline (except preload) 31 | { 32 | ignores: ['./src/preload.ts'], 33 | languageOptions: { globals: { ...globals.node } }, 34 | }, 35 | 36 | // Preload 37 | { 38 | files: ['./src/preload.ts'], 39 | languageOptions: { globals: { ...globals.browser } }, 40 | }, 41 | 42 | // Unicorn 43 | eslintPluginUnicorn.configs['flat/recommended'], 44 | { 45 | rules: { 46 | // Enable 47 | 'unicorn/better-regex': 'warn', 48 | // Disable 49 | 'unicorn/prefer-string-slice': 'off', 50 | 'unicorn/no-negated-condition': 'off', 51 | 'unicorn/filename-case': 'off', 52 | 'unicorn/no-null': 'off', 53 | 'unicorn/prevent-abbreviations': 'off', 54 | 'unicorn/switch-case-braces': 'off', 55 | 'unicorn/explicit-length-check': 'off', 56 | 'unicorn/consistent-function-scoping': 'off', 57 | 'unicorn/prefer-event-target': 'off', 58 | 'unicorn/prefer-ternary': ['error', 'only-single-line'], 59 | 'unicorn/no-nested-ternary': 'off', 60 | }, 61 | }, 62 | 63 | // Scripts 64 | { 65 | files: ['scripts/**/*'], 66 | rules: { 67 | 'unicorn/no-process-exit': 'off', 68 | }, 69 | }, 70 | 71 | // Tests 72 | { 73 | files: ['tests/**/*'], 74 | rules: { 75 | 'unicorn/prefer-module': 'off', 76 | 'unicorn/no-useless-undefined': 'off', 77 | '@typescript-eslint/unbound-method': 'off', 78 | '@typescript-eslint/no-unsafe-assignment': 'off', 79 | '@typescript-eslint/no-unsafe-member-access': 'off', 80 | '@typescript-eslint/no-unsafe-argument': 'off', 81 | '@typescript-eslint/no-unsafe-call': 'off', 82 | '@typescript-eslint/no-unsafe-return': 'off', 83 | '@typescript-eslint/no-explicit-any': 'off', 84 | }, 85 | }, 86 | 87 | // Forbid import of Electron's any-typed ipcMain / ipcRenderer. 88 | { 89 | rules: { 90 | 'no-restricted-imports': [ 91 | 'error', 92 | { 93 | paths: [ 94 | { 95 | name: 'electron', 96 | importNames: ['ipcMain', 'ipcRenderer'], 97 | message: "Import strictIpcMain/strictIpcRenderer from '@/ipc/strictIpc' instead of Electron's IPC.", 98 | }, 99 | { 100 | name: 'electron/main', 101 | importNames: ['ipcMain'], 102 | message: "Import strictIpcMain from '@/ipc/strictIpc' instead of Electron's IPC.", 103 | }, 104 | { 105 | name: 'electron/renderer', 106 | importNames: ['ipcRenderer'], 107 | message: "Import strictIpcRenderer from '@/ipc/strictIpc' instead of Electron's IPC.", 108 | }, 109 | ], 110 | }, 111 | ], 112 | }, 113 | }, 114 | // Override restricted imports for strictIpc.ts. 115 | { 116 | files: ['src/infrastructure/ipcChannels.ts', 'tests/**/*.ts'], 117 | rules: { 'no-restricted-imports': 'off' }, 118 | } 119 | ); 120 | -------------------------------------------------------------------------------- /scripts/downloadFrontend.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import extract from 'extract-zip'; 3 | import { execSync } from 'node:child_process'; 4 | import fs from 'node:fs/promises'; 5 | import path from 'node:path'; 6 | 7 | import packageJson from './getPackage.js'; 8 | 9 | const { frontend } = packageJson.config; 10 | if (!frontend) { 11 | console.error('package.json does not contain frontend version config'); 12 | process.exit(1); 13 | } 14 | 15 | // Example "v1.3.34" 16 | const version = process.argv[2] || frontend.version; 17 | if (!version) { 18 | console.error('No version specified'); 19 | process.exit(1); 20 | } 21 | 22 | const frontendRepo = 'https://github.com/Comfy-Org/ComfyUI_frontend'; 23 | 24 | if (frontend.optionalBranch) { 25 | // Optional branch, no release; build from source 26 | console.log('Building frontend from source...'); 27 | const frontendDir = 'assets/frontend'; 28 | 29 | try { 30 | execAndLog(`git clone ${frontendRepo} --depth 1 --branch ${frontend.optionalBranch} ${frontendDir}`); 31 | execAndLog(`npm ci`, frontendDir); 32 | execAndLog(`npm run build`, frontendDir, { DISTRIBUTION: 'desktop' }); 33 | await fs.mkdir('assets/ComfyUI/web_custom_versions/desktop_app', { recursive: true }); 34 | await fs.cp(path.join(frontendDir, 'dist'), 'assets/ComfyUI/web_custom_versions/desktop_app', { recursive: true }); 35 | await fs.rm(frontendDir, { recursive: true }); 36 | } catch (error) { 37 | console.error('Error building frontend:', error.message); 38 | process.exit(1); 39 | } 40 | 41 | /** 42 | * Run a command and log the output. 43 | * @param {string} command The command to run. 44 | * @param {string | undefined} cwd The working directory. 45 | * @param {Record} env Additional environment variables. 46 | */ 47 | function execAndLog(command, cwd, env = {}) { 48 | const output = execSync(command, { 49 | cwd, 50 | encoding: 'utf8', 51 | env: { ...process.env, ...env }, 52 | }); 53 | console.log(output); 54 | } 55 | } else { 56 | // Download normal frontend release zip 57 | const url = `https://github.com/Comfy-Org/ComfyUI_frontend/releases/download/v${version}/dist.zip`; 58 | 59 | const downloadPath = 'temp_frontend.zip'; 60 | const extractPath = 'assets/ComfyUI/web_custom_versions/desktop_app'; 61 | 62 | async function downloadAndExtractFrontend() { 63 | try { 64 | // Create directories if they don't exist 65 | await fs.mkdir(extractPath, { recursive: true }); 66 | 67 | // Download the file 68 | console.log('Downloading frontend...'); 69 | const response = await axios({ 70 | method: 'GET', 71 | url: url, 72 | responseType: 'arraybuffer', 73 | }); 74 | 75 | // Save to temporary file 76 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 77 | await fs.writeFile(downloadPath, response.data); 78 | 79 | // Extract the zip file 80 | console.log('Extracting frontend...'); 81 | await extract(downloadPath, { dir: path.resolve(extractPath) }); 82 | 83 | // Clean up temporary file 84 | await fs.unlink(downloadPath); 85 | 86 | console.log('Frontend downloaded and extracted successfully!'); 87 | } catch (error) { 88 | console.error('Error downloading frontend:', error.message); 89 | process.exit(1); 90 | } 91 | } 92 | 93 | await downloadAndExtractFrontend(); 94 | } 95 | 96 | // Copy desktop-ui package to assets 97 | console.log('Copying desktop-ui package...'); 98 | const desktopUiSource = 'node_modules/@comfyorg/desktop-ui/dist'; 99 | const desktopUiTarget = 'assets/desktop-ui'; 100 | 101 | try { 102 | await fs.mkdir(desktopUiTarget, { recursive: true }); 103 | await fs.cp(desktopUiSource, desktopUiTarget, { recursive: true }); 104 | console.log('Desktop UI copied successfully!'); 105 | } catch (error) { 106 | console.error('Error copying desktop-ui:', error.message); 107 | process.exit(1); 108 | } 109 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prefer-top-level-await */ 2 | import dotenv from 'dotenv'; 3 | import { app, session, shell } from 'electron'; 4 | import { LevelOption } from 'electron-log'; 5 | import log from 'electron-log/main'; 6 | 7 | import { LogFile } from './constants'; 8 | import { DesktopApp } from './desktopApp'; 9 | import { removeAnsiCodesTransform, replaceFileLoggingTransform } from './infrastructure/structuredLogging'; 10 | import { initializeAppState } from './main-process/appState'; 11 | import { DevOverrides } from './main-process/devOverrides'; 12 | import SentryLogging from './services/sentry'; 13 | import { getTelemetry } from './services/telemetry'; 14 | import { DesktopConfig } from './store/desktopConfig'; 15 | import { rotateLogFiles } from './utils'; 16 | 17 | // Synchronous pre-start configuration 18 | dotenv.config(); 19 | initalizeLogging(); 20 | 21 | const telemetry = getTelemetry(); 22 | initializeAppState(); 23 | const overrides = new DevOverrides(); 24 | 25 | // Register the quit handlers regardless of single instance lock and before squirrel startup events. 26 | quitWhenAllWindowsAreClosed(); 27 | trackAppQuitEvents(); 28 | initializeSentry(); 29 | 30 | // Async config & app start 31 | const gotTheLock = app.requestSingleInstanceLock(); 32 | if (!gotTheLock) { 33 | log.info('App already running. Exiting...'); 34 | app.quit(); 35 | } else { 36 | startApp().catch((error) => { 37 | log.error('Unhandled exception in app startup', error); 38 | app.exit(2020); 39 | }); 40 | } 41 | 42 | /** Wrapper for top-level await; the app is bundled to CommonJS. */ 43 | async function startApp() { 44 | // Wait for electron app ready event 45 | await new Promise((resolve) => app.once('ready', () => resolve())); 46 | await rotateLogFiles(app.getPath('logs'), LogFile.Main, 50); 47 | log.debug('App ready'); 48 | telemetry.registerHandlers(); 49 | telemetry.track('desktop:app_ready'); 50 | 51 | // Load config or exit 52 | const config = await DesktopConfig.load(shell); 53 | if (!config) { 54 | DesktopApp.fatalError({ 55 | message: 'Unknown error loading app config on startup.', 56 | title: 'User Data', 57 | exitCode: 20, 58 | }); 59 | } 60 | 61 | telemetry.loadGenerationCount(config); 62 | 63 | // Load the Vue DevTools extension 64 | if (process.env.VUE_DEVTOOLS_PATH) { 65 | try { 66 | await session.defaultSession.loadExtension(process.env.VUE_DEVTOOLS_PATH); 67 | } catch (error) { 68 | log.error('Error loading Vue DevTools extension', error); 69 | } 70 | } 71 | 72 | const desktopApp = new DesktopApp(overrides, config); 73 | await desktopApp.showLoadingPage(); 74 | await desktopApp.start(); 75 | } 76 | 77 | /** 78 | * Must be called prior to any logging. Sets default log level and logs app version. 79 | * Corrects issues when logging structured data (to file). 80 | */ 81 | function initalizeLogging() { 82 | log.initialize(); 83 | log.transports.file.level = (process.env.LOG_LEVEL as LevelOption) ?? 'info'; 84 | log.transports.file.transforms.unshift(removeAnsiCodesTransform); 85 | replaceFileLoggingTransform(log.transports); 86 | 87 | // Set the app version for the desktop app. Relied on by Manager and other sub-processes. 88 | process.env.__COMFYUI_DESKTOP_VERSION__ = app.getVersion(); 89 | log.info(`Starting app v${app.getVersion()}`); 90 | } 91 | 92 | /** Quit when all windows are closed.*/ 93 | function quitWhenAllWindowsAreClosed() { 94 | app.on('window-all-closed', () => { 95 | log.info('Quitting ComfyUI because window all closed'); 96 | app.quit(); 97 | }); 98 | } 99 | 100 | /** Add telemetry for the app quit event. */ 101 | function trackAppQuitEvents() { 102 | app.on('quit', (event, exitCode) => { 103 | telemetry.track('desktop:app_quit', { 104 | reason: event, 105 | exitCode, 106 | }); 107 | }); 108 | } 109 | 110 | /** Sentry needs to be initialized at the top level. */ 111 | function initializeSentry() { 112 | log.verbose('Initializing Sentry'); 113 | SentryLogging.init(); 114 | } 115 | -------------------------------------------------------------------------------- /src/services/cmCli.ts: -------------------------------------------------------------------------------- 1 | import log from 'electron-log/main'; 2 | import fs from 'node:fs/promises'; 3 | import path from 'node:path'; 4 | import { fileSync } from 'tmp'; 5 | 6 | import { pathAccessible } from '@/utils'; 7 | 8 | import { getAppResourcesPath } from '../install/resourcePaths'; 9 | import { ProcessCallbacks, VirtualEnvironment } from '../virtualEnvironment'; 10 | import { HasTelemetry, ITelemetry, trackEvent } from './telemetry'; 11 | 12 | export class CmCli implements HasTelemetry { 13 | private readonly cliPath: string; 14 | private readonly moduleName = 'comfyui_manager.cm_cli'; 15 | constructor( 16 | private readonly virtualEnvironment: VirtualEnvironment, 17 | readonly telemetry: ITelemetry 18 | ) { 19 | this.cliPath = path.join(getAppResourcesPath(), 'ComfyUI', 'custom_nodes', 'ComfyUI-Manager', 'cm-cli.py'); 20 | } 21 | 22 | private async buildCommandArgs(args: string[]): Promise { 23 | if (await pathAccessible(this.cliPath)) { 24 | return [this.cliPath, ...args]; 25 | } 26 | return ['-m', this.moduleName, ...args]; 27 | } 28 | 29 | public async runCommandAsync( 30 | args: string[], 31 | callbacks?: ProcessCallbacks, 32 | env: Record = {}, 33 | checkExit: boolean = true, 34 | cwd?: string 35 | ) { 36 | let output = ''; 37 | let error = ''; 38 | const ENV = { 39 | COMFYUI_PATH: this.virtualEnvironment.basePath, 40 | ...env, 41 | }; 42 | const commandArgs = await this.buildCommandArgs(args); 43 | const { exitCode } = await this.virtualEnvironment.runPythonCommandAsync( 44 | commandArgs, 45 | { 46 | onStdout: (message) => { 47 | output += message; 48 | callbacks?.onStdout?.(message); 49 | }, 50 | onStderr: (message) => { 51 | console.warn('[warn]', message); 52 | error += message; 53 | callbacks?.onStderr?.(message); 54 | }, 55 | }, 56 | ENV, 57 | cwd 58 | ); 59 | 60 | if (checkExit && exitCode !== 0) { 61 | throw new Error(`Error calling cm-cli: \nExit code: ${exitCode}\nOutput:${output}\n\nError:${error}`); 62 | } 63 | 64 | return output; 65 | } 66 | 67 | @trackEvent('migrate_flow:migrate_custom_nodes') 68 | public async restoreCustomNodes(fromComfyDir: string, callbacks: ProcessCallbacks) { 69 | const tmpFile = fileSync({ postfix: '.json' }); 70 | try { 71 | log.debug('Using temp file:', tmpFile.name); 72 | await this.saveSnapshot(fromComfyDir, tmpFile.name, callbacks); 73 | await this.restoreSnapshot(tmpFile.name, path.join(this.virtualEnvironment.basePath, 'custom_nodes'), callbacks); 74 | 75 | // Remove extra ComfyUI-Manager directory that was created by the migration. 76 | const managerPath = path.join(this.virtualEnvironment.basePath, 'custom_nodes', 'ComfyUI-Manager'); 77 | if (await pathAccessible(managerPath)) { 78 | await fs.rm(managerPath, { recursive: true, force: true }); 79 | log.info('Removed extra ComfyUI-Manager directory:', managerPath); 80 | } 81 | } finally { 82 | tmpFile?.removeCallback(); 83 | } 84 | } 85 | 86 | public async saveSnapshot(fromComfyDir: string, outFile: string, callbacks: ProcessCallbacks): Promise { 87 | const output = await this.runCommandAsync( 88 | ['save-snapshot', '--output', outFile, '--no-full-snapshot'], 89 | callbacks, 90 | { 91 | COMFYUI_PATH: fromComfyDir, 92 | PYTHONPATH: fromComfyDir, 93 | }, 94 | true, 95 | fromComfyDir 96 | ); 97 | log.info(output); 98 | } 99 | 100 | public async restoreSnapshot(snapshotFile: string, toComfyDir: string, callbacks: ProcessCallbacks) { 101 | log.info('Restoring snapshot', snapshotFile); 102 | const output = await this.runCommandAsync( 103 | ['restore-snapshot', snapshotFile, '--restore-to', toComfyDir], 104 | callbacks, 105 | { 106 | COMFYUI_PATH: path.join(getAppResourcesPath(), 'ComfyUI'), 107 | } 108 | ); 109 | log.info(output); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /scripts/resetInstall.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import os from 'node:os'; 3 | import path from 'node:path'; 4 | import * as readline from 'node:readline'; 5 | import * as yaml from 'yaml'; 6 | 7 | /** 8 | * Get the path to the extra_models_config.yaml file based on the platform. 9 | * @param {string} filename The name of the file to find in the user data folder 10 | * @returns The path to the extra_models_config.yaml file. 11 | */ 12 | 13 | function getConfigPath(filename) { 14 | switch (process.platform) { 15 | case 'darwin': // macOS 16 | return path.join(os.homedir(), 'Library', 'Application Support', 'ComfyUI', filename); 17 | case 'win32': // Windows 18 | return path.join(process.env.APPDATA, 'ComfyUI', filename); 19 | default: 20 | console.log('Platform not supported for this operation'); 21 | process.exit(1); 22 | } 23 | } 24 | 25 | /** @returns {Promise} */ 26 | function askForConfirmation(question) { 27 | const rl = readline.createInterface({ 28 | input: process.stdin, 29 | output: process.stdout, 30 | }); 31 | 32 | return new Promise((resolve) => { 33 | rl.question(question + ' (y/N): ', (answer) => { 34 | rl.close(); 35 | resolve(answer.toLowerCase() === 'y'); 36 | }); 37 | }); 38 | } 39 | 40 | async function main() { 41 | try { 42 | const configPath = getConfigPath('config.json'); 43 | const windowStorePath = getConfigPath('window.json'); 44 | const modelsConfigPath = getConfigPath('extra_models_config.yaml'); 45 | let desktopBasePath; 46 | /** @type {string | undefined} */ 47 | let basePath; 48 | 49 | // Read basePath from desktop config 50 | if (fs.existsSync(configPath)) { 51 | const configContent = fs.readFileSync(configPath, 'utf8'); 52 | 53 | /** @type {import('@/store/desktopSettings').DesktopSettings} */ 54 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 55 | const parsed = JSON.parse(configContent); 56 | desktopBasePath = parsed?.basePath; 57 | } 58 | 59 | // Read base_path before deleting the config file 60 | if (fs.existsSync(modelsConfigPath)) { 61 | const configContent = fs.readFileSync(modelsConfigPath, 'utf8'); 62 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 63 | const config = yaml.parse(configContent); 64 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access 65 | basePath = config?.comfyui?.base_path; 66 | } else { 67 | console.log('Config file not found, nothing to remove'); 68 | } 69 | 70 | // Delete all config files 71 | for (const file of [configPath, windowStorePath, modelsConfigPath]) { 72 | if (fs.existsSync(file)) { 73 | fs.unlinkSync(file); 74 | console.log(`Successfully removed ${file}`); 75 | } 76 | } 77 | 78 | // If config.json basePath exists, ask user if they want to delete it 79 | if (desktopBasePath && fs.existsSync(desktopBasePath)) { 80 | console.log(`Found ComfyUI installation directory at: ${desktopBasePath}`); 81 | const shouldDelete = await askForConfirmation('Would you like to delete this directory as well?'); 82 | 83 | if (shouldDelete) { 84 | fs.rmSync(desktopBasePath, { recursive: true, force: true }); 85 | console.log(`Successfully removed ComfyUI directory at ${desktopBasePath}`); 86 | } else { 87 | console.log('Skipping ComfyUI directory deletion'); 88 | } 89 | } 90 | 91 | // If base_path exists and does not match basePath, ask user if they want to delete it 92 | if (basePath && basePath !== desktopBasePath && fs.existsSync(basePath)) { 93 | console.log(`Found ComfyUI models directory at: ${basePath}`); 94 | const shouldDelete = await askForConfirmation('Would you like to delete this directory as well?'); 95 | 96 | if (shouldDelete) { 97 | fs.rmSync(basePath, { recursive: true, force: true }); 98 | console.log(`Successfully removed ComfyUI directory at ${basePath}`); 99 | } else { 100 | console.log('Skipping ComfyUI directory deletion'); 101 | } 102 | } 103 | } catch (error) { 104 | console.error('Error during reset:', error); 105 | process.exit(1); 106 | } 107 | } 108 | 109 | await main(); 110 | -------------------------------------------------------------------------------- /Hyper-V.md: -------------------------------------------------------------------------------- 1 | ## Windows development: Hyper-V 2 | 3 | ComfyUI desktop can be built and tested in using a Hyper-V VM. This document convers configuration of **CPU mode** only. 4 | 5 | ### Requirements 6 | 7 | - 32GB RAM 8 | - A Windows install ISO 9 | - Local admin 10 | - Check the summary on the [official install process](https://learn.microsoft.com/virtualization/hyper-v-on-windows/quick-start/enable-hyper-v#check-requirements) or the [system requirements page](https://learn.microsoft.com/virtualization/hyper-v-on-windows/reference/hyper-v-requirements) for full details. 11 | 12 | ### Enabling 13 | 14 | Enable Hyper-V using the PowerShell command (reboot required): 15 | 16 | ```ps1 17 | Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All 18 | ``` 19 | 20 | Source: [Microsoft documentation](https://learn.microsoft.com/en-us/virtualization/hyper-v-on-windows/quick-start/enable-hyper-v#enable-hyper-v-using-powershell) 21 | 22 | ### Configure a VM 23 | 24 | A quick-start script to create a VM. For full details, see [Create a virtual machine with Hyper-V on Windows](https://learn.microsoft.com/en-us/virtualization/hyper-v-on-windows/quick-start/create-virtual-machine). 25 | 26 | Minimum recommended configuration is: 27 | 28 | - Generation 2 29 | - 16GB RAM 30 | - 100GB virtual HDD 31 | 32 | Commands must be run as administrator. 33 | 34 | ```ps1 35 | # Path to Windows install ISO 36 | $InstallMedia = 'C:\Users\User\Downloads\win11.iso' 37 | 38 | # Location where VM files will be stored 39 | $RootPath = 'D:\Virtual Machines' 40 | 41 | # VM config 42 | $VMName = 'comfyui' 43 | $RAM = 16 44 | $HDD = 100 45 | $VirtualCPUs = 6 46 | 47 | # Required for Windows 11 48 | $UseVirtualTPM = $true 49 | 50 | # Switch name - if unsure, do not change 51 | $Switch = 'Default Switch' 52 | 53 | # 54 | # End VM config 55 | # 56 | 57 | $GBtoBytes = 1024 * 1024 * 1024 58 | $RAM *= $GBtoBytes 59 | $HDD *= $GBtoBytes 60 | 61 | # Create New Virtual Machine 62 | New-VM -Name $VMName -MemoryStartupBytes $RAM -Generation 2 -NewVHDPath "$RootPath\$VMName\$VMName.vhdx" -NewVHDSizeBytes $HDD -Path "$RootPath\$VMName" -SwitchName $Switch 63 | 64 | # Add DVD Drive to Virtual Machine 65 | Add-VMScsiController -VMName $VMName 66 | Add-VMDvdDrive -VMName $VMName -ControllerNumber 1 -ControllerLocation 0 -Path $InstallMedia 67 | 68 | # Mount Installation Media 69 | $DVDDrive = Get-VMDvdDrive -VMName $VMName 70 | 71 | # Configure Virtual Machine to Boot from DVD 72 | Set-VMFirmware -VMName $VMName -FirstBootDevice $DVDDrive -EnableSecureBoot On 73 | 74 | # Enable virtual TPM 75 | Set-VMKeyProtector -VMName $VMName -NewLocalKeyProtector 76 | Enable-VMTPM -VMName $VMName 77 | 78 | # Number of virtual processors to expose 79 | Set-VMProcessor -VMName $VMName -Count $VirtualCPUs 80 | ``` 81 | 82 | ### Connect to the VM 83 | 84 | Use the Hyper-V GUI to connect to the VM, or as an Administrator: 85 | 86 | ```ps1 87 | vmconnect.exe localhost comfyui 88 | ``` 89 | 90 | ### Install Windows 91 | 92 | 1. Install & update Windows, and configure to your liking. 93 | 1. From outside the VM, take a snapshot via the Hyper-V GUI or as admin in PowerShell: 94 | 95 | ```ps1 96 | Checkpoint-VM -Name $VMName -SnapshotName "Base VM configured and updated" 97 | ``` 98 | 99 | ### Checkpoints 100 | 101 | - Take checkpoints before making major changes 102 | - If taken when a VM is shut down, a checkpoint is extremely fast and takes almost no space 103 | - Instead of backing up the entire drive, a checkpoint simply stops writing changes to the virtual drive file. Instead, a new file is created next to it, and differences to the original disk are saved there. 104 | - Applying a checkpoint completely resets the hard drive to the exact condition it was in before 105 | 106 | ### Ready to code 107 | 108 | - Copy & paste both files & code between the VM and host OS 109 | - Proceed with normal dev documentation 110 | - Remember to use `--cpu` when launching ComfyUI 111 | - Don't forget to take checkpoints! 112 | - Common pitfall: avoid opening `.vhdx` files in Windows Explorer (simply opening it once can prevent a VM from starting, requiring manual repair) 113 | 114 | ### Restoring checkpoints 115 | 116 | GUI is strongly recommended when applying checkpoints. If you need the command line: 117 | 118 | ```ps1 119 | Restore-VMCheckpoint -VMName $VMName -Name "Insert 'SnapshotName' Here" 120 | ``` 121 | -------------------------------------------------------------------------------- /.claude/commands/bump-stable.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Create a new version of ComfyUI Desktop. It will update core, frontend, templates, and embedded docs. Updates compiled requirements with new templates / docs versions. 3 | --- 4 | 5 | Please update the version of ComfyUI to the latest: 6 | 7 | 1. Reference this PR as an example. https://github.com/Comfy-Org/desktop/commit/7cba9c25b95b30050dfd6864088ca91493bfd00b 8 | 2. Go to [ComfyUI](https://github.com/comfyanonymous/ComfyUI/) Github repo and see what the latest Github Release is 9 | - If there is {a semver tag (prefixed with `v`) without a release} OR {a `prerelease`} OR {a draft release} that is newer than the release marked as `latest`, AND this new version is a either a single major, minor, or patch version increment on the `latest` release, use that version instead of latest. 10 | 3. Read the `requirements.txt` file from that release 11 | 4. Update the ComfyUI version version in @package.json based on what is in the latest Github Release. If config.ComfyUI.optionalBranch is set in @package.json, change it to an empty string (""). 12 | 5. Update the frontend version in @package.json (`frontendVersion`) to the version specified in `requirements.txt` 13 | - If we currently have a higher version than what is in requirements.txt, DO NOT downgrade - continue and notify the human using warning emoji: ⚠️ 14 | 6. Update the versions in `scripts/core-requirements.patch` to match those in `requirements.txt` from the ComfyUI repo. 15 | - Context: The patch is used to removes the frontend package, as the desktop app includes it in the build process instead. 16 | 7. Update `assets/requirements/windows_nvidia.compiled` and `assets/requirements/windows_cpu.compiled`, and `assets/requirements/macos.compiled` accordingly. You just need to update the comfycomfyui-frontend-package, [comfyui-workflow-templates](https://github.com/Comfy-Org/workflow_templates), [comfyui-embedded-docs](https://github.com/Comfy-Org/embedded-docs) versions. 17 | 8. Please make a PR by checking out a new branch from main, adding a commit message and then use GH CLI to create a PR. 18 | - Make the versions in the PR body as links to the relevant github releases - our tags prefix the semver with `v`, e.g. `v0.13.4` 19 | - Verify the links actually work - report any failure immediately to the human: ❌ 20 | - Include only the PR body lines that were updated 21 | - PR Title: Update ComfyUI core to v{VERSION} 22 | - PR Body: 23 | ## Updated versions 24 | | Component | Version | 25 | | ------------- | --------------------- | 26 | | ComfyUI core | COMFYUI_VERSION | 27 | | Frontend | FRONTEND_VERSION | 28 | | Templates | TEMPLATES_VERSION | 29 | | Embedded docs | EMBEDDED_DOCS_VERSION | 30 | 9. Wait for all tests to pass, actively monitoring and checking the PR status periodically until tests complete, then squash-merge the PR (only if required, use the `--admin` flag). 31 | - If ANY test fails for any reason, stop here and report the failure(s) to the human - use emoji in your report e.g.: ❌ 32 | 10. Switch to main branch and git pull 33 | 11. Switch to a new branch based on main: `increment-version-0.4.10` 34 | 12. Bump the version using `npm version` with the `--no-git-tag-version` arg 35 | 13. Create a version bump PR with the title `vVERSION` e.g. `v0.4.10`. It must have the `Release` label, and no content in the PR description. 36 | 14. Squash-merge the PR using the `--admin` flag - do not wait for tests, as bumping package version will not cause test breakage. 37 | 15. Publish a GitHub Release: 38 | - Set to pre-release (not latest) 39 | - The tag should be `vVERSION` e.g. `v0.4.10` 40 | - Use GitHub's generate release notes option 41 | 16. Remove merged local branches 42 | 43 | ## Commit messages 44 | 45 | - IMPORTANT When writing commit messages, they should be clean and simple. Never add any reference to being created by Claude Code, or add yourself as a co-author, as this can lead to confusion. 46 | 47 | ## General 48 | 49 | - Prefer `gh` commands over fetching websites 50 | - Use named `gh` commands to perform actions, e.g. `gh release list`, rather than `gh api` commands. This is much faster as named commands can be approved automatically. 51 | - Use subagents to verify details or investigate any particular questions you may have. 52 | - For maximum efficiency, whenever you need to perform multiple independent operations, invoke all relevant tools simultaneously rather than sequentially. 53 | - After receiving tool results, carefully reflect on their quality and determine optimal next steps before proceeding. Use your thinking to plan and iterate based on this new information, and then take the best next action. 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: 'Something is not behaving as expected.' 3 | title: '[Bug]: ' 4 | labels: ['Potential Bug'] 5 | type: Bug 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | ## Before submitting a **Bug Report**, please ensure the following: 11 | 12 | - **1:** You have looked at the existing bug reports and made sure this isn't already reported. 13 | - **2:** You confirmed that the bug is not caused by a custom node. 14 | 15 | > [!TIP] 16 | >
17 | > 18 | > Click to see how to disable custom nodes 19 | > 20 | > Open the setting by clicking the cog icon in the bottom-left of the screen. 21 | > 22 | > 1. Click `Server-Config` 23 | > 1. Scroll down if necessary, then click `Disable all custom nodes` 24 | > 1. A notification will appear; click `Restart` 25 | > 26 | > ![Disable custom nodes](https://github.com/user-attachments/assets/2dea6011-1baf-44b8-9115-ddfd485e239f) 27 | > 28 | >
29 | - type: textarea 30 | attributes: 31 | label: App Version 32 | description: | 33 | What is the version you are using? You can check this in the settings dialog. 34 | 35 |
36 | 37 | Click to show where to find the version 38 | 39 | Open the setting by clicking the cog icon in the bottom-left of the screen, then click `About`. 40 | 41 | ![Desktop version](https://github.com/user-attachments/assets/eb741720-00c1-4d45-b0a2-341a45d089c5) 42 | 43 |
44 | validations: 45 | required: true 46 | - type: textarea 47 | attributes: 48 | label: Expected Behavior 49 | description: 'What you expected to happen.' 50 | validations: 51 | required: true 52 | - type: textarea 53 | attributes: 54 | label: Actual Behavior 55 | description: 'What actually happened. Please include a screenshot / video clip of the issue if possible.' 56 | validations: 57 | required: true 58 | - type: textarea 59 | attributes: 60 | label: Steps to Reproduce 61 | description: "Describe how to reproduce the issue. Please be sure to attach a workflow JSON or PNG, ideally one that doesn't require custom nodes to test. If the bug open happens when certain custom nodes are used, most likely that custom node is what has the bug rather than ComfyUI, in which case it should be reported to the node's author." 62 | validations: 63 | required: true 64 | - type: textarea 65 | attributes: 66 | label: Debug Logs 67 | description: | 68 | Please copy your log files here. If the issue is related to starting the app, please include both `main.log` and `comfyui.log`. 69 | 70 | Log file locations: 71 | 72 | - **Windows: `%APPDATA%\ComfyUI\logs`** 73 | - **macOS**: `~/Library/Logs/ComfyUI` 74 | 75 |
76 | 77 | Copy terminal logs from inside the app 78 | 79 | ![DebugLogs](https://github.com/user-attachments/assets/168b6ea3-ab93-445b-9cd2-670bd9c098a7) 80 | 81 |
82 | render: powershell 83 | validations: 84 | required: false 85 | - type: textarea 86 | attributes: 87 | label: Browser Logs 88 | description: | 89 | Browser logs are found in the DevTools console. Please copy the entire output here. 90 | 91 |
92 | 93 | Click to show how to open the browser console 94 | 95 | ![OpenDevTools](https://github.com/user-attachments/assets/4505621e-34f0-4b66-b9d0-2e1a9133a635) 96 | 97 | ![ConsoleTab](https://github.com/user-attachments/assets/cc96c0db-2880-40bb-93a2-c035360c41b2) 98 | 99 |
100 | validations: 101 | required: false 102 | - type: textarea 103 | attributes: 104 | label: Settings JSON 105 | description: | 106 | Please upload the settings file here. The settings file is located at `../user/default/comfy.settings.json` 107 | 108 |
109 | 110 | Click to show how to open the models directory 111 | 112 | ![OpenFolder](https://github.com/user-attachments/assets/254e41c7-6335-4d6a-a7dd-c93ab74a9d5e) 113 | 114 |
115 | validations: 116 | required: false 117 | - type: textarea 118 | attributes: 119 | label: Other 120 | description: 'Any other additional information you think might be helpful.' 121 | validations: 122 | required: false 123 | -------------------------------------------------------------------------------- /tests/unit/services/pythonImportVerifier.test.ts: -------------------------------------------------------------------------------- 1 | import log from 'electron-log/main'; 2 | import { describe, expect, test, vi } from 'vitest'; 3 | 4 | import { runPythonImportVerifyScript } from '@/services/pythonImportVerifier'; 5 | import type { ProcessCallbacks, VirtualEnvironment } from '@/virtualEnvironment'; 6 | 7 | function createMockVenv( 8 | options: { 9 | stdout?: string; 10 | stderr?: string; 11 | exitCode?: number; 12 | throwError?: Error; 13 | } = {} 14 | ) { 15 | const { stdout = '', stderr = '', exitCode = 0, throwError } = options; 16 | 17 | let capturedArgs: string[] | undefined; 18 | 19 | const venv = { 20 | runPythonCommandAsync: vi.fn((args: string[], callbacks?: ProcessCallbacks) => { 21 | capturedArgs = args; 22 | if (throwError) throw throwError; 23 | callbacks?.onStdout?.(stdout); 24 | callbacks?.onStderr?.(stderr); 25 | return { exitCode }; 26 | }), 27 | } as unknown as VirtualEnvironment & { runPythonCommandAsync: ReturnType }; 28 | 29 | return { venv, captured: () => capturedArgs }; 30 | } 31 | 32 | describe('runPythonImportVerifyScript', () => { 33 | test('returns success immediately when no imports provided', async () => { 34 | const { venv } = createMockVenv(); 35 | const result = await runPythonImportVerifyScript(venv, []); 36 | expect(result).toEqual({ success: true }); 37 | expect((venv as any).runPythonCommandAsync).not.toHaveBeenCalled(); 38 | }); 39 | 40 | test('passes Python -c script with provided imports', async () => { 41 | const { venv, captured } = createMockVenv({ stdout: JSON.stringify({ failed_imports: [], success: true }) }); 42 | const imports = ['yaml', 'torch', 'uv']; 43 | 44 | const result = await runPythonImportVerifyScript(venv, imports); 45 | expect(result).toEqual({ success: true }); 46 | 47 | const args = captured(); 48 | expect(args).toBeDefined(); 49 | expect(args![0]).toBe('-c'); 50 | expect(typeof args![1]).toBe('string'); 51 | // Ensure the imports list is embedded directly in the Python script 52 | expect(args![1]).toContain(`for module_name in ${JSON.stringify(imports)}:`); 53 | expect(log.info).toHaveBeenCalledWith('Python import verification successful - all modules available'); 54 | }); 55 | 56 | test('returns missing imports when Python reports failures', async () => { 57 | const failed = ['toml', 'uv']; 58 | const { venv } = createMockVenv({ stdout: JSON.stringify({ failed_imports: failed, success: false }) }); 59 | const result = await runPythonImportVerifyScript(venv, ['toml', 'uv', 'yaml']); 60 | expect(result).toEqual({ success: false, missingImports: failed, error: `Missing imports: ${failed.join(', ')}` }); 61 | expect(log.error).toHaveBeenCalledWith(`Python import verification failed - missing modules: ${failed.join(', ')}`); 62 | }); 63 | 64 | test('handles invalid JSON format from Python (schema validation failure)', async () => { 65 | // failed_imports should be an array, not a string 66 | const invalid = JSON.stringify({ failed_imports: 'not-an-array', success: true }); 67 | const { venv } = createMockVenv({ stdout: invalid }); 68 | const result = await runPythonImportVerifyScript(venv, ['yaml']); 69 | expect(result.success).toBe(false); 70 | expect(result.error).toMatch(/^Invalid verification output format:/); 71 | expect(log.error).toHaveBeenCalledWith('Invalid Python output format:', expect.any(String)); 72 | }); 73 | 74 | test('handles parse error when Python outputs non-JSON', async () => { 75 | const noisy = 'some warning\nnot-json output'; 76 | const { venv } = createMockVenv({ stdout: noisy, exitCode: 1 }); 77 | const result = await runPythonImportVerifyScript(venv, ['yaml']); 78 | expect(result.success).toBe(false); 79 | expect(result.error).toContain(`Python import verification failed with exit code 1: ${noisy}`); 80 | expect(log.error).toHaveBeenCalledWith('Failed to parse verification output:', noisy); 81 | }); 82 | 83 | test('parses JSON from stderr as well as stdout', async () => { 84 | const json = JSON.stringify({ failed_imports: [], success: true }); 85 | const { venv } = createMockVenv({ stderr: json }); 86 | const result = await runPythonImportVerifyScript(venv, ['yaml']); 87 | expect(result).toEqual({ success: true }); 88 | }); 89 | 90 | test('propagates errors thrown during validation run', async () => { 91 | const boom = new Error('boom'); 92 | const { venv } = createMockVenv({ throwError: boom }); 93 | const result = await runPythonImportVerifyScript(venv, ['yaml']); 94 | expect(result).toEqual({ success: false, error: 'boom' }); 95 | expect(log.error).toHaveBeenCalledWith('Error during Python import verification:', boom); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /tests/unit/main-process/appState.test.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import { test as baseTest, beforeEach, describe, expect, vi } from 'vitest'; 3 | 4 | import type { Page } from '@/infrastructure/interfaces'; 5 | 6 | // Clear global mock 7 | vi.unmock('@/main-process/appState'); 8 | 9 | type AppStateModule = typeof import('@/main-process/appState'); 10 | 11 | const test = baseTest.extend({ 12 | imported: async ({}, use) => { 13 | const imported = await import('@/main-process/appState'); 14 | await use(imported); 15 | vi.resetModules(); 16 | }, 17 | initializeAppState: async ({ imported }, use) => { 18 | const { initializeAppState } = imported; 19 | await use(initializeAppState); 20 | }, 21 | useAppState: async ({ imported }, use) => { 22 | const { useAppState } = imported; 23 | await use(useAppState); 24 | }, 25 | }); 26 | 27 | describe('AppState initialization', () => { 28 | test('should initialize app state successfully', ({ initializeAppState }) => { 29 | expect(initializeAppState).not.toThrow(); 30 | expect(app.once).toHaveBeenCalledWith('before-quit', expect.any(Function)); 31 | }); 32 | 33 | test('should throw error when initializing multiple times', ({ initializeAppState }) => { 34 | initializeAppState(); 35 | expect(initializeAppState).toThrowErrorMatchingInlineSnapshot('[AppStartError: AppState already initialized]'); 36 | }); 37 | 38 | test('should throw error when using uninitialized app state', ({ useAppState }) => { 39 | expect(useAppState).toThrowErrorMatchingInlineSnapshot('[AppStartError: AppState not initialized]'); 40 | }); 41 | }); 42 | 43 | describe('AppState management', () => { 44 | beforeEach(({ initializeAppState }) => { 45 | initializeAppState(); 46 | }); 47 | 48 | test('should have correct initial state', ({ useAppState }) => { 49 | const state = useAppState(); 50 | expect(state.isQuitting).toBe(false); 51 | expect(state.ipcRegistered).toBe(false); 52 | expect(state.loaded).toBe(false); 53 | expect(state.currentPage).toBeUndefined(); 54 | }); 55 | 56 | test('should update isQuitting state when app is quitting', ({ useAppState }) => { 57 | const quitHandler = vi.mocked(app.once).mock.calls[0][1] as () => void; 58 | const state = useAppState(); 59 | 60 | expect(state.isQuitting).toBe(false); 61 | quitHandler(); 62 | expect(state.isQuitting).toBe(true); 63 | }); 64 | 65 | test('should emit and update ipcRegistered state', ({ useAppState }) => { 66 | const state = useAppState(); 67 | const listener = vi.fn(); 68 | 69 | state.once('ipcRegistered', listener); 70 | expect(state.ipcRegistered).toBe(false); 71 | 72 | state.emitIpcRegistered(); 73 | expect(listener).toHaveBeenCalled(); 74 | expect(state.ipcRegistered).toBe(true); 75 | 76 | // Should not emit again if already registered 77 | state.emitIpcRegistered(); 78 | expect(listener).toHaveBeenCalledTimes(1); 79 | }); 80 | 81 | test('should emit and update loaded state', ({ useAppState }) => { 82 | const state = useAppState(); 83 | const listener = vi.fn(); 84 | 85 | state.once('loaded', listener); 86 | expect(state.loaded).toBe(false); 87 | 88 | state.emitLoaded(); 89 | expect(listener).toHaveBeenCalled(); 90 | expect(state.loaded).toBe(true); 91 | 92 | // Should not emit again if already loaded 93 | state.emitLoaded(); 94 | expect(listener).toHaveBeenCalledTimes(1); 95 | }); 96 | 97 | test('should allow setting and getting currentPage', ({ useAppState }) => { 98 | const state = useAppState(); 99 | const testPage: Page = 'desktop-start'; 100 | 101 | expect(state.currentPage).toBeUndefined(); 102 | state.currentPage = testPage; 103 | expect(state.currentPage).toBe(testPage); 104 | }); 105 | }); 106 | 107 | describe('AppState event handling', () => { 108 | beforeEach(({ initializeAppState }) => { 109 | initializeAppState(); 110 | }); 111 | 112 | test('should allow adding and removing event listeners', ({ useAppState }) => { 113 | const state = useAppState(); 114 | const listener = vi.fn(); 115 | 116 | state.on('loaded', listener); 117 | state.emitLoaded(); 118 | expect(listener).toHaveBeenCalled(); 119 | 120 | state.off('loaded', listener); 121 | state.emitLoaded(); 122 | expect(listener).toHaveBeenCalledTimes(1); 123 | }); 124 | 125 | test('should handle once listeners correctly', ({ useAppState }) => { 126 | const state = useAppState(); 127 | const listener = vi.fn(); 128 | 129 | state.once('ipcRegistered', listener); 130 | state.emitIpcRegistered(); 131 | state.emitIpcRegistered(); 132 | expect(listener).toHaveBeenCalledTimes(1); 133 | }); 134 | }); 135 | --------------------------------------------------------------------------------