From deea0e8655a9f3f981b08d64aad4a4603f4ea5e6 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 02:45:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=A9=E5=B1=95=E5=AE=BF=E4=B8=BB=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E9=93=BE=E6=89=AB=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自动发现 H5 HostBridge 真实能力调用链 补齐 runtime 回读与 wrapper 消费者扫描兜底 区分壳源码和 H5 业务调用链的禁替身词表 将视觉小说生产预览数据从 mock 命名收口为本地预览命名 更新原生壳验收文档和共享决策记录 --- .../shared-memory/decision-log.md | 3 +- .../shared-memory/development-workflow.md | 2 +- ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 6 +- ...前端架构】宿主壳能力统一协议-2026-06-17.md | 2 +- scripts/check-native-shells.mjs | 244 +++++++++++++++++- .../PlatformEntryFlowShellImpl.tsx | 6 +- .../VisualNovelAgentWorkspace.test.tsx | 4 +- .../VisualNovelResultView.test.tsx | 26 +- .../VisualNovelResultView.tsx | 6 +- ...sualNovelRuntimePanels.emptyState.test.tsx | 18 +- .../VisualNovelRuntimeShell.test.tsx | 30 +-- .../VisualNovelRuntimeShell.tsx | 10 +- ...Data.ts => visualNovelLocalPreviewData.ts} | 60 ++--- 13 files changed, 321 insertions(+), 96 deletions(-) rename src/components/visual-novel-runtime/{visualNovelMockData.ts => visualNovelLocalPreviewData.ts} (89%) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 41bee8718..6a364bd30 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -29,7 +29,7 @@ - 2026-06-18 宿主 runtime 回读:主 App 启动时会通过真实 `host.getRuntime` 回读 Expo / Tauri runtime 并缓存过滤后的能力清单,能力来源为 URL `hostCapabilities` 与宿主真实回包的并集;裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时也能启用真实声明能力,但仍不会仅凭 `native_app` 或 transport 存在推断能力可用。 - 2026-06-18 壳能力防漂移:`npm run mobile-shell:typecheck` 与 `npm run desktop-shell:typecheck` 会校验 Expo / Tauri 壳声明的 capability 均来自共享 HostBridge 白名单,并校验壳 runtime 回包、H5 URL `hostCapabilities` 和实现分支保持一致;微信小程序 `WECHAT_HOST_CAPABILITIES` 也由 `miniprogram/host-bridge/protocol.test.js` 反查同一共享白名单。新增能力必须先更新契约和真实壳实现,再通过这些检查。 - 2026-06-18 原生壳统一验收门禁:根级 `npm run check:native-shells` 统一执行 H5 HostBridge 关键测试、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描;根级 `npm run check` 会在 lint、主站测试、构建和内容检查后继续执行该门禁,避免 HostBridge、三端壳、Expo managed config、移动端 production bundle、桌面 release 入口和 H5 HostBridge 真实调用链禁替身验收散落成容易漏跑的单项命令。 -- 2026-06-19 原生壳临时替身扫描范围:`npm run check:native-shells` 的生产替身词扫描必须覆盖微信小程序壳生产 `.js`、Expo / Tauri 壳源码与配置、共享 HostBridge 契约、H5 native transport,以及已接入的 H5 直接调用链文件;新增 H5 调用点接入 HostBridge 时,必须同步把对应文件纳入扫描或在同等门禁中证明生产代码没有 mock / fake / placeholder / TODO / FIXME / 占位 / 伪造。 +- 2026-06-19 原生壳临时替身扫描范围:`npm run check:native-shells` 的生产替身词扫描必须覆盖微信小程序壳生产 `.js`、Expo / Tauri 壳源码与配置、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件;新增 H5 调用点接入 HostBridge 时,必须让自动扫描覆盖对应文件或在同等门禁中证明生产代码没有 mock / fake / stub / TODO / FIXME / 模拟 / 伪造。 - 2026-06-18 微信壳桥接层纳入统一验收:`npm run check:native-shells` 还会运行 `miniprogram/host-bridge/`、`miniprogram/shell/`、`pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的微信壳测试,覆盖 WebView 入口、登录触发、分享目标、支付结果、订阅消息结果和九宫切图行为;三端桥接层文件结构检查只证明目录边界,行为回归必须由同一门禁中的微信壳测试证明。 - 2026-06-18 登录 / 支付能力禁伪声明:`auth.requestLogin` 和 `payment.request` 保留在共享 HostBridge 契约中供未来真实接入,但 Expo / Tauri 壳在真实 SDK、渠道流程和后端契约落地前不得声明这些 capability,也不得把它们写入入口 URL `hostCapabilities`;两端检查脚本会拒绝伪声明,请求实际到达壳层时必须返回明确 `unsupported_method` 并让 H5 fallback,两端壳测试直接覆盖这两个 method。 - 2026-06-18 移动壳触觉反馈边界:`haptics.impact` 只接受 `light`、`medium`、`heavy` 三档 impact style,缺省为 `light`;未知值必须返回 `invalid_request`,不得静默降级成真实设备触觉反馈。桌面壳不声明该 capability,H5 继续按 HostBridge fallback 处理。 @@ -90,6 +90,7 @@ - 2026-06-18 HostBridge capability / handler 关系门禁:两端壳声明 request method capability 时必须有对应 HostBridge handler;壳 handler 处理的 method 必须已被该壳声明,登录 / 支付等 SDK-backed method 只能保留明确 `unsupported_method` 路径。事件类 capability 不要求 request handler。 - 2026-06-18 桌面壳 CSP 分层:Tauri release `csp` 不得包含 `http://127.0.0.1:*`、`ws://127.0.0.1:*` 或其它本机调试源,本机 Vite、HMR WebSocket 和开发 frame 只允许出现在 `devCsp`。桌面壳配置检查会同时拒绝 release CSP 混入本机调试源、dev CSP 缺失本机开发源,以及 release / dev CSP 加入 `unsafe-eval`、`tauri:` 或 `file:`。 - 2026-06-18 壳生产代码禁用临时替身:微信 / Expo / Tauri 三端壳的生产源码和配置不得出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 mock。两端原生壳配置检查会扫描生产入口、配置和壳实现,根级 `npm run check:native-shells` 会统一扫描 `miniprogram`、`apps/mobile-shell`、`apps/desktop-shell`、H5 HostBridge transport 和共享 HostBridge 契约生产源码,防止把临时替身、占位文案或伪实现带进可分发壳或真实调用链。 +- 2026-06-19 H5 HostBridge 调用链自动扫描:根级 `npm run check:native-shells` 从 `src/` 生产文件自动收集真实宿主能力 facade 的直接消费者,以及 `useHostLifecycleActive`、`useHostNetworkOnline`、`platformProfileHostClipboard` 等薄 wrapper 的消费者。H5 业务文件允许正常表单 `placeholder` 属性和业务占位图文案,但不得出现 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹;壳源码和配置仍继续禁用 placeholder / 占位。 - 2026-06-18 原生壳本地生成物边界:Expo `.expo/`、Expo export smoke 临时目录、Tauri `target/`、Tauri schema `gen/` 和 Tauri 自动生成权限目录都必须保持 gitignored,不作为生产源码敏感词扫描输入;手写 capability / 权限配置仍在扫描范围内。 - 2026-06-18 移动壳启动页与 adaptive icon:Expo 移动壳启动页和 Android adaptive icon 复用现有真实品牌图标 `apps/mobile-shell/assets/icon.png`,背景色固定为 H5 壳根背景 `#fffdf9`。该 PNG 是 1024x1024 RGBA 透明前景品牌资产,不新增占位图;配置检查会校验图标尺寸、透明像素、splash 和 adaptive icon 指向,避免后续换成非品牌或占位素材。 - 2026-06-18 桌面壳 bundle 图标集:Tauri 桌面壳从现有真实品牌 PNG `apps/desktop-shell/src-tauri/icons/icon.png` 派生 `32x32.png`、`128x128.png`、`128x128@2x.png`、`icon.ico` 和 `icon.icns`,并在 `bundle.icon` 中同时声明这些平台图标。检查脚本会校验 PNG 尺寸、ICO 多尺寸头部、ICNS 容器长度和 bundle 图标列表,避免后续退回单图标或替换为非品牌 / 占位素材。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index fb19b7e77..c6f989b8f 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -216,7 +216,7 @@ npm run build npm run check:native-shells ``` -该命令会覆盖 H5 HostBridge 关键测试、微信 / Expo / Tauri 三端桥接层文件结构门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面壳 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描,确认 Expo managed config、移动端 iOS / Android production bundle、打包 H5 资产、Tauri release 入口和 H5 HostBridge 真实调用链没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport 和已接入的 H5 直接调用链文件。 +该命令会覆盖 H5 HostBridge 关键测试、微信 / Expo / Tauri 三端桥接层文件结构门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面壳 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描,确认 Expo managed config、移动端 iOS / Android production bundle、打包 H5 资产、Tauri release 入口和 H5 HostBridge 真实调用链没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。壳源码和配置继续严格禁止 mock / fake / placeholder / stub / TODO / FIXME / 占位 / 模拟 / 伪造;H5 业务调用链允许正常表单 `placeholder` 属性和业务占位图文案,但仍禁止 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。 该命令同时会运行微信小程序 `miniprogram/host-bridge/`、`miniprogram/shell/`、`pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的壳层测试,保证微信桥接层拆分后的支付、订阅消息、九宫切图、分享目标和 WebView 登录 / 分享入口行为与 Expo、Tauri 壳一起验收。 内容检查: diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index ea68208a8..9600b03ec 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -441,7 +441,9 @@ GameBridge 禁止: 2026-06-18 追加:登录 / 支付能力伪声明进入门禁。`auth.requestLogin` 和 `payment.request` 保留在共享 HostBridge 契约中供未来真实接入,但 Expo 与 Tauri 壳在没有真实 SDK、渠道流程和后端契约前不得声明这些 capability,也不得把它们写入 `hostCapabilities`;请求到达壳层时必须返回明确 `unsupported_method`,让 H5 fallback 承接。两端壳测试会直接覆盖这两个 method,避免后续半接入时返回伪成功。 -2026-06-18 追加:微信 / Expo / Tauri 三端壳的生产源码和配置禁止出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 `vi.mock` 或等价测试替身。`apps/mobile-shell/scripts/check-config.mjs` 与 `apps/desktop-shell/scripts/check-config.mjs` 会扫描各自生产入口、配置和壳实现;根级 `npm run check:native-shells` 会统一扫描 `miniprogram`、`apps/mobile-shell`、`apps/desktop-shell`、H5 HostBridge transport 和共享 HostBridge 契约生产源码,防止把临时替身或占位文案带进可分发壳或真实调用链。 +2026-06-18 追加:微信 / Expo / Tauri 三端壳的生产源码和配置禁止出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 `vi.mock` 或等价测试替身。`apps/mobile-shell/scripts/check-config.mjs` 与 `apps/desktop-shell/scripts/check-config.mjs` 会扫描各自生产入口、配置和壳实现;根级 `npm run check:native-shells` 会统一扫描 `miniprogram`、`apps/mobile-shell`、`apps/desktop-shell`、H5 HostBridge transport 和共享 HostBridge 契约生产源码,防止把临时替身或占位文案带进可分发壳。 + +2026-06-19 追加:H5 HostBridge 真实调用链扫描改为自动发现。根级 `npm run check:native-shells` 会从 `src/` 生产文件里收集直接导入并调用真实宿主能力 facade 的文件,以及 `useHostLifecycleActive`、`useHostNetworkOnline`、`platformProfileHostClipboard` 等薄 wrapper 的消费者;新增 `reloadHostWebView`、`showHostLocalNotification`、`setHostAppBadgeCount`、`importHostTextFile`、`importHostImageFile`、`captureHostImageFile`、`subscribeHostImageDrop`、`readHostClipboardText`、`openHostExternalUrl` 等 H5 调用点时,不再手工维护单个扫描文件清单。H5 业务文件允许正常表单 `placeholder` 属性和业务占位图文案,但仍会拒绝 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。 2026-06-18 追加:微信壳行为测试进入原生壳统一验收。`npm run check:native-shells` 会运行 `miniprogram/host-bridge/`、`miniprogram/shell/`、`pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 测试,覆盖微信 WebView 入口、登录触发、分享目标、支付结果、订阅消息结果和九宫切图桥接行为;三端桥接层文件结构检查只锁定职责边界,真实行为回归必须由同一门禁中的微信壳测试证明。 @@ -491,7 +493,7 @@ GameBridge 禁止: - AI sandbox 无法调用 HostBridge,也无法读取 H5 登录态。 - Tauri release 包不允许任意远端页面调用桌面命令。 - Expo WebView 外链离开主站后不保留完整 HostBridge。 -- 根级验收入口 `npm run check:native-shells` 必须同时覆盖 H5 HostBridge 关键路径、三端桥接层文件结构门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描。该扫描范围必须包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport 和已接入的 H5 直接调用链文件。 +- 根级验收入口 `npm run check:native-shells` 必须同时覆盖 H5 HostBridge 关键路径、三端桥接层文件结构门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描。该扫描范围必须包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。 ## 参考资料 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index ff9a43bcc..8bacfd985 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -83,7 +83,7 @@ HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_ - 小程序支付仍跳转 `/pages/wechat-pay/index` 并保留支付结果 hash 回灌确认。 - 小程序订阅授权仍跳转 `/pages/subscribe-message/index`,且返回不阻断生成主链路。 - 普通浏览器分享、H5 支付和 Native 二维码支付不受影响。 -- 原生壳统一验收入口 `npm run check:native-shells` 通过,能力白名单、共享 capability profile、HostBridge event 白名单、壳 runtime 回包、URL `hostCapabilities`、H5 fallback、三端桥接层结构、两端壳实现、Expo managed config、移动端 production bundle、桌面 release 构建入口,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport 和已接入的 H5 直接调用链文件。 +- 原生壳统一验收入口 `npm run check:native-shells` 通过,能力白名单、共享 capability profile、HostBridge event 白名单、壳 runtime 回包、URL `hostCapabilities`、H5 fallback、三端桥接层结构、两端壳实现、Expo managed config、移动端 production bundle、桌面 release 构建入口,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。H5 业务文件允许正常表单 `placeholder` 属性和业务占位图文案,但不得出现 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。 ## 后续 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index e8eca8ef4..bfcefe0bd 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -20,15 +20,74 @@ const productionShellScanRoots = [ 'packages/shared/src/contracts/hostBridge.ts', 'src/services/host-bridge', ]; -const h5HostBridgeCallChainScanFiles = [ - 'src/services/clipboard.ts', - 'src/services/appTitle.ts', - 'src/services/runtimeAudioFeedback.ts', +const h5HostBridgeFacadeModule = 'src/services/host-bridge/hostBridge'; +const h5HostBridgeScannedFacadeImports = new Set([ + 'canUseHostShareGrid', + 'captureHostImageFile', + 'exportHostAudioFile', + 'exportHostImageFile', + 'exportHostTextFile', + 'getHostAppearanceColorScheme', + 'getHostNetworkStatus', + 'getNativeAppHostRuntime', + 'importHostAudioFile', + 'importHostImageFile', + 'importHostTextFile', + 'navigateHostNativePage', + 'openHostExternalUrl', + 'openHostShare', + 'openHostShareGrid', + 'readHostClipboardText', + 'refreshNativeAppHostRuntime', + 'reloadHostWebView', + 'requestHostHapticsImpact', + 'requestHostLogin', + 'requestHostPayment', + 'setHostAppBadgeCount', + 'setHostAppTitle', + 'setHostShareTarget', + 'showHostLocalNotification', + 'subscribeHostAppLifecycle', + 'subscribeHostImageDrop', + 'subscribeHostNavigationCanGoBack', + 'subscribeHostNetworkStatusChange', + 'subscribeHostRuntimeChange', + 'writeHostClipboardText', +]); +const h5HostBridgeCallChainWrapperFiles = [ 'src/hooks/useHostLifecycleActive.ts', 'src/hooks/useHostNetworkOnline.ts', + 'src/components/platform-entry/platformProfileHostClipboard.ts', +]; +const h5HostBridgeRequiredCallChainFiles = [ + 'src/App.tsx', + 'src/components/auth/AuthGate.tsx', + 'src/components/common/CreativeAudioInputPanel.tsx', + 'src/components/common/CreativeImageInputPanel.tsx', 'src/components/common/PublishShareModal.tsx', 'src/components/common/publishShareCardImage.ts', - 'src/components/common/CreativeAudioInputPanel.tsx', + 'src/components/creation-agent/CreationAgentWorkspace.tsx', + 'src/components/match3d-runtime/Match3DRuntimeShell.tsx', + 'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx', + 'src/components/platform-entry/PlatformFeedbackView.tsx', + 'src/components/platform-entry/PlatformProfilePrimitives.tsx', + 'src/components/platform-entry/PlatformProfileReferralModal.tsx', + 'src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx', + 'src/components/platform-entry/usePlatformProfileCenterController.ts', + 'src/components/puzzle-runtime/PuzzleRuntimeShell.tsx', + 'src/components/rpg-creation-result/RpgCreationAssetDebugPanel.tsx', + 'src/components/rpg-entry/RpgEntryHomeView.tsx', + 'src/components/square-hole-result/SquareHoleResultView.tsx', + 'src/hooks/useBackgroundMusic.ts', + 'src/hooks/useHostLifecycleActive.ts', + 'src/hooks/useHostNetworkOnline.ts', + 'src/main.tsx', + 'src/services/appTitle.ts', + 'src/services/clipboard.ts', + 'src/services/runtimeAudioFeedback.ts', + 'src/services/wechatMiniProgramShareTarget.ts', + 'src/services/wechatMiniProgramSubscribe.ts', + 'src/services/wechatMiniProgramShareGrid.ts', ]; const expectedWechatHostBridgeFiles = [ 'dispatch.js', @@ -126,6 +185,7 @@ const productionShellExtensions = new Set([ '.wxml', '.wxss', ]); +const h5ProductionSourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']); const productionShellExcludedSegments = new Set([ '.expo', '.expo-export-smoke', @@ -148,6 +208,15 @@ const productionShellDevScaffoldTerms = [ '模' + '拟', '伪' + '造', ]; +const h5HostBridgeCallChainDevScaffoldTerms = [ + 'mo' + 'ck', + 'fa' + 'ke', + 'st' + 'ub', + 'TO' + 'DO', + 'FIX' + 'ME', + '模' + '拟', + '伪' + '造', +]; const h5HostBridgeTests = [ 'packages/shared/src/contracts/hostBridge.test.ts', @@ -234,6 +303,19 @@ function shouldScanProductionShellFile(filePath) { return productionShellExtensions.has(path.extname(filePath)); } +function shouldScanH5ProductionSourceFile(filePath) { + const normalizedPath = filePath.split(path.sep).join('/'); + if ( + normalizedPath.includes('.test.') || + normalizedPath.includes('/test-utils/') || + normalizedPath.includes('/services/host-bridge/') + ) { + return false; + } + + return h5ProductionSourceExtensions.has(path.extname(filePath)); +} + function collectProductionShellFiles(entryPath) { const normalizedPath = entryPath.split(path.sep).join('/'); if (productionShellExcludedPaths.has(normalizedPath)) { @@ -259,16 +341,145 @@ function collectProductionShellFiles(entryPath) { return shouldScanProductionShellFile(entryPath) ? [entryPath] : []; } -function assertNoProductionShellDevScaffoldTerms() { - const files = [ - ...productionShellScanRoots, - ...h5HostBridgeCallChainScanFiles, - ].flatMap(collectProductionShellFiles); +function collectFiles(entryPath, shouldIncludeFile) { + if (!fs.existsSync(entryPath)) { + throw new Error(`scan path does not exist: ${entryPath}`); + } + const stats = fs.statSync(entryPath); + if (stats.isDirectory()) { + const name = path.basename(entryPath); + if (productionShellExcludedSegments.has(name)) { + return []; + } + + return fs + .readdirSync(entryPath, { withFileTypes: true }) + .flatMap((entry) => collectFiles(path.join(entryPath, entry.name), shouldIncludeFile)); + } + + return shouldIncludeFile(entryPath) ? [entryPath] : []; +} + +function normalizeModulePath(modulePath) { + return modulePath.split(path.sep).join('/').replace(/\.(jsx?|tsx?)$/, ''); +} + +function importedModulePath(fromFile, specifier) { + if (specifier === '@') { + return '.'; + } + if (specifier.startsWith('@/')) { + return normalizeModulePath(specifier.slice(2)); + } + if (!specifier.startsWith('.')) { + return specifier; + } + + return normalizeModulePath(path.normalize(path.join(path.dirname(fromFile), specifier))); +} + +function extractImportSpecifiers(source) { + const specifiers = []; + for (const match of source.matchAll(/import\s+(?:type\s+)?[\s\S]*?\s+from\s+['"]([^'"]+)['"]/g)) { + specifiers.push(match[1]); + } + for (const match of source.matchAll(/export\s+(?:type\s+)?\{[\s\S]*?\}\s+from\s+['"]([^'"]+)['"]/g)) { + specifiers.push(match[1]); + } + for (const match of source.matchAll(/import\s*['"]([^'"]+)['"]/g)) { + specifiers.push(match[1]); + } + + return specifiers; +} + +function extractNamedImportsFromModule(source, fromFile, targetModule) { + const importedNames = new Set(); + const importPattern = + /import\s+(?:type\s+)?(?:\{([\s\S]*?)\}|[A-Za-z0-9_$]+\s*,\s*\{([\s\S]*?)\})\s+from\s+['"]([^'"]+)['"]/g; + const exportPattern = + /export\s+(?:type\s+)?\{([\s\S]*?)\}\s+from\s+['"]([^'"]+)['"]/g; + const collectNamedImports = (importBody) => { + for (const rawImport of importBody.split(',')) { + const cleanedImport = rawImport + .replace(/\btype\s+/g, '') + .trim(); + if (!cleanedImport) { + continue; + } + importedNames.add(cleanedImport.split(/\s+as\s+/)[0]?.trim() ?? ''); + } + }; + + for (const match of source.matchAll(importPattern)) { + if (importedModulePath(fromFile, match[3]) !== targetModule) { + continue; + } + + collectNamedImports(match[1] ?? match[2] ?? ''); + } + + for (const match of source.matchAll(exportPattern)) { + if (importedModulePath(fromFile, match[2]) !== targetModule) { + continue; + } + + collectNamedImports(match[1] ?? ''); + } + + return importedNames; +} + +function collectH5HostBridgeCallChainFiles() { + const sourceFiles = collectFiles('src', shouldScanH5ProductionSourceFile); + const wrapperModules = new Set( + h5HostBridgeCallChainWrapperFiles.map(normalizeModulePath), + ); + const scannedFiles = new Set(); + + for (const file of sourceFiles) { + const source = fs.readFileSync(file, 'utf8'); + const imports = extractImportSpecifiers(source).map((specifier) => + importedModulePath(file, specifier), + ); + const facadeImports = extractNamedImportsFromModule( + source, + file, + h5HostBridgeFacadeModule, + ); + const importsScannedFacadeCapability = [...facadeImports].some((importName) => + h5HostBridgeScannedFacadeImports.has(importName), + ); + if ( + importsScannedFacadeCapability || + imports.some((specifier) => wrapperModules.has(specifier)) + ) { + scannedFiles.add(file); + } + } + + for (const wrapperFile of h5HostBridgeCallChainWrapperFiles) { + scannedFiles.add(wrapperFile); + } + + const missingRequiredFiles = h5HostBridgeRequiredCallChainFiles.filter( + (file) => !scannedFiles.has(file), + ); + if (missingRequiredFiles.length > 0) { + throw new Error( + `H5 HostBridge call chain scan is missing required files: ${missingRequiredFiles.join(', ')}`, + ); + } + + return [...scannedFiles].sort(); +} + +function assertNoDevScaffoldTermsInFiles(files, terms) { for (const file of files) { const source = fs.readFileSync(file, 'utf8'); const lowerSource = source.toLowerCase(); - for (const term of productionShellDevScaffoldTerms) { + for (const term of terms) { const matchIndex = lowerSource.indexOf(term.toLowerCase()); if (matchIndex === -1) { continue; @@ -283,6 +494,17 @@ function assertNoProductionShellDevScaffoldTerms() { } } +function assertNoProductionShellDevScaffoldTerms() { + assertNoDevScaffoldTermsInFiles( + productionShellScanRoots.flatMap(collectProductionShellFiles), + productionShellDevScaffoldTerms, + ); + assertNoDevScaffoldTermsInFiles( + collectH5HostBridgeCallChainFiles().flatMap(collectProductionShellFiles), + h5HostBridgeCallChainDevScaffoldTerms, + ); +} + function assertSameList(actual, expected, label) { if ( actual.length !== expected.length || diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index ff2252323..6d9087c2b 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -416,7 +416,7 @@ import { buildVisualNovelEntryGenerationProgress, type VisualNovelEntryFormPayload, } from '../visual-novel-creation/visualNovelEntryGeneration'; -import { createMockVisualNovelRunFromDraft } from '../visual-novel-runtime/visualNovelMockData'; +import { createLocalPreviewVisualNovelRunFromDraft } from '../visual-novel-runtime/visualNovelLocalPreviewData'; import { buildBarkBattleDraftConfigFromWorkSummary, buildBarkBattlePublishedConfigFromDraft, @@ -7267,7 +7267,7 @@ export function PlatformEntryFlowShellImpl({ const profileId = visualNovelWork?.summary.profileId?.trim() ?? draft.profileId?.trim(); if (!profileId) { - const run = createMockVisualNovelRunFromDraft(draft); + const run = createLocalPreviewVisualNovelRunFromDraft(draft); setVisualNovelRun(run); setVisualNovelRuntimeReturnStage('visual-novel-result'); setSelectionStage('visual-novel-runtime'); @@ -7287,7 +7287,7 @@ export function PlatformEntryFlowShellImpl({ } catch (error) { // 中文注释:VN-07 只要求结果页进入测试运行时;真实 runtime 接口未就绪时, // 使用当前 draft 生成本地 test run,不扩展到正式玩家运行链路。 - setVisualNovelRun(createMockVisualNovelRunFromDraft(draft)); + setVisualNovelRun(createLocalPreviewVisualNovelRunFromDraft(draft)); setVisualNovelRuntimeReturnStage('visual-novel-result'); setSelectionStage('visual-novel-runtime'); setVisualNovelError( diff --git a/src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx b/src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx index 44a68527b..8d90e3921 100644 --- a/src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx +++ b/src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx @@ -5,7 +5,7 @@ import type { ComponentProps } from 'react'; import { expect, test, vi } from 'vitest'; import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy'; -import { mockVisualNovelSession } from '../visual-novel-runtime/visualNovelMockData'; +import { visualNovelLocalPreviewSession } from '../visual-novel-runtime/visualNovelLocalPreviewData'; import { VisualNovelAgentWorkspace } from './VisualNovelAgentWorkspace'; import { buildVisualNovelEntryGenerationAnchorEntries, @@ -107,7 +107,7 @@ test('visual novel workspace submits idea and selected visual style as seed text }); test('visual novel workspace restores idea text from existing session', () => { - renderWorkspace({ session: mockVisualNovelSession }); + renderWorkspace({ session: visualNovelLocalPreviewSession }); expect( (screen.getByLabelText('一句话创作') as HTMLTextAreaElement).value, diff --git a/src/components/visual-novel-result/VisualNovelResultView.test.tsx b/src/components/visual-novel-result/VisualNovelResultView.test.tsx index 4b45be26a..a9369ede6 100644 --- a/src/components/visual-novel-result/VisualNovelResultView.test.tsx +++ b/src/components/visual-novel-result/VisualNovelResultView.test.tsx @@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event'; import { expect, test, vi } from 'vitest'; import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy'; -import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData'; +import { visualNovelLocalPreviewDraft } from '../visual-novel-runtime/visualNovelLocalPreviewData'; import { VisualNovelResultView } from './VisualNovelResultView'; vi.mock('../../services/visual-novel-creation', () => ({ @@ -26,7 +26,7 @@ vi.mock('../../services/assetReadUrlService', () => ({ test('visual novel profile tab uses PlatformSubpanel shells', () => { const { container } = render( - {}} />, + {}} />, ); const profilePanels = Array.from( @@ -48,7 +48,7 @@ test('visual novel result header back button reuses shared compact back action b render( , @@ -69,7 +69,7 @@ test('visual novel result header back button reuses shared compact back action b test('visual novel profile media previews use PlatformMediaFrame aspects', () => { const draftWithCover = { - ...mockVisualNovelDraft, + ...visualNovelLocalPreviewDraft, coverImageSrc: '/visual-novel/cover.png', }; @@ -104,7 +104,7 @@ test('visual novel upload-only asset picker uses PlatformEmptyState chrome', asy const user = userEvent.setup(); render( - {}} />, + {}} />, ); await user.click(screen.getByRole('button', { name: '封面' })); @@ -130,7 +130,7 @@ test('visual novel entity list items use interactive PlatformSubpanel shells', a const user = userEvent.setup(); render( - {}} />, + {}} />, ); await user.click(screen.getByRole('button', { name: '角色' })); @@ -146,7 +146,7 @@ test('visual novel entity list items use interactive PlatformSubpanel shells', a test('visual novel empty entity list uses PlatformEmptyState shell', async () => { const user = userEvent.setup(); const emptyCharacterDraft = { - ...mockVisualNovelDraft, + ...visualNovelLocalPreviewDraft, characters: [], }; @@ -173,7 +173,7 @@ test('visual novel result opens complex editors as a dialog', async () => { const user = userEvent.setup(); render( - {}} />, + {}} />, ); await user.click(screen.getByRole('button', { name: '角色' })); @@ -190,7 +190,7 @@ test('visual novel result exposes test run action with current draft', async () render( {}} onStartTestRun={onStartTestRun} />, @@ -209,7 +209,7 @@ test('visual novel result sends edited character draft to save and test run', as render( {}} onSaveDraft={onSaveDraft} onStartTestRun={onStartTestRun} @@ -251,7 +251,7 @@ test('visual novel result uploads scene and character assets into platform refer render( {}} onSaveDraft={onSaveDraft} />, @@ -304,7 +304,7 @@ test('visual novel result generates scene background from asset picker', async ( render( {}} onSaveDraft={onSaveDraft} />, @@ -330,7 +330,7 @@ test('visual novel result generates scene background from asset picker', async ( expect.objectContaining({ kind: 'scene_background', scene: expect.objectContaining({ - sceneId: mockVisualNovelDraft.scenes[0]?.sceneId, + sceneId: visualNovelLocalPreviewDraft.scenes[0]?.sceneId, }), prompt: '默认图片提示词', }), diff --git a/src/components/visual-novel-result/VisualNovelResultView.tsx b/src/components/visual-novel-result/VisualNovelResultView.tsx index 955b44bfd..05a507978 100644 --- a/src/components/visual-novel-result/VisualNovelResultView.tsx +++ b/src/components/visual-novel-result/VisualNovelResultView.tsx @@ -57,7 +57,7 @@ import { PlatformTextField, } from '../common/PlatformTextField'; import { PlatformToggleRow } from '../common/PlatformToggleRow'; -import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData'; +import { visualNovelLocalPreviewDraft } from '../visual-novel-runtime/visualNovelLocalPreviewData'; type VisualNovelResultViewProps = { draft?: VisualNovelResultDraft | null; @@ -1994,7 +1994,7 @@ function VisualNovelEditorDialog({ } export function VisualNovelResultView({ - draft = mockVisualNovelDraft, + draft = visualNovelLocalPreviewDraft, isBusy = false, error = null, onBack, @@ -2004,7 +2004,7 @@ export function VisualNovelResultView({ onPublish, }: VisualNovelResultViewProps) { const [editDraft, setEditDraft] = useState(() => - cloneDraft(draft ?? mockVisualNovelDraft), + cloneDraft(draft ?? visualNovelLocalPreviewDraft), ); const [activeTab, setActiveTab] = useState('profile'); const [editorTarget, setEditorTarget] = diff --git a/src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx b/src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx index e02ca792a..1e98a1f63 100644 --- a/src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx +++ b/src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx @@ -5,14 +5,14 @@ import { expect, test } from 'vitest'; import { VisualNovelAttributePanel } from './VisualNovelAttributePanel'; import { VisualNovelHistoryPanel } from './VisualNovelHistoryPanel'; -import { mockVisualNovelRun } from './visualNovelMockData'; +import { visualNovelLocalPreviewRun } from './visualNovelLocalPreviewData'; import { VisualNovelSavePanel } from './VisualNovelSavePanel'; test('visual novel history panel uses platform empty state', () => { render( , @@ -28,7 +28,7 @@ test('visual novel attribute panel uses platform empty state', () => { render( , @@ -43,7 +43,7 @@ test('visual novel attribute panel uses platform empty state', () => { test('visual novel save panel uses platform empty states', () => { const { rerender } = render( , @@ -52,7 +52,7 @@ test('visual novel save panel uses platform empty states', () => { expect(screen.getByText('读取中').className).toContain('bg-white/74'); expect(screen.queryByText('暂无存档')).toBeNull(); - rerender(); + rerender(); expect(screen.getByText('暂无存档').className).toContain('bg-white/74'); expect(screen.queryByText('读取中')).toBeNull(); @@ -60,7 +60,7 @@ test('visual novel save panel uses platform empty states', () => { test('visual novel runtime list cards use platform subpanel chrome', () => { const { rerender } = render( - , + , ); const historyCard = screen.getByText('#1').closest('article'); @@ -71,12 +71,12 @@ test('visual novel runtime list cards use platform subpanel chrome', () => { rerender( { @@ -13,8 +13,8 @@ test('visual novel runtime renders mock play surface and opens panels as dialogs render( {}} />, ); @@ -35,8 +35,8 @@ test('visual novel runtime submits free text action with client event id', async render( {}} onSubmitAction={onSubmitAction} />, @@ -61,14 +61,14 @@ test('visual novel runtime submits choice and continue actions', async () => { const onSubmitAction = vi.fn(); const onContinue = vi.fn(); const runWithoutChoices = { - ...mockVisualNovelRun, + ...visualNovelLocalPreviewRun, availableChoices: [], }; const { rerender } = render( {}} onSubmitAction={onSubmitAction} onContinue={onContinue} @@ -88,7 +88,7 @@ test('visual novel runtime submits choice and continue actions', async () => { rerender( {}} onSubmitAction={onSubmitAction} @@ -108,8 +108,8 @@ test('visual novel runtime panels call regeneration and platform archive actions render( {}} onRegenerateHistoryEntry={onRegenerateHistoryEntry} onSaveRun={onSaveRun} @@ -117,8 +117,8 @@ test('visual novel runtime panels call regeneration and platform archive actions saveArchives={[ { worldKey: 'visual-novel:archive-1', - ownerUserId: 'mock-user', - profileId: 'vn-profile-mock-1', + ownerUserId: 'local-preview-user', + profileId: 'vn-profile-preview-1', worldType: 'visual-novel', worldName: '雪线电台', subtitle: '风雪站台', @@ -147,8 +147,8 @@ test('visual novel runtime shows raw text only as transient stream text', () => render( {}} />, diff --git a/src/components/visual-novel-runtime/VisualNovelRuntimeShell.tsx b/src/components/visual-novel-runtime/VisualNovelRuntimeShell.tsx index 27791c516..481ec909f 100644 --- a/src/components/visual-novel-runtime/VisualNovelRuntimeShell.tsx +++ b/src/components/visual-novel-runtime/VisualNovelRuntimeShell.tsx @@ -25,7 +25,7 @@ import type { } from '../../../packages/shared/src/contracts/visualNovel'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { useVisualNovelRuntimeController } from './useVisualNovelRuntimeController'; -import { mockVisualNovelDraft, mockVisualNovelRun } from './visualNovelMockData'; +import { visualNovelLocalPreviewDraft, visualNovelLocalPreviewRun } from './visualNovelLocalPreviewData'; import { VisualNovelRuntimePanel, type VisualNovelRuntimePanelKind, @@ -218,8 +218,8 @@ function resolveChoices( } export function VisualNovelRuntimeShell({ - draft = mockVisualNovelDraft, - run = mockVisualNovelRun, + draft = visualNovelLocalPreviewDraft, + run = visualNovelLocalPreviewRun, isBusy = false, isSaving = false, isLoadingArchives = false, @@ -243,8 +243,8 @@ export function VisualNovelRuntimeShell({ const [localTextModeEnabled, setLocalTextModeEnabled] = useState( run?.textModeEnabled ?? draft?.runtimeConfig.defaultTextMode ?? false, ); - const displayDraft = draft ?? mockVisualNovelDraft; - const baseRun = run ?? mockVisualNovelRun; + const displayDraft = draft ?? visualNovelLocalPreviewDraft; + const baseRun = run ?? visualNovelLocalPreviewRun; const runtimeController = useVisualNovelRuntimeController({ draft: displayDraft, initialRun: baseRun, diff --git a/src/components/visual-novel-runtime/visualNovelMockData.ts b/src/components/visual-novel-runtime/visualNovelLocalPreviewData.ts similarity index 89% rename from src/components/visual-novel-runtime/visualNovelMockData.ts rename to src/components/visual-novel-runtime/visualNovelLocalPreviewData.ts index 14b72a6dc..dab2d9548 100644 --- a/src/components/visual-novel-runtime/visualNovelMockData.ts +++ b/src/components/visual-novel-runtime/visualNovelLocalPreviewData.ts @@ -5,11 +5,11 @@ import type { VisualNovelSourceMode, } from '../../../packages/shared/src/contracts/visualNovel'; -const MOCK_NOW = '2026-05-05T12:00:00.000Z'; +const LOCAL_PREVIEW_NOW = '2026-05-05T12:00:00.000Z'; -// 中文注释:VN-04 只提供 UI 骨架,mock 数据必须保持在前端组件域内,避免被误用成业务真相源。 -export const mockVisualNovelDraft: VisualNovelResultDraft = { - profileId: 'vn-profile-mock-1', +// 中文注释:VN-04 只提供 UI 骨架,本地预览数据必须保持在前端组件域内,避免被误用成业务真相源。 +export const visualNovelLocalPreviewDraft: VisualNovelResultDraft = { + profileId: 'vn-profile-preview-1', workTitle: '雪线电台', workDescription: '一列停在雪夜边境的列车,牵出旧电台、失踪乘客和未寄出的告白。', workTags: ['悬疑', '冬夜', '双人叙事'], @@ -140,12 +140,12 @@ export const mockVisualNovelDraft: VisualNovelResultDraft = { message: '封面待补齐。', }, ], - updatedAt: MOCK_NOW, + updatedAt: LOCAL_PREVIEW_NOW, }; -export const mockVisualNovelSession: VisualNovelAgentSessionSnapshot = { - sessionId: 'vn-session-mock-1', - ownerUserId: 'mock-user', +export const visualNovelLocalPreviewSession: VisualNovelAgentSessionSnapshot = { + sessionId: 'vn-session-preview-1', + ownerUserId: 'local-preview-user', sourceMode: 'idea', status: 'ready', messages: [ @@ -154,24 +154,24 @@ export const mockVisualNovelSession: VisualNovelAgentSessionSnapshot = { role: 'user', kind: 'chat', text: '想做一个雪夜列车和旧电台有关的悬疑视觉小说。', - createdAt: MOCK_NOW, + createdAt: LOCAL_PREVIEW_NOW, }, { id: 'vn-message-2', role: 'assistant', kind: 'summary', text: '底稿已整理到世界观、角色、场景和前两段剧情阶段。', - createdAt: MOCK_NOW, + createdAt: LOCAL_PREVIEW_NOW, }, ], - draft: mockVisualNovelDraft, + draft: visualNovelLocalPreviewDraft, pendingAction: { - actionId: 'vn-action-compile-mock', + actionId: 'vn-action-compile-preview', kind: 'compile_work_profile', label: '生成结果页', }, - createdAt: MOCK_NOW, - updatedAt: MOCK_NOW, + createdAt: LOCAL_PREVIEW_NOW, + updatedAt: LOCAL_PREVIEW_NOW, }; function createVisualNovelDraftId(prefix: string) { @@ -278,7 +278,7 @@ export function createBlankVisualNovelDraft( }; } -export function createMockVisualNovelSessionFromDraft( +export function createLocalPreviewVisualNovelSessionFromDraft( draft: VisualNovelResultDraft, ): VisualNovelAgentSessionSnapshot { const now = new Date().toISOString(); @@ -307,7 +307,7 @@ export function createMockVisualNovelSessionFromDraft( }; } -export function createMockVisualNovelRunFromDraft( +export function createLocalPreviewVisualNovelRunFromDraft( draft: VisualNovelResultDraft, ): VisualNovelRunSnapshot { const now = new Date().toISOString(); @@ -388,10 +388,10 @@ export function createMockVisualNovelRunFromDraft( }; } -export const mockVisualNovelRun: VisualNovelRunSnapshot = { - runId: 'vn-run-mock-1', - ownerUserId: 'mock-user', - profileId: 'vn-profile-mock-1', +export const visualNovelLocalPreviewRun: VisualNovelRunSnapshot = { + runId: 'vn-run-preview-1', + ownerUserId: 'local-preview-user', + profileId: 'vn-profile-preview-1', mode: 'test', status: 'active', currentSceneId: 'vn-scene-platform', @@ -408,7 +408,7 @@ export const mockVisualNovelRun: VisualNovelRunSnapshot = { history: [ { entryId: 'vn-history-1', - runId: 'vn-run-mock-1', + runId: 'vn-run-preview-1', turnIndex: 1, source: 'assistant', actionText: null, @@ -432,24 +432,24 @@ export const mockVisualNovelRun: VisualNovelRunSnapshot = { }, ], snapshotBeforeHash: null, - snapshotAfterHash: 'mock-hash-1', - createdAt: MOCK_NOW, + snapshotAfterHash: 'preview-hash-1', + createdAt: LOCAL_PREVIEW_NOW, }, { entryId: 'vn-history-2', - runId: 'vn-run-mock-1', + runId: 'vn-run-preview-1', turnIndex: 2, source: 'player', actionText: '靠近广播柜,确认频段来源,同时留意林遥是否在隐瞒什么。', steps: [], - snapshotBeforeHash: 'mock-hash-1', - snapshotAfterHash: 'mock-hash-2', - createdAt: MOCK_NOW, + snapshotBeforeHash: 'preview-hash-1', + snapshotAfterHash: 'preview-hash-2', + createdAt: LOCAL_PREVIEW_NOW, }, ], - availableChoices: mockVisualNovelDraft.opening.initialChoices, + availableChoices: visualNovelLocalPreviewDraft.opening.initialChoices, textModeEnabled: true, - createdAt: MOCK_NOW, - updatedAt: MOCK_NOW, + createdAt: LOCAL_PREVIEW_NOW, + updatedAt: LOCAL_PREVIEW_NOW, };