import React, { useState, useRef, useEffect, useSyncExternalStore, useCallback, useMemo } from 'react'; import { useOS } from './OSContext'; import { SIMULATOR_CONFIG } from './data'; import { renderAppContent, getAppManifest, getLocalizedAppName } from './data/appRegistry'; const { recentsCardWidth, recentsAppPreviewWidth, recentsCardGap, recentsTopPadding, recentsScrollContainerHeight, recentsCardHeight, recentsCardBorderRadius, recentsAppPreviewHeight, recentsOpacityDivisor, recentsBackgroundOpacity, recentsSwipeThreshold, recentsHoldDuration, zIndexRecentsCards, zIndexApp, zIndexRecentsBlur, zIndexRecentsChrome, zIndexStatusBar, zIndexGestureBar, zIndexEdgeGestures, statusBarHeight, transitionDuration, gestureBarOpacityLight, gestureBarOpacityDark, gestureProgressDivisor, homeSwipeThreshold, gestureCancelThreshold, bottomGestureHeight, gestureBarWidth, gestureBarHeight, gestureProgressScale, edgeGestureWidth, swipeThreshold, backIndicatorSize, backIndicatorOpacity, viewportWidth: fwViewportWidth, } = SIMULATOR_CONFIG.framework; import { initScrollMeta } from './scrollMeta'; import { initSimInput } from './simInput'; import { Wifi, Bluetooth, Signal, BatteryMedium, Plane } from 'lucide-react'; import { Launcher } from './launcher/Launcher'; import { useTheme } from './ThemeContext'; import { AppIcon } from './components/AppIcon'; import { MediaPickerHost } from './components/MediaPickerHost'; import { KeyboardOverlay } from './components/KeyboardOverlay'; import { TextSelectionMenu } from './components/TextSelectionMenu'; import { TextSelectionHandles } from './components/TextSelectionHandles'; import { HeadsUpNotification } from './components/HeadsUpNotification'; import { SystemShade } from './components/SystemShade'; import { PermissionDialogHost } from './components/PermissionDialog'; import { SystemErrorBoundary } from './components/SystemErrorBoundary'; import { TopEdgeShadeGestureCatcher } from './components/TopEdgeShadeGestureCatcher'; import { DeviceEffects } from './components/DeviceEffects'; import { IntentChooserSheet } from './components/IntentChooserSheet'; import { useGlobalLongPress } from './hooks/useGlobalLongPress'; import { TextSelectionService } from './TextSelectionService'; import * as TimeService from './TimeService'; import { SystemShadeService } from './SystemShadeService'; import QuickSettingsService from './QuickSettingsService'; import { useOsT } from './i18n'; import StatusBarService from './StatusBarService'; import * as SkinService from './SkinService'; import { themeToCssVars } from './utils/themeToCssVars'; import { getActiveTopActivityId, getTaskTopActivity, getTasksMRU } from './taskUtils'; import { ActivityContext } from './ActivityContext'; import { KeyboardService } from './keyboard/KeyboardService'; import { TaskManager } from './TaskManager'; import type { AppId, OSState } from './types'; const computeActivityContainerStyle = (args: { isRecentsVisible: boolean; isActive: boolean; recentsSlot?: { index: number }; shouldHide?: boolean; }): { containerStyle: React.CSSProperties; innerStyle: React.CSSProperties } => { const scale = recentsCardWidth / recentsAppPreviewWidth; const paddingLeft = 48; // Tailwind px-12 const cardStride = recentsCardWidth + recentsCardGap; const cardTop = recentsTopPadding + (recentsScrollContainerHeight - recentsCardHeight) / 2; if (args.shouldHide) { return { containerStyle: { visibility: 'hidden', pointerEvents: 'none', display: 'none', position: 'absolute', inset: 0, overflow: 'hidden', }, innerStyle: { width: '100%', height: '100%', transform: 'translateZ(0)' }, }; } if (args.isRecentsVisible && args.recentsSlot) { return { containerStyle: { position: 'fixed', top: `${cardTop}px`, left: `calc(${paddingLeft + (args.recentsSlot.index * cardStride)}px - var(--recents-scroll, 0px))`, width: `${recentsCardWidth}px`, height: `${recentsCardHeight}px`, zIndex: zIndexRecentsCards, visibility: 'visible', pointerEvents: 'none', overflow: 'hidden', borderRadius: `${recentsCardBorderRadius}px`, backgroundColor: '#fff', }, innerStyle: { width: `${recentsAppPreviewWidth}px`, height: `${recentsAppPreviewHeight}px`, transform: `scale(${scale})`, transformOrigin: 'top left', }, }; } const isVisible = args.isActive && !args.isRecentsVisible; return { containerStyle: { position: 'absolute', inset: 0, zIndex: zIndexApp, display: isVisible ? 'block' : 'none', visibility: isVisible ? 'visible' : 'hidden', pointerEvents: isVisible ? 'auto' : 'none', overflow: 'hidden', }, innerStyle: { width: '100%', height: '100%', transform: 'translateZ(0)' }, }; }; const noopSubscribe = () => () => {}; const getZeroSnapshot = () => 0; const getKeyboardHeightSnapshot = () => KeyboardService.getState().height; function syncSwipeToActivityContainer( topActivityId: string, taskId: string, offset: number, mode: 'drag' | 'resetAnimated' | 'resetImmediate' = 'drag', ): void { const activityContainerEl = document.getElementById(`activity-container-${topActivityId}`) as HTMLElement | null; const escapedTaskId = typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(taskId) : taskId; const chromeCardEl = document.querySelector( `[data-recents-card="${escapedTaskId}"]` ) as HTMLElement | null; const targets = [activityContainerEl, chromeCardEl].filter(Boolean) as HTMLElement[]; if (targets.length === 0) return; if (mode === 'resetImmediate') { targets.forEach((el) => { el.style.transform = ''; el.style.opacity = ''; el.style.transition = ''; }); return; } if (mode === 'resetAnimated') { targets.forEach((el) => { el.style.transform = ''; el.style.opacity = ''; el.style.transition = 'transform 0.2s, opacity 0.2s'; }); return; } { const opacity = String(Math.max(0, 1 - offset / recentsOpacityDivisor)); targets.forEach((el) => { el.style.transform = `translateY(${-offset}px)`; el.style.opacity = opacity; el.style.transition = 'none'; }); } } const RecentsBlur: React.FC = () => { const { state, goHome } = useOS(); if (!state.isRecentsVisible) return null; return (
); }; const RecentsChrome: React.FC = () => { const { state, launchTaskById, closeTask, goHome } = useOS(); const t = useOsT(); const scrollRef = useRef(null); const swipeStartRef = useRef<{ pid: number; y: number; taskId: string } | null>(null); const swipeOffset = useRef(0); const lastDismissTs = useRef(0); const tasksMRU = useMemo(() => getTasksMRU(state.tasks), [state.tasks]); const topActivityByTaskId = useMemo(() => { const map = new Map(); for (const task of tasksMRU) { const top = getTaskTopActivity(task); if (top) map.set(task.taskId, top.activityId); } return map; }, [tasksMRU]); const handleScroll = useCallback(() => { document.documentElement.style.setProperty( '--recents-scroll', `${scrollRef.current?.scrollLeft ?? 0}px` ); }, []); useEffect(() => { if (!state.isRecentsVisible) return; document.documentElement.style.setProperty('--recents-scroll', '0px'); handleScroll(); return () => { document.documentElement.style.removeProperty('--recents-scroll'); }; }, [state.isRecentsVisible, handleScroll]); useEffect(() => { if (!state.isRecentsVisible) return; // 确保进入/退出多任务时,不残留上滑 transform/opacity tasksMRU.forEach((task) => { const topActivityId = topActivityByTaskId.get(task.taskId); if (topActivityId) { syncSwipeToActivityContainer(topActivityId, task.taskId, 0, 'resetImmediate'); } }); return () => { tasksMRU.forEach((task) => { const topActivityId = topActivityByTaskId.get(task.taskId); if (topActivityId) { syncSwipeToActivityContainer(topActivityId, task.taskId, 0, 'resetImmediate'); } }); }; }, [state.isRecentsVisible, tasksMRU, topActivityByTaskId]); if (!state.isRecentsVisible) return null; const handleClearAll = () => { const taskIds = tasksMRU.map(task => task.taskId); taskIds.forEach(taskId => closeTask(taskId)); goHome(); }; const handleScrollContainerClick = (e: React.MouseEvent) => { if (TimeService.realNow() - lastDismissTs.current < 400) return; if (e.target === e.currentTarget) { goHome(); } }; const handleCardPointerDown = (e: React.PointerEvent, taskId: string) => { if (e.pointerType === 'mouse' && e.button !== 0) return; swipeStartRef.current = { pid: e.pointerId, y: e.clientY, taskId }; swipeOffset.current = 0; const topActivityId = topActivityByTaskId.get(taskId); if (topActivityId) syncSwipeToActivityContainer(topActivityId, taskId, 0, 'resetImmediate'); try { (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); } catch { /* ignore */ } }; const handleCardPointerMove = (e: React.PointerEvent) => { const s = swipeStartRef.current; if (!s || s.pid !== e.pointerId) return; const diff = s.y - e.clientY; if (diff <= 0) return; // 只允许向上滑动 swipeOffset.current = diff; const topActivityId = topActivityByTaskId.get(s.taskId); if (topActivityId) syncSwipeToActivityContainer(topActivityId, s.taskId, diff, 'drag'); }; const handleCardPointerEnd = (e: React.PointerEvent) => { const s = swipeStartRef.current; if (!s || s.pid !== e.pointerId) return; const offset = swipeOffset.current; const topActivityId = topActivityByTaskId.get(s.taskId); if (offset > recentsSwipeThreshold) { lastDismissTs.current = TimeService.realNow(); closeTask(s.taskId); if (tasksMRU.length === 1) { goHome(); } } else { if (topActivityId) syncSwipeToActivityContainer(topActivityId, s.taskId, 0, 'resetAnimated'); } swipeStartRef.current = null; swipeOffset.current = 0; }; return (
{tasksMRU.map((task) => { const manifest = getAppManifest(task.rootAppId); return (
handleCardPointerDown(e, task.taskId)} onPointerMove={handleCardPointerMove} onPointerUp={handleCardPointerEnd} onPointerCancel={handleCardPointerEnd} onClick={() => launchTaskById(task.taskId)} >
{manifest ? ( ) : null} {getLocalizedAppName(task.rootAppId)}
); })}
{state.tasks.length > 0 && ( )}
); }; const getLightTextFromDeclaredForeground = (foreground: string | null | undefined): boolean | null => { if (foreground === 'light') return true; if (foreground === 'dark') return false; return null; }; const getLightTextFromManifestForeground = (foreground: 'dark' | 'light' | undefined): boolean | null => { if (foreground === 'light') return true; if (foreground === 'dark') return false; return null; }; const queryLastForegroundDeclaration = ( root: ParentNode | null, attributeName: 'data-status-bar-foreground' | 'data-navigation-bar-foreground' | 'data-status-bar-hidden', ): HTMLElement | null => { if (!root) return null; if (root instanceof HTMLElement && root.hasAttribute(attributeName)) { return root; } const matches = root.querySelectorAll(`[${attributeName}]`); return matches.length > 0 ? matches[matches.length - 1] : null; }; type ChromeTaskSnapshot = { activeTopActivityId: string | null; activeRootAppId: AppId | null; isLauncherVisible: boolean; isRecentsVisible: boolean; }; const getChromeTaskSnapshot = (state: OSState): ChromeTaskSnapshot => { const activeTask = state.activeTaskId ? state.tasks.find((task) => task.taskId === state.activeTaskId) ?? null : null; return { activeTopActivityId: getActiveTopActivityId(state), activeRootAppId: activeTask?.rootAppId ?? null, isLauncherVisible: state.isLauncherVisible, isRecentsVisible: state.isRecentsVisible, }; }; const areChromeTaskSnapshotsEqual = (a: ChromeTaskSnapshot, b: ChromeTaskSnapshot): boolean => { return a.activeTopActivityId === b.activeTopActivityId && a.activeRootAppId === b.activeRootAppId && a.isLauncherVisible === b.isLauncherVisible && a.isRecentsVisible === b.isRecentsVisible; }; function useTaskManagerSelector( selector: (state: OSState) => T, isEqual: (a: T, b: T) => boolean = Object.is, ): T { const cacheRef = useRef(selector(TaskManager.getState())); const subscribe = useCallback((onStoreChange: () => void) => { return TaskManager.subscribe(() => onStoreChange()); }, []); const getSnapshot = useCallback(() => { const next = selector(TaskManager.getState()); if (isEqual(cacheRef.current, next)) return cacheRef.current; cacheRef.current = next; return next; }, [selector, isEqual]); return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } const getForegroundObserverTarget = ( activeTopActivityId: string | null, isLauncherVisible: boolean, isRecentsVisible: boolean, ): ParentNode | null => { if (isLauncherVisible && !isRecentsVisible) { return document.querySelector('[data-launcher="true"]'); } if (!activeTopActivityId) return null; return document.getElementById(`activity-container-${activeTopActivityId}`); }; const getDeclaredForeground = ( root: ParentNode | null, attributeName: 'data-status-bar-foreground' | 'data-navigation-bar-foreground', ): boolean | null => { return getLightTextFromDeclaredForeground( queryLastForegroundDeclaration(root, attributeName)?.getAttribute(attributeName), ); }; const getDeclaredHidden = ( root: ParentNode | null, attributeName: 'data-status-bar-hidden', ): boolean => { const value = queryLastForegroundDeclaration(root, attributeName)?.getAttribute(attributeName); return value === 'true'; }; const StatusBar = () => { const { activeTopActivityId, activeRootAppId, isLauncherVisible, isRecentsVisible, } = useTaskManagerSelector(getChromeTaskSnapshot, areChromeTaskSnapshotsEqual); const { themeService, version } = useTheme(); const [time, setTime] = useState(''); const [isLight, setIsLight] = useState(true); // true = white text, false = black text const [isHidden, setIsHidden] = useState(false); const [shadeOpen, setShadeOpen] = useState(SystemShadeService.getState().open); const [themedIconTick, setThemedIconTick] = useState(0); const [qs, setQs] = useState(() => QuickSettingsService.getState()); const [dyn, setDyn] = useState(() => StatusBarService.getState()); useEffect(() => { const updateTime = () => { setTime(TimeService.formatTime()); }; updateTime(); const t = setInterval(updateTime, 1000); return () => clearInterval(t); }, []); useEffect(() => { return SystemShadeService.subscribe(s => setShadeOpen(s.open)); }, []); useEffect(() => { return QuickSettingsService.subscribe(setQs); }, []); useEffect(() => { return StatusBarService.subscribe(setDyn); }, []); useEffect(() => { let cancelled = false; Promise.all([ themeService.getStatusBarIconAsync('bluetooth'), themeService.getStatusBarIconAsync('signal'), themeService.getStatusBarIconAsync('wifi'), themeService.getStatusBarIconAsync('battery'), ]) .then(() => { if (!cancelled) setThemedIconTick((x) => x + 1); }) .catch(() => { }); return () => { cancelled = true; }; }, [themeService, version]); useEffect(() => { const detectChromeState = () => { const targetElement = getForegroundObserverTarget( activeTopActivityId, isLauncherVisible, isRecentsVisible, ); setIsHidden(getDeclaredHidden(targetElement, 'data-status-bar-hidden')); const declaredLightText = getDeclaredForeground(targetElement, 'data-status-bar-foreground'); if (declaredLightText !== null) { setIsLight(declaredLightText); return; } if (isRecentsVisible) { setIsLight(true); return; } if (!isLauncherVisible && activeRootAppId) { const activeManifest = getAppManifest(activeRootAppId); const manifestLightText = getLightTextFromManifestForeground( activeManifest?.theme.colors.statusBarForeground, ); if (manifestLightText !== null) { setIsLight(manifestLightText); return; } } // 不再做 DOM 颜色采样;无声明时默认使用深色文字。 setIsLight(false); }; detectChromeState(); const targetElement = getForegroundObserverTarget( activeTopActivityId, isLauncherVisible, isRecentsVisible, ); let observer: MutationObserver | null = null; if (targetElement) { observer = new MutationObserver(() => { requestAnimationFrame(detectChromeState); }); observer.observe(targetElement, { childList: true, attributes: true, subtree: true, attributeFilter: ['data-status-bar-foreground', 'data-status-bar-hidden'], }); } return () => { observer?.disconnect(); }; }, [activeRootAppId, activeTopActivityId, isLauncherVisible, isRecentsVisible]); const textColor = isLight ? 'text-white' : 'text-black'; // Themed status bar icons are PNGs with baked-in colors (usually white). // We apply a CSS filter to re-tint them to match the current textColor: // brightness(0) → forces all pixels to black // invert(1) → flips black → white (only when we need white text) const iconTintFilter = isLight ? 'brightness(0) invert(1)' : 'brightness(0)'; if (shadeOpen || isHidden) return null; const wifiVisible = qs.wifiEnabled; const btVisible = qs.bluetoothEnabled; const airplaneMode = qs.airplaneModeEnabled; const noSim = dyn.noSim; const signalVisible = !airplaneMode && !noSim; const vpnUrl = dyn.vpn ? themeService.getStatusBarIcon('vpn') : null; const alarmUrl = dyn.alarm ? themeService.getStatusBarIcon('alarm') : null; const headsetUrl = dyn.headset ? themeService.getStatusBarIcon('headset') : null; const silentUrl = dyn.silent ? themeService.getStatusBarIcon('silent') : null; const nfcUrl = qs.nfcEnabled ? themeService.getStatusBarIcon('nfc') : null; const airplaneUrl = airplaneMode ? themeService.getStatusBarIcon('airplane') : null; const noSimUrl = !airplaneMode && noSim ? themeService.getStatusBarIcon('no_sim') || themeService.getStatusBarIcon('signal_null') : null; const wifiUrl = wifiVisible ? themeService.getStatusBarWifiIcon(dyn.wifiLevel) : null; const signalUrl = signalVisible ? themeService.getStatusBarSignalIcon(dyn.signalLevel) : null; const dataTypeVisible = signalVisible && qs.mobileDataEnabled && dyn.mobileDataType !== 'none'; const dataTypeUrl = dataTypeVisible ? themeService.getStatusBarDataTypeIcon(dyn.mobileDataType) : null; // ---- Battery rendering ---- // // Priority hierarchy (theme-first, system-fallback): // 1. saver+charging → theme `power_save_charge` variant (if any) → render RGBA // 2. charging → theme `charge` variant (if any) → render RGBA (bolt baked in) // 3. saver → theme `power_save` variant (if any) → render RGBA // 4. fallback → base sprite + system tint (mask-image), with bolt overlay // when charging, plus low-battery red when <20% & not charging. // // RGBA mode preserves theme designer's chosen color palette (which varies by // theme: AP15 ships saturated green for charging, 夜半 a darker forest green, // some themes choose teal or blue). Tint mode enforces a consistent system // visual when the theme didn't ship the matching variant. const baseSprite = themeService.getStatusBarBatterySprite(); const lowBattery = !dyn.charging && dyn.batteryPercent < 20; let themeRgbaSprite: { url: string; frames: number; frameSide: number } | null = null; let rgbaHasBakedBolt = false; if (qs.batterySaverEnabled && dyn.charging) { // Saver wins for body color. Try the most specific variant (yellow + bolt // baked); else fall back to power_save (yellow body, bolt added separately // via overlay). DO NOT fall through to `charge` here — that would render // green and lose the saver indication. themeRgbaSprite = themeService.getStatusBarBatteryVariantSprite('power_save_charge'); if (themeRgbaSprite) rgbaHasBakedBolt = true; if (!themeRgbaSprite) { themeRgbaSprite = themeService.getStatusBarBatteryVariantSprite('power_save'); } } else if (dyn.charging) { themeRgbaSprite = themeService.getStatusBarBatteryVariantSprite('charge'); if (themeRgbaSprite) rgbaHasBakedBolt = true; } else if (qs.batterySaverEnabled) { themeRgbaSprite = themeService.getStatusBarBatteryVariantSprite('power_save'); } const batterySprite = themeRgbaSprite ?? baseSprite; const batteryFallback = themeService.getStatusBarIcon('battery'); const batteryPct = Math.min(Math.max(dyn.batteryPercent, 0), 100); const batteryFrames = batterySprite?.frames ?? 0; const batteryFrameIdx = batterySprite && batteryFrames > 1 ? Math.min( Math.max(Math.floor(((batteryFrames - 1) * batteryPct) / 100), 0), batteryFrames - 1 ) : 0; // System-tint mode only — color used when we fall back to alpha-mask + bg. const systemTintColor = qs.batterySaverEnabled ? '#F5C518' // saver yellow : dyn.charging ? '#22C55E' // charging green (普通充电也变绿) : lowBattery ? '#EF4444' // low battery red : isLight ? '#FFFFFF' : '#000000'; // Charging bolt overlay: needed when charging AND the body-rendering layer // doesn't already have a bolt baked in. So: // - tint mode + charging → overlay needed // - rgba `charge`/`power_save_charge` variant → bolt baked, no overlay // - rgba `power_save` (saver+charging fallback) → overlay needed (yellow body, no bolt) const useThemeRgba = themeRgbaSprite !== null; const needsBoltOverlay = dyn.charging && !rgbaHasBakedBolt; const batteryBoltUrl = needsBoltOverlay ? themeService.getStatusBarBatteryBoltOverlay() : null; // 快充叠一层更深的内核 + 略大的 halo,让闪电在 25% 这种小填充态也清晰可见。 const batteryBoltCoreColor = dyn.fastCharging ? 'rgba(0,0,0,0.92)' : 'rgba(0,0,0,0.78)'; const batteryBoltHaloColor = systemTintColor; return (
{time}
{vpnUrl ? ( vpn ) : null} {alarmUrl ? ( alarm ) : null} {headsetUrl ? ( headset ) : null} {silentUrl ? ( silent ) : null} {nfcUrl ? ( nfc ) : null} {btVisible ? ( themeService.getStatusBarIcon('bluetooth') ? ( bluetooth ) : ( ) ) : null} {airplaneMode ? ( airplaneUrl ? ( airplane ) : ( ) ) : noSim ? ( noSimUrl ? ( no-sim ) : null ) : signalUrl ? ( signal ) : ( )} {dataTypeUrl ? ( {`data-${dyn.mobileDataType}`} ) : null} {wifiVisible ? ( wifiUrl ? ( wifi ) : ( ) ) : null} {batteryPct}% {batterySprite ? ( // Two render modes: // - useThemeRgba: render the variant sprite directly via background-image // (theme color baked into RGB). Bolt is pre-baked in `_charge` variants. // - else: alpha-mask + system tint color, plus optional bolt overlay // when charging.
{useThemeRgba ? (
) : (
)} {batteryBoltUrl ? ( <>
) : null}
) : batteryFallback ? ( battery ) : ( )}
); }; const GestureBar = () => { const { activeTopActivityId, activeRootAppId, isLauncherVisible, isRecentsVisible, } = useTaskManagerSelector(getChromeTaskSnapshot, areChromeTaskSnapshotsEqual); const startY = useRef(0); const startTime = useRef(0); const [swipeProgress, setSwipeProgress] = useState(0); const holdTimerRef = useRef(null); const [isMouseDown, setIsMouseDown] = useState(false); // 检测背景颜色:桌面/多任务 或 声明式属性(限定在当前活跃应用内查找) const getActiveActivityContainer = () => activeTopActivityId ? document.getElementById(`activity-container-${activeTopActivityId}`) : null; const getIsOnDarkBg = () => { if (isLauncherVisible || isRecentsVisible) return true; const container = getActiveActivityContainer(); const activeManifest = activeRootAppId ? getAppManifest(activeRootAppId) : undefined; // 1. 优先检查 data-navigation-bar-foreground(底部独立前景信号) const declaredNavLight = getDeclaredForeground(container, 'data-navigation-bar-foreground'); if (declaredNavLight !== null) return declaredNavLight; // 2. 优先使用 app 级 navigationBar 默认值,再回退到共享的 status bar 信号 const manifestNavLight = getLightTextFromManifestForeground( activeManifest?.theme.colors.navigationBarForeground, ); if (manifestNavLight !== null) return manifestNavLight; // 3. 再回退到 status bar 声明(顶底共享信号) const declaredLight = getDeclaredForeground(container, 'data-status-bar-foreground'); if (declaredLight !== null) return declaredLight; // 4. Final semantic fallback: reuse status bar manifest when nav is unspecified. const manifestStatusLight = getLightTextFromManifestForeground( activeManifest?.theme.colors.statusBarForeground, ); if (manifestStatusLight !== null) return manifestStatusLight; return false; }; const [isOnDarkBg, setIsOnDarkBg] = useState(getIsOnDarkBg); // 仅在关键状态变化时重新检测(延迟一帧,确保新页面 DOM 已渲染) useEffect(() => { requestAnimationFrame(() => { setIsOnDarkBg(getIsOnDarkBg()); }); }, [isLauncherVisible, isRecentsVisible, activeTopActivityId, activeRootAppId]); // 监听属性变化及子节点变化(应用内路由切换会替换子组件) useEffect(() => { if (!activeTopActivityId || isLauncherVisible || isRecentsVisible) return; const activityContainer = document.getElementById(`activity-container-${activeTopActivityId}`); if (!activityContainer) return; const redetect = () => requestAnimationFrame(() => { setIsOnDarkBg(getIsOnDarkBg()); }); const observer = new MutationObserver(redetect); observer.observe(activityContainer, { attributes: true, attributeFilter: ['data-status-bar-foreground', 'data-navigation-bar-foreground'], childList: true, subtree: true, }); return () => observer.disconnect(); }, [activeTopActivityId, isLauncherVisible, isRecentsVisible, activeRootAppId]); const barOpacity = isOnDarkBg ? gestureBarOpacityLight : gestureBarOpacityDark; const barColor = isOnDarkBg ? 'white' : 'black'; const handleTouchStart = (e: React.TouchEvent) => { startY.current = e.touches[0].clientY; startTime.current = TimeService.realNow(); setSwipeProgress(0); if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; } }; const handleTouchMove = (e: React.TouchEvent) => { const currentY = e.touches[0].clientY; const diff = startY.current - currentY; const progress = Math.min(Math.max(diff / gestureProgressDivisor, 0), 1); setSwipeProgress(progress); // 上滑悬停触发多任务 if (diff > homeSwipeThreshold && !holdTimerRef.current) { holdTimerRef.current = window.setTimeout(() => { window.__OS__?.showRecents(); setSwipeProgress(0); holdTimerRef.current = null; }, recentsHoldDuration); } if (diff < 40 && holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; } }; const handleTouchEnd = (e: React.TouchEvent) => { if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; } const endY = e.changedTouches[0].clientY; const duration = TimeService.realNow() - startTime.current; const diff = startY.current - endY; // 快速上滑返回桌面 if (diff > homeSwipeThreshold && duration < recentsHoldDuration && !isRecentsVisible) { // 已在桌面时先尝试关闭桌面层遮罩(如搜索) if (isLauncherVisible) { window.__OS__?.handleBack(); } else { window.__OS__?.goHome(); } } setSwipeProgress(0); }; // 鼠标事件处理 - 需要在 document 上监听才能捕获拖动 useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!isMouseDown || startY.current === 0) return; const currentY = e.clientY; const diff = startY.current - currentY; const progress = Math.min(Math.max(diff / gestureProgressDivisor, 0), 1); setSwipeProgress(progress); // 上滑悬停触发多任务 if (diff > homeSwipeThreshold && !holdTimerRef.current) { holdTimerRef.current = window.setTimeout(() => { window.__OS__?.showRecents(); setSwipeProgress(0); holdTimerRef.current = null; }, recentsHoldDuration); } if (diff < gestureCancelThreshold && holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; } }; const handleMouseUp = (e: MouseEvent) => { if (!isMouseDown) return; if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; } const endY = e.clientY; const duration = TimeService.realNow() - startTime.current; const diff = startY.current - endY; // 快速上滑返回桌面 if (diff > homeSwipeThreshold && duration < recentsHoldDuration && !isRecentsVisible) { // 已在桌面时先尝试关闭桌面层遮罩(如搜索) if (isLauncherVisible) { window.__OS__?.handleBack(); } else { window.__OS__?.goHome(); } } startY.current = 0; setSwipeProgress(0); setIsMouseDown(false); }; if (isMouseDown) { document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); } return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [isMouseDown, isRecentsVisible, isLauncherVisible]); const handleMouseDown = (e: React.MouseEvent) => { e.preventDefault(); startY.current = e.clientY; startTime.current = TimeService.realNow(); setIsMouseDown(true); }; // 在App内时,减小触摸区域避免遮挡TabBar const isInApp = activeTopActivityId !== null && !isLauncherVisible && !isRecentsVisible; const touchAreaHeight = isInApp ? bottomGestureHeight : bottomGestureHeight * 2; return (
); }; // 侧边返回手势 const EdgeGestures = () => { const isRecentsVisible = useTaskManagerSelector((state) => state.isRecentsVisible); const [gesture, setGesture] = useState<{ active: boolean; side: 'left' | 'right'; progress: number; y: number; }>({ active: false, side: 'left', progress: 0, y: 0 }); const EDGE_WIDTH = edgeGestureWidth; const SWIPE_THRESHOLD = swipeThreshold; const gestureRef = useRef<{ startX: number; startY: number; side: 'left' | 'right' | null }>({ startX: 0, startY: 0, side: null }); // 获取模拟器在视口中的实际边界(通过 getBoundingClientRect 直接读取,自动计入 CSS transform) const getPhoneBounds = () => { const rootEl = document.getElementById('root'); if (!rootEl) { return { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight, scale: 1 }; } const rect = rootEl.getBoundingClientRect(); return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, scale: rect.width / fwViewportWidth, }; }; // 全局统一的返回处理 - 类似安卓的 onBackPressed const handleSystemBack = () => { // Prefer the OS unified back handler if available (handles overlays like keyboard/pickers) const os = window.__OS__; if (os && typeof os.handleBack === 'function') { os.handleBack(); return; } // Fallback (should rarely happen) console.log('[System] handleSystemBack fallback'); TaskManager.goHome(); }; useEffect(() => { // Always register edge gestures (except during recents view). // This allows back-gesture to dismiss overlays like SystemShade even // when on the home screen (activeAppId === null). if (isRecentsVisible) return; // Touch 事件处理 const handleTouchStart = (e: TouchEvent) => { const touch = e.touches[0]; const { left, top: phoneTop, right, scale } = getPhoneBounds(); const edgeW = EDGE_WIDTH * scale; // 允许从手机边缘内外各 edgeW 范围起手 if (touch.clientX >= left - edgeW && touch.clientX <= left + edgeW) { const localY = (touch.clientY - phoneTop) / scale; gestureRef.current = { startX: touch.clientX, startY: touch.clientY, side: 'left' }; setGesture({ active: true, side: 'left', progress: 0, y: localY }); } else if (touch.clientX >= right - edgeW && touch.clientX <= right + edgeW) { const localY = (touch.clientY - phoneTop) / scale; gestureRef.current = { startX: touch.clientX, startY: touch.clientY, side: 'right' }; setGesture({ active: true, side: 'right', progress: 0, y: localY }); } }; const handleTouchMove = (e: TouchEvent) => { if (!gestureRef.current.side) return; const touch = e.touches[0]; const { top: phoneTop, scale } = getPhoneBounds(); const diffX = gestureRef.current.side === 'left' ? touch.clientX - gestureRef.current.startX : gestureRef.current.startX - touch.clientX; const progress = Math.min(Math.max(diffX / (SWIPE_THRESHOLD * scale), 0), 1); const localY = (touch.clientY - phoneTop) / scale; setGesture(prev => ({ ...prev, progress, y: localY })); }; const handleTouchEnd = (e: TouchEvent) => { if (!gestureRef.current.side) return; const touch = e.changedTouches[0]; const { scale } = getPhoneBounds(); const diffX = gestureRef.current.side === 'left' ? touch.clientX - gestureRef.current.startX : gestureRef.current.startX - touch.clientX; if (diffX >= SWIPE_THRESHOLD * scale) { handleSystemBack(); } gestureRef.current = { startX: 0, startY: 0, side: null }; setGesture({ active: false, side: 'left', progress: 0, y: 0 }); }; // Mouse 事件处理(与 Touch 逻辑相同) const handleMouseDown = (e: MouseEvent) => { const { left, top: phoneTop, right, scale } = getPhoneBounds(); const edgeW = EDGE_WIDTH * scale; if (e.clientX >= left - edgeW && e.clientX <= left + edgeW) { const localY = (e.clientY - phoneTop) / scale; gestureRef.current = { startX: e.clientX, startY: e.clientY, side: 'left' }; setGesture({ active: true, side: 'left', progress: 0, y: localY }); } else if (e.clientX >= right - edgeW && e.clientX <= right + edgeW) { const localY = (e.clientY - phoneTop) / scale; gestureRef.current = { startX: e.clientX, startY: e.clientY, side: 'right' }; setGesture({ active: true, side: 'right', progress: 0, y: localY }); } }; const handleMouseMove = (e: MouseEvent) => { if (!gestureRef.current.side) return; const { top: phoneTop, scale } = getPhoneBounds(); const diffX = gestureRef.current.side === 'left' ? e.clientX - gestureRef.current.startX : gestureRef.current.startX - e.clientX; const progress = Math.min(Math.max(diffX / (SWIPE_THRESHOLD * scale), 0), 1); const localY = (e.clientY - phoneTop) / scale; setGesture(prev => ({ ...prev, progress, y: localY })); }; const handleMouseUp = (e: MouseEvent) => { if (!gestureRef.current.side) return; const { scale } = getPhoneBounds(); const diffX = gestureRef.current.side === 'left' ? e.clientX - gestureRef.current.startX : gestureRef.current.startX - e.clientX; if (diffX >= SWIPE_THRESHOLD * scale) { handleSystemBack(); } gestureRef.current = { startX: 0, startY: 0, side: null }; setGesture({ active: false, side: 'left', progress: 0, y: 0 }); }; document.addEventListener('touchstart', handleTouchStart, { passive: true }); document.addEventListener('touchmove', handleTouchMove, { passive: true }); document.addEventListener('touchend', handleTouchEnd, { passive: true }); document.addEventListener('mousedown', handleMouseDown); document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('touchstart', handleTouchStart); document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('touchend', handleTouchEnd); document.removeEventListener('mousedown', handleMouseDown); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [isRecentsVisible]); if (!gesture.active) return null; const indicatorStyle = { top: gesture.y - 24, transform: `translateX(${gesture.side === 'left' ? -24 + gesture.progress * 40 : 24 - gesture.progress * 40}px) scale(${0.5 + gesture.progress * 0.5})`, opacity: gesture.progress, }; return (
); }; /** * Isolates keyboard-height subscription so that SystemShell (and all mounted * apps) do NOT re-render when the keyboard opens/closes. Only this thin * wrapper re-renders; its `children` reference stays stable because the * parent (SystemShell) didn't re-render, so React skips the subtree. */ const AdjustResizeContainer: React.FC<{ isActive: boolean; children: React.ReactNode }> = ({ isActive, children }) => { const subscribe = isActive ? KeyboardService.subscribe : noopSubscribe; const getSnapshot = isActive ? getKeyboardHeightSnapshot : getZeroSnapshot; const kbHeight = useSyncExternalStore( subscribe, getSnapshot, getZeroSnapshot, ); return (
0 ? { 'data-keyboard-active': '' } : {})} style={ isActive && kbHeight > 0 ? { width: '100%', height: `calc(100% - ${kbHeight}px)`, overflow: 'hidden' } : { width: '100%', height: '100%' } } > {children}
); }; /** * Memoized activity content — prevents app component trees from re-rendering * when SystemShell re-renders due to OS state changes (task switch, recents, * brightness, etc.). Only re-renders when the activity identity or viewport * actually changes. Also memoizes the ActivityContext value to avoid * unnecessary consumer re-renders. */ const MemoizedActivityContent = React.memo<{ activityId: string; appId: AppId; taskId: string; viewportWidth: number; }>(({ activityId, appId, taskId, viewportWidth }) => { const ctxValue = useMemo( () => ({ activityId, appId, taskId }), [activityId, appId, taskId], ); const manifest = getAppManifest(appId); const needsZoom = manifest?.designViewportWidth != null && manifest.designViewportWidth > 0 && manifest.designViewportWidth !== viewportWidth; return ( {needsZoom ? (
{renderAppContent(appId)}
) : ( renderAppContent(appId) )}
); }); export const SystemShell: React.FC = () => { const { state, intentChooser, chooseIntentActivity, cancelIntentChooser } = useOS(); const skin = useSyncExternalStore(SkinService.subscribe, SkinService.getState, SkinService.getState); const skinImageFilter = skin.imageFilter; const activeTopActivityId = getActiveTopActivityId(state); const tasksMRU = useMemo(() => getTasksMRU(state.tasks), [state.tasks]); const recentsSlotByTaskId = useMemo(() => { const map = new Map(); tasksMRU.forEach((task, index) => map.set(task.taskId, { index })); return map; }, [tasksMRU]); const allActivities = useMemo(() => ( state.tasks.flatMap(task => task.stack.map((activity, index) => ({ ...activity, taskId: task.taskId, rootAppId: task.rootAppId, isTaskTop: index === task.stack.length - 1, }))) ), [state.tasks]); const themeVarsByApp = useMemo(() => { const map: Record> = {}; for (const activity of allActivities) { if (map[activity.appId]) continue; const manifest = getAppManifest(activity.appId); const colors = manifest ? SkinService.applySkinToThemeColors(manifest.theme.colors) : null; map[activity.appId] = colors ? themeToCssVars(colors) : {}; } return map; }, [allActivities, skin]); // 初始化滚动状态观测 API useEffect(() => { initScrollMeta(); initSimInput(); }, []); // 启用全局长按检测(剪贴板菜单) useGlobalLongPress(); // Android-like: hide system selection menu on major OS UI transitions useEffect(() => { TextSelectionService.hideSelectionMenu(); }, [activeTopActivityId, state.isLauncherVisible, state.isRecentsVisible]); const displayScale: number = SIMULATOR_CONFIG.display.scale ?? 1; const viewportWidth: number = fwViewportWidth ?? 360; return (
系统出现问题
} >
{/* Screens Layer — 仅 Launcher */}
{(state.isLauncherVisible || state.isRecentsVisible) && ( )}
{/* Activity containers — 支持同一 App 在不同 Task 中多实例 */} {allActivities.map(activity => { const isActive = activity.activityId === activeTopActivityId && !state.isLauncherVisible && !state.isRecentsVisible; const recentsSlot = state.isRecentsVisible && activity.isTaskTop ? recentsSlotByTaskId.get(activity.taskId) : undefined; const shouldHide = state.isRecentsVisible && !activity.isTaskTop; const { containerStyle, innerStyle } = computeActivityContainerStyle({ isRecentsVisible: state.isRecentsVisible, isActive, recentsSlot, shouldHide, }); return (
); })} {/* Recents 三层 */}
); };