Rework desktop install / startup UX (#5292)

### Summary

Complete redesign of Desktop app installer, implementing the new app
startup progress system and error reporting.
This commit is contained in:
filtered
2025-09-25 05:06:58 +10:00
committed by GitHub
parent 6449d26cee
commit b0f81b2245
24 changed files with 1635 additions and 628 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -1,5 +1,8 @@
<template> <template>
<div ref="rootEl" class="relative overflow-hidden h-full w-full bg-black"> <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 class="p-terminal rounded-none h-full w-full p-2">
<div ref="terminalEl" class="h-full terminal-host" /> <div ref="terminalEl" class="h-full terminal-host" />
</div> </div>
@@ -98,12 +101,13 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
@reference '../../../../assets/css/style.css';
:deep(.p-terminal) .xterm { :deep(.p-terminal) .xterm {
overflow-x: auto; @apply overflow-hidden;
} }
:deep(.p-terminal) .xterm-screen { :deep(.p-terminal) .xterm-screen {
background-color: black; @apply bg-neutral-900 overflow-hidden;
overflow-y: hidden;
} }
</style> </style>

View File

@@ -0,0 +1,71 @@
<template>
<div :class="wrapperClass">
<div class="grid grid-rows-2 gap-8">
<!-- Top container: Logo -->
<div class="flex items-end justify-center">
<img
src="/assets/images/comfy-brand-mark.svg"
:alt="t('g.logoAlt')"
class="w-60"
/>
</div>
<!-- Bottom container: Progress and text -->
<div class="flex flex-col items-center justify-center gap-4">
<ProgressBar
v-if="!hideProgress"
:mode="progressMode"
:value="progressPercentage ?? 0"
:show-value="false"
class="w-90 h-2 mt-8"
:pt="{ value: { class: 'bg-brand-yellow' } }"
/>
<h1 v-if="title" class="font-inter font-bold text-3xl text-neutral-300">
{{ title }}
</h1>
<p v-if="statusText" class="text-lg text-neutral-400">
{{ statusText }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ProgressBar from 'primevue/progressbar'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
/** Props for the StartupDisplay component */
interface StartupDisplayProps {
/** Progress: 0-100 for determinate, undefined for indeterminate */
progressPercentage?: number
/** Main title text */
title?: string
/** Status text shown below the title */
statusText?: string
/** Hide the progress bar */
hideProgress?: boolean
/** Use full screen wrapper (default: true) */
fullScreen?: boolean
}
const {
progressPercentage,
title,
statusText,
hideProgress = false,
fullScreen = true
} = defineProps<StartupDisplayProps>()
const progressMode = computed(() =>
progressPercentage === undefined ? 'indeterminate' : 'determinate'
)
const wrapperClass = computed(() =>
fullScreen
? 'flex items-center justify-center min-h-screen'
: 'flex items-center justify-center'
)
</script>

View File

@@ -10,14 +10,14 @@
</p> </p>
</div> </div>
<div class="flex flex-col bg-neutral-800 p-4 rounded-lg"> <div class="flex flex-col bg-neutral-800 p-4 rounded-lg text-sm">
<!-- Auto Update Setting --> <!-- Auto Update Setting -->
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="flex-1"> <div class="flex-1">
<h3 class="text-lg font-medium text-neutral-100"> <h3 class="text-lg font-medium text-neutral-100">
{{ $t('install.settings.autoUpdate') }} {{ $t('install.settings.autoUpdate') }}
</h3> </h3>
<p class="text-sm text-neutral-400 mt-1"> <p class="text-neutral-400 mt-1">
{{ $t('install.settings.autoUpdateDescription') }} {{ $t('install.settings.autoUpdateDescription') }}
</p> </p>
</div> </div>
@@ -32,14 +32,10 @@
<h3 class="text-lg font-medium text-neutral-100"> <h3 class="text-lg font-medium text-neutral-100">
{{ $t('install.settings.allowMetrics') }} {{ $t('install.settings.allowMetrics') }}
</h3> </h3>
<p class="text-sm text-neutral-400 mt-1"> <p class="text-neutral-400">
{{ $t('install.settings.allowMetricsDescription') }} {{ $t('install.settings.allowMetricsDescription') }}
</p> </p>
<a <a href="#" @click.prevent="showMetricsInfo">
href="#"
class="text-sm text-blue-400 hover:text-blue-300 mt-1 inline-block"
@click.prevent="showMetricsInfo"
>
{{ $t('install.settings.learnMoreAboutData') }} {{ $t('install.settings.learnMoreAboutData') }}
</a> </a>
</div> </div>
@@ -51,7 +47,9 @@
<Dialog <Dialog
v-model:visible="showDialog" v-model:visible="showDialog"
modal modal
dismissable-mask
:header="$t('install.settings.dataCollectionDialog.title')" :header="$t('install.settings.dataCollectionDialog.title')"
class="select-none"
> >
<div class="text-neutral-300"> <div class="text-neutral-300">
<h4 class="font-medium mb-2"> <h4 class="font-medium mb-2">
@@ -110,11 +108,7 @@
</ul> </ul>
<div class="mt-4"> <div class="mt-4">
<a <a href="https://comfy.org/privacy" target="_blank">
href="https://comfy.org/privacy"
target="_blank"
class="text-blue-400 hover:text-blue-300 underline"
>
{{ $t('install.settings.dataCollectionDialog.viewFullPolicy') }} {{ $t('install.settings.dataCollectionDialog.viewFullPolicy') }}
</a> </a>
</div> </div>

View File

@@ -1,126 +1,66 @@
<template> <template>
<div class="flex flex-col gap-6 w-[600px] h-[30rem] select-none"> <div
<!-- Installation Path Section --> class="grid grid-rows-[1fr_auto_auto_1fr] w-full max-w-3xl mx-auto h-[40rem] select-none"
<div class="grow flex flex-col gap-4 text-neutral-300"> >
<h2 class="text-2xl font-semibold text-neutral-100"> <h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
{{ $t('install.gpuSelection.selectGpu') }} {{ $t('install.gpuPicker.title') }}
</h2> </h2>
<p class="m-1 text-neutral-400"> <!-- GPU Selection buttons - takes up remaining space and centers content -->
{{ $t('install.gpuSelection.selectGpuDescription') }}: <div class="flex-1 flex gap-8 justify-center items-center">
</p> <!-- Apple Metal / NVIDIA -->
<HardwareOption
v-if="platform === 'darwin'"
:image-path="'/assets/images/apple-mps-logo.png'"
placeholder-text="Apple Metal"
subtitle="Apple Metal"
:value="'mps'"
:selected="selected === 'mps'"
:recommended="true"
@click="pickGpu('mps')"
/>
<HardwareOption
v-else
:image-path="'/assets/images/nvidia-logo-square.jpg'"
placeholder-text="NVIDIA"
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
:value="'nvidia'"
:selected="selected === 'nvidia'"
:recommended="true"
@click="pickGpu('nvidia')"
/>
<!-- CPU -->
<HardwareOption
placeholder-text="CPU"
:subtitle="$t('install.gpuPicker.cpuSubtitle')"
:value="'cpu'"
:selected="selected === 'cpu'"
@click="pickGpu('cpu')"
/>
<!-- Manual Install -->
<HardwareOption
placeholder-text="Manual Install"
:subtitle="$t('install.gpuPicker.manualSubtitle')"
:value="'unsupported'"
:selected="selected === 'unsupported'"
@click="pickGpu('unsupported')"
/>
</div>
<!-- GPU Selection buttons --> <div class="pt-12 px-24 h-16">
<div <div v-show="showRecommendedBadge" class="flex items-center gap-2">
class="flex gap-2 text-center transition-opacity" <Tag
:class="{ selected: selected }" :value="$t('install.gpuPicker.recommended')"
> class="bg-neutral-300 text-neutral-900 rounded-full text-sm font-bold px-2 py-[1px]"
<!-- NVIDIA --> />
<div <i-lucide:badge-check class="text-neutral-300 text-lg" />
v-if="platform !== 'darwin'"
class="gpu-button"
:class="{ selected: selected === 'nvidia' }"
role="button"
@click="pickGpu('nvidia')"
>
<img
class="m-12"
alt="NVIDIA logo"
width="196"
height="32"
src="/assets/images/nvidia-logo.svg"
/>
</div>
<!-- MPS -->
<div
v-if="platform === 'darwin'"
class="gpu-button"
:class="{ selected: selected === 'mps' }"
role="button"
@click="pickGpu('mps')"
>
<img
class="rounded-lg hover-brighten"
alt="Apple Metal Performance Shaders Logo"
width="292"
ratio
src="/assets/images/apple-mps-logo.png"
/>
</div>
<!-- Manual configuration -->
<div
class="gpu-button"
:class="{ selected: selected === 'unsupported' }"
role="button"
@click="pickGpu('unsupported')"
>
<img
class="m-12"
alt="Manual configuration"
width="196"
src="/assets/images/manual-configuration.svg"
/>
</div>
</div>
<!-- Details on selected GPU -->
<p v-if="selected === 'nvidia'" class="m-1">
<Tag icon="pi pi-check" severity="success" :value="'CUDA'" />
{{ $t('install.gpuSelection.nvidiaDescription') }}
</p>
<p v-if="selected === 'mps'" class="m-1">
<Tag icon="pi pi-check" severity="success" :value="'MPS'" />
{{ $t('install.gpuSelection.mpsDescription') }}
</p>
<div v-if="selected === 'unsupported'" class="text-neutral-300">
<p class="m-1">
<Tag
icon="pi pi-exclamation-triangle"
severity="warn"
:value="t('icon.exclamation-triangle')"
/>
{{ $t('install.gpuSelection.customSkipsPython') }}
</p>
<ul>
<li>
<strong>
{{ $t('install.gpuSelection.customComfyNeedsPython') }}
</strong>
</li>
<li>{{ $t('install.gpuSelection.customManualVenv') }}</li>
<li>{{ $t('install.gpuSelection.customInstallRequirements') }}</li>
<li>{{ $t('install.gpuSelection.customMayNotWork') }}</li>
</ul>
</div>
<div v-if="selected === 'cpu'">
<p class="m-1">
<Tag
icon="pi pi-exclamation-triangle"
severity="warn"
:value="t('icon.exclamation-triangle')"
/>
{{ $t('install.gpuSelection.cpuModeDescription') }}
</p>
<p class="m-1">
{{ $t('install.gpuSelection.cpuModeDescription2') }}
</p>
</div> </div>
</div> </div>
<div <div class="text-neutral-300 px-24">
class="transition-opacity flex gap-3 h-0 items-center" <p v-show="descriptionText" class="leading-relaxed">
:class="{ {{ descriptionText }}
'opacity-40': selected && selected !== 'cpu' </p>
}"
>
<ToggleSwitch v-model="cpuMode" input-id="cpu-mode" />
<label for="cpu-mode" class="select-none">
{{ $t('install.gpuSelection.enableCpuMode') }}
</label>
</div> </div>
</div> </div>
</template> </template>
@@ -128,20 +68,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types' import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import Tag from 'primevue/tag' import Tag from 'primevue/tag'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import HardwareOption from '@/components/install/HardwareOption.vue'
import { st } from '@/i18n'
import { electronAPI } from '@/utils/envUtil' import { electronAPI } from '@/utils/envUtil'
const { t } = useI18n()
const cpuMode = computed({
get: () => selected.value === 'cpu',
set: (value) => {
selected.value = value ? 'cpu' : null
}
})
const selected = defineModel<TorchDeviceType | null>('device', { const selected = defineModel<TorchDeviceType | null>('device', {
required: true required: true
}) })
@@ -149,55 +81,23 @@ const selected = defineModel<TorchDeviceType | null>('device', {
const electron = electronAPI() const electron = electronAPI()
const platform = electron.getPlatform() const platform = electron.getPlatform()
const pickGpu = (value: typeof selected.value) => { const showRecommendedBadge = computed(
const newValue = selected.value === value ? null : value () => selected.value === 'mps' || selected.value === 'nvidia'
selected.value = newValue )
const descriptionKeys = {
mps: 'appleMetal',
nvidia: 'nvidia',
cpu: 'cpu',
unsupported: 'manual'
} as const
const descriptionText = computed(() => {
const key = selected.value ? descriptionKeys[selected.value] : undefined
return st(`install.gpuPicker.${key}Description`, '')
})
const pickGpu = (value: TorchDeviceType) => {
selected.value = value
} }
</script> </script>
<style scoped>
@reference '../../assets/css/style.css';
.p-tag {
--p-tag-gap: 0.5rem;
}
.hover-brighten {
@apply transition-colors;
transition-property: filter, box-shadow;
&:hover {
filter: brightness(107%) contrast(105%);
box-shadow: 0 0 0.25rem #ffffff79;
}
}
.p-accordioncontent-content {
@apply bg-neutral-900 rounded-lg transition-colors;
}
div.selected {
.gpu-button:not(.selected) {
@apply opacity-50 hover:opacity-100;
}
}
.gpu-button {
@apply w-1/2 m-0 cursor-pointer rounded-lg flex flex-col items-center justify-around bg-neutral-800/50 hover:bg-neutral-800/75 transition-colors;
&.selected {
@apply opacity-100 bg-neutral-700/50 hover:bg-neutral-700/60;
}
}
.disabled {
@apply pointer-events-none opacity-40;
}
.p-card-header {
@apply text-center grow;
}
.p-card-body {
@apply text-center pt-0;
}
</style>

View File

@@ -0,0 +1,73 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import HardwareOption from './HardwareOption.vue'
const meta: Meta<typeof HardwareOption> = {
title: 'Desktop/Components/HardwareOption',
component: HardwareOption,
parameters: {
layout: 'centered',
backgrounds: {
default: 'dark',
values: [{ name: 'dark', value: '#1a1a1a' }]
}
},
argTypes: {
selected: { control: 'boolean' },
imagePath: { control: 'text' },
placeholderText: { control: 'text' },
subtitle: { control: 'text' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const AppleMetalSelected: Story = {
args: {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
value: 'mps',
selected: true
}
}
export const AppleMetalUnselected: Story = {
args: {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
value: 'mps',
selected: false
}
}
export const CPUOption: Story = {
args: {
placeholderText: 'CPU',
subtitle: 'Subtitle',
value: 'cpu',
selected: false
}
}
export const ManualInstall: Story = {
args: {
placeholderText: 'Manual Install',
subtitle: 'Subtitle',
value: 'unsupported',
selected: false
}
}
export const NvidiaSelected: Story = {
args: {
imagePath: '/assets/images/nvidia-logo-square.jpg',
placeholderText: 'NVIDIA',
subtitle: 'NVIDIA',
value: 'nvidia',
selected: true
}
}

View File

@@ -0,0 +1,55 @@
<template>
<div class="relative">
<!-- Recommended Badge -->
<button
:class="
cn(
'hardware-option w-[170px] h-[190px] p-5 flex flex-col items-center rounded-3xl transition-all duration-200 bg-neutral-900/70 border-4',
selected ? 'border-solid border-brand-yellow' : 'border-transparent'
)
"
@click="$emit('click')"
>
<!-- Icon/Logo Area - Rounded square container -->
<div
class="icon-container w-[110px] h-[110px] shrink-0 rounded-2xl bg-neutral-800 flex items-center justify-center overflow-hidden"
>
<img
v-if="imagePath"
:src="imagePath"
:alt="placeholderText"
class="w-full h-full object-cover"
style="object-position: 57% center"
draggable="false"
/>
<span v-else class="text-xl font-medium text-neutral-400">
{{ placeholderText }}
</span>
</div>
<!-- Text Content -->
<div v-if="subtitle" class="text-center mt-4">
<div class="text-sm text-neutral-500">{{ subtitle }}</div>
</div>
</button>
</div>
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { cn } from '@/utils/tailwindUtil'
interface Props {
imagePath?: string
placeholderText: string
subtitle?: string
value: TorchDeviceType
selected?: boolean
recommended?: boolean
}
defineProps<Props>()
defineEmits<{ click: [] }>()
</script>

View File

@@ -0,0 +1,79 @@
<template>
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-4">
<!-- Back button -->
<Button
v-if="currentStep !== '1'"
:label="$t('g.back')"
severity="secondary"
icon="pi pi-arrow-left"
class="font-inter rounded-lg border-0 px-6 py-2 justify-self-start"
@click="$emit('previous')"
/>
<div v-else></div>
<!-- Step indicators in center -->
<StepList class="flex justify-center items-center gap-3 select-none">
<Step value="1" :pt="stepPassthrough">
{{ $t('install.gpu') }}
</Step>
<Step value="2" :disabled="disableLocationStep" :pt="stepPassthrough">
{{ $t('install.installLocation') }}
</Step>
<Step value="3" :disabled="disableSettingsStep" :pt="stepPassthrough">
{{ $t('install.desktopSettings') }}
</Step>
</StepList>
<!-- Next/Install button -->
<Button
:label="currentStep !== '3' ? $t('g.next') : $t('g.install')"
class="px-8 py-2 bg-brand-yellow hover:bg-brand-yellow/90 font-inter rounded-lg border-0 transition-colors justify-self-end"
:pt="{
label: { class: 'text-neutral-900 font-inter font-black' }
}"
:disabled="!canProceed"
@click="currentStep !== '3' ? $emit('next') : $emit('install')"
/>
</div>
</template>
<script setup lang="ts">
import type { PassThrough } from '@primevue/core'
import Button from 'primevue/button'
import Step, { type StepPassThroughOptions } from 'primevue/step'
import StepList from 'primevue/steplist'
defineProps<{
/** Current step index as string ('1', '2', '3', '4') */
currentStep: string
/** Whether the user can proceed to the next step */
canProceed: boolean
/** Whether the location step should be disabled */
disableLocationStep: boolean
/** Whether the migration step should be disabled */
disableMigrationStep: boolean
/** Whether the settings step should be disabled */
disableSettingsStep: boolean
}>()
defineEmits<{
previous: []
next: []
install: []
}>()
const stepPassthrough: PassThrough<StepPassThroughOptions> = {
root: { class: 'flex-none p-0 m-0' },
header: ({ context }) => ({
class: [
'h-2.5 p-0 m-0 border-0 rounded-full transition-all duration-300',
context.active
? 'bg-brand-yellow w-8 rounded-sm'
: 'bg-neutral-700 w-2.5',
context.disabled ? 'opacity-60 cursor-not-allowed' : ''
].join(' ')
}),
number: { class: 'hidden' },
title: { class: 'hidden' }
}
</script>

View File

@@ -0,0 +1,148 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import { ref } from 'vue'
import InstallLocationPicker from './InstallLocationPicker.vue'
const meta: Meta<typeof InstallLocationPicker> = {
title: 'Desktop/Components/InstallLocationPicker',
component: InstallLocationPicker,
parameters: {
layout: 'padded',
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' },
{ name: 'neutral-950', value: '#0a0a0a' }
]
}
},
decorators: [
() => {
// Mock electron API
;(window as any).electronAPI = {
getSystemPaths: () =>
Promise.resolve({
defaultInstallPath: '/Users/username/ComfyUI'
}),
validateInstallPath: () =>
Promise.resolve({
isValid: true,
exists: false,
canWrite: true,
freeSpace: 100000000000,
requiredSpace: 10000000000,
isNonDefaultDrive: false
}),
validateComfyUISource: () =>
Promise.resolve({
isValid: true
}),
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
}
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
// Default story with accordion expanded
export const Default: Story = {
render: (args) => ({
components: { InstallLocationPicker },
setup() {
const installPath = ref('/Users/username/ComfyUI')
const pathError = ref('')
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
return {
args,
installPath,
pathError,
migrationSourcePath,
migrationItemIds
}
},
template: `
<div class="min-h-screen bg-neutral-950 p-8">
<InstallLocationPicker
v-model:installPath="installPath"
v-model:pathError="pathError"
v-model:migrationSourcePath="migrationSourcePath"
v-model:migrationItemIds="migrationItemIds"
/>
</div>
`
})
}
// Story with different background to test transparency
export const OnNeutral900: Story = {
render: (args) => ({
components: { InstallLocationPicker },
setup() {
const installPath = ref('/Users/username/ComfyUI')
const pathError = ref('')
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
return {
args,
installPath,
pathError,
migrationSourcePath,
migrationItemIds
}
},
template: `
<div class="min-h-screen bg-neutral-900 p-8">
<InstallLocationPicker
v-model:installPath="installPath"
v-model:pathError="pathError"
v-model:migrationSourcePath="migrationSourcePath"
v-model:migrationItemIds="migrationItemIds"
/>
</div>
`
})
}
// Story with debug overlay showing background colors
export const DebugBackgrounds: Story = {
render: (args) => ({
components: { InstallLocationPicker },
setup() {
const installPath = ref('/Users/username/ComfyUI')
const pathError = ref('')
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
return {
args,
installPath,
pathError,
migrationSourcePath,
migrationItemIds
}
},
template: `
<div class="min-h-screen bg-neutral-950 p-8 relative">
<div class="absolute top-4 right-4 text-white text-xs space-y-2 z-50">
<div>Parent bg: neutral-950 (#0a0a0a)</div>
<div>Accordion content: bg-transparent</div>
<div>Migration options: bg-transparent + p-4 rounded-lg</div>
</div>
<InstallLocationPicker
v-model:installPath="installPath"
v-model:pathError="pathError"
v-model:migrationSourcePath="migrationSourcePath"
v-model:migrationItemIds="migrationItemIds"
/>
</div>
`
})
}

View File

@@ -1,103 +1,215 @@
<template> <template>
<div class="flex flex-col gap-6 w-[600px]"> <div class="flex flex-col gap-8 w-full max-w-3xl mx-auto select-none">
<!-- Installation Path Section --> <!-- Installation Path Section -->
<div class="flex flex-col gap-4"> <div class="grow flex flex-col gap-6 text-neutral-300">
<h2 class="text-2xl font-semibold text-neutral-100"> <h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
{{ $t('install.chooseInstallationLocation') }} {{ $t('install.locationPicker.title') }}
</h2> </h2>
<p class="text-neutral-400 my-0"> <p class="text-center text-neutral-400 px-12">
{{ $t('install.installLocationDescription') }} {{ $t('install.locationPicker.subtitle') }}
</p> </p>
<div class="flex gap-2"> <!-- Path Input -->
<IconField class="flex-1"> <div class="flex gap-2 px-12">
<InputText <InputText
v-model="installPath" v-model="installPath"
class="w-full" :placeholder="$t('install.locationPicker.pathPlaceholder')"
:class="{ 'p-invalid': pathError }" class="flex-1 bg-neutral-800/50 border-neutral-700 text-neutral-200 placeholder:text-neutral-500"
@update:model-value="validatePath" :class="{ 'p-invalid': pathError }"
@focus="onFocus" @update:model-value="validatePath"
/> @focus="onFocus"
<InputIcon />
v-tooltip.top="$t('install.installLocationTooltip')" <Button
class="pi pi-info-circle" icon="pi pi-folder-open"
/> severity="secondary"
</IconField> class="bg-neutral-700 hover:bg-neutral-600 border-0"
<Button icon="pi pi-folder" class="w-12" @click="browsePath" /> @click="browsePath"
/>
</div> </div>
<Message v-if="pathError" severity="error" class="whitespace-pre-line"> <!-- Error Messages -->
{{ pathError }} <div v-if="pathError || pathExists || nonDefaultDrive" class="px-12">
</Message> <Message
<Message v-if="pathExists" severity="warn"> v-if="pathError"
{{ $t('install.pathExists') }} severity="error"
</Message> class="whitespace-pre-line w-full"
<Message v-if="nonDefaultDrive" severity="warn"> >
{{ $t('install.nonDefaultDrive') }} {{ pathError }}
</Message> </Message>
</div> <Message v-if="pathExists" severity="warn" class="w-full">
{{ $t('install.pathExists') }}
<!-- System Paths Info --> </Message>
<div class="bg-neutral-800 p-4 rounded-lg"> <Message v-if="nonDefaultDrive" severity="warn" class="w-full">
<h3 class="text-lg font-medium mt-0 mb-3 text-neutral-100"> {{ $t('install.nonDefaultDrive') }}
{{ $t('install.systemLocations') }} </Message>
</h3>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<i class="pi pi-folder text-neutral-400" />
<span class="text-neutral-400">App Data:</span>
<span class="text-neutral-200">{{ appData }}</span>
<span
v-tooltip="$t('install.appDataLocationTooltip')"
class="pi pi-info-circle"
/>
</div>
<div class="flex items-center gap-2">
<i class="pi pi-desktop text-neutral-400" />
<span class="text-neutral-400">App Path:</span>
<span class="text-neutral-200">{{ appPath }}</span>
<span
v-tooltip="$t('install.appPathLocationTooltip')"
class="pi pi-info-circle"
/>
</div>
</div> </div>
<!-- Collapsible Sections using PrimeVue Accordion -->
<Accordion
v-model:value="activeAccordionIndex"
:multiple="true"
class="location-picker-accordion"
:pt="{
root: 'bg-transparent border-0',
panel: {
root: 'border-0 mb-0'
},
header: {
root: 'border-0',
content:
'text-neutral-400 hover:text-neutral-300 px-4 py-2 flex items-center gap-3',
toggleicon: 'text-xs order-first mr-0'
},
content: {
root: 'bg-transparent border-0',
content: 'text-neutral-500 text-sm pl-11 pb-3 pt-0'
}
}"
>
<AccordionPanel value="0">
<AccordionHeader>
{{ $t('install.locationPicker.migrateFromExisting') }}
</AccordionHeader>
<AccordionContent>
<MigrationPicker
v-model:source-path="migrationSourcePath"
v-model:migration-item-ids="migrationItemIds"
/>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="1">
<AccordionHeader>
{{ $t('install.locationPicker.chooseDownloadServers') }}
</AccordionHeader>
<AccordionContent>
<template
v-for="([item, modelValue], index) in mirrors"
:key="item.settingId + item.mirror"
>
<Divider v-if="index > 0" class="my-8" />
<MirrorItem
v-model="modelValue.value"
:item="item"
@state-change="validationStates[index] = $event"
/>
</template>
</AccordionContent>
</AccordionPanel>
</Accordion>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
import Accordion from 'primevue/accordion'
import AccordionContent from 'primevue/accordioncontent'
import AccordionHeader from 'primevue/accordionheader'
import AccordionPanel from 'primevue/accordionpanel'
import Button from 'primevue/button' import Button from 'primevue/button'
import IconField from 'primevue/iconfield' import Divider from 'primevue/divider'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext' import InputText from 'primevue/inputtext'
import Message from 'primevue/message' import Message from 'primevue/message'
import { onMounted, ref } from 'vue' import { type ModelRef, computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import MigrationPicker from '@/components/install/MigrationPicker.vue'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
import {
PYPI_MIRROR,
PYTHON_MIRROR,
type UVMirror
} from '@/constants/uvMirrors'
import { electronAPI } from '@/utils/envUtil' import { electronAPI } from '@/utils/envUtil'
import { isInChina } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'
const { t } = useI18n() const { t } = useI18n()
const installPath = defineModel<string>('installPath', { required: true }) const installPath = defineModel<string>('installPath', { required: true })
const pathError = defineModel<string>('pathError', { required: true }) const pathError = defineModel<string>('pathError', { required: true })
const migrationSourcePath = defineModel<string>('migrationSourcePath')
const migrationItemIds = defineModel<string[]>('migrationItemIds')
const pythonMirror = defineModel<string>('pythonMirror', {
default: ''
})
const pypiMirror = defineModel<string>('pypiMirror', {
default: ''
})
const torchMirror = defineModel<string>('torchMirror', {
default: ''
})
const { device } = defineProps<{ device: TorchDeviceType | null }>()
const pathExists = ref(false) const pathExists = ref(false)
const nonDefaultDrive = ref(false) const nonDefaultDrive = ref(false)
const appData = ref('')
const appPath = ref('')
const inputTouched = ref(false) const inputTouched = ref(false)
// Accordion state - array of active panel values
const activeAccordionIndex = ref<string[] | undefined>(undefined)
const electron = electronAPI() const electron = electronAPI()
// Get system paths on component mount // Mirror configuration logic
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
switch (device) {
case 'mps':
return {
settingId,
mirror: TorchMirrorUrl.NightlyCpu,
fallbackMirror: TorchMirrorUrl.NightlyCpu
}
case 'nvidia':
return {
settingId,
mirror: TorchMirrorUrl.Cuda,
fallbackMirror: TorchMirrorUrl.Cuda
}
case 'cpu':
default:
return {
settingId,
mirror: PYPI_MIRROR.mirror,
fallbackMirror: PYPI_MIRROR.fallbackMirror
}
}
}
const userIsInChina = ref(false)
const useFallbackMirror = (mirror: UVMirror) => ({
...mirror,
mirror: mirror.fallbackMirror
})
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
(
[
[PYTHON_MIRROR, pythonMirror],
[PYPI_MIRROR, pypiMirror],
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
] as [UVMirror, ModelRef<string>][]
).map(([item, modelValue]) => [
userIsInChina.value ? useFallbackMirror(item) : item,
modelValue
])
)
const validationStates = ref<ValidationState[]>(
mirrors.value.map(() => ValidationState.IDLE)
)
// Get default install path on component mount
onMounted(async () => { onMounted(async () => {
const paths = await electron.getSystemPaths() const paths = await electron.getSystemPaths()
appData.value = paths.appData
appPath.value = paths.appPath
installPath.value = paths.defaultInstallPath installPath.value = paths.defaultInstallPath
await validatePath(paths.defaultInstallPath) await validatePath(paths.defaultInstallPath)
userIsInChina.value = await isInChina()
}) })
const validatePath = async (path: string | undefined) => { const validatePath = async (path: string | undefined) => {
@@ -151,3 +263,52 @@ const onFocus = async () => {
await validatePath(installPath.value) await validatePath(installPath.value)
} }
</script> </script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.location-picker-accordion) {
@apply px-12;
.p-accordionpanel {
@apply border-0 bg-transparent;
}
.p-accordionheader {
@apply bg-neutral-800/50 border-0 rounded-xl mt-2 hover:bg-neutral-700/50;
transition:
background-color 0.2s ease,
border-radius 0.5s ease;
}
/* When panel is expanded, adjust header border radius */
.p-accordionpanel-active {
.p-accordionheader {
@apply rounded-t-xl rounded-b-none;
}
}
.p-accordioncontent {
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
}
.p-accordioncontent-content {
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
}
/* Override default chevron icons to use up/down */
.p-accordionheader-toggle-icon {
&::before {
content: '\e933';
}
}
.p-accordionpanel-active {
.p-accordionheader-toggle-icon {
&::before {
content: '\e902';
}
}
}
}
</style>

View File

@@ -0,0 +1,45 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import { ref } from 'vue'
import MigrationPicker from './MigrationPicker.vue'
const meta: Meta<typeof MigrationPicker> = {
title: 'Desktop/Components/MigrationPicker',
component: MigrationPicker,
parameters: {
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' }
]
}
},
decorators: [
() => {
;(window as any).electronAPI = {
validateComfyUISource: () => Promise.resolve({ isValid: true }),
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
}
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { MigrationPicker },
setup() {
const sourcePath = ref('')
const migrationItemIds = ref<string[]>([])
return { sourcePath, migrationItemIds }
},
template:
'<MigrationPicker v-model:sourcePath="sourcePath" v-model:migrationItemIds="migrationItemIds" />'
})
}

View File

@@ -2,10 +2,6 @@
<div class="flex flex-col gap-6 w-[600px]"> <div class="flex flex-col gap-6 w-[600px]">
<!-- Source Location Section --> <!-- Source Location Section -->
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<h2 class="text-2xl font-semibold text-neutral-100">
{{ $t('install.migrateFromExistingInstallation') }}
</h2>
<p class="text-neutral-400 my-0"> <p class="text-neutral-400 my-0">
{{ $t('install.migrationSourcePathDescription') }} {{ $t('install.migrationSourcePathDescription') }}
</p> </p>
@@ -13,7 +9,7 @@
<div class="flex gap-2"> <div class="flex gap-2">
<InputText <InputText
v-model="sourcePath" v-model="sourcePath"
placeholder="Select existing ComfyUI installation (optional)" :placeholder="$t('install.locationPicker.migrationPathPlaceholder')"
class="flex-1" class="flex-1"
:class="{ 'p-invalid': pathError }" :class="{ 'p-invalid': pathError }"
@update:model-value="validateSource" @update:model-value="validateSource"
@@ -27,10 +23,7 @@
</div> </div>
<!-- Migration Options --> <!-- Migration Options -->
<div <div v-if="isValidSource" class="flex flex-col gap-4 p-4 rounded-lg">
v-if="isValidSource"
class="flex flex-col gap-4 bg-neutral-800 p-4 rounded-lg"
>
<h3 class="text-lg mt-0 font-medium text-neutral-100"> <h3 class="text-lg mt-0 font-medium text-neutral-100">
{{ $t('install.selectItemsToMigrate') }} {{ $t('install.selectItemsToMigrate') }}
</h3> </h3>

View File

@@ -1,121 +0,0 @@
<template>
<Panel
:header="$t('install.settings.mirrorSettings')"
toggleable
:collapsed="!showMirrorInputs"
pt:root="bg-neutral-800 border-none w-[600px]"
>
<template
v-for="([item, modelValue], index) in mirrors"
:key="item.settingId + item.mirror"
>
<Divider v-if="index > 0" />
<MirrorItem
v-model="modelValue.value"
:item="item"
@state-change="validationStates[index] = $event"
/>
</template>
<template #icons>
<i
v-tooltip="validationStateTooltip"
:class="{
'pi pi-spin pi-spinner text-neutral-400':
validationState === ValidationState.LOADING,
'pi pi-check text-green-500':
validationState === ValidationState.VALID,
'pi pi-times text-red-500':
validationState === ValidationState.INVALID
}"
/>
</template>
</Panel>
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
import Divider from 'primevue/divider'
import Panel from 'primevue/panel'
import type { ModelRef } from 'vue'
import { computed, onMounted, ref } from 'vue'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
import type { UVMirror } from '@/constants/uvMirrors'
import { PYPI_MIRROR, PYTHON_MIRROR } from '@/constants/uvMirrors'
import { t } from '@/i18n'
import { isInChina } from '@/utils/networkUtil'
import { ValidationState, mergeValidationStates } from '@/utils/validationUtil'
const showMirrorInputs = ref(false)
const { device } = defineProps<{ device: TorchDeviceType | null }>()
const pythonMirror = defineModel<string>('pythonMirror', { required: true })
const pypiMirror = defineModel<string>('pypiMirror', { required: true })
const torchMirror = defineModel<string>('torchMirror', { required: true })
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
switch (device) {
case 'mps':
return {
settingId,
mirror: TorchMirrorUrl.NightlyCpu,
fallbackMirror: TorchMirrorUrl.NightlyCpu
}
case 'nvidia':
return {
settingId,
mirror: TorchMirrorUrl.Cuda,
fallbackMirror: TorchMirrorUrl.Cuda
}
case 'cpu':
default:
return {
settingId,
mirror: PYPI_MIRROR.mirror,
fallbackMirror: PYPI_MIRROR.fallbackMirror
}
}
}
const userIsInChina = ref(false)
onMounted(async () => {
userIsInChina.value = await isInChina()
})
const useFallbackMirror = (mirror: UVMirror) => ({
...mirror,
mirror: mirror.fallbackMirror
})
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
(
[
[PYTHON_MIRROR, pythonMirror],
[PYPI_MIRROR, pypiMirror],
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
] as [UVMirror, ModelRef<string>][]
).map(([item, modelValue]) => [
userIsInChina.value ? useFallbackMirror(item) : item,
modelValue
])
)
const validationStates = ref<ValidationState[]>(
mirrors.value.map(() => ValidationState.IDLE)
)
const validationState = computed(() => {
return mergeValidationStates(validationStates.value)
})
const validationStateTooltip = computed(() => {
switch (validationState.value) {
case ValidationState.INVALID:
return t('install.settings.mirrorsUnreachable')
case ValidationState.VALID:
return t('install.settings.mirrorsReachable')
default:
return t('install.settings.checkingMirrors')
}
})
</script>

