Skip to content

Commit

Permalink
Add basic installation validation (#554)
Browse files Browse the repository at this point in the history
* Rename for clarity

* Move classes to individual files

* Add containsDirectory FS helper function

* Add temporary base path updater - updates YAML

* Add modal native dialog helpers

* Flatten app install call chaining

- Adds framework for app state validation
- Simplifies call chains (even with additions)

* Add logging events

* Remove unused code

* [Refactor] Improve promise handling and readability

* Set run-once install listener to self-remove

* Add framework for installation validation

* Remove unused code

* Remove redundant code

* Refactor installationvalidator

* Rename for clarity - InstallationManager

* nit

* nit
  • Loading branch information
webfiltered authored Dec 24, 2024
1 parent bb82891 commit 0d54841
Show file tree
Hide file tree
Showing 12 changed files with 346 additions and 179 deletions.
2 changes: 1 addition & 1 deletion scripts/resetInstall.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ async function main() {
if (fs.existsSync(configPath)) {
const configContent = fs.readFileSync(configPath, 'utf8');

/** @type {import('../src/store/index').DesktopSettings} */
/** @type {import('../src/store/desktopSettings').DesktopSettings} */
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const parsed = JSON.parse(configContent);
desktopBasePath = parsed?.basePath;
Expand Down
12 changes: 12 additions & 0 deletions src/config/comfyServerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,4 +215,16 @@ export class ComfyServerConfig {
}
}
}

/** @deprecated Do not use. Tempoary workaround for validation only. */
public static async setBasePathInDefaultConfig(basePath: string): Promise<boolean> {
const parsedConfig = await ComfyServerConfig.readConfigFile(ComfyServerConfig.configPath);
if (!parsedConfig) return false;

parsedConfig.comfyui_desktop ??= {};
parsedConfig.comfyui_desktop.base_path = basePath;
const stringified = ComfyServerConfig.generateConfigFileContent(parsedConfig);

return await ComfyServerConfig.writeConfigFile(ComfyServerConfig.configPath, stringified);
}
}
152 changes: 152 additions & 0 deletions src/install/installationManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { app, ipcMain } from 'electron';
import log from 'electron-log/main';
import { ComfyInstallation } from '../main-process/comfyInstallation';
import type { AppWindow } from '../main-process/appWindow';
import { useDesktopConfig } from '../store/desktopConfig';
import type { InstallOptions } from '../preload';
import { IPC_CHANNELS } from '../constants';
import { InstallWizard } from './installWizard';
import { validateHardware } from '../utils';

