Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions src/apps/desktop/src/api/miniapp_market_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/miniapp-market-web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 响应式布局与视觉样式 |
Expand Down
1 change: 1 addition & 0 deletions src/miniapp-market-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
15 changes: 8 additions & 7 deletions src/miniapp-market-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -598,7 +599,7 @@ function AppCard({
<img src={app.screenshotUrls[0]} alt={localized.name} loading="lazy" />
) : (
<span className="app-icon-large">
{app.icon || <Cube weight="duotone" aria-hidden="true" />}
<MiniAppIcon name={app.icon} />
</span>
)}
</div>
Expand All @@ -609,7 +610,7 @@ function AppCard({
</div>
<div className="card-heading">
<span className="app-icon">
{app.icon || <Cube weight="duotone" aria-hidden="true" />}
<MiniAppIcon name={app.icon} />
</span>
<div>
<h2>{localized.name}</h2>
Expand Down Expand Up @@ -715,7 +716,7 @@ function DetailPage({
<span className="category-chip">{categoryLabel(app.category, t)}</span>
<div className="detail-title-row">
<span className="detail-icon">
{app.icon || <Cube weight="duotone" aria-hidden="true" />}
<MiniAppIcon name={app.icon} />
</span>
<div>
<h1>{localized.name}</h1>
Expand Down Expand Up @@ -806,7 +807,7 @@ function DetailPage({
))}
</div>
) : (
<span>{app.icon || <Cube weight="duotone" aria-hidden="true" />}</span>
<span><MiniAppIcon name={app.icon} /></span>
)}
</div>
</section>
Expand Down Expand Up @@ -1260,7 +1261,7 @@ function AdminPage({
}}
>
<span className="app-icon">
{item.icon || <Cube weight="duotone" aria-hidden="true" />}
<MiniAppIcon name={item.icon} />
</span>
<span>
<strong>{item.name}</strong>
Expand All @@ -1284,7 +1285,7 @@ function AdminPage({
<>
<div className="review-summary">
<span className="detail-icon">
{selected.submission.icon || <Cube weight="duotone" aria-hidden="true" />}
<MiniAppIcon name={selected.submission.icon} />
</span>
<div>
<h2>{selected.submission.name}</h2>
Expand Down Expand Up @@ -1509,7 +1510,7 @@ function SubmissionRow({
return (
<article className="submission-row">
<span className="app-icon">
{item.icon || <Cube weight="duotone" aria-hidden="true" />}
<MiniAppIcon name={item.icon} />
</span>
<div>
<h2>{item.name}</h2>
Expand Down
24 changes: 24 additions & 0 deletions src/miniapp-market-web/src/MiniAppIcon.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MiniAppIcon name={name} />);

expect(markup).toContain('<svg');
expect(markup).toContain(className);
expect(markup).not.toContain(`>${name}<`);
});

it('uses a stable icon fallback for unknown or empty metadata', () => {
expect(renderToStaticMarkup(<MiniAppIcon name="unknown-market-icon" />))
.toContain('lucide-box');
expect(renderToStaticMarkup(<MiniAppIcon name="" />))
.toContain('lucide-box');
});
});
69 changes: 69 additions & 0 deletions src/miniapp-market-web/src/MiniAppIcon.tsx
Original file line number Diff line number Diff line change
@@ -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<string, LucideIcon>;

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 <Icon size={24} strokeWidth={1.5} aria-hidden="true" focusable="false" />;
}
22 changes: 22 additions & 0 deletions src/web-ui/src/app/scenes/miniapps/miniAppStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@ describe('miniAppStore customization state', () => {
expect(useMiniAppStore.getState().openedAppIds).toEqual(['gomoku']);
expect(useMiniAppStore.getState().runningWorkerIds).toEqual(['gomoku']);
});

it('adds and updates an installed app without waiting for a catalog reload', () => {
const installed = {
id: 'market-app',
name: 'Market App',
description: '',
category: 'productivity',
version: 1,
icon: 'Aperture',
tags: [],
created_at: 1,
updated_at: 1,
permissions: {},
};

useMiniAppStore.getState().upsertApp(installed);
expect(useMiniAppStore.getState().apps).toEqual([installed]);

const updated = { ...installed, name: 'Updated Market App', version: 2 };
useMiniAppStore.getState().upsertApp(updated);
expect(useMiniAppStore.getState().apps).toEqual([updated]);
});
});

describe('miniAppStore floating bubble composer claims', () => {
Expand Down
11 changes: 11 additions & 0 deletions src/web-ui/src/app/scenes/miniapps/miniAppStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ interface MiniAppState {
composerClaims: Record<string, MiniAppComposerClaim>;

setApps: (apps: MiniAppMeta[]) => void;
upsertApp: (app: MiniAppMeta) => void;
setLoading: (loading: boolean) => void;
openApp: (id: string) => void;
closeApp: (id: string) => void;
Expand Down Expand Up @@ -186,6 +187,16 @@ export const useMiniAppStore = create<MiniAppState>((set) => ({
),
};
}),
upsertApp: (app) =>
set((state) => {
const existingIndex = state.apps.findIndex((current) => current.id === app.id);
if (existingIndex < 0) {
return { apps: [app, ...state.apps] };
}
const apps = [...state.apps];
apps[existingIndex] = app;
return { apps };
}),
setLoading: (loading) => set({ loading }),

openApp: (id) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
import { createLogger } from '@/shared/utils/logger';
import { useNotification } from '@/shared/notification-system';
import { getMiniAppIconGradient, renderMiniAppIcon } from '../utils/miniAppIcons';
import { useMiniAppStore } from '../miniAppStore';
import { pickLocalizedString } from '../utils/pickLocalizedString';
import './MiniAppMarketView.scss';

Expand All @@ -68,6 +69,7 @@ const MiniAppMarketView: React.FC = () => {
const notification = useNotification();
const { workspace } = useCurrentWorkspace();
const { openScene, activateScene, openTabs } = useSceneManager();
const upsertApp = useMiniAppStore((state) => state.upsertApp);
const [query, setQuery] = useState('');
const [category, setCategory] = useState<(typeof CATEGORIES)[number]>('all');
const [sort, setSort] = useState<MarketSort>('newest');
Expand Down Expand Up @@ -221,6 +223,7 @@ const MiniAppMarketView: React.FC = () => {
confirmPermissions: true,
confirmOverwrite: Boolean(installed?.localOverride),
});
upsertApp(result.app);
setInstalled(await miniAppMarketAPI.installedStatus(detail.listingId));
notification.success(
t(result.updated ? 'market.messages.updated' : 'market.messages.installed'),
Expand Down