View File

@@ -1,10 +1,10 @@
<template> <template>
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col gap-4 text-neutral-400 text-sm">
<div class="w-full"> <div>
<h3 class="text-lg font-medium text-neutral-100"> <h3 class="text-lg font-medium text-neutral-100 mb-3 mt-0">
{{ $t(`settings.${normalizedSettingId}.name`) }} {{ $t(`settings.${normalizedSettingId}.name`) }}
</h3> </h3>
<p class="text-sm text-neutral-400 mt-1"> <p class="my-1">
{{ $t(`settings.${normalizedSettingId}.tooltip`) }} {{ $t(`settings.${normalizedSettingId}.tooltip`) }}
</p> </p>
</div> </div>
@@ -16,18 +16,61 @@
" "
@state-change="validationState = $event" @state-change="validationState = $event"
/> />
<div v-if="secondParagraph" class="mt-2">
<a href="#" @click.prevent="showDialog = true">
{{ $t('g.learnMore') }}
</a>
<Dialog
v-model:visible="showDialog"
modal
dismissable-mask
:header="$t(`settings.${normalizedSettingId}.urlFormatTitle`)"
class="select-none max-w-3xl"
>
<div class="text-neutral-300">
<p class="mt-1 whitespace-pre-wrap">{{ secondParagraph }}</p>
<div class="mt-2 break-all">
<span class="text-neutral-300 font-semibold">
{{ EXAMPLE_URL_FIRST_PART }}
</span>
<span>{{ EXAMPLE_URL_SECOND_PART }}</span>
</div>
<Divider />
<p>
{{ $t(`settings.${normalizedSettingId}.fileUrlDescription`) }}
</p>
<span class="text-neutral-300 font-semibold">
{{ FILE_URL_SCHEME }}
</span>
<span>
{{ EXAMPLE_FILE_URL }}
</span>
</div>
</Dialog>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Dialog from 'primevue/dialog'
import Divider from 'primevue/divider'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import UrlInput from '@/components/common/UrlInput.vue' import UrlInput from '@/components/common/UrlInput.vue'
import type { UVMirror } from '@/constants/uvMirrors' import type { UVMirror } from '@/constants/uvMirrors'
import { st } from '@/i18n'
import { normalizeI18nKey } from '@/utils/formatUtil' import { normalizeI18nKey } from '@/utils/formatUtil'
import { checkMirrorReachable } from '@/utils/networkUtil' import { checkMirrorReachable } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil' import { ValidationState } from '@/utils/validationUtil'
const FILE_URL_SCHEME = 'file://'
const EXAMPLE_FILE_URL = '/C:/MyPythonInstallers/'
const EXAMPLE_URL_FIRST_PART =
'https://github.com/astral-sh/python-build-standalone/releases/download'
const EXAMPLE_URL_SECOND_PART =
'/20250902/cpython-3.12.11+20250902-x86_64-pc-windows-msvc-install_only.tar.gz'
const { item } = defineProps<{ const { item } = defineProps<{
item: UVMirror item: UVMirror
}>() }>()
@@ -38,11 +81,16 @@ const emit = defineEmits<{
const modelValue = defineModel<string>('modelValue', { required: true }) const modelValue = defineModel<string>('modelValue', { required: true })
const validationState = ref<ValidationState>(ValidationState.IDLE) const validationState = ref<ValidationState>(ValidationState.IDLE)
const showDialog = ref(false)
const normalizedSettingId = computed(() => { const normalizedSettingId = computed(() => {
return normalizeI18nKey(item.settingId) return normalizeI18nKey(item.settingId)
}) })
const secondParagraph = computed(() =>
st(`settings.${normalizedSettingId.value}.urlDescription`, '')
)
onMounted(() => { onMounted(() => {
modelValue.value = item.mirror modelValue.value = item.mirror
}) })

View File

@@ -9,7 +9,8 @@ export function useTerminal(element: Ref<HTMLElement | undefined>) {
const fitAddon = new FitAddon() const fitAddon = new FitAddon()
const terminal = markRaw( const terminal = markRaw(
new Terminal({ new Terminal({
convertEol: true convertEol: true,
theme: { background: '#171717' }
}) })
) )
terminal.loadAddon(fitAddon) terminal.loadAddon(fitAddon)

View File

@@ -18,6 +18,7 @@
"calculatingDimensions": "Calculating dimensions", "calculatingDimensions": "Calculating dimensions",
"import": "Import", "import": "Import",
"loadAllFolders": "Load All Folders", "loadAllFolders": "Load All Folders",
"logoAlt": "ComfyUI Logo",
"refresh": "Refresh", "refresh": "Refresh",
"refreshNode": "Refresh Node", "refreshNode": "Refresh Node",
"terminal": "Terminal", "terminal": "Terminal",
@@ -406,6 +407,27 @@
"migration": "Migration", "migration": "Migration",
"desktopSettings": "Desktop Settings", "desktopSettings": "Desktop Settings",
"chooseInstallationLocation": "Choose Installation Location", "chooseInstallationLocation": "Choose Installation Location",
"gpuPicker": {
"title": "Choose your hardware setup",
"recommended": "RECOMMENDED",
"nvidiaSubtitle": "NVIDIA CUDA",
"cpuSubtitle": "CPU Mode",
"manualSubtitle": "Manual Setup",
"appleMetalDescription": "Leverages your Mac's GPU for faster speed and a better overall experience",
"nvidiaDescription": "Use your NVIDIA GPU with CUDA acceleration for the best performance.",
"cpuDescription": "Use CPU mode for compatibility when GPU acceleration is not available",
"manualDescription": "Configure ComfyUI manually for advanced setups or unsupported hardware"
},
"locationPicker": {
"title": "Choose where to install ComfyUI",
"subtitle": "Pick a folder for ComfyUI's files. We'll also set up Python there automatically.",
"pathPlaceholder": "/Users/username/Documents/ComfyUI",
"migrationPathPlaceholder": "Select existing ComfyUI installation (optional)",
"migrateFromExisting": "Migrate from existing installation",
"migrateDescription": "Copy or link your existing models, custom nodes, and configurations from a previous ComfyUI installation.",
"chooseDownloadServers": "Choose download servers manually",
"downloadServersDescription": "Select specific mirror servers for downloading Python, PyPI packages, and PyTorch based on your location."
},
"systemLocations": "System Locations", "systemLocations": "System Locations",
"failedToSelectDirectory": "Failed to select directory", "failedToSelectDirectory": "Failed to select directory",
"pathValidationFailed": "Failed to validate path", "pathValidationFailed": "Failed to validate path",
@@ -490,18 +512,26 @@
"metricsDisabled": "Metrics Disabled", "metricsDisabled": "Metrics Disabled",
"updateConsent": "You previously opted in to reporting crashes. We are now tracking event-based metrics to help identify bugs and improve the app. No personal identifiable information is collected." "updateConsent": "You previously opted in to reporting crashes. We are now tracking event-based metrics to help identify bugs and improve the app. No personal identifiable information is collected."
}, },
"desktopStart": {
"initialising": "Initialising..."
},
"serverStart": { "serverStart": {
"title": "Starting ComfyUI",
"troubleshoot": "Troubleshoot", "troubleshoot": "Troubleshoot",
"reportIssue": "Report Issue", "reportIssue": "Report Issue",
"openLogs": "Open Logs", "openLogs": "Open Logs",
"showTerminal": "Show Terminal", "showTerminal": "Show Terminal",
"copySelectionTooltip": "Copy selection", "copySelectionTooltip": "Copy selection",
"copyAllTooltip": "Copy all", "copyAllTooltip": "Copy all",
"errorMessage": "Unable to start ComfyUI Desktop",
"installation": {
"title": "Installing ComfyUI"
},
"process": { "process": {
"initial-state": "Loading...", "initial-state": "Loading...",
"python-setup": "Setting up Python Environment...", "python-setup": "Setting up Python Environment...",
"starting-server": "Starting ComfyUI server...", "starting-server": "Starting ComfyUI server...",
"ready": "Finishing...", "ready": "Loading Human Interface",
"error": "Unable to start ComfyUI Desktop" "error": "Unable to start ComfyUI Desktop"
} }
}, },

