mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-22 07:44:11 +00:00
Merge main (as of 10-06-2025) into rh-test (#5965)
## Summary Merges latest changes from `main` as of 10-06-2025. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5965-Merge-main-as-of-10-06-2025-into-rh-test-2856d73d3650812cb95fd8917278a770) by [Unito](https://www.unito.io) --------- Signed-off-by: Marcel Petrick <mail@marcelpetrick.it> Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com> Co-authored-by: Christian Byrne <cbyrne@comfy.org> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: Benjamin Lu <benceruleanlu@proton.me> Co-authored-by: Terry Jia <terryjia88@gmail.com> Co-authored-by: snomiao <snomiao@gmail.com> Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com> Co-authored-by: Jake Schroeder <jake.schroeder@isophex.com> Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com> Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Co-authored-by: Marcel Petrick <mail@marcelpetrick.it> Co-authored-by: Alexander Brown <DrJKL0424@gmail.com> Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com> Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe> Co-authored-by: JakeSchroeder <jake@axiom.co> Co-authored-by: AustinMroz <austin@comfy.org> Co-authored-by: DrJKL <DrJKL@users.noreply.github.com> Co-authored-by: ComfyUI Wiki <contact@comfyui-wiki.com>
This commit is contained in:
23
src/utils/createAnnotatedPath.ts
Normal file
23
src/utils/createAnnotatedPath.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ResultItem } from '@/schemas/apiSchema'
|
||||
|
||||
const hasAnnotation = (filepath: string): boolean =>
|
||||
/\[(input|output|temp)\]/i.test(filepath)
|
||||
|
||||
const createAnnotation = (filepath: string, rootFolder = 'input'): string =>
|
||||
!hasAnnotation(filepath) && rootFolder !== 'input' ? ` [${rootFolder}]` : ''
|
||||
|
||||
const createPath = (filename: string, subfolder = ''): string =>
|
||||
subfolder ? `${subfolder}/${filename}` : filename
|
||||
|
||||
/** Creates annotated filepath in format used by folder_paths.py */
|
||||
export function createAnnotatedPath(
|
||||
item: string | ResultItem,
|
||||
options: { rootFolder?: string; subfolder?: string } = {}
|
||||
): string {
|
||||
const { rootFolder = 'input', subfolder } = options
|
||||
if (typeof item === 'string')
|
||||
return `${createPath(item, subfolder)}${createAnnotation(item, rootFolder)}`
|
||||
return `${createPath(item.filename ?? '', item.subfolder)}${
|
||||
item.type ? createAnnotation(item.type, rootFolder) : ''
|
||||
}`
|
||||
}
|
||||
13
src/utils/electronMirrorCheck.ts
Normal file
13
src/utils/electronMirrorCheck.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { isValidUrl } from '@/utils/formatUtil'
|
||||
|
||||
/**
|
||||
* Check if a mirror is reachable from the electron App.
|
||||
* @param mirror - The mirror to check.
|
||||
* @returns True if the mirror is reachable, false otherwise.
|
||||
*/
|
||||
export const checkMirrorReachable = async (mirror: string) => {
|
||||
return (
|
||||
isValidUrl(mirror) && (await electronAPI().NetWork.canAccessUrl(mirror))
|
||||
)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export class ExecutableGroupNodeChildDTO extends ExecutableNodeDTO {
|
||||
subgraphNodePath: readonly NodeId[],
|
||||
/** A flattened map of all DTOs in this node network. Subgraph instances have been expanded into their inner nodes. */
|
||||
nodesByExecutionId: Map<ExecutionId, ExecutableLGraphNode>,
|
||||
/** The actual subgraph instance that contains this node, otherise undefined. */
|
||||
/** The actual subgraph instance that contains this node, otherwise undefined. */
|
||||
subgraphNode?: SubgraphNode | undefined,
|
||||
groupNodeHandler?: GroupNodeHandler
|
||||
) {
|
||||
|
||||
@@ -1,475 +0,0 @@
|
||||
import type { ResultItem } from '@/schemas/apiSchema'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
|
||||
export function formatCamelCase(str: string): string {
|
||||
// Check if the string is camel case
|
||||
const isCamelCase = /^([A-Z][a-z]*)+$/.test(str)
|
||||
|
||||
if (!isCamelCase) {
|
||||
return str // Return original string if not camel case
|
||||
}
|
||||
|
||||
// Split the string into words, keeping acronyms together
|
||||
const words = str.split(/(?=[A-Z][a-z])|\d+/)
|
||||
|
||||
// Process each word
|
||||
const processedWords = words.map((word) => {
|
||||
// If the word is all uppercase and longer than one character, it's likely an acronym
|
||||
if (word.length > 1 && word === word.toUpperCase()) {
|
||||
return word // Keep acronyms as is
|
||||
}
|
||||
// For other words, ensure the first letter is capitalized
|
||||
return word.charAt(0).toUpperCase() + word.slice(1)
|
||||
})
|
||||
|
||||
// Join the words with spaces
|
||||
return processedWords.join(' ')
|
||||
}
|
||||
|
||||
export function appendJsonExt(path: string) {
|
||||
if (!path.toLowerCase().endsWith('.json')) {
|
||||
path += '.json'
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
export function highlightQuery(text: string, query: string) {
|
||||
if (!query) return text
|
||||
|
||||
// Escape special regex characters in the query string
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
const regex = new RegExp(`(${escapedQuery})`, 'gi')
|
||||
return text.replace(regex, '<span class="highlight">$1</span>')
|
||||
}
|
||||
|
||||
export function formatNumberWithSuffix(
|
||||
num: number,
|
||||
{
|
||||
precision = 1,
|
||||
roundToInt = false
|
||||
}: { precision?: number; roundToInt?: boolean } = {}
|
||||
): string {
|
||||
const suffixes = ['', 'k', 'm', 'b', 't']
|
||||
const absNum = Math.abs(num)
|
||||
|
||||
if (absNum < 1000) {
|
||||
return roundToInt ? Math.round(num).toString() : num.toFixed(precision)
|
||||
}
|
||||
|
||||
const exp = Math.min(Math.floor(Math.log10(absNum) / 3), suffixes.length - 1)
|
||||
const formattedNum = (num / Math.pow(1000, exp)).toFixed(precision)
|
||||
|
||||
return `${formattedNum}${suffixes[exp]}`
|
||||
}
|
||||
|
||||
export function formatSize(value?: number) {
|
||||
if (value === null || value === undefined) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const bytes = value
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns various filename components.
|
||||
* Example:
|
||||
* - fullFilename: 'file.txt'
|
||||
* - filename: 'file'
|
||||
* - suffix: 'txt'
|
||||
*/
|
||||
function getFilenameDetails(fullFilename: string) {
|
||||
if (fullFilename.includes('.')) {
|
||||
return {
|
||||
filename: fullFilename.split('.').slice(0, -1).join('.'),
|
||||
suffix: fullFilename.split('.').pop() ?? null
|
||||
}
|
||||
} else {
|
||||
return { filename: fullFilename, suffix: null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns various path components.
|
||||
* Example:
|
||||
* - path: 'dir/file.txt'
|
||||
* - directory: 'dir'
|
||||
* - fullFilename: 'file.txt'
|
||||
* - filename: 'file'
|
||||
* - suffix: 'txt'
|
||||
*/
|
||||
export function getPathDetails(path: string) {
|
||||
const directory = path.split('/').slice(0, -1).join('/')
|
||||
const fullFilename = path.split('/').pop() ?? path
|
||||
return { directory, fullFilename, ...getFilenameDetails(fullFilename) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a string to be used as an i18n key.
|
||||
* Replaces dots with underscores.
|
||||
*/
|
||||
export function normalizeI18nKey(key: string) {
|
||||
return typeof key === 'string' ? key.replace(/\./g, '_') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments.
|
||||
* @param input The dynamic prompt to process
|
||||
* @returns
|
||||
*/
|
||||
export function processDynamicPrompt(input: string): string {
|
||||
/*
|
||||
* Strips C-style line and block comments from a string
|
||||
*/
|
||||
function stripComments(str: string) {
|
||||
return str.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '')
|
||||
}
|
||||
|
||||
let i = 0
|
||||
let result = ''
|
||||
input = stripComments(input)
|
||||
|
||||
const handleEscape = () => {
|
||||
const nextChar = input[i++]
|
||||
return '\\' + nextChar
|
||||
}
|
||||
|
||||
function parseChoiceBlock() {
|
||||
// Parse the content inside {}
|
||||
const options: string[] = []
|
||||
let choice = ''
|
||||
let depth = 0
|
||||
|
||||
while (i < input.length) {
|
||||
const char = input[i++]
|
||||
|
||||
if (char === '\\') {
|
||||
choice += handleEscape()
|
||||
continue
|
||||
} else if (char === '{') {
|
||||
depth++
|
||||
} else if (char === '}') {
|
||||
if (!depth) break
|
||||
depth--
|
||||
} else if (char === '|') {
|
||||
if (!depth) {
|
||||
options.push(choice)
|
||||
choice = ''
|
||||
continue
|
||||
}
|
||||
}
|
||||
choice += char
|
||||
}
|
||||
|
||||
options.push(choice)
|
||||
|
||||
const chosenOption = options[Math.floor(Math.random() * options.length)]
|
||||
return processDynamicPrompt(chosenOption)
|
||||
}
|
||||
|
||||
while (i < input.length) {
|
||||
const char = input[i++]
|
||||
if (char === '\\') {
|
||||
result += handleEscape()
|
||||
} else if (char === '{') {
|
||||
result += parseChoiceBlock()
|
||||
} else {
|
||||
result += char
|
||||
}
|
||||
}
|
||||
|
||||
return result.replace(/\\([{}|])/g, '$1')
|
||||
}
|
||||
|
||||
export function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const hasAnnotation = (filepath: string): boolean =>
|
||||
/\[(input|output|temp)\]/i.test(filepath)
|
||||
|
||||
const createAnnotation = (filepath: string, rootFolder = 'input'): string =>
|
||||
!hasAnnotation(filepath) && rootFolder !== 'input' ? ` [${rootFolder}]` : ''
|
||||
|
||||
const createPath = (filename: string, subfolder = ''): string =>
|
||||
subfolder ? `${subfolder}/${filename}` : filename
|
||||
|
||||
/** Creates annotated filepath in format used by folder_paths.py */
|
||||
export function createAnnotatedPath(
|
||||
item: string | ResultItem,
|
||||
options: { rootFolder?: string; subfolder?: string } = {}
|
||||
): string {
|
||||
const { rootFolder = 'input', subfolder } = options
|
||||
if (typeof item === 'string')
|
||||
return `${createPath(item, subfolder)}${createAnnotation(item, rootFolder)}`
|
||||
return `${createPath(item.filename ?? '', item.subfolder)}${
|
||||
item.type ? createAnnotation(item.type, rootFolder) : ''
|
||||
}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a filepath into its filename and subfolder components.
|
||||
*
|
||||
* @example
|
||||
* parseFilePath('folder/file.txt') // → { filename: 'file.txt', subfolder: 'folder' }
|
||||
* parseFilePath('/folder/file.txt') // → { filename: 'file.txt', subfolder: 'folder' }
|
||||
* parseFilePath('file.txt') // → { filename: 'file.txt', subfolder: '' }
|
||||
* parseFilePath('folder//file.txt') // → { filename: 'file.txt', subfolder: 'folder' }
|
||||
*
|
||||
* @param filepath The filepath to parse
|
||||
* @returns Object containing filename and subfolder
|
||||
*/
|
||||
export function parseFilePath(filepath: string): {
|
||||
filename: string
|
||||
subfolder: string
|
||||
} {
|
||||
if (!filepath?.trim()) return { filename: '', subfolder: '' }
|
||||
|
||||
const normalizedPath = filepath
|
||||
.replace(/[\\/]+/g, '/') // Normalize path separators
|
||||
.replace(/^\//, '') // Remove leading slash
|
||||
.replace(/\/$/, '') // Remove trailing slash
|
||||
|
||||
const lastSlashIndex = normalizedPath.lastIndexOf('/')
|
||||
|
||||
if (lastSlashIndex === -1) {
|
||||
return {
|
||||
filename: normalizedPath,
|
||||
subfolder: ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filename: normalizedPath.slice(lastSlashIndex + 1),
|
||||
subfolder: normalizedPath.slice(0, lastSlashIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// Simple date formatter
|
||||
const parts = {
|
||||
d: (d: Date) => d.getDate(),
|
||||
M: (d: Date) => d.getMonth() + 1,
|
||||
h: (d: Date) => d.getHours(),
|
||||
m: (d: Date) => d.getMinutes(),
|
||||
s: (d: Date) => d.getSeconds()
|
||||
}
|
||||
const format =
|
||||
Object.keys(parts)
|
||||
.map((k) => k + k + '?')
|
||||
.join('|') + '|yyy?y?'
|
||||
|
||||
export function formatDate(text: string, date: Date) {
|
||||
return text.replace(new RegExp(format, 'g'), (text: string): string => {
|
||||
if (text === 'yy') return (date.getFullYear() + '').substring(2)
|
||||
if (text === 'yyyy') return date.getFullYear().toString()
|
||||
if (text[0] in parts) {
|
||||
const p = parts[text[0] as keyof typeof parts](date)
|
||||
return (p + '').padStart(text.length, '0')
|
||||
}
|
||||
return text
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cache key from parameters
|
||||
* Sorts the parameters to ensure consistent keys regardless of parameter order
|
||||
*/
|
||||
export const paramsToCacheKey = (params: unknown): string => {
|
||||
if (typeof params === 'string') return params
|
||||
if (typeof params === 'object' && params !== null)
|
||||
return Object.keys(params)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((key) => `${key}:${params[key as keyof typeof params]}`)
|
||||
.join('&')
|
||||
|
||||
return String(params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a RFC4122 compliant UUID v4 using the native crypto API when available
|
||||
* @returns A properly formatted UUID string
|
||||
*/
|
||||
export const generateUUID = (): string => {
|
||||
// Use native crypto.randomUUID() if available (modern browsers)
|
||||
if (
|
||||
typeof crypto !== 'undefined' &&
|
||||
typeof crypto.randomUUID === 'function'
|
||||
) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
// Fallback implementation for older browsers
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL is a Civitai model URL
|
||||
* @example
|
||||
* isCivitaiModelUrl('https://civitai.com/api/download/models/1234567890') // true
|
||||
* isCivitaiModelUrl('https://civitai.com/api/v1/models/1234567890') // true
|
||||
* isCivitaiModelUrl('https://civitai.com/api/v1/models-versions/15342') // true
|
||||
* isCivitaiModelUrl('https://example.com/model.safetensors') // false
|
||||
*/
|
||||
export const isCivitaiModelUrl = (url: string): boolean => {
|
||||
if (!isValidUrl(url)) return false
|
||||
if (!url.includes('civitai.com')) return false
|
||||
|
||||
const urlObj = new URL(url)
|
||||
const pathname = urlObj.pathname
|
||||
|
||||
return (
|
||||
/^\/api\/download\/models\/(\d+)$/.test(pathname) ||
|
||||
/^\/api\/v1\/models\/(\d+)$/.test(pathname) ||
|
||||
/^\/api\/v1\/models-versions\/(\d+)$/.test(pathname)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Hugging Face download URL to a repository page URL
|
||||
* @param url The download URL to convert
|
||||
* @returns The repository page URL or the original URL if conversion fails
|
||||
* @example
|
||||
* downloadUrlToHfRepoUrl(
|
||||
* 'https://huggingface.co/bfl/FLUX.1/resolve/main/flux1-canny-dev.safetensors?download=true'
|
||||
* ) // https://huggingface.co/bfl/FLUX.1
|
||||
*/
|
||||
export const downloadUrlToHfRepoUrl = (url: string): string => {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const pathname = urlObj.pathname
|
||||
|
||||
// Use regex to match everything before /resolve/ or /blob/
|
||||
const regex = /^(.*?)(?:\/resolve\/|\/blob\/|$)/
|
||||
const repoPathMatch = regex.exec(pathname)
|
||||
|
||||
// Extract the repository path and remove leading slash if present
|
||||
const repoPath = repoPathMatch?.[1]?.replace(/^\//, '') || ''
|
||||
|
||||
return `https://huggingface.co/${repoPath}`
|
||||
} catch (error) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Metronome's integer amount back to a formatted currency string.
|
||||
* For USD, converts from cents to dollars.
|
||||
* For all other currencies (including custom pricing units), returns the amount as is.
|
||||
* This is specific to Metronome's API requirements.
|
||||
*
|
||||
* @param amount - The amount in Metronome's integer format (cents for USD, base units for others)
|
||||
* @param currency - The currency to convert
|
||||
* @returns The formatted amount in currency with 2 decimal places for USD
|
||||
* @example
|
||||
* formatMetronomeCurrency(123, 'usd') // returns "1.23" (cents to USD)
|
||||
* formatMetronomeCurrency(1000, 'jpy') // returns "1000" (yen)
|
||||
*/
|
||||
export function formatMetronomeCurrency(
|
||||
amount: number,
|
||||
currency: string
|
||||
): string {
|
||||
if (currency === 'usd') {
|
||||
return (amount / 100).toFixed(2)
|
||||
}
|
||||
return amount.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a USD amount to microdollars (1/1,000,000 of a dollar).
|
||||
* This conversion is commonly used in financial systems to avoid floating-point precision issues
|
||||
* by representing monetary values as integers.
|
||||
*
|
||||
* @param usd - The amount in US dollars to convert
|
||||
* @returns The amount in microdollars (multiplied by 1,000,000)
|
||||
* @example
|
||||
* usdToMicros(1.23) // returns 1230000
|
||||
*/
|
||||
export function usdToMicros(usd: number): number {
|
||||
return Math.round(usd * 1_000_000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts URLs in a string to HTML links.
|
||||
* @param text - The string to convert
|
||||
* @returns The string with URLs converted to HTML links
|
||||
* @example
|
||||
* linkifyHtml('Visit https://example.com for more info') // returns 'Visit <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="text-primary-400 hover:underline">https://example.com</a> for more info'
|
||||
*/
|
||||
export function linkifyHtml(text: string): string {
|
||||
if (!text) return ''
|
||||
const urlRegex =
|
||||
/(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%?=~_|])|(\bwww\.[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%?=~_|])/gi
|
||||
return text.replace(urlRegex, (_match, p1, _p2, p3) => {
|
||||
const url = p1 || p3
|
||||
const href = p3 ? `http://${url}` : url
|
||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer" class="text-primary-400 hover:underline">${url}</a>`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts newline characters to HTML <br> tags.
|
||||
* @param text - The string to convert
|
||||
* @returns The string with newline characters converted to <br> tags
|
||||
* @example
|
||||
* nl2br('Hello\nWorld') // returns 'Hello<br />World'
|
||||
*/
|
||||
export function nl2br(text: string): string {
|
||||
if (!text) return ''
|
||||
return text.replace(/\n/g, '<br />')
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a version string to an anchor-safe format by replacing dots with dashes.
|
||||
* @param version The version string (e.g., "1.0.0", "2.1.3-beta.1")
|
||||
* @returns The anchor-safe version string (e.g., "v1-0-0", "v2-1-3-beta-1")
|
||||
* @example
|
||||
* formatVersionAnchor("1.0.0") // returns "v1-0-0"
|
||||
* formatVersionAnchor("2.1.3-beta.1") // returns "v2-1-3-beta-1"
|
||||
*/
|
||||
export function formatVersionAnchor(version: string): string {
|
||||
return `v${version.replace(/\./g, '-')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported locale types for the application (from OpenAPI schema)
|
||||
*/
|
||||
type SupportedLocale = NonNullable<
|
||||
operations['getReleaseNotes']['parameters']['query']['locale']
|
||||
>
|
||||
|
||||
/**
|
||||
* Converts a string to a valid locale type with 'en' as default
|
||||
* @param locale - The locale string to validate and convert
|
||||
* @returns A valid SupportedLocale type, defaults to 'en' if invalid
|
||||
* @example
|
||||
* stringToLocale('fr') // returns 'fr'
|
||||
* stringToLocale('invalid') // returns 'en'
|
||||
* stringToLocale('') // returns 'en'
|
||||
*/
|
||||
export function stringToLocale(locale: string): SupportedLocale {
|
||||
const supportedLocales: SupportedLocale[] = [
|
||||
'en',
|
||||
'es',
|
||||
'fr',
|
||||
'ja',
|
||||
'ko',
|
||||
'ru',
|
||||
'zh'
|
||||
]
|
||||
return supportedLocales.includes(locale as SupportedLocale)
|
||||
? (locale as SupportedLocale)
|
||||
: 'en'
|
||||
}
|
||||
98
src/utils/hostWhitelist.ts
Normal file
98
src/utils/hostWhitelist.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Whitelisting helper for enabling SSO on safe, local-only hosts.
|
||||
*
|
||||
* Built-ins (always allowed):
|
||||
* • 'localhost' and any subdomain of '.localhost' (e.g., app.localhost)
|
||||
* • IPv4 loopback 127.0.0.0/8 (e.g., 127.0.0.1, 127.1.2.3)
|
||||
* • IPv6 loopback ::1 (supports compressed/expanded textual forms)
|
||||
*
|
||||
* No environment variables are used. To add more exact hostnames,
|
||||
* edit HOST_WHITELIST below.
|
||||
*/
|
||||
|
||||
const HOST_WHITELIST: string[] = ['localhost']
|
||||
|
||||
/** Normalize for comparison: lowercase, strip port/brackets, trim trailing dot. */
|
||||
export function normalizeHost(input: string): string {
|
||||
let h = (input || '').trim().toLowerCase()
|
||||
|
||||
// Trim a trailing dot: 'localhost.' -> 'localhost'
|
||||
h = h.replace(/\.$/, '')
|
||||
|
||||
// Remove ':port' safely.
|
||||
// Case 1: [IPv6]:port
|
||||
const mBracket = h.match(/^\[([^\]]+)\]:(\d+)$/)
|
||||
if (mBracket) {
|
||||
h = mBracket[1] // keep only the host inside the brackets
|
||||
} else {
|
||||
// Case 2: hostname/IPv4:port (exactly one ':')
|
||||
const mPort = h.match(/^([^:]+):(\d+)$/)
|
||||
if (mPort) h = mPort[1]
|
||||
}
|
||||
|
||||
// Strip any remaining brackets (e.g., '[::1]' -> '::1')
|
||||
h = h.replace(/^\[|\]$/g, '')
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
/** Public check used by the UI. */
|
||||
export function isHostWhitelisted(rawHost: string): boolean {
|
||||
const host = normalizeHost(rawHost)
|
||||
if (isLocalhostLabel(host)) return true
|
||||
if (isIPv4Loopback(host)) return true
|
||||
if (isIPv6Loopback(host)) return true
|
||||
if (isComfyOrgHost(host)) return true
|
||||
const normalizedList = HOST_WHITELIST.map(normalizeHost)
|
||||
return normalizedList.includes(host)
|
||||
}
|
||||
|
||||
/* -------------------- Helpers -------------------- */
|
||||
|
||||
function isLocalhostLabel(h: string): boolean {
|
||||
// 'localhost' and any subdomain (e.g., 'app.localhost')
|
||||
return h === 'localhost' || h.endsWith('.localhost')
|
||||
}
|
||||
|
||||
const IPV4_OCTET = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|0?\\d?\\d)'
|
||||
const V4_LOOPBACK_RE = new RegExp(
|
||||
'^127\\.' + IPV4_OCTET + '\\.' + IPV4_OCTET + '\\.' + IPV4_OCTET + '$'
|
||||
)
|
||||
|
||||
function isIPv4Loopback(h: string): boolean {
|
||||
// 127/8 with strict 0–255 octets (leading zeros allowed, e.g., 127.000.000.001)
|
||||
return V4_LOOPBACK_RE.test(h)
|
||||
}
|
||||
|
||||
// Fully expanded IPv6 loopback: 0:0:0:0:0:0:0:1 (allow leading zeros up to 4 chars)
|
||||
const V6_FULL_LOOPBACK_RE = /^(?:0{1,4}:){7}0{0,3}1$/i
|
||||
|
||||
// Compressed IPv6 loopback forms around '::' with only zero groups before the final :1
|
||||
// - Left side: zero groups separated by ':' (no trailing colon required)
|
||||
// - Right side: zero groups each followed by ':' (so the final ':1' is provided by the pattern)
|
||||
// The final group is exactly value 1, with up to 3 leading zeros (e.g., '0001').
|
||||
const V6_COMPRESSED_LOOPBACK_RE =
|
||||
/^((?:0{1,4}(?::0{1,4}){0,6})?)::((?:0{1,4}:){0,6})0{0,3}1$/i
|
||||
|
||||
function isIPv6Loopback(h: string): boolean {
|
||||
// Exact full form: 0:0:0:0:0:0:0:1 (with up to 3 leading zeros on the final "1" group)
|
||||
if (V6_FULL_LOOPBACK_RE.test(h)) return true
|
||||
|
||||
// Compressed forms that still equal ::1 (e.g., ::1, ::0001, 0:0::1, ::0:1, etc.)
|
||||
const m = h.match(V6_COMPRESSED_LOOPBACK_RE)
|
||||
if (!m) return false
|
||||
|
||||
// Count explicit zero groups on each side of '::' to ensure at least one group is compressed.
|
||||
// (leftCount + rightCount) must be ≤ 6 so that the total expanded groups = 8.
|
||||
const leftCount = m[1] ? m[1].match(/0{1,4}:/gi)?.length ?? 0 : 0
|
||||
const rightCount = m[2] ? m[2].match(/0{1,4}:/gi)?.length ?? 0 : 0
|
||||
|
||||
// Require that at least one group was actually compressed: i.e., leftCount + rightCount ≤ 6.
|
||||
return leftCount + rightCount <= 6
|
||||
}
|
||||
|
||||
const COMFY_ORG_HOST = /\.comfy\.org$/
|
||||
|
||||
function isComfyOrgHost(h: string): boolean {
|
||||
return COMFY_ORG_HOST.test(h)
|
||||
}
|
||||
@@ -70,7 +70,7 @@ function extendLink(link: SerialisedLLinkArray) {
|
||||
* makes logical sense. Can apply fixes when passed the `fix` argument as true.
|
||||
*
|
||||
* Note that fixes are a best-effort attempt. Seems to get it correct in most cases, but there is a
|
||||
* chance it correct an anomoly that results in placing an incorrect link (say, if there were two
|
||||
* chance it correct an anomaly that results in placing an incorrect link (say, if there were two
|
||||
* links in the data). Users should take care to not overwrite work until manually checking the
|
||||
* result.
|
||||
*/
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { electronAPI } from './envUtil'
|
||||
import { isValidUrl } from './formatUtil'
|
||||
|
||||
const VALID_STATUS_CODES = [200, 201, 301, 302, 307, 308]
|
||||
export const checkUrlReachable = async (url: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await axios.head(url)
|
||||
// Additional check for successful response
|
||||
return VALID_STATUS_CODES.includes(response.status)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mirror is reachable from the electron App.
|
||||
* @param mirror - The mirror to check.
|
||||
* @returns True if the mirror is reachable, false otherwise.
|
||||
*/
|
||||
export const checkMirrorReachable = async (mirror: string) => {
|
||||
return (
|
||||
isValidUrl(mirror) && (await electronAPI().NetWork.canAccessUrl(mirror))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is likely in mainland China by:
|
||||
* 1. Checking navigator.language
|
||||
* 2. Testing connectivity to commonly blocked services
|
||||
* 3. Testing latency to China-specific domains
|
||||
*/
|
||||
export async function isInChina(): Promise<boolean> {
|
||||
// Quick check based on language/locale
|
||||
const isChineseLocale = navigator.language.toLowerCase().startsWith('zh-cn')
|
||||
|
||||
try {
|
||||
// Test connectivity to Google - commonly blocked in China
|
||||
const googleTest = await Promise.race([
|
||||
fetch('https://www.google.com', {
|
||||
mode: 'no-cors',
|
||||
cache: 'no-cache'
|
||||
}),
|
||||
new Promise((_, reject) => setTimeout(() => reject(), 2000))
|
||||
])
|
||||
|
||||
// If Google is accessible, user is likely not in China
|
||||
if (googleTest) {
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
// Google is not accessible - potential indicator of being in China
|
||||
if (isChineseLocale) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Additional check - test latency to a reliable Chinese domain
|
||||
try {
|
||||
const start = performance.now()
|
||||
await fetch('https://www.baidu.com', {
|
||||
mode: 'no-cors',
|
||||
cache: 'no-cache'
|
||||
})
|
||||
const latency = performance.now() - start
|
||||
|
||||
// If Baidu responds quickly (<150ms), user is likely in China
|
||||
return latency < 150
|
||||
} catch {
|
||||
// If both tests fail, default to locale check
|
||||
return isChineseLocale
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
29
src/utils/rafBatch.ts
Normal file
29
src/utils/rafBatch.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export function createRafBatch(run: () => void) {
|
||||
let rafId: number | null = null
|
||||
|
||||
const schedule = () => {
|
||||
if (rafId != null) return
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
run()
|
||||
})
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
if (rafId != null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
if (rafId == null) return
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
run()
|
||||
}
|
||||
|
||||
const isScheduled = () => rafId != null
|
||||
|
||||
return { schedule, cancel, flush, isScheduled }
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useTimeout } from '@vueuse/core'
|
||||
import { type Ref, computed, ref, watch } from 'vue'
|
||||
|
||||
/**
|
||||
* Vue boolean ref (writable computed) with one difference: when set to `true` it stays that way for at least {@link minDuration}.
|
||||
* If set to `false` before {@link minDuration} has passed, it uses a timer to delay the change.
|
||||
* @param value The default value to set on this ref
|
||||
* @param minDuration The minimum time that this ref must be `true` for
|
||||
* @returns A custom boolean vue ref with a minimum activation time
|
||||
*/
|
||||
export function useMinLoadingDurationRef(
|
||||
value: Ref<boolean>,
|
||||
minDuration = 250
|
||||
) {
|
||||
const current = ref(value.value)
|
||||
|
||||
const { ready, start } = useTimeout(minDuration, {
|
||||
controls: true,
|
||||
immediate: false
|
||||
})
|
||||
|
||||
watch(value, (newValue) => {
|
||||
if (newValue && !current.value) start()
|
||||
|
||||
current.value = newValue
|
||||
})
|
||||
|
||||
return computed(() => current.value || !ready.value)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function applyTextReplacements(
|
||||
let nodes = allNodes.filter(
|
||||
(n) => n.properties?.['Node name for S&R'] === split[0]
|
||||
)
|
||||
// If we cant, see if there is a node with that title
|
||||
// If we can't, see if there is a node with that title
|
||||
if (!nodes.length) {
|
||||
nodes = allNodes.filter((n) => n.title === split[0])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user