Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ publish:
owner: Comfy-Org
repo: Comfy-Desktop
artifactName: Comfy-Desktop-${version}-${os}-${arch}.${ext}
protocols:
- name: Comfy Desktop
schemes:
- comfy
win:
target: nsis
icon: assets/Comfy_Logo_x256.png
Expand Down
169 changes: 161 additions & 8 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import todesktop from '@todesktop/runtime'
import * as ipc from './lib/ipc'
import { getAppVersion } from './lib/ipc'
import type { ExitCallbackInfo } from './lib/ipc'
import { startDesktopBeacon, stopDesktopBeacon } from './lib/desktopBeacon'
import { closeAllPopouts } from './lib/popoutWindows'
import { disposeAllTerminals } from './lib/terminal'
import * as updater from './lib/updater'
Expand Down Expand Up @@ -51,7 +52,8 @@ import { registerDownloadHandlers } from './lib/ipc/registerDownloadHandlers'
import {
get as getInstallation,
installationEvents,
list as listInstallations
list as listInstallations,
CLOUD_SOURCE_ID
} from './installations'
import { startPeriodicReleaseChecks } from './lib/release-cache-startup'
import { showSplashPage } from './lib/relaunchPage'
Expand Down Expand Up @@ -86,8 +88,10 @@ import {
import { initExperiments } from './lib/experiments'
import { initCloudCapacity } from './lib/cloudCapacity'
import { initUserTier } from './lib/userTier'
import { resolveDeepLink } from './lib/deepLink'

import {
bringToFront,
claimAttachHost,
comfyWindows,
computeBodyMode,
Expand Down Expand Up @@ -1227,14 +1231,126 @@ function findInstallationIdForWindow(win: BrowserWindow): string | undefined {
return undefined
}

// ---------------------------------------------------------------------------
// `comfy://` deep links
//
// An OS `comfy://open?path=/workflows/123` link opens (or focuses) the app and
// loads `https://cloud.comfy.org/workflows/123` in the Comfy Cloud host window.
// The raw string is untrusted: `resolveDeepLink` allowlists the resolved origin
// to cloud.comfy.org and returns `null` for anything else (other scheme/origin,
// `//evil.com` tricks, relative paths). We never navigate to a non-cloud URL.
// ---------------------------------------------------------------------------

/** A `comfy://` URL received before `app.whenReady()` resolved (macOS
* `open-url` can fire at cold start). Replayed once the app is ready. */
let pendingDeepLink: string | null = null
/** Flipped true once `whenReady` has run so `handleDeepLink` knows whether to
* act immediately or buffer. */
let appIsReady = false

/** Pull the first `comfy://` argument out of a process argv array (the shape
* Windows/Linux deliver a deep link in — at cold start and on second-instance). */
function findDeepLinkArg(argv: readonly string[]): string | null {
return argv.find((arg) => arg.startsWith('comfy://')) ?? null
}

/**
* Resolve, then route a raw `comfy://` deep link to the cloud host window.
* Invalid links (per `resolveDeepLink`) are ignored. Buffers until the app is
* ready so a cold-start link isn't dropped.
*/
function handleDeepLink(rawUrl: string): void {
const target = resolveDeepLink(rawUrl)
if (!target) {
console.warn('[deeplink] ignored untrusted or malformed link', { rawUrl })
return
}
if (!appIsReady) {
pendingDeepLink = target
return
}
void routeCloudDeepLink(target).catch((err) => {
console.warn('[deeplink] failed to route link', err)
})
}

/**
* Navigate the Comfy Cloud host to `targetUrl`.
* - If a cloud host window already exists, focus it and point its comfyView at
* the URL (covers the already-running case `handleLaunch` would refuse).
* - Otherwise stamp the URL onto the cloud install and launch it through the
* normal cloud source path, which opens the host window via `onLaunch`.
*/
/** Open the normal cold-start surface (dashboard / restore-last) unless a
* cold-start `comfy://` deep link is pending — in which case go straight to
* the cloud window so the user doesn't see the dashboard and the deep-linked
* cloud window pop up side-by-side. */
function openColdStartSurface(): void {
if (pendingDeepLink !== null) {
const link = pendingDeepLink
pendingDeepLink = null
void routeCloudDeepLink(link).catch((err) => {
console.warn('[deeplink] failed to route buffered link', err)
})
return
}
void openStartupSurface()
}

async function routeCloudDeepLink(targetUrl: string): Promise<void> {
for (const entry of comfyWindows.values()) {
if (entry.window.isDestroyed()) continue
if (entry.sourceCategory !== 'cloud') continue
entry.comfyUrl = targetUrl
bringToFront(entry.window)
if (!entry.comfyView.webContents.isDestroyed()) {
void entry.comfyView.webContents.loadURL(targetUrl).catch(() => {})
}
return
}

// No live cloud host — launch the cloud install pointed at the target URL.
const all = await listInstallations()
const cloudInstall = all.find((i) => i.sourceId === CLOUD_SOURCE_ID)
if (!cloudInstall) {
console.warn('[deeplink] no cloud installation to launch')
return
}
const inst = (await updateInstallation(cloudInstall.id, { remoteUrl: targetUrl })) ?? cloudInstall
const stubSender = {
isDestroyed: () => false,
send: () => {}
} as unknown as Electron.WebContents
const stubEvent = { sender: stubSender } as unknown as Electron.IpcMainInvokeEvent
await handleLaunch({
event: stubEvent,
installationId: inst.id,
inst,
actionData: undefined
})
}

// macOS delivers `comfy://` links via `open-url`, which can fire BEFORE
// `whenReady` at cold start — register it eagerly so the link is buffered.
app.on('open-url', (event, url) => {
event.preventDefault()
handleDeepLink(url)
})

if (app.isPackaged && !app.requestSingleInstanceLock()) {
app.quit()
} else {
if (app.isPackaged) {
app.on('second-instance', () => {
// OS-level "open another instance" attempt — focus an existing
// host window (chooser or install-backed) instead of stacking
// a duplicate.
app.on('second-instance', (_event, argv) => {
// OS-level "open another instance" attempt. On Windows/Linux a
// `comfy://` deep link is delivered as the second instance's argv —
// route it instead of merely focusing. Otherwise just focus an
// existing host window rather than stacking a duplicate.
const deepLink = findDeepLinkArg(argv)
if (deepLink) {
handleDeepLink(deepLink)
return
}
openOrFocusAnyHostWindow()
})
}
Expand Down Expand Up @@ -1267,6 +1383,31 @@ if (app.isPackaged && !app.requestSingleInstanceLock()) {
})

app.whenReady().then(async () => {
// Register the app as the OS handler for `comfy://` links. On an
// unpackaged dev build the executable is Electron itself, so the
// launcher path + the app entry argv must be forwarded explicitly
// (Windows especially); packaged builds resolve to the real app.
if (process.defaultApp && process.argv.length >= 2) {
app.setAsDefaultProtocolClient('comfy', process.execPath, [path.resolve(process.argv[1]!)])
} else {
app.setAsDefaultProtocolClient('comfy')
}

// Localhost discovery beacon: lets cloud.comfy.org detect Desktop and
// hand off `?share=…` / canvas URLs without spamming the OS scheme
// prompt at users who don't have Desktop. Fire-and-forget — a failed
// bind silently disables discovery for the session rather than
// crashing the app.
void startDesktopBeacon(app.getVersion()).catch((err) => {
console.warn('[beacon] failed to start', err)
})

// Windows/Linux cold start: a `comfy://` link the OS used to launch us
// arrives in argv rather than via `open-url`. Buffer it now; it replays
// once the app finishes coming up (below, after host factories wire up).
const coldStartLink = findDeepLinkArg(process.argv)
if (coldStartLink) pendingDeepLink = resolveDeepLink(coldStartLink)

// Test-only hooks for the E2E suite. Registered before any host
// opens so seeded state (downloads, install-update overrides,
// app-update state) is visible to the very first title-bar paint.
Expand Down Expand Up @@ -2063,7 +2204,7 @@ if (app.isPackaged && !app.requestSingleInstanceLock()) {
clearQuitReason()
if (updateSplash && !updateSplash.isDestroyed()) updateSplash.destroy()
mainTelemetry.emit('comfy.desktop.app_update.startup_install_backstop_recovered', {})
void openStartupSurface()
openColdStartSurface()
}, STARTUP_INSTALL_QUIT_BACKSTOP_MS)
} else {
app.removeListener('before-quit', onUpdateInstallQuit)
Expand All @@ -2073,8 +2214,10 @@ if (app.isPackaged && !app.requestSingleInstanceLock()) {
// when launched, and the chooser host is the entry-point for
// picking / creating installs. When the user last left an instance
// window (and the reopen setting is on), restore that instance
// in-place on top of the freshly-opened chooser host.
void openStartupSurface()
// in-place on top of the freshly-opened chooser host. A pending
// cold-start deep link short-circuits the dashboard inside
// openColdStartSurface and goes straight to the cloud window.
openColdStartSurface()
}

// Single subscription rebroadcasts every install-list mutation
Expand Down Expand Up @@ -2119,6 +2262,12 @@ if (app.isPackaged && !app.requestSingleInstanceLock()) {
? { intervalMs: _periodicIntervalMs }
: {})
})

// App is fully up. Subsequent `comfy://` arrivals (open-url on macOS,
// second-instance argv on Windows/Linux) route directly via
// `handleDeepLink` now that `appIsReady` is true; cold-start links are
// already handled by `openColdStartSurface` above.
appIsReady = true
})

app.on('activate', () => {
Expand All @@ -2136,6 +2285,10 @@ if (app.isPackaged && !app.requestSingleInstanceLock()) {
})

app.on('before-quit', () => {
// The beacon owns a `node:http` server bound to 127.0.0.1; close it
// unconditionally so its handle doesn't outlive the main process
// across an `app.relaunch`.
void stopDesktopBeacon()
if (!isQuitInProgress()) {
setQuitReason('user-quit')
ipc.cancelAll()
Expand Down
117 changes: 117 additions & 0 deletions src/main/lib/deepLink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import { resolveDeepLink } from './deepLink'

describe('resolveDeepLink', () => {
describe('valid links', () => {
it('resolves a path link to the cloud origin', () => {
expect(resolveDeepLink('comfy://open?path=/workflows/123')).toBe(
'https://cloud.comfy.org/workflows/123'
)
})

it('resolves the bare cloud root path', () => {
expect(resolveDeepLink('comfy://open?path=/')).toBe('https://cloud.comfy.org/')
})

it('preserves a query string on a path link', () => {
// The query rides along in the `path` value. A fragment cannot: an
// unencoded `#` belongs to the outer `comfy://` URL, so anything that
// needs a hash must use the encoded `url=` form below.
expect(resolveDeepLink('comfy://open?path=/workflows/123?tab=runs')).toBe(
'https://cloud.comfy.org/workflows/123?tab=runs'
)
})

it('preserves query and hash via an encoded url param', () => {
const target = 'https://cloud.comfy.org/workflows/123?tab=runs#top'
expect(resolveDeepLink(`comfy://open?url=${encodeURIComponent(target)}`)).toBe(target)
})

it('resolves a full cloud url param', () => {
expect(resolveDeepLink('comfy://open?url=https://cloud.comfy.org/x')).toBe(
'https://cloud.comfy.org/x'
)
})

it('prefers url over path when both are present', () => {
expect(
resolveDeepLink('comfy://open?url=https://cloud.comfy.org/a&path=/b')
).toBe('https://cloud.comfy.org/a')
})
})

describe('rejected: wrong scheme', () => {
it('rejects https scheme', () => {
expect(resolveDeepLink('https://cloud.comfy.org/workflows/123')).toBeNull()
})

it('rejects http scheme', () => {
expect(resolveDeepLink('http://cloud.comfy.org/workflows/123')).toBeNull()
})

it('rejects file scheme', () => {
expect(resolveDeepLink('file:///etc/passwd')).toBeNull()
})

it('rejects javascript scheme', () => {
expect(resolveDeepLink('javascript:alert(1)')).toBeNull()
})
})

describe('rejected: disallowed origin', () => {
it('rejects a non-cloud origin in url param', () => {
expect(resolveDeepLink('comfy://open?url=https://evil.com')).toBeNull()
})

it('rejects a non-cloud origin with a cloud-looking subdomain', () => {
expect(
resolveDeepLink('comfy://open?url=https://cloud.comfy.org.evil.com/x')
).toBeNull()
})

it('rejects an http cloud url param (origin includes scheme)', () => {
expect(resolveDeepLink('comfy://open?url=http://cloud.comfy.org/x')).toBeNull()
})
})

describe('rejected: path tricks', () => {
it('rejects protocol-relative path', () => {
expect(resolveDeepLink('comfy://open?path=//evil.com')).toBeNull()
})

it('rejects an absolute https url passed as path', () => {
expect(resolveDeepLink('comfy://open?path=https://evil.com')).toBeNull()
})

it('rejects a backslash trick path', () => {
expect(resolveDeepLink('comfy://open?path=/\\evil.com')).toBeNull()
})

it('rejects a relative path', () => {
expect(resolveDeepLink('comfy://open?path=workflows/123')).toBeNull()
})

it('rejects a missing path/url', () => {
expect(resolveDeepLink('comfy://open')).toBeNull()
})

it('rejects an empty path', () => {
expect(resolveDeepLink('comfy://open?path=')).toBeNull()
})
})

describe('rejected: malformed input', () => {
it('rejects an empty string', () => {
expect(resolveDeepLink('')).toBeNull()
})

it('rejects garbage', () => {
expect(resolveDeepLink('not a url at all')).toBeNull()
})

it('rejects a non-string input', () => {
// @ts-expect-error exercising the runtime guard against non-string input
expect(resolveDeepLink(undefined)).toBeNull()
})
})
})
Loading
Loading