From 9a02fa63d2fdd9ed584dba9ebee447187d220c84 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 6 Feb 2026 16:40:30 +0800 Subject: [PATCH] fix(ecosystem): stabilize miniapp foreground/background state machine --- .../src/components/window-stack-manager.ts | 8 + .../src/components/window-stack.test.ts | 74 +++++++ .../src/components/window-stack.ts | 61 +++++- src/StackflowApp.tsx | 2 - .../ecosystem/miniapp-motion-flow.ts | 24 +-- .../miniapp-splash-screen.module.css | 3 +- .../ecosystem/miniapp-stack-view.module.css | 25 +-- .../ecosystem/miniapp-stack-view.test.tsx | 132 ++++++++++++ .../ecosystem/miniapp-stack-view.tsx | 138 +++++++----- .../ecosystem/miniapp-window.stories.tsx | 2 - src/components/ecosystem/miniapp-window.tsx | 48 +++-- src/i18n/locales/ar/ecosystem.json | 2 +- src/i18n/locales/en/ecosystem.json | 2 +- src/i18n/locales/zh-CN/ecosystem.json | 2 +- src/i18n/locales/zh-TW/ecosystem.json | 2 +- .../__tests__/activate-app.test.ts | 161 ++++++++++++++ .../__tests__/container.test.ts | 54 ++++- .../__tests__/runtime-state-machine.test.ts | 125 +++++++++++ .../container/iframe-container.ts | 38 ++-- .../miniapp-runtime/container/types.ts | 4 + src/services/miniapp-runtime/index.ts | 200 ++++++++++-------- .../miniapp-runtime/runtime-dom-binding.ts | 30 +++ .../miniapp-runtime/runtime-state-machine.ts | 146 +++++++++++++ src/stackflow/components/TabBar.tsx | 50 ++++- 24 files changed, 1081 insertions(+), 252 deletions(-) create mode 100644 src/components/ecosystem/miniapp-stack-view.test.tsx create mode 100644 src/services/miniapp-runtime/__tests__/activate-app.test.ts create mode 100644 src/services/miniapp-runtime/__tests__/runtime-state-machine.test.ts create mode 100644 src/services/miniapp-runtime/runtime-dom-binding.ts create mode 100644 src/services/miniapp-runtime/runtime-state-machine.ts diff --git a/packages/ecosystem-native/src/components/window-stack-manager.ts b/packages/ecosystem-native/src/components/window-stack-manager.ts index 72386ced8..b0d216b6f 100644 --- a/packages/ecosystem-native/src/components/window-stack-manager.ts +++ b/packages/ecosystem-native/src/components/window-stack-manager.ts @@ -98,6 +98,14 @@ class WindowStackManager { stack?.setSlotHidden(appId, hidden); } + /** + * Set slot interactivity. + */ + setSlotInteractive(desktop: MiniappTargetDesktop, appId: string, interactive: boolean): void { + const stack = this.stacks.get(desktop); + stack?.setSlotInteractive(appId, interactive); + } + /** * Check if a desktop's WindowStack is registered. */ diff --git a/packages/ecosystem-native/src/components/window-stack.test.ts b/packages/ecosystem-native/src/components/window-stack.test.ts index 3e3c4c845..c3a59320a 100644 --- a/packages/ecosystem-native/src/components/window-stack.test.ts +++ b/packages/ecosystem-native/src/components/window-stack.test.ts @@ -50,6 +50,8 @@ describe('WindowStack', () => { expect(slot).toBeInstanceOf(HTMLDivElement); expect(slot.dataset.appId).toBe('test-app-1'); expect(slot.dataset.desktop).toBe('mine'); + expect(slot.dataset.interactive).toBe('false'); + expect(slot.style.pointerEvents).toBe('none'); expect(stack.hasSlot('test-app-1')).toBe(true); }); @@ -81,6 +83,66 @@ describe('WindowStack', () => { expect(slot.style.display).toBe(''); }); + it('should set slot interactive state', () => { + const slot = stack.getOrCreateSlot('test-app-1'); + + stack.setSlotInteractive('test-app-1', true); + expect(slot.dataset.interactive).toBe('true'); + expect(slot.style.pointerEvents).toBe('auto'); + + stack.setSlotInteractive('test-app-1', false); + expect(slot.dataset.interactive).toBe('false'); + expect(slot.style.pointerEvents).toBe('none'); + }); + + it('should keep existing slots after component update', async () => { + const slot = stack.getOrCreateSlot('test-app-1'); + expect(stack.hasSlot('test-app-1')).toBe(true); + + // Trigger a Lit update cycle + stack.requestUpdate(); + await stack.updateComplete; + + // Slot should still exist in manager and DOM + expect(stack.hasSlot('test-app-1')).toBe(true); + expect(stack.getSlot('test-app-1')).toBe(slot); + expect(slot.isConnected).toBe(true); + }); + + it('should attach slots created before first render', async () => { + const localContainer = document.createElement('div'); + document.body.appendChild(localContainer); + + const earlyStack = document.createElement('ecosystem-window-stack') as WindowStack; + earlyStack.desktop = 'stack'; + localContainer.appendChild(earlyStack); + + const earlySlot = earlyStack.getOrCreateSlot('early-app'); + expect(earlySlot.isConnected).toBe(false); + + await earlyStack.updateComplete; + + expect(earlySlot.isConnected).toBe(true); + expect(earlyStack.getSlot('early-app')).toBe(earlySlot); + + localContainer.remove(); + }); + + it('should reattach detached slot when requesting existing slot', () => { + const slot = stack.getOrCreateSlot('test-app-1'); + expect(slot.isConnected).toBe(true); + + slot.remove(); + expect(slot.isConnected).toBe(false); + + const recovered = stack.getOrCreateSlot('test-app-1'); + + expect(recovered).toBe(slot); + expect(slot.isConnected).toBe(true); + expect(slot.parentElement?.classList.contains('stack-container')).toBe(true); + }); + + it('should get all slot ids', () => { stack.getOrCreateSlot('app-1'); stack.getOrCreateSlot('app-2'); @@ -186,4 +248,16 @@ describe('windowStackManager', () => { expect(slot.dataset.hidden).toBe('false'); expect(slot.style.display).toBe(''); }); + + it('should set slot interactive via manager', () => { + const slot = windowStackManager.getOrCreateSlot('mine', 'test-app-1'); + + windowStackManager.setSlotInteractive('mine', 'test-app-1', true); + expect(slot.dataset.interactive).toBe('true'); + expect(slot.style.pointerEvents).toBe('auto'); + + windowStackManager.setSlotInteractive('mine', 'test-app-1', false); + expect(slot.dataset.interactive).toBe('false'); + expect(slot.style.pointerEvents).toBe('none'); + }); }); diff --git a/packages/ecosystem-native/src/components/window-stack.ts b/packages/ecosystem-native/src/components/window-stack.ts index 33f43ed24..dc0c1b7e0 100644 --- a/packages/ecosystem-native/src/components/window-stack.ts +++ b/packages/ecosystem-native/src/components/window-stack.ts @@ -66,6 +66,30 @@ export class WindowStack extends LitElement { */ private containerEl: HTMLDivElement | null = null; + /** + * Resolve the latest stack container element. + */ + private resolveContainer(): HTMLDivElement | null { + if (this.containerEl && this.contains(this.containerEl)) { + return this.containerEl; + } + + this.containerEl = this.querySelector('.stack-container'); + return this.containerEl; + } + + /** + * Ensure a slot element is attached to the current container. + */ + private ensureSlotAttached(slotInfo: SlotInfo): void { + const container = this.resolveContainer(); + if (!container) return; + + if (slotInfo.element.parentElement !== container) { + container.appendChild(slotInfo.element); + } + } + /** * Callback when a slot is created */ @@ -121,15 +145,17 @@ export class WindowStack extends LitElement { getOrCreateSlot(appId: string): HTMLDivElement { const existing = this.slots.get(appId); if (existing) { + this.ensureSlotAttached(existing); return existing.element; } // Create slot element synchronously const slot = document.createElement('div'); - slot.style.cssText = 'grid-area: 1 / 1; pointer-events: auto;'; + slot.style.cssText = 'grid-area: 1 / 1; pointer-events: none;'; slot.dataset.miniappSlot = ''; slot.dataset.appId = appId; slot.dataset.desktop = this.desktop; + slot.dataset.interactive = 'false'; const slotInfo: SlotInfo = { appId, @@ -138,12 +164,7 @@ export class WindowStack extends LitElement { }; this.slots.set(appId, slotInfo); - - // Append to container - const container = this.containerEl ?? this.querySelector('.stack-container'); - if (container) { - container.appendChild(slot); - } + this.ensureSlotAttached(slotInfo); // Notify listeners this.onSlotCreated?.(slotInfo); @@ -168,7 +189,11 @@ export class WindowStack extends LitElement { * Get an existing slot (returns null if not found). */ getSlot(appId: string): HTMLDivElement | null { - return this.slots.get(appId)?.element ?? null; + const slotInfo = this.slots.get(appId); + if (!slotInfo) return null; + + this.ensureSlotAttached(slotInfo); + return slotInfo.element; } /** @@ -189,6 +214,19 @@ export class WindowStack extends LitElement { } } + /** + * Set slot interactivity. + * interactive=true => pointer-events:auto (foreground) + * interactive=false => pointer-events:none (background) + */ + setSlotInteractive(appId: string, interactive: boolean): void { + const slot = this.slots.get(appId)?.element; + if (slot) { + slot.dataset.interactive = String(interactive); + slot.style.pointerEvents = interactive ? 'auto' : 'none'; + } + } + /** * Get all slot appIds. */ @@ -218,7 +256,12 @@ export class WindowStack extends LitElement { override firstUpdated() { // Cache the container reference - this.containerEl = this.querySelector('.stack-container'); + this.containerEl = this.resolveContainer(); + + // Recover any slot created before first render completed + for (const slotInfo of this.slots.values()) { + this.ensureSlotAttached(slotInfo); + } } override render() { diff --git a/src/StackflowApp.tsx b/src/StackflowApp.tsx index eba0827c6..867f8b796 100644 --- a/src/StackflowApp.tsx +++ b/src/StackflowApp.tsx @@ -18,8 +18,6 @@ export function StackflowApp() { {/* 层叠视图 - 多应用管理 */} - {/* Fallback 容器 - 当 slot lost 时保持 MiniappWindow 挂载 */} -
{/* 开发版水印 */} diff --git a/src/components/ecosystem/miniapp-motion-flow.ts b/src/components/ecosystem/miniapp-motion-flow.ts index 086980c01..1e3ea7342 100644 --- a/src/components/ecosystem/miniapp-motion-flow.ts +++ b/src/components/ecosystem/miniapp-motion-flow.ts @@ -15,8 +15,8 @@ export type MiniappFlow = (typeof MINIAPP_FLOW)[number] export type WindowContainerVariant = 'open' | 'closed' export type VisibilityVariant = 'show' | 'hide' -/** 层级变体:top=上层可见,bottom=下层隐藏,gone=display:none */ -export type LayerVariant = 'top' | 'bottom' | 'gone' +/** 层级变体:top=上层可见,bottom=下层隐藏 */ +export type LayerVariant = 'top' | 'bottom' export const flowToWindowContainer: Record = { closed: 'closed', @@ -33,15 +33,14 @@ export const flowToWindowContainer: Record * splash-bg 层级(和 iframe 互斥,作为内容层) * - top: 在 iframe 上方,可见(启动中) * - bottom: 在 iframe 下方,隐藏(已打开) - * - gone: display:none(后台、已关闭) */ export const flowToSplashBgLayer: Record = { - closed: 'gone', + closed: 'bottom', opening: 'top', splash: 'top', opened: 'bottom', backgrounding: 'bottom', - backgrounded: 'gone', + backgrounded: 'bottom', foregrounding: 'bottom', closing: 'bottom', // 关闭时 splash-bg 不显示,只有 splash-icon 做动画 } @@ -50,15 +49,14 @@ export const flowToSplashBgLayer: Record = { * iframe 层级(和 splash-bg 互斥,作为内容层) * - top: 在 splash-bg 上方,可见(已打开) * - bottom: 在 splash-bg 下方,隐藏(启动中) - * - gone: display:none(后台、已关闭) */ export const flowToIframeLayer: Record = { - closed: 'gone', + closed: 'bottom', opening: 'bottom', splash: 'bottom', opened: 'top', backgrounding: 'top', - backgrounded: 'gone', + backgrounded: 'bottom', foregrounding: 'top', closing: 'top', // 关闭时 iframe 保持在上层,通过 opacity 淡出 } @@ -67,13 +65,13 @@ export const flowToIframeLayer: Record = { * splash-icon 层级(独立于内容层,只在 opening/closing 时出现做动画) */ export const flowToSplashIconLayer: Record = { - closed: 'gone', + closed: 'bottom', opening: 'top', splash: 'top', - opened: 'gone', - backgrounding: 'gone', - backgrounded: 'gone', - foregrounding: 'gone', + opened: 'bottom', + backgrounding: 'bottom', + backgrounded: 'bottom', + foregrounding: 'bottom', closing: 'top', } diff --git a/src/components/ecosystem/miniapp-splash-screen.module.css b/src/components/ecosystem/miniapp-splash-screen.module.css index 18c44cfb2..33be4ef61 100644 --- a/src/components/ecosystem/miniapp-splash-screen.module.css +++ b/src/components/ecosystem/miniapp-splash-screen.module.css @@ -14,7 +14,7 @@ justify-content: center; overflow: hidden; z-index: 10; - pointer-events: auto; + pointer-events: none; /* 动画状态 */ opacity: 1; @@ -23,7 +23,6 @@ .splashScreen[data-visible="false"] { opacity: 0; - pointer-events: none; } /* 光晕背景层 - 使用 CSS 变量动态设置颜色 */ diff --git a/src/components/ecosystem/miniapp-stack-view.module.css b/src/components/ecosystem/miniapp-stack-view.module.css index 7c80e1263..7f2e9bac3 100644 --- a/src/components/ecosystem/miniapp-stack-view.module.css +++ b/src/components/ecosystem/miniapp-stack-view.module.css @@ -63,30 +63,13 @@ font-weight: 500; } -/* Swiper 区域 */ -.swiperWrapper { +/* Item 列表区域 */ +.listWrapper { position: relative; z-index: 1; flex: 1; - display: flex; - align-items: center; - justify-content: center; - padding: 20px; -} - -.swiper { - width: 100%; - max-width: 320px; - height: 100%; - max-height: 480px; -} - -.slide { - display: flex; - align-items: center; - justify-content: center; - border-radius: 16px; - overflow: hidden; + min-height: 0; + padding-top: 8px; } /* 操作提示 */ diff --git a/src/components/ecosystem/miniapp-stack-view.test.tsx b/src/components/ecosystem/miniapp-stack-view.test.tsx new file mode 100644 index 000000000..4a6bb6ebb --- /dev/null +++ b/src/components/ecosystem/miniapp-stack-view.test.tsx @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MiniappStackView } from './miniapp-stack-view' +import { TestI18nProvider } from '@/test/i18n-mock' + +type MockMiniappInstance = { + appId: string + manifest: { name: string; icon: string; description: string } + lastActiveAt: number +} + +type MockRuntimeState = { + apps: Map + focusedAppId: string | null +} + +const { + activateAppMock, + closeAppMock, + closeStackViewMock, + mockRuntimeState, + mockRuntimeStore, + emitStoreChange, +} = vi.hoisted(() => { + const state: MockRuntimeState = { + apps: new Map(), + focusedAppId: null, + } + const listeners = new Set<() => void>() + + return { + activateAppMock: vi.fn(), + closeAppMock: vi.fn(), + closeStackViewMock: vi.fn(), + mockRuntimeState: state, + mockRuntimeStore: { + get state() { + return state + }, + subscribe(listener: () => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + }, + emitStoreChange: () => { + listeners.forEach((listener) => listener()) + }, + } +}) + +vi.mock('@/services/miniapp-runtime', () => ({ + miniappRuntimeStore: mockRuntimeStore, + miniappRuntimeSelectors: { + getApps: (state: MockRuntimeState) => Array.from(state.apps.values()), + getFocusedAppId: (state: MockRuntimeState) => state.focusedAppId, + }, + activateApp: (...args: unknown[]) => activateAppMock(...args), + closeApp: (...args: unknown[]) => closeAppMock(...args), + closeStackView: (...args: unknown[]) => closeStackViewMock(...args), +})) + +function renderWithProviders(ui: React.ReactElement) { + return render({ui}) +} + +describe('MiniappStackView', () => { + beforeEach(() => { + mockRuntimeState.apps = new Map([ + [ + 'app-a', + { + appId: 'app-a', + manifest: { + name: 'App A', + icon: 'https://example.com/a.png', + description: 'A desc', + }, + lastActiveAt: 10, + }, + ], + [ + 'app-b', + { + appId: 'app-b', + manifest: { + name: 'App B', + icon: 'https://example.com/b.png', + description: 'B desc', + }, + lastActiveAt: 20, + }, + ], + ]) + mockRuntimeState.focusedAppId = 'app-a' + emitStoreChange() + + activateAppMock.mockReset() + closeAppMock.mockReset() + closeStackViewMock.mockReset() + }) + + it('renders list items sorted by lastActiveAt desc', () => { + renderWithProviders() + + const items = screen.getAllByTestId(/stack-item-/) + expect(items).toHaveLength(2) + expect(items[0]).toHaveAttribute('data-app-id', 'app-b') + expect(items[1]).toHaveAttribute('data-app-id', 'app-a') + }) + + it('activates app and closes stack view when item clicked', async () => { + const user = userEvent.setup() + renderWithProviders() + + await user.click(screen.getByTestId('stack-item-app-b')) + + expect(activateAppMock).toHaveBeenCalledWith('app-b') + expect(closeStackViewMock).toHaveBeenCalledTimes(1) + }) + + it('closes single app when close button clicked', async () => { + const user = userEvent.setup() + renderWithProviders() + + await user.click(screen.getByTestId('stack-close-app-a')) + + expect(closeAppMock).toHaveBeenCalledWith('app-a') + expect(activateAppMock).not.toHaveBeenCalled() + expect(closeStackViewMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/ecosystem/miniapp-stack-view.tsx b/src/components/ecosystem/miniapp-stack-view.tsx index e934133b7..dff048c6a 100644 --- a/src/components/ecosystem/miniapp-stack-view.tsx +++ b/src/components/ecosystem/miniapp-stack-view.tsx @@ -1,20 +1,24 @@ /** * MiniappStackView - 层叠视图容器 * - * 显示所有运行中的应用卡片 - * 使用 Swiper 实现左右滑动切换 - * 支持上滑关闭应用 + * 显示所有运行中的应用列表 + * 采用类似 iOS 通知中心的一条条 item */ -import { useCallback, useRef, useEffect } from 'react' -import { Swiper, SwiperSlide } from 'swiper/react' -import { EffectCards } from 'swiper/modules' -import type { Swiper as SwiperType } from 'swiper' -import 'swiper/css' -import 'swiper/css/effect-cards' +import { useCallback, useEffect, useMemo } from 'react' +import { IconX } from '@tabler/icons-react' import { useStore } from '@tanstack/react-store' import { cn } from '@/lib/utils' import { useTranslation } from 'react-i18next' +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemGroup, + ItemMedia, + ItemTitle, +} from '@/components/ui/item' import { miniappRuntimeStore, miniappRuntimeSelectors, @@ -23,7 +27,7 @@ import { closeStackView, } from '@/services/miniapp-runtime' import type { MiniappInstance } from '@/services/miniapp-runtime' -import { MiniappStackCard } from './miniapp-stack-card' +import { MiniappIcon } from './miniapp-icon' import styles from './miniapp-stack-view.module.css' export interface MiniappStackViewProps { @@ -40,12 +44,15 @@ export function MiniappStackView({ onClose, className, }: MiniappStackViewProps) { - const { t } = useTranslation('ecosystem') - const swiperRef = useRef(null) + const { t } = useTranslation(['ecosystem', 'common']) // 获取所有运行中的应用 const apps = useStore(miniappRuntimeStore, miniappRuntimeSelectors.getApps) as MiniappInstance[] - const activeAppId = useStore(miniappRuntimeStore, (s) => s.activeAppId) + const focusedAppId = useStore(miniappRuntimeStore, miniappRuntimeSelectors.getFocusedAppId) + + const sortedApps = useMemo(() => { + return [...apps].sort((left, right) => right.lastActiveAt - left.lastActiveAt) + }, [apps]) // 当应用列表变化时,如果没有应用了就关闭视图 useEffect(() => { @@ -62,30 +69,15 @@ export function MiniappStackView({ onClose?.() }, [onClose]) - // 处理上滑关闭 - const handleSwipeUp = useCallback((appId: string) => { + // 处理关闭应用 + const handleCloseApp = useCallback((appId: string) => { closeApp(appId) - - // 如果关闭后还有其他应用,停留在层叠视图 - // 如果没有应用了,useEffect 会自动关闭视图 }, []) - // 处理滑动切换 - const handleSlideChange = useCallback((swiper: SwiperType) => { - const currentApp = apps[swiper.activeIndex] - if (currentApp) { - // 只更新选中状态,不激活应用 - // activateApp 会在点击卡片时调用 - } - }, [apps]) - if (!visible || apps.length === 0) { return null } - // 找到当前激活应用的索引 - const initialIndex = apps.findIndex((app) => app.appId === activeAppId) - return (
{apps.length}
- {/* 卡片滑动区域 */} -
- = 0 ? initialIndex : 0} - onSwiper={(swiper) => { swiperRef.current = swiper }} - onSlideChange={handleSlideChange} - cardsEffect={{ - slideShadows: false, - perSlideOffset: 8, - perSlideRotate: 2, - }} - className={styles.swiper} - > - {apps.map((app, index) => ( - - handleCardTap(app.appId)} - onSwipeUp={() => handleSwipeUp(app.appId)} - /> - - ))} - + {/* Item 列表区域 */} +
+ + {sortedApps.map((app) => { + const isFocused = app.appId === focusedAppId + + return ( + handleCardTap(app.appId)} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) { + return + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + handleCardTap(app.appId) + } + }} + className={cn( + 'w-full items-center gap-3 border border-white/10 bg-black/35 text-white backdrop-blur-md', + isFocused && 'border-primary/60 ring-primary/35 ring-1' + )} + > + + + + + + {app.manifest.name} + {app.manifest.description} + + + + {isFocused && } + + + + ) + })} +
{/* 操作提示 */} diff --git a/src/components/ecosystem/miniapp-window.stories.tsx b/src/components/ecosystem/miniapp-window.stories.tsx index 8f4bb66e9..e9aa4199c 100644 --- a/src/components/ecosystem/miniapp-window.stories.tsx +++ b/src/components/ecosystem/miniapp-window.stories.tsx @@ -392,8 +392,6 @@ export const TabSwitchVisibility: Story = { {/* MiniappWindow 在全局层,不随 Tab 切换卸载(与 StackflowApp 一致) */} - {/* Fallback 容器 - 当 slot lost 时保持 MiniappWindow 挂载 */} -
{/* 真实 TabBar */} diff --git a/src/components/ecosystem/miniapp-window.tsx b/src/components/ecosystem/miniapp-window.tsx index 978db607b..79ce17e98 100644 --- a/src/components/ecosystem/miniapp-window.tsx +++ b/src/components/ecosystem/miniapp-window.tsx @@ -49,25 +49,39 @@ export interface MiniappWindowProps { } const WINDOW_CONTAINER_VARIANTS = { - open: { opacity: 1, pointerEvents: 'auto' }, - closed: { opacity: 0, pointerEvents: 'none' }, + open: { opacity: 1 }, + closed: { opacity: 0 }, } as const; const VISIBILITY_VARIANTS = { - show: { opacity: 1, pointerEvents: 'auto' }, - hide: { opacity: 0, pointerEvents: 'none' }, + show: { opacity: 1 }, + hide: { opacity: 0 }, } as const; /** * 层级变体:z-index 和 opacity 联动 - * top: 上层可见,bottom: 下层隐藏,gone: display:none + * top: 上层可见,bottom: 下层隐藏 */ const LAYER_VARIANTS = { top: { zIndex: 10, opacity: 1, pointerEvents: 'auto' }, bottom: { zIndex: 0, opacity: 0, pointerEvents: 'none' }, - gone: { zIndex: 0, opacity: 0, display: 'none', pointerEvents: 'none' }, }; +type WindowDomBindingState = { + visible: boolean; + interactive: boolean; +}; + +function domBindDeriveWindowState( + appState: MiniappInstance['state'] | undefined, + isFocused: boolean, + isSlotLost: boolean, +): WindowDomBindingState { + const visible = !isSlotLost && appState !== 'background' && appState !== 'preparing'; + const interactive = appState === 'active' && isFocused && !isSlotLost; + return { visible, interactive }; +} + export function MiniappWindow({ className }: MiniappWindowProps) { const presentations = useStore(miniappRuntimeStore, miniappRuntimeSelectors.getPresentations); const focusedAppId = useStore(miniappRuntimeStore, miniappRuntimeSelectors.getFocusedAppId); @@ -122,12 +136,7 @@ function MiniappWindowPortal({ // 订阅 slot 状态,响应式感知 slot 注册/注销 const slotStatus = useSlotStatus(presentation.desktop, appId); const portalHost = getDesktopAppSlotRef(presentation.desktop, appId); - - // 当 slot lost 时使用 fallback 容器,保持组件挂载避免 motion 动画问题 - const fallbackHost = useMemo(() => { - return document.getElementById('miniapp-fallback-portal') ?? document.body; - }, []); - const actualHost = portalHost ?? fallbackHost; + const actualHost = portalHost; const isSlotLost = slotStatus === 'lost' || !portalHost; useLayoutEffect(() => { @@ -185,6 +194,7 @@ function MiniappWindowPortal({ const splashIconHasLayoutId = flowToSplashIconLayoutId[flow as MiniappFlow]; const capsuleVariant = flowToCapsule[flow as MiniappFlow]; const isTransitioning = flow === 'opening' || flow === 'closing'; + const windowDomState = domBindDeriveWindowState(presentApp?.state, isFocused, isSlotLost); const appDisplay = useMemo(() => { return { @@ -211,10 +221,12 @@ function MiniappWindowPortal({ container.appendChild(element); } + containerHandle.setMountTarget?.(container); + if (element.style.display === 'none') { element.style.display = ''; } - }, [presentApp?.containerHandle, presentApp?.appId, portalHost]); + }, [presentApp?.containerHandle, presentApp?.appId, presentApp?.state, portalHost]); const handleClose = useCallback(() => { requestDismiss(appId); @@ -228,6 +240,10 @@ function MiniappWindowPortal({ // Safari 优化:当 sharedLayout 被禁用时也禁用 layoutId const enableLayoutId = !isSlotLost && isAnimationEnabled('sharedLayout'); + if (!actualHost) { + return null; + } + const node = ( ; + moveToForeground: ReturnType; +}; + +function createMockContainerHandle(appId: string): MockContainerHandle { + const iframe = document.createElement('iframe'); + iframe.dataset.appId = appId; + + const moveToBackground = vi.fn(); + const moveToForeground = vi.fn(); + + return { + type: 'iframe', + element: iframe, + destroy: vi.fn(), + moveToBackground, + moveToForeground, + setMountTarget: vi.fn(), + isConnected: vi.fn(() => true), + getIframe: vi.fn(() => null), + }; +} + +function createAppInstance(params: { + appId: string; + appState: MiniappState; + flow: MiniappFlow; + lastActiveAt: number; + containerHandle: ContainerHandle; +}): MiniappInstance { + return { + appId: params.appId, + manifest: { + id: params.appId, + name: params.appId, + description: `${params.appId} description`, + icon: `/icons/${params.appId}.png`, + url: `https://example.com/${params.appId}`, + version: '1.0.0', + targetDesktop: 'mine', + }, + state: params.appState, + flow: params.flow, + ctx: { + capsuleTheme: 'auto', + }, + processStatus: 'loaded', + readiness: 'ready', + launchedAt: 1, + lastActiveAt: params.lastActiveAt, + containerType: 'iframe', + containerHandle: params.containerHandle, + iframeRef: null, + iconRef: null, + }; +} + +describe('miniapp-runtime activateApp', () => { + const initialState = cloneRuntimeState(miniappRuntimeStore.state); + let appAHandle: MockContainerHandle; + let appBHandle: MockContainerHandle; + + afterEach(() => { + miniappRuntimeStore.setState(() => cloneRuntimeState(initialState)); + }); + + beforeEach(() => { + appAHandle = createMockContainerHandle('app-a'); + appBHandle = createMockContainerHandle('app-b'); + + const appA = createAppInstance({ + appId: 'app-a', + appState: 'background', + flow: 'backgrounded', + lastActiveAt: 1, + containerHandle: appAHandle, + }); + const appB = createAppInstance({ + appId: 'app-b', + appState: 'active', + flow: 'opened', + lastActiveAt: 2, + containerHandle: appBHandle, + }); + + miniappRuntimeStore.setState(() => ({ + apps: new Map([ + [appA.appId, appA], + [appB.appId, appB], + ]), + visualConfig: DEFAULT_MINIAPP_VISUAL_CONFIG, + activeAppId: appB.appId, + focusedAppId: appB.appId, + presentations: new Map([ + [ + appA.appId, + { + appId: appA.appId, + desktop: 'mine', + state: 'presented', + zOrder: 2, + transitionId: null, + transitionKind: null, + }, + ], + [ + appB.appId, + { + appId: appB.appId, + desktop: 'mine', + state: 'presented', + zOrder: 3, + transitionId: null, + transitionKind: null, + }, + ], + ]), + zOrderSeed: 3, + isStackViewOpen: true, + maxBackgroundApps: 4, + })); + }); + + it('should enforce single foreground app and promote zOrder when activating background app', () => { + activateApp('app-a'); + + const state = miniappRuntimeStore.state; + const appA = state.apps.get('app-a'); + const appB = state.apps.get('app-b'); + + expect(appA?.state).toBe('active'); + expect(appA?.flow).toBe('foregrounding'); + expect(appB?.state).toBe('background'); + expect(appB?.flow).toBe('backgrounding'); + + expect(state.activeAppId).toBe('app-a'); + expect(state.focusedAppId).toBe('app-a'); + + expect(state.presentations.get('app-a')?.zOrder).toBe(4); + expect(state.presentations.get('app-b')?.zOrder).toBe(3); + expect(state.zOrderSeed).toBe(4); + + expect(appAHandle.moveToForeground).toHaveBeenCalledTimes(1); + expect(appBHandle.moveToBackground).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/services/miniapp-runtime/__tests__/container.test.ts b/src/services/miniapp-runtime/__tests__/container.test.ts index 8530a9778..cc1262e89 100644 --- a/src/services/miniapp-runtime/__tests__/container.test.ts +++ b/src/services/miniapp-runtime/__tests__/container.test.ts @@ -123,9 +123,9 @@ describe('IframeContainerManager', () => { handle.moveToBackground(); - const hiddenContainer = document.getElementById('miniapp-hidden-container'); - expect(hiddenContainer).not.toBeNull(); - expect(hiddenContainer?.contains(handle.element)).toBe(true); + expect(mountTarget.contains(handle.element)).toBe(true); + expect((handle.element as HTMLIFrameElement).style.opacity).toBe('0'); + expect((handle.element as HTMLIFrameElement).style.pointerEvents).toBe('none'); }); it('should move to foreground', async () => { @@ -135,6 +135,50 @@ describe('IframeContainerManager', () => { handle.moveToForeground(); expect(mountTarget.contains(handle.element)).toBe(true); + expect((handle.element as HTMLIFrameElement).style.opacity).toBe('1'); + expect((handle.element as HTMLIFrameElement).style.pointerEvents).toBe('auto'); + }); + + it('should not re-parent connected iframe after target changed', async () => { + const handle = await manager.create({ appId: 'test-app', url: 'about:blank', mountTarget }); + const latestTarget = document.createElement('div'); + latestTarget.id = 'latest-target'; + document.body.appendChild(latestTarget); + + handle.moveToBackground(); + ;( + handle as typeof handle & { + setMountTarget: (target: HTMLElement) => void; + } + ).setMountTarget(latestTarget); + handle.moveToForeground(); + + expect(mountTarget.contains(handle.element)).toBe(true); + expect(latestTarget.contains(handle.element)).toBe(false); + expect((handle.element as HTMLIFrameElement).style.opacity).toBe('1'); + expect((handle.element as HTMLIFrameElement).style.pointerEvents).toBe('auto'); + }); + + it('should recover disconnected iframe to latest mount target', async () => { + const handle = await manager.create({ appId: 'test-app', url: 'about:blank', mountTarget }); + const latestTarget = document.createElement('div'); + latestTarget.id = 'latest-target'; + document.body.appendChild(latestTarget); + + ;( + handle as typeof handle & { + setMountTarget: (target: HTMLElement) => void; + } + ).setMountTarget(latestTarget); + + handle.element.remove(); + expect(handle.element.isConnected).toBe(false); + + handle.moveToForeground(); + + expect(latestTarget.contains(handle.element)).toBe(true); + expect((handle.element as HTMLIFrameElement).style.opacity).toBe('1'); + expect((handle.element as HTMLIFrameElement).style.pointerEvents).toBe('auto'); }); it('should not move after destroyed', async () => { @@ -149,7 +193,7 @@ describe('IframeContainerManager', () => { }); describe('cleanupAllIframeContainers', () => { - it('should remove hidden container from DOM', async () => { + it('should be safe when hidden container does not exist', async () => { const manager = new IframeContainerManager(); const mountTarget = document.createElement('div'); document.body.appendChild(mountTarget); @@ -162,5 +206,7 @@ describe('cleanupAllIframeContainers', () => { cleanupAllIframeContainers(); expect(document.getElementById('miniapp-hidden-container')).toBeNull(); + expect(mountTarget.contains(handle1.element)).toBe(true); + expect(mountTarget.contains(handle2.element)).toBe(true); }); }); diff --git a/src/services/miniapp-runtime/__tests__/runtime-state-machine.test.ts b/src/services/miniapp-runtime/__tests__/runtime-state-machine.test.ts new file mode 100644 index 000000000..0e08e4d3a --- /dev/null +++ b/src/services/miniapp-runtime/__tests__/runtime-state-machine.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_MINIAPP_VISUAL_CONFIG } from '../visual-config'; +import { + fsmDeriveFlow, + fsmPartitionForegroundAppIds, + fsmTransitionAppState, + fsmTransitionProcessStatus, + fsmTransitionReadiness, +} from '../runtime-state-machine'; +import type { MiniappRuntimeState, MiniappInstance, MiniappState } from '../types'; + +function createApp(appState: MiniappState): MiniappInstance { + return { + appId: 'demo-app', + manifest: { + id: 'demo-app', + name: 'Demo App', + description: 'Demo', + icon: '/demo.png', + url: 'https://example.com', + version: '1.0.0', + targetDesktop: 'mine', + }, + state: appState, + flow: 'closed', + ctx: { capsuleTheme: 'auto' }, + processStatus: 'loading', + readiness: 'notReady', + launchedAt: 0, + lastActiveAt: 0, + containerType: 'iframe', + containerHandle: null, + iframeRef: null, + iconRef: null, + }; +} + +function createRuntimeState(appState: MiniappState): MiniappRuntimeState { + const app = createApp(appState); + return { + apps: new Map([[app.appId, app]]), + visualConfig: DEFAULT_MINIAPP_VISUAL_CONFIG, + activeAppId: null, + focusedAppId: null, + presentations: new Map([ + [ + app.appId, + { + appId: app.appId, + desktop: 'mine', + state: 'presented', + zOrder: 1, + transitionId: null, + transitionKind: null, + }, + ], + ]), + zOrderSeed: 1, + isStackViewOpen: false, + maxBackgroundApps: 4, + }; +} + +describe('runtime-state-machine', () => { + it('fsmDeriveFlow should map active -> background to backgrounding', () => { + expect(fsmDeriveFlow('active', 'background')).toBe('backgrounding'); + }); + + it('fsmTransitionAppState should return dom command and updated flow', () => { + const runtimeState = createRuntimeState('background'); + + const transition = fsmTransitionAppState(runtimeState, 'demo-app', 'active'); + + expect(transition.changed).toBe(true); + expect(transition.domCommand).toEqual({ + type: 'slot:interactive', + desktop: 'mine', + appId: 'demo-app', + interactive: true, + }); + + const updatedApp = transition.nextState.apps.get('demo-app'); + expect(updatedApp?.state).toBe('active'); + expect(updatedApp?.flow).toBe('foregrounding'); + }); + + it('fsmTransitionProcessStatus and fsmTransitionReadiness should be pure and idempotent', () => { + const runtimeState = createRuntimeState('launching'); + + const nextStatusState = fsmTransitionProcessStatus(runtimeState, 'demo-app', 'loaded'); + expect(nextStatusState).not.toBe(runtimeState); + expect(nextStatusState.apps.get('demo-app')?.processStatus).toBe('loaded'); + + const idempotentStatusState = fsmTransitionProcessStatus(nextStatusState, 'demo-app', 'loaded'); + expect(idempotentStatusState).toBe(nextStatusState); + + const nextReadyState = fsmTransitionReadiness(nextStatusState, 'demo-app', 'ready'); + expect(nextReadyState).not.toBe(nextStatusState); + expect(nextReadyState.apps.get('demo-app')?.readiness).toBe('ready'); + + const idempotentReadyState = fsmTransitionReadiness(nextReadyState, 'demo-app', 'ready'); + expect(idempotentReadyState).toBe(nextReadyState); + }); + + it('fsmTransitionAppState should return unchanged state when app missing', () => { + const runtimeState = createRuntimeState('active'); + + const transition = fsmTransitionAppState(runtimeState, 'missing-app', 'background'); + + expect(transition.changed).toBe(false); + expect(transition.nextState).toBe(runtimeState); + expect(transition.domCommand).toBeNull(); + }); + + + it('fsmPartitionForegroundAppIds should split by intersection and difference', () => { + const allAppIds = ['app-a', 'app-b', 'app-c']; + const targetForegroundIds = ['app-b']; + + const result = fsmPartitionForegroundAppIds(allAppIds, targetForegroundIds); + + expect(result.foregroundAppIds).toEqual(['app-b']); + expect(result.backgroundAppIds).toEqual(['app-a', 'app-c']); + }); +}); diff --git a/src/services/miniapp-runtime/container/iframe-container.ts b/src/services/miniapp-runtime/container/iframe-container.ts index 472e8b792..0e6fe137a 100644 --- a/src/services/miniapp-runtime/container/iframe-container.ts +++ b/src/services/miniapp-runtime/container/iframe-container.ts @@ -2,26 +2,6 @@ import type { ContainerManager, ContainerHandle, ContainerCreateOptions } from ' const HIDDEN_CONTAINER_ID = 'miniapp-hidden-container'; -function getOrCreateHiddenContainer(): HTMLElement { - let container = document.getElementById(HIDDEN_CONTAINER_ID); - if (!container) { - container = document.createElement('div'); - container.id = HIDDEN_CONTAINER_ID; - 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; @@ -34,6 +14,10 @@ class IframeContainerHandle implements ContainerHandle { this.element = iframe; } + setMountTarget(nextMountTarget: HTMLElement): void { + this.mountTarget = nextMountTarget; + } + destroy(): void { if (this.destroyed) return; this.destroyed = true; @@ -43,13 +27,19 @@ class IframeContainerHandle implements ContainerHandle { moveToBackground(): void { if (this.destroyed) return; - const container = getOrCreateHiddenContainer(); - container.appendChild(this.iframe); + // 关键约束:后台运行时不能移出 DOM(避免 iframe 被浏览器释放) + this.iframe.style.opacity = '0'; + this.iframe.style.pointerEvents = 'none'; } moveToForeground(): void { if (this.destroyed) return; - this.mountTarget.appendChild(this.iframe); + // 前台恢复只在「已脱离文档」时补挂,避免反复 re-parent 导致 iframe 重载 + if (!this.iframe.isConnected) { + this.mountTarget.appendChild(this.iframe); + } + this.iframe.style.opacity = '1'; + this.iframe.style.pointerEvents = 'auto'; } isConnected(): boolean { @@ -96,6 +86,8 @@ export class IframeContainerManager implements ContainerManager { height: 100%; border: none; background: transparent; + opacity: 1; + pointer-events: auto; `; if (onLoad) { diff --git a/src/services/miniapp-runtime/container/types.ts b/src/services/miniapp-runtime/container/types.ts index 00535c3e3..fe3f4cfce 100644 --- a/src/services/miniapp-runtime/container/types.ts +++ b/src/services/miniapp-runtime/container/types.ts @@ -8,6 +8,10 @@ export interface ContainerHandle { destroy(): void; moveToBackground(): void; moveToForeground(): void; + /** + * 更新挂载目标(可选,仅在支持重新挂载的容器中实现) + */ + setMountTarget?(mountTarget: HTMLElement): void; isConnected(): boolean; getIframe(): HTMLIFrameElement | null; } diff --git a/src/services/miniapp-runtime/index.ts b/src/services/miniapp-runtime/index.ts index 37698551e..57972ec17 100644 --- a/src/services/miniapp-runtime/index.ts +++ b/src/services/miniapp-runtime/index.ts @@ -65,6 +65,14 @@ import { toastService } from '../toast'; import { getDesktopAppSlotRect, getIconRef } from './runtime-refs'; import i18n from '@/i18n'; import { windowStackManager } from '@biochain/ecosystem-native'; +import { + fsmPartitionForegroundAppIds, + fsmTransitionAppState, + fsmTransitionProcessStatus, + fsmTransitionReadiness, + type RuntimeStateDomCommand, +} from './runtime-state-machine'; +import { domBindApplyCommand, domBindSyncIframeMountTarget } from './runtime-dom-binding'; import { isDwebEnvironment } from '@/lib/crypto/secure-storage'; import type { MiniappContext as MiniappContextPayload } from '@biochain/bio-sdk'; @@ -453,81 +461,30 @@ function getSplashTimeout(manifest: MiniappManifest): number | null { } /** - * 根据状态变化推导 flow(包含方向性) - */ -function deriveFlow(prevState: MiniappState | null, nextState: MiniappState): MiniappFlow { - // 从无到有:opening - if (!prevState || prevState === 'preparing') { - if (nextState === 'launching') return 'opening'; - if (nextState === 'splash') return 'splash'; - if (nextState === 'active') return 'opened'; - } - - // launching -> splash - if (prevState === 'launching' && nextState === 'splash') return 'splash'; - - // launching/splash -> active - if ((prevState === 'launching' || prevState === 'splash') && nextState === 'active') return 'opened'; - - // active -> background - if (prevState === 'active' && nextState === 'background') return 'backgrounding'; - - // background -> active - if (prevState === 'background' && nextState === 'active') return 'foregrounding'; - - // any -> closing - if (nextState === 'closing') return 'closing'; - - // 兜底:根据 nextState 推导稳定态 - if (nextState === 'preparing') return 'closed'; - if (nextState === 'launching') return 'opening'; - if (nextState === 'splash') return 'splash'; - if (nextState === 'active') return 'opened'; - if (nextState === 'background') return 'backgrounded'; - - return 'closed'; -} - -/** - * 更新应用状态 + * 更新应用状态(状态机层 + DOM 绑定层) */ function updateAppState(appId: string, state: MiniappState): void { - miniappRuntimeStore.setState((s) => { - const app = s.apps.get(appId); - if (!app) return s; - - const flow = deriveFlow(app.state, state); - const newApps = new Map(s.apps); - newApps.set(appId, { ...app, state, flow }); + let domCommand: RuntimeStateDomCommand | null = null; - return { ...s, apps: newApps }; + miniappRuntimeStore.setState((currentState) => { + const transition = fsmTransitionAppState(currentState, appId, state); + domCommand = transition.domCommand; + return transition.nextState; }); + if (domCommand) { + domBindApplyCommand(domCommand); + } + emit({ type: 'app:state-change', appId, state }); } function updateAppProcessStatus(appId: string, processStatus: MiniappProcessStatus): void { - miniappRuntimeStore.setState((s) => { - const app = s.apps.get(appId); - if (!app) return s; - if (app.processStatus === processStatus) return s; - - const newApps = new Map(s.apps); - newApps.set(appId, { ...app, processStatus }); - return { ...s, apps: newApps }; - }); + miniappRuntimeStore.setState((currentState) => fsmTransitionProcessStatus(currentState, appId, processStatus)); } function updateAppReadiness(appId: string, readiness: MiniappReadinessState): void { - miniappRuntimeStore.setState((s) => { - const app = s.apps.get(appId); - if (!app) return s; - if (app.readiness === readiness) return s; - - const newApps = new Map(s.apps); - newApps.set(appId, { ...app, readiness }); - return { ...s, apps: newApps }; - }); + miniappRuntimeStore.setState((currentState) => fsmTransitionReadiness(currentState, appId, readiness)); } function readyGateOpened(appId: string): void { @@ -692,6 +649,9 @@ export function didDismiss(appId: string, transitionId: string): void { if (!presentation) return; if (presentation.transitionKind !== 'dismiss' || presentation.transitionId !== transitionId) return; + const app = miniappRuntimeStore.state.apps.get(appId); + if (!app || app.state !== 'closing') return; + finalizeCloseApp(appId); } @@ -820,16 +780,28 @@ function clearCloseTimeout(appId: string): void { closeTimeouts.delete(appId); } +function syncIframeMountTarget(appId: string): void { + const state = miniappRuntimeStore.state; + const app = state.apps.get(appId); + if (!app) return; + + domBindSyncIframeMountTarget(app, state.presentations.get(appId)); +} + export function finalizeCloseApp(appId: string): void { clearLaunchTimeouts(appId); clearCloseTimeout(appId); clearSplashTimeout(appId); cancelPreparing(appId); - // Get the app's desktop before removing from store + // 根因防护:仅允许 closing 状态进入真正的 finalize + // 避免误触导致 slot/iframe 被移除(例如后台切换场景) const app = miniappRuntimeStore.state.apps.get(appId); + if (!app || app.state !== 'closing') return; + + // Get the app's desktop before removing from store const presentation = miniappRuntimeStore.state.presentations.get(appId); - const targetDesktop = presentation?.desktop ?? app?.manifest.targetDesktop ?? 'stack'; + const targetDesktop = presentation?.desktop ?? app.manifest.targetDesktop ?? 'stack'; miniappRuntimeStore.setState((s) => { const currentApp = s.apps.get(appId); @@ -1061,12 +1033,29 @@ export function activateApp(appId: string): void { if (app.state === 'preparing') return; - if (state.activeAppId && state.activeAppId !== appId) { - deactivateApp(state.activeAppId); - } + const runningAppIds = Array.from(state.apps.values()) + .filter((instance) => instance.state !== 'preparing' && instance.state !== 'closing') + .map((instance) => instance.appId); + + const { foregroundAppIds, backgroundAppIds } = fsmPartitionForegroundAppIds(runningAppIds, [appId]); + if (!foregroundAppIds.includes(appId)) return; + + backgroundAppIds.forEach((backgroundAppId) => { + const backgroundApp = miniappRuntimeStore.state.apps.get(backgroundAppId); + if (!backgroundApp) return; + if (backgroundApp.state !== 'active') return; + + backgroundApp.containerHandle?.moveToBackground(); + updateAppState(backgroundAppId, 'background'); + emit({ type: 'app:deactivate', appId: backgroundAppId }); + }); + + const nextApp = miniappRuntimeStore.state.apps.get(appId); + if (!nextApp) return; - if (app.containerHandle && app.state === 'background') { - app.containerHandle.moveToForeground(); + if (nextApp.containerHandle && nextApp.state === 'background') { + syncIframeMountTarget(appId); + nextApp.containerHandle.moveToForeground(); } updateAppState(appId, 'active'); @@ -1074,12 +1063,28 @@ export function activateApp(appId: string): void { attachBioProvider(appId); miniappRuntimeStore.setState((s) => { + const nextZ = s.zOrderSeed + 1; + const newApps = new Map(s.apps); const existingApp = newApps.get(appId); if (existingApp) { newApps.set(appId, { ...existingApp, lastActiveAt: Date.now() }); } - return { ...s, apps: newApps, activeAppId: appId, focusedAppId: appId }; + + const newPresentations = new Map(s.presentations); + const presentation = newPresentations.get(appId); + if (presentation) { + newPresentations.set(appId, { ...presentation, zOrder: nextZ }); + } + + return { + ...s, + apps: newApps, + presentations: newPresentations, + activeAppId: appId, + focusedAppId: appId, + zOrderSeed: presentation ? nextZ : s.zOrderSeed, + }; }); emit({ type: 'app:activate', appId }); @@ -1089,38 +1094,18 @@ export function activateApp(appId: string): void { * 将应用切换到后台 */ export function deactivateApp(appId: string): void { - const state = miniappRuntimeStore.state; - const app = state.apps.get(appId); + const app = miniappRuntimeStore.state.apps.get(appId); if (!app) return; if (app.state === 'preparing') return; - if (app.containerHandle) { - app.containerHandle.moveToBackground(); - } + app.containerHandle?.moveToBackground(); updateAppState(appId, 'background'); - 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; - } - } -} - /** * 关闭应用 */ @@ -1201,6 +1186,35 @@ export function closeAllApps(): void { cleanupAllIframeContainers(); } +/** + * 最小化所有应用(保留后台运行,不执行关闭) + */ +export function minimizeAllApps(): void { + const state = miniappRuntimeStore.state; + + const foregroundAppIds = new Set(); + if (state.activeAppId) { + foregroundAppIds.add(state.activeAppId); + } + if (state.focusedAppId) { + foregroundAppIds.add(state.focusedAppId); + } + + foregroundAppIds.forEach((appId) => { + deactivateApp(appId); + // 最小化是直接回桌面,不需要停留在 backgrounding 过渡态 + // 否则可能出现 iframe/胶囊短暂残留 + settleFlow(appId); + }); + + miniappRuntimeStore.setState((s) => { + if (!s.activeAppId && !s.focusedAppId) { + return s; + } + return { ...s, activeAppId: null, focusedAppId: null }; + }); +} + /** * 打开层叠视图 */ diff --git a/src/services/miniapp-runtime/runtime-dom-binding.ts b/src/services/miniapp-runtime/runtime-dom-binding.ts new file mode 100644 index 000000000..2150cb47b --- /dev/null +++ b/src/services/miniapp-runtime/runtime-dom-binding.ts @@ -0,0 +1,30 @@ +import { windowStackManager } from '@biochain/ecosystem-native'; +import type { MiniappPresentation, MiniappInstance } from './types'; +import type { RuntimeStateDomCommand } from './runtime-state-machine'; + +/** + * DOM 绑定层:执行由状态机输出的命令 + */ +export function domBindApplyCommand(command: RuntimeStateDomCommand): void { + if (command.type === 'slot:interactive') { + if (!windowStackManager.isStackRegistered(command.desktop)) return; + windowStackManager.setSlotInteractive(command.desktop, command.appId, command.interactive); + } +} + +/** + * DOM 绑定层:同步 iframe 的挂载目标(不直接触发状态变更) + */ +export function domBindSyncIframeMountTarget( + app: MiniappInstance, + presentation: MiniappPresentation | null | undefined, +): void { + const handle = app.containerHandle; + if (!handle || handle.type !== 'iframe') return; + + const desktop = presentation?.desktop ?? app.manifest.targetDesktop ?? 'stack'; + if (!windowStackManager.isStackRegistered(desktop)) return; + + const latestSlot = windowStackManager.getSlot(desktop, app.appId) ?? windowStackManager.getOrCreateSlot(desktop, app.appId); + handle.setMountTarget?.(latestSlot); +} diff --git a/src/services/miniapp-runtime/runtime-state-machine.ts b/src/services/miniapp-runtime/runtime-state-machine.ts new file mode 100644 index 000000000..5213e63a4 --- /dev/null +++ b/src/services/miniapp-runtime/runtime-state-machine.ts @@ -0,0 +1,146 @@ +import type { MiniappTargetDesktop } from '../ecosystem/types'; +import type { + MiniappFlow, + MiniappProcessStatus, + MiniappReadinessState, + MiniappRuntimeState, + MiniappState, +} from './types'; + +export type RuntimeStateDomCommand = { + type: 'slot:interactive'; + desktop: MiniappTargetDesktop; + appId: string; + interactive: boolean; +}; + +export type RuntimeStateTransitionResult = { + nextState: MiniappRuntimeState; + changed: boolean; + domCommand: RuntimeStateDomCommand | null; +}; + +/** + * 根据状态变化推导 flow(包含方向性) + */ +export function fsmDeriveFlow(prevState: MiniappState | null, nextState: MiniappState): MiniappFlow { + if (!prevState || prevState === 'preparing') { + if (nextState === 'launching') return 'opening'; + if (nextState === 'splash') return 'splash'; + if (nextState === 'active') return 'opened'; + } + + if (prevState === 'launching' && nextState === 'splash') return 'splash'; + + if ((prevState === 'launching' || prevState === 'splash') && nextState === 'active') return 'opened'; + + if (prevState === 'active' && nextState === 'background') return 'backgrounding'; + + if (prevState === 'background' && nextState === 'active') return 'foregrounding'; + + if (nextState === 'closing') return 'closing'; + + if (nextState === 'preparing') return 'closed'; + if (nextState === 'launching') return 'opening'; + if (nextState === 'splash') return 'splash'; + if (nextState === 'active') return 'opened'; + if (nextState === 'background') return 'backgrounded'; + + return 'closed'; +} + +function fsmResolveDesktop(state: MiniappRuntimeState, appId: string): MiniappTargetDesktop { + const presentation = state.presentations.get(appId); + const app = state.apps.get(appId); + return presentation?.desktop ?? app?.manifest.targetDesktop ?? 'stack'; +} + +/** + * 纯状态机:推进 app.state/flow,并输出 DOM 绑定命令(不直接操作 DOM) + */ +export function fsmTransitionAppState( + state: MiniappRuntimeState, + appId: string, + nextAppState: MiniappState, +): RuntimeStateTransitionResult { + const app = state.apps.get(appId); + if (!app) { + return { nextState: state, changed: false, domCommand: null }; + } + + const nextFlow = fsmDeriveFlow(app.state, nextAppState); + const nextApps = new Map(state.apps); + nextApps.set(appId, { ...app, state: nextAppState, flow: nextFlow }); + + const nextState: MiniappRuntimeState = { ...state, apps: nextApps }; + const domCommand: RuntimeStateDomCommand = { + type: 'slot:interactive', + desktop: fsmResolveDesktop(state, appId), + appId, + interactive: nextAppState === 'active', + }; + + return { nextState, changed: true, domCommand }; +} + +/** + * 纯状态机:更新 processStatus + */ +export function fsmTransitionProcessStatus( + state: MiniappRuntimeState, + appId: string, + processStatus: MiniappProcessStatus, +): MiniappRuntimeState { + const app = state.apps.get(appId); + if (!app) return state; + if (app.processStatus === processStatus) return state; + + const nextApps = new Map(state.apps); + nextApps.set(appId, { ...app, processStatus }); + return { ...state, apps: nextApps }; +} + +/** + * 纯状态机:更新 readiness + */ +export function fsmTransitionReadiness( + state: MiniappRuntimeState, + appId: string, + readiness: MiniappReadinessState, +): MiniappRuntimeState { + const app = state.apps.get(appId); + if (!app) return state; + if (app.readiness === readiness) return state; + + const nextApps = new Map(state.apps); + nextApps.set(appId, { ...app, readiness }); + return { ...state, apps: nextApps }; +} + + + + +/** + * 纯状态机:将 allAppIds 按目标前台集合拆分为交集/差集 + */ +export function fsmPartitionForegroundAppIds( + allAppIds: readonly string[], + targetForegroundAppIds: readonly string[], +): { + foregroundAppIds: string[]; + backgroundAppIds: string[]; +} { + const targetSet = new Set(targetForegroundAppIds); + const foregroundAppIds: string[] = []; + const backgroundAppIds: string[] = []; + + allAppIds.forEach((appId) => { + if (targetSet.has(appId)) { + foregroundAppIds.push(appId); + } else { + backgroundAppIds.push(appId); + } + }); + + return { foregroundAppIds, backgroundAppIds }; +} diff --git a/src/stackflow/components/TabBar.tsx b/src/stackflow/components/TabBar.tsx index ef239b79f..22df475b8 100644 --- a/src/stackflow/components/TabBar.tsx +++ b/src/stackflow/components/TabBar.tsx @@ -4,6 +4,7 @@ import { IconWallet, IconSettings, IconApps, + IconCircleDot, IconBrandMiniprogram, IconAppWindowFilled, type Icon, @@ -15,7 +16,13 @@ import { useTranslation } from 'react-i18next'; import { useStore } from '@tanstack/react-store'; import { useSwiperMember } from '@/components/common/swiper-sync-context'; import { ecosystemStore, type EcosystemSubPage } from '@/stores/ecosystem'; -import { miniappRuntimeStore, miniappRuntimeSelectors, openStackView } from '@/services/miniapp-runtime'; +import { + miniappRuntimeStore, + miniappRuntimeSelectors, + openStackView, + closeStackView, + minimizeAllApps, +} from '@/services/miniapp-runtime'; import { usePendingTransactions } from '@/hooks/use-pending-transactions'; import { useCurrentWallet, useCurrentChainAddress, useSelectedChain } from '@/stores'; import { HomeButtonWrapper } from '@biochain/ecosystem-native/react'; @@ -31,7 +38,11 @@ const PAGE_ICONS: Record = { }; /** 生态页面指示器(真实项目使用) - 使用 Controller 模块实现双向绑定 */ -function useEcosystemIndicator(availablePages: EcosystemSubPage[], isActive: boolean) { +function useEcosystemIndicator( + availablePages: EcosystemSubPage[], + isActive: boolean, + hasForegroundRunningApp: boolean, +) { const pageCount = availablePages.length; const maxIndex = pageCount - 1; @@ -80,7 +91,7 @@ function useEcosystemIndicator(availablePages: EcosystemSubPage[], isActive: boo resistanceRatio={0.5} > {availablePages.map((page, index) => { - const PageIcon = PAGE_ICONS[page]; + const PageIcon = page === 'mine' && hasForegroundRunningApp ? IconCircleDot : PAGE_ICONS[page]; const opacity = getIconOpacity(index); return ( @@ -137,6 +148,13 @@ export function TabBar({ activeTab, onTabChange, className }: TabBarProps) { const storeAvailablePages = useStore(ecosystemStore, (s) => s.availableSubPages); const hasRunningApps = useStore(miniappRuntimeStore, (s) => miniappRuntimeSelectors.getApps(s).length > 0); const hasRunningStackApps = useStore(miniappRuntimeStore, miniappRuntimeSelectors.hasRunningStackApps); + const hasForegroundRunningApp = useStore(miniappRuntimeStore, (s) => { + const focusedId = s.focusedAppId ?? s.activeAppId; + if (!focusedId) return false; + + const focusedApp = s.apps.get(focusedId); + return focusedApp?.state === 'active'; + }); // Pending transactions count for wallet tab badge const currentWallet = useCurrentWallet(); @@ -158,7 +176,7 @@ export function TabBar({ activeTab, onTabChange, className }: TabBarProps) { return hasRunningStackApps ? ECOSYSTEM_PAGE_ORDER : ECOSYSTEM_PAGE_ORDER.filter((p) => p !== 'stack'); }, [storeAvailablePages, hasRunningStackApps]); - const ecosystemIndicator = useEcosystemIndicator(availablePages, isEcosystemActive); + const ecosystemIndicator = useEcosystemIndicator(availablePages, isEcosystemActive, hasForegroundRunningApp); // 生态 tab 图标: // - 在"应用堆栈"页或有运行中应用时:IconAppWindowFilled @@ -168,11 +186,14 @@ export function TabBar({ activeTab, onTabChange, className }: TabBarProps) { if (ecosystemSubPage === 'stack' || hasRunningApps) { return IconAppWindowFilled; } + if (ecosystemSubPage === 'mine' && hasForegroundRunningApp) { + return IconCircleDot; + } if (ecosystemSubPage === 'mine') { return IconBrandMiniprogram; } return IconApps; - }, [ecosystemSubPage, hasRunningApps]); + }, [ecosystemSubPage, hasRunningApps, hasForegroundRunningApp]); const tabConfigs: Tab[] = useMemo( () => [ @@ -188,6 +209,23 @@ export function TabBar({ activeTab, onTabChange, className }: TabBarProps) { openStackView(); }, []); + // Handle click on ecosystem tab: + // - inactive -> switch to ecosystem + // - active + has running apps -> minimize all (show desktop) + const handleEcosystemClick = useCallback(() => { + if (!isEcosystemActive) { + onTabChange('ecosystem'); + return; + } + + if (!hasRunningApps) { + return; + } + + minimizeAllApps(); + closeStackView(); + }, [isEcosystemActive, onTabChange, hasRunningApps]); + return (