[refactor] Improve updates/notifications domain organization (#5590)

* [refactor] Move update-related functionality to platform/updates domain

Reorganizes release management, version compatibility, and notification functionality
following Domain-Driven Design principles, mirroring VSCode's architecture pattern.

- Move releaseService.ts to platform/updates/common/
- Move releaseStore.ts to platform/updates/common/
- Move versionCompatibilityStore.ts to platform/updates/common/
- Move useFrontendVersionMismatchWarning.ts to platform/updates/common/
- Move toastStore.ts to platform/updates/common/
- Move ReleaseNotificationToast.vue to platform/updates/components/
- Move WhatsNewPopup.vue to platform/updates/components/
- Update 25+ import paths across codebase and tests

This creates a cohesive "updates" domain containing all functionality related to
software updates, version checking, release notifications, and user communication
about application state changes.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* fix imports

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Christian Byrne
2025-09-15 04:34:37 -07:00
committed by GitHub
parent 4c8c4a1ad4
commit 27ab355f9c
49 changed files with 70 additions and 61 deletions

View File

@@ -0,0 +1,119 @@
import axios, { AxiosError, AxiosResponse } from 'axios'
import { ref } from 'vue'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
const releaseApiClient = axios.create({
baseURL: COMFY_API_BASE_URL,
headers: {
'Content-Type': 'application/json'
}
})
// Use generated types from OpenAPI spec
export type ReleaseNote = components['schemas']['ReleaseNote']
type GetReleasesParams = operations['getReleaseNotes']['parameters']['query']
// Use generated error response type
type ErrorResponse = components['schemas']['ErrorResponse']
// Release service for fetching release notes
export const useReleaseService = () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
// No transformation needed - API response matches the generated type
// Handle API errors with context
const handleApiError = (
err: unknown,
context: string,
routeSpecificErrors?: Record<number, string>
): string => {
if (!axios.isAxiosError(err))
return err instanceof Error
? `${context}: ${err.message}`
: `${context}: Unknown error occurred`
const axiosError = err as AxiosError<ErrorResponse>
if (axiosError.response) {
const { status, data } = axiosError.response
if (routeSpecificErrors && routeSpecificErrors[status])
return routeSpecificErrors[status]
switch (status) {
case 400:
return `Bad request: ${data?.message || 'Invalid input'}`
case 401:
return 'Unauthorized: Authentication required'
case 403:
return `Forbidden: ${data?.message || 'Access denied'}`
case 404:
return `Not found: ${data?.message || 'Resource not found'}`
case 500:
return `Server error: ${data?.message || 'Internal server error'}`
default:
return `${context}: ${data?.message || axiosError.message}`
}
}
return `${context}: ${axiosError.message}`
}
// Execute API request with error handling
const executeApiRequest = async <T>(
apiCall: () => Promise<AxiosResponse<T>>,
errorContext: string,
routeSpecificErrors?: Record<number, string>
): Promise<T | null> => {
isLoading.value = true
error.value = null
try {
const response = await apiCall()
return response.data
} catch (err) {
// Don't treat cancellations as errors
if (isAbortError(err)) return null
error.value = handleApiError(err, errorContext, routeSpecificErrors)
return null
} finally {
isLoading.value = false
}
}
// Fetch release notes from API
const getReleases = async (
params: GetReleasesParams,
signal?: AbortSignal
): Promise<ReleaseNote[] | null> => {
const endpoint = '/releases'
const errorContext = 'Failed to get releases'
const routeSpecificErrors = {
400: 'Invalid project or version parameter'
}
const apiResponse = await executeApiRequest(
() =>
releaseApiClient.get<ReleaseNote[]>(endpoint, {
params,
signal
}),
errorContext,
routeSpecificErrors
)
return apiResponse
}
return {
isLoading,
error,
getReleases
}
}

View File

