From 1dcec7008e138ed5d39856084894d6c443d499fe Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 5 Feb 2026 12:50:40 +0800 Subject: [PATCH] feat: complete implementation --- .../01-Runtime-Env/02-Miniapp-Manifest.md | 4 + .../add-miniapp-policy-permissions/design.md | 26 ++++++ .../proposal.md | 15 +++ .../specs/miniapp-runtime/spec.md | 25 +++++ .../add-miniapp-policy-permissions/tasks.md | 24 +++++ src/i18n/locales/ar/ecosystem.json | 16 +++- src/i18n/locales/en-US/ecosystem.json | 16 ++++ src/i18n/locales/en/ecosystem.json | 16 +++- src/i18n/locales/zh-CN/ecosystem.json | 16 +++- src/i18n/locales/zh-TW/ecosystem.json | 16 +++- src/services/ecosystem/index.ts | 1 + src/services/ecosystem/permissions-policy.ts | 93 +++++++++++++++++++ src/services/ecosystem/registry.ts | 10 ++ src/services/ecosystem/schema.ts | 2 +- src/services/ecosystem/types.ts | 4 + .../__tests__/container.test.ts | 14 +++ .../container/iframe-container.ts | 5 +- .../miniapp-runtime/container/types.ts | 1 + .../container/wujie-container.ts | 7 +- src/services/miniapp-runtime/index.ts | 88 ++++++++++++++++++ .../activities/MiniappDetailActivity.tsx | 73 +++++++++++++-- vite.config.ts | 5 + 22 files changed, 461 insertions(+), 16 deletions(-) create mode 100644 openspec/changes/add-miniapp-policy-permissions/design.md create mode 100644 openspec/changes/add-miniapp-policy-permissions/proposal.md create mode 100644 openspec/changes/add-miniapp-policy-permissions/specs/miniapp-runtime/spec.md create mode 100644 openspec/changes/add-miniapp-policy-permissions/tasks.md create mode 100644 src/services/ecosystem/permissions-policy.ts diff --git a/docs/white-book/11-DApp-Guide/01-Runtime-Env/02-Miniapp-Manifest.md b/docs/white-book/11-DApp-Guide/01-Runtime-Env/02-Miniapp-Manifest.md index a0a18b258..dc93fecad 100644 --- a/docs/white-book/11-DApp-Guide/01-Runtime-Env/02-Miniapp-Manifest.md +++ b/docs/white-book/11-DApp-Guide/01-Runtime-Env/02-Miniapp-Manifest.md @@ -22,6 +22,7 @@ Code: `src/ecosystem/types.ts` } ], "permissions": ["wallet:read", "wallet:write"], + "permissionsPolicy": ["clipboard-write", "camera"], "splash_screen": { "timeout": 3000 } @@ -32,8 +33,11 @@ Code: `src/ecosystem/types.ts` - `display`: 控制窗口模式。`standalone` (隐藏浏览器UI), `minimal-ui`, `browser`。 - `permissions`: 申请的 BioBridge 权限。 +- `permissionsPolicy`: 申请的 Web Permissions Policy 指令列表(如 `clipboard-write`, `camera`)。KeyApp 会根据该字段为 miniapp iframe 注入 `allow`,用于跨域权限委派。 - `splash_screen`: 自定义启动闪屏的行为。 +> 注意:跨域 miniapp 需要宿主页面的 `Permissions-Policy` 响应头允许委派,否则 `allow` 不生效。 + ## 样式规范 (CSS Guidelines) Miniapp 运行在 KeyApp 的容器中(iframe 或 Wujie 沙箱),需要遵循以下样式规范以确保正确渲染。 diff --git a/openspec/changes/add-miniapp-policy-permissions/design.md b/openspec/changes/add-miniapp-policy-permissions/design.md new file mode 100644 index 000000000..38cd2d61b --- /dev/null +++ b/openspec/changes/add-miniapp-policy-permissions/design.md @@ -0,0 +1,26 @@ +# Design: Miniapp Permissions Policy + +## Goals +- Manifest-driven Permissions Policy for cross-origin miniapps. +- Full directive coverage aligned with Web standard. +- Immediate manifest refresh for installed and running apps. + +## Manifest Field +- `permissionsPolicy?: PermissionsPolicyDirective[]` +- Directive names match Permissions-Policy tokens (e.g. `clipboard-write`, `camera`). +- Validation uses a compile-time list of known directives. + +## Policy Enforcement +- Top-level document must allow delegation for all directives (header `Permissions-Policy: =*`), while per-miniapp restriction is enforced via iframe/wujie `allow` attributes. +- Allow string format: `directive1; directive2; ...` (deduped, stable order). + +## Runtime Sync +- Registry refresh triggers manifest refresh for installed/running apps. +- If a running app's manifest changes, update: + - stored manifest + - iframe allow attribute + - active bridge permissions (when active) + +## Risk/Compatibility +- No behavior change for miniapps without `permissionsPolicy`. +- Headers are additive in dev; production requires CDN/host config. diff --git a/openspec/changes/add-miniapp-policy-permissions/proposal.md b/openspec/changes/add-miniapp-policy-permissions/proposal.md new file mode 100644 index 000000000..b7c0842ad --- /dev/null +++ b/openspec/changes/add-miniapp-policy-permissions/proposal.md @@ -0,0 +1,15 @@ +# Change: Add manifest-driven Permissions Policy for miniapps + +## Why +Miniapps run cross-origin inside KeyApp. Without a manifest-driven Permissions Policy layer, Web APIs like clipboard and camera fail at runtime, and updates to miniapp manifests do not propagate to running apps. + +## What Changes +- Introduce a manifest field for Permissions Policy directives (full Web standard list). +- Map manifest directives to iframe/wujie `allow` attributes and update policies on source refresh. +- Ensure installed miniapps refresh their manifests immediately when sources update. +- Add runtime integration to keep allowlists in sync for running apps. +- Update docs and example miniapps to declare required directives. + +## Impact +- Affected specs: miniapp-runtime (new) +- Affected code: ecosystem registry, miniapp runtime container, manifest schema/types, docs, i18n, Vite dev headers diff --git a/openspec/changes/add-miniapp-policy-permissions/specs/miniapp-runtime/spec.md b/openspec/changes/add-miniapp-policy-permissions/specs/miniapp-runtime/spec.md new file mode 100644 index 000000000..a0cbe6e25 --- /dev/null +++ b/openspec/changes/add-miniapp-policy-permissions/specs/miniapp-runtime/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: Manifest-driven Permissions Policy +The system SHALL allow miniapps to declare Permissions Policy directives in their manifest and enforce them at runtime. + +#### Scenario: Miniapp declares clipboard write +- **GIVEN** a miniapp manifest includes `permissionsPolicy: ["clipboard-write"]` +- **WHEN** the miniapp is launched +- **THEN** the runtime injects `allow="clipboard-write"` into the miniapp iframe + +### Requirement: Permissions Policy directive validation +The system SHALL validate declared Permissions Policy directives against the supported directive list. + +#### Scenario: Invalid directive +- **GIVEN** a manifest includes an unknown directive +- **WHEN** the registry parses the manifest +- **THEN** the manifest is rejected or the invalid directive is ignored with diagnostics + +### Requirement: Manifest refresh updates running apps +The system SHALL update running miniapp manifests and their iframe allowlists when source data is refreshed. + +#### Scenario: Source update changes permissions +- **GIVEN** a running miniapp with `permissionsPolicy: ["camera"]` +- **WHEN** the source refresh updates the manifest to include `"clipboard-write"` +- **THEN** the runtime updates the iframe allowlist to include `clipboard-write` diff --git a/openspec/changes/add-miniapp-policy-permissions/tasks.md b/openspec/changes/add-miniapp-policy-permissions/tasks.md new file mode 100644 index 000000000..a5ef45c1b --- /dev/null +++ b/openspec/changes/add-miniapp-policy-permissions/tasks.md @@ -0,0 +1,24 @@ +## 1. Design +- [ ] Confirm Permissions Policy directive list source and mapping strategy +- [ ] Define manifest field name and validation rules + +## 2. Schema + Types +- [ ] Add Permissions Policy directive type + list +- [ ] Extend MiniappManifest schema/type with policy directives + +## 3. Runtime Integration +- [ ] Build allowlist helper (dedupe + validation + string format) +- [ ] Inject allow attribute into iframe and wujie containers +- [ ] Update running miniapps when sources refresh (manifest + allow) + +## 4. Permissions Policy Headers +- [ ] Add dev server Permissions-Policy header template +- [ ] Document production header requirements + +## 5. Docs + Tests +- [ ] Update manifest docs with new field +- [ ] Add unit tests for allowlist generation + runtime updates + +## 6. Miniapp Manifests +- [ ] Update bfm-rwa-hub-app manifest +- [ ] Update bfm-rwa-org-app manifest diff --git a/src/i18n/locales/ar/ecosystem.json b/src/i18n/locales/ar/ecosystem.json index 0cc0e11fd..35dc66827 100644 --- a/src/i18n/locales/ar/ecosystem.json +++ b/src/i18n/locales/ar/ecosystem.json @@ -144,6 +144,20 @@ "exchange": "تبادل", "other": "أخرى" }, - "tagLabel": "#{{tag}}" + "tagLabel": "#{{tag}}", + "permissions": "أذونات التطبيق" + }, + "permissionsPolicy": { + "title": "قدرات المتصفح", + "hint": "قد يطلب هذا التطبيق الوصول إلى الميزات التالية في المتصفح", + "defaultDescription": "قد يصل هذا التطبيق إلى هذه الميزة في المتصفح", + "clipboard-read": { + "name": "قراءة الحافظة", + "description": "قراءة نص من الحافظة" + }, + "clipboard-write": { + "name": "الكتابة إلى الحافظة", + "description": "كتابة نص إلى الحافظة" + } } } diff --git a/src/i18n/locales/en-US/ecosystem.json b/src/i18n/locales/en-US/ecosystem.json index d841c3677..70148cca4 100644 --- a/src/i18n/locales/en-US/ecosystem.json +++ b/src/i18n/locales/en-US/ecosystem.json @@ -20,5 +20,21 @@ "bio_signMessage": "Sign message", "bio_signTypedData": "Sign typed data", "bio_sendTransaction": "Send transaction" + }, + "permissionsPolicy": { + "title": "Browser Capabilities", + "hint": "This app also requests access to the following browser features", + "defaultDescription": "This app may access this browser capability", + "clipboard-read": { + "name": "Read Clipboard", + "description": "Read text from your clipboard" + }, + "clipboard-write": { + "name": "Write Clipboard", + "description": "Write text to your clipboard" + } + }, + "detail": { + "permissions": "App Permissions" } } diff --git a/src/i18n/locales/en/ecosystem.json b/src/i18n/locales/en/ecosystem.json index 452a32807..76b410e33 100644 --- a/src/i18n/locales/en/ecosystem.json +++ b/src/i18n/locales/en/ecosystem.json @@ -144,6 +144,20 @@ "exchange": "Exchange", "other": "Other" }, - "tagLabel": "#{{tag}}" + "tagLabel": "#{{tag}}", + "permissions": "App Permissions" + }, + "permissionsPolicy": { + "title": "Browser Capabilities", + "hint": "This app also requests access to the following browser features", + "defaultDescription": "This app may access this browser capability", + "clipboard-read": { + "name": "Read Clipboard", + "description": "Read text from your clipboard" + }, + "clipboard-write": { + "name": "Write Clipboard", + "description": "Write text to your clipboard" + } } } diff --git a/src/i18n/locales/zh-CN/ecosystem.json b/src/i18n/locales/zh-CN/ecosystem.json index 6e80236d6..4c2e50b80 100644 --- a/src/i18n/locales/zh-CN/ecosystem.json +++ b/src/i18n/locales/zh-CN/ecosystem.json @@ -144,6 +144,20 @@ "exchange": "交易所", "other": "其他" }, - "tagLabel": "#{{tag}}" + "tagLabel": "#{{tag}}", + "permissions": "应用权限" + }, + "permissionsPolicy": { + "title": "浏览器能力", + "hint": "此应用还会请求以下浏览器能力", + "defaultDescription": "此应用可能访问此浏览器能力", + "clipboard-read": { + "name": "读取剪贴板", + "description": "读取剪贴板中的文本内容" + }, + "clipboard-write": { + "name": "写入剪贴板", + "description": "将文本写入剪贴板" + } } } diff --git a/src/i18n/locales/zh-TW/ecosystem.json b/src/i18n/locales/zh-TW/ecosystem.json index b6a53d825..b6e1d5ed9 100644 --- a/src/i18n/locales/zh-TW/ecosystem.json +++ b/src/i18n/locales/zh-TW/ecosystem.json @@ -144,6 +144,20 @@ "exchange": "交易所", "other": "其他" }, - "tagLabel": "#{{tag}}" + "tagLabel": "#{{tag}}", + "permissions": "應用權限" + }, + "permissionsPolicy": { + "title": "瀏覽器能力", + "hint": "此應用還會請求以下瀏覽器能力", + "defaultDescription": "此應用可能存取此瀏覽器能力", + "clipboard-read": { + "name": "讀取剪貼簿", + "description": "讀取剪貼簿中的文字內容" + }, + "clipboard-write": { + "name": "寫入剪貼簿", + "description": "將文字寫入剪貼簿" + } } } diff --git a/src/services/ecosystem/index.ts b/src/services/ecosystem/index.ts index 6fa46ad5f..1d2abc1a7 100644 --- a/src/services/ecosystem/index.ts +++ b/src/services/ecosystem/index.ts @@ -8,3 +8,4 @@ export * from './provider'; export * from './registry'; export * from './my-apps'; export * from './permissions'; +export * from './permissions-policy'; diff --git a/src/services/ecosystem/permissions-policy.ts b/src/services/ecosystem/permissions-policy.ts new file mode 100644 index 000000000..a2c786f45 --- /dev/null +++ b/src/services/ecosystem/permissions-policy.ts @@ -0,0 +1,93 @@ +/** + * Permissions Policy directives for miniapps. + * + * Source: MDN Permissions-Policy directive list + clipboard directives. + */ + +export const PERMISSIONS_POLICY_DIRECTIVES = [ + 'accelerometer', + 'ambient-light-sensor', + 'aria-notify', + 'attribution-reporting', + 'autoplay', + 'bluetooth', + 'browsing-topics', + 'camera', + 'captured-surface-control', + 'ch-ua-high-entropy-values', + 'compute-pressure', + 'cross-origin-isolated', + 'deferred-fetch', + 'deferred-fetch-minimal', + 'display-capture', + 'encrypted-media', + 'fullscreen', + 'gamepad', + 'geolocation', + 'gyroscope', + 'hid', + 'identity-credentials-get', + 'idle-detection', + 'language-detector', + 'local-fonts', + 'magnetometer', + 'microphone', + 'midi', + 'on-device-speech-recognition', + 'otp-credentials', + 'payment', + 'picture-in-picture', + 'private-state-token-issuance', + 'private-state-token-redemption', + 'publickey-credentials-create', + 'publickey-credentials-get', + 'screen-wake-lock', + 'serial', + 'speaker-selection', + 'storage-access', + 'translator', + 'summarizer', + 'usb', + 'web-share', + 'window-management', + 'xr-spatial-tracking', + 'clipboard-read', + 'clipboard-write', +] as const; + +export type PermissionsPolicyDirective = (typeof PERMISSIONS_POLICY_DIRECTIVES)[number]; + +const PERMISSIONS_POLICY_DIRECTIVE_SET = new Set(PERMISSIONS_POLICY_DIRECTIVES); + +export function isPermissionsPolicyDirective(value: string): value is PermissionsPolicyDirective { + return PERMISSIONS_POLICY_DIRECTIVE_SET.has(value as PermissionsPolicyDirective); +} + +export function normalizePermissionsPolicy( + directives?: readonly string[] | null, +): PermissionsPolicyDirective[] { + if (!directives || directives.length === 0) return []; + + const allowed = new Set(); + for (const entry of directives) { + if (isPermissionsPolicyDirective(entry)) { + allowed.add(entry); + } + } + + if (allowed.size === 0) return []; + return PERMISSIONS_POLICY_DIRECTIVES.filter((directive) => allowed.has(directive)); +} + +export function buildPermissionsPolicyAllow( + directives?: readonly PermissionsPolicyDirective[] | null, +): string | undefined { + if (!directives || directives.length === 0) return undefined; + return directives.join('; '); +} + +export function buildPermissionsPolicyHeaderValue( + directives: readonly PermissionsPolicyDirective[] = PERMISSIONS_POLICY_DIRECTIVES, +): string { + return directives.map((directive) => `${directive}=*`).join(', '); +} diff --git a/src/services/ecosystem/registry.ts b/src/services/ecosystem/registry.ts index 123424eb0..cac6d8bc5 100644 --- a/src/services/ecosystem/registry.ts +++ b/src/services/ecosystem/registry.ts @@ -14,6 +14,7 @@ import type { EcosystemSource, MiniappManifest, SourceRecord } from './types'; import { EcosystemSearchResponseSchema, EcosystemSourceSchema } from './schema'; import { computeFeaturedScore } from './scoring'; import { createResolver } from '@/lib/url-resolver'; +import { isPermissionsPolicyDirective, normalizePermissionsPolicy } from './permissions-policy'; const SUPPORTED_SEARCH_RESPONSE_VERSIONS = new Set(['1', '1.0.0']); @@ -367,8 +368,17 @@ function normalizeAppFromSource( const resolve = createResolver(source.url); + const permissionsPolicy = normalizePermissionsPolicy(app.permissionsPolicy ?? []); + if ((app.permissionsPolicy?.length ?? 0) > 0) { + const unknown = (app.permissionsPolicy ?? []).filter((entry) => !isPermissionsPolicyDirective(entry)); + if (unknown.length > 0) { + debugLog('unknown permissions policy directives', { appId: app.id, directives: unknown }); + } + } + return { ...app, + permissionsPolicy, icon: resolve(app.icon), url: resolve(app.url), screenshots: app.screenshots?.map(resolve), diff --git a/src/services/ecosystem/schema.ts b/src/services/ecosystem/schema.ts index 9411a06a1..8bd075ebe 100644 --- a/src/services/ecosystem/schema.ts +++ b/src/services/ecosystem/schema.ts @@ -35,6 +35,7 @@ export const MiniappManifestSchema = z category: MiniappCategorySchema.optional(), tags: z.array(z.string()).optional(), permissions: z.array(z.string()).optional(), + permissionsPolicy: z.array(z.string()).optional(), chains: z.array(z.string()).optional(), screenshots: z.array(z.string()).optional(), minWalletVersion: z.string().optional(), @@ -74,4 +75,3 @@ export const EcosystemSearchResponseSchema = z data: z.array(MiniappManifestSchema), }) .passthrough() - diff --git a/src/services/ecosystem/types.ts b/src/services/ecosystem/types.ts index ede372e51..26f782035 100644 --- a/src/services/ecosystem/types.ts +++ b/src/services/ecosystem/types.ts @@ -4,6 +4,8 @@ * 统一从 chain-adapter 导入交易相关类型 */ +import type { PermissionsPolicyDirective } from './permissions-policy'; + // ===== 从 chain-adapter 导入核心类型 ===== export type { // 交易意图 @@ -158,6 +160,8 @@ export interface MiniappManifest { tags?: string[]; /** 请求的权限列表 */ permissions?: string[]; + /** Permissions Policy directives */ + permissionsPolicy?: PermissionsPolicyDirective[]; /** 支持的链 */ chains?: string[]; /** 截图 URL 列表 */ diff --git a/src/services/miniapp-runtime/__tests__/container.test.ts b/src/services/miniapp-runtime/__tests__/container.test.ts index 0b7361712..8530a9778 100644 --- a/src/services/miniapp-runtime/__tests__/container.test.ts +++ b/src/services/miniapp-runtime/__tests__/container.test.ts @@ -50,6 +50,20 @@ describe('IframeContainerManager', () => { expect(iframe.src).toContain('baz=qux'); }); + it('should set permissions policy allow attribute when provided', async () => { + const options: ContainerCreateOptions = { + appId: 'test-app', + url: 'https://example.com', + mountTarget, + permissionsPolicyAllow: 'clipboard-write; camera', + }; + + const handle = await manager.create(options); + const iframe = handle.element as HTMLIFrameElement; + + expect(iframe.getAttribute('allow')).toBe('clipboard-write; camera'); + }); + it('should call onLoad callback when iframe loads', async () => { const onLoad = vi.fn(); const options: ContainerCreateOptions = { diff --git a/src/services/miniapp-runtime/container/iframe-container.ts b/src/services/miniapp-runtime/container/iframe-container.ts index c4797701f..472e8b792 100644 --- a/src/services/miniapp-runtime/container/iframe-container.ts +++ b/src/services/miniapp-runtime/container/iframe-container.ts @@ -69,7 +69,7 @@ export class IframeContainerManager implements ContainerManager { * Creates the iframe and appends it to the mount target immediately. */ createSync(options: ContainerCreateOptions): IframeContainerHandle { - const { appId, url, mountTarget, contextParams, onLoad } = options; + const { appId, url, mountTarget, contextParams, onLoad, permissionsPolicyAllow } = options; const iframe = document.createElement('iframe'); iframe.id = `miniapp-iframe-${appId}`; @@ -88,6 +88,9 @@ export class IframeContainerManager implements ContainerManager { } else { iframe.setAttribute('sandbox', 'allow-scripts allow-forms allow-same-origin'); } + if (permissionsPolicyAllow) { + iframe.setAttribute('allow', permissionsPolicyAllow); + } iframe.style.cssText = ` width: 100%; height: 100%; diff --git a/src/services/miniapp-runtime/container/types.ts b/src/services/miniapp-runtime/container/types.ts index e4f6a04aa..00535c3e3 100644 --- a/src/services/miniapp-runtime/container/types.ts +++ b/src/services/miniapp-runtime/container/types.ts @@ -19,6 +19,7 @@ export interface ContainerCreateOptions { contextParams?: Record; onLoad?: () => void; wujieConfig?: WujieRuntimeConfig; + permissionsPolicyAllow?: string; } export interface ContainerManager { diff --git a/src/services/miniapp-runtime/container/wujie-container.ts b/src/services/miniapp-runtime/container/wujie-container.ts index 79d622ba4..daea3b8af 100644 --- a/src/services/miniapp-runtime/container/wujie-container.ts +++ b/src/services/miniapp-runtime/container/wujie-container.ts @@ -133,7 +133,7 @@ export class WujieContainerManager implements ContainerManager { readonly type = 'wujie' as const; async create(options: ContainerCreateOptions): Promise { - const { appId, url, mountTarget, contextParams, onLoad, wujieConfig } = options; + const { appId, url, mountTarget, contextParams, onLoad, wujieConfig, permissionsPolicyAllow } = options; const container = document.createElement('div'); container.id = `miniapp-wujie-${appId}`; @@ -163,6 +163,11 @@ export class WujieContainerManager implements ContainerManager { }, }; + if (permissionsPolicyAllow) { + startAppOptions.attrs = { allow: permissionsPolicyAllow }; + startAppOptions.degradeAttrs = { allow: permissionsPolicyAllow }; + } + if (wujieConfig?.rewriteAbsolutePaths) { const rewriter = createAbsolutePathRewriter(urlWithParams.toString()); startAppOptions.fetch = rewriter.fetch; diff --git a/src/services/miniapp-runtime/index.ts b/src/services/miniapp-runtime/index.ts index d488b6dc7..0779d49bb 100644 --- a/src/services/miniapp-runtime/index.ts +++ b/src/services/miniapp-runtime/index.ts @@ -59,6 +59,8 @@ import { } from './container'; import type { MiniappManifest, MiniappTargetDesktop } from '../ecosystem/types'; import { getBridge } from '../ecosystem/provider'; +import { buildPermissionsPolicyAllow, normalizePermissionsPolicy } from '../ecosystem/permissions-policy'; +import { subscribeApps } from '../ecosystem/registry'; import { toastService } from '../toast'; import { getDesktopAppSlotRect, getIconRef } from './runtime-refs'; import i18n from '@/i18n'; @@ -122,6 +124,7 @@ function attachBioProvider(appId: string): void { const iframe = app.containerHandle?.getIframe() ?? app.iframeRef; if (!iframe) return; + applyPermissionsPolicyAllow(iframe, getPermissionsPolicyAllow(app.manifest)); getBridge().attach(iframe, appId, app.manifest.name, app.manifest.permissions ?? []); } @@ -129,10 +132,83 @@ function attachBioProviderToContainer(appId: string, handle: ContainerHandle, ma const iframe = handle.getIframe(); if (!iframe) return; + applyPermissionsPolicyAllow(iframe, getPermissionsPolicyAllow(manifest)); getBridge().attach(iframe, appId, manifest.name, manifest.permissions ?? []); sendKeyAppContext(iframe); } +function getPermissionsPolicyAllow(manifest: MiniappManifest): string | undefined { + const directives = normalizePermissionsPolicy(manifest.permissionsPolicy ?? []); + return buildPermissionsPolicyAllow(directives); +} + +function applyPermissionsPolicyAllow(iframe: HTMLIFrameElement, allow?: string): void { + if (allow) { + iframe.setAttribute('allow', allow); + } else { + iframe.removeAttribute('allow'); + } +} + +function areStringArraysEqual(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i += 1) { + if (left[i] !== right[i]) return false; + } + return true; +} + +function shouldRefreshManifest(prev: MiniappManifest, next: MiniappManifest): boolean { + if (prev.version !== next.version) return true; + if (prev.updatedAt !== next.updatedAt) return true; + if (prev.url !== next.url) return true; + if ((prev.runtime ?? 'iframe') !== (next.runtime ?? 'iframe')) return true; + if (JSON.stringify(prev.wujieConfig ?? {}) !== JSON.stringify(next.wujieConfig ?? {})) return true; + + const prevPermissions = prev.permissions ?? []; + const nextPermissions = next.permissions ?? []; + if (!areStringArraysEqual(prevPermissions, nextPermissions)) return true; + + const prevPolicy = normalizePermissionsPolicy(prev.permissionsPolicy ?? []); + const nextPolicy = normalizePermissionsPolicy(next.permissionsPolicy ?? []); + if (!areStringArraysEqual(prevPolicy, nextPolicy)) return true; + + return false; +} + +function syncRunningMiniapps(nextApps: MiniappManifest[]): void { + if (nextApps.length === 0) return; + const nextById = new Map(nextApps.map((app) => [app.id, app])); + const updates: Array<{ appId: string; allow?: string }> = []; + + miniappRuntimeStore.setState((state) => { + let changed = false; + const newApps = new Map(state.apps); + + for (const [appId, instance] of newApps.entries()) { + const nextManifest = nextById.get(appId); + if (!nextManifest) continue; + if (!shouldRefreshManifest(instance.manifest, nextManifest)) continue; + + changed = true; + newApps.set(appId, { ...instance, manifest: nextManifest }); + updates.push({ appId, allow: getPermissionsPolicyAllow(nextManifest) }); + } + + return changed ? { ...state, apps: newApps } : state; + }); + + updates.forEach((update) => { + const app = miniappRuntimeStore.state.apps.get(update.appId); + if (!app) return; + const iframe = app.containerHandle?.getIframe() ?? app.iframeRef; + if (iframe) { + applyPermissionsPolicyAllow(iframe, update.allow); + } + attachBioProvider(update.appId); + }); +} + function getCapsuleSafeAreaTop(): number { const testEl = document.createElement('div'); testEl.style.cssText = 'position:fixed;top:env(safe-area-inset-top,0px);visibility:hidden;'; @@ -213,11 +289,19 @@ function ensureKeyAppContextRequestListener(): void { window.addEventListener('message', handleKeyAppContextRequest); } +let miniappManifestSyncListenerReady = false; +function ensureMiniappManifestSyncListener(): void { + if (miniappManifestSyncListenerReady) return; + miniappManifestSyncListenerReady = true; + subscribeApps(syncRunningMiniapps); +} + /** Store 实例 */ export const miniappRuntimeStore = new Store(initialState); // 注册 context-request 监听器(必须在 store 定义之后) ensureKeyAppContextRequestListener(); +ensureMiniappManifestSyncListener(); export function setMiniappVisualConfig(update: MiniappVisualConfigUpdate): void { miniappRuntimeStore.setState((s) => ({ @@ -709,6 +793,7 @@ export function launchApp( const hasSplash = !!manifest.splashScreen; const containerType: ContainerType = manifest.runtime ?? 'iframe'; const targetDesktop = manifest.targetDesktop ?? 'stack'; + const permissionsPolicyAllow = getPermissionsPolicyAllow(manifest); const instance: MiniappInstance = { appId, @@ -759,6 +844,7 @@ export function launchApp( mountTarget, contextParams, wujieConfig: manifest.wujieConfig, + permissionsPolicyAllow, onLoad, }); @@ -834,12 +920,14 @@ function createContainerAsync( onLoad: () => void, mountTarget: HTMLElement, ): void { + const permissionsPolicyAllow = getPermissionsPolicyAllow(manifest); createContainer(instance.containerType, { appId: instance.appId, url: manifest.url, mountTarget, contextParams, wujieConfig: manifest.wujieConfig, + permissionsPolicyAllow, onLoad, }).then((handle) => { instance.containerHandle = handle; diff --git a/src/stackflow/activities/MiniappDetailActivity.tsx b/src/stackflow/activities/MiniappDetailActivity.tsx index eae1dbe81..0065e643e 100644 --- a/src/stackflow/activities/MiniappDetailActivity.tsx +++ b/src/stackflow/activities/MiniappDetailActivity.tsx @@ -29,13 +29,15 @@ import { } from '@tabler/icons-react'; import { cn } from '@/lib/utils'; import { launchApp } from '@/services/miniapp-runtime'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; import { ecosystemActions, ecosystemStore, ecosystemSelectors } from '@/stores/ecosystem'; type MiniappDetailActivityParams = { appId: string; }; -function PrivacyItem({ permission, isLast }: { permission: string; isLast: boolean }) { +function PrivacyItem({ permission }: { permission: string }) { const def = KNOWN_PERMISSIONS[permission]; const risk = def?.risk ?? 'medium'; const info = getPermissionInfo(permission); @@ -44,7 +46,7 @@ function PrivacyItem({ permission, isLast }: { permission: string; isLast: boole const iconColor = risk === 'high' ? 'text-red-500' : risk === 'medium' ? 'text-amber-500' : 'text-green-500'; return ( -
+

