From 66f4b704e437543dae590d1560c6a756fc3057e4 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Wed, 21 Jan 2026 02:55:40 +0800 Subject: [PATCH 1/4] refactor(miniapp-runtime): introduce Container abstraction for SOLID compliance - Create ContainerManager interface with ContainerHandle - Implement IframeContainerManager (extracted from iframe-manager) - Implement WujieContainerManager (using wujie library) - Create container factory for runtime type switching - Refactor launchApp to use container factory based on manifest.runtime - Update activateApp/deactivateApp to use containerHandle methods - Update finalizeCloseApp to properly destroy containers - Add containerType and containerHandle to MiniappInstance - Deprecate iframeRef in favor of containerHandle.element BREAKING CHANGE: MiniappInstance now uses containerHandle instead of iframeRef --- .../container/iframe-container.ts | 110 +++++++++++++ .../miniapp-runtime/container/index.ts | 20 +++ .../miniapp-runtime/container/types.ts | 22 +++ .../container/wujie-container.ts | 86 ++++++++++ src/services/miniapp-runtime/index.ts | 110 ++++++------- src/services/miniapp-runtime/types.ts | 148 +++++++++--------- 6 files changed, 364 insertions(+), 132 deletions(-) create mode 100644 src/services/miniapp-runtime/container/iframe-container.ts create mode 100644 src/services/miniapp-runtime/container/index.ts create mode 100644 src/services/miniapp-runtime/container/types.ts create mode 100644 src/services/miniapp-runtime/container/wujie-container.ts diff --git a/src/services/miniapp-runtime/container/iframe-container.ts b/src/services/miniapp-runtime/container/iframe-container.ts new file mode 100644 index 000000000..fff9b830e --- /dev/null +++ b/src/services/miniapp-runtime/container/iframe-container.ts @@ -0,0 +1,110 @@ +import type { ContainerManager, ContainerHandle, ContainerCreateOptions } from './types'; + +const VISIBLE_CONTAINER_ID = 'miniapp-iframe-container'; +const HIDDEN_CONTAINER_ID = 'miniapp-hidden-container'; + +function getOrCreateContainer(id: string, hidden: boolean): HTMLElement { + let container = document.getElementById(id); + if (!container) { + container = document.createElement('div'); + container.id = id; + if (hidden) { + container.style.cssText = ` + position: fixed; + top: -9999px; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; + visibility: hidden; + pointer-events: none; + `; + } + document.body.appendChild(container); + } + return container; +} + +class IframeContainerHandle implements ContainerHandle { + readonly type = 'iframe' as const; + readonly element: HTMLIFrameElement; + + constructor(private iframe: HTMLIFrameElement) { + this.element = iframe; + } + + destroy(): void { + this.iframe.src = 'about:blank'; + this.iframe.remove(); + } + + moveToBackground(): void { + const container = getOrCreateContainer(HIDDEN_CONTAINER_ID, true); + container.appendChild(this.iframe); + } + + moveToForeground(): void { + const container = getOrCreateContainer(VISIBLE_CONTAINER_ID, false); + container.appendChild(this.iframe); + } + + isConnected(): boolean { + return this.iframe.isConnected; + } + + getIframe(): HTMLIFrameElement { + return this.iframe; + } +} + +export class IframeContainerManager implements ContainerManager { + readonly type = 'iframe' as const; + + async create(options: ContainerCreateOptions): Promise { + const { appId, url, contextParams, onLoad } = options; + + const iframe = document.createElement('iframe'); + iframe.id = `miniapp-iframe-${appId}`; + iframe.dataset.appId = appId; + + const iframeUrl = new URL(url, window.location.origin); + if (contextParams) { + Object.entries(contextParams).forEach(([key, value]) => { + iframeUrl.searchParams.set(key, value); + }); + } + iframe.src = iframeUrl.toString(); + + iframe.sandbox.add('allow-scripts', 'allow-forms', 'allow-same-origin'); + iframe.style.cssText = ` + width: 100%; + height: 100%; + border: none; + background: transparent; + `; + + if (onLoad) { + iframe.addEventListener('load', onLoad, { once: true }); + } + + const container = getOrCreateContainer(VISIBLE_CONTAINER_ID, false); + container.appendChild(iframe); + + return new IframeContainerHandle(iframe); + } +} + +export function cleanupAllIframeContainers(): void { + const visibleContainer = document.getElementById(VISIBLE_CONTAINER_ID); + const hiddenContainer = document.getElementById(HIDDEN_CONTAINER_ID); + + if (visibleContainer) { + visibleContainer.innerHTML = ''; + visibleContainer.remove(); + } + + if (hiddenContainer) { + hiddenContainer.innerHTML = ''; + hiddenContainer.remove(); + } +} diff --git a/src/services/miniapp-runtime/container/index.ts b/src/services/miniapp-runtime/container/index.ts new file mode 100644 index 000000000..d280e4dff --- /dev/null +++ b/src/services/miniapp-runtime/container/index.ts @@ -0,0 +1,20 @@ +import type { ContainerType, ContainerManager, ContainerHandle, ContainerCreateOptions } from './types'; +import { IframeContainerManager, cleanupAllIframeContainers } from './iframe-container'; +import { WujieContainerManager } from './wujie-container'; + +export type { ContainerType, ContainerManager, ContainerHandle, ContainerCreateOptions }; +export { cleanupAllIframeContainers }; + +const managers: Record = { + iframe: new IframeContainerManager(), + wujie: new WujieContainerManager(), +}; + +export function getContainerManager(type: ContainerType): ContainerManager { + return managers[type]; +} + +export async function createContainer(type: ContainerType, options: ContainerCreateOptions): Promise { + const manager = getContainerManager(type); + return manager.create(options); +} diff --git a/src/services/miniapp-runtime/container/types.ts b/src/services/miniapp-runtime/container/types.ts new file mode 100644 index 000000000..1c1df0eed --- /dev/null +++ b/src/services/miniapp-runtime/container/types.ts @@ -0,0 +1,22 @@ +export type ContainerType = 'iframe' | 'wujie'; + +export interface ContainerHandle { + readonly type: ContainerType; + readonly element: HTMLElement; + destroy(): void; + moveToBackground(): void; + moveToForeground(): void; + isConnected(): boolean; +} + +export interface ContainerCreateOptions { + appId: string; + url: string; + contextParams?: Record; + onLoad?: () => void; +} + +export interface ContainerManager { + readonly type: ContainerType; + create(options: ContainerCreateOptions): Promise; +} diff --git a/src/services/miniapp-runtime/container/wujie-container.ts b/src/services/miniapp-runtime/container/wujie-container.ts new file mode 100644 index 000000000..104e75bef --- /dev/null +++ b/src/services/miniapp-runtime/container/wujie-container.ts @@ -0,0 +1,86 @@ +import { startApp, destroyApp, bus } from 'wujie'; +import type { ContainerManager, ContainerHandle, ContainerCreateOptions } from './types'; + +const VISIBLE_CONTAINER_ID = 'miniapp-iframe-container'; + +function getOrCreateVisibleContainer(): HTMLElement { + let container = document.getElementById(VISIBLE_CONTAINER_ID); + if (!container) { + container = document.createElement('div'); + container.id = VISIBLE_CONTAINER_ID; + document.body.appendChild(container); + } + return container; +} + +class WujieContainerHandle implements ContainerHandle { + readonly type = 'wujie' as const; + readonly element: HTMLElement; + private destroyed = false; + + constructor( + private appId: string, + private container: HTMLElement, + ) { + this.element = container; + } + + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + destroyApp(this.appId); + this.container.remove(); + } + + moveToBackground(): void { + this.container.style.visibility = 'hidden'; + this.container.style.pointerEvents = 'none'; + } + + moveToForeground(): void { + this.container.style.visibility = 'visible'; + this.container.style.pointerEvents = 'auto'; + } + + isConnected(): boolean { + return this.container.isConnected && !this.destroyed; + } +} + +export class WujieContainerManager implements ContainerManager { + readonly type = 'wujie' as const; + + async create(options: ContainerCreateOptions): Promise { + const { appId, url, contextParams, onLoad } = options; + + const container = document.createElement('div'); + container.id = `miniapp-wujie-${appId}`; + container.style.cssText = 'width: 100%; height: 100%;'; + + const visibleContainer = getOrCreateVisibleContainer(); + visibleContainer.appendChild(container); + + const urlWithParams = new URL(url, window.location.origin); + if (contextParams) { + Object.entries(contextParams).forEach(([key, value]) => { + urlWithParams.searchParams.set(key, value); + }); + } + + await startApp({ + name: appId, + url: urlWithParams.toString(), + el: container, + alive: true, + fiber: true, + sync: false, + afterMount: () => { + onLoad?.(); + }, + }); + + return new WujieContainerHandle(appId, container); + } +} + +export { bus as wujieBus }; diff --git a/src/services/miniapp-runtime/index.ts b/src/services/miniapp-runtime/index.ts index e378ddc28..a07c0f8c0 100644 --- a/src/services/miniapp-runtime/index.ts +++ b/src/services/miniapp-runtime/index.ts @@ -50,15 +50,7 @@ import { mergeMiniappVisualConfig, type MiniappVisualConfigUpdate, } from './visual-config'; -import { - createIframe, - mountIframeVisible, - moveIframeToBackground, - moveIframeToForeground, - removeIframe, - enforceBackgroundLimit, - cleanupAllIframes, -} from './iframe-manager'; +import { createContainer, cleanupAllIframeContainers, type ContainerType, type ContainerHandle } from './container'; import type { MiniappManifest, MiniappTargetDesktop } from '../ecosystem/types'; import { getBridge } from '../ecosystem/provider'; import { toastService } from '../toast'; @@ -118,13 +110,17 @@ const initialState: MiniappRuntimeState = { function attachBioProvider(appId: string): void { const app = miniappRuntimeStore.state.apps.get(appId); - const iframe = app?.iframeRef; - if (!app || !iframe) return; + if (!app) return; + + const iframe = app.iframeRef ?? (app.containerHandle?.element as HTMLIFrameElement | undefined); + if (!iframe || !(iframe instanceof HTMLIFrameElement)) return; getBridge().attach(iframe, appId, app.manifest.name, app.manifest.permissions ?? []); } -function attachBioProviderToIframe(appId: string, iframe: HTMLIFrameElement, manifest: MiniappManifest): void { +function attachBioProviderToContainer(appId: string, handle: ContainerHandle, manifest: MiniappManifest): void { + if (handle.type !== 'iframe') return; + const iframe = handle.element as HTMLIFrameElement; getBridge().attach(iframe, appId, manifest.name, manifest.permissions ?? []); } @@ -336,8 +332,8 @@ function isElementRectReady(el: HTMLElement | null): boolean { return rect.width > 0 && rect.height > 0; } -function isIframeReady(iframe: HTMLIFrameElement | null): boolean { - return !!iframe && iframe.isConnected; +function isContainerReady(app: MiniappInstance): boolean { + return app.containerHandle?.isConnected() ?? false; } function clearSplashTimeout(appId: string): void { @@ -429,8 +425,8 @@ function failPreparing(appId: string, message: string): void { const app = miniappRuntimeStore.state.apps.get(appId); - if (app?.iframeRef) { - removeIframe(app.iframeRef); + if (app?.containerHandle) { + app.containerHandle.destroy(); } miniappRuntimeStore.setState((s) => { @@ -504,9 +500,9 @@ function startPreparing(appId: string, targetDesktop: MiniappTargetDesktop, hasS const iconReady = isElementRectReady(getIconRef(appId)); const slotReady = !!getDesktopAppSlotRect(targetDesktop, appId); - const iframeReady = isIframeReady(app.iframeRef); + const containerReady = isContainerReady(app); - if (iconReady && slotReady && iframeReady) { + if (iconReady && slotReady && containerReady) { cancelPreparing(appId); beginLaunchSequence(appId, hasSplash); return; @@ -553,9 +549,8 @@ export function finalizeCloseApp(appId: string): void { const app = s.apps.get(appId); if (!app) return s; - // 移除 iframe(在动画完成后才执行) - if (app.iframeRef) { - removeIframe(app.iframeRef); + if (app.containerHandle) { + app.containerHandle.destroy(); } const newApps = new Map(s.apps); @@ -596,7 +591,6 @@ export function launchApp( const state = miniappRuntimeStore.state; const existingApp = state.apps.get(appId); - // 如果已存在,直接激活 if (existingApp) { attachBioProvider(appId); const targetDesktop = existingApp.manifest.targetDesktop ?? 'stack'; @@ -611,8 +605,8 @@ export function launchApp( } const hasSplash = !!manifest.splashScreen; + const containerType: ContainerType = manifest.runtime ?? 'iframe'; - // 创建新实例 const instance: MiniappInstance = { appId, manifest, @@ -625,27 +619,30 @@ export function launchApp( readiness: 'notReady', launchedAt: Date.now(), lastActiveAt: Date.now(), + containerType, + containerHandle: null, iframeRef: null, iconRef: getIconRef(appId), }; - // 创建 iframe - instance.iframeRef = createIframe(appId, manifest.url, contextParams); - - // 绑定 BioProvider bridge(用于 window.bio.request + 权限弹窗) - attachBioProviderToIframe(appId, instance.iframeRef, manifest); - - // iframe load:无 splash 时作为 ready 信号;有 splash 时只记录 loaded - instance.iframeRef.addEventListener('load', () => { - updateAppProcessStatus(appId, 'loaded'); - if (!manifest.splashScreen) { - readyGateOpened(appId); + createContainer(containerType, { + appId, + url: manifest.url, + contextParams, + onLoad: () => { + updateAppProcessStatus(appId, 'loaded'); + if (!manifest.splashScreen) { + readyGateOpened(appId); + } + }, + }).then((handle) => { + instance.containerHandle = handle; + if (handle.type === 'iframe') { + instance.iframeRef = handle.element as HTMLIFrameElement; } + attachBioProviderToContainer(appId, handle, manifest); }); - // 先挂到可见容器,确保 iframe 一定进入 DOM;MiniappWindow 会再把它移动到自己的容器 - mountIframeVisible(instance.iframeRef); - const targetDesktop = manifest.targetDesktop ?? 'stack'; const presentTransition = createTransition('present', appId); @@ -677,7 +674,6 @@ export function launchApp( emit({ type: 'app:launch', appId, manifest }); - // 内核准备:等待 icon/slot/iframe 就绪后再进入 launching startPreparing(appId, targetDesktop, hasSplash); return instance; @@ -699,20 +695,16 @@ export function activateApp(appId: string): void { const app = state.apps.get(appId); if (!app) return; - // preparing 阶段只用于资源就绪检查,不允许提前进入 active if (app.state === 'preparing') return; - // 当前激活的应用切换到后台 if (state.activeAppId && state.activeAppId !== appId) { deactivateApp(state.activeAppId); } - // 如果 iframe 在后台,移到前台 - if (app.iframeRef && app.state === 'background') { - moveIframeToForeground(app.iframeRef); + if (app.containerHandle && app.state === 'background') { + app.containerHandle.moveToForeground(); } - // 更新状态 updateAppState(appId, 'active'); attachBioProvider(appId); @@ -739,24 +731,32 @@ export function deactivateApp(appId: string): void { if (app.state === 'preparing') return; - // 移动 iframe 到后台 - if (app.iframeRef) { - moveIframeToBackground(app.iframeRef); + if (app.containerHandle) { + app.containerHandle.moveToBackground(); } - // 更新状态 updateAppState(appId, 'background'); - // 检查后台数量限制 - enforceBackgroundLimit( - miniappRuntimeStore.state.apps, - miniappRuntimeStore.state.activeAppId, - state.maxBackgroundApps, - ); + enforceBackgroundLimitInternal(state.maxBackgroundApps); emit({ type: 'app:deactivate', appId }); } +function enforceBackgroundLimitInternal(maxBackground: number): void { + const state = miniappRuntimeStore.state; + const backgroundApps = Array.from(state.apps.values()) + .filter((app) => app.appId !== state.activeAppId && app.state === 'background') + .toSorted((a, b) => a.lastActiveAt - b.lastActiveAt); + + while (backgroundApps.length > maxBackground) { + const oldest = backgroundApps.shift(); + if (oldest?.containerHandle) { + oldest.containerHandle.destroy(); + oldest.containerHandle = null; + } + } +} + /** * 关闭应用 */ @@ -834,7 +834,7 @@ export function closeAllApps(): void { closeApp(appId); finalizeCloseApp(appId); }); - cleanupAllIframes(); + cleanupAllIframeContainers(); } /** diff --git a/src/services/miniapp-runtime/types.ts b/src/services/miniapp-runtime/types.ts index 2bf97023d..61b0fe2eb 100644 --- a/src/services/miniapp-runtime/types.ts +++ b/src/services/miniapp-runtime/types.ts @@ -4,40 +4,40 @@ * 小程序运行时服务的类型定义 */ -import type { MiniappManifest } from '../ecosystem/types' -import type { MiniappTargetDesktop } from '../ecosystem/types' -import type { MiniappVisualConfig } from './visual-config' +import type { MiniappManifest } from '../ecosystem/types'; +import type { MiniappTargetDesktop } from '../ecosystem/types'; +import type { MiniappVisualConfig } from './visual-config'; /** 小程序实例状态 */ -export type MiniappState = 'preparing' | 'launching' | 'splash' | 'active' | 'background' | 'closing' +export type MiniappState = 'preparing' | 'launching' | 'splash' | 'active' | 'background' | 'closing'; /** 窗口呈现状态(系统级,可见性) */ -export type MiniappPresentationState = 'hidden' | 'presenting' | 'presented' | 'dismissing' +export type MiniappPresentationState = 'hidden' | 'presenting' | 'presented' | 'dismissing'; /** 进程/内容载体状态(iframe) */ -export type MiniappProcessStatus = 'loading' | 'loaded' +export type MiniappProcessStatus = 'loading' | 'loaded'; /** 应用就绪状态(可交互门闩) */ -export type MiniappReadinessState = 'notReady' | 'ready' +export type MiniappReadinessState = 'notReady' | 'ready'; -export type MiniappTransitionKind = 'present' | 'dismiss' +export type MiniappTransitionKind = 'present' | 'dismiss'; -export type MiniappTransitionStatus = 'requested' | 'inProgress' | 'completed' | 'cancelled' +export type MiniappTransitionStatus = 'requested' | 'inProgress' | 'completed' | 'cancelled'; export interface MiniappTransition { - id: string - kind: MiniappTransitionKind - status: MiniappTransitionStatus - startedAt: number + id: string; + kind: MiniappTransitionKind; + status: MiniappTransitionStatus; + startedAt: number; } export interface MiniappPresentation { - appId: string - desktop: MiniappTargetDesktop - state: MiniappPresentationState - zOrder: number - transitionId: string | null - transitionKind: MiniappTransitionKind | null + appId: string; + desktop: MiniappTargetDesktop; + state: MiniappPresentationState; + zOrder: number; + transitionId: string | null; + transitionKind: MiniappTransitionKind | null; } /** 小程序动画流(包含方向性) */ @@ -49,96 +49,90 @@ export type MiniappFlow = | 'backgrounding' | 'backgrounded' | 'foregrounding' - | 'closing' + | 'closing'; /** 胶囊主题 */ -export type CapsuleTheme = 'auto' | 'dark' | 'light' +export type CapsuleTheme = 'auto' | 'dark' | 'light'; /** * 小程序可控设置(miniapp → KeyApp) - * + * * 通过 bio.updateContext() API 动态修改 * 修改后会触发 app:ctx-change 事件 - * + * * 注意:这与 KeyApp → miniapp 的 contextState 不同 * contextState 通过 postMessage 传递 theme/locale/env/a11y 给 miniapp */ export interface MiniappContext { - /** + /** * 胶囊按钮主题 * - 'auto': 跟随 KeyApp 主题(默认) * - 'dark': 强制深色胶囊(适合浅色背景 app) * - 'light': 强制浅色胶囊(适合深色背景 app) */ - capsuleTheme: CapsuleTheme + capsuleTheme: CapsuleTheme; } +import type { ContainerHandle, ContainerType } from './container/types'; + /** 小程序实例 */ export interface MiniappInstance { - /** 应用 ID */ - appId: string - /** 应用清单 */ - manifest: MiniappManifest - /** 当前状态 */ - state: MiniappState - /** 动画流状态(包含方向性) */ - flow: MiniappFlow - /** 运行时上下文(可动态修改) */ - ctx: MiniappContext - /** iframe 加载状态 */ - processStatus: MiniappProcessStatus - /** 是否已就绪(由 iframe load 或 splash 关闭触发) */ - readiness: MiniappReadinessState - /** 启动时间 */ - launchedAt: number - /** 最后激活时间 */ - lastActiveAt: number - /** iframe 元素引用 */ - iframeRef: HTMLIFrameElement | null - /** 图标元素引用(用于 FLIP 动画) */ - iconRef: HTMLElement | null + appId: string; + manifest: MiniappManifest; + state: MiniappState; + flow: MiniappFlow; + ctx: MiniappContext; + processStatus: MiniappProcessStatus; + readiness: MiniappReadinessState; + launchedAt: number; + lastActiveAt: number; + containerType: ContainerType; + containerHandle: ContainerHandle | null; + /** @deprecated Use containerHandle.element instead */ + iframeRef: HTMLIFrameElement | null; + iconRef: HTMLElement | null; } /** FLIP 动画帧数据 */ export interface FlipFrame { /** 元素位置和尺寸 */ - rect: DOMRect + rect: DOMRect; /** 不透明度 */ - opacity: number + opacity: number; /** 圆角 */ - borderRadius: number + borderRadius: number; } /** FLIP 动画帧组 */ export interface FlipFrames { /** 起始帧 */ - first: FlipFrame + first: FlipFrame; /** 结束帧 */ - last: FlipFrame + last: FlipFrame; /** 动画持续时间 (ms) */ - duration: number + duration: number; /** 动画曲线 */ - easing: string + easing: string; } /** 运行时状态 */ export interface MiniappRuntimeState { /** 所有运行中的应用 */ - apps: Map + apps: Map; /** miniapp 视觉配置(motion + css token) */ - visualConfig: MiniappVisualConfig + visualConfig: MiniappVisualConfig; /** 当前交互焦点的应用 ID(兼容字段,旧逻辑仍使用) */ - activeAppId: string | null + activeAppId: string | null; /** 当前交互焦点(多窗口架构下的正式字段) */ - focusedAppId: string | null + focusedAppId: string | null; /** 当前呈现中的窗口集合(多窗口) */ - presentations: Map + presentations: Map; /** z-index 递增种子 */ - zOrderSeed: number + zOrderSeed: number; /** 是否处于层叠视图 */ - isStackViewOpen: boolean + isStackViewOpen: boolean; /** 最大后台应用数 */ - maxBackgroundApps: number + maxBackgroundApps: number; } /** 运行时事件 */ @@ -150,22 +144,22 @@ export type MiniappRuntimeEvent = | { type: 'app:state-change'; appId: string; state: MiniappState } | { type: 'app:ctx-change'; appId: string; ctx: MiniappContext } | { type: 'stack-view:open' } - | { type: 'stack-view:close' } + | { type: 'stack-view:close' }; /** Slot 状态 */ -export type SlotStatus = 'ready' | 'lost' +export type SlotStatus = 'ready' | 'lost'; /** 运行时事件监听器 */ -export type MiniappRuntimeListener = (event: MiniappRuntimeEvent) => void +export type MiniappRuntimeListener = (event: MiniappRuntimeEvent) => void; /** 动画配置 */ export interface AnimationConfig { /** 启动动画持续时间 */ - launchDuration: number + launchDuration: number; /** 关闭动画持续时间 */ - closeDuration: number + closeDuration: number; /** iOS 动画曲线 */ - iosEasing: string + iosEasing: string; } /** 默认动画配置 */ @@ -173,21 +167,21 @@ export const DEFAULT_ANIMATION_CONFIG: AnimationConfig = { launchDuration: 400, closeDuration: 400, iosEasing: 'cubic-bezier(0.25, 0.1, 0.25, 1.0)', -} +}; /** 窗口位置配置 */ export interface WindowConfig { /** 窗口到屏幕边缘的边距 */ - margin: number + margin: number; /** 窗口圆角 */ - borderRadius: number + borderRadius: number; /** 安全区域 */ safeAreaInsets: { - top: number - bottom: number - left: number - right: number - } + top: number; + bottom: number; + left: number; + right: number; + }; } /** 默认窗口配置 */ @@ -195,4 +189,4 @@ export const DEFAULT_WINDOW_CONFIG: WindowConfig = { margin: 0, borderRadius: 0, safeAreaInsets: { top: 0, bottom: 0, left: 0, right: 0 }, -} +}; From aa9c5b227558ba704a24f1ad13a6cabbc475cdfc Mon Sep 17 00:00:00 2001 From: Gaubee Date: Wed, 21 Jan 2026 08:23:09 +0800 Subject: [PATCH 2/4] fix: add destroyed state guards and remove deprecated iframe-manager - Add destroyed state check to IframeContainerHandle methods - Add destroyed state check to WujieContainerHandle moveToBackground/Foreground - Remove deprecated iframe-manager.ts (replaced by container/iframe-container.ts) --- .../container/iframe-container.ts | 7 +- .../container/wujie-container.ts | 2 + .../miniapp-runtime/iframe-manager.ts | 172 ------------------ 3 files changed, 8 insertions(+), 173 deletions(-) delete mode 100644 src/services/miniapp-runtime/iframe-manager.ts diff --git a/src/services/miniapp-runtime/container/iframe-container.ts b/src/services/miniapp-runtime/container/iframe-container.ts index fff9b830e..af19ae5f9 100644 --- a/src/services/miniapp-runtime/container/iframe-container.ts +++ b/src/services/miniapp-runtime/container/iframe-container.ts @@ -28,28 +28,33 @@ function getOrCreateContainer(id: string, hidden: boolean): HTMLElement { class IframeContainerHandle implements ContainerHandle { readonly type = 'iframe' as const; readonly element: HTMLIFrameElement; + private destroyed = false; constructor(private iframe: HTMLIFrameElement) { this.element = iframe; } destroy(): void { + if (this.destroyed) return; + this.destroyed = true; this.iframe.src = 'about:blank'; this.iframe.remove(); } moveToBackground(): void { + if (this.destroyed) return; const container = getOrCreateContainer(HIDDEN_CONTAINER_ID, true); container.appendChild(this.iframe); } moveToForeground(): void { + if (this.destroyed) return; const container = getOrCreateContainer(VISIBLE_CONTAINER_ID, false); container.appendChild(this.iframe); } isConnected(): boolean { - return this.iframe.isConnected; + return this.iframe.isConnected && !this.destroyed; } getIframe(): HTMLIFrameElement { diff --git a/src/services/miniapp-runtime/container/wujie-container.ts b/src/services/miniapp-runtime/container/wujie-container.ts index 104e75bef..e27a48dc1 100644 --- a/src/services/miniapp-runtime/container/wujie-container.ts +++ b/src/services/miniapp-runtime/container/wujie-container.ts @@ -33,11 +33,13 @@ class WujieContainerHandle implements ContainerHandle { } moveToBackground(): void { + if (this.destroyed) return; this.container.style.visibility = 'hidden'; this.container.style.pointerEvents = 'none'; } moveToForeground(): void { + if (this.destroyed) return; this.container.style.visibility = 'visible'; this.container.style.pointerEvents = 'auto'; } diff --git a/src/services/miniapp-runtime/iframe-manager.ts b/src/services/miniapp-runtime/iframe-manager.ts deleted file mode 100644 index b983ad804..000000000 --- a/src/services/miniapp-runtime/iframe-manager.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Iframe Manager - * - * 管理小程序 iframe 的生命周期 - * - 前台应用:正常渲染 - * - 后台应用:最多 4 个,visibility: hidden - * - 超出限制:从 DOM 移除 - */ - -import type { MiniappInstance } from './types' - -/** 最大后台 iframe 数量 */ -const MAX_BACKGROUND_IFRAMES = 4 - -/** iframe 容器 ID */ -const IFRAME_CONTAINER_ID = 'miniapp-iframe-container' -const HIDDEN_CONTAINER_ID = 'miniapp-hidden-container' - -/** - * 获取或创建 iframe 容器 - */ -function getOrCreateContainer(id: string, hidden: boolean): HTMLElement { - let container = document.getElementById(id) - if (!container) { - container = document.createElement('div') - container.id = id - if (hidden) { - container.style.cssText = ` - position: fixed; - top: -9999px; - left: -9999px; - width: 1px; - height: 1px; - overflow: hidden; - visibility: hidden; - pointer-events: none; - ` - } - document.body.appendChild(container) - } - return container -} - -/** - * 创建小程序 iframe - */ -export function createIframe( - appId: string, - url: string, - contextParams?: Record -): HTMLIFrameElement { - const iframe = document.createElement('iframe') - iframe.id = `miniapp-iframe-${appId}` - iframe.dataset.appId = appId - - // 构建带参数的 URL - const iframeUrl = new URL(url, window.location.origin) - if (contextParams) { - Object.entries(contextParams).forEach(([key, value]) => { - iframeUrl.searchParams.set(key, value) - }) - } - iframe.src = iframeUrl.toString() - - // 安全沙箱 - iframe.sandbox.add('allow-scripts', 'allow-forms', 'allow-same-origin') - - // 样式 - iframe.style.cssText = ` - width: 100%; - height: 100%; - border: none; - background: transparent; - ` - - return iframe -} - -/** - * 将 iframe 挂载到可见容器 - */ -export function mountIframeVisible(iframe: HTMLIFrameElement): void { - const container = getOrCreateContainer(IFRAME_CONTAINER_ID, false) - container.appendChild(iframe) -} - -/** - * 将 iframe 移到隐藏容器(后台) - */ -export function moveIframeToBackground(iframe: HTMLIFrameElement): void { - const container = getOrCreateContainer(HIDDEN_CONTAINER_ID, true) - container.appendChild(iframe) -} - -/** - * 将 iframe 从隐藏容器移回可见 - */ -export function moveIframeToForeground(iframe: HTMLIFrameElement): void { - const container = getOrCreateContainer(IFRAME_CONTAINER_ID, false) - container.appendChild(iframe) -} - -/** - * 从 DOM 移除 iframe - */ -export function removeIframe(iframe: HTMLIFrameElement): void { - // 先清空 src 以停止加载 - iframe.src = 'about:blank' - iframe.remove() -} - -/** - * 根据 appId 查找 iframe - */ -export function findIframe(appId: string): HTMLIFrameElement | null { - return document.getElementById(`miniapp-iframe-${appId}`) as HTMLIFrameElement | null -} - -/** - * 管理后台 iframe 数量 - * 超出限制时移除最旧的 - */ -export function enforceBackgroundLimit( - apps: Map, - activeAppId: string | null, - maxBackground: number = MAX_BACKGROUND_IFRAMES -): string[] { - const removedAppIds: string[] = [] - - // 获取所有后台应用,按最后激活时间排序 - const backgroundApps = Array.from(apps.values()) - .filter((app) => app.appId !== activeAppId && app.state === 'background') - .toSorted((a, b) => a.lastActiveAt - b.lastActiveAt) - - // 移除超出限制的 - while (backgroundApps.length > maxBackground) { - const oldest = backgroundApps.shift() - if (oldest?.iframeRef) { - removeIframe(oldest.iframeRef) - oldest.iframeRef = null - removedAppIds.push(oldest.appId) - } - } - - return removedAppIds -} - -/** - * 清理所有 iframe - */ -export function cleanupAllIframes(): void { - const visibleContainer = document.getElementById(IFRAME_CONTAINER_ID) - const hiddenContainer = document.getElementById(HIDDEN_CONTAINER_ID) - - if (visibleContainer) { - visibleContainer.innerHTML = '' - visibleContainer.remove() - } - - if (hiddenContainer) { - hiddenContainer.innerHTML = '' - hiddenContainer.remove() - } -} - -/** - * 获取当前后台 iframe 数量 - */ -export function getBackgroundIframeCount(): number { - const container = document.getElementById(HIDDEN_CONTAINER_ID) - return container?.children.length ?? 0 -} From 1c36cd443b08ea23e3e8e8ae061c2a4576642795 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Wed, 21 Jan 2026 10:43:25 +0800 Subject: [PATCH 3/4] feat: add wujie dependency, unit tests, and dev headless mode - Add wujie package (required by wujie-container) - Add container unit tests (10 tests for IframeContainerManager) - Fix iframe.sandbox compatibility for jsdom test environment - Support headless mode in dev.ts for non-TTY environments (CI) --- package.json | 1 + pnpm-lock.yaml | 3 + scripts/dev.ts | 28 +++- .../__tests__/container.test.ts | 140 ++++++++++++++++++ .../container/iframe-container.ts | 6 +- 5 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 src/services/miniapp-runtime/__tests__/container.test.ts diff --git a/package.json b/package.json index 3933b1209..ffbffc1d8 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "tweetnacl": "^1.0.3", "vaul": "^1.1.2", "viem": "^2.43.3", + "wujie": "^1.0.29", "wujie-react": "^1.0.29", "yargs": "^18.0.0", "zod": "^4.1.13" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 624407793..cd40e289b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -194,6 +194,9 @@ importers: viem: specifier: ^2.43.3 version: 2.43.3(typescript@5.9.3)(zod@4.2.1) + wujie: + specifier: ^1.0.29 + version: 1.0.29 wujie-react: specifier: ^1.0.29 version: 1.0.29(react@19.2.3) diff --git a/scripts/dev.ts b/scripts/dev.ts index a29db1d9a..e0163dab2 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -129,13 +129,10 @@ async function cleanup() { } async function main() { - if (!process.stdin.isTTY) { - console.error('\x1b[31mError: Dev Runner requires a TTY. Run directly in terminal.\x1b[0m'); - process.exit(1); - } + const isHeadless = !process.stdin.isTTY || process.env.CI === 'true'; console.clear(); - console.log('\x1b[36mStarting Dev Runner...\x1b[0m\n'); + console.log('\x1b[36mStarting Dev Runner...\\x1b[0m\n'); for (const cmd of commands) { if (cmd.autoStart) { @@ -144,6 +141,27 @@ async function main() { } } + if (isHeadless) { + console.log('\x1b[33mRunning in headless mode (no TTY). Press Ctrl+C to stop.\x1b[0m\n'); + + processManager.onOutput = (_commandId, text) => { + process.stdout.write(text); + }; + + await new Promise((resolve) => { + process.on('SIGINT', async () => { + await cleanup(); + resolve(); + }); + process.on('SIGTERM', async () => { + await cleanup(); + resolve(); + }); + }); + + process.exit(0); + } + process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding('utf8'); diff --git a/src/services/miniapp-runtime/__tests__/container.test.ts b/src/services/miniapp-runtime/__tests__/container.test.ts new file mode 100644 index 000000000..43b16b971 --- /dev/null +++ b/src/services/miniapp-runtime/__tests__/container.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { IframeContainerManager, cleanupAllIframeContainers } from '../container/iframe-container'; +import type { ContainerCreateOptions } from '../container/types'; + +describe('IframeContainerManager', () => { + let manager: IframeContainerManager; + + beforeEach(() => { + manager = new IframeContainerManager(); + document.body.innerHTML = ''; + }); + + afterEach(() => { + cleanupAllIframeContainers(); + }); + + describe('create', () => { + it('should create an iframe container', async () => { + const options: ContainerCreateOptions = { + appId: 'test-app', + url: 'https://example.com', + }; + + const handle = await manager.create(options); + + expect(handle.type).toBe('iframe'); + expect(handle.element).toBeInstanceOf(HTMLIFrameElement); + expect(handle.element.id).toBe('miniapp-iframe-test-app'); + expect(handle.isConnected()).toBe(true); + }); + + it('should append context params to URL', async () => { + const options: ContainerCreateOptions = { + appId: 'test-app', + url: 'https://example.com', + contextParams: { foo: 'bar', baz: 'qux' }, + }; + + const handle = await manager.create(options); + const iframe = handle.element as HTMLIFrameElement; + + expect(iframe.src).toContain('foo=bar'); + expect(iframe.src).toContain('baz=qux'); + }); + + it('should call onLoad callback when iframe loads', async () => { + const onLoad = vi.fn(); + const options: ContainerCreateOptions = { + appId: 'test-app', + url: 'about:blank', + onLoad, + }; + + const handle = await manager.create(options); + const iframe = handle.element as HTMLIFrameElement; + + iframe.dispatchEvent(new Event('load')); + + expect(onLoad).toHaveBeenCalledTimes(1); + }); + + it('should only call onLoad once', async () => { + const onLoad = vi.fn(); + const options: ContainerCreateOptions = { + appId: 'test-app', + url: 'about:blank', + onLoad, + }; + + const handle = await manager.create(options); + const iframe = handle.element as HTMLIFrameElement; + + iframe.dispatchEvent(new Event('load')); + iframe.dispatchEvent(new Event('load')); + + expect(onLoad).toHaveBeenCalledTimes(1); + }); + }); + + describe('ContainerHandle', () => { + it('should destroy iframe correctly', async () => { + const handle = await manager.create({ appId: 'test-app', url: 'about:blank' }); + + expect(handle.isConnected()).toBe(true); + + handle.destroy(); + + expect(handle.isConnected()).toBe(false); + }); + + it('should not throw when destroy called multiple times', async () => { + const handle = await manager.create({ appId: 'test-app', url: 'about:blank' }); + + handle.destroy(); + expect(() => handle.destroy()).not.toThrow(); + }); + + it('should move to background', async () => { + const handle = await manager.create({ appId: 'test-app', url: 'about:blank' }); + + handle.moveToBackground(); + + const hiddenContainer = document.getElementById('miniapp-hidden-container'); + expect(hiddenContainer).not.toBeNull(); + expect(hiddenContainer?.contains(handle.element)).toBe(true); + }); + + it('should move to foreground', async () => { + const handle = await manager.create({ appId: 'test-app', url: 'about:blank' }); + + handle.moveToBackground(); + handle.moveToForeground(); + + const visibleContainer = document.getElementById('miniapp-iframe-container'); + expect(visibleContainer?.contains(handle.element)).toBe(true); + }); + + it('should not move after destroyed', async () => { + const handle = await manager.create({ appId: 'test-app', url: 'about:blank' }); + + handle.destroy(); + + expect(() => handle.moveToBackground()).not.toThrow(); + expect(() => handle.moveToForeground()).not.toThrow(); + }); + }); +}); + +describe('cleanupAllIframeContainers', () => { + it('should remove all containers from DOM', async () => { + const manager = new IframeContainerManager(); + await manager.create({ appId: 'app1', url: 'about:blank' }); + await manager.create({ appId: 'app2', url: 'about:blank' }); + + cleanupAllIframeContainers(); + + expect(document.getElementById('miniapp-iframe-container')).toBeNull(); + expect(document.getElementById('miniapp-hidden-container')).toBeNull(); + }); +}); diff --git a/src/services/miniapp-runtime/container/iframe-container.ts b/src/services/miniapp-runtime/container/iframe-container.ts index af19ae5f9..3fec3533e 100644 --- a/src/services/miniapp-runtime/container/iframe-container.ts +++ b/src/services/miniapp-runtime/container/iframe-container.ts @@ -80,7 +80,11 @@ export class IframeContainerManager implements ContainerManager { } iframe.src = iframeUrl.toString(); - iframe.sandbox.add('allow-scripts', 'allow-forms', 'allow-same-origin'); + if (iframe.sandbox?.add) { + iframe.sandbox.add('allow-scripts', 'allow-forms', 'allow-same-origin'); + } else { + iframe.setAttribute('sandbox', 'allow-scripts allow-forms allow-same-origin'); + } iframe.style.cssText = ` width: 100%; height: 100%; From bb4e8dd3822c29e9a886676baf80c0f2f24d497d Mon Sep 17 00:00:00 2001 From: Gaubee Date: Wed, 21 Jan 2026 14:46:35 +0800 Subject: [PATCH 4/4] feat(miniapp-runtime): send safeAreaInsets via postMessage to miniapps - Add sendKeyAppContext() to broadcast keyapp:context-update after container attach - Calculate capsule safe area dynamically (top margin + capsule height + padding) - Enable miniapps to receive context via window message listener - Update rwa-hub to listen for context and apply --f7-safe-area-top CSS variable - Update white-book docs with CSS guidelines and container architecture --- .../01-Container-Architecture.md | 170 +++++ .../01-Runtime-Env/02-Miniapp-Manifest.md | 93 ++- .../01-Bio-SDK-Communication.md | 246 +++++++ docs/white-book/11-DApp-Guide/README.md | 12 +- miniapps/forge/src/App.tsx | 441 ++++++------ miniapps/forge/src/index.css | 9 +- miniapps/teleport/src/App.tsx | 645 ++++++++++-------- miniapps/teleport/src/index.css | 21 +- packages/create-miniapp/src/utils/inject.ts | 325 +++++---- scripts/vite-plugin-miniapps.ts | 1 + src/components/ecosystem/miniapp-window.tsx | 15 +- .../__tests__/container.test.ts | 34 +- .../container/container.stories.tsx | 467 +++++++++++++ .../container/iframe-container.ts | 52 +- .../miniapp-runtime/container/types.ts | 2 + .../container/wujie-container.ts | 62 +- src/services/miniapp-runtime/index.ts | 47 +- vite.config.ts | 8 +- 18 files changed, 1927 insertions(+), 723 deletions(-) create mode 100644 docs/white-book/11-DApp-Guide/01-Runtime-Env/01-Container-Architecture.md create mode 100644 docs/white-book/11-DApp-Guide/02-Connectivity/01-Bio-SDK-Communication.md create mode 100644 src/services/miniapp-runtime/container/container.stories.tsx diff --git a/docs/white-book/11-DApp-Guide/01-Runtime-Env/01-Container-Architecture.md b/docs/white-book/11-DApp-Guide/01-Runtime-Env/01-Container-Architecture.md new file mode 100644 index 000000000..00c380d6f --- /dev/null +++ b/docs/white-book/11-DApp-Guide/01-Runtime-Env/01-Container-Architecture.md @@ -0,0 +1,170 @@ +# 01. Container 架构 (Container Architecture) + +> Code: `src/services/miniapp-runtime/container/` + +KeyApp 的 Miniapp 运行时采用 **Container 抽象层** 设计,支持多种沙箱隔离技术。 + +## 设计目标 + +1. **统一接口**: 无论底层使用 iframe 还是 Wujie,上层代码通过统一的 `ContainerHandle` 操作 +2. **可扩展性**: 未来可轻松添加新的容器实现(如 Web Worker、Shadow DOM) +3. **平滑迁移**: 从 iframe 模式迁移到 Wujie 模式无需修改 UI 层代码 + +## 容器类型 + +| 类型 | 实现文件 | 特点 | 适用场景 | +| -------- | --------------------- | ------------------ | ------------- | +| `iframe` | `iframe-container.ts` | 原生浏览器隔离 | 简单 H5 应用 | +| `wujie` | `wujie-container.ts` | JS 沙箱 + CSS 隔离 | 复杂 SPA 应用 | + +## 核心接口 + +### ContainerCreateOptions + +创建容器时的配置选项: + +```typescript +interface ContainerCreateOptions { + type: 'iframe' | 'wujie'; + appId: string; + url: string; + mountTarget: HTMLElement; // 容器挂载的目标元素 +} +``` + +### ContainerHandle + +容器操作句柄: + +```typescript +interface ContainerHandle { + element: HTMLElement; // 容器的 DOM 元素 + destroy: () => void; // 销毁容器 + moveToForeground: () => void; // 移到前台 + moveToBackground: () => void; // 移到后台 + getIframe: () => HTMLIFrameElement | null; // 获取 iframe(用于通讯) +} +``` + +## 工作原理 + +### 1. iframe 模式 + +直接创建 `