View File

@@ -6,12 +6,15 @@
"name": "Send anonymous usage metrics" "name": "Send anonymous usage metrics"
}, },
"Comfy-Desktop_UV_PypiInstallMirror": { "Comfy-Desktop_UV_PypiInstallMirror": {
"name": "Pypi Install Mirror", "name": "PyPI Install Mirror",
"tooltip": "Default pip install mirror" "tooltip": "Default pip install mirror"
}, },
"Comfy-Desktop_UV_PythonInstallMirror": { "Comfy-Desktop_UV_PythonInstallMirror": {
"name": "Python Install Mirror", "name": "Python Install Mirror",
"tooltip": "Managed Python installations are downloaded from the Astral python-build-standalone project. This variable can be set to a mirror URL to use a different source for Python installations. The provided URL will replace https://github.com/astral-sh/python-build-standalone/releases/download in, e.g., https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz. Distributions can be read from a local directory by using the file:// URL scheme." "tooltip": "Managed Python installations are downloaded from the Astral python-build-standalone project. To use a different source for Python installations, enter a mirror URL.",
"urlFormatTitle": "Mirror URL Format",
"urlDescription": "This is an example python download URL.\n\nThe mirror URL is the first half, including everything before the date (20250902):",
"fileUrlDescription": "To install from a file you downloaded earlier, you may use a file URL:"
}, },
"Comfy-Desktop_UV_TorchInstallMirror": { "Comfy-Desktop_UV_TorchInstallMirror": {
"name": "Torch Install Mirror", "name": "Torch Install Mirror",
@@ -421,4 +424,4 @@
"pysssss_SnapToGrid": { "pysssss_SnapToGrid": {
"name": "Always snap to grid" "name": "Always snap to grid"
} }
} }

