diff --git a/scripts/installer.nsh b/scripts/installer.nsh index 0f59d7a0d..41a9e6c49 100644 --- a/scripts/installer.nsh +++ b/scripts/installer.nsh @@ -1,17 +1,199 @@ !include 'MUI2.nsh' +!include 'FileFunc.nsh' !include 'StrFunc.nsh' !include 'LogicLib.nsh' !include 'nsDialogs.nsh' !include 'WinMessages.nsh' +# Register string helper functions used in installer-scope code. +!ifndef BUILD_UNINSTALLER + ${StrRep} +!endif + # Define allowToChangeInstallationDirectory to show the directory page !define allowToChangeInstallationDirectory -# Per-user install +Var /GLOBAL cliInstallScopeOverride + +!ifndef BUILD_UNINSTALLER + Var /GLOBAL cliBasePathOverride + Var /GLOBAL machineEffectiveBasePath + Var /GLOBAL machineScopeInstallSelected +!endif + +# Default to per-user install. CLI flags can override this in `ResolveInstallScopeFromCli`. !macro customInstallMode + StrCpy $isForceMachineInstall "0" + StrCpy $isForceCurrentInstall "1" + !ifdef BUILD_UNINSTALLER + Call un.ResolveInstallScopeFromCli + !else + Call ResolveInstallScopeFromCli + !endif + + StrCmp $cliInstallScopeOverride "machine" forceMachineInstall + StrCmp $cliInstallScopeOverride "user" forceCurrentUserInstall + Goto done + +forceMachineInstall: + StrCpy $isForceMachineInstall "1" + StrCpy $isForceCurrentInstall "0" + Goto done + +forceCurrentUserInstall: + StrCpy $isForceMachineInstall "0" StrCpy $isForceCurrentInstall "1" + +done: !macroend +!macro ResolveInstallScopeFromCliBody + Push $0 + Push $1 + Push $2 + + StrCpy $2 "0" + StrCpy $cliInstallScopeOverride "" + ${GetParameters} $0 + + # Explicit scope controls. + # Supported: + # /INSTALL_SCOPE=user|machine + # /ALLUSERS + # /CURRENTUSER + # /BASE_PATH= + ClearErrors + ${GetOptions} "$0" "/INSTALL_SCOPE=" $1 + IfErrors checkAllUsers + StrCmp $1 "machine" setMachineFromScope + StrCmp $1 "MACHINE" setMachineFromScope + StrCmp $1 "user" setUserFromScope + StrCmp $1 "USER" setUserFromScope + DetailPrint "[Warn] Ignoring invalid /INSTALL_SCOPE value: $1" + Goto checkAllUsers + +setMachineFromScope: + StrCpy $cliInstallScopeOverride "machine" + StrCpy $2 "1" + Goto checkAllUsers + +setUserFromScope: + StrCpy $cliInstallScopeOverride "user" + StrCpy $2 "1" + +checkAllUsers: + ClearErrors + ${GetOptions} "$0" "/ALLUSERS" $1 + IfErrors checkCurrentUser + StrCpy $cliInstallScopeOverride "machine" + StrCpy $2 "1" + +checkCurrentUser: + ClearErrors + ${GetOptions} "$0" "/CURRENTUSER" $1 + IfErrors maybeOem + StrCpy $cliInstallScopeOverride "user" + StrCpy $2 "1" + +maybeOem: + # OEM flags default to machine mode unless explicit scope was set. + StrCmp $2 "1" done + + ClearErrors + ${GetOptions} "$0" "/OEM" $1 + IfErrors checkOemValue + StrCpy $cliInstallScopeOverride "machine" + Goto done + +checkOemValue: + ClearErrors + ${GetOptions} "$0" "/OEM=" $1 + IfErrors done + StrCpy $cliInstallScopeOverride "machine" + +done: + Pop $2 + Pop $1 + Pop $0 +!macroend + +!ifdef BUILD_UNINSTALLER +Function un.ResolveInstallScopeFromCli + !insertmacro ResolveInstallScopeFromCliBody +FunctionEnd +!else +Function ResolveInstallScopeFromCli + !insertmacro ResolveInstallScopeFromCliBody +FunctionEnd +!endif + +!ifndef BUILD_UNINSTALLER +Function ResolveBasePathOverrideFromCli + Push $0 + Push $1 + + StrCpy $cliBasePathOverride "" + ${GetParameters} $0 + + ClearErrors + ${GetOptions} "$0" "/BASE_PATH=" $1 + IfErrors done + ${StrRep} $1 $1 '"' "" + StrCpy $cliBasePathOverride $1 + +done: + Pop $1 + Pop $0 +FunctionEnd + +Function HardenMachineScopeDataAcl + Push $0 + Push $1 + + ${If} $machineScopeInstallSelected != "1" + Goto done + ${EndIf} + + ReadEnvStr $0 "ProgramData" + ${If} $0 == "" + StrCpy $0 "C:\ProgramData" + ${EndIf} + + StrCpy $machineEffectiveBasePath "$0\ComfyUI\base" + ${If} $cliBasePathOverride != "" + StrCpy $machineEffectiveBasePath $cliBasePathOverride + ${EndIf} + + CreateDirectory "$machineEffectiveBasePath" + DetailPrint "[OEM] Hardening ACLs for machine base path: $machineEffectiveBasePath" + + # Use SID-based grants for locale-independent behavior: + # - SYSTEM (S-1-5-18): Full control + # - BUILTIN\Administrators (S-1-5-32-544): Full control + # - BUILTIN\Users (S-1-5-32-545): Modify + nsExec::ExecToLog '"$SYSDIR\icacls.exe" "$machineEffectiveBasePath" /inheritance:e /grant *S-1-5-18:(OI)(CI)F /grant *S-1-5-32-544:(OI)(CI)F /grant *S-1-5-32-545:(OI)(CI)M /T /C' + Pop $1 + ${If} $1 != "0" + DetailPrint "[Warn] icacls returned non-zero exit code for base path ACL hardening: $1" + ${EndIf} + +done: + Pop $1 + Pop $0 +FunctionEnd +!endif + +!ifndef BUILD_UNINSTALLER +!macro customInstall + StrCpy $machineScopeInstallSelected "0" + ${if} $installMode == "all" + StrCpy $machineScopeInstallSelected "1" + ${endif} + Call ResolveBasePathOverrideFromCli + Call HardenMachineScopeDataAcl +!macroend +!endif + # Custom finish page that skips when in update mode !macro customFinishPage !ifndef HIDE_RUN_AFTER_FINISH @@ -401,43 +583,42 @@ ${EndIf} FunctionEnd - # Resolve $basePath from $APPDATA\ComfyUI\config.json (sets empty if not found) - Function un.ResolveBasePath - StrCpy $basePath "" + Function un.ReadBasePathFromJsonFile + Pop $0 ClearErrors - FileOpen $0 "$APPDATA\ComfyUI\config.json" r + FileOpen $1 "$0" r IfErrors done - StrCpy $1 "basePath" - StrLen $2 $1 + StrCpy $2 "basePath" + StrLen $3 $2 loop: - FileRead $0 $3 + FileRead $1 $4 IfErrors close # scan for "basePath" StrCpy $R2 -1 scan: IntOp $R2 $R2 + 1 - StrCpy $R3 $3 1 $R2 + StrCpy $R3 $4 1 $R2 StrCmp $R3 "" loop StrCmp $R3 '"' check_key Goto scan check_key: IntOp $R4 $R2 + 1 - StrCpy $R5 $3 $2 $R4 - StrCmp $R5 $1 next_quote scan + StrCpy $R5 $4 $3 $R4 + StrCmp $R5 $2 next_quote scan next_quote: - IntOp $R6 $R4 + $2 - StrCpy $R7 $3 1 $R6 + IntOp $R6 $R4 + $3 + StrCpy $R7 $4 1 $R6 StrCmp $R7 '"' find_colon scan find_colon: IntOp $R8 $R6 + 1 find_colon_loop: - StrCpy $R7 $3 1 $R8 + StrCpy $R7 $4 1 $R8 StrCmp $R7 ":" after_colon StrCmp $R7 "" loop IntOp $R8 $R8 + 1 @@ -446,7 +627,7 @@ after_colon: IntOp $R9 $R8 + 1 find_open_quote: - StrCpy $R7 $3 1 $R9 + StrCpy $R7 $4 1 $R9 StrCmp $R7 '"' open_ok StrCmp $R7 "" loop IntOp $R9 $R9 + 1 @@ -455,7 +636,7 @@ open_ok: IntOp $R0 $R9 + 1 find_close_quote: - StrCpy $R7 $3 1 $R0 + StrCpy $R7 $4 1 $R0 StrCmp $R7 '"' got_value StrCmp $R7 "" loop IntOp $R0 $R0 + 1 @@ -465,15 +646,35 @@ IntOp $R1 $R0 - $R9 IntOp $R1 $R1 - 1 IntOp $R6 $R9 + 1 - StrCpy $basePath $3 $R1 $R6 + StrCpy $basePath $4 $R1 $R6 # Normalize JSON doubled backslashes to single backslashes ${UnStrRep} $basePath $basePath "\\" "\" Goto close close: - FileClose $0 + FileClose $1 done: FunctionEnd + + # Resolve $basePath by preferring the machine config override before falling back to the per-user settings + Function un.ResolveBasePath + StrCpy $basePath "" + + ReadEnvStr $0 "ProgramData" + ${If} $0 == "" + StrCpy $0 "C:\ProgramData" + ${EndIf} + + StrCpy $R1 "$0\ComfyUI\machine-config.json" + Push $R1 + Call un.ReadBasePathFromJsonFile + + ${If} $basePath == "" + StrCpy $R1 "$APPDATA\ComfyUI\config.json" + Push $R1 + Call un.ReadBasePathFromJsonFile + ${EndIf} + FunctionEnd !endif ################################################################################ diff --git a/src/config/machineConfig.ts b/src/config/machineConfig.ts new file mode 100644 index 000000000..54d50f5d9 --- /dev/null +++ b/src/config/machineConfig.ts @@ -0,0 +1,160 @@ +import log from 'electron-log/main'; +import fs from 'node:fs'; +import path from 'node:path'; + +import type { DesktopInstallState } from '../store/desktopSettings'; + +export const MACHINE_CONFIG_VERSION = 1; +export const MACHINE_ROOT_DIR_NAME = 'ComfyUI'; +export const MACHINE_CONFIG_FILE_NAME = 'machine-config.json'; +const WINDOWS_DEFAULT_SYSTEM_DRIVE = 'C:'; + +export interface MachineScopeConfig { + version: number; + installState: DesktopInstallState; + basePath: string; + updatedAt: string; +} + +type WritableMachineScopeConfig = Omit; + +const isDesktopInstallState = (value: unknown): value is DesktopInstallState => { + return value === 'started' || value === 'installed' || value === 'upgraded'; +}; + +const isNonEmptyString = (value: unknown): value is string => { + return typeof value === 'string' && value.trim().length > 0; +}; + +const normalizePathForComparison = (targetPath: string): string => { + return path.win32.resolve(targetPath).toLowerCase(); +}; + +const isPathInside = (candidate: string, parent: string): boolean => { + if (candidate === parent) return true; + + const relative = path.win32.relative(parent, candidate); + return relative === '' || (!relative.startsWith('..') && !path.win32.isAbsolute(relative)); +}; + +const isMachineScopeConfig = (value: unknown): value is MachineScopeConfig => { + if (!value || typeof value !== 'object') return false; + + const candidate = value as Partial; + return ( + candidate.version === MACHINE_CONFIG_VERSION && + isDesktopInstallState(candidate.installState) && + isNonEmptyString(candidate.basePath) && + isNonEmptyString(candidate.updatedAt) + ); +}; + +export const isWindows = (): boolean => process.platform === 'win32'; + +export const getWindowsProgramDataPath = (): string | undefined => { + if (!isWindows()) return undefined; + + const programData = process.env.ProgramData?.trim(); + if (programData) return programData; + + const systemDrive = process.env.SystemDrive?.trim(); + const drive = systemDrive && /^[a-z]:$/i.test(systemDrive) ? systemDrive : WINDOWS_DEFAULT_SYSTEM_DRIVE; + return path.win32.join(drive, 'ProgramData'); +}; + +export const getMachineRootPath = (): string | undefined => { + const programData = getWindowsProgramDataPath(); + if (!programData) return undefined; + return path.win32.join(programData, MACHINE_ROOT_DIR_NAME); +}; + +export const getMachineConfigPath = (): string | undefined => { + const rootPath = getMachineRootPath(); + if (!rootPath) return undefined; + return path.win32.join(rootPath, MACHINE_CONFIG_FILE_NAME); +}; + +export const getDefaultWindowsMachineBasePath = (): string | undefined => { + const rootPath = getMachineRootPath(); + if (!rootPath) return undefined; + return path.win32.join(rootPath, 'base'); +}; + +export const isPathUnderWindowsProgramData = (targetPath: string): boolean => { + const programDataPath = getWindowsProgramDataPath(); + if (!isWindows() || !programDataPath || !targetPath) return false; + + const normalizedTargetPath = normalizePathForComparison(targetPath); + const normalizedProgramDataPath = normalizePathForComparison(programDataPath); + return isPathInside(normalizedTargetPath, normalizedProgramDataPath); +}; + +export const isWindowsMachineInstallExePath = (exePath: string): boolean => { + if (!isWindows() || !exePath) return false; + + const normalizedExePath = normalizePathForComparison(exePath); + const roots = [ + process.env.ProgramFiles?.trim(), + process.env['ProgramFiles(x86)']?.trim(), + path.win32.join(WINDOWS_DEFAULT_SYSTEM_DRIVE, 'Program Files'), + path.win32.join(WINDOWS_DEFAULT_SYSTEM_DRIVE, 'Program Files (x86)'), + ].filter((candidate): candidate is string => !!candidate); + + return roots.some((root) => isPathInside(normalizedExePath, normalizePathForComparison(root))); +}; + +export const readMachineConfig = (): MachineScopeConfig | undefined => { + const configPath = getMachineConfigPath(); + if (!isWindows() || !configPath || !fs.existsSync(configPath)) return undefined; + + try { + const content = fs.readFileSync(configPath, 'utf8'); + const parsed = JSON.parse(content) as unknown; + + if (!isMachineScopeConfig(parsed)) { + log.warn('Machine scope config is invalid and will be ignored.', { configPath }); + return undefined; + } + + return parsed; + } catch (error) { + log.warn('Failed reading machine scope config. Falling back to user config.', { configPath, error }); + return undefined; + } +}; + +export const writeMachineConfig = (config: WritableMachineScopeConfig): boolean => { + const configPath = getMachineConfigPath(); + const rootPath = getMachineRootPath(); + if (!isWindows() || !configPath || !rootPath) return false; + + try { + fs.mkdirSync(rootPath, { recursive: true }); + const payload: MachineScopeConfig = { + ...config, + version: MACHINE_CONFIG_VERSION, + updatedAt: new Date().toISOString(), + }; + fs.writeFileSync(configPath, JSON.stringify(payload, null, 2), 'utf8'); + return true; + } catch (error) { + log.error('Failed writing machine scope config.', { configPath, error }); + return false; + } +}; + +export const shouldUseMachineScope = (basePath: string): boolean => { + if (!isWindows()) return false; + if (isPathUnderWindowsProgramData(basePath)) return true; + const machineConfig = readMachineConfig(); + if (!machineConfig) return false; + return normalizePathForComparison(machineConfig.basePath) === normalizePathForComparison(basePath); +}; + +export const resolvePreferredWindowsInstallPath = (exePath: string): string | undefined => { + if (!isWindows()) return undefined; + const machineConfig = readMachineConfig(); + if (machineConfig?.basePath) return machineConfig.basePath; + if (!isWindowsMachineInstallExePath(exePath)) return undefined; + return getDefaultWindowsMachineBasePath(); +}; diff --git a/src/handlers/pathHandlers.ts b/src/handlers/pathHandlers.ts index 64030377b..44f6932ff 100644 --- a/src/handlers/pathHandlers.ts +++ b/src/handlers/pathHandlers.ts @@ -8,6 +8,7 @@ import { strictIpcMain as ipcMain } from '@/infrastructure/ipcChannels'; import { ComfyConfigManager } from '../config/comfyConfigManager'; import { ComfyServerConfig } from '../config/comfyServerConfig'; +import { resolvePreferredWindowsInstallPath } from '../config/machineConfig'; import { IPC_CHANNELS } from '../constants'; import type { PathValidationResult, SystemPaths } from '../preload'; @@ -202,11 +203,12 @@ export function registerPathHandlers() { // Remove OneDrive from documents path if present if (process.platform === 'win32') { documentsPath = documentsPath.replace(/OneDrive\\/, ''); + const machineInstallPath = resolvePreferredWindowsInstallPath(app.getPath('exe')); // We should use path.win32.join for Windows paths return { appData: app.getPath('appData'), appPath: app.getAppPath(), - defaultInstallPath: path.join(documentsPath, 'ComfyUI'), + defaultInstallPath: machineInstallPath ?? path.join(documentsPath, 'ComfyUI'), }; } diff --git a/src/install/installWizard.ts b/src/install/installWizard.ts index f65f27968..7f0d1a7c1 100644 --- a/src/install/installWizard.ts +++ b/src/install/installWizard.ts @@ -1,3 +1,4 @@ +import { app } from 'electron'; import log from 'electron-log/main'; import fs from 'node:fs'; import path from 'node:path'; @@ -5,6 +6,12 @@ import path from 'node:path'; import { ComfyConfigManager } from '../config/comfyConfigManager'; import { ComfyServerConfig, ModelPaths } from '../config/comfyServerConfig'; import { ComfySettings, type ComfySettingsData } from '../config/comfySettings'; +import { + readMachineConfig, + resolvePreferredWindowsInstallPath, + shouldUseMachineScope, + writeMachineConfig, +} from '../config/machineConfig'; import { InstallStage } from '../constants'; import { useAppState } from '../main-process/appState'; import { createInstallStageInfo } from '../main-process/installStages'; @@ -38,6 +45,7 @@ export class InstallWizard implements HasTelemetry { useAppState().setInstallStage(createInstallStageInfo(InstallStage.INITIALIZING_CONFIG, { progress: 10 })); await this.initializeSettings(); + this.initializeMachineScopeConfig(); await this.initializeModelPaths(); } @@ -122,4 +130,26 @@ export class InstallWizard implements HasTelemetry { await ComfyServerConfig.createConfigFile(ComfyServerConfig.configPath, yamlContent); } + + /** + * Persist machine-scope bootstrap config so newly-created users can reuse + * the same base path and install state after sysprep/OOBE. + */ + public initializeMachineScopeConfig() { + const existingMachineConfig = readMachineConfig(); + const isMachineScopedInstall = + shouldUseMachineScope(this.basePath) || + !!existingMachineConfig || + !!resolvePreferredWindowsInstallPath(app.getPath('exe')); + if (!isMachineScopedInstall) return; + + const updated = writeMachineConfig({ + installState: 'started', + basePath: this.basePath, + }); + + if (!updated) { + log.warn('Unable to write machine scope config. Falling back to user-scoped initialization.'); + } + } } diff --git a/src/main-process/comfyInstallation.ts b/src/main-process/comfyInstallation.ts index 3859f59aa..237834e86 100644 --- a/src/main-process/comfyInstallation.ts +++ b/src/main-process/comfyInstallation.ts @@ -1,8 +1,16 @@ import log from 'electron-log/main'; import { rm } from 'node:fs/promises'; +import path from 'node:path'; import { ComfyServerConfig } from '../config/comfyServerConfig'; import { ComfySettings, useComfySettings } from '../config/comfySettings'; +import { + type MachineScopeConfig, + getMachineConfigPath, + readMachineConfig, + shouldUseMachineScope, + writeMachineConfig, +} from '../config/machineConfig'; import { evaluatePathRestrictions } from '../handlers/pathHandlers'; import type { DesktopInstallState } from '../main_types'; import type { InstallValidation } from '../preload'; @@ -81,9 +89,31 @@ export class ComfyInstallation { */ static async fromConfig(): Promise { const config = useDesktopConfig(); - const state = config.get('installState'); - const basePath = config.get('basePath'); + let state = config.get('installState'); + let basePath = config.get('basePath'); + let machineConfig: MachineScopeConfig | undefined; + + if (!state || !basePath) { + machineConfig = readMachineConfig(); + if (machineConfig) { + state ??= machineConfig.installState; + basePath ??= machineConfig.basePath; + + // Hydrate first-launch user config from machine-scoped config so + // sysprep-created users do not need to re-run onboarding. + if (state) config.set('installState', state); + if (basePath) config.set('basePath', basePath); + } + } + if (state && basePath) { + if (machineConfig && !ComfyServerConfig.exists()) { + const updated = await ComfyServerConfig.setBasePathInDefaultConfig(basePath); + if (!updated) { + log.warn('Unable to recreate per-user model config from machine install state.'); + } + } + await ComfySettings.load(basePath); return new ComfyInstallation(state, basePath, getTelemetry()); } @@ -233,6 +263,7 @@ export class ComfyInstallation { setState(state: DesktopInstallState) { this.state = state; useDesktopConfig().set('installState', state); + this.syncMachineScopeConfig(state, this.basePath); } /** @@ -248,6 +279,7 @@ export class ComfyInstallation { // If settings file exists at new location, load it await ComfySettings.load(basePath); + this.syncMachineScopeConfig(this.state, basePath); } /** @@ -258,6 +290,40 @@ export class ComfyInstallation { if (await pathAccessible(ComfyServerConfig.configPath)) { await rm(ComfyServerConfig.configPath); } + + const machineConfigPath = getMachineConfigPath(); + const machineConfig = readMachineConfig(); + const isMachineScopedInstall = ComfyInstallation.isMachineScopedInstall(this.basePath, machineConfig); + if (isMachineScopedInstall && machineConfigPath && (await pathAccessible(machineConfigPath))) { + await rm(machineConfigPath); + } + await useDesktopConfig().permanentlyDeleteConfigFile(); } + + private syncMachineScopeConfig(state: DesktopInstallState, basePath: string) { + const currentMachineConfig = readMachineConfig(); + if (!shouldUseMachineScope(basePath) && !currentMachineConfig) return; + + const updated = writeMachineConfig({ + installState: state, + basePath, + }); + + if (!updated) { + log.warn('Failed to synchronize machine scope config state.'); + } + } + + private static isMachineScopedInstall(basePath: string, machineConfig?: MachineScopeConfig): boolean { + if (!machineConfig) return false; + return this.normalizePathForComparison(machineConfig.basePath) === this.normalizePathForComparison(basePath); + } + + private static normalizePathForComparison(targetPath: string): string { + if (process.platform === 'win32') { + return path.win32.resolve(targetPath).toLowerCase(); + } + return path.resolve(targetPath); + } } diff --git a/src/virtualEnvironment.ts b/src/virtualEnvironment.ts index 513d698c5..bae78aef9 100644 --- a/src/virtualEnvironment.ts +++ b/src/virtualEnvironment.ts @@ -7,6 +7,7 @@ import { readdir, rm } from 'node:fs/promises'; import os, { EOL } from 'node:os'; import path from 'node:path'; +import { isPathUnderWindowsProgramData } from './config/machineConfig'; import { AMD_ROCM_SDK_PACKAGES, AMD_TORCH_PACKAGES, @@ -137,6 +138,7 @@ export class VirtualEnvironment implements HasTelemetry, PythonExecutor { readonly selectedDevice: TorchDeviceType; readonly telemetry: ITelemetry; readonly pythonMirror?: string; + readonly uvPythonInstallDir?: string; readonly pypiMirror?: string; readonly torchMirror?: string; uvPty: pty.IPty | undefined; @@ -149,6 +151,7 @@ export class VirtualEnvironment implements HasTelemetry, PythonExecutor { // dropping them here to avoid passing them to uv. // `node-pty` does not support `undefined`. ...(this.pythonMirror ? { UV_PYTHON_INSTALL_MIRROR: this.pythonMirror } : {}), + ...(this.uvPythonInstallDir ? { UV_PYTHON_INSTALL_DIR: this.uvPythonInstallDir } : {}), }; } @@ -206,6 +209,7 @@ export class VirtualEnvironment implements HasTelemetry, PythonExecutor { this.pythonVersion = pythonVersion ?? '3.12'; this.selectedDevice = selectedDevice ?? 'cpu'; this.pythonMirror = pythonMirror; + this.uvPythonInstallDir = this.resolveUvPythonInstallDir(basePath); this.pypiMirror = pypiMirror; this.torchMirror = fixDeviceMirrorMismatch(selectedDevice!, torchMirror); @@ -271,6 +275,14 @@ export class VirtualEnvironment implements HasTelemetry, PythonExecutor { return primary; } + private resolveUvPythonInstallDir(basePath: string): string | undefined { + if (process.platform !== 'win32') return undefined; + if (!isPathUnderWindowsProgramData(basePath)) return undefined; + + // Machine-scoped sysprep images must avoid user-profile-managed Python locations. + return path.win32.join(basePath, 'uv-python'); + } + public async create(callbacks?: ProcessCallbacks): Promise { try { await this.createEnvironment(callbacks); diff --git a/tests/unit/config/machineConfig.test.ts b/tests/unit/config/machineConfig.test.ts new file mode 100644 index 000000000..f4aaa37ba --- /dev/null +++ b/tests/unit/config/machineConfig.test.ts @@ -0,0 +1,121 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + getDefaultWindowsMachineBasePath, + getMachineConfigPath, + getMachineRootPath, + getWindowsProgramDataPath, + isPathUnderWindowsProgramData, + readMachineConfig, + resolvePreferredWindowsInstallPath, + shouldUseMachineScope, + writeMachineConfig, +} from '@/config/machineConfig'; + +const originalProcess = process; +const originalEnv = process.env; + +const withWindowsProcess = (envOverrides: NodeJS.ProcessEnv = {}) => { + vi.stubGlobal('process', { + ...originalProcess, + platform: 'win32', + env: { + ...originalEnv, + ...envOverrides, + }, + }); +}; + +describe('machineConfig', () => { + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('resolves ProgramData-rooted machine paths on Windows', () => { + withWindowsProcess({ ProgramData: String.raw`D:\ProgramData` }); + + expect(getWindowsProgramDataPath()).toBe(String.raw`D:\ProgramData`); + expect(getMachineRootPath()).toBe(path.win32.join(String.raw`D:\ProgramData`, 'ComfyUI')); + expect(getMachineConfigPath()).toBe(path.win32.join(String.raw`D:\ProgramData`, 'ComfyUI', 'machine-config.json')); + expect(getDefaultWindowsMachineBasePath()).toBe(path.win32.join(String.raw`D:\ProgramData`, 'ComfyUI', 'base')); + }); + + it('detects whether a path is under ProgramData', () => { + withWindowsProcess({ ProgramData: String.raw`C:\ProgramData` }); + + expect(isPathUnderWindowsProgramData(String.raw`C:\ProgramData\ComfyUI\base`)).toBe(true); + expect(isPathUnderWindowsProgramData(String.raw`C:\Users\Test\ComfyUI`)).toBe(false); + }); + + it('reads machine config when valid', () => { + withWindowsProcess({ ProgramData: String.raw`C:\ProgramData` }); + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(fs, 'readFileSync').mockReturnValue( + JSON.stringify({ + version: 1, + installState: 'installed', + basePath: String.raw`C:\ProgramData\ComfyUI\base`, + updatedAt: '2026-02-07T00:00:00.000Z', + }) + ); + + const config = readMachineConfig(); + expect(config).toMatchObject({ + installState: 'installed', + basePath: String.raw`C:\ProgramData\ComfyUI\base`, + }); + }); + + it('writes machine config for Windows scope', () => { + withWindowsProcess({ ProgramData: String.raw`C:\ProgramData` }); + const mkdirSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); + const writeSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); + + const result = writeMachineConfig({ + installState: 'started', + basePath: String.raw`C:\ProgramData\ComfyUI\base`, + }); + + expect(result).toBe(true); + expect(mkdirSpy).toHaveBeenCalledWith(path.win32.join(String.raw`C:\ProgramData`, 'ComfyUI'), { recursive: true }); + expect(writeSpy).toHaveBeenCalled(); + }); + + it('enables machine scope when machine config matches the base path', () => { + withWindowsProcess({ ProgramData: String.raw`C:\ProgramData` }); + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(fs, 'readFileSync').mockReturnValue( + JSON.stringify({ + version: 1, + installState: 'installed', + basePath: String.raw`D:\Shared\ComfyUI`, + updatedAt: '2026-02-07T00:00:00.000Z', + }) + ); + + expect(shouldUseMachineScope(String.raw`D:\Shared\ComfyUI`)).toBe(true); + expect(shouldUseMachineScope(String.raw`C:\Users\user\AppData\Roaming\ComfyUI`)).toBe(false); + }); + + it('enables machine scope when base path is under ProgramData', () => { + withWindowsProcess({ ProgramData: String.raw`C:\ProgramData` }); + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + expect(shouldUseMachineScope(String.raw`C:\ProgramData\ComfyUI\base`)).toBe(true); + expect(shouldUseMachineScope(String.raw`C:\Users\Test\ComfyUI`)).toBe(false); + }); + + it('prefers ProgramData base path for machine installs without existing machine config', () => { + withWindowsProcess({ + ProgramData: String.raw`C:\ProgramData`, + ProgramFiles: String.raw`C:\Program Files`, + }); + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + + const resolved = resolvePreferredWindowsInstallPath(String.raw`C:\Program Files\ComfyUI\ComfyUI.exe`); + expect(resolved).toBe(path.win32.join(String.raw`C:\ProgramData`, 'ComfyUI', 'base')); + }); +}); diff --git a/tests/unit/install/installWizard.test.ts b/tests/unit/install/installWizard.test.ts index b0e0496b7..4541f08ca 100644 --- a/tests/unit/install/installWizard.test.ts +++ b/tests/unit/install/installWizard.test.ts @@ -11,6 +11,18 @@ import { InstallOptions } from '../../../src/preload'; import { getTelemetry } from '../../../src/services/telemetry'; import { electronMock } from '../setup'; +const { + mockReadMachineConfig, + mockResolvePreferredWindowsInstallPath, + mockShouldUseMachineScope, + mockWriteMachineConfig, +} = vi.hoisted(() => ({ + mockShouldUseMachineScope: vi.fn(), + mockReadMachineConfig: vi.fn(), + mockResolvePreferredWindowsInstallPath: vi.fn(), + mockWriteMachineConfig: vi.fn(), +})); + vi.mock('node:fs', () => ({ default: { cpSync: vi.fn(), @@ -23,16 +35,28 @@ vi.mock('node:fs', () => ({ vi.mock('node:fs/promises', () => ({ default: { access: vi.fn(), + mkdir: vi.fn(), readFile: vi.fn(), writeFile: vi.fn(), }, access: vi.fn(), + mkdir: vi.fn(), readFile: vi.fn(), writeFile: vi.fn(), })); vi.mock('../../../src/config/comfyConfigManager'); vi.mock('../../../src/config/comfyServerConfig'); +vi.mock('../../../src/config/machineConfig', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readMachineConfig: mockReadMachineConfig, + resolvePreferredWindowsInstallPath: mockResolvePreferredWindowsInstallPath, + shouldUseMachineScope: mockShouldUseMachineScope, + writeMachineConfig: mockWriteMachineConfig, + }; +}); vi.mock('../../../src/main-process/appState', () => ({ useAppState: vi.fn(() => ({ @@ -85,6 +109,11 @@ describe('InstallWizard', () => { }; beforeEach(async () => { + vi.clearAllMocks(); + mockShouldUseMachineScope.mockReturnValue(false); + mockReadMachineConfig.mockReturnValue(undefined); + mockResolvePreferredWindowsInstallPath.mockReturnValue(undefined); + mockWriteMachineConfig.mockReturnValue(true); await ComfySettings.load('/test/path'); installWizard = new InstallWizard(defaultInstallOptions, getTelemetry()); }); @@ -100,6 +129,26 @@ describe('InstallWizard', () => { expect(getTelemetry().track).toHaveBeenCalledWith('install_flow:create_comfy_directories_start'); expect(getTelemetry().track).toHaveBeenCalledWith('install_flow:create_comfy_directories_end'); }); + + it('writes machine install state for machine-scoped installs', async () => { + mockResolvePreferredWindowsInstallPath.mockReturnValue('/machine/base'); + vi.spyOn(ComfyServerConfig, 'getBaseConfig').mockReturnValue({ test: 'config' }); + + await installWizard.install(); + + expect(mockWriteMachineConfig).toHaveBeenCalledWith({ + installState: 'started', + basePath: '/test/path', + }); + }); + + it('skips machine install state for user-scoped installs', async () => { + vi.spyOn(ComfyServerConfig, 'getBaseConfig').mockReturnValue({ test: 'config' }); + + await installWizard.install(); + + expect(mockWriteMachineConfig).not.toHaveBeenCalled(); + }); }); describe('initializeUserFiles', () => { diff --git a/tests/unit/main-process/comfyInstallation.test.ts b/tests/unit/main-process/comfyInstallation.test.ts new file mode 100644 index 000000000..d63547c41 --- /dev/null +++ b/tests/unit/main-process/comfyInstallation.test.ts @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { MachineScopeConfig } from '@/config/machineConfig'; +import { ComfyInstallation } from '@/main-process/comfyInstallation'; +import type { ITelemetry } from '@/services/telemetry'; + +const { + mockPathAccessible, + mockRm, + mockReadMachineConfig, + mockGetMachineConfigPath, + mockDesktopConfig, + mockComfySettings, + mockComfySettingsLoad, + mockComfyServerConfigExists, + mockSetBasePathInDefaultConfig, + mockGetTelemetry, +} = vi.hoisted(() => ({ + mockPathAccessible: vi.fn(), + mockRm: vi.fn(), + mockReadMachineConfig: vi.fn(), + mockGetMachineConfigPath: vi.fn(), + mockDesktopConfig: { + get: vi.fn(), + set: vi.fn(), + permanentlyDeleteConfigFile: vi.fn(() => Promise.resolve()), + }, + mockComfySettings: { + get: vi.fn(), + }, + mockComfySettingsLoad: vi.fn(), + mockComfyServerConfigExists: vi.fn(), + mockSetBasePathInDefaultConfig: vi.fn(), + mockGetTelemetry: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ + rm: mockRm, +})); + +vi.mock('@/utils', () => ({ + pathAccessible: mockPathAccessible, + canExecute: vi.fn(), + canExecuteShellCommand: vi.fn(), +})); + +vi.mock('@/store/desktopConfig', () => ({ + useDesktopConfig: vi.fn(() => mockDesktopConfig), +})); + +vi.mock('@/config/comfySettings', () => ({ + ComfySettings: { + load: mockComfySettingsLoad, + }, + useComfySettings: vi.fn(() => mockComfySettings), +})); + +vi.mock('@/virtualEnvironment', () => ({ + VirtualEnvironment: vi.fn(() => ({ + exists: vi.fn(), + hasRequirements: vi.fn(), + pythonInterpreterPath: '', + uvPath: '', + })), +})); + +vi.mock('@/config/comfyServerConfig', () => ({ + ComfyServerConfig: { + configPath: '/user/extra_models_config.yaml', + exists: mockComfyServerConfigExists, + setBasePathInDefaultConfig: mockSetBasePathInDefaultConfig, + }, +})); + +vi.mock('@/config/machineConfig', () => ({ + getMachineConfigPath: mockGetMachineConfigPath, + readMachineConfig: mockReadMachineConfig, + shouldUseMachineScope: vi.fn(), + writeMachineConfig: vi.fn(), +})); + +vi.mock('@/services/telemetry', () => ({ + getTelemetry: mockGetTelemetry, +})); + +const createMockTelemetry = (): ITelemetry => ({ + track: vi.fn(), + hasConsent: true, + flush: vi.fn(), + registerHandlers: vi.fn(), + loadGenerationCount: vi.fn(), +}); + +const createMachineConfig = (basePath: string): MachineScopeConfig => ({ + version: 1, + installState: 'installed', + basePath, + updatedAt: '2026-02-07T00:00:00.000Z', +}); + +describe('ComfyInstallation fromConfig', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockComfySettingsLoad.mockResolvedValue(mockComfySettings); + mockComfyServerConfigExists.mockReturnValue(false); + mockSetBasePathInDefaultConfig.mockResolvedValue(true); + mockGetTelemetry.mockReturnValue(createMockTelemetry()); + mockReadMachineConfig.mockReturnValue(undefined); + }); + + it('hydrates missing per-user config from machine scope config', async () => { + mockDesktopConfig.get.mockImplementation((key: string) => { + if (key === 'installState' || key === 'basePath') return undefined; + return undefined; + }); + mockReadMachineConfig.mockReturnValue(createMachineConfig('/machine/base')); + + const installation = await ComfyInstallation.fromConfig(); + + expect(installation).toBeDefined(); + expect(installation?.state).toBe('installed'); + expect(installation?.basePath).toBe('/machine/base'); + expect(mockDesktopConfig.set).toHaveBeenCalledWith('installState', 'installed'); + expect(mockDesktopConfig.set).toHaveBeenCalledWith('basePath', '/machine/base'); + expect(mockSetBasePathInDefaultConfig).toHaveBeenCalledWith('/machine/base'); + expect(mockComfySettingsLoad).toHaveBeenCalledWith('/machine/base'); + }); +}); + +describe('ComfyInstallation uninstall', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockComfySettingsLoad.mockResolvedValue(mockComfySettings); + mockGetTelemetry.mockReturnValue(createMockTelemetry()); + mockPathAccessible.mockResolvedValue(false); + mockGetMachineConfigPath.mockReturnValue('/machine/machine-config.json'); + mockReadMachineConfig.mockReturnValue(undefined); + mockDesktopConfig.permanentlyDeleteConfigFile.mockResolvedValue(undefined); + }); + + it('does not delete machine config during per-user uninstall', async () => { + mockReadMachineConfig.mockReturnValue(createMachineConfig('/machine/base')); + mockPathAccessible.mockImplementation((targetPath: string) => targetPath === '/user/extra_models_config.yaml'); + + const installation = new ComfyInstallation('installed', '/users/alice/comfy', createMockTelemetry()); + await installation.uninstall(); + + expect(mockRm).toHaveBeenCalledTimes(1); + expect(mockRm).toHaveBeenCalledWith('/user/extra_models_config.yaml'); + expect(mockRm).not.toHaveBeenCalledWith('/machine/machine-config.json'); + expect(mockDesktopConfig.permanentlyDeleteConfigFile).toHaveBeenCalledTimes(1); + }); + + it('deletes machine config when uninstalling the machine-scoped install', async () => { + mockReadMachineConfig.mockReturnValue(createMachineConfig('/machine/base')); + mockPathAccessible.mockImplementation( + (targetPath: string) => + targetPath === '/user/extra_models_config.yaml' || targetPath === '/machine/machine-config.json' + ); + + const installation = new ComfyInstallation('installed', '/machine/base', createMockTelemetry()); + await installation.uninstall(); + + expect(mockRm).toHaveBeenCalledTimes(2); + expect(mockRm).toHaveBeenCalledWith('/user/extra_models_config.yaml'); + expect(mockRm).toHaveBeenCalledWith('/machine/machine-config.json'); + expect(mockDesktopConfig.permanentlyDeleteConfigFile).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/virtualEnvironment.test.ts b/tests/unit/virtualEnvironment.test.ts index 2e6af3a4d..348a50955 100644 --- a/tests/unit/virtualEnvironment.test.ts +++ b/tests/unit/virtualEnvironment.test.ts @@ -312,5 +312,48 @@ describe('VirtualEnvironment', () => { expect('UV_PYTHON_INSTALL_MIRROR' in uvEnv).toBe(false); expect(uvEnv.UV_PYTHON_INSTALL_MIRROR).toBeUndefined(); }); + + test('sets UV_PYTHON_INSTALL_DIR for machine-scoped ProgramData base path on Windows', () => { + vi.stubGlobal('process', { + ...process, + platform: 'win32', + env: { + ...process.env, + ProgramData: String.raw`C:\ProgramData`, + }, + resourcesPath: '/test/resources', + }); + + const envMachineScope = new VirtualEnvironment(String.raw`C:\ProgramData\ComfyUI\base`, { + telemetry: mockTelemetry, + selectedDevice: 'cpu', + pythonVersion: '3.12', + }); + + const { uvEnv } = envMachineScope; + expect(uvEnv.UV_PYTHON_INSTALL_DIR).toBe(String.raw`C:\ProgramData\ComfyUI\base\uv-python`); + }); + + test('omits UV_PYTHON_INSTALL_DIR for non-ProgramData base path on Windows', () => { + vi.stubGlobal('process', { + ...process, + platform: 'win32', + env: { + ...process.env, + ProgramData: String.raw`C:\ProgramData`, + }, + resourcesPath: '/test/resources', + }); + + const envUserScope = new VirtualEnvironment(String.raw`C:\Users\Ben\ComfyUI`, { + telemetry: mockTelemetry, + selectedDevice: 'cpu', + pythonVersion: '3.12', + }); + + const { uvEnv } = envUserScope; + expect('UV_PYTHON_INSTALL_DIR' in uvEnv).toBe(false); + expect(uvEnv.UV_PYTHON_INSTALL_DIR).toBeUndefined(); + }); }); });