|
@@ -1,4 +1,4 @@
|
|
|
-import React, { useEffect, useState } from 'react'
|
|
|
|
|
|
|
+import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
|
|
import type { LaunchpadIconResolvePayload, LaunchpadIconResolveResult } from '../types/ipcLaunchpad'
|
|
import type { LaunchpadIconResolvePayload, LaunchpadIconResolveResult } from '../types/ipcLaunchpad'
|
|
|
import { getAvatarPlaceholder, type ApplicationIconShape } from '../utils/avatarUtils'
|
|
import { getAvatarPlaceholder, type ApplicationIconShape } from '../utils/avatarUtils'
|
|
|
|
|
|
|
@@ -7,6 +7,23 @@ function isProbablyHttpIconUrl(raw: string): boolean {
|
|
|
return /^https?:\/\//i.test(s)
|
|
return /^https?:\/\//i.test(s)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** 解析后的自定义协议 URL;key 与主进程 `isLaunchpadCacheCurrent` 一致,避免签名 URL 每次变导致缓存失效 */
|
|
|
|
|
+const launchpadResolvedIconCache = new Map<string, string>()
|
|
|
|
|
+
|
|
|
|
|
+/** 有 `icon_object_key` 时用 appId+objectKey;否则用整段 URL(与主进程仅比 URL 分支一致) */
|
|
|
|
|
+function launchpadIconCacheKey(
|
|
|
|
|
+ appId: string,
|
|
|
|
|
+ iconUrlTrim: string,
|
|
|
|
|
+ iconObjectKey: string | null | undefined
|
|
|
|
|
+): string {
|
|
|
|
|
+ const objTrim =
|
|
|
|
|
+ typeof iconObjectKey === 'string' && iconObjectKey.trim() ? iconObjectKey.trim() : null
|
|
|
|
|
+ if (objTrim != null) {
|
|
|
|
|
+ return `${appId}\0obj:${objTrim}`
|
|
|
|
|
+ }
|
|
|
|
|
+ return `${appId}\0url:${iconUrlTrim}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
interface LaunchpadAppIconProps {
|
|
interface LaunchpadAppIconProps {
|
|
|
appName: string
|
|
appName: string
|
|
|
appId: string
|
|
appId: string
|
|
@@ -29,21 +46,31 @@ export function LaunchpadAppIcon({
|
|
|
extraStyle,
|
|
extraStyle,
|
|
|
applicationIconShape = 'roundedSquare',
|
|
applicationIconShape = 'roundedSquare',
|
|
|
}: LaunchpadAppIconProps): JSX.Element {
|
|
}: LaunchpadAppIconProps): JSX.Element {
|
|
|
- const [resolvedSrc, setResolvedSrc] = useState<string | null>(null)
|
|
|
|
|
|
|
+ const cacheKey = useMemo(() => {
|
|
|
|
|
+ const urlTrim = typeof iconUrl === 'string' ? iconUrl.trim() : ''
|
|
|
|
|
+ if (!urlTrim || !isProbablyHttpIconUrl(urlTrim)) return null
|
|
|
|
|
+ return launchpadIconCacheKey(appId, urlTrim, iconObjectKey)
|
|
|
|
|
+ }, [appId, iconUrl, iconObjectKey])
|
|
|
|
|
+
|
|
|
|
|
+ /** 仅当 `key === 当前 cacheKey` 时参与展示,避免换应用瞬间错用上一 IPC 结果 */
|
|
|
|
|
+ const [ipcMatch, setIpcMatch] = useState<{ key: string; src: string } | null>(null)
|
|
|
const [usePlaceholder, setUsePlaceholder] = useState(false)
|
|
const [usePlaceholder, setUsePlaceholder] = useState(false)
|
|
|
|
|
|
|
|
- useEffect(() => {
|
|
|
|
|
|
|
+ useLayoutEffect(() => {
|
|
|
setUsePlaceholder(false)
|
|
setUsePlaceholder(false)
|
|
|
|
|
+ setIpcMatch((prev) => (prev && cacheKey && prev.key === cacheKey ? prev : null))
|
|
|
|
|
+ }, [cacheKey])
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!cacheKey) return
|
|
|
|
|
|
|
|
const urlTrim = typeof iconUrl === 'string' ? iconUrl.trim() : ''
|
|
const urlTrim = typeof iconUrl === 'string' ? iconUrl.trim() : ''
|
|
|
- if (!urlTrim || !isProbablyHttpIconUrl(urlTrim)) {
|
|
|
|
|
- setResolvedSrc(null)
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (!urlTrim || !isProbablyHttpIconUrl(urlTrim)) return
|
|
|
|
|
|
|
|
const invoke = window.electron?.ipcRenderer?.invoke
|
|
const invoke = window.electron?.ipcRenderer?.invoke
|
|
|
if (!invoke) {
|
|
if (!invoke) {
|
|
|
- setResolvedSrc(null)
|
|
|
|
|
|
|
+ launchpadResolvedIconCache.delete(cacheKey)
|
|
|
|
|
+ setIpcMatch((prev) => (prev?.key === cacheKey ? null : prev))
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -60,19 +87,28 @@ export function LaunchpadAppIcon({
|
|
|
|
|
|
|
|
if (cancelled) return
|
|
if (cancelled) return
|
|
|
if (res.kind === 'custom' && res.url) {
|
|
if (res.kind === 'custom' && res.url) {
|
|
|
- setResolvedSrc(res.url)
|
|
|
|
|
|
|
+ launchpadResolvedIconCache.set(cacheKey, res.url)
|
|
|
|
|
+ setIpcMatch({ key: cacheKey, src: res.url })
|
|
|
} else {
|
|
} else {
|
|
|
- setResolvedSrc(null)
|
|
|
|
|
|
|
+ launchpadResolvedIconCache.delete(cacheKey)
|
|
|
|
|
+ setIpcMatch((prev) => (prev?.key === cacheKey ? null : prev))
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
|
- if (!cancelled) setResolvedSrc(null)
|
|
|
|
|
|
|
+ if (!cancelled) {
|
|
|
|
|
+ launchpadResolvedIconCache.delete(cacheKey)
|
|
|
|
|
+ setIpcMatch((prev) => (prev?.key === cacheKey ? null : prev))
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
})()
|
|
})()
|
|
|
|
|
|
|
|
return () => {
|
|
return () => {
|
|
|
cancelled = true
|
|
cancelled = true
|
|
|
}
|
|
}
|
|
|
- }, [appId, iconUrl, iconObjectKey])
|
|
|
|
|
|
|
+ }, [cacheKey, appId, iconUrl, iconObjectKey])
|
|
|
|
|
+
|
|
|
|
|
+ const cachedSrc = cacheKey ? launchpadResolvedIconCache.get(cacheKey) ?? null : null
|
|
|
|
|
+ const ipcSrc = ipcMatch?.key === cacheKey ? ipcMatch.src : null
|
|
|
|
|
+ const displaySrc = cachedSrc ?? ipcSrc
|
|
|
|
|
|
|
|
const placeholderNode = getAvatarPlaceholder(
|
|
const placeholderNode = getAvatarPlaceholder(
|
|
|
appName,
|
|
appName,
|
|
@@ -83,7 +119,7 @@ export function LaunchpadAppIcon({
|
|
|
applicationIconShape
|
|
applicationIconShape
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- if (!resolvedSrc || usePlaceholder) {
|
|
|
|
|
|
|
+ if (!displaySrc || usePlaceholder) {
|
|
|
return <>{placeholderNode}</>
|
|
return <>{placeholderNode}</>
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -92,10 +128,13 @@ export function LaunchpadAppIcon({
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<img
|
|
<img
|
|
|
- src={resolvedSrc}
|
|
|
|
|
|
|
+ src={displaySrc}
|
|
|
alt=""
|
|
alt=""
|
|
|
draggable={false}
|
|
draggable={false}
|
|
|
- onError={() => setUsePlaceholder(true)}
|
|
|
|
|
|
|
+ onError={() => {
|
|
|
|
|
+ if (cacheKey) launchpadResolvedIconCache.delete(cacheKey)
|
|
|
|
|
+ setUsePlaceholder(true)
|
|
|
|
|
+ }}
|
|
|
style={{
|
|
style={{
|
|
|
width: sizePx,
|
|
width: sizePx,
|
|
|
height: sizePx,
|
|
height: sizePx,
|