View File

@@ -4,16 +4,3 @@ export enum ValidationState {
VALID = 'VALID', VALID = 'VALID',
INVALID = 'INVALID' INVALID = 'INVALID'
} }
export const mergeValidationStates = (states: ValidationState[]) => {
if (states.some((state) => state === ValidationState.INVALID)) {
return ValidationState.INVALID
}
if (states.some((state) => state === ValidationState.LOADING)) {
return ValidationState.LOADING
}
if (states.every((state) => state === ValidationState.VALID)) {
return ValidationState.VALID
}
return ValidationState.IDLE
}

View File

@@ -1,11 +1,11 @@
<template> <template>
<BaseViewTemplate dark> <BaseViewTemplate dark>
<ProgressSpinner class="m-8 w-48 h-48" /> <StartupDisplay :title="$t('desktopStart.initialising')" />
</BaseViewTemplate> </BaseViewTemplate>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ProgressSpinner from 'primevue/progressspinner' import StartupDisplay from '@/components/common/StartupDisplay.vue'
import BaseViewTemplate from './templates/BaseViewTemplate.vue' import BaseViewTemplate from './templates/BaseViewTemplate.vue'
</script> </script>

View File

@@ -0,0 +1,423 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import { nextTick, provide } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import InstallView from './InstallView.vue'
// Create a mock router for stories
const createMockRouter = () =>
createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
{
path: '/server-start',
component: { template: '<div>Server Start</div>' }
},
{
path: '/manual-configuration',
component: { template: '<div>Manual Configuration</div>' }
}
]
})
const meta: Meta<typeof InstallView> = {
title: 'Desktop/Views/InstallView',
component: InstallView,
parameters: {
layout: 'fullscreen',
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' },
{ name: 'neutral-950', value: '#0a0a0a' }
]
}
},
decorators: [
(story) => {
// Create router for this story
const router = createMockRouter()
// Mock electron API
;(window as any).electronAPI = {
getPlatform: () => 'darwin',
Config: {
getDetectedGpu: () => Promise.resolve('mps')
},
Events: {
trackEvent: (eventName: string, data?: any) => {
console.log('Track event:', eventName, data)
}
},
installComfyUI: (options: any) => {
console.log('Install ComfyUI with options:', options)
},
changeTheme: (theme: any) => {
console.log('Change theme:', theme)
},
getSystemPaths: () =>
Promise.resolve({
defaultInstallPath: '/Users/username/ComfyUI'
}),
validateInstallPath: () =>
Promise.resolve({
isValid: true,
exists: false,
canWrite: true,
freeSpace: 100000000000,
requiredSpace: 10000000000,
isNonDefaultDrive: false
}),
validateComfyUISource: () =>
Promise.resolve({
isValid: true
}),
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
}
return {
setup() {
// Provide router for all child components
provide('router', router)
return {
story
}
},
template: '<div style="width: 100vw; height: 100vh;"><story /></div>'
}
}
]
}
export default meta
type Story = StoryObj<typeof meta>
// Default story - start at GPU selection
export const GpuSelection: Story = {
render: () => ({
components: { InstallView },
setup() {
// The component will automatically start at step 1
return {}
},
template: '<InstallView />'
})
}
// Story showing the install location step
export const InstallLocation: Story = {
render: () => ({
components: { InstallView },
setup() {
return {}
},
async mounted() {
// Wait for component to be fully mounted
await nextTick()
// Select Apple Metal option to enable navigation
const hardwareOptions = this.$el.querySelectorAll(
'.p-selectbutton-option'
)
if (hardwareOptions.length > 0) {
hardwareOptions[0].click() // Click Apple Metal (first option)
}
await nextTick()
// Click Next to go to step 2
const buttons = Array.from(
this.$el.querySelectorAll('button')
) as HTMLButtonElement[]
const nextBtn = buttons.find((btn) => btn.textContent?.includes('Next'))
if (nextBtn) {
nextBtn.click()
}
},
template: '<InstallView />'
})
}
// Story showing the migration step (currently empty)
export const MigrationStep: Story = {
render: () => ({
components: { InstallView },
setup() {
return {}
},
async mounted() {
// Wait for component to be fully mounted
await nextTick()
// Select Apple Metal option to enable navigation
const hardwareOptions = this.$el.querySelectorAll(
'.p-selectbutton-option'
)
if (hardwareOptions.length > 0) {
hardwareOptions[0].click() // Click Apple Metal (first option)
}
await nextTick()
// Click Next to go to step 2
const buttons1 = Array.from(
this.$el.querySelectorAll('button')
) as HTMLButtonElement[]
const nextBtn1 = buttons1.find((btn) => btn.textContent?.includes('Next'))
if (nextBtn1) {
nextBtn1.click()
}
await nextTick()
// Click Next again to go to step 3
const buttons2 = Array.from(
this.$el.querySelectorAll('button')
) as HTMLButtonElement[]
const nextBtn2 = buttons2.find((btn) => btn.textContent?.includes('Next'))
if (nextBtn2) {
nextBtn2.click()
}
},
template: '<InstallView />'
})
}
// Story showing the desktop settings configuration
export const DesktopSettings: Story = {
render: () => ({
components: { InstallView },
setup() {
return {}
},
async mounted() {
// Wait for component to be fully mounted
await nextTick()
// Select Apple Metal option to enable navigation
const hardwareOptions = this.$el.querySelectorAll(
'.p-selectbutton-option'
)
if (hardwareOptions.length > 0) {
hardwareOptions[0].click() // Click Apple Metal (first option)
}
await nextTick()
// Click Next to go to step 2
const buttons1 = Array.from(
this.$el.querySelectorAll('button')
) as HTMLButtonElement[]
const nextBtn1 = buttons1.find((btn) => btn.textContent?.includes('Next'))
if (nextBtn1) {
nextBtn1.click()
}
await nextTick()
// Click Next again to go to step 3
const buttons2 = Array.from(
this.$el.querySelectorAll('button')
) as HTMLButtonElement[]
const nextBtn2 = buttons2.find((btn) => btn.textContent?.includes('Next'))
if (nextBtn2) {
nextBtn2.click()
}
await nextTick()
// Click Next again to go to step 4
const buttons3 = Array.from(
this.$el.querySelectorAll('button')
) as HTMLButtonElement[]
const nextBtn3 = buttons3.find((btn) => btn.textContent?.includes('Next'))
if (nextBtn3) {
nextBtn3.click()
}
},
template: '<InstallView />'
})
}
// Story with Windows platform (no Apple Metal option)
export const WindowsPlatform: Story = {
render: () => {
// Override the platform to Windows
;(window as any).electronAPI.getPlatform = () => 'win32'
;(window as any).electronAPI.Config.getDetectedGpu = () =>
Promise.resolve('nvidia')
return {
components: { InstallView },
setup() {
return {}
},
template: '<InstallView />'
}
}
}
// Story with macOS platform (Apple Metal option)
export const MacOSPlatform: Story = {
name: 'macOS Platform',
render: () => {
// Override the platform to macOS
;(window as any).electronAPI.getPlatform = () => 'darwin'
;(window as any).electronAPI.Config.getDetectedGpu = () =>
Promise.resolve('mps')
return {
components: { InstallView },
setup() {
return {}
},
template: '<InstallView />'
}
}
}
// Story with CPU selected
export const CpuSelected: Story = {
render: () => ({
components: { InstallView },
setup() {
return {}
},
async mounted() {
// Wait for component to be fully mounted
await nextTick()
// Find and click the CPU hardware option
const hardwareButtons = this.$el.querySelectorAll('.hardware-option')
// CPU is the button with "CPU" text
for (const button of hardwareButtons) {
if (button.textContent?.includes('CPU')) {
button.click()
break
}
}
},
template: '<InstallView />'
})
}
// Story with manual install selected
export const ManualInstall: Story = {
render: () => ({
components: { InstallView },
setup() {
return {}
},
async mounted() {
// Wait for component to be fully mounted
await nextTick()
// Find and click the Manual Install hardware option
const hardwareButtons = this.$el.querySelectorAll('.hardware-option')
// Manual Install is the button with "Manual Install" text
for (const button of hardwareButtons) {
if (button.textContent?.includes('Manual Install')) {
button.click()
break
}
}
},
template: '<InstallView />'
})
}
// Story with error state (invalid install path)
export const ErrorState: Story = {
render: () => {
// Override validation to return an error
;(window as any).electronAPI.validateInstallPath = () =>
Promise.resolve({
isValid: false,
exists: false,
canWrite: false,
freeSpace: 100000000000,
requiredSpace: 10000000000,
isNonDefaultDrive: false,
error: 'Story mock: Example error state'
})
return {
components: { InstallView },
setup() {
return {}
},
async mounted() {
// Wait for component to be fully mounted
await nextTick()
// Select Apple Metal option to enable navigation
const hardwareOptions = this.$el.querySelectorAll(
'.p-selectbutton-option'
)
if (hardwareOptions.length > 0) {
hardwareOptions[0].click() // Click Apple Metal (first option)
}
await nextTick()
// Click Next to go to step 2 where error will be shown
const buttons = Array.from(
this.$el.querySelectorAll('button')
) as HTMLButtonElement[]
const nextBtn = buttons.find((btn) => btn.textContent?.includes('Next'))
if (nextBtn) {
nextBtn.click()
}
},
template: '<InstallView />'
}
}
}
// Story with warning state (non-default drive)
export const WarningState: Story = {
render: () => {
// Override validation to return a warning about non-default drive
;(window as any).electronAPI.validateInstallPath = () =>
Promise.resolve({
isValid: true,
exists: false,
canWrite: true,
freeSpace: 500_000_000_000,
requiredSpace: 10_000_000_000,
isNonDefaultDrive: true
})
return {
components: { InstallView },
setup() {
return {}
},
async mounted() {
// Wait for component to be fully mounted
await nextTick()
// Select Apple Metal option to enable navigation
const hardwareOptions = this.$el.querySelectorAll('.hardware-option')
if (hardwareOptions.length > 0) {
hardwareOptions[0].click() // Click Apple Metal (first option)
}
await nextTick()
// Click Next to go to step 2 where warning will be shown
const buttons = Array.from(
this.$el.querySelectorAll('button')
) as HTMLButtonElement[]
const nextBtn = buttons.find((btn) => btn.textContent?.includes('Next'))
if (nextBtn) {
nextBtn.click()
}
},
template: '<InstallView />'
}
}
}

