Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/config/comfySettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
export const DEFAULT_SETTINGS: ComfySettingsData = {
'Comfy-Desktop.AutoUpdate': true,
'Comfy-Desktop.SendStatistics': true,
'Comfy-Desktop.RunInBackgroundOnClose': false,
'Comfy.ColorPalette': 'dark',
'Comfy.UseNewMenu': 'Top',
'Comfy.Workflow.WorkflowTabsPosition': 'Topbar',
Expand All @@ -18,6 +19,7 @@
export interface ComfySettingsData {
'Comfy-Desktop.AutoUpdate': boolean;
'Comfy-Desktop.SendStatistics': boolean;
'Comfy-Desktop.RunInBackgroundOnClose': boolean;
'Comfy.ColorPalette': 'dark' | 'light';
'Comfy.UseNewMenu': 'Top' | 'Bottom';
'Comfy.Workflow.WorkflowTabsPosition': 'Topbar' | 'Sidebar';
Expand Down Expand Up @@ -135,7 +137,7 @@
}

try {
await fs.writeFile(this.filePath, JSON.stringify(this.settings, null, 2));

Check failure on line 140 in src/config/comfySettings.ts

View workflow job for this annotation

GitHub Actions / lint-and-format (macos-latest)

tests/unit/install/installWizard.test.ts > InstallWizard > initializeSettings > should add CPU launch args when device is cpu

Error: ENOENT: no such file or directory, open '/test/path/user/default/comfy.settings.json' ❯ ComfySettings.saveSettings src/config/comfySettings.ts:140:7 ❯ InstallWizard.initializeSettings src/install/installWizard.ts:81:5 ❯ tests/unit/install/installWizard.test.ts:194:7 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'open', path: '/test/path/user/default/comfy.settings.json' }

Check failure on line 140 in src/config/comfySettings.ts

View workflow job for this annotation

GitHub Actions / lint-and-format (macos-latest)

tests/unit/install/installWizard.test.ts > InstallWizard > initializeSettings > should merge with existing settings when settings file exists

Error: ENOENT: no such file or directory, open '/test/path/user/default/comfy.settings.json' ❯ ComfySettings.saveSettings src/config/comfySettings.ts:140:7 ❯ InstallWizard.initializeSettings src/install/installWizard.ts:81:5 ❯ tests/unit/install/installWizard.test.ts:173:7 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'open', path: '/test/path/user/default/comfy.settings.json' }

Check failure on line 140 in src/config/comfySettings.ts

View workflow job for this annotation

GitHub Actions / lint-and-format (macos-latest)

tests/unit/install/installWizard.test.ts > InstallWizard > initializeSettings > should create settings file with default values when no existing settings

Error: ENOENT: no such file or directory, open '/test/path/user/default/comfy.settings.json' ❯ ComfySettings.saveSettings src/config/comfySettings.ts:140:7 ❯ InstallWizard.initializeSettings src/install/installWizard.ts:81:5 ❯ tests/unit/install/installWizard.test.ts:150:7 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'open', path: '/test/path/user/default/comfy.settings.json' }

Check failure on line 140 in src/config/comfySettings.ts

View workflow job for this annotation

GitHub Actions / lint-and-format (macos-latest)

tests/unit/install/installWizard.test.ts > InstallWizard > install > should create ComfyUI directories and initialize required files

Error: ENOENT: no such file or directory, open '/test/path/user/default/comfy.settings.json' ❯ ComfySettings.saveSettings src/config/comfySettings.ts:140:7 ❯ InstallWizard.initializeSettings src/install/installWizard.ts:81:5 ❯ InstallWizard.install src/install/installWizard.ts:34:5 ❯ InstallWizard.descriptor.value src/services/telemetry.ts:233:9 ❯ tests/unit/install/installWizard.test.ts:89:7 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'open', path: '/test/path/user/default/comfy.settings.json' }

Check failure on line 140 in src/config/comfySettings.ts

View workflow job for this annotation

GitHub Actions / lint-and-format (macos-latest)

tests/unit/config/comfySettings.test.ts > ComfySettings > file operations > should save settings to correct path with proper formatting

Error: ENOENT: no such file or directory, open 'test/base/path/user/default/comfy.settings.json' ❯ ComfySettings.saveSettings src/config/comfySettings.ts:140:7 ❯ tests/unit/config/comfySettings.test.ts:113:7 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'open', path: 'test/base/path/user/default/comfy.settings.json' }