{info.label}

@@ -54,6 +56,24 @@ function PrivacyItem({ permission, isLast }: { permission: string; isLast: boole ); } +function PolicyItem({ directive }: { directive: string }) { + const { t } = useTranslation('ecosystem'); + const name = t(`permissionsPolicy.${directive}.name`, { defaultValue: directive }); + const description = t(`permissionsPolicy.${directive}.description`, { + defaultValue: t('permissionsPolicy.defaultDescription'), + }); + + return ( +
+ +
+

{name}

+

{description}

+
+
+ ); +} + function InfoRow({ label, value, @@ -165,6 +185,8 @@ export const MiniappDetailActivity: ActivityComponentType 150; const displayDesc = descExpanded || !isDescLong ? description : description.slice(0, 150) + '...'; + const declaredPermissions = app.permissions ?? []; + const declaredPolicies = app.permissionsPolicy ?? []; return ( @@ -296,14 +318,47 @@ export const MiniappDetailActivity: ActivityComponentType 0 && ( + {(declaredPermissions.length > 0 || declaredPolicies.length > 0) && (
-

{t('detail.privacy')}

-

{t('detail.privacyHint')}

-
- {app.permissions.map((perm, i) => ( - - ))} +
+

{t('detail.privacy')}

+ + {declaredPermissions.length + declaredPolicies.length} + +
+
+ {declaredPermissions.length > 0 && ( + + +
+ {t('detail.permissions')} + {declaredPermissions.length} +
+ {t('detail.privacyHint')} +
+ + {declaredPermissions.map((perm) => ( + + ))} + +
+ )} + {declaredPolicies.length > 0 && ( + + +
+ {t('permissionsPolicy.title')} + {declaredPolicies.length} +
+ {t('permissionsPolicy.hint')} +
+ + {declaredPolicies.map((directive) => ( + + ))} + +
+ )}
)} diff --git a/vite.config.ts b/vite.config.ts index ff09d6914..7bd8409bc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,7 @@ import { mockDevToolsPlugin } from './scripts/vite-plugin-mock-devtools'; import { miniappsPlugin } from './scripts/vite-plugin-miniapps'; import { remoteMiniappsPlugin, type RemoteMiniappConfig } from './scripts/vite-plugin-remote-miniapps'; import { buildCheckPlugin } from './scripts/vite-plugin-build-check'; +import { buildPermissionsPolicyHeaderValue } from './src/services/ecosystem/permissions-policy'; const remoteMiniappsConfig: RemoteMiniappConfig[] = [ { @@ -122,11 +123,15 @@ export default defineConfig(({ mode }) => { const pad = (value: number) => value.toString().padStart(2, '0'); const buildSuffix = `-${pad(buildTime.getUTCMonth() + 1)}${pad(buildTime.getUTCDate())}${pad(buildTime.getUTCHours())}`; const appVersion = `${getPackageVersion()}${isDevBuild ? buildSuffix : ''}`; + const permissionsPolicyHeader = buildPermissionsPolicyHeaderValue(); return { base: BASE_URL, server: { host: true, + headers: { + 'Permissions-Policy': permissionsPolicyHeader, + }, // 手机上的“每隔几秒自动刷新”通常是 HMR WebSocket 连不上导致的。 // 明确指定 wss + 局域网 IP,避免客户端默认连到 localhost(在手机上等于连自己)。 hmr: DEV_HOST