Skip to content

Commit e2dfb87

Browse files
feat: detect ComfyUI Desktop installs and add desktop source plugin (#84)
* feat: detect ComfyUI Desktop installs and add desktop source plugin - Add desktopDetect.ts: reads Desktop's config.json from platform-specific userData path, validates basePath, locates executable - Add desktop source plugin: hidden, skipInstall, Win/Mac only. Supports probing Desktop basePaths, launching via shell.openPath, opening data folder - Add detect-desktop-install IPC handler for renderer integration - Extend findComfyUIDir to recognize Desktop basePath layout (models/ + user/ + custom_nodes/ directly in path) for migration compatibility - Add 15 tests covering detection, executable lookup, and probe heuristics Amp-Thread-ID: https://ampcode.com/threads/T-019cadf9-1231-738c-8421-ba4c41cef4e7 Co-authored-by: Amp <amp@ampcode.com> * feat: auto-track Desktop install on Launcher startup Call detectDesktopInstall() during startup and ensureExists() to automatically surface the Desktop installation in the installs list. Uses sourceId 'desktop' as a singleton key so it's only created once. Amp-Thread-ID: https://ampcode.com/threads/T-019cadf9-1231-738c-8421-ba4c41cef4e7 Co-authored-by: Amp <amp@ampcode.com> * feat: sync shared model paths into Desktop's extra_models_config.yaml - Add syncSharedModelPaths() to inject Launcher's shared model directories into Desktop's extra_models_config.yaml, preserving Desktop's own sections - Call sync on startup when Desktop is detected and on modelsDirs setting change - Remove dead code: detect-desktop-install IPC handler, autoDetectDesktop export - Add 5 tests for syncSharedModelPaths Amp-Thread-ID: https://ampcode.com/threads/T-019caee3-d546-775c-89f1-ed6ef60e10c2 Co-authored-by: Amp <amp@ampcode.com> * refactor: remove redundant platform guards around detectDesktopInstall calls Amp-Thread-ID: https://ampcode.com/threads/T-019caee3-d546-775c-89f1-ed6ef60e10c2 Co-authored-by: Amp <amp@ampcode.com> * feat: Desktop-to-Standalone migration with snapshot preview Add migrate-to-standalone action to the desktop source plugin that: - Captures a snapshot of the Desktop installation (custom nodes, pip packages) - Shows a rich preview in the confirmation dialog (version grid, collapsible node list with status indicators, pip packages) - Creates a new Standalone installation and restores the snapshot - Copies workflows, settings, input/output to shared directories - Adds Desktop models directory to shared model paths Implementation details: - Add snapshotPreview field to modal confirm() for rich data previews - Add preview-desktop-migration IPC handler with temp file lifecycle management - Add captureDesktopSnapshot() with pip freeze and custom node scanning - Add sp-* CSS classes and modal-box-wide for snapshot preview styling - Add unit tests for captureDesktopSnapshot and modal messageDetails Amp-Thread-ID: https://ampcode.com/threads/T-019cb430-c12a-7607-aa34-1311497cf320 Co-authored-by: Amp <amp@ampcode.com> * feat: launch Desktop as child process with skipPipSync, skipPortWait, and Show Window support - Add skipPipSync flag to Snapshot: when set, pip packages are not force-synced during restore (node deps still installed via requirements.txt/install.py) - Desktop migration sets skipPipSync since environments differ - Launch Desktop as tracked child process instead of detached shell.openPath - Add skipPortWait to LaunchCommand: spawns and immediately registers session without port conflict detection or readiness waiting - Add showWindow to LaunchCommand: disables windowsHide so Desktop's Electron window appears - Add focusExternalProcessWindow for Show Window support on Desktop (wscript AppActivate on Windows, osascript on macOS) - Clean exit (code 0) from skipPortWait processes is not treated as crash - Skip launch progress modal for Desktop (instant spawn) - Fix i18n: actions.openFolder -> actions.openDirectory - Remove ellipsis from desktop.migrating progress title Amp-Thread-ID: https://ampcode.com/threads/T-019cbf7c-f57d-71b9-b8a3-57afcec04628 Co-authored-by: Amp <amp@ampcode.com> * fix: resolve relative basePath in desktop config and normalize modelsDirs for dedup Amp-Thread-ID: https://ampcode.com/threads/T-019cc06b-c6d9-72d0-b2a8-dcf89888256c Co-authored-by: Amp <amp@ampcode.com> * fix: remove no-explicit-any lint warning in desktopDetect test Amp-Thread-ID: https://ampcode.com/threads/T-019cc06b-c6d9-72d0-b2a8-dcf89888256c Co-authored-by: Amp <amp@ampcode.com> * fix: use path.resolve for basePath to fix CI test on Linux Amp-Thread-ID: https://ampcode.com/threads/T-019cc06b-c6d9-72d0-b2a8-dcf89888256c Co-authored-by: Amp <amp@ampcode.com> * fix: use path.resolve in test expectations to match impl on all platforms Amp-Thread-ID: https://ampcode.com/threads/T-019cc06b-c6d9-72d0-b2a8-dcf89888256c Co-authored-by: Amp <amp@ampcode.com> --------- Co-authored-by: Amp <amp@ampcode.com>
1 parent 772246f commit e2dfb87

19 files changed

Lines changed: 1264 additions & 14 deletions

File tree

locales/en.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,30 @@
400400
"desc": "Connect to Comfy Cloud for remote GPU-powered workflows."
401401
},
402402

