[Electron] ComfyUI Desktop install wizard (#1503)

* Basic prototype

* Welcome screen animation

* nit

* Refactor structure

* Fix mocking

* Add tooltips

* i18n

* Add next button

* nit

* Stepper navigate

* Extract

* More i18n

* More i18n

* Polish MigrationPicker

* Polish settings step

* Add more i18n

* nit

* nit
This commit is contained in:
Chenlei Hu
2024-11-10 19:56:01 -05:00
committed by GitHub
parent 7bc79edf3d
commit d1e019589d
9 changed files with 644 additions and 78 deletions

View File

@@ -0,0 +1,112 @@
<template>
<div class="flex flex-col gap-6 w-[600px]">
<div class="flex flex-col gap-4">
<h2 class="text-2xl font-semibold text-neutral-100">
{{ $t('install.desktopAppSettings') }}
</h2>
<p class="text-neutral-400 my-0">
{{ $t('install.desktopAppSettingsDescription') }}
</p>
</div>
<div class="flex flex-col bg-neutral-800 p-4 rounded-lg">
<!-- Auto Update Setting -->
<div class="flex items-center gap-4">
<div class="flex-1">
<h3 class="text-lg font-medium text-neutral-100">
{{ $t('install.settings.autoUpdate') }}
</h3>
<p class="text-sm text-neutral-400 mt-1">
{{ $t('install.settings.autoUpdateDescription') }}
</p>
</div>
<InputSwitch v-model="autoUpdate" />
</div>
<Divider />
<!-- Metrics Collection Setting -->
<div class="flex items-center gap-4">
<div class="flex-1">
<h3 class="text-lg font-medium text-neutral-100">
{{ $t('install.settings.allowMetrics') }}
</h3>
<p class="text-sm text-neutral-400 mt-1">
{{ $t('install.settings.allowMetricsDescription') }}
</p>
<a
href="#"
class="text-sm text-blue-400 hover:text-blue-300 mt-1 inline-block"
@click.prevent="showMetricsInfo"
>
{{ $t('install.settings.learnMoreAboutData') }}
</a>
</div>
<InputSwitch v-model="allowMetrics" />
</div>
</div>
<!-- Info Dialog -->
<Dialog
v-model:visible="showDialog"
modal
:header="$t('install.settings.dataCollectionDialog.title')"
>
<div class="text-neutral-300">
<h4 class="font-medium mb-2">
{{ $t('install.settings.dataCollectionDialog.whatWeCollect') }}
</h4>
<ul class="list-disc pl-6 space-y-1">
<li>
{{ $t('install.settings.dataCollectionDialog.errorReports') }}
</li>
<li>
{{ $t('install.settings.dataCollectionDialog.systemInfo') }}
</li>
</ul>
<h4 class="font-medium mt-4 mb-2">
{{ $t('install.settings.dataCollectionDialog.whatWeDoNotCollect') }}
</h4>
<ul class="list-disc pl-6 space-y-1">
<li>
{{
$t('install.settings.dataCollectionDialog.personalInformation')
}}
</li>
<li>
{{ $t('install.settings.dataCollectionDialog.workflowContents') }}
</li>
<li>
{{
$t('install.settings.dataCollectionDialog.fileSystemInformation')
}}
</li>
<li>
{{
$t(
'install.settings.dataCollectionDialog.customNodeConfigurations'
)
}}
</li>
</ul>
</div>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import InputSwitch from 'primevue/inputswitch'
import Dialog from 'primevue/dialog'
import Divider from 'primevue/divider'
const showDialog = ref(false)
const autoUpdate = defineModel('autoUpdate', { required: true })
const allowMetrics = defineModel('allowMetrics', { required: true })
const showMetricsInfo = () => {
showDialog.value = true
}
</script>

View File

@@ -0,0 +1,115 @@
<template>
<div class="flex flex-col gap-6 w-[600px]">
<!-- Installation Path Section -->
<div class="flex flex-col gap-4">
<h2 class="text-2xl font-semibold text-neutral-100">
{{ $t('install.chooseInstallationLocation') }}
</h2>
<p class="text-neutral-400 my-0">
{{ $t('install.installLocationDescription') }}
</p>
<div class="flex gap-2">
<IconField class="flex-1">
<InputText
v-model="installPath"
class="w-full"
:class="{ 'p-invalid': pathError }"
@change="validatePath"
/>
<InputIcon
class="pi pi-info-circle"
v-tooltip="$t('install.installLocationTooltip')"
/>
</IconField>
<Button icon="pi pi-folder" @click="browsePath" class="w-12" />
</div>
<Message v-if="pathError" severity="error">
{{ pathError }}
</Message>
</div>
<!-- System Paths Info -->
<div class="bg-neutral-800 p-4 rounded-lg">
<h3 class="text-lg font-medium mt-0 mb-3 text-neutral-100">
{{ $t('install.systemLocations') }}
</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
class="pi pi-info-circle"
v-tooltip="$t('install.appDataLocationTooltip')"
></span>
</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
class="pi pi-info-circle"
v-tooltip="$t('install.appPathLocationTooltip')"
></span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { electronAPI } from '@/utils/envUtil'
import InputText from 'primevue/inputtext'
import Button from 'primevue/button'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import Message from 'primevue/message'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const installPath = defineModel<string>('installPath', { required: true })
const pathError = defineModel<string>('pathError', { required: true })
const appData = ref('')
const appPath = ref('')
// TODO: Implement the actual electron API.
const electron = electronAPI() as any
// Get system paths on component mount
onMounted(async () => {
const paths = await electron.getSystemPaths()
appData.value = paths.appData
appPath.value = paths.appPath
installPath.value = paths.defaultInstallPath
})
const validatePath = async () => {
try {
pathError.value = ''
const validation = await electron.validateInstallPath(installPath.value)
if (!validation.isValid) {
pathError.value = validation.error
}
} catch (error) {
pathError.value = t('install.pathValidationFailed')
}
}
const browsePath = async () => {
try {
const result = await electron.showDirectoryPicker()
if (result) {
installPath.value = result
await validatePath()
}
} catch (error) {
pathError.value = t('install.failedToSelectDirectory')
}
}
</script>

View File

@@ -0,0 +1,136 @@
<template>
<div class="flex flex-col gap-6 w-[600px]">
<!-- Source Location Section -->
<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">
{{ $t('install.migrationSourcePathDescription') }}
</p>
<div class="flex gap-2">
<InputText
v-model="sourcePath"
placeholder="Select existing ComfyUI installation (optional)"
class="flex-1"
:class="{ 'p-invalid': pathError }"
@change="validateSource"
/>
<Button icon="pi pi-folder" @click="browsePath" class="w-12" />
</div>
<Message v-if="pathError" severity="error">
{{ pathError }}
</Message>
</div>
<!-- Migration Options -->
<div
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">
{{ $t('install.selectItemsToMigrate') }}
</h3>
<div class="flex flex-col gap-3">
<div
v-for="item in migrationItems"
:key="item.id"
class="flex items-center gap-3 p-2 hover:bg-neutral-700 rounded"
@click="item.selected = !item.selected"
>
<Checkbox
v-model="item.selected"
:inputId="item.id"
:binary="true"
@click.stop
/>
<div>
<label :for="item.id" class="text-neutral-200 font-medium">
{{ item.label }}
</label>
<p class="text-sm text-neutral-400 my-1">
{{ item.description }}
</p>
</div>
</div>
</div>
</div>
<!-- Skip Migration -->
<div v-else class="text-neutral-400 italic">
{{ $t('install.migrationOptional') }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watchEffect } from 'vue'
import { electronAPI } from '@/utils/envUtil'
import InputText from 'primevue/inputtext'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import Message from 'primevue/message'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const electron = electronAPI() as any
const sourcePath = defineModel<string>('sourcePath', { required: false })
const migrationItemIds = defineModel<string[]>('migrationItemIds', {
required: false
})
const migrationItems = ref([])
const pathError = ref('')
const isValidSource = computed(
() => sourcePath.value !== '' && pathError.value === ''
)
const validateSource = async () => {
if (!sourcePath.value) {
pathError.value = ''
return
}
try {
pathError.value = ''
const validation = await electron.validateComfyUISource(sourcePath.value)
if (!validation.isValid) pathError.value = validation.error
} catch (error) {
console.error(error)
pathError.value = t('install.pathValidationFailed')
}
}
const browsePath = async () => {
try {
const result = await electron.showDirectoryPicker()
if (result) {
sourcePath.value = result
await validateSource()
}
} catch (error) {
console.error(error)
pathError.value = t('install.failedToSelectDirectory')
}
}
onMounted(async () => {
migrationItems.value = (await electron.migrationItems()).map((item) => ({
...item,
selected: true
}))
})
watchEffect(() => {
migrationItemIds.value = migrationItems.value
.filter((item) => item.selected)
.map((item) => item.id)
})
</script>

View File

@@ -1,47 +0,0 @@
<template>
<div class="flex flex-col items-center justify-center gap-8 p-8">
<!-- Header -->
<h1 class="text-4xl font-bold text-neutral-100">Welcome to ComfyUI</h1>
<!-- Get Started Button -->
<Button
label="Get Started"
icon="pi pi-arrow-right"
iconPos="right"
size="large"
@click="$emit('start')"
class="p-4 text-lg"
/>
<!-- Installation Steps -->
<div class="w-[600px]">
<Steps :model="installSteps" :readonly="true" />
</div>
</div>
</template>
<script setup lang="ts">
import Steps from 'primevue/steps'
import Button from 'primevue/button'
const installSteps = [
{
label: 'Install Location',
icon: 'pi pi-folder'
},
{
label: 'Migration',
icon: 'pi pi-download'
},
{
label: 'Desktop Settings',
icon: 'pi pi-desktop'
},
{
label: 'Review',
icon: 'pi pi-check'
}
]
defineEmits(['start'])
</script>

View File

@@ -2,6 +2,51 @@ import { createI18n } from 'vue-i18n'
const messages = {
en: {
install: {
installLocation: 'Install Location',
migration: 'Migration',
desktopSettings: 'Desktop Settings',
chooseInstallationLocation: 'Choose Installation Location',
systemLocations: 'System Locations',
failedToSelectDirectory: 'Failed to select directory',
pathValidationFailed: 'Failed to validate path',
installLocationDescription:
"Select the directory for ComfyUI's user data. A python environment will be installed to the selected location. Please make sure the selected disk has enough space (~5GB) left.",
installLocationTooltip:
"ComfyUI's user data directory. Stores:\n- Python Environment\n- Models\n- Custom nodes\n",
appDataLocationTooltip:
"ComfyUI's app data directory. Stores:\n- Logs\n- Server configs",
appPathLocationTooltip:
"ComfyUI's app asset directory. Stores the ComfyUI code and assets",
migrateFromExistingInstallation: 'Migrate from Existing Installation',
migrationSourcePathDescription:
'If you have an existing ComfyUI installation, we can copy/link your existing user files and models to the new installation.',
selectItemsToMigrate: 'Select Items to Migrate',
migrationOptional:
"Migration is optional. If you don't have an existing installation, you can skip this step.",
desktopAppSettings: 'Desktop App Settings',
desktopAppSettingsDescription:
'Configure how ComfyUI behaves on your desktop. You can change these settings later.',
settings: {
autoUpdate: 'Automatic Updates',
allowMetrics: 'Usage Analytics',
autoUpdateDescription:
"Automatically download and install updates when they become available. You'll always be notified before updates are installed.",
allowMetricsDescription:
'Help improve ComfyUI by sending anonymous usage data. No personal information or workflow content will be collected.',
learnMoreAboutData: 'Learn more about data collection',
dataCollectionDialog: {
title: 'About Data Collection',
whatWeCollect: 'What we collect:',
whatWeDoNotCollect: "What we don't collect:",
errorReports: 'Error reports',
systemInfo: 'Operating system and app version',
personalInformation: 'Personal information',
workflowContent: 'Workflow content',
fileSystemInformation: 'File system information'
}
}
},
download: 'Download',
loadAllFolders: 'Load All Folders',
refresh: 'Refresh',

View File

@@ -1,12 +1,25 @@
import {
createRouter,
createWebHashHistory,
createWebHistory
createWebHistory,
NavigationGuardNext,
RouteLocationNormalized
} from 'vue-router'
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
import { isElectron } from './utils/envUtil'
const isFileProtocol = () => window.location.protocol === 'file:'
const guardElectronAccess = (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext
) => {
if (isElectron()) {
next()
} else {
next('/')
}
}
const router = createRouter({
history: isFileProtocol() ? createWebHashHistory() : createWebHistory(),
@@ -24,27 +37,19 @@ const router = createRouter({
path: 'server-start',
name: 'ServerStartView',
component: () => import('@/views/ServerStartView.vue'),
beforeEnter: async (to, from, next) => {
// Only allow access to this page in electron environment
if (isElectron()) {
next()
} else {
next('/')
}
}
beforeEnter: guardElectronAccess
},
{
path: 'install',
name: 'InstallView',
component: () => import('@/views/InstallView.vue'),
beforeEnter: async (to, from, next) => {
// Only allow access to this page in electron environment
if (isElectron()) {
next()
} else {
next('/')
}
}
beforeEnter: guardElectronAccess
},
{
path: 'welcome',
name: 'WelcomeView',
component: () => import('@/views/WelcomeView.vue'),
beforeEnter: guardElectronAccess
}
]
}

View File

@@ -1,11 +1,118 @@
<template>
<div
class="font-sans flex flex-col justify-center items-center h-screen m-0 text-neutral-300 bg-neutral-900 dark-theme pointer-events-auto"
class="font-sans flex flex-col items-center h-screen m-0 text-neutral-300 bg-neutral-900 dark-theme pointer-events-auto"
>
<WelcomeScreen />
<Stepper class="mt-[5vh] 2xl:mt-[20vh]" value="1">
<StepList>
<Step value="1" :disabled="hasError">
{{ $t('install.installLocation') }}
</Step>
<Step value="2" :disabled="hasError">
{{ $t('install.migration') }}
</Step>
<Step value="3" :disabled="hasError">
{{ $t('install.desktopSettings') }}
</Step>
</StepList>
<StepPanels>
<StepPanel value="1" v-slot="{ activateCallback }">
<InstallLocationPicker
v-model:installPath="installPath"
v-model:pathError="pathError"
/>
<div class="flex pt-6 justify-end">
<Button
label="Next"
icon="pi pi-arrow-right"
iconPos="right"
@click="activateCallback('2')"
:disabled="pathError !== ''"
/>
</div>
</StepPanel>
<StepPanel value="2" v-slot="{ activateCallback }">
<MigrationPicker
v-model:sourcePath="migrationSourcePath"
v-model:migrationItemIds="migrationItemIds"
/>
<div class="flex pt-6 justify-between">
<Button
label="Back"
severity="secondary"
icon="pi pi-arrow-left"
@click="activateCallback('1')"
/>
<Button
label="Next"
icon="pi pi-arrow-right"
iconPos="right"
@click="activateCallback('3')"
/>
</div>
</StepPanel>
<StepPanel value="3" v-slot="{ activateCallback }">
<DesktopSettingsConfiguration
v-model:autoUpdate="autoUpdate"
v-model:allowMetrics="allowMetrics"
/>
<div class="flex pt-6 justify-between">
<Button
label="Back"
severity="secondary"
icon="pi pi-arrow-left"
@click="activateCallback('2')"
/>
<Button
label="Install"
icon="pi pi-check"
iconPos="right"
@click="install()"
/>
</div>
</StepPanel>
</StepPanels>
</Stepper>
</div>
</template>
<script setup lang="ts">
import WelcomeScreen from '@/components/install/WelcomeScreen.vue'
import Button from 'primevue/button'
import Stepper from 'primevue/stepper'
import StepList from 'primevue/steplist'
import StepPanels from 'primevue/steppanels'
import Step from 'primevue/step'
import StepPanel from 'primevue/steppanel'
import InstallLocationPicker from '@/components/install/InstallLocationPicker.vue'
import MigrationPicker from '@/components/install/MigrationPicker.vue'
import DesktopSettingsConfiguration from '@/components/install/DesktopSettingsConfiguration.vue'
import { electronAPI } from '@/utils/envUtil'
import { ref, computed } from 'vue'
const installPath = ref('')
const pathError = ref('')
const migrationSourcePath = ref('')
const migrationItemIds = ref<string[]>([])
const autoUpdate = ref(true)
const allowMetrics = ref(true)
const hasError = computed(() => pathError.value !== '')
const install = () => {
;(electronAPI() as any).installComfyUI({
installPath: installPath.value,
autoUpdate: autoUpdate.value,
allowMetrics: allowMetrics.value,
migrationSourcePath: migrationSourcePath.value,
migrationItemIds: migrationItemIds.value
})
}
</script>
<style scoped>
:deep(.p-steppanel) {
@apply bg-transparent;
}
</style>

69
src/views/WelcomeView.vue Normal file
View File

@@ -0,0 +1,69 @@
<template>
<div
class="font-sans flex flex-col justify-center items-center h-screen m-0 text-neutral-300 bg-neutral-900 dark-theme pointer-events-auto"
>
<div class="flex flex-col items-center justify-center gap-8 p-8">
<!-- Header -->
<h1 class="animated-gradient-text text-glow">Welcome to ComfyUI</h1>
<!-- Get Started Button -->
<Button
label="Get Started"
icon="pi pi-arrow-right"
iconPos="right"
size="large"
rounded
@click="$router.push('/install')"
class="p-4 text-lg fade-in-up"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
</script>
<style scoped>
.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>

View File

@@ -2,16 +2,6 @@ import { defineConfig, Plugin } from 'vite'
import { mergeConfig } from 'vite'
import type { UserConfig } from 'vitest/config'
import baseConfig from './vite.config.mts'
import type { ElectronAPI } from '@comfyorg/comfyui-electron-types'
const electronAPIMock: Partial<ElectronAPI> = {
sendReady: () => {},
onShowSelectDirectory: () => {},
onFirstTimeSetupComplete: () => {},
onProgressUpdate: () => {},
onLogMessage: () => {},
isFirstTimeSetup: () => Promise.resolve(true)
}
const mockElectronAPI: Plugin = {
name: 'mock-electron-api',
@@ -19,7 +9,41 @@ const mockElectronAPI: Plugin = {
return [
{
tag: 'script',
children: `window.electronAPI = ${JSON.stringify(electronAPIMock)};`
children: `window.electronAPI = {
sendReady: () => {},
onShowSelectDirectory: () => {},
onFirstTimeSetupComplete: () => {},
onProgressUpdate: () => {},
onLogMessage: () => {},
isFirstTimeSetup: () => Promise.resolve(true),
getSystemPaths: () =>
Promise.resolve({
appData: 'C:/Users/username/AppData/Roaming',
appPath: 'C:/Program Files/comfyui-electron/resources/app',
defaultInstallPath: 'C:/Users/username/comfyui-electron'
}),
validateInstallPath: (path) => {
if (path === 'bad') {
return { isValid: false, error: 'Bad path!' }
}
return { isValid: true }
},
migrationItems: () =>
Promise.resolve([
{
id: 'user_files',
label: 'User Files',
description: 'Settings and user-created workflows'
}
]),
validateComfyUISource: (path) => {
if (path === 'bad') {
return { isValid: false, error: 'Bad path!' }
}
return { isValid: true }
},
showDirectoryPicker: () => Promise.resolve('C:/Users/username/comfyui-electron/1')
};`
}
]
}