import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme, Drawer, Button, } from "antd" import { HomeOutlined, SettingOutlined, UserOutlined, HistoryOutlined, SyncOutlined, AppstoreAddOutlined, ApartmentOutlined, UnorderedListOutlined, FileTextOutlined, MoreOutlined, UpOutlined, DownOutlined, HolderOutlined, CrownOutlined, } from "@ant-design/icons" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { HashRouter, useLocation, useNavigate, Routes, Route } from "react-router-dom" import { useTranslation } from "react-i18next" import { Sidebar } from "./components/Sidebar" import { ContentArea } from "./components/ContentArea" import { OOBE } from "./components/OOBE/OOBE" import { OAuthLogin } from "./components/OAuth/OAuthLogin" import { OAuthCallback } from "./components/OAuth/OAuthCallback" import { ThemeProvider, useTheme } from "./contexts/ThemeContext" import { MOBILE_NAV_ITEMS, MobileNavKey, sanitizeMobileNavKeys } from "./shared/mobileNavigation" import { resolveStoredFontFamily } from "./shared/fontFamily" import { getPluginRuntime } from "./plugins/runtime" const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key) const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice( 0, 4 ) const applyGlobalFontFamily = (fontValue?: string) => { const fontFamily = resolveStoredFontFamily(fontValue) document.documentElement.style.setProperty("--ss-font-family", fontFamily) document.body.style.fontFamily = fontFamily } const mapOAuthPermissionToAppPermission = ( oauthPermission: number ): "admin" | "points" | "view" => { if (oauthPermission >= 18) return "admin" if (oauthPermission >= 1) return "points" return "view" } function MainContent(): React.JSX.Element { const { t } = useTranslation() const navigate = useNavigate() const location = useLocation() const { currentTheme } = useTheme() const [messageApi, contextHolder] = message.useMessage() const { isIosDevice, isAndroidDevice, defaultPortraitMode } = useMemo(getMobileDeviceInfo, []) const [immersiveMode, setImmersiveMode] = useState(false) const normalizeStoredBottomKeys = (raw: unknown): MobileNavKey[] => { if (Array.isArray(raw)) { return sanitizeMobileNavKeys(raw, []).slice(0, 4) } return DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS } useEffect(() => { const api = (window as any).api if (!api) return let disposed = false let unlisten: (() => void) | null = null api .onNavigate((route: string) => { const currentPath = location.pathname === "/" ? "/home" : location.pathname const targetPath = route === "/" ? "/home" : route if (immersiveMode && !targetPath.startsWith("/home")) { navigate("/", { replace: true }) return } if (currentPath !== targetPath) { navigate(route) } }) .then((fn: () => void) => { if (disposed) { fn() return } unlisten = fn }) .catch(() => void 0) return () => { disposed = true if (unlisten) unlisten() } }, [navigate, location.pathname, immersiveMode]) const [wizardVisible, setWizardVisible] = useState(false) const [permission, setPermission] = useState<"admin" | "points" | "view">("view") const [hasAnyPassword, setHasAnyPassword] = useState(false) const [authVisible, setAuthVisible] = useState(false) const [authPassword, setAuthPassword] = useState("") const [authLoading, setAuthLoading] = useState(false) const [oauthVisible, setOAuthVisible] = useState(false) const [oauthUserName, setOAuthUserName] = useState(null) const [mobileBottomNavItems, setMobileBottomNavItems] = useState( DEFAULT_MOBILE_BOTTOM_NAV_ITEMS ) const [moreNavVisible, setMoreNavVisible] = useState(false) const [editingNav, setEditingNav] = useState(false) const [editingBottomNavKeys, setEditingBottomNavKeys] = useState([]) const [editingMoreNavKeys, setEditingMoreNavKeys] = useState([]) const [draggingNavKey, setDraggingNavKey] = useState(null) const [draggingFromList, setDraggingFromList] = useState<"bottom" | "more" | null>(null) const [dragOverSlot, setDragOverSlot] = useState<{ list: "bottom" | "more" index: number } | null>(null) const [isPortraitMode] = useState(defaultPortraitMode) const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultPortraitMode) const [floatingSidebarExpanded, setFloatingSidebarExpanded] = useState(false) const [syncConflictVisible, setSyncConflictVisible] = useState(false) const [syncConflicts, setSyncConflicts] = useState< Array<{ table: string; key: string; local_summary: string; remote_summary: string }> >([]) const [syncApplyLoading, setSyncApplyLoading] = useState(false) const syncCheckingRef = useRef(false) const syncApplyLoadingRef = useRef(false) const lastLocalMutationAtRef = useRef(0) const pluginRuntimeRef = useRef(getPluginRuntime()) const refreshPermissionFromAuth = useCallback(async () => { const api = (window as any).api if (!api) return const authRes = await api.authGetStatus() if (authRes?.success && authRes.data) { const anyPwd = Boolean(authRes.data.hasAdminPassword || authRes.data.hasPointsPassword) setHasAnyPassword(anyPwd) setPermission(authRes.data.permission) setAuthVisible(anyPwd && authRes.data.permission === "view") } }, []) const activeMenu = useMemo(() => { const p = location.pathname if (p === "/" || p.startsWith("/home")) return "home" if (p.startsWith("/students")) return "students" if (p.startsWith("/score")) return "score" if (p.startsWith("/boards")) return "boards" if (p.startsWith("/leaderboard")) return "leaderboard" if (p.startsWith("/settlements")) return "settlements" if (p.startsWith("/reasons")) return "reasons" if (p.startsWith("/auto-score")) return "auto-score" if (p.startsWith("/reward-settings")) return "reward-settings" if (p.startsWith("/plugins")) return "plugins" if (p.startsWith("/settings")) return "settings" return "home" }, [location.pathname]) useEffect(() => { const runtime = pluginRuntimeRef.current runtime.start().catch((error) => { console.error("Failed to start plugin runtime:", error) }) const handlePluginsUpdated = () => { runtime.reload().catch((error) => { console.error("Failed to reload plugin runtime:", error) }) } window.addEventListener("ss:plugins-updated", handlePluginsUpdated) return () => { window.removeEventListener("ss:plugins-updated", handlePluginsUpdated) runtime.stop().catch((error) => { console.error("Failed to stop plugin runtime:", error) }) } }, []) useEffect(() => { const normalizedPath = location.pathname === "/" ? "/home" : location.pathname window.dispatchEvent(new CustomEvent("ss:route-changed", { detail: { path: normalizedPath } })) }, [location.pathname]) useEffect(() => { const checkWizard = async () => { if (!(window as any).api) return const res = await (window as any).api.getAllSettings() if (res.success && res.data && !res.data.is_wizard_completed) { setWizardVisible(true) } } checkWizard() }, []) useEffect(() => { const loadAuthAndSettings = async () => { const api = (window as any).api if (!api) return const [authRes, oauthRes, settingsRes] = await Promise.all([ api.authGetStatus(), api.oauthLoadLoginState(), api.getAllSettings(), ]) const oauthPermission = oauthRes?.success && oauthRes.data ? mapOAuthPermissionToAppPermission(oauthRes.data.permission) : null if (oauthRes?.success && oauthRes.data?.name) { setOAuthUserName(String(oauthRes.data.name)) } else { setOAuthUserName(null) } if (authRes?.success && authRes.data) { const anyPwd = Boolean(authRes.data.hasAdminPassword || authRes.data.hasPointsPassword) setHasAnyPassword(anyPwd) const finalPermission = oauthPermission ?? authRes.data.permission setPermission(finalPermission) setAuthVisible(anyPwd && finalPermission === "view") } else if (oauthPermission) { setPermission(oauthPermission) setAuthVisible(false) } if (settingsRes?.success && settingsRes.data) { setMobileBottomNavItems(normalizeStoredBottomKeys(settingsRes.data.mobile_bottom_nav_items)) applyGlobalFontFamily(settingsRes.data.font_family) } } loadAuthAndSettings() }, []) useEffect(() => { const handleOAuthUserUpdated = (event: Event) => { const customEvent = event as CustomEvent<{ user?: { name?: string } | null }> const userName = customEvent?.detail?.user?.name if (typeof userName === "string" && userName.trim()) { setOAuthUserName(userName) } else { setOAuthUserName(null) void refreshPermissionFromAuth() } } window.addEventListener("ss:oauth-user-updated", handleOAuthUserUpdated as EventListener) return () => { window.removeEventListener("ss:oauth-user-updated", handleOAuthUserUpdated as EventListener) } }, [refreshPermissionFromAuth]) useEffect(() => { const api = (window as any).api if (!api || typeof api.onSettingChanged !== "function") return let disposed = false let unlisten: (() => void) | null = null api .onSettingChanged((change: { key?: string; value?: unknown }) => { if (change?.key === "mobile_bottom_nav_items") { setMobileBottomNavItems(normalizeStoredBottomKeys(change.value)) return } if (change?.key === "font_family") { applyGlobalFontFamily(String(change.value || "system")) } }) .then((fn: () => void) => { if (disposed) { fn() return } unlisten = fn }) .catch(() => void 0) return () => { disposed = true if (unlisten) unlisten() } }, []) useEffect(() => { const api = (window as any).api if (!api || !isIosDevice) return const fitIosWindow = async () => { try { await api.windowMaximize() } catch { void 0 } try { await api.windowSetResizable(false) } catch { void 0 } try { // 传入远大于设备尺寸的物理像素,交由系统裁剪为可用全屏区域 await api.windowResize(10000, 10000) } catch { void 0 } } fitIosWindow().catch(() => void 0) const timer = window.setTimeout(() => { fitIosWindow().catch(() => void 0) }, 300) return () => { window.clearTimeout(timer) } }, [isIosDevice]) useEffect(() => { const api = (window as any).api if (!api || typeof api.onDataUpdated !== "function") return let disposed = false let unlisten: (() => void) | null = null api .onDataUpdated((payload: { category?: string; source?: string }) => { const detail = { category: payload?.category || "all", source: payload?.source || "tauri", } window.dispatchEvent(new CustomEvent("ss:data-updated", { detail })) }) .then((fn: () => void) => { if (disposed) { fn() return } unlisten = fn }) .catch(() => void 0) return () => { disposed = true if (unlisten) unlisten() } }, []) const applySyncStrategy = async (strategy: "keep_local" | "keep_remote") => { const api = (window as any).api if (!api) return setSyncApplyLoading(true) syncApplyLoadingRef.current = true try { const res = await api.dbSyncApply(strategy) if (res?.success && res?.data?.success) { messageApi.success( res.data.message || `同步完成(同步 ${res.data.synced_records} 条,解决冲突 ${res.data.resolved_conflicts} 条)` ) window.dispatchEvent( new CustomEvent("ss:data-updated", { detail: { category: "all", source: "sync" } }) ) } else { messageApi.error(res?.data?.message || res?.message || "同步失败") } } catch (error: any) { messageApi.error(error?.message || "同步失败") } finally { setSyncApplyLoading(false) syncApplyLoadingRef.current = false setSyncConflictVisible(false) setSyncConflicts([]) } } useEffect(() => { const api = (window as any).api if (!api) return let disposed = false const checkAndSync = async () => { if (disposed || syncCheckingRef.current || syncApplyLoadingRef.current) return if (permission !== "admin") return try { syncCheckingRef.current = true const statusRes = await api.dbGetStatus() if ( !statusRes?.success || statusRes?.data?.type !== "postgresql" || !statusRes?.data?.connected ) { return } const previewRes = await api.dbSyncPreview() if (!previewRes?.success || !previewRes?.data?.can_sync || !previewRes?.data?.need_sync) { return } const conflicts = previewRes.data.conflicts || [] if (conflicts.length > 0) { const recentLocalMutation = Date.now() - lastLocalMutationAtRef.current < 15000 if (recentLocalMutation) { const autoApplyRes = await api.dbSyncApply("keep_remote") if ( autoApplyRes?.success && autoApplyRes?.data?.success && autoApplyRes?.data?.synced_records > 0 ) { window.dispatchEvent( new CustomEvent("ss:data-updated", { detail: { category: "all", source: "sync" } }) ) } return } setSyncConflicts(conflicts) setSyncConflictVisible(true) return } const applyRes = await api.dbSyncApply("keep_remote") if (applyRes?.success && applyRes?.data?.success && applyRes?.data?.synced_records > 0) { window.dispatchEvent( new CustomEvent("ss:data-updated", { detail: { category: "all", source: "sync" } }) ) } } catch (error) { console.error("Auto sync failed:", error) } finally { syncCheckingRef.current = false } } checkAndSync() const timer = window.setInterval(checkAndSync, 30000) const onDataUpdated = (e: Event) => { const customEvent = e as CustomEvent<{ source?: string }> if (customEvent?.detail?.source !== "sync") { lastLocalMutationAtRef.current = Date.now() } window.setTimeout(() => { checkAndSync().catch(() => void 0) }, 1200) } window.addEventListener("ss:data-updated", onDataUpdated) return () => { disposed = true window.clearInterval(timer) window.removeEventListener("ss:data-updated", onDataUpdated) } }, [permission]) const login = async () => { if (!(window as any).api) return setAuthLoading(true) const res = await (window as any).api.authLogin(authPassword) setAuthLoading(false) if (res.success && res.data) { setPermission(res.data.permission) setAuthVisible(false) setAuthPassword("") messageApi.success(t("auth.unlocked")) } else { messageApi.error(res.message || t("common.error")) } } const logout = async () => { const api = (window as any).api if (!api) return // 退出时同时清理 OAuth 持久化状态,避免刷新后又自动恢复权限 try { await api.oauthClearLoginState() setOAuthUserName(null) window.dispatchEvent(new CustomEvent("ss:oauth-user-updated", { detail: { user: null } })) } catch (error) { console.error("Failed to clear OAuth login state:", error) } const res = await api.authLogout() if (res?.success && res.data) { setPermission(res.data.permission) messageApi.success(t("auth.logout")) } } const logoutOAuthFromHeader = useCallback(async () => { const api = (window as any).api if (!api) return try { await api.oauthClearLoginState() setOAuthUserName(null) window.dispatchEvent(new CustomEvent("ss:oauth-user-updated", { detail: { user: null } })) await refreshPermissionFromAuth() messageApi.success("已退出云账号") } catch (error: any) { messageApi.error(error?.message || t("common.error")) } }, [messageApi, refreshPermissionFromAuth, t]) const handleOAuthSuccess = (userInfo: { user_id: string email: string name: string github_username?: string permission: number }) => { setPermission(mapOAuthPermissionToAppPermission(userInfo.permission)) setAuthVisible(false) setOAuthUserName(userInfo.name || null) messageApi.success(t("auth.oauthSuccess", "登录成功")) } const onMenuChange = (v: string) => { const key = String(v) setMoreNavVisible(false) setEditingNav(false) if (immersiveMode && key !== "home") return if (key === "home") navigate("/") if (key === "students") navigate("/students") if (key === "score") navigate("/score") if (key === "auto-score") navigate("/auto-score") if (key === "reward-settings") navigate("/reward-settings") if (key === "boards") navigate("/boards") if (key === "leaderboard") navigate("/leaderboard") if (key === "settlements") navigate("/settlements") if (key === "reasons") navigate("/reasons") if (key === "plugins") navigate("/plugins") if (key === "settings") navigate("/settings") } const toggleSidebar = () => { if (immersiveMode) return if (isPortraitMode && sidebarCollapsed) { setFloatingSidebarExpanded((prev) => !prev) return } setSidebarCollapsed((prev) => !prev) } useEffect(() => { if (!immersiveMode) return if (location.pathname !== "/" && !location.pathname.startsWith("/home")) { navigate("/", { replace: true }) } }, [immersiveMode, location.pathname, navigate]) const toggleImmersiveMode = () => { setImmersiveMode((prev) => { const next = !prev if (next) { setFloatingSidebarExpanded(false) if (location.pathname !== "/" && !location.pathname.startsWith("/home")) { navigate("/", { replace: true }) } } return next }) } const isDark = currentTheme?.mode === "dark" const brandColor = currentTheme?.config?.tdesign?.brandColor || "#0052D9" const isMobileDevice = isIosDevice || isAndroidDevice const showMobileBottomNav = isPortraitMode && !immersiveMode const mobileBottomNavIconMap: Record = { home: , students: , score: , "auto-score": , "reward-settings": , boards: , leaderboard: , settlements: , reasons: , plugins: , settings: , } const mobileNavAvailableItems = useMemo( () => MOBILE_NAV_ITEMS.filter((item) => !(item.adminOnly && permission !== "admin")), [permission] ) const mobileBottomSelectedKeys = useMemo(() => { const availableKeySet = new Set(mobileNavAvailableItems.map((item) => item.key)) return sanitizeMobileNavKeys(mobileBottomNavItems, []) .filter((key) => availableKeySet.has(key)) .slice(0, 4) }, [mobileBottomNavItems, mobileNavAvailableItems]) const mobileBottomPrimaryItems = useMemo( () => mobileBottomSelectedKeys .map((key) => mobileNavAvailableItems.find((item) => item.key === key)) .filter((item): item is (typeof MOBILE_NAV_ITEMS)[number] => Boolean(item)), [mobileBottomSelectedKeys, mobileNavAvailableItems] ) const mobileBottomOverflowItems = useMemo( () => mobileNavAvailableItems.filter((item) => !mobileBottomSelectedKeys.includes(item.key)), [mobileNavAvailableItems, mobileBottomSelectedKeys] ) const isMoreActive = mobileBottomOverflowItems.some((item) => item.key === activeMenu) const openEditNav = () => { const savedKeys = mobileBottomSelectedKeys const missingKeys = mobileNavAvailableItems .map((item) => item.key) .filter((key) => !savedKeys.includes(key)) setEditingBottomNavKeys(savedKeys) setEditingMoreNavKeys(missingKeys) setEditingNav(true) setDraggingNavKey(null) setDraggingFromList(null) setDragOverSlot(null) } const dropPlaceholder = (
) const persistMobileBottomKeys = async (nextKeys: MobileNavKey[]) => { const api = (window as any).api if (!api) return const next = sanitizeMobileNavKeys(nextKeys, []) const res = await api.setSetting("mobile_bottom_nav_items", next) if (res.success) { setMobileBottomNavItems(next) } else { messageApi.error(res.message || t("settings.general.saveFailed")) } } const handleDropToList = (targetList: "bottom" | "more", targetIndex: number) => { if (!draggingNavKey || !draggingFromList) return const bottom = editingBottomNavKeys.slice() const more = editingMoreNavKeys.slice() const source = draggingFromList === "bottom" ? bottom : more const sourceIndex = source.indexOf(draggingNavKey) if (sourceIndex < 0) return source.splice(sourceIndex, 1) if (targetList === "bottom" && draggingFromList === "more" && bottom.length >= 4) { setDraggingNavKey(null) setDraggingFromList(null) setDragOverSlot(null) messageApi.warning(t("settings.mobile.bottomMaxHint", "底栏最多 4 个")) return } const target = targetList === "bottom" ? bottom : more const clampedIndex = Math.max(0, Math.min(targetIndex, target.length)) target.splice(clampedIndex, 0, draggingNavKey) setEditingBottomNavKeys(bottom) setEditingMoreNavKeys(more) void persistMobileBottomKeys(bottom) setDraggingNavKey(null) setDraggingFromList(null) setDragOverSlot(null) } return ( {contextHolder} {!isPortraitMode && (
)} setAuthVisible(true)} onLogout={logout} showWindowControls={!isIosDevice && !isAndroidDevice} isPortraitMode={isPortraitMode} isMobileDevice={isMobileDevice} sidebarCollapsed={sidebarCollapsed} floatingExpand={isPortraitMode} floatingExpanded={floatingSidebarExpanded} onToggleSidebar={toggleSidebar} immersiveMode={immersiveMode} isHomePage={activeMenu === "home"} onToggleImmersiveMode={toggleImmersiveMode} showSidebarToggle={!isPortraitMode} bottomInset={showMobileBottomNav ? 84 : 0} /> {showMobileBottomNav && (
{mobileBottomPrimaryItems.map((item) => ( ))} {mobileBottomOverflowItems.length > 0 && ( )}
)} { setMoreNavVisible(false) setEditingNav(false) setDraggingNavKey(null) setDraggingFromList(null) setDragOverSlot(null) }} height={editingNav ? "calc(100vh - env(safe-area-inset-top, 0px))" : "46vh"} styles={{ content: { borderTopLeftRadius: editingNav ? 0 : "14px", borderTopRightRadius: editingNav ? 0 : "14px", background: "var(--ss-card-bg)", }, header: { borderBottom: "1px solid var(--ss-border-color)", padding: "12px 16px", }, body: { padding: "12px 16px 20px", display: "flex", flexDirection: "column", minHeight: 0, }, }} extra={ permission === "admin" && (editingNav ? ( ) : ( )) } > {editingNav ? (
{t( "settings.mobile.dragHint", "拖动可调整顺序。前 4 项显示在底栏,其余显示在“更多”。" )}
{[ { list: "bottom" as const, title: t("settings.mobile.bottomSection", "底栏(最多 4 个)"), keys: editingBottomNavKeys, }, { list: "more" as const, title: t("settings.mobile.moreSection", "更多"), keys: editingMoreNavKeys, }, ].map((group) => (
{group.title}
{Array.from({ length: group.keys.length + 1 }, (_, i) => { const key = group.keys[i] const item = key ? MOBILE_NAV_ITEMS.find((it) => it.key === key) : null return (
setDragOverSlot({ list: group.list, index: i })} onDragOver={(e) => { e.preventDefault() setDragOverSlot({ list: group.list, index: i }) }} onDrop={() => handleDropToList(group.list, i)} > {dragOverSlot?.list === group.list && dragOverSlot?.index === i ? ( dropPlaceholder ) : (
)}
{key && item && ( )}
) })}
))}
) : (
{mobileBottomOverflowItems.map((item) => ( ))}
)} setWizardVisible(false)} /> setAuthVisible(false)} onOk={login} confirmLoading={authLoading} okText={t("auth.unlockButton")} cancelText={t("common.cancel")} >
{t("auth.unlockHint")}
setAuthPassword(e.target.value)} placeholder={t("auth.passwordPlaceholder")} maxLength={6} />
setOAuthVisible(false)} onSuccess={handleOAuthSuccess} /> { if (syncApplyLoading) return setSyncConflictVisible(false) }} footer={null} closable={!syncApplyLoading} maskClosable={false} destroyOnHidden >
自动同步发现冲突,请选择冲突时优先保留哪一侧的数据。
{syncConflicts.slice(0, 30).map((item) => (
{item.table} / {item.key}
本地: {item.local_summary}
远程: {item.remote_summary}
))} {syncConflicts.length > 30 &&
仅显示前 30 条冲突...
}
{import.meta.env.DEV ? (

开发中画面,不代表最终品质

SecScore Dev ({getPlatform()}-{getArchitecture()})

) : null} ) } function getArchitecture(): string { const userAgent = navigator.userAgent.toLowerCase() if (userAgent.includes("arm64") || userAgent.includes("aarch64")) { return "ARM64" } else if ( userAgent.includes("x64") || userAgent.includes("amd64") || userAgent.includes("x86_64") || userAgent.includes("intel") ) { return "x64" } else if (userAgent.includes("i386") || userAgent.includes("i686")) { return "x86" } return "Unknown" } function getPlatform(): string { const userAgent = navigator.userAgent.toLowerCase() if (userAgent.includes("iphone") || userAgent.includes("ipad") || userAgent.includes("ipod")) { return "iOS" } else if (userAgent.includes("android")) { return "Android" } if (userAgent.includes("windows")) { return "Windows" } else if (userAgent.includes("mac")) { return "Mac" } else if (userAgent.includes("linux")) { return "Linux" } return "Unknown" } function getIosDeviceInfo(): { isIosDevice: boolean; isIosPhone: boolean } { const userAgent = navigator.userAgent.toLowerCase() const isIosDevice = /iphone|ipad|ipod/.test(userAgent) const isIosTablet = userAgent.includes("ipad") return { isIosDevice, isIosPhone: isIosDevice && !isIosTablet, } } function getMobileDeviceInfo(): { isIosDevice: boolean isAndroidDevice: boolean defaultPortraitMode: boolean } { const { isIosDevice, isIosPhone } = getIosDeviceInfo() const isAndroidDevice = navigator.userAgent.toLowerCase().includes("android") return { isIosDevice, isAndroidDevice, defaultPortraitMode: isIosPhone || isAndroidDevice, } } function App(): React.JSX.Element { return ( } /> } /> ) } export default App