Compare commits

...

14 Commits

Author SHA1 Message Date
Rizumu Ayaka
08aca84c1f fix build error 2025-10-22 17:17:02 +09:00
Rizumu Ayaka
cc544d5fa5 update Vue dependency to minor 20b5240 2025-10-22 15:32:28 +09:00
Rizumu Ayaka
a2c3ee29b5 perf: vue vapor with LGraphNode.vue 2025-10-20 16:12:57 +09:00
Christian Byrne
26f587c956 [auth] add service worker on cloud distribution to attach auth header to browser native /view requests (#6139)
## Summary

Added Service Worker to inject Firebase auth headers into browser-native
`/api/view` requests (img, video, audio tags) for cloud distribution.

## Changes

- **What**: Implemented [Service
Worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)
to intercept and authenticate media requests that cannot natively send
custom headers
- **Dependencies**: None (uses native Service Worker API)

## Implementation Details

**Tree-shaking**: Uses compile-time `isCloud` constant - completely
removed from localhost/desktop builds (verified via bundle analysis).
Verify yourself by building the app and `grep -r
"registerAuthServiceWorker\|setupAuth" dist/`
**Caching**: 50-minute auth header cache with automatic invalidation on
login/logout to prevent redundant token fetches.

**Message Flow**:
```mermaid
sequenceDiagram
    participant IMG as Browser
    participant SW as Service Worker
    participant MT as Main Thread
    participant FB as Firebase Auth

    IMG->>SW: GET /api/view/image.png
    SW->>SW: Check cache (50min TTL)
    alt Cache miss
        SW->>MT: REQUEST_AUTH_HEADER
        MT->>FB: getAuthHeader()
        FB-->>MT: Bearer token
        MT-->>SW: AUTH_HEADER_RESPONSE
        SW->>SW: Cache token
    end
    SW->>IMG: Fetch with Authorization header

    Note over SW,MT: On login/logout: INVALIDATE_AUTH_HEADER
```

## Review Focus

- **Same-origin mode**: Service Worker uses `mode: 'same-origin'` to
allow custom headers (browser-native requests default to `no-cors` which
strips headers)
- **Request deduplication**: Prevents concurrent auth header requests
from timing out
- **Build verification**: Confirm `register-*.js` absent in localhost
builds, present (~3.2KB) in cloud builds

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6139-auth-add-service-worker-on-cloud-distribution-to-attach-auth-header-to-browser-native--2916d73d3650812698dccd07d943ab3c)
by [Unito](https://www.unito.io)
2025-10-19 22:51:37 -07:00
Christian Byrne
7ad1112535 add telemetry provider for cloud distribution (#6154)
## Summary

This code is entirely excluded from open-source, local, and desktop
builds. During minification and dead-code elimination, the Mixpanel
library is fully tree-shaken -- meaning no telemetry code is ever
included or downloaded in those builds. Even the inline callsites are
removed during the build (because `isCloud` becomes false and the entire
block becomes dead code and is removed). The code not only has no
effect, is not even distributed in the first place. We’ve gone to great
lengths to ensure this behavior.

Verification proof:


https://github.com/user-attachments/assets/b66c35f7-e233-447f-93da-4d70c433908d

Telemetry is *enabled only in the ComfyUI Cloud environment*. Its goal
is to help us understand and improve onboarding and new-user adoption.
ComfyUI aims to be accessible to everyone, but we know the learning
curve can be steep. Anonymous usage insights will help us identify where
users struggle and guide us toward making the experience more intuitive
and welcoming.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6154-add-telemetry-provider-for-cloud-distribution-2926d73d3650813cb9ccfb3a2733848b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-19 19:47:35 -07:00
Christian Byrne
522656a2dc [style] remove hover effect on Vue node socket labels (#6150)
## Summary

Align with the design by removing hover state. Hover state should not be
applied if clicking doesn't actually do anything.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6150-style-remove-hover-effect-on-Vue-node-socket-labels-2916d73d365081158edef8065edc42e8)
by [Unito](https://www.unito.io)
2025-10-19 14:29:03 -07:00
Christian Byrne
15d223ef9b [style] adjust Vue widget hover state (#6146)
## Summary

Change to match the design (hover changes background color slightly and
doesn't affect outline/border).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6146-style-adjust-Vue-widget-hover-state-2916d73d365081a19297e77208dc9613)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-10-19 12:55:29 -07:00
Christian Byrne
c8146ffc64 Revert "fix dragging video/image components on Vue nodes triggers node drag (#5922)" (#6148)
## Summary

This PR reverts #5922 which fixed pointer capture behavior on video and
image preview components to prevent unintended node dragging.

## Changes

- Removes `data-capture-node="true"` attribute from `VideoPreview.vue`
and `ImagePreview.vue` components
- Removes pointer event delegation logic from
`useNodePointerInteractions.ts` composable
- Restores previous drag behavior where dragging on preview components
triggers node drag

## Reason for Revert

This changes the behavior from original Litegraph and is generally
annoying. Users would rather be able to drag the node than be able to
drag an image/video out from a node.

Reverts #5922

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6148-Revert-fix-dragging-video-image-components-on-Vue-nodes-triggers-node-drag-5922-2916d73d365081398bb5c20384e26bb8)
by [Unito](https://www.unito.io)
2025-10-19 12:28:40 -07:00
Christian Byrne
c263f6da25 [style] prevent Vue node selection outline being obscured by image output (#6061)
## Summary

Adds `px-2` on image to prevent this issue (below) - I think there's a
better solution but I'm not really sure what it is. We use outline for
selection state and it's somewhat complex how our ring/border/outline
with many different node states and interactions works right now. It
will take some CSS skill to allow the images to be totally flush.

<img width="720" height="715" alt="image"
src="https://github.com/user-attachments/assets/0283e036-7a31-45ef-b5cc-af3ac73171c9"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6061-style-prevent-Vue-node-selection-outline-being-obscured-by-image-output-28c6d73d365081d59b34d8f91252de92)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-10-19 11:52:14 -07:00
Christian Byrne
1f5191847a make "require subscription" toggleable in build (#6144)
## Summary

Adds build time feature flags system starting with a flag that indicates
whether subscription is required to use the app. This is only used on
cloud.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6144-make-require-subscription-toggleable-in-build-2916d73d3650813bb140c5e96bcce1ce)
by [Unito](https://www.unito.io)
2025-10-19 10:30:14 -07:00
sno
fc69924c4a [feat] implement dynamic imports for locale code splitting (#6076)
## Summary
- Implement dynamic imports for internationalization (i18n) locale files
to reduce initial bundle size
- Only load English locale eagerly as default/fallback, load other
locales on-demand
- Apply code splitting to both main ComfyUI frontend and desktop-ui
applications

## Technical Details
- **Before**: All locale files (main.json, nodeDefs.json, commands.json,
settings.json) for all 9 languages were bundled in the initial
JavaScript bundle
- **After**: Only English locale files are included in initial bundle,
other locales are loaded dynamically when needed
- Implemented `loadLocale()` function that uses dynamic imports with
`Promise.all()` for efficient parallel loading
- Added locale tracking with `loadedLocales` Set to prevent duplicate
loading
- Updated both `src/i18n.ts` and `apps/desktop-ui/src/i18n.ts` with
consistent implementation

## Bundle Size Impact
This change significantly reduces the initial bundle size by removing ~8
languages worth of JSON locale data from the main bundle. Locale files
are now loaded on-demand only when users switch languages.

## Implementation
- Uses dynamic imports: `import('./locales/[locale]/[file].json')`
- Maintains backward compatibility with existing locale switching
mechanism
- Graceful error handling for unsupported locales
- No breaking changes to the public API

## Test plan
- [x] Verify initial load only includes English locale
- [x] Test dynamic locale loading when switching languages in settings
- [x] Confirm fallback behavior for unsupported locales
- [x] Validate both web and desktop-ui applications work correctly

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6076-feat-implement-dynamic-imports-for-locale-code-splitting-28d6d73d36508189ae0ef060804a5cee)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-18 23:05:26 -07:00
Christian Byrne
5b5151f41f [perf] manually chunk vendored code (#6137)
## Summary

Added a `manualChunks` strategy in `vite.config.mts` that splits
primevue, tiptap, chart.js, three/@xterm, core Vue/Pinia, and the
remaining dependencies into dedicated vendor bundles. This reduces the
main application chunk size and allows browsers to cache heavy
third-party code across releases, improving load times when those
libraries stay unchanged.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6137-perf-manually-chunk-vendored-code-2916d73d36508140a44ec0de228ef9cc)
by [Unito](https://www.unito.io)
2025-10-18 22:49:11 -07:00
Christian Byrne
2018f1e671 [ci] drop console statements (except warn and error) when building app (#6123)
## Summary

Marks all the console methods besides `warn` and `error` as pure
functions so they can be dropped during DCE in build pipeline. It's
simpler to use `drop` but that would remove errors/warnings.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6123-ci-drop-console-statements-except-warn-and-error-when-building-app-2906d73d365081f28303c0d784352b12)
by [Unito](https://www.unito.io)
2025-10-18 22:43:38 -07:00
Christian Byrne
8822f186e0 [style] adjust appearance of "delete account" component to be text rather than button (#6126)
Adjusts style of "Delete Account" button.

**Before**:

<img width="731" height="925" alt="Screenshot from 2025-10-18 04-22-24"
src="https://github.com/user-attachments/assets/497de4ca-9359-41d8-b944-0d69835f43b1"
/>

**After**:

<img width="731" height="925" alt="Screenshot from 2025-10-18 04-22-14"
src="https://github.com/user-attachments/assets/f42a21fb-7d4d-43f5-b27b-1f85e4470dbd"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6126-style-adjust-appearance-of-delete-account-component-to-be-text-rather-than-button-2906d73d365081eab48ece171fa4fd8a)
by [Unito](https://www.unito.io)
2025-10-18 22:37:06 -07:00
43 changed files with 1575 additions and 294 deletions

View File

@@ -1,67 +1,163 @@
import arCommands from '@frontend-locales/ar/commands.json' with { type: 'json' }
import ar from '@frontend-locales/ar/main.json' with { type: 'json' }
import arNodes from '@frontend-locales/ar/nodeDefs.json' with { type: 'json' }
import arSettings from '@frontend-locales/ar/settings.json' with { type: 'json' }
// Import only English locale eagerly as the default/fallback
// ESLint cannot statically resolve dynamic imports with path aliases (@frontend-locales/*),
// but these are properly configured in tsconfig.json and resolved by Vite at build time.
// eslint-disable-next-line import-x/no-unresolved
import enCommands from '@frontend-locales/en/commands.json' with { type: 'json' }
// eslint-disable-next-line import-x/no-unresolved
import en from '@frontend-locales/en/main.json' with { type: 'json' }
// eslint-disable-next-line import-x/no-unresolved
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
// eslint-disable-next-line import-x/no-unresolved
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
import esCommands from '@frontend-locales/es/commands.json' with { type: 'json' }
import es from '@frontend-locales/es/main.json' with { type: 'json' }
import esNodes from '@frontend-locales/es/nodeDefs.json' with { type: 'json' }
import esSettings from '@frontend-locales/es/settings.json' with { type: 'json' }
import frCommands from '@frontend-locales/fr/commands.json' with { type: 'json' }
import fr from '@frontend-locales/fr/main.json' with { type: 'json' }
import frNodes from '@frontend-locales/fr/nodeDefs.json' with { type: 'json' }
import frSettings from '@frontend-locales/fr/settings.json' with { type: 'json' }
import jaCommands from '@frontend-locales/ja/commands.json' with { type: 'json' }
import ja from '@frontend-locales/ja/main.json' with { type: 'json' }
import jaNodes from '@frontend-locales/ja/nodeDefs.json' with { type: 'json' }
import jaSettings from '@frontend-locales/ja/settings.json' with { type: 'json' }
import koCommands from '@frontend-locales/ko/commands.json' with { type: 'json' }
import ko from '@frontend-locales/ko/main.json' with { type: 'json' }
import koNodes from '@frontend-locales/ko/nodeDefs.json' with { type: 'json' }
import koSettings from '@frontend-locales/ko/settings.json' with { type: 'json' }
import ruCommands from '@frontend-locales/ru/commands.json' with { type: 'json' }
import ru from '@frontend-locales/ru/main.json' with { type: 'json' }
import ruNodes from '@frontend-locales/ru/nodeDefs.json' with { type: 'json' }
import ruSettings from '@frontend-locales/ru/settings.json' with { type: 'json' }
import trCommands from '@frontend-locales/tr/commands.json' with { type: 'json' }
import tr from '@frontend-locales/tr/main.json' with { type: 'json' }
import trNodes from '@frontend-locales/tr/nodeDefs.json' with { type: 'json' }
import trSettings from '@frontend-locales/tr/settings.json' with { type: 'json' }
import zhTWCommands from '@frontend-locales/zh-TW/commands.json' with { type: 'json' }
import zhTW from '@frontend-locales/zh-TW/main.json' with { type: 'json' }
import zhTWNodes from '@frontend-locales/zh-TW/nodeDefs.json' with { type: 'json' }
import zhTWSettings from '@frontend-locales/zh-TW/settings.json' with { type: 'json' }
import zhCommands from '@frontend-locales/zh/commands.json' with { type: 'json' }
import zh from '@frontend-locales/zh/main.json' with { type: 'json' }
import zhNodes from '@frontend-locales/zh/nodeDefs.json' with { type: 'json' }
import zhSettings from '@frontend-locales/zh/settings.json' with { type: 'json' }
import { createI18n } from 'vue-i18n'
function buildLocale<M, N, C, S>(main: M, nodes: N, commands: C, settings: S) {
function buildLocale<
M extends Record<string, unknown>,
N extends Record<string, unknown>,
C extends Record<string, unknown>,
S extends Record<string, unknown>
>(main: M, nodes: N, commands: C, settings: S) {
return {
...main,
nodeDefs: nodes,
commands: commands,
settings: settings
}
} as M & { nodeDefs: N; commands: C; settings: S }
}
const messages = {
en: buildLocale(en, enNodes, enCommands, enSettings),
zh: buildLocale(zh, zhNodes, zhCommands, zhSettings),
'zh-TW': buildLocale(zhTW, zhTWNodes, zhTWCommands, zhTWSettings),
ru: buildLocale(ru, ruNodes, ruCommands, ruSettings),
ja: buildLocale(ja, jaNodes, jaCommands, jaSettings),
ko: buildLocale(ko, koNodes, koCommands, koSettings),
fr: buildLocale(fr, frNodes, frCommands, frSettings),
es: buildLocale(es, esNodes, esCommands, esSettings),
ar: buildLocale(ar, arNodes, arCommands, arSettings),
tr: buildLocale(tr, trNodes, trCommands, trSettings)
// Locale loader map - dynamically import locales only when needed
// ESLint cannot statically resolve these dynamic imports, but they are valid at build time
/* eslint-disable import-x/no-unresolved */
const localeLoaders: Record<
string,
() => Promise<{ default: Record<string, unknown> }>
> = {
ar: () => import('@frontend-locales/ar/main.json'),
es: () => import('@frontend-locales/es/main.json'),
fr: () => import('@frontend-locales/fr/main.json'),
ja: () => import('@frontend-locales/ja/main.json'),
ko: () => import('@frontend-locales/ko/main.json'),
ru: () => import('@frontend-locales/ru/main.json'),
tr: () => import('@frontend-locales/tr/main.json'),
zh: () => import('@frontend-locales/zh/main.json'),
'zh-TW': () => import('@frontend-locales/zh-TW/main.json')
}
const nodeDefsLoaders: Record<
string,
() => Promise<{ default: Record<string, unknown> }>
> = {
ar: () => import('@frontend-locales/ar/nodeDefs.json'),
es: () => import('@frontend-locales/es/nodeDefs.json'),
fr: () => import('@frontend-locales/fr/nodeDefs.json'),
ja: () => import('@frontend-locales/ja/nodeDefs.json'),
ko: () => import('@frontend-locales/ko/nodeDefs.json'),
ru: () => import('@frontend-locales/ru/nodeDefs.json'),
tr: () => import('@frontend-locales/tr/nodeDefs.json'),
zh: () => import('@frontend-locales/zh/nodeDefs.json'),
'zh-TW': () => import('@frontend-locales/zh-TW/nodeDefs.json')
}
const commandsLoaders: Record<
string,
() => Promise<{ default: Record<string, unknown> }>
> = {
ar: () => import('@frontend-locales/ar/commands.json'),
es: () => import('@frontend-locales/es/commands.json'),
fr: () => import('@frontend-locales/fr/commands.json'),
ja: () => import('@frontend-locales/ja/commands.json'),
ko: () => import('@frontend-locales/ko/commands.json'),
ru: () => import('@frontend-locales/ru/commands.json'),
tr: () => import('@frontend-locales/tr/commands.json'),
zh: () => import('@frontend-locales/zh/commands.json'),
'zh-TW': () => import('@frontend-locales/zh-TW/commands.json')
}
const settingsLoaders: Record<
string,
() => Promise<{ default: Record<string, unknown> }>
> = {
ar: () => import('@frontend-locales/ar/settings.json'),
es: () => import('@frontend-locales/es/settings.json'),
fr: () => import('@frontend-locales/fr/settings.json'),
ja: () => import('@frontend-locales/ja/settings.json'),
ko: () => import('@frontend-locales/ko/settings.json'),
ru: () => import('@frontend-locales/ru/settings.json'),
tr: () => import('@frontend-locales/tr/settings.json'),
zh: () => import('@frontend-locales/zh/settings.json'),
'zh-TW': () => import('@frontend-locales/zh-TW/settings.json')
}
// Track which locales have been loaded
const loadedLocales = new Set<string>(['en'])
// Track locales currently being loaded to prevent race conditions
const loadingLocales = new Map<string, Promise<void>>()
/**
* Dynamically load a locale and its associated files (nodeDefs, commands, settings)
*/
export async function loadLocale(locale: string): Promise<void> {
if (loadedLocales.has(locale)) {
return
}
// If already loading, return the existing promise to prevent duplicate loads
const existingLoad = loadingLocales.get(locale)
if (existingLoad) {
return existingLoad
}
const loader = localeLoaders[locale]
const nodeDefsLoader = nodeDefsLoaders[locale]
const commandsLoader = commandsLoaders[locale]
const settingsLoader = settingsLoaders[locale]
if (!loader || !nodeDefsLoader || !commandsLoader || !settingsLoader) {
console.warn(`Locale "${locale}" is not supported`)
return
}
// Create and track the loading promise
const loadPromise = (async () => {
try {
const [main, nodes, commands, settings] = await Promise.all([
loader(),
nodeDefsLoader(),
commandsLoader(),
settingsLoader()
])
const messages = buildLocale(
main.default,
nodes.default,
commands.default,
settings.default
)
i18n.global.setLocaleMessage(locale, messages as LocaleMessages)
loadedLocales.add(locale)
} catch (error) {
console.error(`Failed to load locale "${locale}":`, error)
throw error
} finally {
// Clean up the loading promise once complete
loadingLocales.delete(locale)
}
})()
loadingLocales.set(locale, loadPromise)
return loadPromise
}
// Only include English in the initial bundle
const messages = {
en: buildLocale(en, enNodes, enCommands, enSettings)
}
// Type for locale messages - inferred from the English locale structure
type LocaleMessages = typeof messages.en
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -60,6 +60,7 @@ export default defineConfig([
'**/vite.config.*.timestamp*',
'**/vitest.config.*.timestamp*',
'packages/registry-types/src/comfyRegistryTypes.ts',
'public/auth-sw.js',
'src/extensions/core/*',
'src/scripts/*',
'src/types/generatedManagerTypes.ts',

6
global.d.ts vendored
View File

@@ -4,6 +4,12 @@ declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_CONFIG__: boolean
declare const __MIXPANEL_TOKEN__: string
type BuildFeatureFlags = {
REQUIRE_SUBSCRIPTION: boolean
}
declare const __BUILD_FLAGS__: BuildFeatureFlags
interface Navigator {
/**

View File

@@ -12,6 +12,10 @@ const config: KnipConfig = {
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
},
'apps/desktop-ui': {
entry: ['src/main.ts', 'src/i18n.ts'],
project: ['src/**/*.{js,ts,vue}', '*.{js,ts,mts}']
},
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},
@@ -39,7 +43,9 @@ const config: KnipConfig = {
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/registry-types/src/comfyRegistryTypes.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts'
'src/scripts/ui/components/splitButton.ts',
// Service worker - registered at runtime via navigator.serviceWorker.register()
'public/auth-sw.js'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -89,6 +89,7 @@
"knip": "catalog:",
"lint-staged": "catalog:",
"markdown-table": "catalog:",
"mixpanel-browser": "catalog:",
"nx": "catalog:",
"picocolors": "catalog:",
"postcss-html": "catalog:",
@@ -109,6 +110,7 @@
"vite": "catalog:",
"vite-plugin-dts": "catalog:",
"vite-plugin-html": "catalog:",
"vite-plugin-inspect": "catalog:",
"vite-plugin-vue-devtools": "catalog:",
"vitest": "catalog:",
"vue-component-type-helpers": "catalog:",
@@ -167,7 +169,7 @@
"semver": "^7.7.2",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"vue": "catalog:",
"vue": "https://pkg.pr.new/vuejs/core/vue@20b5240",
"vue-i18n": "catalog:",
"vue-router": "catalog:",
"vuefire": "catalog:",

486
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,7 @@ catalog:
'@types/node': ^20.14.8
'@types/semver': ^7.7.0
'@types/three': ^0.169.0
'@vitejs/plugin-vue': ^5.1.4
'@vitejs/plugin-vue': ^6.0.1
'@vitest/coverage-v8': ^3.2.4
'@vitest/ui': ^3.0.0
'@vue/test-utils': ^2.4.6
@@ -63,6 +63,7 @@ catalog:
knip: ^5.62.0
lint-staged: ^15.2.7
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 21.4.1
picocolors: ^1.1.1
pinia: ^2.1.7
@@ -85,14 +86,15 @@ catalog:
vite: ^5.4.19
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-inspect: ^0.8.9
vite-plugin-vue-devtools: ^7.7.6
vitest: ^3.2.4
vue: ^3.5.13
vue-component-type-helpers: ^3.0.7
vue: https://pkg.pr.new/vuejs/core/vue@20b5240
vue-component-type-helpers: ^3.1.0
vue-eslint-parser: ^10.2.0
vue-i18n: ^9.14.3
vue-router: ^4.4.3
vue-tsc: ^3.0.7
vue-tsc: ^3.1.1
vuefire: ^3.2.1
yjs: ^13.6.27
zod: ^3.23.8

147
public/auth-sw.js Normal file
View File

@@ -0,0 +1,147 @@
/**
* @fileoverview Authentication Service Worker
* Intercepts /api/view requests and adds Firebase authentication headers.
* Required for browser-native requests (img, video, audio) that cannot send custom headers.
*/
/**
* @typedef {Object} AuthHeader
* @property {string} Authorization - Bearer token for authentication
*/
/**
* @typedef {Object} CachedAuth
* @property {AuthHeader|null} header
* @property {number} expiresAt - Timestamp when cache expires
*/
const CACHE_TTL_MS = 50 * 60 * 1000 // 50 minutes (Firebase tokens expire in 1 hour)
/** @type {CachedAuth|null} */
let authCache = null
/** @type {Promise<AuthHeader|null>|null} */
let authRequestInFlight = null
self.addEventListener('message', (event) => {
if (event.data.type === 'INVALIDATE_AUTH_HEADER') {
authCache = null
authRequestInFlight = null
}
})
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
if (
!url.pathname.startsWith('/api/view') &&
!url.pathname.startsWith('/api/viewvideo')
) {
return
}
event.respondWith(
(async () => {
try {
const authHeader = await getAuthHeader()
if (!authHeader) {
return fetch(event.request)
}
const headers = new Headers(event.request.headers)
for (const [key, value] of Object.entries(authHeader)) {
headers.set(key, value)
}
return fetch(
new Request(event.request.url, {
method: event.request.method,
headers: headers,
mode: 'same-origin',
credentials: event.request.credentials,
cache: 'no-store',
redirect: event.request.redirect,
referrer: event.request.referrer,
integrity: event.request.integrity
})
)
} catch (error) {
console.error('[Auth SW] Request failed:', error)
return fetch(event.request)
}
})()
)
})
/**
* Gets auth header from cache or requests from main thread
* @returns {Promise<AuthHeader|null>}
*/
async function getAuthHeader() {
// Return cached value if valid
if (authCache && authCache.expiresAt > Date.now()) {
return authCache.header
}
// Clear expired cache
if (authCache) {
authCache = null
}
// Deduplicate concurrent requests
if (authRequestInFlight) {
return authRequestInFlight
}
authRequestInFlight = requestAuthHeaderFromMainThread()
const header = await authRequestInFlight
authRequestInFlight = null
// Cache the result
if (header) {
authCache = {
header,
expiresAt: Date.now() + CACHE_TTL_MS
}
}
return header
}
/**
* Requests auth header from main thread via MessageChannel
* @returns {Promise<AuthHeader|null>}
*/
async function requestAuthHeaderFromMainThread() {
const clients = await self.clients.matchAll()
if (clients.length === 0) {
return null
}
const messageChannel = new MessageChannel()
return new Promise((resolve) => {
let timeoutId
messageChannel.port1.onmessage = (event) => {
clearTimeout(timeoutId)
resolve(event.data.authHeader)
}
timeoutId = setTimeout(() => {
console.error(
'[Auth SW] Timeout waiting for auth header from main thread'
)
resolve(null)
}, 1000)
clients[0].postMessage({ type: 'REQUEST_AUTH_HEADER' }, [
messageChannel.port2
])
})
}
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})

View File

@@ -86,6 +86,8 @@ import SplitButton from 'primevue/splitbutton'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import {
useQueuePendingTaskCountStore,
@@ -141,10 +143,15 @@ const hasPendingTasks = computed(
const commandStore = useCommandStore()
const queuePrompt = async (e: Event) => {
const commandId =
'shiftKey' in e && e.shiftKey
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
const isShiftPressed = 'shiftKey' in e && e.shiftKey
const commandId = isShiftPressed
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
if (isCloud) {
useTelemetry()?.trackRunButton({ subscribe_to_run: false })
}
await commandStore.execute(commandId)
}
</script>

View File

@@ -2,6 +2,6 @@ import { defineAsyncComponent } from 'vue'
import { isCloud } from '@/platform/distribution/types'
export default isCloud
export default isCloud && __BUILD_FLAGS__.REQUIRE_SUBSCRIPTION
? defineAsyncComponent(() => import('./CloudRunButtonWrapper.vue'))
: defineAsyncComponent(() => import('./ComfyQueueButton.vue'))

View File

@@ -67,10 +67,10 @@
/>
<Button
v-if="!isApiKeyLogin"
class="w-32"
class="w-fit"
variant="text"
severity="danger"
:label="$t('auth.deleteAccount.deleteAccount')"
icon="pi pi-trash"
@click="handleDeleteAccount"
/>
</div>

View File

@@ -21,7 +21,9 @@ import {
import type { Point } from '@/lib/litegraph/src/litegraph'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -451,6 +453,11 @@ export function useCoreCommands(): ComfyCommand[] {
category: 'essentials' as const,
function: async () => {
const batchCount = useQueueSettingsStore().batchCount
if (isCloud) {
useTelemetry()?.trackWorkflowExecution()
}
await app.queuePrompt(0, batchCount)
}
},
@@ -462,6 +469,11 @@ export function useCoreCommands(): ComfyCommand[] {
category: 'essentials' as const,
function: async () => {
const batchCount = useQueueSettingsStore().batchCount
if (isCloud) {
useTelemetry()?.trackWorkflowExecution()
}
await app.queuePrompt(-1, batchCount)
}
},

View File

@@ -26,5 +26,8 @@ import './widgetInputs'
if (isCloud) {
import('./cloudBadge')
import('./cloudSubscription')
if (__BUILD_FLAGS__.REQUIRE_SUBSCRIPTION) {
import('./cloudSubscription')
}
}

View File

@@ -1,68 +1,159 @@
import { createI18n } from 'vue-i18n'
import arCommands from './locales/ar/commands.json' with { type: 'json' }
import ar from './locales/ar/main.json' with { type: 'json' }
import arNodes from './locales/ar/nodeDefs.json' with { type: 'json' }
import arSettings from './locales/ar/settings.json' with { type: 'json' }
// ESLint cannot statically resolve dynamic imports with relative paths in template strings,
// but these are valid ES module imports that Vite processes correctly at build time.
// Import only English locale eagerly as the default/fallback
import enCommands from './locales/en/commands.json' with { type: 'json' }
import en from './locales/en/main.json' with { type: 'json' }
import enNodes from './locales/en/nodeDefs.json' with { type: 'json' }
import enSettings from './locales/en/settings.json' with { type: 'json' }
import esCommands from './locales/es/commands.json' with { type: 'json' }
import es from './locales/es/main.json' with { type: 'json' }
import esNodes from './locales/es/nodeDefs.json' with { type: 'json' }
import esSettings from './locales/es/settings.json' with { type: 'json' }
import frCommands from './locales/fr/commands.json' with { type: 'json' }
import fr from './locales/fr/main.json' with { type: 'json' }
import frNodes from './locales/fr/nodeDefs.json' with { type: 'json' }
import frSettings from './locales/fr/settings.json' with { type: 'json' }
import jaCommands from './locales/ja/commands.json' with { type: 'json' }
import ja from './locales/ja/main.json' with { type: 'json' }
import jaNodes from './locales/ja/nodeDefs.json' with { type: 'json' }
import jaSettings from './locales/ja/settings.json' with { type: 'json' }
import koCommands from './locales/ko/commands.json' with { type: 'json' }
import ko from './locales/ko/main.json' with { type: 'json' }
import koNodes from './locales/ko/nodeDefs.json' with { type: 'json' }
import koSettings from './locales/ko/settings.json' with { type: 'json' }
import ruCommands from './locales/ru/commands.json' with { type: 'json' }
import ru from './locales/ru/main.json' with { type: 'json' }
import ruNodes from './locales/ru/nodeDefs.json' with { type: 'json' }
import ruSettings from './locales/ru/settings.json' with { type: 'json' }
import trCommands from './locales/tr/commands.json' with { type: 'json' }
import tr from './locales/tr/main.json' with { type: 'json' }
import trNodes from './locales/tr/nodeDefs.json' with { type: 'json' }
import trSettings from './locales/tr/settings.json' with { type: 'json' }
import zhTWCommands from './locales/zh-TW/commands.json' with { type: 'json' }
import zhTW from './locales/zh-TW/main.json' with { type: 'json' }
import zhTWNodes from './locales/zh-TW/nodeDefs.json' with { type: 'json' }
import zhTWSettings from './locales/zh-TW/settings.json' with { type: 'json' }
import zhCommands from './locales/zh/commands.json' with { type: 'json' }
import zh from './locales/zh/main.json' with { type: 'json' }
import zhNodes from './locales/zh/nodeDefs.json' with { type: 'json' }
import zhSettings from './locales/zh/settings.json' with { type: 'json' }
function buildLocale<M, N, C, S>(main: M, nodes: N, commands: C, settings: S) {
function buildLocale<
M extends Record<string, unknown>,
N extends Record<string, unknown>,
C extends Record<string, unknown>,
S extends Record<string, unknown>
>(main: M, nodes: N, commands: C, settings: S) {
return {
...main,
nodeDefs: nodes,
commands: commands,
settings: settings
}
} as M & { nodeDefs: N; commands: C; settings: S }
}
const messages = {
en: buildLocale(en, enNodes, enCommands, enSettings),
zh: buildLocale(zh, zhNodes, zhCommands, zhSettings),
'zh-TW': buildLocale(zhTW, zhTWNodes, zhTWCommands, zhTWSettings),
ru: buildLocale(ru, ruNodes, ruCommands, ruSettings),
ja: buildLocale(ja, jaNodes, jaCommands, jaSettings),
ko: buildLocale(ko, koNodes, koCommands, koSettings),
fr: buildLocale(fr, frNodes, frCommands, frSettings),
es: buildLocale(es, esNodes, esCommands, esSettings),
ar: buildLocale(ar, arNodes, arCommands, arSettings),
tr: buildLocale(tr, trNodes, trCommands, trSettings)
// Locale loader map - dynamically import locales only when needed
const localeLoaders: Record<
string,
() => Promise<{ default: Record<string, unknown> }>
> = {
ar: () => import('./locales/ar/main.json'),
es: () => import('./locales/es/main.json'),
fr: () => import('./locales/fr/main.json'),
ja: () => import('./locales/ja/main.json'),
ko: () => import('./locales/ko/main.json'),
ru: () => import('./locales/ru/main.json'),
tr: () => import('./locales/tr/main.json'),
zh: () => import('./locales/zh/main.json'),
'zh-TW': () => import('./locales/zh-TW/main.json')
}
const nodeDefsLoaders: Record<
string,
() => Promise<{ default: Record<string, unknown> }>
> = {
ar: () => import('./locales/ar/nodeDefs.json'),
es: () => import('./locales/es/nodeDefs.json'),
fr: () => import('./locales/fr/nodeDefs.json'),
ja: () => import('./locales/ja/nodeDefs.json'),
ko: () => import('./locales/ko/nodeDefs.json'),
ru: () => import('./locales/ru/nodeDefs.json'),
tr: () => import('./locales/tr/nodeDefs.json'),
zh: () => import('./locales/zh/nodeDefs.json'),
'zh-TW': () => import('./locales/zh-TW/nodeDefs.json')
}
const commandsLoaders: Record<
string,
() => Promise<{ default: Record<string, unknown> }>
> = {
ar: () => import('./locales/ar/commands.json'),
es: () => import('./locales/es/commands.json'),
fr: () => import('./locales/fr/commands.json'),
ja: () => import('./locales/ja/commands.json'),
ko: () => import('./locales/ko/commands.json'),
ru: () => import('./locales/ru/commands.json'),
tr: () => import('./locales/tr/commands.json'),
zh: () => import('./locales/zh/commands.json'),
'zh-TW': () => import('./locales/zh-TW/commands.json')
}
const settingsLoaders: Record<
string,
() => Promise<{ default: Record<string, unknown> }>
> = {
ar: () => import('./locales/ar/settings.json'),
es: () => import('./locales/es/settings.json'),
fr: () => import('./locales/fr/settings.json'),
ja: () => import('./locales/ja/settings.json'),
ko: () => import('./locales/ko/settings.json'),
ru: () => import('./locales/ru/settings.json'),
tr: () => import('./locales/tr/settings.json'),
zh: () => import('./locales/zh/settings.json'),
'zh-TW': () => import('./locales/zh-TW/settings.json')
}
// Track which locales have been loaded
const loadedLocales = new Set<string>(['en'])
// Track locales currently being loaded to prevent race conditions
const loadingLocales = new Map<string, Promise<void>>()
/**
* Dynamically load a locale and its associated files (nodeDefs, commands, settings)
*/
export async function loadLocale(locale: string): Promise<void> {
if (loadedLocales.has(locale)) {
return
}
// If already loading, return the existing promise to prevent duplicate loads
const existingLoad = loadingLocales.get(locale)
if (existingLoad) {
return existingLoad
}
const loader = localeLoaders[locale]
const nodeDefsLoader = nodeDefsLoaders[locale]
const commandsLoader = commandsLoaders[locale]
const settingsLoader = settingsLoaders[locale]
if (!loader || !nodeDefsLoader || !commandsLoader || !settingsLoader) {
console.warn(`Locale "${locale}" is not supported`)
return
}
// Create and track the loading promise
const loadPromise = (async () => {
try {
const [main, nodes, commands, settings] = await Promise.all([
loader(),
nodeDefsLoader(),
commandsLoader(),
settingsLoader()
])
const messages = buildLocale(
main.default,
nodes.default,
commands.default,
settings.default
)
i18n.global.setLocaleMessage(locale, messages as LocaleMessages)
loadedLocales.add(locale)
} catch (error) {
console.error(`Failed to load locale "${locale}":`, error)
throw error
} finally {
// Clean up the loading promise once complete
loadingLocales.delete(locale)
}
})()
loadingLocales.set(locale, loadPromise)
return loadPromise
}
// Only include English in the initial bundle
const messages = {
en: buildLocale(en, enNodes, enCommands, enSettings)
}
// Type for locale messages - inferred from the English locale structure
type LocaleMessages = typeof messages.en
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,

View File

@@ -8,11 +8,12 @@ import PrimeVue from 'primevue/config'
import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue'
import { createApp, vaporInteropPlugin } from 'vue'
import { VueFire, VueFireAuth } from 'vuefire'
import { FIREBASE_CONFIG } from '@/config/firebase'
import '@/lib/litegraph/public/css/litegraph.css'
import '@/platform/auth/serviceWorker'
import router from '@/router'
import App from './App.vue'
@@ -44,6 +45,7 @@ Sentry.init({
})
app.directive('tooltip', Tooltip)
app
.use(vaporInteropPlugin) // Enable Vapor and vDOM mixed mode compatibility
.use(router)
.use(PrimeVue, {
theme: {

View File

@@ -0,0 +1,9 @@
import { isCloud } from '@/platform/distribution/types'
/**
* Auth service worker registration (cloud-only).
* Tree-shaken for desktop/localhost builds via compile-time constant.
*/
if (isCloud) {
void import('./register')
}

View File

@@ -0,0 +1,57 @@
import { watch } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
/**
* Registers the authentication service worker for cloud distribution.
* Intercepts /api/view requests to add auth headers for browser-native requests.
*/
async function registerAuthServiceWorker(): Promise<void> {
if (!('serviceWorker' in navigator)) {
return
}
try {
await navigator.serviceWorker.register('/auth-sw.js')
setupAuthHeaderProvider()
setupCacheInvalidation()
} catch (error) {
console.error('[Auth SW] Registration failed:', error)
}
}
/**
* Listens for auth header requests from the service worker
*/
function setupAuthHeaderProvider(): void {
navigator.serviceWorker.addEventListener('message', async (event) => {
if (event.data.type === 'REQUEST_AUTH_HEADER') {
const firebaseAuthStore = useFirebaseAuthStore()
const authHeader = await firebaseAuthStore.getAuthHeader()
event.ports[0].postMessage({
type: 'AUTH_HEADER_RESPONSE',
authHeader
})
}
})
}
/**
* Invalidates cached auth header when user logs in/out
*/
function setupCacheInvalidation(): void {
const { isLoggedIn } = useCurrentUser()
watch(isLoggedIn, (newValue, oldValue) => {
if (newValue !== oldValue) {
navigator.serviceWorker.controller?.postMessage({
type: 'INVALIDATE_AUTH_HEADER'
})
}
})
}
void registerAuthServiceWorker()

View File

@@ -15,6 +15,8 @@ import Button from 'primevue/button'
import { onBeforeUnmount, ref } from 'vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
withDefaults(
defineProps<{
@@ -82,6 +84,10 @@ const stopPolling = () => {
}
const handleSubscribe = async () => {
if (isCloud) {
useTelemetry()?.trackSubscription('subscribe_clicked')
}
isLoading.value = true
try {
await subscribe()

View File

@@ -10,7 +10,7 @@
severity="primary"
size="small"
data-testid="subscribe-to-run-button"
@click="showSubscriptionDialog"
@click="handleSubscribeToRun"
/>
</template>
@@ -18,6 +18,16 @@
import Button from 'primevue/button'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
const { showSubscriptionDialog } = useSubscription()
const handleSubscribeToRun = () => {
if (isCloud) {
useTelemetry()?.trackRunButton({ subscribe_to_run: true })
}
showSubscriptionDialog()
}
</script>

View File

@@ -7,6 +7,7 @@ import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import {
FirebaseAuthStoreError,
@@ -26,7 +27,7 @@ interface CloudSubscriptionStatusResponse {
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
const isActiveSubscription = computed(() => {
if (!isCloud) return true
if (!isCloud || !__BUILD_FLAGS__.REQUIRE_SUBSCRIPTION) return true
return subscriptionStatus.value?.is_active ?? false
})
@@ -78,6 +79,10 @@ export function useSubscription() {
}, reportError)
const showSubscriptionDialog = () => {
if (isCloud) {
useTelemetry()?.trackSubscription('modal_opened')
}
dialogService.showSubscriptionRequiredDialog()
}

View File

@@ -80,21 +80,22 @@ export function useSettingUI(
)
}
const subscriptionPanel: SettingPanelItem | null = !isCloud
? null
: {
node: {
key: 'subscription',
label: 'PlanCredits',
children: []
},
component: defineAsyncComponent(
() =>
import(
'@/platform/cloud/subscription/components/SubscriptionPanel.vue'
)
)
}
const subscriptionPanel: SettingPanelItem | null =
!isCloud || !__BUILD_FLAGS__.REQUIRE_SUBSCRIPTION
? null
: {
node: {
key: 'subscription',
label: 'PlanCredits',
children: []
},
component: defineAsyncComponent(
() =>
import(
'@/platform/cloud/subscription/components/SubscriptionPanel.vue'
)
)
}
const userPanel: SettingPanelItem = {
node: {
@@ -148,7 +149,9 @@ export function useSettingUI(
keybindingPanel,
extensionPanel,
...(isElectron() ? [serverConfigPanel] : []),
...(isCloud && subscriptionPanel ? [subscriptionPanel] : [])
...(isCloud && __BUILD_FLAGS__.REQUIRE_SUBSCRIPTION && subscriptionPanel
? [subscriptionPanel]
: [])
].filter((panel) => panel.component)
)
@@ -180,10 +183,16 @@ export function useSettingUI(
label: 'Account',
children: [
userPanel.node,
...(isLoggedIn.value && isCloud && subscriptionPanel
...(isLoggedIn.value &&
isCloud &&
__BUILD_FLAGS__.REQUIRE_SUBSCRIPTION &&
subscriptionPanel
? [subscriptionPanel.node]
: []),
...(isLoggedIn.value && !isCloud ? [creditsPanel.node] : [])
...(isLoggedIn.value &&
!(isCloud && __BUILD_FLAGS__.REQUIRE_SUBSCRIPTION)
? [creditsPanel.node]
: [])
].map(translateCategory)
},
// Normal settings stored in the settingStore

View File

@@ -0,0 +1,42 @@
/**
* Telemetry Provider - OSS Build Safety
*
* CRITICAL: OSS Build Safety
* This module is conditionally compiled based on distribution. When building
* the open source version (DISTRIBUTION unset), this entire module and its dependencies
* are excluded through via tree-shaking.
*
* To verify OSS builds exclude this code:
* 1. `DISTRIBUTION= pnpm build` (OSS build)
* 2. `grep -RinE --include='*.js' 'trackWorkflow|trackEvent|mixpanel' dist/` (should find nothing)
* 3. Check dist/assets/*.js files contain no tracking code
*
* This approach maintains complete separation between cloud and OSS builds
* while ensuring the open source version contains no telemetry dependencies.
*/
import { isCloud } from '@/platform/distribution/types'
import { MixpanelTelemetryProvider } from './providers/cloud/MixpanelTelemetryProvider'
import type { TelemetryProvider } from './types'
// Singleton instance
let _telemetryProvider: TelemetryProvider | null = null
/**
* Telemetry factory - conditionally creates provider based on distribution
* Returns singleton instance.
*
* CRITICAL: This returns undefined in OSS builds. There is no telemetry provider
* for OSS builds and all tracking calls are no-ops.
*/
export function useTelemetry(): TelemetryProvider | null {
if (_telemetryProvider === null) {
// Use distribution check for tree-shaking
if (isCloud) {
_telemetryProvider = new MixpanelTelemetryProvider()
}
// For OSS builds, _telemetryProvider stays null
}
return _telemetryProvider
}

View File

@@ -0,0 +1,221 @@
import type { OverridedMixpanel } from 'mixpanel-browser'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type {
AuthMetadata,
ExecutionContext,
RunButtonProperties,
SurveyResponses,
TelemetryEventName,
TelemetryEventProperties,
TelemetryProvider,
TemplateMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
interface QueuedEvent {
eventName: TelemetryEventName
properties?: TelemetryEventProperties
}
/**
* Mixpanel Telemetry Provider - Cloud Build Implementation
*
* CRITICAL: OSS Build Safety
* This provider integrates with Mixpanel for cloud telemetry tracking.
* Entire file is tree-shaken away in OSS builds (DISTRIBUTION unset).
*
* To verify OSS builds exclude this code:
* 1. `DISTRIBUTION= pnpm build` (OSS build)
* 2. `grep -RinE --include='*.js' 'trackWorkflow|trackEvent|mixpanel' dist/` (should find nothing)
* 3. Check dist/assets/*.js files contain no tracking code
*/
export class MixpanelTelemetryProvider implements TelemetryProvider {
private isEnabled = true
private mixpanel: OverridedMixpanel | null = null
private eventQueue: QueuedEvent[] = []
private isInitialized = false
constructor() {
const token = __MIXPANEL_TOKEN__
if (token) {
try {
// Dynamic import to avoid bundling mixpanel in OSS builds
void import('mixpanel-browser')
.then((mixpanelModule) => {
this.mixpanel = mixpanelModule.default
this.mixpanel.init(token, {
debug: import.meta.env.DEV,
track_pageview: true,
api_host: 'https://mp.comfy.org',
cross_subdomain_cookie: true,
persistence: 'cookie',
loaded: () => {
this.isInitialized = true
this.flushEventQueue() // flush events that were queued while initializing
useCurrentUser().onUserResolved((user) => {
if (this.mixpanel && user.id) {
this.mixpanel.identify(user.id)
}
})
}
})
})
.catch((error) => {
console.error('Failed to load Mixpanel:', error)
this.isEnabled = false
})
} catch (error) {
console.error('Failed to initialize Mixpanel:', error)
this.isEnabled = false
}
} else {
console.warn('Mixpanel token not provided')
this.isEnabled = false
}
}
private flushEventQueue(): void {
if (!this.isInitialized || !this.mixpanel) {
return
}
while (this.eventQueue.length > 0) {
const event = this.eventQueue.shift()!
try {
this.mixpanel.track(event.eventName, event.properties || {})
} catch (error) {
console.error('Failed to track queued event:', error)
}
}
}
private trackEvent(
eventName: TelemetryEventName,
properties?: TelemetryEventProperties
): void {
if (!this.isEnabled) {
return
}
const event: QueuedEvent = { eventName, properties }
if (this.isInitialized && this.mixpanel) {
// Mixpanel is ready, track immediately
try {
this.mixpanel.track(eventName, properties || {})
} catch (error) {
console.error('Failed to track event:', error)
}
} else {
// Mixpanel not ready yet, queue the event
this.eventQueue.push(event)
}
}
trackAuth(metadata: AuthMetadata): void {
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
}
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void {
const eventName =
event === 'modal_opened'
? TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED
: TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED
this.trackEvent(eventName)
}
trackRunButton(options?: { subscribe_to_run?: boolean }): void {
const executionContext = this.getExecutionContext()
const runButtonProperties: RunButtonProperties = {
subscribe_to_run: options?.subscribe_to_run || false,
workflow_type: executionContext.is_template ? 'template' : 'custom',
workflow_name: executionContext.workflow_name ?? 'untitled'
}
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
}
trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses
): void {
const eventName =
stage === 'opened'
? TelemetryEvents.USER_SURVEY_OPENED
: TelemetryEvents.USER_SURVEY_SUBMITTED
this.trackEvent(eventName, responses)
}
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
let eventName: TelemetryEventName
switch (stage) {
case 'opened':
eventName = TelemetryEvents.USER_EMAIL_VERIFY_OPENED
break
case 'requested':
eventName = TelemetryEvents.USER_EMAIL_VERIFY_REQUESTED
break
case 'completed':
eventName = TelemetryEvents.USER_EMAIL_VERIFY_COMPLETED
break
}
this.trackEvent(eventName)
}
trackTemplate(metadata: TemplateMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_WORKFLOW_OPENED, metadata)
}
trackWorkflowExecution(): void {
const context = this.getExecutionContext()
this.trackEvent(TelemetryEvents.WORKFLOW_EXECUTION_STARTED, context)
}
getExecutionContext(): ExecutionContext {
const workflowStore = useWorkflowStore()
const templatesStore = useWorkflowTemplatesStore()
const activeWorkflow = workflowStore.activeWorkflow
if (activeWorkflow?.filename) {
const isTemplate = templatesStore.knownTemplateNames.has(
activeWorkflow.filename
)
if (isTemplate) {
const template = templatesStore.getTemplateByName(
activeWorkflow.filename
)
return {
is_template: true,
workflow_name: activeWorkflow.filename,
template_source: template?.sourceModule,
template_category: template?.category,
template_tags: template?.tags,
template_models: template?.models,
template_use_case: template?.useCase,
template_license: template?.license
}
}
return {
is_template: false,
workflow_name: activeWorkflow.filename
}
}
return {
is_template: false,
workflow_name: undefined
}
}
}

View File

@@ -0,0 +1,138 @@
/**
* Telemetry Provider Interface
*
* CRITICAL: OSS Build Safety
* This module is excluded from OSS builds via conditional compilation.
* When DISTRIBUTION is unset (OSS builds), Vite's tree-shaking removes this code entirely,
* ensuring the open source build contains no telemetry dependencies.
*
* To verify OSS builds are clean:
* 1. `DISTRIBUTION= pnpm build` (OSS build)
* 2. `grep -RinE --include='*.js' 'trackWorkflow|trackEvent|mixpanel' dist/` (should find nothing)
* 3. Check dist/assets/*.js files contain no tracking code
*/
/**
* Authentication metadata for sign-up tracking
*/
export interface AuthMetadata {
method?: 'email' | 'google' | 'github'
is_new_user?: boolean
referrer_url?: string
utm_source?: string
utm_medium?: string
utm_campaign?: string
}
/**
* Survey response data for user profiling
*/
export interface SurveyResponses {
industry?: string
team_size?: string
use_case?: string
familiarity?: string
intended_use?: 'personal' | 'client' | 'inhouse'
}
/**
* Run button tracking properties
*/
export interface RunButtonProperties {
subscribe_to_run: boolean
workflow_type: 'template' | 'custom'
workflow_name: string
}
/**
* Execution context for workflow tracking
*/
export interface ExecutionContext {
is_template: boolean
workflow_name?: string
// Template metadata (only present when is_template = true)
template_source?: string
template_category?: string
template_tags?: string[]
template_models?: string[]
template_use_case?: string
template_license?: string
}
/**
* Template metadata for workflow tracking
*/
export interface TemplateMetadata {
workflow_name: string
template_source?: string
template_category?: string
template_tags?: string[]
template_models?: string[]
template_use_case?: string
template_license?: string
}
/**
* Core telemetry provider interface
*/
export interface TelemetryProvider {
// Authentication flow events
trackAuth(metadata: AuthMetadata): void
// Subscription flow events
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
trackRunButton(options?: { subscribe_to_run?: boolean }): void
// Survey flow events
trackSurvey(stage: 'opened' | 'submitted', responses?: SurveyResponses): void
// Email verification events
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void
// Template workflow events
trackTemplate(metadata: TemplateMetadata): void
// Workflow execution events
trackWorkflowExecution(): void
}
/**
* Telemetry event constants
*/
export const TelemetryEvents = {
// Authentication Flow
USER_AUTH_COMPLETED: 'user_auth_completed',
// Subscription Flow
RUN_BUTTON_CLICKED: 'run_button_clicked',
SUBSCRIPTION_REQUIRED_MODAL_OPENED: 'subscription_required_modal_opened',
SUBSCRIBE_NOW_BUTTON_CLICKED: 'subscribe_now_button_clicked',
// Onboarding Survey
USER_SURVEY_OPENED: 'user_survey_opened',
USER_SURVEY_SUBMITTED: 'user_survey_submitted',
// Email Verification
USER_EMAIL_VERIFY_OPENED: 'user_email_verify_opened',
USER_EMAIL_VERIFY_REQUESTED: 'user_email_verify_requested',
USER_EMAIL_VERIFY_COMPLETED: 'user_email_verify_completed',
// Template Tracking
TEMPLATE_WORKFLOW_OPENED: 'template_workflow_opened',
// Workflow Execution Tracking
WORKFLOW_EXECUTION_STARTED: 'workflow_execution_started'
} as const
export type TelemetryEventName =
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
/**
* Union type for all possible telemetry event properties
*/
export type TelemetryEventProperties =
| AuthMetadata
| SurveyResponses
| TemplateMetadata
| ExecutionContext
| RunButtonProperties

View File

@@ -1,6 +1,8 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type {
TemplateGroup,
@@ -128,6 +130,13 @@ export function useTemplateWorkflows() {
? t(`templateWorkflows.template.${id}`, id)
: id
if (isCloud) {
useTelemetry()?.trackTemplate({
workflow_name: workflowName,
template_source: actualSourceModule
})
}
dialogStore.closeDialog()
await app.loadGraphData(json, true, true, workflowName)
@@ -142,6 +151,13 @@ export function useTemplateWorkflows() {
? t(`templateWorkflows.template.${id}`, id)
: id
if (isCloud) {
useTelemetry()?.trackTemplate({
workflow_name: workflowName,
template_source: sourceModule
})
}
dialogStore.closeDialog()
await app.loadGraphData(json, true, true, workflowName)

View File

@@ -30,6 +30,11 @@ export const useWorkflowTemplatesStore = defineStore(
const customTemplates = shallowRef<{ [moduleName: string]: string[] }>({})
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
const isLoaded = ref(false)
const knownTemplateNames = ref(new Set<string>())
const getTemplateByName = (name: string): EnhancedTemplate | undefined => {
return enhancedTemplates.value.find((template) => template.name === name)
}
// Store filter mappings for dynamic categories
type FilterData = {
@@ -432,6 +437,13 @@ export const useWorkflowTemplatesStore = defineStore(
customTemplates.value = await api.getWorkflowTemplates()
const locale = i18n.global.locale.value
coreTemplates.value = await api.getCoreWorkflowTemplates(locale)
const coreNames = coreTemplates.value.flatMap((category) =>
category.templates.map((template) => template.name)
)
const customNames = Object.values(customTemplates.value).flat()
knownTemplateNames.value = new Set([...coreNames, ...customNames])
isLoaded.value = true
}
} catch (error) {
@@ -446,7 +458,9 @@ export const useWorkflowTemplatesStore = defineStore(
templateFuse,
filterTemplatesByCategory,
isLoaded,
loadWorkflowTemplates
loadWorkflowTemplates,
knownTemplateNames,
getTemplateByName
}
}
)

View File

@@ -1,11 +1,10 @@
<template>
<div
v-if="imageUrls.length > 0"
class="video-preview group relative flex size-full min-h-16 min-w-16 flex-col"
class="video-preview group relative flex size-full min-h-16 min-w-16 flex-col px-2"
tabindex="0"
role="region"
:aria-label="$t('g.videoPreview')"
data-capture-node="true"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@keydown="handleKeyDown"

View File

@@ -1,8 +1,7 @@
<template>
<div
v-if="imageUrls.length > 0"
class="image-preview group relative flex size-full min-h-16 min-w-16 flex-col"
data-capture-node="true"
class="image-preview group relative flex size-full min-h-16 min-w-16 flex-col px-2"
tabindex="0"
role="region"
:aria-label="$t('g.imagePreview')"

View File

@@ -118,9 +118,7 @@ const slotWrapperClass = computed(() =>
cn(
'lg-slot lg-slot--input flex items-center group rounded-r-lg h-6',
'cursor-crosshair',
props.dotOnly
? 'lg-slot--dot-only'
: 'pr-6 hover:bg-black/5 hover:dark:bg-white/5',
props.dotOnly ? 'lg-slot--dot-only' : 'pr-6',
{
'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible,

View File

@@ -117,7 +117,7 @@
</div>
</template>
<script setup lang="ts">
<script setup lang="ts" vapor>
import { whenever } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, inject, onErrorCaptured, onMounted, ref } from 'vue'

View File

@@ -88,9 +88,7 @@ const slotWrapperClass = computed(() =>
cn(
'lg-slot lg-slot--output flex items-center justify-end group rounded-l-lg h-6',
'cursor-crosshair',
props.dotOnly
? 'lg-slot--dot-only justify-center'
: 'pl-6 hover:bg-black/5 hover:dark:bg-white/5',
props.dotOnly ? 'lg-slot--dot-only justify-center' : 'pl-6',
{
'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible,

View File

@@ -55,17 +55,6 @@ export function useNodePointerInteractions(
if (forwardMiddlePointerIfNeeded(event)) return
const stopNodeDragTarget =
event.target instanceof HTMLElement
? event.target.closest('[data-capture-node="true"]')
: null
if (stopNodeDragTarget) {
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
}
return
}
// Only start drag on left-click (button 0)
if (event.button !== 0) {
return

View File

@@ -10,5 +10,5 @@ export const WidgetInputBaseClass = cn([
// Rounded
'rounded-lg',
// Hover
'hover:outline-blue-500/80'
'hover:bg-node-component-surface-hovered'
])

View File

@@ -488,7 +488,7 @@ export const useDialogService = () => {
}
function showSubscriptionRequiredDialog() {
if (!isCloud) {
if (!isCloud || !__BUILD_FLAGS__.REQUIRE_SUBSCRIPTION) {
return
}

View File

@@ -6,6 +6,7 @@ import {
browserLocalPersistence,
createUserWithEmailAndPassword,
deleteUser,
getAdditionalUserInfo,
onAuthStateChanged,
sendPasswordResetEmail,
setPersistence,
@@ -21,6 +22,8 @@ import { useFirebaseAuth } from 'vuefire'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import type { AuthHeader } from '@/types/authTypes'
@@ -242,36 +245,79 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const login = async (
email: string,
password: string
): Promise<UserCredential> =>
executeAuthAction(
): Promise<UserCredential> => {
const result = await executeAuthAction(
(authInstance) =>
signInWithEmailAndPassword(authInstance, email, password),
{ createCustomer: true }
)
if (isCloud) {
useTelemetry()?.trackAuth({
method: 'email',
is_new_user: false
})
}
return result
}
const register = async (
email: string,
password: string
): Promise<UserCredential> => {
return executeAuthAction(
const result = await executeAuthAction(
(authInstance) =>
createUserWithEmailAndPassword(authInstance, email, password),
{ createCustomer: true }
)
if (isCloud) {
useTelemetry()?.trackAuth({
method: 'email',
is_new_user: true
})
}
return result
}
const loginWithGoogle = async (): Promise<UserCredential> =>
executeAuthAction(
const loginWithGoogle = async (): Promise<UserCredential> => {
const result = await executeAuthAction(
(authInstance) => signInWithPopup(authInstance, googleProvider),
{ createCustomer: true }
)
const loginWithGithub = async (): Promise<UserCredential> =>
executeAuthAction(
if (isCloud) {
const additionalUserInfo = getAdditionalUserInfo(result)
const isNewUser = additionalUserInfo?.isNewUser ?? false
useTelemetry()?.trackAuth({
method: 'google',
is_new_user: isNewUser
})
}
return result
}
const loginWithGithub = async (): Promise<UserCredential> => {
const result = await executeAuthAction(
(authInstance) => signInWithPopup(authInstance, githubProvider),
{ createCustomer: true }
)
if (isCloud) {
const additionalUserInfo = getAdditionalUserInfo(result)
const isNewUser = additionalUserInfo?.isNewUser ?? false
useTelemetry()?.trackAuth({
method: 'github',
is_new_user: isNewUser
})
}
return result
}
const logout = async (): Promise<void> =>
executeAuthAction((authInstance) => signOut(authInstance))

View File

@@ -45,7 +45,7 @@ import { useCoreCommands } from '@/composables/useCoreCommands'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useProgressFavicon } from '@/composables/useProgressFavicon'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
import { i18n } from '@/i18n'
import { i18n, loadLocale } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning'
import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore'
@@ -145,10 +145,17 @@ watchEffect(() => {
)
})
watchEffect(() => {
watchEffect(async () => {
const locale = settingStore.get('Comfy.Locale')
if (locale) {
i18n.global.locale.value = locale as 'en' | 'zh' | 'ru' | 'ja'
// Load the locale dynamically if not already loaded
try {
await loadLocale(locale)
// Type assertion is safe here as loadLocale validates the locale exists
i18n.global.locale.value = locale as typeof i18n.global.locale.value
} catch (error) {
console.error(`Failed to switch to locale "${locale}":`, error)
}
}
})

View File

@@ -31,6 +31,11 @@ vi.mock('@/scripts/app', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: vi.fn((key, fallback) => fallback || key)
}),
createI18n: () => ({
global: {
t: (key: string) => key
}
})
}))

View File

@@ -19,6 +19,10 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => null)
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
reportError: mockReportError,

View File

@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
describe('useTelemetry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return null when not in cloud distribution', async () => {
const { useTelemetry } = await import('@/platform/telemetry')
const provider = useTelemetry()
// Should return null for OSS builds
expect(provider).toBeNull()
})
it('should return null consistently for OSS builds', async () => {
const { useTelemetry } = await import('@/platform/telemetry')
const provider1 = useTelemetry()
const provider2 = useTelemetry()
// Both should be null for OSS builds
expect(provider1).toBeNull()
expect(provider2).toBeNull()
})
})

View File

@@ -9,6 +9,7 @@ import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vite'
import type { UserConfig } from 'vite'
import { createHtmlPlugin } from 'vite-plugin-html'
import Inspect from 'vite-plugin-inspect'
import vueDevTools from 'vite-plugin-vue-devtools'
import { comfyAPIPlugin, generateImportMapPlugin } from './build/plugins'
@@ -32,6 +33,10 @@ const DISTRIBUTION = (process.env.DISTRIBUTION || 'localhost') as
| 'localhost'
| 'cloud'
const BUILD_FLAGS = {
REQUIRE_SUBSCRIPTION: process.env.REQUIRE_SUBSCRIPTION === 'true'
}
export default defineConfig({
base: '',
server: {
@@ -110,13 +115,14 @@ export default defineConfig({
...(!DISABLE_VUE_PLUGINS
? [vueDevTools(), vue(), createHtmlPlugin({})]
: [vue()]),
Inspect(),
tailwindcss(),
comfyAPIPlugin(IS_DEV),
generateImportMapPlugin([
{
name: 'vue',
pattern: 'vue',
entry: './dist/vue.esm-browser.prod.js'
entry: './dist/vue.runtime-with-vapor.esm-browser.prod.js'
},
{
name: 'vue-i18n',
@@ -188,7 +194,36 @@ export default defineConfig({
target: 'es2022',
sourcemap: GENERATE_SOURCEMAP,
rollupOptions: {
treeshake: true
treeshake: true,
output: {
manualChunks: (id) => {
if (!id.includes('node_modules')) {
return undefined
}
if (id.includes('primevue') || id.includes('@primeuix')) {
return 'vendor-primevue'
}
if (id.includes('@tiptap')) {
return 'vendor-tiptap'
}
if (id.includes('chart.js')) {
return 'vendor-chart'
}
if (id.includes('three') || id.includes('@xterm')) {
return 'vendor-visualization'
}
if (id.includes('/vue') || id.includes('pinia')) {
return 'vendor-vue'
}
return 'vendor-other'
}
}
}
},
@@ -196,7 +231,29 @@ export default defineConfig({
minifyIdentifiers: SHOULD_MINIFY,
keepNames: true,
minifySyntax: SHOULD_MINIFY,
minifyWhitespace: SHOULD_MINIFY
minifyWhitespace: SHOULD_MINIFY,
pure: SHOULD_MINIFY
? [
'console.log',
'console.debug',
'console.info',
'console.trace',
'console.dir',
'console.dirxml',
'console.group',
'console.groupCollapsed',
'console.groupEnd',
'console.table',
'console.time',
'console.timeEnd',
'console.timeLog',
'console.count',
'console.countReset',
'console.profile',
'console.profileEnd',
'console.clear'
]
: []
},
test: {
@@ -216,7 +273,9 @@ export default defineConfig({
__ALGOLIA_APP_ID__: JSON.stringify(process.env.ALGOLIA_APP_ID || ''),
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''),
__USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true',
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION)
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION),
__BUILD_FLAGS__: JSON.stringify(BUILD_FLAGS),
__MIXPANEL_TOKEN__: JSON.stringify(process.env.MIXPANEL_TOKEN || '')
},
resolve: {

View File

@@ -9,6 +9,9 @@ globalThis.__ALGOLIA_APP_ID__ = ''
globalThis.__ALGOLIA_API_KEY__ = ''
globalThis.__USE_PROD_CONFIG__ = false
globalThis.__DISTRIBUTION__ = 'localhost'
globalThis.__BUILD_FLAGS__ = {
REQUIRE_SUBSCRIPTION: true
}
// Mock Worker for extendable-media-recorder
globalThis.Worker = vi.fn().mockImplementation(() => ({