Decouple Desktop UI into monorepo app (#5912)
## Summary Extracts desktop UI into apps/desktop-ui package with minimal changes. ## Changes - **What**: - Separates desktop-specific code into standalone package with independent Vite config, router, and i18n - Drastically simplifies the main app router by removing all desktop routes - Adds a some code duplication, most due to the existing design - Some duplication can be refactored to be *simpler* on either side - no need to split things by `isElectron()` - Rudimentary storybook support has been added - **Breaking**: Stacked PR for publishing must be merged before this PR makes it to stable core (but publishing _could_ be done manually) - #5915 - **Dependencies**: Takes full advantage of pnpm catalog. No additional dependencies added. ## Review Focus - Should be no changes to normal frontend operation - Scripts added to root package.json are acceptable - The duplication in this PR is copied as is, wherever possible. Any corrections or fix-ups beyond the scope of simply migrating the functionality as-is, can be addressed in later PRs. That said, if any changes are made, it instantly becomes more difficult to separate the duplicated code out into a shared utility. - Tracking issue to address concerns: #5925 ### i18n Fixing i18n is out of scope for this PR. It is a larger task that we should consider carefully and implement properly. Attempting to isolate the desktop i18n and duplicate the _current_ localisation scripts would be wasted energy.
@@ -76,11 +76,6 @@ const config: StorybookConfig = {
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: () => {
|
||||
// Don't externalize any modules in Storybook build
|
||||
// This ensures PrimeVue and other dependencies are bundled
|
||||
return false
|
||||
},
|
||||
onwarn: (warning, warn) => {
|
||||
// Suppress specific warnings
|
||||
if (
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
# Desktop/Electron
|
||||
/src/types/desktop/ @webfiltered
|
||||
/src/constants/desktopDialogs.ts @webfiltered
|
||||
/src/constants/desktopMaintenanceTasks.ts @webfiltered
|
||||
/apps/desktop-ui/ @webfiltered
|
||||
/src/stores/electronDownloadStore.ts @webfiltered
|
||||
/src/extensions/core/electronAdapter.ts @webfiltered
|
||||
/src/views/DesktopDialogView.vue @webfiltered
|
||||
/src/components/install/ @webfiltered
|
||||
/src/components/maintenance/ @webfiltered
|
||||
/vite.electron.config.mts @webfiltered
|
||||
|
||||
# Common UI Components
|
||||
|
||||
103
apps/desktop-ui/.storybook/main.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { StorybookConfig } from '@storybook/vue3-vite'
|
||||
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
|
||||
import IconsResolver from 'unplugin-icons/resolver'
|
||||
import Icons from 'unplugin-icons/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import type { InlineConfig } from 'vite'
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: ['@storybook/addon-docs'],
|
||||
framework: {
|
||||
name: '@storybook/vue3-vite',
|
||||
options: {}
|
||||
},
|
||||
staticDirs: [{ from: '../public', to: '/' }],
|
||||
async viteFinal(config) {
|
||||
// Use dynamic import to avoid CJS deprecation warning
|
||||
const { mergeConfig } = await import('vite')
|
||||
const { default: tailwindcss } = await import('@tailwindcss/vite')
|
||||
|
||||
// Filter out any plugins that might generate import maps
|
||||
if (config.plugins) {
|
||||
config.plugins = config.plugins
|
||||
// Type guard: ensure we have valid plugin objects with names
|
||||
.filter(
|
||||
(plugin): plugin is NonNullable<typeof plugin> & { name: string } => {
|
||||
return (
|
||||
plugin !== null &&
|
||||
plugin !== undefined &&
|
||||
typeof plugin === 'object' &&
|
||||
'name' in plugin &&
|
||||
typeof plugin.name === 'string'
|
||||
)
|
||||
}
|
||||
)
|
||||
// Business logic: filter out import-map plugins
|
||||
.filter((plugin) => !plugin.name.includes('import-map'))
|
||||
}
|
||||
|
||||
return mergeConfig(config, {
|
||||
// Replace plugins entirely to avoid inheritance issues
|
||||
plugins: [
|
||||
// Only include plugins we explicitly need for Storybook
|
||||
tailwindcss(),
|
||||
Icons({
|
||||
compiler: 'vue3',
|
||||
customCollections: {
|
||||
comfy: FileSystemIconLoader(
|
||||
process.cwd() + '/../../packages/design-system/src/icons'
|
||||
)
|
||||
}
|
||||
}),
|
||||
Components({
|
||||
dts: false, // Disable dts generation in Storybook
|
||||
resolvers: [
|
||||
IconsResolver({
|
||||
customCollections: ['comfy']
|
||||
})
|
||||
],
|
||||
dirs: [
|
||||
process.cwd() + '/src/components',
|
||||
process.cwd() + '/src/views'
|
||||
],
|
||||
deep: true,
|
||||
extensions: ['vue'],
|
||||
directoryAsNamespace: true
|
||||
})
|
||||
],
|
||||
server: {
|
||||
allowedHosts: true
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': process.cwd() + '/src',
|
||||
'@frontend-locales': process.cwd() + '/../../src/locales'
|
||||
}
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
onwarn: (warning, warn) => {
|
||||
// Suppress specific warnings
|
||||
if (
|
||||
warning.code === 'UNUSED_EXTERNAL_IMPORT' &&
|
||||
warning.message?.includes('resolveComponent')
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Suppress Storybook font asset warnings
|
||||
if (
|
||||
warning.code === 'UNRESOLVED_IMPORT' &&
|
||||
warning.message?.includes('nunito-sans')
|
||||
) {
|
||||
return
|
||||
}
|
||||
warn(warning)
|
||||
}
|
||||
},
|
||||
chunkSizeWarningLimit: 1000
|
||||
}
|
||||
} satisfies InlineConfig)
|
||||
}
|
||||
}
|
||||
export default config
|
||||
88
apps/desktop-ui/.storybook/preview.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { definePreset } from '@primevue/themes'
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import { setup } from '@storybook/vue3'
|
||||
import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'primeicons/primeicons.css'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
|
||||
import '@/assets/css/style.css'
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
const ComfyUIPreset = definePreset(Aura, {
|
||||
semantic: {
|
||||
// @ts-expect-error prime type quirk
|
||||
primary: Aura['primitive'].blue
|
||||
}
|
||||
})
|
||||
|
||||
setup((app) => {
|
||||
app.directive('tooltip', Tooltip)
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
app.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: ComfyUIPreset,
|
||||
options: {
|
||||
prefix: 'p',
|
||||
cssLayer: { name: 'primevue', order: 'primevue, tailwind-utilities' },
|
||||
darkModeSelector: '.dark-theme, :root:has(.dark-theme)'
|
||||
}
|
||||
}
|
||||
})
|
||||
app.use(ConfirmationService)
|
||||
app.use(ToastService)
|
||||
})
|
||||
|
||||
export const withTheme = (Story: StoryFn, context: StoryContext) => {
|
||||
const theme = context.globals.theme || 'light'
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark-theme')
|
||||
document.body.classList.add('dark-theme')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark-theme')
|
||||
document.body.classList.remove('dark-theme')
|
||||
}
|
||||
|
||||
return Story(context.args, context)
|
||||
}
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: { color: /(background|color)$/i, date: /Date$/i }
|
||||
},
|
||||
backgrounds: {
|
||||
default: 'light',
|
||||
values: [
|
||||
{ name: 'light', value: '#ffffff' },
|
||||
{ name: 'dark', value: '#0a0a0a' }
|
||||
]
|
||||
}
|
||||
},
|
||||
globalTypes: {
|
||||
theme: {
|
||||
name: 'Theme',
|
||||
description: 'Global theme for components',
|
||||
defaultValue: 'light',
|
||||
toolbar: {
|
||||
icon: 'circlehollow',
|
||||
items: [
|
||||
{ value: 'light', icon: 'sun', title: 'Light' },
|
||||
{ value: 'dark', icon: 'moon', title: 'Dark' }
|
||||
],
|
||||
showName: true,
|
||||
dynamicTitle: true
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [withTheme]
|
||||
}
|
||||
|
||||
export default preview
|
||||
12
apps/desktop-ui/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>ComfyUI Desktop</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="desktop-app"></div>
|
||||
<script type="module" src="src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
117
apps/desktop-ui/package.json
Normal file
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"name": "@comfyorg/desktop-ui",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:desktop",
|
||||
"type:app"
|
||||
],
|
||||
"targets": {
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite --config vite.config.mts"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite --config vite.config.mts"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite build --config vite.config.mts"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
]
|
||||
},
|
||||
"preview": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite preview --config vite.config.mts"
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "storybook dev -p 6007"
|
||||
}
|
||||
},
|
||||
"build-storybook": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "storybook build -o dist/storybook"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist/storybook"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "eslint src --cache"
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vue-tsc --noEmit -p tsconfig.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"storybook": "storybook dev -p 6007",
|
||||
"build-storybook": "storybook build -o dist/storybook"
|
||||
},
|
||||
"dependencies": {
|
||||
"@comfyorg/comfyui-electron-types": "0.4.73-0",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@primevue/core": "catalog:",
|
||||
"@primevue/themes": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"pinia": "catalog:",
|
||||
"primeicons": "catalog:",
|
||||
"primevue": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-i18n": "catalog:",
|
||||
"vue-router": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@vitejs/plugin-vue": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"unplugin-icons": "catalog:",
|
||||
"unplugin-vue-components": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-html": "catalog:",
|
||||
"vite-plugin-vue-devtools": "catalog:",
|
||||
"vue-tsc": "catalog:"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 174 KiB |
7
apps/desktop-ui/src/App.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
6
apps/desktop-ui/src/assets/css/style.css
Normal file
@@ -0,0 +1,6 @@
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
|
||||
#desktop-app {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="relative overflow-hidden h-full w-full bg-neutral-900"
|
||||
>
|
||||
<div class="p-terminal rounded-none h-full w-full p-2">
|
||||
<div ref="terminalEl" class="h-full terminal-host" />
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip.left="{
|
||||
value: tooltipText,
|
||||
showDelay: 300
|
||||
}"
|
||||
icon="pi pi-copy"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
:class="
|
||||
cn('absolute top-2 right-8 transition-opacity', {
|
||||
'opacity-0 pointer-events-none select-none': !isHovered
|
||||
})
|
||||
"
|
||||
:aria-label="tooltipText"
|
||||
@click="handleCopy"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover, useEventListener } from '@vueuse/core'
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
import Button from 'primevue/button'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [ReturnType<typeof useTerminal>, Ref<HTMLElement | undefined>]
|
||||
unmounted: []
|
||||
}>()
|
||||
const terminalEl = ref<HTMLElement | undefined>()
|
||||
const rootEl = ref<HTMLElement | undefined>()
|
||||
const hasSelection = ref(false)
|
||||
|
||||
const isHovered = useElementHover(rootEl)
|
||||
|
||||
const terminalData = useTerminal(terminalEl)
|
||||
emit('created', terminalData, ref(rootEl))
|
||||
|
||||
const { terminal } = terminalData
|
||||
let selectionDisposable: IDisposable | undefined
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
return hasSelection.value
|
||||
? t('serverStart.copySelectionTooltip')
|
||||
: t('serverStart.copyAllTooltip')
|
||||
})
|
||||
|
||||
const handleCopy = async () => {
|
||||
const existingSelection = terminal.getSelection()
|
||||
const shouldSelectAll = !existingSelection
|
||||
if (shouldSelectAll) terminal.selectAll()
|
||||
|
||||
const selectedText = shouldSelectAll
|
||||
? terminal.getSelection()
|
||||
: existingSelection
|
||||
|
||||
if (selectedText) {
|
||||
await navigator.clipboard.writeText(selectedText)
|
||||
|
||||
if (shouldSelectAll) {
|
||||
terminal.clearSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showContextMenu = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
electronAPI()?.showContextMenu({ type: 'text' })
|
||||
}
|
||||
|
||||
if (isElectron()) {
|
||||
useEventListener(terminalEl, 'contextmenu', showContextMenu)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
selectionDisposable = terminal.onSelectionChange(() => {
|
||||
hasSelection.value = terminal.hasSelection()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
selectionDisposable?.dispose()
|
||||
emit('unmounted')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-terminal) .xterm {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
:deep(.p-terminal) .xterm-screen {
|
||||
@apply bg-neutral-900 overflow-hidden;
|
||||
}
|
||||
</style>
|
||||
129
apps/desktop-ui/src/components/common/UrlInput.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<IconField class="w-full">
|
||||
<InputText
|
||||
v-bind="$attrs"
|
||||
:model-value="internalValue"
|
||||
class="w-full"
|
||||
:invalid="validationState === ValidationState.INVALID"
|
||||
@update:model-value="handleInput"
|
||||
@blur="handleBlur"
|
||||
/>
|
||||
<InputIcon
|
||||
:class="{
|
||||
'pi pi-spin pi-spinner text-neutral-400':
|
||||
validationState === ValidationState.LOADING,
|
||||
'pi pi-check text-green-500 cursor-pointer':
|
||||
validationState === ValidationState.VALID,
|
||||
'pi pi-times text-red-500 cursor-pointer':
|
||||
validationState === ValidationState.INVALID
|
||||
}"
|
||||
@click="validateUrl(props.modelValue)"
|
||||
/>
|
||||
</IconField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isValidUrl } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import { checkUrlReachable } from '@comfyorg/shared-frontend-utils/networkUtil'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
validateUrlFn?: (url: string) => Promise<boolean>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
'state-change': [state: ValidationState]
|
||||
}>()
|
||||
|
||||
const validationState = ref<ValidationState>(ValidationState.IDLE)
|
||||
|
||||
const cleanInput = (value: string): string =>
|
||||
value ? value.replace(/\s+/g, '') : ''
|
||||
|
||||
// Add internal value state
|
||||
const internalValue = ref(cleanInput(props.modelValue))
|
||||
|
||||
// Watch for external modelValue changes
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (newValue: string) => {
|
||||
internalValue.value = cleanInput(newValue)
|
||||
await validateUrl(newValue)
|
||||
}
|
||||
)
|
||||
|
||||
watch(validationState, (newState) => {
|
||||
emit('state-change', newState)
|
||||
})
|
||||
|
||||
// Validate on mount
|
||||
onMounted(async () => {
|
||||
await validateUrl(props.modelValue)
|
||||
})
|
||||
|
||||
const handleInput = (value: string | undefined) => {
|
||||
// Update internal value without emitting
|
||||
internalValue.value = cleanInput(value ?? '')
|
||||
// Reset validation state when user types
|
||||
validationState.value = ValidationState.IDLE
|
||||
}
|
||||
|
||||
const handleBlur = async () => {
|
||||
const input = cleanInput(internalValue.value)
|
||||
|
||||
let normalizedUrl = input
|
||||
try {
|
||||
const url = new URL(input)
|
||||
normalizedUrl = url.toString()
|
||||
} catch {
|
||||
// If URL parsing fails, just use the cleaned input
|
||||
}
|
||||
|
||||
// Emit the update only on blur
|
||||
emit('update:modelValue', normalizedUrl)
|
||||
}
|
||||
|
||||
// Default validation implementation
|
||||
const defaultValidateUrl = async (url: string): Promise<boolean> => {
|
||||
if (!isValidUrl(url)) return false
|
||||
try {
|
||||
return await checkUrlReachable(url)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const validateUrl = async (value: string) => {
|
||||
if (validationState.value === ValidationState.LOADING) return
|
||||
|
||||
const url = cleanInput(value)
|
||||
|
||||
// Reset state
|
||||
validationState.value = ValidationState.IDLE
|
||||
|
||||
// Skip validation if empty
|
||||
if (!url) return
|
||||
|
||||
validationState.value = ValidationState.LOADING
|
||||
try {
|
||||
const isValid = await (props.validateUrlFn ?? defaultValidateUrl)(url)
|
||||
validationState.value = isValid
|
||||
? ValidationState.VALID
|
||||
: ValidationState.INVALID
|
||||
} catch {
|
||||
validationState.value = ValidationState.INVALID
|
||||
}
|
||||
}
|
||||
|
||||
// Add inheritAttrs option to prevent attrs from being applied to root element
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
</script>
|
||||
@@ -106,6 +106,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
|
||||
import { isInChina } from '@comfyorg/shared-frontend-utils/networkUtil'
|
||||
import Accordion from 'primevue/accordion'
|
||||
import AccordionContent from 'primevue/accordioncontent'
|
||||
import AccordionHeader from 'primevue/accordionheader'
|
||||
@@ -125,7 +126,6 @@ import {
|
||||
type UVMirror
|
||||
} from '@/constants/uvMirrors'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -53,6 +53,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Divider from 'primevue/divider'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
@@ -61,7 +62,6 @@ import UrlInput from '@/components/common/UrlInput.vue'
|
||||
import type { UVMirror } from '@/constants/uvMirrors'
|
||||
import { st } from '@/i18n'
|
||||
import { checkMirrorReachable } from '@/utils/electronMirrorCheck'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
const FILE_URL_SCHEME = 'file://'
|
||||
105
apps/desktop-ui/src/composables/bottomPanelTabs/useTerminal.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import type { Ref } from 'vue'
|
||||
import { markRaw, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function useTerminal(element: Ref<HTMLElement | undefined>) {
|
||||
const fitAddon = new FitAddon()
|
||||
const terminal = markRaw(
|
||||
new Terminal({
|
||||
convertEol: true,
|
||||
theme: { background: '#171717' }
|
||||
})
|
||||
)
|
||||
terminal.loadAddon(fitAddon)
|
||||
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
// Allow default browser copy/paste handling
|
||||
if (
|
||||
event.type === 'keydown' &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
((event.key === 'c' && terminal.hasSelection()) || event.key === 'v')
|
||||
) {
|
||||
// TODO: Deselect text after copy/paste; use IPC.
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (element.value) {
|
||||
terminal.open(element.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
return {
|
||||
terminal,
|
||||
useAutoSize({
|
||||
root,
|
||||
autoRows = true,
|
||||
autoCols = true,
|
||||
minCols = Number.NEGATIVE_INFINITY,
|
||||
minRows = Number.NEGATIVE_INFINITY,
|
||||
onResize
|
||||
}: {
|
||||
root: Ref<HTMLElement | undefined>
|
||||
autoRows?: boolean
|
||||
autoCols?: boolean
|
||||
minCols?: number
|
||||
minRows?: number
|
||||
onResize?: () => void
|
||||
}) {
|
||||
const ensureValidRows = (rows: number | undefined): number => {
|
||||
if (rows == null || isNaN(rows)) {
|
||||
return (root.value?.clientHeight ?? 80) / 20
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const ensureValidCols = (cols: number | undefined): number => {
|
||||
if (cols == null || isNaN(cols)) {
|
||||
// Sometimes this is NaN if so, estimate.
|
||||
return (root.value?.clientWidth ?? 80) / 8
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
const resize = () => {
|
||||
const dims = fitAddon.proposeDimensions()
|
||||
// Sometimes propose returns NaN, so we may need to estimate.
|
||||
terminal.resize(
|
||||
Math.max(
|
||||
autoCols ? ensureValidCols(dims?.cols) : terminal.cols,
|
||||
minCols
|
||||
),
|
||||
Math.max(
|
||||
autoRows ? ensureValidRows(dims?.rows) : terminal.rows,
|
||||
minRows
|
||||
)
|
||||
)
|
||||
onResize?.()
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(debounce(resize, 25))
|
||||
|
||||
onMounted(async () => {
|
||||
if (root.value) {
|
||||
resizeObserver.observe(root.value)
|
||||
resize()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver.disconnect()
|
||||
})
|
||||
|
||||
return { resize }
|
||||
}
|
||||
}
|
||||
}
|
||||
34
apps/desktop-ui/src/constants/uvMirrors.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface UVMirror {
|
||||
/**
|
||||
* The setting id defined for the mirror.
|
||||
*/
|
||||
settingId: string
|
||||
/**
|
||||
* The default mirror to use.
|
||||
*/
|
||||
mirror: string
|
||||
/**
|
||||
* The fallback mirror to use.
|
||||
*/
|
||||
fallbackMirror: string
|
||||
/**
|
||||
* The path suffix to validate the mirror is reachable.
|
||||
*/
|
||||
validationPathSuffix?: string
|
||||
}
|
||||
|
||||
export const PYTHON_MIRROR: UVMirror = {
|
||||
settingId: 'Comfy-Desktop.UV.PythonInstallMirror',
|
||||
mirror:
|
||||
'https://github.com/astral-sh/python-build-standalone/releases/download',
|
||||
fallbackMirror:
|
||||
'https://python-standalone.org/mirror/astral-sh/python-build-standalone',
|
||||
validationPathSuffix:
|
||||
'/20250115/cpython-3.10.16+20250115-aarch64-apple-darwin-debug-full.tar.zst.sha256'
|
||||
}
|
||||
|
||||
export const PYPI_MIRROR: UVMirror = {
|
||||
settingId: 'Comfy-Desktop.UV.PypiInstallMirror',
|
||||
mirror: 'https://pypi.org/simple/',
|
||||
fallbackMirror: 'https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple'
|
||||
}
|
||||
88
apps/desktop-ui/src/i18n.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
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 enCommands from '@frontend-locales/en/commands.json' with { type: 'json' }
|
||||
import en from '@frontend-locales/en/main.json' with { type: 'json' }
|
||||
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
|
||||
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) {
|
||||
return {
|
||||
...main,
|
||||
nodeDefs: nodes,
|
||||
commands: commands,
|
||||
settings: settings
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
// Must set `false`, as Vue I18n Legacy API is for Vue 2
|
||||
legacy: false,
|
||||
locale: navigator.language.split('-')[0] || 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
// Ignore warnings for locale options as each option is in its own language.
|
||||
// e.g. "English", "中文", "Русский", "日本語", "한국어", "Français", "Español"
|
||||
missingWarn: /^(?!settings\.Comfy_Locale\.options\.).+/,
|
||||
fallbackWarn: /^(?!settings\.Comfy_Locale\.options\.).+/
|
||||
})
|
||||
|
||||
/** Convenience shorthand: i18n.global */
|
||||
export const { t, te } = i18n.global
|
||||
|
||||
/**
|
||||
* Safe translation function that returns the fallback message if the key is not found.
|
||||
*
|
||||
* @param key - The key to translate.
|
||||
* @param fallbackMessage - The fallback message to use if the key is not found.
|
||||
*/
|
||||
export function st(key: string, fallbackMessage: string) {
|
||||
return te(key) ? t(key) : fallbackMessage
|
||||
}
|
||||
46
apps/desktop-ui/src/main.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { definePreset } from '@primevue/themes'
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'primeicons/primeicons.css'
|
||||
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 App from './App.vue'
|
||||
import './assets/css/style.css'
|
||||
import { i18n } from './i18n'
|
||||
import router from './router'
|
||||
|
||||
const ComfyUIPreset = definePreset(Aura, {
|
||||
semantic: {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
primary: Aura['primitive'].blue
|
||||
}
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.directive('tooltip', Tooltip)
|
||||
app
|
||||
.use(router)
|
||||
.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: ComfyUIPreset,
|
||||
options: {
|
||||
prefix: 'p',
|
||||
cssLayer: {
|
||||
name: 'primevue',
|
||||
order: 'theme, base, primevue'
|
||||
},
|
||||
darkModeSelector: '.dark-theme, :root:has(.dark-theme)'
|
||||
}
|
||||
}
|
||||
})
|
||||
.use(ConfirmationService)
|
||||
.use(ToastService)
|
||||
.use(pinia)
|
||||
.use(i18n)
|
||||
.mount('#desktop-app')
|
||||
92
apps/desktop-ui/src/router.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
createRouter,
|
||||
createWebHashHistory,
|
||||
createWebHistory
|
||||
} from 'vue-router'
|
||||
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
|
||||
|
||||
const isFileProtocol = window.location.protocol === 'file:'
|
||||
const basePath = isElectron() ? '/' : window.location.pathname
|
||||
|
||||
const router = createRouter({
|
||||
history: isFileProtocol ? createWebHashHistory() : createWebHistory(basePath),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: LayoutDefault,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'WelcomeView',
|
||||
component: () => import('@/views/WelcomeView.vue')
|
||||
},
|
||||
{
|
||||
path: 'welcome',
|
||||
name: 'WelcomeViewAlias',
|
||||
component: () => import('@/views/WelcomeView.vue')
|
||||
},
|
||||
{
|
||||
path: 'install',
|
||||
name: 'InstallView',
|
||||
component: () => import('@/views/InstallView.vue')
|
||||
},
|
||||
{
|
||||
path: 'download-git',
|
||||
name: 'DownloadGitView',
|
||||
component: () => import('@/views/DownloadGitView.vue')
|
||||
},
|
||||
{
|
||||
path: 'desktop-start',
|
||||
name: 'DesktopStartView',
|
||||
component: () => import('@/views/DesktopStartView.vue')
|
||||
},
|
||||
{
|
||||
path: 'desktop-update',
|
||||
name: 'DesktopUpdateView',
|
||||
component: () => import('@/views/DesktopUpdateView.vue')
|
||||
},
|
||||
{
|
||||
path: 'server-start',
|
||||
name: 'ServerStartView',
|
||||
component: () => import('@/views/ServerStartView.vue')
|
||||
},
|
||||
{
|
||||
path: 'manual-configuration',
|
||||
name: 'ManualConfigurationView',
|
||||
component: () => import('@/views/ManualConfigurationView.vue')
|
||||
},
|
||||
{
|
||||
path: 'metrics-consent',
|
||||
name: 'MetricsConsentView',
|
||||
component: () => import('@/views/MetricsConsentView.vue')
|
||||
},
|
||||
{
|
||||
path: 'maintenance',
|
||||
name: 'MaintenanceView',
|
||||
component: () => import('@/views/MaintenanceView.vue')
|
||||
},
|
||||
{
|
||||
path: 'not-supported',
|
||||
name: 'NotSupportedView',
|
||||
component: () => import('@/views/NotSupportedView.vue')
|
||||
},
|
||||
{
|
||||
path: 'desktop-dialog/:dialogId',
|
||||
name: 'DesktopDialogView',
|
||||
component: () => import('@/views/DesktopDialogView.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
scrollBehavior(_to, _from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
}
|
||||
|
||||
return { top: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
12
apps/desktop-ui/src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
declare global {
|
||||
interface Navigator {
|
||||
/**
|
||||
* Desktop app uses windowControlsOverlay to decide if it is in a custom window.
|
||||
*/
|
||||
windowControlsOverlay?: {
|
||||
visible: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
14
apps/desktop-ui/src/utils/electronMirrorCheck.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { isValidUrl } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
|
||||
import { electronAPI } from './envUtil'
|
||||
|
||||
/**
|
||||
* Check if a mirror is reachable from the electron App.
|
||||
* @param mirror - The mirror to check.
|
||||
* @returns True if the mirror is reachable, false otherwise.
|
||||
*/
|
||||
export const checkMirrorReachable = async (mirror: string) => {
|
||||
return (
|
||||
isValidUrl(mirror) && (await electronAPI().NetWork.canAccessUrl(mirror))
|
||||
)
|
||||
}
|
||||
13
apps/desktop-ui/src/utils/envUtil.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ElectronAPI } from '@comfyorg/comfyui-electron-types'
|
||||
|
||||
export function isElectron() {
|
||||
return 'electronAPI' in window && window.electronAPI !== undefined
|
||||
}
|
||||
|
||||
export function electronAPI() {
|
||||
return (window as any).electronAPI as ElectronAPI
|
||||
}
|
||||
|
||||
export function isNativeWindow() {
|
||||
return isElectron() && !!window.navigator.windowControlsOverlay?.visible
|
||||
}
|
||||
1
apps/desktop-ui/src/utils/tailwindUtil.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { cn } from '@comfyorg/tailwind-utils'
|
||||
6
apps/desktop-ui/src/utils/validationUtil.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum ValidationState {
|
||||
IDLE = 'IDLE',
|
||||
LOADING = 'LOADING',
|
||||
VALID = 'VALID',
|
||||
INVALID = 'INVALID'
|
||||
}
|
||||
@@ -25,13 +25,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import Button from 'primevue/button'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { type DialogAction, getDialog } from '@/constants/desktopDialogs'
|
||||
import { t } from '@/i18n'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
const route = useRoute()
|
||||
const { id, title, message, buttons } = getDialog(route.params.dialogId)
|
||||
11
apps/desktop-ui/src/views/layouts/LayoutDefault.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<main class="w-full h-full overflow-hidden relative">
|
||||
<router-view />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFavicon } from '@vueuse/core'
|
||||
|
||||
useFavicon('/assets/favicon.ico')
|
||||
</script>
|
||||
52
apps/desktop-ui/src/views/templates/BaseViewTemplate.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div
|
||||
class="font-sans w-screen h-screen flex flex-col"
|
||||
:class="[
|
||||
dark
|
||||
? 'text-neutral-300 bg-neutral-900 dark-theme'
|
||||
: 'text-neutral-900 bg-neutral-300'
|
||||
]"
|
||||
>
|
||||
<!-- Virtual top menu for native window (drag handle) -->
|
||||
<div
|
||||
v-show="isNativeWindow()"
|
||||
ref="topMenuRef"
|
||||
class="app-drag w-full h-(--comfy-topbar-height)"
|
||||
/>
|
||||
<div class="grow w-full flex items-center justify-center overflow-auto">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
import { electronAPI, isElectron, isNativeWindow } from '../../utils/envUtil'
|
||||
|
||||
const { dark = false } = defineProps<{
|
||||
dark?: boolean
|
||||
}>()
|
||||
|
||||
const darkTheme = {
|
||||
color: 'rgba(0, 0, 0, 0)',
|
||||
symbolColor: '#d4d4d4'
|
||||
}
|
||||
|
||||
const lightTheme = {
|
||||
color: 'rgba(0, 0, 0, 0)',
|
||||
symbolColor: '#171717'
|
||||
}
|
||||
|
||||
const topMenuRef = ref<HTMLDivElement | null>(null)
|
||||
onMounted(async () => {
|
||||
if (isElectron()) {
|
||||
await nextTick()
|
||||
|
||||
electronAPI().changeTheme({
|
||||
...(dark ? darkTheme : lightTheme),
|
||||
height: topMenuRef.value?.getBoundingClientRect().height ?? 0
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
20
apps/desktop-ui/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@frontend-locales/*": ["../../src/locales/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
".storybook/**/*",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.vue",
|
||||
"src/**/*.d.ts",
|
||||
"vite.config.mts"
|
||||
],
|
||||
"references": []
|
||||
}
|
||||
72
apps/desktop-ui/vite.config.mts
Normal file
@@ -0,0 +1,72 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import dotenv from 'dotenv'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
|
||||
import IconsResolver from 'unplugin-icons/resolver'
|
||||
import Icons from 'unplugin-icons/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import { createHtmlPlugin } from 'vite-plugin-html'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
const SHOULD_MINIFY = process.env.ENABLE_MINIFY === 'true'
|
||||
const VITE_REMOTE_DEV = process.env.VITE_REMOTE_DEV === 'true'
|
||||
const DISABLE_VUE_PLUGINS = process.env.DISABLE_VUE_PLUGINS === 'true'
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
root: projectRoot,
|
||||
base: '',
|
||||
publicDir: path.resolve(projectRoot, 'public'),
|
||||
server: {
|
||||
port: 5174,
|
||||
host: VITE_REMOTE_DEV ? '0.0.0.0' : undefined
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(projectRoot, 'src'),
|
||||
'@frontend-locales': path.resolve(projectRoot, '../../src/locales')
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
...(!DISABLE_VUE_PLUGINS
|
||||
? [vueDevTools(), vue(), createHtmlPlugin({})]
|
||||
: [vue()]),
|
||||
tailwindcss(),
|
||||
Icons({
|
||||
compiler: 'vue3',
|
||||
customCollections: {
|
||||
comfy: FileSystemIconLoader(
|
||||
path.resolve(projectRoot, '../../packages/design-system/src/icons')
|
||||
)
|
||||
}
|
||||
}),
|
||||
Components({
|
||||
dts: path.resolve(projectRoot, 'components.d.ts'),
|
||||
resolvers: [
|
||||
IconsResolver({
|
||||
customCollections: ['comfy']
|
||||
})
|
||||
],
|
||||
dirs: [
|
||||
path.resolve(projectRoot, 'src/components'),
|
||||
path.resolve(projectRoot, 'src/views')
|
||||
],
|
||||
deep: true,
|
||||
extensions: ['vue'],
|
||||
directoryAsNamespace: true
|
||||
})
|
||||
],
|
||||
build: {
|
||||
minify: SHOULD_MINIFY ? ('esbuild' as const) : false,
|
||||
target: 'es2022',
|
||||
sourcemap: true
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -8,10 +8,12 @@
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"build:desktop": "nx build @comfyorg/desktop-ui",
|
||||
"build-storybook": "storybook build",
|
||||
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"build": "pnpm typecheck && nx build",
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"dev:desktop": "nx dev @comfyorg/desktop-ui",
|
||||
"dev:electron": "nx serve --config vite.electron.config.mts",
|
||||
"dev": "nx serve",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
|
||||
64
pnpm-lock.yaml
generated
@@ -610,6 +610,70 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: 3.24.1(zod@3.24.1)
|
||||
|
||||
apps/desktop-ui:
|
||||
dependencies:
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: 0.4.73-0
|
||||
version: 0.4.73-0
|
||||
'@comfyorg/shared-frontend-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared-frontend-utils
|
||||
'@primevue/core':
|
||||
specifier: 'catalog:'
|
||||
version: 4.2.5(vue@3.5.13(typescript@5.9.2))
|
||||
'@primevue/themes':
|
||||
specifier: 'catalog:'
|
||||
version: 4.2.5
|
||||
'@vueuse/core':
|
||||
specifier: 'catalog:'
|
||||
version: 11.0.0(vue@3.5.13(typescript@5.9.2))
|
||||
pinia:
|
||||
specifier: 'catalog:'
|
||||
version: 2.2.2(typescript@5.9.2)(vue@3.5.13(typescript@5.9.2))
|
||||
primeicons:
|
||||
specifier: 'catalog:'
|
||||
version: 7.0.0
|
||||
primevue:
|
||||
specifier: 'catalog:'
|
||||
version: 4.2.5(vue@3.5.13(typescript@5.9.2))
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.13(typescript@5.9.2)
|
||||
vue-i18n:
|
||||
specifier: 'catalog:'
|
||||
version: 9.14.3(vue@3.5.13(typescript@5.9.2))
|
||||
vue-router:
|
||||
specifier: 'catalog:'
|
||||
version: 4.4.3(vue@3.5.13(typescript@5.9.2))
|
||||
devDependencies:
|
||||
'@tailwindcss/vite':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.12(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: 'catalog:'
|
||||
version: 5.1.4(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))(vue@3.5.13(typescript@5.9.2))
|
||||
dotenv:
|
||||
specifier: 'catalog:'
|
||||
version: 16.6.1
|
||||
unplugin-icons:
|
||||
specifier: 'catalog:'
|
||||
version: 0.22.0(@vue/compiler-sfc@3.5.13)
|
||||
unplugin-vue-components:
|
||||
specifier: 'catalog:'
|
||||
version: 0.28.0(@babel/parser@7.28.4)(rollup@4.22.4)(vue@3.5.13(typescript@5.9.2))
|
||||
vite:
|
||||
specifier: 'catalog:'
|
||||
version: 5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)
|
||||
vite-plugin-html:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.2(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
|
||||
vite-plugin-vue-devtools:
|
||||
specifier: 'catalog:'
|
||||
version: 7.7.6(rollup@4.22.4)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))(vue@3.5.13(typescript@5.9.2))
|
||||
vue-tsc:
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.7(typescript@5.9.2)
|
||||
|
||||
packages/design-system:
|
||||
dependencies:
|
||||
'@iconify-json/lucide':
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as fs from 'fs'
|
||||
|
||||
import { DESKTOP_DIALOGS } from '../apps/desktop-ui/src/constants/desktopDialogs'
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import {
|
||||
formatCamelCase,
|
||||
normalizeI18nKey
|
||||
} from '../packages/shared-frontend-utils/src/formatUtil'
|
||||
import { CORE_MENU_COMMANDS } from '../src/constants/coreMenuCommands'
|
||||
import { DESKTOP_DIALOGS } from '../src/constants/desktopDialogs'
|
||||
import { SERVER_CONFIG_ITEMS } from '../src/constants/serverConfig'
|
||||
import type { FormItem, SettingParams } from '../src/platform/settings/types'
|
||||
import type { ComfyCommandImpl } from '../src/stores/commandStore'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface UVMirror {
|
||||
interface UVMirror {
|
||||
/**
|
||||
* The setting id defined for the mirror.
|
||||
*/
|
||||
@@ -26,9 +26,3 @@ export const PYTHON_MIRROR: UVMirror = {
|
||||
validationPathSuffix:
|
||||
'/20250115/cpython-3.10.16+20250115-aarch64-apple-darwin-debug-full.tar.zst.sha256'
|
||||
}
|
||||
|
||||
export const PYPI_MIRROR: UVMirror = {
|
||||
settingId: 'Comfy-Desktop.UV.PypiInstallMirror',
|
||||
mirror: 'https://pypi.org/simple/',
|
||||
fallbackMirror: 'https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple'
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
|
||||
import {
|
||||
createRouter,
|
||||
createWebHashHistory,
|
||||
@@ -13,18 +12,6 @@ import { isElectron } from './utils/envUtil'
|
||||
const isFileProtocol = window.location.protocol === 'file:'
|
||||
const basePath = isElectron() ? '/' : window.location.pathname
|
||||
|
||||
const guardElectronAccess = (
|
||||
_to: RouteLocationNormalized,
|
||||
_from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext
|
||||
) => {
|
||||
if (isElectron()) {
|
||||
next()
|
||||
} else {
|
||||
next('/')
|
||||
}
|
||||
}
|
||||
|
||||
const router = createRouter({
|
||||
history: isFileProtocol
|
||||
? createWebHashHistory()
|
||||
@@ -55,72 +42,6 @@ const router = createRouter({
|
||||
path: 'user-select',
|
||||
name: 'UserSelectView',
|
||||
component: () => import('@/views/UserSelectView.vue')
|
||||
},
|
||||
{
|
||||
path: 'server-start',
|
||||
name: 'ServerStartView',
|
||||
component: () => import('@/views/ServerStartView.vue'),
|
||||
beforeEnter: guardElectronAccess
|
||||
},
|
||||
{
|
||||
path: 'install',
|
||||
name: 'InstallView',
|
||||
component: () => import('@/views/InstallView.vue'),
|
||||
beforeEnter: guardElectronAccess
|
||||
},
|
||||
{
|
||||
path: 'welcome',
|
||||
name: 'WelcomeView',
|
||||
component: () => import('@/views/WelcomeView.vue'),
|
||||
beforeEnter: guardElectronAccess
|
||||
},
|
||||
{
|
||||
path: 'not-supported',
|
||||
name: 'NotSupportedView',
|
||||
component: () => import('@/views/NotSupportedView.vue'),
|
||||
beforeEnter: guardElectronAccess
|
||||
},
|
||||
{
|
||||
path: 'download-git',
|
||||
name: 'DownloadGitView',
|
||||
component: () => import('@/views/DownloadGitView.vue'),
|
||||
beforeEnter: guardElectronAccess
|
||||
},
|
||||
{
|
||||
path: 'manual-configuration',
|
||||
name: 'ManualConfigurationView',
|
||||
component: () => import('@/views/ManualConfigurationView.vue'),
|
||||
beforeEnter: guardElectronAccess
|
||||
},
|
||||
{
|
||||
path: '/metrics-consent',
|
||||
name: 'MetricsConsentView',
|
||||
component: () => import('@/views/MetricsConsentView.vue'),
|
||||
beforeEnter: guardElectronAccess
|
||||
},
|
||||
{
|
||||
path: 'desktop-start',
|
||||
name: 'DesktopStartView',
|
||||
component: () => import('@/views/DesktopStartView.vue'),
|
||||
beforeEnter: guardElectronAccess
|
||||
},
|
||||
{
|
||||
path: 'maintenance',
|
||||
name: 'MaintenanceView',
|
||||
component: () => import('@/views/MaintenanceView.vue'),
|
||||
beforeEnter: guardElectronAccess
|
||||
},
|
||||
{
|
||||
path: 'desktop-update',
|
||||
name: 'DesktopUpdateView',
|
||||
component: () => import('@/views/DesktopUpdateView.vue'),
|
||||
beforeEnter: guardElectronAccess
|
||||
},
|
||||
{
|
||||
path: 'desktop-dialog/:dialogId',
|
||||
name: 'DesktopDialogView',
|
||||
component: () => import('@/views/DesktopDialogView.vue'),
|
||||
beforeEnter: guardElectronAccess
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||