Check failure on line 140 in src/config/comfySettings.ts

View workflow job for this annotation

GitHub Actions / lint-and-format (macos-latest)

tests/unit/config/comfySettings.test.ts > ComfySettings > file operations > should use correct file path

Error: ENOENT: no such file or directory, open 'test/base/path/user/default/comfy.settings.json' ❯ ComfySettings.saveSettings src/config/comfySettings.ts:140:7 ❯ tests/unit/config/comfySettings.test.ts:77:7 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'open', path: 'test/base/path/user/default/comfy.settings.json' }

Check failure on line 140 in src/config/comfySettings.ts

View workflow job for this annotation

GitHub Actions / lint-and-format (macos-latest)

tests/unit/config/comfySettings.test.ts > ComfySettings > write locking > should allow writes before being locked

Error: ENOENT: no such file or directory, open 'test/base/path/user/default/comfy.settings.json' ❯ ComfySettings.saveSettings src/config/comfySettings.ts:140:7 ❯ tests/unit/config/comfySettings.test.ts:39:7 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'open', path: 'test/base/path/user/default/comfy.settings.json' }
} catch (error) {
log.error('Failed to save settings:', error);
throw error;
Expand Down
49 changes: 37 additions & 12 deletions src/main-process/appWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { debounce } from 'lodash';
import path from 'node:path';
import { URL } from 'node:url';

import { useComfySettings } from '@/config/comfySettings';
import { ElectronError } from '@/infrastructure/electronError';
import type { Page } from '@/infrastructure/interfaces';
import { type IAppState, useAppState } from '@/main-process/appState';
Expand All @@ -30,7 +31,7 @@ import { useDesktopConfig } from '../store/desktopConfig';

/**
* Creates a single application window that displays the renderer and encapsulates all the logic for sending messages to the renderer.
* Closes the application when the window is closed.
* Conditionally hides to system tray when the window is closed (based on 'Comfy-Desktop.RunInBackgroundOnClose' setting).
*/
export class AppWindow {
private readonly appState: IAppState = useAppState();
Expand Down Expand Up @@ -170,10 +171,18 @@ export class AppWindow {

public show(): void {
this.window.show();
if (process.platform === 'darwin') {
app.dock.show().catch((error) => {
log.error('Error showing dock', error);
});
}
}

public hide(): void {
this.window.hide();
if (process.platform === 'darwin') {
app.dock.hide();
}
}

public isMinimized(): boolean {
Expand Down Expand Up @@ -318,7 +327,28 @@ export class AppWindow {

this.window.on('resize', updateBounds);
this.window.on('move', updateBounds);
this.window.on('close', () => log.info('App window closed.'));
this.window.on('close', (event) => {
if (this.appState.isQuitting) {
// App is actually quitting - allow the window to close
log.info('App window closing - app is quitting');
return;
}

// Check if run in background setting is enabled
const settings = useComfySettings();
const runInBackground = settings.get('Comfy-Desktop.RunInBackgroundOnClose');

if (runInBackground) {
// Hide to tray - prevent window close
log.info('App window close requested - hiding to tray instead');
event.preventDefault();
this.hide();
} else {
// Setting is disabled - allow normal close behavior (quit app)
log.info('App window closing - run in background disabled');
// Let the window close normally, which will quit the app
}
});

this.window.webContents.setWindowOpenHandler(({ url }) => {
if (this.#shouldOpenInPopup(url)) {
Expand All @@ -343,6 +373,11 @@ export class AppWindow {
if (this.isMinimized()) this.restore();
this.focus();
});

// Handle activate event (macOS - clicking dock icon when no windows visible)
app.on('activate', () => {
this.show();
});
}

private setupIpcEvents() {
Expand Down Expand Up @@ -418,12 +453,6 @@ export class AppWindow {
label: 'Show Comfy Window',
click: () => {
this.show();
// Mac Only
if (process.platform === 'darwin') {
app.dock.show().catch((error) => {
log.error('Error showing dock', error);
});
}
},
},
{
Expand All @@ -436,10 +465,6 @@ export class AppWindow {
label: 'Hide',
click: () => {
this.hide();
// Mac Only
if (process.platform === 'darwin') {
app.dock.hide();
}
},
},
]);
Expand Down
23 changes: 19 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { app, session, shell } from 'electron';
import { LevelOption } from 'electron-log';
import log from 'electron-log/main';

import { useComfySettings } from './config/comfySettings';
import { LogFile } from './constants';
import { DesktopApp } from './desktopApp';
import { removeAnsiCodesTransform, replaceFileLoggingTransform } from './infrastructure/structuredLogging';
Expand All @@ -23,7 +24,7 @@ initializeAppState();
const overrides = new DevOverrides();

// Register the quit handlers regardless of single instance lock and before squirrel startup events.
quitWhenAllWindowsAreClosed();
maybeQuitWhenAllWindowsAreClosed();
trackAppQuitEvents();
initializeSentry();

Expand Down Expand Up @@ -89,10 +90,24 @@ function initalizeLogging() {
log.info(`Starting app v${app.getVersion()}`);
}

/** Quit when all windows are closed.*/
function quitWhenAllWindowsAreClosed() {
/** Quit when all windows are closed, unless run in background setting is enabled.*/
function maybeQuitWhenAllWindowsAreClosed() {
app.on('window-all-closed', () => {
log.info('Quitting ComfyUI because window all closed');
try {
// Check if run in background setting is enabled
const settings = useComfySettings();
const runInBackground = settings.get('Comfy-Desktop.RunInBackgroundOnClose');
Comment on lines +98 to +99
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the setting is updated in frontend the state should be sent to the desktop process and kept synchronised (e.g. AppState). IPC is mostly handled in preload.ts, constants.ts.

Dynamically calling settings every time performs a round-trip IPC call to a single-threaded browser process.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a new IPC handler with logic on both sides seems a bit over-engineered to me. Even if we read the setting every time, seems like it'd be fast enough for an infrequent even. Other code seems to be using a similar approach.

If we did want to IPC in here, it might make sense to make a generic settings syncing mechanism that would transfer the whole set of settings from the frontend whenever it's updated, then the desktop app can just read the local settings copy. That's a bit out of scope of this PR though.

Let me know your thoughts. If you still prefer IPC syncing, I can add it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, thanks for taking the time to respond. I've been out of action for a bit.

For this one, it's more about how the app should work, and preventing weird error states/lockups by just using proper separation of concerns. Using code running in a browser process (which isn't always available) as the runtime memory storage for the parent process' state is just too circular and brittle.

p.s. If you can point me at the other code, I'll add it to the issues list.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking closer at this, useSettings().get('key') looks like it just reads a key from an in-memory object: https://github.com/Comfy-Org/desktop/blob/main/src/config/comfySettings.ts#L153

So to make this work correctly, I'm thinking I'll add a new IPC handler for reloadComfySettings. The frontend will call after updating a setting, and in response the backend code will reread the settings file from disk. This mechanism will work more broadly with other settings that the frontend updates which need to be synced to the server.

Does this sound right to you? It would not touch AppState and would continue to use useSettings(). I might have missed a detail of the implementation or misunderstood what you were pointing out.


if (runInBackground) {
log.info('All windows closed but keeping app running in background');
return; // Don't quit, keep running in background
}
} catch (error) {
// Settings not loaded yet or other error - fall back to default behavior (quit)
log.warn('Could not check RunInBackgroundOnClose setting, defaulting to quit:', error);
}

log.info('Quitting ComfyUI because all windows closed');
app.quit();
});
}
Expand Down
1 change: 1 addition & 0 deletions tests/unit/config/comfySettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
const mockSettings: ComfySettingsData = {
'Comfy-Desktop.AutoUpdate': false,
'Comfy-Desktop.SendStatistics': false,
'Comfy-Desktop.RunInBackgroundOnClose': true,
'Comfy.ColorPalette': 'dark',
'Comfy.UseNewMenu': 'Top',
'Comfy.Workflow.WorkflowTabsPosition': 'Topbar',
Expand All @@ -96,7 +97,7 @@
vi.mocked(fsPromises.readFile).mockResolvedValue(JSON.stringify(mockSettings));

settings = await ComfySettings.load(basePath);
expect(settings.get('Comfy-Desktop.AutoUpdate')).toBe(false);

Check failure on line 100 in tests/unit/config/comfySettings.test.ts

View workflow job for this annotation

GitHub Actions / lint-and-format (macos-latest)

tests/unit/config/comfySettings.test.ts > ComfySettings > file operations > should load settings from file when available

AssertionError: expected true to be false // Object.is equality - Expected + Received - false + true ❯ tests/unit/config/comfySettings.test.ts:100:56
expect(settings.get('Comfy.Server.LaunchArgs')).toEqual({ test: 'value' });
expect(settings.get('Comfy-Desktop.SendStatistics')).toBe(false);
});
Expand Down Expand Up @@ -125,7 +126,7 @@

settings = await ComfySettings.load(basePath);
expect(settings.get('Comfy-Desktop.AutoUpdate')).toBe(DEFAULT_SETTINGS['Comfy-Desktop.AutoUpdate']);
expect(log.error).toHaveBeenCalled();

Check failure on line 129 in tests/unit/config/comfySettings.test.ts

View workflow job for this annotation

GitHub Actions / lint-and-format (macos-latest)

tests/unit/config/comfySettings.test.ts > ComfySettings > file operations > should fall back to defaults on file read error

AssertionError: expected "spy" to be called at least once ❯ tests/unit/config/comfySettings.test.ts:129:25
});
});

Expand Down Expand Up @@ -169,7 +170,7 @@

it('should throw error on write error during saveSettings', async () => {
vi.mocked(fsPromises.writeFile).mockRejectedValue(new Error('Permission denied'));
await expect(settings.saveSettings()).rejects.toThrow('Permission denied');

Check failure on line 173 in tests/unit/config/comfySettings.test.ts

View workflow job for this annotation

GitHub Actions / lint-and-format (macos-latest)

tests/unit/config/comfySettings.test.ts > ComfySettings > settings operations > should throw error on write error during saveSettings

AssertionError: expected [Function] to throw error including 'Permission denied' but got 'ENOENT: no such file or directory, op…' Expected: "Permission denied" Received: "ENOENT: no such file or directory, open 'test/base/path/user/default/comfy.settings.json'" ❯ tests/unit/config/comfySettings.test.ts:173:7
});
});