@@ -0,0 +1,289 @@
import { until } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { isElectron } from '@/utils/envUtil'
import { compareVersions, stringToLocale } from '@/utils/formatUtil'
import { type ReleaseNote, useReleaseService } from './releaseService'
// Store for managing release notes
export const useReleaseStore = defineStore('release', () => {
// State
const releases = ref<ReleaseNote[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
// Services
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
const settingStore = useSettingStore()
// Current ComfyUI version
const currentComfyUIVersion = computed(
() => systemStatsStore?.systemStats?.system?.comfyui_version ?? ''
)
// Release data from settings
const locale = computed(() => settingStore.get('Comfy.Locale'))
const releaseVersion = computed(() =>
settingStore.get('Comfy.Release.Version')
)
const releaseStatus = computed(() => settingStore.get('Comfy.Release.Status'))
const releaseTimestamp = computed(() =>
settingStore.get('Comfy.Release.Timestamp')
)
const showVersionUpdates = computed(() =>
settingStore.get('Comfy.Notification.ShowVersionUpdates')
)
// Most recent release
const recentRelease = computed(() => {
return releases.value[0] ?? null
})
// 3 most recent releases
const recentReleases = computed(() => {
return releases.value.slice(0, 3)
})
// Helper constants
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000 // 3 days
// New version available?
const isNewVersionAvailable = computed(
() =>
!!recentRelease.value &&
compareVersions(
recentRelease.value.version,
currentComfyUIVersion.value
) > 0
)
const isLatestVersion = computed(
() =>
!!recentRelease.value &&
!compareVersions(recentRelease.value.version, currentComfyUIVersion.value)
)
const hasMediumOrHighAttention = computed(() =>
recentReleases.value
.slice(0, -1)
.some(
(release) =>
release.attention === 'medium' || release.attention === 'high'
)
)
// Show toast if needed
const shouldShowToast = computed(() => {
// Only show on desktop version
if (!isElectron()) {
return false
}
// Skip if notifications are disabled
if (!showVersionUpdates.value) {
return false
}
if (!isNewVersionAvailable.value) {
return false
}
// Skip if low attention
if (!hasMediumOrHighAttention.value) {
return false
}
// Skip if user already skipped or changelog seen
if (
releaseVersion.value === recentRelease.value?.version &&
['skipped', 'changelog seen'].includes(releaseStatus.value)
) {
return false
}
return true
})
// Show red-dot indicator
const shouldShowRedDot = computed(() => {
// Only show on desktop version
if (!isElectron()) {
return false
}
// Skip if notifications are disabled
if (!showVersionUpdates.value) {
return false
}
// Already latest → no dot
if (!isNewVersionAvailable.value) {
return false
}
const { version } = recentRelease.value
// Changelog seen → clear dot
if (
releaseVersion.value === version &&
releaseStatus.value === 'changelog seen'
) {
return false
}
// Attention medium / high (levels 2 & 3)
if (hasMediumOrHighAttention.value) {
// Persist until changelog is opened
return true
}
// Attention low (level 1) and skipped → keep up to 3 d
if (
releaseVersion.value === version &&
releaseStatus.value === 'skipped' &&
releaseTimestamp.value &&
Date.now() - releaseTimestamp.value >= THREE_DAYS_MS
) {
return false
}
// Not skipped → show
return true
})
// Show "What's New" popup
const shouldShowPopup = computed(() => {
// Only show on desktop version
if (!isElectron()) {
return false
}
// Skip if notifications are disabled
if (!showVersionUpdates.value) {
return false
}
if (!isLatestVersion.value) {
return false
}
// Hide if already seen
if (
releaseVersion.value === recentRelease.value.version &&
releaseStatus.value === "what's new seen"
) {
return false
}
return true
})
// Action handlers for user interactions
async function handleSkipRelease(version: string): Promise<void> {
if (
version !== recentRelease.value?.version ||
releaseStatus.value === 'changelog seen'
) {
return
}
await settingStore.set('Comfy.Release.Version', version)
await settingStore.set('Comfy.Release.Status', 'skipped')
await settingStore.set('Comfy.Release.Timestamp', Date.now())
}
async function handleShowChangelog(version: string): Promise<void> {
if (version !== recentRelease.value?.version) {
return
}
await settingStore.set('Comfy.Release.Version', version)
await settingStore.set('Comfy.Release.Status', 'changelog seen')
await settingStore.set('Comfy.Release.Timestamp', Date.now())
}
async function handleWhatsNewSeen(version: string): Promise<void> {
if (version !== recentRelease.value?.version) {
return
}
await settingStore.set('Comfy.Release.Version', version)
await settingStore.set('Comfy.Release.Status', "what's new seen")
await settingStore.set('Comfy.Release.Timestamp', Date.now())
}
// Fetch releases from API
async function fetchReleases(): Promise<void> {
if (isLoading.value) {
return
}
// Skip fetching if notifications are disabled
if (!showVersionUpdates.value) {
return
}
// Skip fetching if API nodes are disabled via argv
if (
systemStatsStore.systemStats?.system?.argv?.includes(
'--disable-api-nodes'
)
) {
return
}
isLoading.value = true
error.value = null
try {
// Ensure system stats are loaded
if (!systemStatsStore.systemStats) {
await until(systemStatsStore.isInitialized)
}
const fetchedReleases = await releaseService.getReleases({
project: 'comfyui',
current_version: currentComfyUIVersion.value,
form_factor: systemStatsStore.getFormFactor(),
locale: stringToLocale(locale.value)
})
if (fetchedReleases !== null) {
releases.value = fetchedReleases
} else if (releaseService.error.value) {
error.value = releaseService.error.value
}
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Unknown error occurred'
} finally {
isLoading.value = false
}
}
// Initialize store
async function initialize(): Promise<void> {
await fetchReleases()
}
return {
releases,
isLoading,
error,
recentRelease,
recentReleases,
shouldShowToast,
shouldShowRedDot,
shouldShowPopup,
shouldShowUpdateButton: isNewVersionAvailable,
handleSkipRelease,
handleShowChangelog,
handleWhatsNewSeen,
fetchReleases,
initialize
}
})

View File

@@ -0,0 +1,39 @@
// Within Vue component context, you can directly call useToast().add()
// instead of going through the store.
// The store is useful when you need to call it from outside the Vue component context.
import { defineStore } from 'pinia'
import type { ToastMessageOptions } from 'primevue/toast'
import { ref } from 'vue'
export const useToastStore = defineStore('toast', () => {
const messagesToAdd = ref<ToastMessageOptions[]>([])
const messagesToRemove = ref<ToastMessageOptions[]>([])
const removeAllRequested = ref(false)
function add(message: ToastMessageOptions) {
messagesToAdd.value = [...messagesToAdd.value, message]
}
function remove(message: ToastMessageOptions) {
messagesToRemove.value = [...messagesToRemove.value, message]
}
function removeAll() {
removeAllRequested.value = true
}
function addAlert(message: string) {
add({ severity: 'warn', summary: 'Alert', detail: message })
}
return {
messagesToAdd,
messagesToRemove,
removeAllRequested,
add,
remove,
removeAll,
addAlert
}
})

View File

@@ -0,0 +1,94 @@
import { whenever } from '@vueuse/core'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToastStore } from './toastStore'
import { useVersionCompatibilityStore } from './versionCompatibilityStore'
interface UseFrontendVersionMismatchWarningOptions {
immediate?: boolean
}
/**
* Composable for handling frontend version mismatch warnings.
*
* Displays toast notifications when the frontend version is incompatible with the backend,
* either because the frontend is outdated or newer than the backend expects.
* Automatically dismisses warnings when shown and persists dismissal state for 7 days.
*
* @param options - Configuration options
* @param options.immediate - If true, automatically shows warning when version mismatch is detected
* @returns Object with methods and computed properties for managing version warnings
*
* @example
* ```ts
* // Show warning immediately when mismatch detected
* const { showWarning, shouldShowWarning } = useFrontendVersionMismatchWarning({ immediate: true })
*
* // Manual control
* const { showWarning } = useFrontendVersionMismatchWarning()
* showWarning() // Call when needed
* ```
*/
export function useFrontendVersionMismatchWarning(
options: UseFrontendVersionMismatchWarningOptions = {}
) {
const { immediate = false } = options
const { t } = useI18n()
const toastStore = useToastStore()
const versionCompatibilityStore = useVersionCompatibilityStore()
// Track if we've already shown the warning
let hasShownWarning = false
const showWarning = () => {
// Prevent showing the warning multiple times
if (hasShownWarning) return
const message = versionCompatibilityStore.warningMessage
if (!message) return
const detailMessage = t('g.frontendOutdated', {
frontendVersion: message.frontendVersion,
requiredVersion: message.requiredVersion
})
const fullMessage = t('g.versionMismatchWarningMessage', {
warning: t('g.versionMismatchWarning'),
detail: detailMessage
})
toastStore.addAlert(fullMessage)
hasShownWarning = true
// Automatically dismiss the warning so it won't show again for 7 days
versionCompatibilityStore.dismissWarning()
}
onMounted(() => {
// Only set up the watcher if immediate is true
if (immediate) {
whenever(
() => versionCompatibilityStore.shouldShowWarning,
() => {
showWarning()
},
{
immediate: true,
once: true
}
)
}
})
return {
showWarning,
shouldShowWarning: computed(
() => versionCompatibilityStore.shouldShowWarning
),
dismissWarning: versionCompatibilityStore.dismissWarning,
hasVersionMismatch: computed(
() => versionCompatibilityStore.hasVersionMismatch
)
}
}

