diff --git a/.github/actions/build/windows/todesktop/action.yml b/.github/actions/build/windows/todesktop/action.yml index 93deb95e..0349c78a 100644 --- a/.github/actions/build/windows/todesktop/action.yml +++ b/.github/actions/build/windows/todesktop/action.yml @@ -22,6 +22,8 @@ runs: shell: cmd - run: yarn set version --yarn-path self shell: cmd + - run: yarn run download:uv all + shell: powershell - name: Make app shell: powershell env: diff --git a/.gitignore b/.gitignore index 086aeb67..3c65ceda 100644 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,6 @@ test-results # Python venv venv/ + +# UV +assets/uv diff --git a/README.md b/README.md index 96d6d84e..c6ca31aa 100644 --- a/README.md +++ b/README.md @@ -85,12 +85,6 @@ This will install a usable `yarn` binary. Then, in the root directory of this re yarn install ``` -Start the development server: - -```bash -yarn start -``` - ## Setup Python Make sure you have python 3.12+ installed. It is recommended to setup a virtual environment to run the python code. @@ -117,7 +111,7 @@ With the python environment activated, install comfy-cli: pip install comfy-cli ``` -## Building/running +## Building/Running First, initialize the application resources by running `make:assets:`: @@ -126,7 +120,11 @@ First, initialize the application resources by running `make:assets:`: yarn make:assets:[amd|cpu|nvidia|macos] ``` -This command will install ComfyUI under `assets`, as well ComfyUI-Manager, and the frontend [extension](https://github.com/Comfy-Org/DesktopSettingsExtension) responsible for electron settings menu. +This command will install ComfyUI under `assets`, as well ComfyUI-Manager, and the frontend [extension](https://github.com/Comfy-Org/DesktopSettingsExtension) responsible for electron settings menu. The exact versions of each package is defined in `package.json`. + +Second, you need to install `uv`. This will be bundled with the distributable, but we also need it locally. + +`yarn download:uv` You can then run `start` to build/launch the code and a live buildserver that will automatically rebuild the code on any changes: diff --git a/builder-debug.config.ts b/builder-debug.config.ts index c3295ab2..94ab81e9 100644 --- a/builder-debug.config.ts +++ b/builder-debug.config.ts @@ -4,7 +4,8 @@ const debugConfig: Configuration = { files: ['node_modules', 'package.json', '.vite/**'], extraResources: [ { from: './assets/ComfyUI', to: 'ComfyUI' }, - { from: './assets/python.tgz', to: 'python.tgz' }, + { from: './assets/uv/uv', to: 'uv/uv' }, + { from: './assets/uv/uvx', to: 'uv/uvx' }, { from: './assets/UI', to: 'UI' }, ], beforeBuild: './scripts/preMake.js', diff --git a/package.json b/package.json index 5cfdd343..8fdf569e 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "productName": "ComfyUI", "repository": "github:comfy-org/electron", "copyright": "Copyright © 2024 Comfy Org", - "version": "0.2.17", + "version": "0.3.0", "homepage": "https://comfy.org", "description": "The best modular GUI to run AI diffusion models.", "main": ".vite/build/main.js", @@ -11,15 +11,18 @@ "config": { "frontendVersion": "1.3.43", "comfyVersion": "0.2.7", - "managerCommit": "e629215c100c89a9a9d33fc03be3248069ff67ef" + "managerCommit": "e629215c100c89a9a9d33fc03be3248069ff67ef", + "uvVersion": "0.5.1" }, "scripts": { "clean": "rimraf .vite dist out", + "clean:uv": "rimraf assets/uv", "clean:assets": "rimraf assets/.env assets/ComfyUI assets/python.tgz & yarn run clean:assets:dev", "clean:assets:dev": "yarn run clean:assets:git && rimraf assets/python assets/override.txt & rimraf assets/cpython*.tar.gz & rimraf assets/requirements.*", "clean:assets:git": "rimraf assets/ComfyUI/.git assets/ComfyUI/custom_nodes/ComfyUI_Manager/.git assets/ComfyUI/custom_nodes/DesktopSettingsExtension/.git", "clean:slate": "yarn run clean & yarn run clean:assets & rimraf node_modules", "clone-settings-extension": "git clone https://github.com/Comfy-Org/DesktopSettingsExtension.git assets/ComfyUI/custom_nodes/DesktopSettingsExtension", + "download:uv": "node scripts/downloadUV.js", "download-frontend": "node scripts/downloadFrontend.js", "make:frontend": "yarn run download-frontend && yarn run clone-settings-extension", "format": "prettier --check .", diff --git a/scripts/checkAssetsMacos.sh b/scripts/checkAssetsMacos.sh deleted file mode 100755 index 3f4b37b2..00000000 --- a/scripts/checkAssetsMacos.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash - -for pyexec in "$1/bin/python" "$1/lib/libpython*.dylib"; do - # the grep captures the last word in the output of the file cmd - if [ $(file $pyexec | grep -oE '[^ ]+$') = "x86_64" ]; then - # the leading redirect causes echo to output to stderr - >&2 echo "ERROR: bundled python executable $pyexec is built for incorrect machine (x86_64)" - exit 1 - fi -done diff --git a/scripts/downloadUV.js b/scripts/downloadUV.js new file mode 100644 index 00000000..42db97e3 --- /dev/null +++ b/scripts/downloadUV.js @@ -0,0 +1,83 @@ +const path = require("path"); +const os = require('os'); +const fs = require('fs-extra'); +const axios = require('axios'); +const tar = require('tar'); +const extractZip = require('extract-zip'); +const uvVer = require('../package.json').config.uvVersion; + +const options = { + win32: { + zipFile: 'uv-x86_64-pc-windows-msvc.zip', + uvOutputFolderName: 'win', + zip: true, + }, + darwin: { + zipFile: 'uv-aarch64-apple-darwin.tar.gz', + uvOutputFolderName: 'macos', + zip: false, + }, + linux: { + zipFile: 'uv-x86_64-unknown-linux-gnu.tar.gz', + uvOutputFolderName: 'linux', + zip: false, + } +} + +async function downloadUV() { + + const allFlag = process.argv[2]; + const baseDownloadURL = `https://github.com/astral-sh/uv/releases/download/${uvVer}/`; + if (allFlag) + { + if (allFlag === 'all') { + await downloadAndExtract(baseDownloadURL, options.win32); + await downloadAndExtract(baseDownloadURL, options.darwin); + await downloadAndExtract(baseDownloadURL, options.linux); + return; + } + if (allFlag === 'none') { + return; + } + } + + const uvDownloaded = fs.existsSync(path.join('./assets', 'uv')); + if (!uvDownloaded) { + await downloadAndExtract(baseDownloadURL, options[os.platform()]); + return; + } + console.log('< UV Folder Exists, Skipping >'); + +}; + +async function downloadAndExtract(baseURL, options) { + const { + zipFile, + uvOutputFolderName, + zip + } = options; + const zipFilePath = path.join('./assets', zipFile); + const outputUVFolder = path.join('./assets', 'uv', uvOutputFolderName); + await fs.mkdir(outputUVFolder, { + recursive: true + }); + const downloadedFile = await axios({ + method: 'GET', + url: baseURL + zipFile, + responseType: 'arraybuffer' + }); + fs.writeFileSync(zipFilePath, downloadedFile.data); + zip ? await extractZip(zipFilePath, { + dir: path.resolve(outputUVFolder) + }) : tar.extract({ + sync: true, + file: zipFilePath, + C: outputUVFolder, + "strip-components": 1 + }); + await fs.unlink(zipFilePath); + console.log(`FINISHED DOWNLOAD AND EXTRACT UV ${uvOutputFolderName}`); +} + +//** Download and Extract UV. Default uses OS.Platfrom. Add 'all' will download all. Add 'none' will skip */ +downloadUV(); diff --git a/scripts/makeComfy.js b/scripts/makeComfy.js index 155113b4..e8b63b98 100644 --- a/scripts/makeComfy.js +++ b/scripts/makeComfy.js @@ -17,11 +17,8 @@ function makeAssets(gpuFlag) { 'yarn run make:frontend' ].join(' '); - if (gpuFlag === '--m-series') { - execSync(`${baseCommand} && ../scripts/checkAssetsMacos.sh python`, { stdio: 'inherit' }); - } else { - execSync(baseCommand, { stdio: 'inherit' }); - } + + execSync(baseCommand, { stdio: 'inherit' }); // Rename custom_nodes/ComfyUI-Manager to manager-core if (!fs.existsSync('assets/ComfyUI/custom_nodes/ComfyUI-Manager')) { diff --git a/scripts/todesktop/afterPack.js b/scripts/todesktop/afterPack.js index bf265ba1..15533375 100644 --- a/scripts/todesktop/afterPack.js +++ b/scripts/todesktop/afterPack.js @@ -1,6 +1,7 @@ const os = require('os'); const fs = require('fs/promises'); const path = require('path'); +const { spawnSync } = require('child_process'); module.exports = async ({ appOutDir, packager, outDir }) => { /** @@ -13,7 +14,7 @@ module.exports = async ({ appOutDir, packager, outDir }) => { * arch - number - the architecture of the app. ia32 = 0, x64 = 1, armv7l = 2, arm64 = 3, universal = 4. */ - // The purpose of this script is to move the built python and comfy files from assets to the resource folder of the app + // The purpose of this script is to move comfy files from assets to the resource folder of the app // We can not add them to extraFiles as that is done prior to building, where we need to move them AFTER if (os.platform() === "darwin") { @@ -22,8 +23,18 @@ module.exports = async ({ appOutDir, packager, outDir }) => { const mainPath = path.dirname(outDir); const assetPath = path.join(mainPath, 'app-wrapper', 'app', 'assets'); const resourcePath = path.join(appPath, "Contents", "Resources"); - const result = await fs.rm(path.join(assetPath, "ComfyUI", ".git"), { recursive: true, force: true }); - const result2 = await fs.cp(assetPath, resourcePath, { recursive: true }); + // 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 + await fs.rm(path.join(assetPath, "ComfyUI", ".git"), { recursive: true, force: true }); + await fs.rm(path.join(assetPath, "ComfyUI", 'custom_nodes', 'manager-core', ".git"), { recursive: true, force: true }); + await fs.rm(path.join(assetPath, "ComfyUI", 'custom_nodes', 'DesktopSettingsExtension', ".git"), { recursive: true, force: true }); + // Move rest of items to the resource folder + await fs.cp(assetPath, resourcePath, { recursive: true }); + // Remove other OS's UV + await fs.rm(path.join(resourcePath, 'uv', 'win'), { recursive: true, force: true }); + await fs.rm(path.join(resourcePath, 'uv', 'linux'), { recursive: true, force: true }); + await fs.chmod(path.join(resourcePath, 'uv', 'macos', 'uv'), '755'); + await fs.chmod(path.join(resourcePath, 'uv', 'macos', 'uvx'), '755'); + } if (os.platform() === 'win32') { @@ -32,6 +43,12 @@ module.exports = async ({ appOutDir, packager, outDir }) => { const mainPath = path.dirname(outDir); const assetPath = path.join(mainPath, 'app-wrapper', 'app', 'assets'); const resourcePath = path.join(path.dirname(appPath), "resources"); + // Move rest of items to the resource folder await fs.cp(assetPath, resourcePath, { recursive: true }); + // Remove other OS's UV + await fs.rm(path.join(resourcePath, 'uv', 'macos'), { recursive: true, force: true }); + await fs.rm(path.join(resourcePath, 'uv', 'linux'), { recursive: true, force: true }); } -} \ No newline at end of file + + //TODO: Linux +} diff --git a/scripts/todesktop/postInstall.js b/scripts/todesktop/postInstall.js index 38bab8ad..f4a3eca5 100644 --- a/scripts/todesktop/postInstall.js +++ b/scripts/todesktop/postInstall.js @@ -2,7 +2,7 @@ const { spawnSync } = require("child_process"); const path = require("path"); const os = require('os'); const process = require("process"); -//const fs = require('fs-extra'); +const fs = require('fs-extra'); async function postInstall() { const firstInstallOnToDesktopServers = @@ -10,35 +10,49 @@ async function postInstall() { if (!firstInstallOnToDesktopServers) return; - console.log('After Yarn Install' , os.platform()); + console.log('After Yarn Install ' , os.platform()); if (os.platform() === "win32") { // Change stdio to get back the logs if there are issues. const resultUpgradePip = spawnSync(`py`, ['-3.12', '-m', 'pip' ,'install' ,'--upgrade pip'],{shell:true,stdio: 'ignore'}).toString(); const resultInstallComfyCLI = spawnSync(`py`, ['-3.12 ','-m' ,'pip' ,'install comfy-cli'], {shell:true,stdio: 'ignore'}).toString(); - console.log("Finish PIP & ComfyCLI Install"); const resultComfyManagerInstall = spawnSync('set PATH=C:\\hostedtoolcache\\windows\\Python\\3.12.7\\x64\\Scripts;%PATH% && yarn run make:assets:nvidia' ,[''],{shell:true,stdio: 'inherit'}).toString(); - console.log("Finish Comfy Manager Install and Rehydration"); } - if (os.platform() === "darwin") { - - const resultUpgradePip = spawnSync(`py`, ['-3.12', '-m', 'pip' ,'install' ,'--upgrade pip'],{shell:true,stdio: 'ignore'}).toString(); - const resultInstallComfyCLI = spawnSync(`py`, ['-3.12 ','-m' ,'pip' ,'install comfy-cli'], {shell:true,stdio: 'ignore'}).toString(); - const resultComfyManagerInstall = spawnSync('yarn run make:assets:macos' ,[''],{shell:true,stdio: 'inherit'}).toString(); - - // Do not delete, useful if there are build issues with mac - // TODO: Consider making a global build log as ToDesktop logs can be hit or miss - /* - fs.createFileSync('./src/macpip.txt'); - fs.writeFileSync('./src/macpip.txt',JSON.stringify({ - log: result.stdout.toString(), - err:result.stderr.toString() - })); - */ - console.log("Finish Python & Comfy Install for Mac"); + if (os.platform() == 'darwin') + { + // Python install pip and install comfy-cli + const resultUpgradePip = spawnSync(`python3.12`, ['-m', 'pip', 'install', '--upgrade pip'], { + shell: true, + stdio: 'ignore', + encoding: 'utf-8', + }); + const resultInstallComfyCLI = spawnSync(`python3.12`, ['-m', 'pip', 'install comfy-cli'], { + shell: true, + stdio: 'inherit', + encoding: 'utf-8', + }); + // Finally add this python to path and then run the Assets Make for MacOS + const resultComfyManagerInstall = spawnSync('export PATH="/Library/Frameworks/Python.framework/Versions/3.12/bin:$PATH" && yarn run make:assets:macos', [''], { + shell: true, + stdio: 'inherit', + encoding: 'utf-8', + }); + } + + //TODO: Linux + + // Remove python stuff + await fs.rm(path.join('./assets', 'python'), { recursive: true, force: true }); + await fs.rm(path.join('./assets', 'python.tgz'), { force: true }); + fs.readdirSync(path.join('./assets')).forEach((tgzFile) => { + if (tgzFile.endsWith('.gz')) { + fs.rmSync(path.join('./assets', tgzFile)); + } + }); + }; -postInstall(); \ No newline at end of file +postInstall(); diff --git a/src/main.ts b/src/main.ts index e7518833..bf96e2d5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { spawn, ChildProcess } from 'node:child_process'; +import { ChildProcess } from 'node:child_process'; import fs from 'fs'; import axios from 'axios'; import path from 'node:path'; @@ -11,7 +11,6 @@ import * as net from 'net'; import { graphics } from 'systeminformation'; import { createModelConfigFiles, getModelConfigPath } from './config/extra_model_config'; import todesktop from '@todesktop/runtime'; -import { PythonEnvironment } from './pythonEnvironment'; import { DownloadManager } from './models/DownloadManager'; import { getModelsDirectory } from './utils'; import { ComfySettings } from './config/comfySettings'; @@ -23,6 +22,7 @@ import { getAppResourcesPath, getBasePath, getPythonInstallPath } from './instal import { PathHandlers } from './handlers/pathHandlers'; import { AppInfoHandlers } from './handlers/appInfoHandlers'; import { InstallOptions } from './preload'; +import { VirtualEnvironment } from './virtualEnvironment'; dotenv.config(); @@ -305,7 +305,7 @@ let currentWaitTime = 0; let spawnServerTimeout: NodeJS.Timeout | null = null; const launchPythonServer = async ( - pythonInterpreterPath: string, + virtualEnvironment: VirtualEnvironment, appResourcesPath: string, modelConfigPath: string, basePath: string @@ -316,11 +316,7 @@ const launchPythonServer = async ( // Server has been started outside the app, so attach to it. return loadComfyIntoMainWindow(); } - - log.info( - `Launching Python server with port ${port}. python path: ${pythonInterpreterPath}, app resources path: ${appResourcesPath}, model config path: ${modelConfigPath}, base path: ${basePath}` - ); - + rotateLogFiles(app.getPath('logs'), 'comfyui'); return new Promise(async (resolve, reject) => { const scriptPath = path.join(appResourcesPath, 'ComfyUI', 'main.py'); const userDirectoryPath = path.join(basePath, 'user'); @@ -344,10 +340,32 @@ const launchPythonServer = async ( ]; log.info(`Starting ComfyUI using port ${port}.`); + const comfyUILog = log.create({ logId: 'comfyui' }); + comfyUILog.transports.file.fileName = 'comfyui.log'; + comfyServerProcess = virtualEnvironment.runPythonCommand(comfyMainCmd, { + onStdout: (data) => { + comfyUILog.info(data); + appWindow.send(IPC_CHANNELS.LOG_MESSAGE, data); + }, + onStderr: (data) => { + comfyUILog.error(data); + appWindow.send(IPC_CHANNELS.LOG_MESSAGE, data); + }, + }); + + comfyServerProcess.on('error', (err) => { + log.error(`Failed to start ComfyUI: ${err}`); + reject(err); + }); - comfyServerProcess = spawnPython(pythonInterpreterPath, comfyMainCmd, path.dirname(scriptPath), { - logFile: 'comfyui', - stdx: true, + comfyServerProcess.on('exit', (code, signal) => { + if (code !== 0) { + log.error(`Python process exited with code ${code} and signal ${signal}`); + reject(new Error(`Python process exited with code ${code} and signal ${signal}`)); + } else { + log.info(`Python process exited successfully with code ${code}`); + resolve(); + } }); const checkInterval = 1000; // Check every 1 second @@ -419,97 +437,6 @@ const killPythonServer = async (): Promise => { }); }; -const spawnPython = ( - pythonInterpreterPath: string, - cmd: string[], - cwd: string, - options = { stdx: true, logFile: '' } -) => { - log.info(`Spawning python process ${pythonInterpreterPath} with command: ${cmd.join(' ')} in directory: ${cwd}`); - const pythonProcess: ChildProcess = spawn(pythonInterpreterPath, cmd, { - cwd, - }); - - if (options.stdx) { - log.info('Setting up python process stdout/stderr listeners'); - - let pythonLog = log; - if (options.logFile) { - log.info('Creating separate python log file: ', options.logFile); - // Rotate log files so each log file is unique to a single python run. - rotateLogFiles(app.getPath('logs'), options.logFile); - pythonLog = log.create({ logId: options.logFile }); - pythonLog.transports.file.fileName = `${options.logFile}.log`; - pythonLog.transports.file.resolvePathFn = (variables) => { - return path.join(variables.electronDefaultDir ?? '', variables.fileName ?? ''); - }; - } - - pythonProcess.stderr?.on?.('data', (data) => { - const message = data.toString().trim(); - pythonLog.error(`stderr: ${message}`); - if (appWindow) { - appWindow.send(IPC_CHANNELS.LOG_MESSAGE, message); - } - }); - pythonProcess.stdout?.on?.('data', (data) => { - const message = data.toString().trim(); - pythonLog.info(`stdout: ${message}`); - if (appWindow) { - appWindow.send(IPC_CHANNELS.LOG_MESSAGE, message); - } - }); - } - - return pythonProcess; -}; - -const spawnPythonAsync = ( - pythonInterpreterPath: string, - cmd: string[], - cwd: string, - options = { stdx: true } -): Promise<{ exitCode: number | null }> => { - return new Promise((resolve, reject) => { - log.info(`Spawning python process with command: ${pythonInterpreterPath} ${cmd.join(' ')} in directory: ${cwd}`); - const pythonProcess: ChildProcess = spawn(pythonInterpreterPath, cmd, { cwd }); - - const cleanup = () => { - pythonProcess.removeAllListeners(); - }; - - if (options.stdx) { - log.info('Setting up python process stdout/stderr listeners'); - pythonProcess.stderr?.on?.('data', (data) => { - const message = data.toString(); - log.error(message); - if (appWindow) { - appWindow.send(IPC_CHANNELS.LOG_MESSAGE, message); - } - }); - pythonProcess.stdout?.on?.('data', (data) => { - const message = data.toString(); - log.info(message); - if (appWindow) { - appWindow.send(IPC_CHANNELS.LOG_MESSAGE, message); - } - }); - } - - pythonProcess.on('close', (code) => { - cleanup(); - log.info(`Python process exited with code ${code}`); - resolve({ exitCode: code }); - }); - - pythonProcess.on('error', (err) => { - cleanup(); - log.error(`Failed to start Python process: ${err}`); - reject(err); - }); - }); -}; - function findAvailablePort(startPort: number, endPort: number): Promise { return new Promise((resolve, reject) => { function tryPort(port: number) { @@ -550,6 +477,7 @@ async function handleInstall(installOptions: InstallOptions) { } async function serverStart() { + log.info('Server start'); const basePath = await getBasePath(); const pythonInstallPath = await getPythonInstallPath(); if (!basePath || !pythonInstallPath) { @@ -570,11 +498,21 @@ async function serverStart() { if (!useExternalServer) { sendProgressUpdate(ProgressStatus.PYTHON_SETUP); const appResourcesPath = await getAppResourcesPath(); - const pythonEnvironment = new PythonEnvironment(pythonInstallPath, appResourcesPath, spawnPythonAsync); - await pythonEnvironment.setup(); + appWindow.send(IPC_CHANNELS.LOG_MESSAGE, `Creating Python environment...`); + const virtualEnvironment = new VirtualEnvironment(basePath); + await virtualEnvironment.create({ + onStdout: (data) => { + log.info(data); + appWindow.send(IPC_CHANNELS.LOG_MESSAGE, data); + }, + onStderr: (data) => { + log.error(data); + appWindow.send(IPC_CHANNELS.LOG_MESSAGE, data); + }, + }); const modelConfigPath = getModelConfigPath(); sendProgressUpdate(ProgressStatus.STARTING_SERVER); - await launchPythonServer(pythonEnvironment.pythonInterpreterPath, appResourcesPath, modelConfigPath, basePath); + await launchPythonServer(virtualEnvironment, appResourcesPath, modelConfigPath, basePath); } else { sendProgressUpdate(ProgressStatus.READY); loadComfyIntoMainWindow(); diff --git a/src/pythonEnvironment.ts b/src/pythonEnvironment.ts deleted file mode 100644 index 4c7483ac..00000000 --- a/src/pythonEnvironment.ts +++ /dev/null @@ -1,166 +0,0 @@ -import log from 'electron-log/main'; -import * as fsPromises from 'node:fs/promises'; -import * as path from 'node:path'; -import { pathAccessible } from './utils'; -import tar from 'tar'; - -export class PythonEnvironment { - readonly pythonRootPath: string; - readonly pythonInterpreterPath: string; - /** - * The path to determine if Python is installed. - * After we install Python, we write a file to this path to indicate that it is installed by us. - */ - readonly pythonRecordPath: string; - /** - * The path to the python tar file in the app resources. - */ - readonly pythonTarPath: string; - /** - * The path to the wheels directory. - */ - readonly wheelsPath: string; - /** - * The path to the requirements.compiled file. - */ - readonly requirementsCompiledPath: string; - - /** - * Mac needs extra files to be code signed that on other platforms are included into the python.tgz - */ - readonly macExtraFiles: Array; - - constructor( - public pythonInstallPath: string, - public appResourcesPath: string, - // TODO(huchenlei): move spawnPythonAsync to this class - public spawnPythonAsync: ( - pythonInterpreterPath: string, - cmd: string[], - cwd: string, - options: { stdx: boolean } - ) => Promise<{ exitCode: number | null }> - ) { - this.pythonRootPath = path.join(pythonInstallPath, 'python'); - this.pythonInterpreterPath = - process.platform === 'win32' - ? path.join(this.pythonRootPath, 'python.exe') - : path.join(this.pythonRootPath, 'bin', 'python'); - this.pythonRecordPath = path.join(this.pythonRootPath, 'INSTALLER'); - this.pythonTarPath = path.join(appResourcesPath, 'python.tgz'); - this.wheelsPath = path.join(this.pythonRootPath, 'wheels'); - this.requirementsCompiledPath = path.join(this.pythonRootPath, 'requirements.compiled'); - this.macExtraFiles = [ - 'lib/libpython3.12.dylib', - 'lib/python3.12/lib-dynload/_crypt.cpython-312-darwin.so', - 'bin/uv', - 'bin/uvx', - 'bin/python3.12', - ]; - } - - async isInstalled(): Promise { - log.info(`Checking if Python is installed at ${this.pythonInterpreterPath} and ${this.pythonRecordPath}`); - return (await pathAccessible(this.pythonInterpreterPath)) && (await pathAccessible(this.pythonRecordPath)); - } - - async packWheels(): Promise { - return await pathAccessible(this.wheelsPath); - } - - async installRequirements(): Promise { - // install python pkgs from wheels if packed in bundle, otherwise just use requirements.compiled - const rehydrateCmd = (await this.packWheels()) - ? // TODO: report space bug to uv upstream, then revert below mac fix - [ - '-m', - ...(process.platform !== 'darwin' ? ['uv'] : []), - 'pip', - 'install', - '--no-index', - '--no-deps', - ...(await fsPromises.readdir(this.wheelsPath)).map((x) => path.join(this.wheelsPath, x)), - ] - : ['-m', 'uv', 'pip', 'install', '-r', this.requirementsCompiledPath, '--index-strategy', 'unsafe-best-match']; - - const { exitCode } = await this.spawnPythonAsync(this.pythonInterpreterPath, rehydrateCmd, this.pythonRootPath, { - stdx: true, - }); - return exitCode ?? -1; - } - - async install(): Promise { - try { - // clean up any possible existing non-functional python env - await fsPromises.rm(this.pythonRootPath, { recursive: true }); - } catch { - null; - } - - log.info(`Extracting python bundle from ${this.pythonTarPath} to ${this.pythonInstallPath}`); - await tar.extract({ - file: this.pythonTarPath, - cwd: this.pythonInstallPath, - strict: true, - }); - - if (process.platform === 'darwin') { - // Mac need extra files to be codesigned, these now need to be unpacked and placed inside of the python folder. - this.macExtraFiles.forEach(async (fileName) => { - await fsPromises.cp( - path.join(this.appResourcesPath, 'output', fileName), - path.join(this.pythonRootPath, fileName) - ); - await fsPromises.chmod(path.join(this.pythonRootPath, fileName), '755'); - }); - try { - // TODO: If python tar is done more than once we could lose these so for now do not clean up - // This is a cleanup step, and is non critical if failed. - //await fsPromises.rm(path.join(this.appResourcesPath, 'output'), { recursive: true, force: true }); - } catch (error) { - null; - } - // Mac seems to need a CPU cycle before allowing executing the python bin. - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - await sleep(1000); - } else { - try { - // For non mac we can just delete these - // This is a cleanup step, and is non critical if failed. - await fsPromises.rm(path.join(this.appResourcesPath, 'output'), { recursive: true, force: true }); - } catch (error) { - null; - } - } - - const exitCode = await this.installRequirements(); - - if (exitCode === 0) { - // write an INSTALLER record on successful completion of rehydration - fsPromises.writeFile(this.pythonRecordPath, 'ComfyUI'); - - if (await this.packWheels()) { - // remove the now installed wheels - fsPromises.rm(this.wheelsPath, { recursive: true }); - log.info(`Removed ${this.wheelsPath}`); - } - - log.info(`Python successfully installed to ${this.pythonRootPath}`); - } else { - log.info(`Rehydration of python bundle exited with code ${exitCode}`); - throw new Error('Python rehydration failed'); - } - } - - async setup(): Promise { - if (await this.isInstalled()) { - log.info(`Python environment already installed at ${this.pythonInstallPath}`); - return; - } - - log.info( - `Running one-time python installation on first startup at ${this.pythonInstallPath} and ${this.pythonRootPath}` - ); - await this.install(); - } -} diff --git a/src/tray.ts b/src/tray.ts index c0e86d47..8f3fefbc 100644 --- a/src/tray.ts +++ b/src/tray.ts @@ -1,6 +1,7 @@ import path from 'path'; import { Tray, Menu, app } from 'electron'; import { AppWindow } from './main-process/appWindow'; +import { VirtualEnvironment } from './virtualEnvironment'; export function setupTray(mainView: AppWindow): Tray { // Set icon for the tray diff --git a/src/virtualEnvironment.ts b/src/virtualEnvironment.ts new file mode 100644 index 00000000..a36bf25e --- /dev/null +++ b/src/virtualEnvironment.ts @@ -0,0 +1,242 @@ +import * as path from 'node:path'; +import { spawn, ChildProcess } from 'node:child_process'; +import log from 'electron-log/main'; +import { pathAccessible } from './utils'; +import { app } from 'electron'; + +type ProcessCallbacks = { + onStdout?: (data: string) => void; + onStderr?: (data: string) => void; +}; + +/** + * Manages a virtual Python environment using uv. + */ +export class VirtualEnvironment { + readonly venvRootPath: string; + readonly venvPath: string; + readonly pythonVersion: string; + readonly uvPath: string; + readonly requirementsCompiledPath: string; + readonly cacheDir: string; + readonly pythonInterpreterPath: string; + readonly comfyUIRequirementsPath: string; + readonly comfyUIManagerRequirementsPath: string; + + constructor(venvPath: string, pythonVersion: string = '3.12.4') { + this.venvRootPath = venvPath; + this.venvPath = path.join(venvPath, '.venv'); // uv defaults to .venv + const resourcesPath = app.isPackaged ? path.join(process.resourcesPath) : path.join(app.getAppPath(), 'assets'); + this.comfyUIRequirementsPath = path.join(resourcesPath, 'ComfyUI', 'requirements.txt'); + this.comfyUIManagerRequirementsPath = path.join( + resourcesPath, + 'ComfyUI', + 'custom_nodes', + 'manager-core', + 'requirements.txt' + ); + + this.pythonVersion = pythonVersion; + this.cacheDir = path.join(venvPath, 'uv-cache'); + this.requirementsCompiledPath = path.join(resourcesPath, 'requirements.compiled'); + this.pythonInterpreterPath = + process.platform === 'win32' + ? path.join(this.venvPath, 'Scripts', 'python.exe') + : path.join(this.venvPath, 'bin', 'python'); + + const uvFolder = app.isPackaged + ? path.join(process.resourcesPath, 'uv') + : path.join(app.getAppPath(), 'assets', 'uv'); + + if (process.platform === 'win32') { + this.uvPath = path.join(uvFolder, 'win', 'uv.exe'); + } else if (process.platform === 'linux') { + this.uvPath = path.join(uvFolder, 'linux', 'uv'); + } else if (process.platform === 'darwin') { + this.uvPath = path.join(uvFolder, 'macos', 'uv'); + } else { + throw new Error(`Unsupported platform: ${process.platform}`); + } + log.info(`Using uv at ${this.uvPath}`); + } + + public async create(callbacks?: ProcessCallbacks): Promise { + try { + if (await this.exists()) { + log.info(`Virtual environment already exists at ${this.venvPath}`); + return; + } + + log.info(`Creating virtual environment at ${this.venvPath} with python ${this.pythonVersion}`); + + // Create virtual environment using uv + const args = ['venv', '--python', this.pythonVersion]; + const { exitCode } = await this.runUvCommandAsync(args, callbacks); + + if (exitCode !== 0) { + throw new Error(`Failed to create virtual environment: exit code ${exitCode}`); + } + + log.info(`Successfully created virtual environment at ${this.venvPath}`); + } catch (error) { + log.error(`Error creating virtual environment: ${error}`); + throw error; + } + + await this.installRequirements(callbacks); + } + + public async installRequirements(callbacks?: ProcessCallbacks): Promise { + const installCmd = ['pip', 'install', '-r', this.requirementsCompiledPath, '--index-strategy', 'unsafe-best-match']; + + const { exitCode } = await this.runUvCommandAsync(installCmd, callbacks); + if (exitCode !== 0) { + log.error( + `Failed to install requirements.compiled: exit code ${exitCode}. Falling back to installing requirements.txt` + ); + + await this.installComfyUIRequirements(callbacks); + await this.installComfyUIManagerRequirements(callbacks); + } + } + + /** + * Runs a python command using the virtual environment's python interpreter. + * @param args + * @returns + */ + public runPythonCommand(args: string[], callbacks?: ProcessCallbacks): ChildProcess { + const pythonInterpreterPath = + process.platform === 'win32' + ? path.join(this.venvPath, 'Scripts', 'python.exe') + : path.join(this.venvPath, 'bin', 'python'); + + return this.runCommand(pythonInterpreterPath, args, {}, callbacks); + } + + /** + * Runs a python command using the virtual environment's python interpreter and returns a promise with the exit code. + * @param args + * @returns + */ + public async runPythonCommandAsync( + args: string[], + callbacks?: ProcessCallbacks + ): Promise<{ exitCode: number | null }> { + return this.runCommandAsync(this.pythonInterpreterPath, args, {}, callbacks); + } + + /** + * Runs a uv command with the virtual environment set to this instance's venv and returns a promise with the exit code. + * @param args + * @returns + */ + public async runUvCommandAsync(args: string[], callbacks?: ProcessCallbacks): Promise<{ exitCode: number | null }> { + return new Promise((resolve, reject) => { + const childProcess = this.runUvCommand(args, callbacks); + childProcess.on('close', (code) => { + resolve({ exitCode: code }); + }); + + childProcess.on('error', (err) => { + reject(err); + }); + }); + } + + /** + * Runs a uv command with the virtual environment set to this instance's venv. + * @param args + * @returns + */ + public runUvCommand(args: string[], callbacks?: ProcessCallbacks): ChildProcess { + return this.runCommand( + this.uvPath, + args, + { + UV_CACHE_DIR: this.cacheDir, + UV_TOOL_DIR: this.cacheDir, + UV_TOOL_BIN_DIR: this.cacheDir, + UV_PYTHON_INSTALL_DIR: this.cacheDir, + VIRTUAL_ENV: this.venvPath, + }, + callbacks + ); + } + + private runCommand( + command: string, + args: string[], + env: Record, + callbacks?: ProcessCallbacks + ): ChildProcess { + log.info(`Running command: ${command} ${args.join(' ')} in ${this.venvRootPath}`); + const childProcess: ChildProcess = spawn(command, args, { + cwd: this.venvRootPath, + env: { + ...process.env, + ...env, + }, + }); + + if (callbacks) { + childProcess.stdout?.on('data', (data) => { + console.log(data.toString()); + callbacks.onStdout?.(data.toString()); + }); + + childProcess.stderr?.on('data', (data) => { + console.log(data.toString()); + callbacks.onStderr?.(data.toString()); + }); + } + + return childProcess; + } + + private async runCommandAsync( + command: string, + args: string[], + env: Record, + callbacks?: ProcessCallbacks + ): Promise<{ exitCode: number | null }> { + return new Promise((resolve, reject) => { + const childProcess = this.runCommand(command, args, env, callbacks); + + childProcess.on('close', (code) => { + resolve({ exitCode: code }); + }); + + childProcess.on('error', (err) => { + reject(err); + }); + }); + } + + private async installComfyUIRequirements(callbacks?: ProcessCallbacks): Promise { + log.info(`Installing ComfyUI requirements from ${this.comfyUIRequirementsPath}`); + const installCmd = ['pip', 'install', '-r', this.comfyUIRequirementsPath]; + + if (process.platform === 'win32') { + installCmd.push('--index-url', 'https://download.pytorch.org/whl/cu121'); + } + + const { exitCode } = await this.runUvCommandAsync(installCmd, callbacks); + if (exitCode !== 0) { + throw new Error(`Failed to install requirements.txt: exit code ${exitCode}`); + } + } + + private async installComfyUIManagerRequirements(callbacks?: ProcessCallbacks): Promise { + log.info(`Installing ComfyUIManager requirements from ${this.comfyUIManagerRequirementsPath}`); + const installCmd = ['pip', 'install', '-r', this.comfyUIManagerRequirementsPath]; + const { exitCode } = await this.runUvCommandAsync(installCmd, callbacks); + if (exitCode !== 0) { + throw new Error(`Failed to install requirements.txt: exit code ${exitCode}`); + } + } + + private async exists(): Promise { + return await pathAccessible(this.venvPath); + } +} diff --git a/todesktop.json b/todesktop.json index 0e175e81..3d608629 100644 --- a/todesktop.json +++ b/todesktop.json @@ -2,18 +2,23 @@ "id": "241012ess7yxs0e", "icon": "./assets/UI/Comfy_Logo_x128.png", "schemaVersion": 1, - "uploadSizeLimit": 250, + "uploadSizeLimit": 300, "appPath": ".", - "appFiles": ["src/**", "scripts/**", "assets/**", "dist/**", ".vite/**", ".yarnrc.yml", ".yarn/**"], + "appFiles": [ + "src/**", + "scripts/**", + "assets/ComfyUI/**", + "assets/UI/**", + "assets/requirements.compiled", + "assets/override.txt", + "assets/uv/**", + ".vite/**", + ".yarnrc.yml", + ".yarn/**" + ], "extraResources": [{ "from": "./assets" }], "filesForDistribution": ["!assets/**", "!dist/**", "!src/**", "!scripts/**", "!.yarn/**", "!.yarnrc.yml"], "mac": { - "additionalBinariesToSign": [ - "./assets/output/lib/libpython3.12.dylib", - "./assets/output/lib/python3.12/lib-dynload/_crypt.cpython-312-darwin.so", - "./assets/output/bin/uv", - "./assets/output/bin/uvx", - "./assets/output/bin/python3.12" - ] + "additionalBinariesToSign": ["./assets/uv/macos/uv", "./assets/uv/macos/uvx"] } } diff --git a/vite.main.config.ts b/vite.main.config.ts index c7bd950a..9ac7de70 100644 --- a/vite.main.config.ts +++ b/vite.main.config.ts @@ -24,14 +24,16 @@ export default defineConfig((env) => { }, plugins: [ pluginHotRestart('restart'), - sentryVitePlugin({ - org: 'comfy-org', - project: 'electron', - authToken: process.env.SENTRY_AUTH_TOKEN, - release: { - name: version, - }, - }), + process.env.NODE_ENV === 'production' + ? sentryVitePlugin({ + org: 'comfy-org', + project: 'electron', + authToken: process.env.SENTRY_AUTH_TOKEN, + release: { + name: version, + }, + }) + : undefined, ], define: { VITE_NAME: JSON.stringify('COMFY'),