Expand Down
2 changes: 1 addition & 1 deletion tests/unit/handlers/pathHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ const mockFileSystem = ({ exists = true, writable = true, isDirectory = false, c
isDirectory: () => isDirectory,
} as unknown as fs.Stats);
vi.mocked(fs.readdirSync).mockReturnValue(
Array.from({ length: contentLength }, () => ({ name: 'mock-file' }) as fs.Dirent)
Array.from({ length: contentLength }, () => ({ name: 'mock-file' }) as any)
);
if (writable) {
vi.mocked(fs.accessSync).mockReturnValue();
Expand Down
133 changes: 133 additions & 0 deletions tests/unit/main-process/appWindow.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { BrowserWindow, type Tray } from 'electron';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { useComfySettings } from '@/config/comfySettings';
import { AppWindow } from '@/main-process/appWindow';

import { type PartialMock, electronMock } from '../setup';

const mockAppState = {
isQuitting: false,
};

vi.mock('@/main-process/appState', () => ({
useAppState: vi.fn(() => mockAppState),
}));

const additionalMocks: PartialMock<typeof Electron> = {
BrowserWindow: vi.fn() as PartialMock<BrowserWindow>,
nativeTheme: {
Expand Down Expand Up @@ -107,3 +116,127 @@ describe('AppWindow.isOnPage', () => {
expect(appWindow.isOnPage('welcome')).toBe(true);
});
});

describe('AppWindow tray behavior', () => {
let mockWindow: PartialMock<BrowserWindow>;
let windowCloseHandler: (event: Electron.Event) => void;

beforeEach(() => {
vi.clearAllMocks();
mockAppState.isQuitting = false;

mockWindow = {
show: vi.fn(),
hide: vi.fn(),
on: vi.fn((event: string, handler: any) => {
if (event === 'close') windowCloseHandler = handler;
}),
once: vi.fn(),
webContents: { getURL: vi.fn(), setWindowOpenHandler: vi.fn() },
};

vi.mocked(BrowserWindow).mockImplementation(() => mockWindow as BrowserWindow);
});

describe('when RunInBackgroundOnClose is enabled', () => {
beforeEach(() => {
const settings = useComfySettings();
settings.set('Comfy-Desktop.RunInBackgroundOnClose', true);
});

it('should hide to tray when window closed and app not quitting', () => {
vi.stubGlobal('process', { ...process, platform: 'win32', resourcesPath: '/mock' });
new AppWindow();
const mockEvent = { preventDefault: vi.fn() };

windowCloseHandler(mockEvent as any);

expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(mockWindow.hide).toHaveBeenCalled();
});

it('should still allow window close when app is quitting', () => {
vi.stubGlobal('process', { ...process, platform: 'win32', resourcesPath: '/mock' });
mockAppState.isQuitting = true;
new AppWindow();
const mockEvent = { preventDefault: vi.fn() };

windowCloseHandler(mockEvent as any);

expect(mockEvent.preventDefault).not.toHaveBeenCalled();
expect(mockWindow.hide).not.toHaveBeenCalled();
});
});

describe('when RunInBackgroundOnClose is disabled (default)', () => {
beforeEach(() => {
const settings = useComfySettings();
settings.set('Comfy-Desktop.RunInBackgroundOnClose', false);
});

it('should allow window to close normally', () => {
vi.stubGlobal('process', { ...process, platform: 'win32', resourcesPath: '/mock' });
new AppWindow();
const mockEvent = { preventDefault: vi.fn() };

windowCloseHandler(mockEvent as any);

expect(mockEvent.preventDefault).not.toHaveBeenCalled();
expect(mockWindow.hide).not.toHaveBeenCalled();
});

it('should allow window close when app is quitting', () => {
vi.stubGlobal('process', { ...process, platform: 'win32', resourcesPath: '/mock' });
mockAppState.isQuitting = true;
new AppWindow();
const mockEvent = { preventDefault: vi.fn() };

windowCloseHandler(mockEvent as any);

expect(mockEvent.preventDefault).not.toHaveBeenCalled();
expect(mockWindow.hide).not.toHaveBeenCalled();
});
});

describe('macOS dock behavior', () => {
beforeEach(() => {
vi.stubGlobal('process', { ...process, platform: 'darwin', resourcesPath: '/mock' });
electronMock.app.dock = {
show: vi.fn().mockResolvedValue(undefined),
hide: vi.fn(),
bounce: vi.fn(),
cancelBounce: vi.fn(),
downloadFinished: vi.fn(),
getBadge: vi.fn(),
setBadge: vi.fn(),
getMenu: vi.fn(),
setMenu: vi.fn(),
setIcon: vi.fn(),
} as any;
});

it('should hide dock when hiding window on macOS', () => {
const appWindow = new AppWindow();

appWindow.hide();

expect(mockWindow.hide).toHaveBeenCalled();
expect(electronMock.app.dock?.hide).toHaveBeenCalled();
});

it('should show dock when showing window on macOS', () => {
const appWindow = new AppWindow();

appWindow.show();

expect(mockWindow.show).toHaveBeenCalled();
expect(electronMock.app.dock?.show).toHaveBeenCalled();
});

it('should register activate handler for dock clicks on macOS', () => {
new AppWindow();

expect(electronMock.app.on).toHaveBeenCalledWith('activate', expect.any(Function));
});
});
});
20 changes: 19 additions & 1 deletion tests/unit/setup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { FileTransport, MainLogger, MainTransports } from 'electron-log';
import log from 'electron-log/main';
import { vi } from 'vitest';
import { beforeEach, vi } from 'vitest';

import { ComfySettings } from '@/config/comfySettings';
import type { IAppState } from '@/main-process/appState';
import type { ITelemetry } from '@/services/telemetry';

Expand Down Expand Up @@ -45,6 +46,18 @@ export const electronMock: ElectronMock = {
getVersion: vi.fn(() => '1.0.0'),
on: vi.fn(),
once: vi.fn(),
dock: {
show: vi.fn().mockResolvedValue(undefined),
hide: vi.fn(),
bounce: vi.fn(),
cancelBounce: vi.fn(),
downloadFinished: vi.fn(),
getBadge: vi.fn(),
setBadge: vi.fn(),
getMenu: vi.fn(),
setMenu: vi.fn(),
setIcon: vi.fn(),
} as any,
},
dialog: {
showErrorBox: vi.fn(),
Expand Down Expand Up @@ -95,3 +108,8 @@ vi.mock('@/services/telemetry', async () => {
promptMetricsConsent: vi.fn().mockResolvedValue(true),
};
});

// Initialize ComfySettings for tests
beforeEach(async () => {
await ComfySettings.load('/test/path');
});
Loading