/** High-level / UI control over the installation of ComfyUI server. */
export class InstallationManager {
constructor(public readonly appWindow: AppWindow) {}

/**
* Ensures that ComfyUI is installed and ready to run.
*
* First checks for an existing installation and validates it. If missing or invalid, a fresh install is started.
* @returns A valid {@link ComfyInstallation} object.
*/
async ensureInstalled(): Promise<ComfyInstallation> {
const installation = ComfyInstallation.fromConfig();

// Fresh install
if (!installation) return await this.freshInstall();

// Validate installation
const state = await installation.validate();
if (state !== 'installed') await this.resumeInstallation(installation);

// Resolve issues and re-run validation
if (installation.issues.size > 0) {
await this.resolveIssues(installation);
await installation.validate();
}

// TODO: Confirm this is no longer possible after resolveIssues and remove.
if (!installation.basePath) throw new Error('Base path was invalid after installation validation.');
if (installation.issues.size > 0) throw new Error('Installation issues remain after validation.');

// Return validated installation
return installation;
}

/**
* Resumes an installation that was never completed.
* @param installation The installation to resume
*/
async resumeInstallation(installation: ComfyInstallation) {
// TODO: Resume install at point of interruption
if (installation.state === 'started') await this.freshInstall();
if (installation.state === 'upgraded') installation.upgradeConfig();
}

/**
* Install ComfyUI and return the base path.
*/
async freshInstall(): Promise<ComfyInstallation> {
log.info('Starting installation.');

const config = useDesktopConfig();
config.set('installState', 'started');

const hardware = await validateHardware();
if (typeof hardware?.gpu === 'string') config.set('detectedGpu', hardware.gpu);

const optionsPromise = new Promise<InstallOptions>((resolve) => {
ipcMain.once(IPC_CHANNELS.INSTALL_COMFYUI, (_event, installOptions: InstallOptions) => {
log.verbose('Received INSTALL_COMFYUI.');
resolve(installOptions);
});
});

if (!hardware.isValid) {
log.error(hardware.error);
log.verbose('Loading not-supported renderer.');
await this.appWindow.loadRenderer('not-supported');
} else {
log.verbose('Loading welcome renderer.');
await this.appWindow.loadRenderer('welcome');
}

const installOptions = await optionsPromise;

const installWizard = new InstallWizard(installOptions);
useDesktopConfig().set('basePath', installWizard.basePath);

const { device } = installOptions;
if (device !== undefined) {
useDesktopConfig().set('selectedDevice', device);
}

await installWizard.install();
this.appWindow.maximize();
if (installWizard.shouldMigrateCustomNodes && installWizard.migrationSource) {
useDesktopConfig().set('migrateCustomNodesFrom', installWizard.migrationSource);
}

return new ComfyInstallation('installed', installWizard.basePath);
}

/**
* Shows a dialog box to select a base path to install ComfyUI.
* @param initialPath The initial path to show in the dialog box.
* @returns The selected path, otherwise `undefined`.
*/
async showBasePathPicker(initialPath?: string): Promise<string | undefined> {
const defaultPath = initialPath ?? app.getPath('documents');
const { filePaths } = await this.appWindow.showOpenDialog({
defaultPath,
properties: ['openDirectory', 'treatPackageAsDirectory', 'dontAddToRecent'],
});
return filePaths[0];
}

/** Notify user that the provided base apth is not valid. */
async #showInvalidBasePathMessage() {
await this.appWindow.showMessageBox({
title: 'Invalid base path',
message:
'ComfyUI needs a valid directory set as its base path. Inside, models, custom nodes, etc will be stored.\n\nClick OK, then selected a new base path.',
type: 'error',
});
}

/**
* Resolves any issues found during installation validation.
* @param installation The installation to resolve issues for
* @throws If the base path is invalid or cannot be saved
*/
async resolveIssues(installation: ComfyInstallation) {
const issues = [...installation.issues];
for (const issue of issues) {
switch (issue) {
// TODO: Other issues (uv mising, venv etc)
case 'invalidBasePath': {
// TODO: Add IPC listeners and proper UI for this
await this.#showInvalidBasePathMessage();

const path = await this.showBasePathPicker();
if (!path) return;

const success = await installation.updateBasePath(path);
if (!success) throw new Error('No base path selected or failed to save in config.');

installation.issues.delete('invalidBasePath');
break;
}
}
}
}
}
60 changes: 0 additions & 60 deletions src/install/installationValidator.ts

This file was deleted.

12 changes: 11 additions & 1 deletion src/main-process/appWindow.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { BrowserWindow, screen, app, shell, ipcMain, Tray, Menu, dialog, MenuItem } from 'electron';
import path from 'node:path';
import Store from 'electron-store';
import { AppWindowSettings } from '../store';
import { AppWindowSettings } from '../store/AppWindowSettings';
import log from 'electron-log/main';
import { IPC_CHANNELS, ProgressStatus, ServerArgs } from '../constants';
import { getAppResourcesPath } from '../install/resourcePaths';
Expand Down Expand Up @@ -151,6 +151,16 @@ export class AppWindow {
}
}

/** Opens a modal file/folder picker. @inheritdoc {@link Electron.Dialog.showOpenDialog} */
public async showOpenDialog(options: Electron.OpenDialogOptions) {
return await dialog.showOpenDialog(this.window, options);
}

/** Opens a modal message box. @inheritdoc {@link Electron.Dialog.showMessageBox} */
public async showMessageBox(options: Electron.MessageBoxOptions) {
return await dialog.showMessageBox(this.window, options);
}

/**
* Loads window state from `userData` via `electron-store`. Overwrites invalid config with defaults.
* @returns The electron store for non-critical window state (size/position etc)
Expand Down
Loading

0 comments on commit 0d54841

Please sign in to comment.