diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa99d61b6e..81a9be71bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,9 @@ importers: '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + lucide-react: + specifier: ^0.541.0 + version: 0.541.0(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 diff --git a/src/apps/desktop/src/api/miniapp_market_api.rs b/src/apps/desktop/src/api/miniapp_market_api.rs index 3ca47dd606..304cb4726b 100644 --- a/src/apps/desktop/src/api/miniapp_market_api.rs +++ b/src/apps/desktop/src/api/miniapp_market_api.rs @@ -5,6 +5,8 @@ //! update transaction against the desktop MiniApp manager. use crate::api::app_state::AppState; +use bitfun_core::infrastructure::events::{emit_global_event, BackendEvent}; +use bitfun_core::miniapp::lifecycle::miniapp_runtime_event_payload; use bitfun_core::miniapp::{ MiniApp, MiniAppCustomizationMetadata, MiniAppPermissionDiff, MiniAppPermissions, MiniAppSource, }; @@ -344,6 +346,16 @@ pub async fn miniapp_market_install( .map_err(|error| error.to_string())?; (app, false, diff) }; + let (event_name, reason) = if updated { + ("miniapp-updated", "market-update") + } else { + ("miniapp-created", "market-install") + }; + let _ = emit_global_event(BackendEvent::Custom { + event_name: event_name.to_string(), + payload: miniapp_runtime_event_payload(&app, reason), + }) + .await; Ok(MarketInstallResult { app, origin, diff --git a/src/miniapp-market-web/README.md b/src/miniapp-market-web/README.md index 713ac609d3..670f7ba9b2 100644 --- a/src/miniapp-market-web/README.md +++ b/src/miniapp-market-web/README.md @@ -35,6 +35,7 @@ | `src/App.tsx` | 目录、详情、受开关控制的投稿、只读“我的投稿”和管理员审核页面 | | `src/api.ts` | `/miniapp/api/v1` 客户端、CSRF、登录和下载 URL | | `src/types.ts` | 网页使用的 API DTO | +| `src/MiniAppIcon.tsx` | 将 MiniApp 元数据中的 Lucide 图标名安全解析为图标组件 | | `src/i18n.ts` | `zh-CN`、`zh-TW`、`en-US` 文案与 fallback | | `src/format.ts` | 市场页面的日期和数字格式化 | | `src/styles.css` | 响应式布局与视觉样式 | diff --git a/src/miniapp-market-web/package.json b/src/miniapp-market-web/package.json index 78a304c819..bc5ba59b08 100644 --- a/src/miniapp-market-web/package.json +++ b/src/miniapp-market-web/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@phosphor-icons/react": "^2.1.10", + "lucide-react": "^0.541.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/src/miniapp-market-web/src/App.tsx b/src/miniapp-market-web/src/App.tsx index 53bc6f3701..2b39085116 100644 --- a/src/miniapp-market-web/src/App.tsx +++ b/src/miniapp-market-web/src/App.tsx @@ -33,6 +33,7 @@ import { import { downloadUrl, loginUrl, marketApi, MarketApiError } from './api'; import { formatCompactNumber, formatMarketDate } from './format'; import { useLocale, type Locale, type MessageKey } from './i18n'; +import { MiniAppIcon } from './MiniAppIcon'; import { useTheme, type Theme } from './theme'; import type { AdminSubmissionDetail, @@ -598,7 +599,7 @@ function AppCard({ {localized.name} ) : ( - {app.icon || )} @@ -609,7 +610,7 @@ function AppCard({
- {app.icon ||

{localized.name}

@@ -715,7 +716,7 @@ function DetailPage({ {categoryLabel(app.category, t)}
- {app.icon ||

{localized.name}

@@ -806,7 +807,7 @@ function DetailPage({ ))}
) : ( - {app.icon || + )}
@@ -1260,7 +1261,7 @@ function AdminPage({ }} > - {item.icon || {item.name} @@ -1284,7 +1285,7 @@ function AdminPage({ <>
- {selected.submission.icon ||

{selected.submission.name}

@@ -1509,7 +1510,7 @@ function SubmissionRow({ return (
- {item.icon ||

{item.name}

diff --git a/src/miniapp-market-web/src/MiniAppIcon.test.tsx b/src/miniapp-market-web/src/MiniAppIcon.test.tsx new file mode 100644 index 0000000000..4f83eee931 --- /dev/null +++ b/src/miniapp-market-web/src/MiniAppIcon.test.tsx @@ -0,0 +1,24 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { MiniAppIcon } from './MiniAppIcon'; + +describe('MiniAppIcon', () => { + it.each([ + ['Aperture', 'lucide-aperture'], + ['Grid3x3', 'lucide-grid-3x3'], + ['git-pull-request', 'lucide-git-pull-request'], + ])('renders the supported metadata identifier %s as an icon', (name, className) => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('${name}<`); + }); + + it('uses a stable icon fallback for unknown or empty metadata', () => { + expect(renderToStaticMarkup()) + .toContain('lucide-box'); + expect(renderToStaticMarkup()) + .toContain('lucide-box'); + }); +}); diff --git a/src/miniapp-market-web/src/MiniAppIcon.tsx b/src/miniapp-market-web/src/MiniAppIcon.tsx new file mode 100644 index 0000000000..b339cc66b0 --- /dev/null +++ b/src/miniapp-market-web/src/MiniAppIcon.tsx @@ -0,0 +1,69 @@ +import { + Aperture, + AppWindow, + Box, + Bot, + Code, + Database, + FileText, + GitPullRequest, + Globe, + Grid3x3, + Image, + LayoutGrid, + Presentation, + Regex, + Rocket, + Settings, + Sparkles, + Terminal, + Workflow, + Wrench, + type LucideIcon, +} from 'lucide-react'; + +// Keep this allowlist aligned with the native MiniApp gallery. Marketplace +// metadata stores a Lucide icon identifier (for example, "Aperture"), not +// display text. Unknown identifiers deliberately fall back to Box so untrusted +// metadata can never become oversized text inside the icon slot. +const MINI_APP_ICONS = { + Aperture, + AppWindow, + Box, + Bot, + Code, + Database, + FileText, + GitPullRequest, + Globe, + Grid3x3, + Image, + LayoutGrid, + Presentation, + Regex, + Rocket, + Settings, + Sparkles, + Terminal, + Workflow, + Wrench, +} satisfies Record; + +function normalizeIconName(name: string | null | undefined): string { + return (name || 'Box') + .trim() + .split('-') + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} + +export function resolveMiniAppIcon(name: string | null | undefined): LucideIcon { + const key = normalizeIconName(name) as keyof typeof MINI_APP_ICONS; + return MINI_APP_ICONS[key] || Box; +} + +export function MiniAppIcon({ name }: { name: string | null | undefined }) { + const Icon = resolveMiniAppIcon(name); + return