403+
"desktop": {
404+
"label": "Desktop",
405+
"desc": "Detected ComfyUI Desktop installation.",
406+
"installInfo": "Desktop Install",
407+
"basePath": "Data Path",
408+
"executable": "Executable",
409+
"tracked": "Tracked Since",
410+
"openApp": "Open Desktop App",
411+
"notFound": "ComfyUI Desktop executable not found.",
412+
"migrateToStandalone": "Migrate to Standalone",
413+
"migrateConfirmTitle": "Migrate Desktop to Standalone",
414+
"migrateConfirmMessage": "This will create a new Standalone installation with your Desktop custom nodes, workflows, and settings. Your Desktop installation will not be modified.",
415+
"migrateConfirm": "Start Migration",
416+
"migrating": "Migrating Desktop to Standalone",
417+
"scanningDesktop": "Scanning Desktop installation…",
418+
"creatingSnapshot": "Creating Desktop snapshot…",
419+
"copyingUserData": "Copying workflows and settings…",
420+
"copyingInput": "Copying input files…",
421+
"copyingOutput": "Copying output files…",
422+
"addingModels": "Adding Desktop models to shared paths…",
423+
"migrateComplete": "Migration complete",
424+
"migrateNoData": "No data found to migrate."
425+
},
426+
403427
"standalone": {
404428
"_note": "standalone.label and portable.label are product names — keep in English across all translations.",
405429
"label": "Standalone",

src/main/index.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { app, BrowserWindow, Tray, Menu, ipcMain, shell, clipboard, screen } from 'electron'
22
import path from 'path'
33
import fs from 'fs'
4+
import { execFile } from 'child_process'
45
import type { ChildProcess } from 'child_process'
56
import todesktop from '@todesktop/runtime'
67
import * as ipc from './lib/ipc'
@@ -139,6 +140,22 @@ function attachContextMenu(comfyWindow: BrowserWindow): void {
139140
let launcherWindow: BrowserWindow | null = null
140141
let tray: Tray | null = null
141142
const comfyWindows = new Map<string, BrowserWindow>()
143+
144+
function focusExternalProcessWindow(pid: number): void {
145+
if (process.platform === 'win32') {
146+
// AppActivate accepts a numeric PID to bring the process window to the foreground.
147+
// wscript is near-instant compared to PowerShell.
148+
const vbsPath = path.join(app.getPath('temp'), `comfy-focus-${pid}.vbs`)
149+
fs.writeFileSync(vbsPath, `CreateObject("WScript.Shell").AppActivate ${pid}`)
150+
execFile('wscript.exe', ['//Nologo', '//B', vbsPath], { windowsHide: true }, () => {
151+
fs.unlink(vbsPath, () => {})
152+
})
153+
} else if (process.platform === 'darwin') {
154+
execFile('osascript', ['-e',
155+
`tell application "System Events" to set frontmost of (first process whose unix id is ${pid}) to true`,
156+
], () => {})
157+
}
158+
}
142159
let processErrorHandlersRegistered = false
143160

144161
function serializeUnknownError(error: unknown): { message: string; stack?: string } {
@@ -410,12 +427,7 @@ function onLaunch({ port, url, process: proc, installation, mode }: {
410427
const comfyUrl = url || `http://127.0.0.1:${port}`
411428
const installationId = installation.id
412429

413-
if (mode === 'console') {
414-
if (proc) {
415-
proc.on('exit', () => {
416-
comfyWindows.delete(installationId)
417-
})
418-
}
430+
if (mode === 'console' || mode === 'external') {
419431
return
420432
}
421433

@@ -552,6 +564,14 @@ ipcMain.handle('focus-comfy-window', (_event, installationId: string) => {
552564
win.focus()
553565
return true
554566
}
567+
568+
// For external processes (e.g. Desktop), bring the child process window to front
569+
const proc = ipc.getSessionProcess(installationId)
570+
if (proc?.pid) {
571+
focusExternalProcessWindow(proc.pid)
572+
return true
573+
}
574+
555575
return false
556576
})
557577

src/main/lib/desktopDetect.test.ts

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import path from 'path'
2+
import { describe, expect, it, vi, beforeEach, type MockInstance } from 'vitest'
3+
import fs from 'fs'
4+
5+
vi.mock('electron', () => ({
6+
app: { getPath: () => '' },
7+
}))
8+
9+
vi.mock('./nodes', () => ({
10+
scanCustomNodes: vi.fn().mockResolvedValue([]),
11+
}))
12+
13+
import { detectDesktopInstall, findDesktopExecutable, syncSharedModelPaths, captureDesktopSnapshot } from './desktopDetect'
14+
import type { DesktopInstallInfo } from './desktopDetect'
15+
16+
describe('detectDesktopInstall', () => {
17+
let readFileSyncSpy: MockInstance
18+
let existsSyncSpy: MockInstance
19+
20+
beforeEach(() => {
21+
vi.restoreAllMocks()
22+
readFileSyncSpy = vi.spyOn(fs, 'readFileSync')
23+
existsSyncSpy = vi.spyOn(fs, 'existsSync')
24+
delete process.env.APPDATA
25+
delete process.env.LOCALAPPDATA
26+
})
27+
28+
it('returns null on unsupported platforms', () => {
29+
vi.stubGlobal('process', { ...process, platform: 'linux', env: {} })
30+
expect(detectDesktopInstall()).toBeNull()
31+
vi.unstubAllGlobals()
32+
})
33+
34+
it('returns null when APPDATA is not set on Windows', () => {
35+
vi.stubGlobal('process', { ...process, platform: 'win32', env: {} })
36+
expect(detectDesktopInstall()).toBeNull()
37+
vi.unstubAllGlobals()
38+
})
39+
40+
it('returns null when config.json does not exist', () => {
41+
vi.stubGlobal('process', { ...process, platform: 'win32', env: { APPDATA: '/mock/AppData/Roaming' } })
42+
readFileSyncSpy.mockImplementation(() => { throw new Error('ENOENT') })
43+
expect(detectDesktopInstall()).toBeNull()
44+
vi.unstubAllGlobals()
45+
})
46+
47+
it('returns null when config.json has no basePath', () => {
48+
vi.stubGlobal('process', { ...process, platform: 'win32', env: { APPDATA: '/mock/AppData/Roaming' } })
49+
readFileSyncSpy.mockReturnValue('{"installState":"installed"}')
50+
expect(detectDesktopInstall()).toBeNull()
51+
vi.unstubAllGlobals()
52+
})
53+
54+
it('returns null when basePath does not exist on disk', () => {
55+
vi.stubGlobal('process', { ...process, platform: 'win32', env: { APPDATA: '/mock/AppData/Roaming' } })
56+
readFileSyncSpy.mockReturnValue(JSON.stringify({ basePath: '/mock/Documents/ComfyUI' }))
57+
existsSyncSpy.mockReturnValue(false)
58+
expect(detectDesktopInstall()).toBeNull()
59+
vi.unstubAllGlobals()
60+
})
61+
62+
it('returns info when a valid Desktop install is found', () => {
63+
const appData = '/mock/AppData/Roaming'
64+
const localAppData = '/mock/AppData/Local'
65+
const configDir = path.join(appData, 'ComfyUI')
66+
// Use path.resolve so the expected value matches what the implementation produces
67+
const basePath = path.resolve(configDir, '/mock/Documents/ComfyUI')
68+
vi.stubGlobal('process', {
69+
...process,
70+
platform: 'win32',
71+
env: { APPDATA: appData, LOCALAPPDATA: localAppData },
72+
})
73+
74+
readFileSyncSpy.mockReturnValue(JSON.stringify({ basePath: '/mock/Documents/ComfyUI' }))
75+
existsSyncSpy.mockImplementation((p: fs.PathLike) => {
76+
const s = p.toString()
77+
if (s === basePath) return true
78+
if (s === path.join(basePath, 'models')) return true
79+
if (s === path.join(basePath, 'user')) return true
80+
if (s === path.join(basePath, '.venv')) return true
81+
return false
82+
})
83+
84+
const result = detectDesktopInstall()
85+
expect(result).not.toBeNull()
86+
expect(result!.basePath).toBe(basePath)
87+
expect(result!.hasVenv).toBe(true)
88+
vi.unstubAllGlobals()
89+
})
90+
91+
it('returns info with hasVenv false when .venv is missing', () => {
92+
const appData = '/mock/AppData/Roaming'
93+
const configDir = path.join(appData, 'ComfyUI')
94+
const basePath = path.resolve(configDir, '/mock/Documents/ComfyUI')
95+
vi.stubGlobal('process', {
96+
...process,
97+
platform: 'win32',
98+
env: { APPDATA: appData },
99+
})
100+
101+
readFileSyncSpy.mockReturnValue(JSON.stringify({ basePath: '/mock/Documents/ComfyUI' }))
102+
existsSyncSpy.mockImplementation((p: fs.PathLike) => {
103+
const s = p.toString()
104+
if (s === basePath) return true
105+
if (s === path.join(basePath, 'models')) return true
106+
if (s === path.join(basePath, 'user')) return true
107+
return false
108+
})
109+
110+
const result = detectDesktopInstall()
111+
expect(result).not.toBeNull()
112+
expect(result!.hasVenv).toBe(false)
113+
vi.unstubAllGlobals()
114+
})
115+
})
116+
117+
describe('findDesktopExecutable', () => {
118+
let existsSyncSpy: MockInstance
119+
120+
beforeEach(() => {
121+
vi.restoreAllMocks()
122+
existsSyncSpy = vi.spyOn(fs, 'existsSync')
123+
})
124+
125+
it('returns null on unsupported platforms', () => {
126+
vi.stubGlobal('process', { ...process, platform: 'linux', env: {} })
127+
expect(findDesktopExecutable()).toBeNull()
128+
vi.unstubAllGlobals()
129+
})
130+
131+
it('returns executable path on Windows when it exists', () => {
132+
const localAppData = '/mock/AppData/Local'
133+
vi.stubGlobal('process', { ...process, platform: 'win32', env: { LOCALAPPDATA: localAppData } })
134+
const expected = path.join(localAppData, 'Programs', 'ComfyUI', 'ComfyUI.exe')
135+
existsSyncSpy.mockImplementation((p: fs.PathLike) => p.toString() === expected)
136+
expect(findDesktopExecutable()).toBe(expected)
137+
vi.unstubAllGlobals()
138+
})
139+
140+
it('returns null on Windows when executable does not exist', () => {
141+
vi.stubGlobal('process', {
142+
...process,
143+
platform: 'win32',
144+
env: { LOCALAPPDATA: '/mock/AppData/Local' },
145+
})
146+
existsSyncSpy.mockReturnValue(false)
147+
expect(findDesktopExecutable()).toBeNull()
148+
vi.unstubAllGlobals()
149+
})
150+
})
151+
152+
describe('syncSharedModelPaths', () => {
153+
let readFileSyncSpy: MockInstance
154+
let writeFileSyncSpy: MockInstance
155+
let mkdirSyncSpy: MockInstance
156+
157+
beforeEach(() => {
158+
vi.restoreAllMocks()
159+
readFileSyncSpy = vi.spyOn(fs, 'readFileSync')
160+
writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {})
161+
mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined)
162+
})
163+
164+
it('creates config with launcher sections when file does not exist', () => {
165+
readFileSyncSpy.mockImplementation(() => { throw new Error('ENOENT') })
166+
167+
syncSharedModelPaths('/config/ComfyUI', ['/shared/models'])
168+
169+
expect(mkdirSyncSpy).toHaveBeenCalledWith('/config/ComfyUI', { recursive: true })
170+
expect(writeFileSyncSpy).toHaveBeenCalledOnce()
171+
const written = writeFileSyncSpy.mock.calls[0]![1] as string
172+
expect(written).toContain('comfyui_launcher_0:')
173+
expect(written).toContain('checkpoints: checkpoints/')
174+
expect(written).toContain('loras: loras/')
175+
})
176+
177+
it('preserves existing Desktop sections and appends launcher sections', () => {
178+
readFileSyncSpy.mockReturnValue(
179+
'comfyui_desktop:\n base_path: /docs/ComfyUI\n is_default: true\n'
180+
)
181+
182+
syncSharedModelPaths('/config/ComfyUI', ['/shared/models'])
183+
184+
const written = writeFileSyncSpy.mock.calls[0]![1] as string
185+
expect(written).toContain('comfyui_desktop:')
186+
expect(written).toContain('base_path: /docs/ComfyUI')
187+
expect(written).toContain('comfyui_launcher_0:')
188+
})
189+
190+
it('replaces existing launcher sections on re-sync', () => {
191+
readFileSyncSpy.mockReturnValue(
192+
'comfyui_desktop:\n base_path: /docs/ComfyUI\n\n' +
193+
'comfyui_launcher_0:\n base_path: /old/models\n checkpoints: checkpoints/\n'
194+
)
195+
196+
const newDir = path.resolve('/new/models')
197+
syncSharedModelPaths('/config/ComfyUI', ['/new/models'])
198+
199+
const written = writeFileSyncSpy.mock.calls[0]![1] as string
200+
expect(written).not.toContain('/old/models')
201+
expect(written).toContain(newDir)
202+
expect(written).toContain('comfyui_desktop:')
203+
// Should have exactly one launcher section
204+
expect(written.match(/comfyui_launcher_0:/g)).toHaveLength(1)
205+
})
206+
207+
it('handles multiple model directories', () => {
208+
readFileSyncSpy.mockImplementation(() => { throw new Error('ENOENT') })
209+
210+
syncSharedModelPaths('/config/ComfyUI', ['/models/a', '/models/b'])
211+
212+
const written = writeFileSyncSpy.mock.calls[0]![1] as string
213+
expect(written).toContain('comfyui_launcher_0:')
214+
expect(written).toContain('comfyui_launcher_1:')
215+
})
216+
217+
it('writes no launcher sections when modelsDirs is empty', () => {
218+
readFileSyncSpy.mockReturnValue(
219+
'comfyui_desktop:\n base_path: /docs/ComfyUI\n'
220+
)
221+
222+
syncSharedModelPaths('/config/ComfyUI', [])
223+
224+
const written = writeFileSyncSpy.mock.calls[0]![1] as string
225+
expect(written).toContain('comfyui_desktop:')
226+
expect(written).not.toContain('comfyui_launcher_')
227+
})
228+
})
229+
230+
describe('captureDesktopSnapshot', () => {
231+
let mockScan: ReturnType<typeof vi.fn>
232+
233+
beforeEach(async () => {
234+
vi.restoreAllMocks()
235+
const nodes = await import('./nodes')
236+
mockScan = vi.mocked(nodes.scanCustomNodes)
237+
mockScan.mockResolvedValue([])
238+
})
239+
240+
it('returns a valid snapshot with empty nodes when no custom nodes exist', async () => {
241+
vi.spyOn(fs, 'existsSync').mockReturnValue(false)
242+
const info: DesktopInstallInfo = {
243+
configDir: '/config/ComfyUI',
244+
basePath: '/data/ComfyUI',
245+
executablePath: null,
246+
hasVenv: false,
247+
}
248+
249+
const snapshot = await captureDesktopSnapshot(info)
250+
251+
expect(snapshot.version).toBe(1)
252+
expect(snapshot.trigger).toBe('manual')
253+
expect(snapshot.label).toBe('Desktop migration')
254+
expect(snapshot.comfyui.ref).toBe('desktop')
255+
expect(snapshot.customNodes).toEqual([])
256+
expect(snapshot.pipPackages).toEqual({})
257+
})
258+
259+
it('scans custom nodes from basePath', async () => {
260+
vi.spyOn(fs, 'existsSync').mockReturnValue(false)
261+
const fakeNodes = [
262+
{ id: 'test-node', type: 'cnr' as const, dirName: 'test-node', enabled: true, version: '1.0' },
263+
]
264+
mockScan.mockResolvedValue(fakeNodes)
265+
266+
const info: DesktopInstallInfo = {
267+
configDir: '/config/ComfyUI',
268+
basePath: '/data/ComfyUI',
269+
executablePath: null,
270+
hasVenv: false,
271+
}
272+
273+
const snapshot = await captureDesktopSnapshot(info)
274+
275+
expect(mockScan).toHaveBeenCalledWith('/data/ComfyUI')
276+
expect(snapshot.customNodes).toEqual(fakeNodes)
277+
})
278+
279+
it('skips pip freeze when no venv exists', async () => {
280+
vi.spyOn(fs, 'existsSync').mockReturnValue(false)
281+
const info: DesktopInstallInfo = {
282+
configDir: '/config/ComfyUI',
283+
basePath: '/data/ComfyUI',
284+
executablePath: null,
285+
hasVenv: false,
286+
}
287+
288+
const snapshot = await captureDesktopSnapshot(info)
289+
290+
expect(snapshot.pipPackages).toEqual({})
291+
})
292+
})

0 commit comments

Comments
 (0)