View File

@@ -0,0 +1,138 @@
import { until, useStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import * as semver from 'semver'
import { computed } from 'vue'
import config from '@/config'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
const DISMISSAL_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
export const useVersionCompatibilityStore = defineStore(
'versionCompatibility',
() => {
const systemStatsStore = useSystemStatsStore()
const frontendVersion = computed(() => config.app_version)
const backendVersion = computed(
() => systemStatsStore.systemStats?.system?.comfyui_version ?? ''
)
const requiredFrontendVersion = computed(
() =>
systemStatsStore.systemStats?.system?.required_frontend_version ?? ''
)
const isFrontendOutdated = computed(() => {
if (
!frontendVersion.value ||
!requiredFrontendVersion.value ||
!semver.valid(frontendVersion.value) ||
!semver.valid(requiredFrontendVersion.value)
) {
return false
}
// Returns true if required version is greater than frontend version
return semver.gt(requiredFrontendVersion.value, frontendVersion.value)
})
const isFrontendNewer = computed(() => {
// We don't warn about frontend being newer than backend
// Only warn when frontend is outdated (behind required version)
return false
})
const hasVersionMismatch = computed(() => {
return isFrontendOutdated.value
})
const versionKey = computed(() => {
if (
!frontendVersion.value ||
!backendVersion.value ||
!requiredFrontendVersion.value
) {
return null
}
return `${frontendVersion.value}-${backendVersion.value}-${requiredFrontendVersion.value}`
})
// Use reactive storage for dismissals - creates a reactive ref that syncs with localStorage
// All version mismatch dismissals are stored in a single object for clean localStorage organization
const dismissalStorage = useStorage(
'comfy.versionMismatch.dismissals',
{} as Record<string, number>,
localStorage,
{
serializer: {
read: (value: string) => {
try {
return JSON.parse(value)
} catch {
return {}
}
},
write: (value: Record<string, number>) => JSON.stringify(value)
}
}
)
const isDismissed = computed(() => {
if (!versionKey.value) return false
const dismissedUntil = dismissalStorage.value[versionKey.value]
if (!dismissedUntil) return false
// Check if dismissal has expired
return Date.now() < dismissedUntil
})
const shouldShowWarning = computed(() => {
return hasVersionMismatch.value && !isDismissed.value
})
const warningMessage = computed(() => {
if (isFrontendOutdated.value) {
return {
type: 'outdated' as const,
frontendVersion: frontendVersion.value,
requiredVersion: requiredFrontendVersion.value
}
}
return null
})
async function checkVersionCompatibility() {
if (!systemStatsStore.systemStats) {
await until(systemStatsStore.isInitialized)
}
}
function dismissWarning() {
if (!versionKey.value) return
const dismissUntil = Date.now() + DISMISSAL_DURATION_MS
dismissalStorage.value = {
...dismissalStorage.value,
[versionKey.value]: dismissUntil
}
}
async function initialize() {
await checkVersionCompatibility()
}
return {
frontendVersion,
backendVersion,
requiredFrontendVersion,
hasVersionMismatch,
shouldShowWarning,
warningMessage,
isFrontendOutdated,
isFrontendNewer,
checkVersionCompatibility,
dismissWarning,
initialize
}
}
)