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 (