View File

@@ -1,111 +1,54 @@
<template> <template>
<BaseViewTemplate dark> <BaseViewTemplate dark>
<!-- h-full to make sure the stepper does not layout shift between steps <!-- Fixed height container with flexbox layout for proper content management -->
as for each step the stepper height is different. Inherit the center element <div class="w-full h-full flex flex-col">
placement from BaseViewTemplate would cause layout shift. --> <Stepper
<Stepper v-model:value="currentStep"
class="h-full p-8 2xl:p-16" class="flex flex-col h-full"
value="0" @update:value="handleStepChange"
@update:value="handleStepChange" >
> <!-- Main content area that grows to fill available space -->
<StepList class="select-none"> <StepPanels
<Step value="0"> class="flex-1 overflow-auto"
{{ $t('install.gpu') }} :style="{ scrollbarGutter: 'stable' }"
</Step> >
<Step value="1" :disabled="noGpu"> <StepPanel value="1" class="flex">
{{ $t('install.installLocation') }} <GpuPicker v-model:device="device" />
</Step> </StepPanel>
<Step value="2" :disabled="noGpu || hasError || highestStep < 1"> <StepPanel value="2">
{{ $t('install.migration') }} <InstallLocationPicker
</Step> v-model:install-path="installPath"
<Step value="3" :disabled="noGpu || hasError || highestStep < 2"> v-model:path-error="pathError"
{{ $t('install.desktopSettings') }} v-model:migration-source-path="migrationSourcePath"
</Step> v-model:migration-item-ids="migrationItemIds"
</StepList> v-model:python-mirror="pythonMirror"
<StepPanels> v-model:pypi-mirror="pypiMirror"
<StepPanel v-slot="{ activateCallback }" value="0"> v-model:torch-mirror="torchMirror"
<GpuPicker v-model:device="device" /> :device="device"
<div class="flex pt-6 justify-end">
<Button
:label="$t('g.next')"
icon="pi pi-arrow-right"
icon-pos="right"
:disabled="typeof device !== 'string'"
@click="activateCallback('1')"
/> />
</div> </StepPanel>
</StepPanel> <StepPanel value="3">
<StepPanel v-slot="{ activateCallback }" value="1"> <DesktopSettingsConfiguration
<InstallLocationPicker v-model:auto-update="autoUpdate"
v-model:install-path="installPath" v-model:allow-metrics="allowMetrics"
v-model:path-error="pathError"
/>
<div class="flex pt-6 justify-between">
<Button
:label="$t('g.back')"
severity="secondary"
icon="pi pi-arrow-left"
@click="activateCallback('0')"
/> />
<Button </StepPanel>
:label="$t('g.next')" </StepPanels>
icon="pi pi-arrow-right"
icon-pos="right" <!-- Install footer with navigation -->
:disabled="pathError !== ''" <InstallFooter
@click="activateCallback('2')" class="w-full max-w-2xl my-6 mx-auto"
/> :current-step
</div> :can-proceed
</StepPanel> :disable-location-step="noGpu"
<StepPanel v-slot="{ activateCallback }" value="2"> :disable-migration-step="noGpu || hasError || highestStep < 2"
<MigrationPicker :disable-settings-step="noGpu || hasError || highestStep < 3"
v-model:source-path="migrationSourcePath" @previous="goToPreviousStep"
v-model:migration-item-ids="migrationItemIds" @next="goToNextStep"
/> @install="install"
<div class="flex pt-6 justify-between"> />
<Button </Stepper>
:label="$t('g.back')" </div>
severity="secondary"
icon="pi pi-arrow-left"
@click="activateCallback('1')"
/>
<Button
:label="$t('g.next')"
icon="pi pi-arrow-right"
icon-pos="right"
@click="activateCallback('3')"
/>
</div>
</StepPanel>
<StepPanel v-slot="{ activateCallback }" value="3">
<DesktopSettingsConfiguration
v-model:auto-update="autoUpdate"
v-model:allow-metrics="allowMetrics"
/>
<MirrorsConfiguration
v-model:python-mirror="pythonMirror"
v-model:pypi-mirror="pypiMirror"
v-model:torch-mirror="torchMirror"
:device="device"
class="mt-6"
/>
<div class="flex mt-6 justify-between">
<Button
:label="$t('g.back')"
severity="secondary"
icon="pi pi-arrow-left"
@click="activateCallback('2')"
/>
<Button
:label="$t('g.install')"
icon="pi pi-check"
icon-pos="right"
:disabled="hasError"
@click="install()"
/>
</div>
</StepPanel>
</StepPanels>
</Stepper>
</BaseViewTemplate> </BaseViewTemplate>
</template> </template>
@@ -114,9 +57,6 @@ import type {
InstallOptions, InstallOptions,
TorchDeviceType TorchDeviceType
} from '@comfyorg/comfyui-electron-types' } from '@comfyorg/comfyui-electron-types'
import Button from 'primevue/button'
import Step from 'primevue/step'
import StepList from 'primevue/steplist'
import StepPanel from 'primevue/steppanel' import StepPanel from 'primevue/steppanel'
import StepPanels from 'primevue/steppanels' import StepPanels from 'primevue/steppanels'
import Stepper from 'primevue/stepper' import Stepper from 'primevue/stepper'
@@ -125,9 +65,8 @@ import { useRouter } from 'vue-router'
import DesktopSettingsConfiguration from '@/components/install/DesktopSettingsConfiguration.vue' import DesktopSettingsConfiguration from '@/components/install/DesktopSettingsConfiguration.vue'
import GpuPicker from '@/components/install/GpuPicker.vue' import GpuPicker from '@/components/install/GpuPicker.vue'
import InstallFooter from '@/components/install/InstallFooter.vue'
import InstallLocationPicker from '@/components/install/InstallLocationPicker.vue' import InstallLocationPicker from '@/components/install/InstallLocationPicker.vue'
import MigrationPicker from '@/components/install/MigrationPicker.vue'
import MirrorsConfiguration from '@/components/install/MirrorsConfiguration.vue'
import { electronAPI } from '@/utils/envUtil' import { electronAPI } from '@/utils/envUtil'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue' import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
@@ -145,6 +84,9 @@ const pythonMirror = ref('')
const pypiMirror = ref('') const pypiMirror = ref('')
const torchMirror = ref('') const torchMirror = ref('')
/** Current step in the stepper */
const currentStep = ref('1')
/** Forces each install step to be visited at least once. */ /** Forces each install step to be visited at least once. */
const highestStep = ref(0) const highestStep = ref(0)
@@ -164,6 +106,40 @@ const setHighestStep = (value: string | number) => {
const hasError = computed(() => pathError.value !== '') const hasError = computed(() => pathError.value !== '')
const noGpu = computed(() => typeof device.value !== 'string') const noGpu = computed(() => typeof device.value !== 'string')
// Computed property to determine if user can proceed to next step
const regex = /^Insufficient space - minimum free space: \d+ GB$/
const canProceed = computed(() => {
switch (currentStep.value) {
case '1':
return typeof device.value === 'string'
case '2':
return pathError.value === '' || regex.test(pathError.value)
case '3':
return !hasError.value
default:
return false
}
})
// Navigation methods
const goToNextStep = () => {
const nextStep = (parseInt(currentStep.value) + 1).toString()
currentStep.value = nextStep
setHighestStep(nextStep)
electronAPI().Events.trackEvent('install_stepper_change', {
step: nextStep
})
}
const goToPreviousStep = () => {
const prevStep = (parseInt(currentStep.value) - 1).toString()
currentStep.value = prevStep
electronAPI().Events.trackEvent('install_stepper_change', {
step: prevStep
})
}
const electron = electronAPI() const electron = electronAPI()
const router = useRouter() const router = useRouter()
const install = async () => { const install = async () => {
@@ -195,7 +171,7 @@ onMounted(async () => {
} }
electronAPI().Events.trackEvent('install_stepper_change', { electronAPI().Events.trackEvent('install_stepper_change', {
step: '0', step: currentStep.value,
gpu: detectedGpu gpu: detectedGpu
}) })
}) })
@@ -205,6 +181,30 @@ onMounted(async () => {
@reference '../assets/css/style.css'; @reference '../assets/css/style.css';
:deep(.p-steppanel) { :deep(.p-steppanel) {
@apply mt-8 flex justify-center bg-transparent;
}
/* Remove default padding/margin from StepPanels to make scrollbar flush */
:deep(.p-steppanels) {
@apply p-0 m-0;
}
/* Ensure StepPanel content container has no top/bottom padding */
:deep(.p-steppanel-content) {
@apply p-0;
}
/* Custom overlay scrollbar for WebKit browsers (Electron, Chrome) */
:deep(.p-steppanels::-webkit-scrollbar) {
@apply w-4;
}
:deep(.p-steppanels::-webkit-scrollbar-track) {
@apply bg-transparent; @apply bg-transparent;
} }
:deep(.p-steppanels::-webkit-scrollbar-thumb) {
@apply bg-white/20 rounded-lg border-[4px] border-transparent;
background-clip: content-box;
}
</style> </style>

View File

@@ -1,76 +1,205 @@
<template> <template>
<BaseViewTemplate dark class="flex-col"> <BaseViewTemplate dark>
<div class="flex flex-col w-full h-full items-center"> <div class="relative min-h-screen">
<h2 class="text-2xl font-bold"> <!-- Terminal Background Layer (always visible during loading) -->
{{ t(`serverStart.process.${status}`) }} <div v-if="!isError" class="fixed inset-0 overflow-hidden z-0">
<span v-if="status === ProgressStatus.ERROR"> <div class="h-full w-full">
v{{ electronVersion }} <BaseTerminal @created="terminalCreated" />
</span> </div>
</h2> </div>
<div
v-if="status === ProgressStatus.ERROR" <!-- Semi-transparent overlay -->
class="flex flex-col items-center gap-4" <div v-if="!isError" class="fixed inset-0 bg-neutral-900/80 z-5"></div>
>
<div class="flex items-center my-4 gap-2"> <!-- Smooth radial gradient overlay -->
<Button <div
icon="pi pi-flag" v-if="!isError"
severity="secondary" class="fixed inset-0 z-8"
:label="t('serverStart.reportIssue')" style="
@click="reportIssue" background: radial-gradient(
/> ellipse 800px 600px at center,
<Button rgba(23, 23, 23, 0.95) 0%,
icon="pi pi-file" rgba(23, 23, 23, 0.93) 10%,
severity="secondary" rgba(23, 23, 23, 0.9) 20%,
:label="t('serverStart.openLogs')" rgba(23, 23, 23, 0.85) 30%,
@click="openLogs" rgba(23, 23, 23, 0.75) 40%,
/> rgba(23, 23, 23, 0.6) 50%,
<Button rgba(23, 23, 23, 0.4) 60%,
icon="pi pi-wrench" rgba(23, 23, 23, 0.2) 70%,
:label="t('serverStart.troubleshoot')" rgba(23, 23, 23, 0.1) 80%,
@click="troubleshoot" rgba(23, 23, 23, 0.05) 90%,
/> transparent 100%
);
"
></div>
<div class="relative z-10">
<!-- Main startup display using StartupDisplay component -->
<StartupDisplay
:title="displayTitle"
:status-text="displayStatusText"
:progress-percentage="installStageProgress"
:hide-progress="isError"
/>
<!-- Error Section (positioned at bottom) -->
<div
v-if="isError"
class="absolute bottom-20 left-0 right-0 flex flex-col items-center gap-4"
>
<div class="flex gap-4 justify-center">
<Button
icon="pi pi-flag"
:label="$t('serverStart.reportIssue')"
severity="secondary"
@click="reportIssue"
/>
<Button
icon="pi pi-file"
:label="$t('serverStart.openLogs')"
severity="secondary"
@click="openLogs"
/>
<Button
icon="pi pi-wrench"
:label="$t('serverStart.troubleshoot')"
@click="troubleshoot"
/>
</div>
<div class="text-center">
<button
v-if="!terminalVisible"
class="text-sm text-neutral-500 hover:text-neutral-300 transition-colors flex items-center gap-2 mx-auto"
@click="terminalVisible = true"
>
<i class="pi pi-search"></i>
{{ $t('serverStart.showTerminal') }}
</button>
</div>
</div>
<!-- Terminal Output (positioned at bottom when manually toggled in error state) -->
<div
v-if="terminalVisible && isError"
class="absolute bottom-4 left-4 right-4 max-w-4xl mx-auto z-10"
>
<div
class="bg-neutral-900/95 rounded-lg p-4 border border-neutral-700 h-[300px]"
>
<BaseTerminal @created="terminalCreated" />
</div>
</div> </div>
<Button
v-if="!terminalVisible"
icon="pi pi-search"
severity="secondary"
:label="t('serverStart.showTerminal')"
@click="terminalVisible = true"
/>
</div> </div>
<BaseTerminal v-show="terminalVisible" @created="terminalCreated" />
</div> </div>
</BaseViewTemplate> </BaseViewTemplate>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ProgressStatus } from '@comfyorg/comfyui-electron-types' import {
InstallStage,
type InstallStageInfo,
type InstallStageName,
ProgressStatus
} from '@comfyorg/comfyui-electron-types'
import type { Terminal } from '@xterm/xterm' import type { Terminal } from '@xterm/xterm'
import Button from 'primevue/button' import Button from 'primevue/button'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { onMounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import BaseTerminal from '@/components/bottomPanel/tabs/terminal/BaseTerminal.vue' import BaseTerminal from '@/components/bottomPanel/tabs/terminal/BaseTerminal.vue'
import StartupDisplay from '@/components/common/StartupDisplay.vue'
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal' import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import { electronAPI } from '@/utils/envUtil' import { electronAPI } from '@/utils/envUtil'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue' import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
const electron = electronAPI()
const { t } = useI18n() const { t } = useI18n()
const electron = electronAPI()
const status = ref<ProgressStatus>(ProgressStatus.INITIAL_STATE) const status = ref<ProgressStatus>(ProgressStatus.INITIAL_STATE)
const electronVersion = ref<string>('') const electronVersion = ref<string>('')
const terminalVisible = ref(false)
const installStage = ref<InstallStageName | null>(null)
const installStageMessage = ref<string>('')
const installStageProgress = ref<number | undefined>(undefined)
let xterm: Terminal | undefined let xterm: Terminal | undefined
const terminalVisible = ref(true) /**
* Handles installation stage updates from the desktop
*/
const updateInstallStage = (stageInfo: InstallStageInfo) => {
console.warn('[InstallStage.onUpdate] Received:', {
stage: stageInfo.stage,
progress: stageInfo.progress,
message: stageInfo.message,
error: stageInfo.error,
timestamp: stageInfo.timestamp,
fullInfo: stageInfo
})
installStage.value = stageInfo.stage
installStageMessage.value = stageInfo.message || ''
installStageProgress.value = stageInfo.progress
}
const currentStatusLabel = computed(() => {
// Use the message from the Electron API if available
if (installStageMessage.value) {
return installStageMessage.value
}
return t(`serverStart.process.${status.value}`)
})
const isError = computed(
() =>
status.value === ProgressStatus.ERROR ||
installStage.value === InstallStage.ERROR
)
const isInstallationStage = computed(() => {
const installationStages: InstallStageName[] = [
InstallStage.WELCOME_SCREEN,
InstallStage.INSTALL_OPTIONS_SELECTION,
InstallStage.CREATING_DIRECTORIES,
InstallStage.INITIALIZING_CONFIG,
InstallStage.PYTHON_ENVIRONMENT_SETUP,
InstallStage.INSTALLING_REQUIREMENTS,
InstallStage.INSTALLING_PYTORCH,
InstallStage.INSTALLING_COMFYUI_REQUIREMENTS,
InstallStage.INSTALLING_MANAGER_REQUIREMENTS,
InstallStage.MIGRATING_CUSTOM_NODES
]
return (
installStage.value !== null &&
installationStages.includes(installStage.value)
)
})
const displayTitle = computed(() => {
if (isError.value) {
return t('serverStart.errorMessage')
}
if (isInstallationStage.value) {
return t('serverStart.installation.title')
}
return t('serverStart.title')
})
const displayStatusText = computed(() => {
if (isError.value && electronVersion.value) {
return `v${electronVersion.value}`
}
return currentStatusLabel.value
})
const updateProgress = ({ status: newStatus }: { status: ProgressStatus }) => { const updateProgress = ({ status: newStatus }: { status: ProgressStatus }) => {
status.value = newStatus status.value = newStatus
// Make critical error screen more obvious. // Make critical error screen more obvious.
if (newStatus === ProgressStatus.ERROR) terminalVisible.value = false if (newStatus === ProgressStatus.ERROR) terminalVisible.value = false
else xterm?.clear()
} }
const terminalCreated = ( const terminalCreated = (
@@ -95,9 +224,30 @@ const reportIssue = () => {
} }
const openLogs = () => electron.openLogsFolder() const openLogs = () => electron.openLogsFolder()
let cleanupInstallStageListener: (() => void) | undefined
onMounted(async () => { onMounted(async () => {
electron.sendReady() electron.sendReady()
electron.onProgressUpdate(updateProgress) electron.onProgressUpdate(updateProgress)
cleanupInstallStageListener =
electron.InstallStage.onUpdate(updateInstallStage)
const stageInfo = await electron.InstallStage.getCurrent()
updateInstallStage(stageInfo)
electronVersion.value = await electron.getElectronVersion() electronVersion.value = await electron.getElectronVersion()
}) })
onUnmounted(() => {
xterm?.dispose()
cleanupInstallStageListener?.()
})
</script> </script>
<style scoped>
@reference '../assets/css/style.css';
/* Hide the xterm scrollbar completely */
:deep(.p-terminal) .xterm-viewport {
overflow: hidden !important;
}
</style>

View File

@@ -1,21 +1,27 @@
<template> <template>
<BaseViewTemplate dark> <BaseViewTemplate dark>
<div class="flex flex-col items-center justify-center gap-8 p-8"> <div class="flex items-center justify-center min-h-screen">
<!-- Header --> <div class="grid grid-rows-2 gap-8">
<h1 class="animated-gradient-text text-glow select-none"> <!-- Top container: Logo -->
{{ $t('welcome.title') }} <div class="flex items-end justify-center">
</h1> <img
src="/assets/images/comfy-brand-mark.svg"
<!-- Get Started Button --> :alt="$t('g.logoAlt')"
<Button class="w-60"
:label="$t('welcome.getStarted')" />
icon="pi pi-arrow-right" </div>
icon-pos="right" <!-- Bottom container: Title and button -->
size="large" <div class="flex flex-col items-center justify-center gap-4">
rounded <Button
class="p-4 text-lg fade-in-up" :label="$t('welcome.getStarted')"
@click="navigateTo('/install')" class="px-8 mt-4 bg-brand-yellow hover:bg-brand-yellow/90 border-0 rounded-lg transition-colors"
/> :pt="{
label: { class: 'font-inter text-neutral-900 font-black' }
}"
@click="navigateTo('/install')"
/>
</div>
</div>
</div> </div>
</BaseViewTemplate> </BaseViewTemplate>
</template> </template>
@@ -31,49 +37,3 @@ const navigateTo = async (path: string) => {
await router.push(path) await router.push(path)
} }
</script> </script>
<style scoped>
@reference '../assets/css/style.css';
.animated-gradient-text {
@apply font-bold;
font-size: clamp(2rem, 8vw, 4rem);
background: linear-gradient(to right, #12c2e9, #c471ed, #f64f59, #12c2e9);
background-size: 300% auto;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradient 8s linear infinite;
}
.text-glow {
filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.3));
}
@keyframes gradient {
0% {
background-position: 0% center;
}
100% {
background-position: 300% center;
}
}
.fade-in-up {
animation: fadeInUp 1.5s ease-out;
animation-fill-mode: both;
}
@keyframes fadeInUp {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
</style>