miaoda-expo-devkit 0.1.1-beta.1 → 0.1.1-beta.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +635 -8
  2. package/biome-config.json +52 -0
  3. package/dist/babel/plugin-jsx-source.d.ts +55 -0
  4. package/dist/babel/plugin-jsx-source.js +129 -18
  5. package/dist/babel/plugin-lucide-react-native.d.ts +31 -0
  6. package/dist/babel/plugin-lucide-react-native.js +14790 -0
  7. package/dist/babel/preset.d.ts +32 -0
  8. package/dist/babel/preset.js +68 -0
  9. package/dist/cli/lint.js +17504 -0
  10. package/dist/metro.d.mts +456 -71
  11. package/dist/metro.d.ts +456 -71
  12. package/dist/metro.js +599 -67
  13. package/dist/metro.mjs +587 -67
  14. package/dist/rules/no-duplicate-expo-router-url.js +136 -0
  15. package/dist/rules/no-expo-audio-compat.js +2041 -0
  16. package/dist/rules/no-expo-video-compat.js +2041 -0
  17. package/dist/rules/no-gifted-charts-missing-linear-gradient.js +101 -0
  18. package/dist/rules/no-invalid-expo-config-value.js +141 -0
  19. package/dist/rules/no-invalid-notification-config.js +228 -0
  20. package/dist/rules/no-invalid-tabs-screen.js +193 -0
  21. package/dist/rules/no-missing-css-import.js +100 -0
  22. package/dist/rules/no-missing-image-import.js +105 -0
  23. package/dist/rules/no-missing-notification-asset.js +138 -0
  24. package/dist/rules/no-pressable-function-style.js +78 -0
  25. package/dist/rules/no-pressable-without-on-press.js +77 -0
  26. package/dist/rules/no-rn-alert.js +57 -0
  27. package/dist/rules/no-splash-screen-missing-image.js +140 -0
  28. package/dist/rules/no-undeclared-expo-plugin.js +181 -0
  29. package/dist/rules/no-unregistered-dynamic-tab-route.js +191 -0
  30. package/dist/rules/no-unstable-expo-router.js +63 -0
  31. package/dist/rules/no-unused-expo-plugin.js +211 -0
  32. package/dist/stubs/css-control.js +8 -6
  33. package/dist/stubs/entry-inject.js +4 -0
  34. package/dist/stubs/expo-blur-stub.js +29 -0
  35. package/dist/stubs/expo-calendar-stub.js +316 -0
  36. package/dist/stubs/expo-camera-record-stub.js +143 -0
  37. package/dist/stubs/expo-camera-stub.js +28 -0
  38. package/dist/stubs/expo-contacts-stub.js +310 -0
  39. package/dist/stubs/expo-file-system-next-stub.js +204 -0
  40. package/dist/stubs/expo-file-system-stub.js +215 -0
  41. package/dist/stubs/expo-haptics-stub.js +85 -0
  42. package/dist/stubs/expo-image-picker-stub.js +264 -0
  43. package/dist/stubs/expo-image-stub.js +28 -0
  44. package/dist/stubs/expo-linear-gradient-stub.js +28 -0
  45. package/dist/stubs/expo-media-library-stub.js +142 -0
  46. package/dist/stubs/expo-notifications-stub.js +178 -0
  47. package/dist/stubs/i18n/en.js +195 -0
  48. package/dist/stubs/i18n/index.js +66 -0
  49. package/dist/stubs/i18n/zh.js +198 -0
  50. package/dist/stubs/lgui-control.js +230 -65
  51. package/dist/stubs/navigation-guard-spy.js +86 -0
  52. package/dist/stubs/no-op-logbox.js +5 -2
  53. package/dist/stubs/preview-control.js +60 -0
  54. package/dist/stubs/screenshot-control.js +3729 -0
  55. package/dist/stubs/sentry-feedback-stub.js +60 -0
  56. package/dist/stubs/sentry-react-native-stub.js +1 -1
  57. package/dist/stubs/sentry-replay-canvas-stub.js +35 -0
  58. package/dist/stubs/sentry-replay-stub.js +41 -0
  59. package/dist/stubs/web-stub-dialog.js +169 -0
  60. package/dist/utils/navigation-guard-detector.js +57 -0
  61. package/oxlint-config.json +94 -0
  62. package/package.json +115 -29
  63. package/pnpm-config.json +16 -0
  64. package/pnpm-patches/@shopify__react-native-skia@2.4.18.patch +14 -0
  65. package/pnpm-patches/expo-modules-core@55.0.15.patch +21 -0
  66. package/pnpm-patches/expo@55.0.6.patch +22 -0
  67. package/pnpm-patches/react-native-css-interop@0.2.6.patch +12 -0
  68. package/pnpm-patches/react-native-reanimated@4.2.1.patch +34 -0
  69. package/pnpmfile.cjs +122 -0
  70. package/tsconfig-base.json +7 -0
  71. package/dist/babel/plugin-jsx-source.js.map +0 -1
  72. package/dist/index.js.map +0 -1
  73. package/dist/index.mjs.map +0 -1
  74. package/dist/metro.js.map +0 -1
  75. package/dist/metro.mjs.map +0 -1
  76. package/dist/stubs/css-control.js.map +0 -1
  77. package/dist/stubs/entry-inject.js.map +0 -1
  78. package/dist/stubs/expo-router-entry-stub.js.map +0 -1
  79. package/dist/stubs/hmr-control.js.map +0 -1
  80. package/dist/stubs/lgui-control.js.map +0 -1
  81. package/dist/stubs/no-op-logbox.js.map +0 -1
  82. package/dist/stubs/router-control.js.map +0 -1
  83. package/dist/stubs/sentry-react-native-stub.js.map +0 -1
package/README.md CHANGED
@@ -7,6 +7,20 @@ Expo / React Native 开发环境工具集,通过 Metro 构建层注入以下
7
7
  - **Bundle 首部注入** — 在 expo-router 初始化之前执行自定义脚本
8
8
  - **HMR postMessage 控制** — 通过 `window.postMessage` 在运行时启动或停止 Fast Refresh
9
9
  - **LogBox 屏蔽** — web 平台禁用 Expo 全屏错误遮罩
10
+ - **Metro transform 缓存持久化** — 将缓存目录固定到项目根目录(可通过 `METRO_CACHE_DIR` 指定),容器/CI 重启后不丢失
11
+ - **构建耗时日志** — 将 bundle 总耗时和每个 cache miss 文件的 transform 耗时写入 JSONL 文件(通过 `METRO_TRANSFORM_LOG` 启用)
12
+ - **esbuild minifier** — 生产构建时将 Metro minifier 切换为 esbuild(比 terser 快数十倍)
13
+ - **WASM 支持** — 将 `.wasm` 加入 assetExts,修复 expo-sqlite web worker 打包失败
14
+ - **lucide-react-native 路径解析** — 配合 babel 插件,消除图标子路径 exports 未声明的 warning
15
+ - **workspace node_modules 修复** — 修复沙箱环境中 node_modules 位于祖先目录时 Metro 模块解析和 bundle 请求失败的问题
16
+ - **expo-notifications stub** — Expo Go(Android)中提供 no-op 实现,核心 API 调用时弹出带参数校验的调试 Alert,Dev Build 透传真实模块
17
+ - **expo-media-library stub** — Expo Go / Web 中提供 no-op 实现,`saveToLibraryAsync`、`createAssetAsync`、权限请求等 API 调用时弹出 Alert 提示,Dev Build 原生环境透传真实模块
18
+ - **expo-calendar stub** — Expo Go / Web 中提供 no-op 实现,`getEventsAsync`、`createEventAsync`、`getCalendarsAsync`、权限请求等核心 API 调用时弹出 Alert 提示并校验参数,Dev Build 原生环境透传真实模块
19
+ - **expo-file-system stub** — Web 中将 `expo-file-system` 和 `expo-file-system/legacy` 替换为 no-op stub,核心 API 弹 Dialog 提示,Expo Go / Dev Build 透传真实模块
20
+ - **expo-haptics stub** — Web 中提供 no-op 实现,触觉 API 调用时弹 Dialog 提示参数信息,native 不受影响
21
+ - **expo-contacts stub** — Web / Expo Go 中提供 no-op 实现,联系人 API 调用时弹 Dialog 提示,Dev Build 透传真实模块
22
+ - **expo-image-picker stub** — 桌面 Web 中 `launchCameraAsync` 通过 `getUserMedia` 打开摄像头预览弹窗(浏览器原生 `capture` 属性在 PC 端被忽略),移动端浏览器透传 expo 原实现,Native 不受影响
23
+ - **devkit-lint** — 集成 Oxlint(18 条自定义规则)、Biome、TypeScript 类型检查、以及 `app.json` 字段合法性校验,一条命令完成项目全量静态检查
10
24
 
11
25
  ## 安装
12
26
 
@@ -32,16 +46,16 @@ pnpm install
32
46
  ```js
33
47
  // metro.config.js
34
48
  const { getDefaultConfig } = require('expo/metro-config');
35
- const { withDevStubs, withEntryInjection } = require('miaoda-expo-devkit/metro');
49
+ const { withDevkit } = require('miaoda-expo-devkit/metro');
36
50
 
37
- const config = getDefaultConfig(__dirname);
38
- module.exports = withEntryInjection(withDevStubs(config));
51
+ module.exports = withDevkit(getDefaultConfig(__dirname));
39
52
  ```
40
53
 
41
- 支持与其他 Metro wrapper 链式组合:
54
+ `withDevkit` 已内置所有 wrapper(含 expo-notifications、expo-media-library、expo-calendar stub),无需手动叠加。也可单独使用各 wrapper:
42
55
 
43
56
  ```js
44
- module.exports = withNativeWind(withEntryInjection(withDevStubs(config)), { input: './global.css' });
57
+ const { withDevStubs, withEntryInjection, withExpoMediaLibraryStub, withExpoCalendarStub } = require('miaoda-expo-devkit/metro');
58
+ module.exports = withExpoCalendarStub(withExpoMediaLibraryStub(withEntryInjection(withDevStubs(config))));
45
59
  ```
46
60
 
47
61
  ### Sentry 初始化
@@ -220,18 +234,88 @@ expect(onError).toHaveBeenCalledWith(
220
234
  | 变量 | 默认值 | 说明 |
221
235
  |---|---|---|
222
236
  | `SENTRY_OVERRIDE_DSN` | `https://stubPublicKey@o0.ingest.sentry.io/0` | 覆盖 Sentry DSN,可指向本地 relay 等自定义端点 |
237
+ | `METRO_CACHE_DIR` | `projectRoot/.metro-cache` | Metro transform 缓存目录绝对路径,优先级最高,适合容器/CI 挂载外部持久目录 |
238
+ | `METRO_TRANSFORM_LOG` | _(未设置时不记录)_ | 构建日志输出文件的绝对路径(JSONL 格式),仅开发模式(`__DEV__`)下生效 |
223
239
 
224
240
  ## 工作原理
225
241
 
226
242
  ```
227
243
  metro.config.js
228
- └─ withEntryInjection(withDevStubs(config))
244
+ └─ withDevkit(config)
245
+ │
246
+ ├─ withTransformLogger → unstable_perfLoggerFactory + metro-core Logger
247
+ │ ├─ CACHE_CONFIG 条目(启动时由 withPersistentCache 写入,含 cache_root / store_class / source)
248
+ │ ├─ BUNDLING_REQUEST 条目(每次 bundle 请求写一条)
249
+ │ │ ├─ duration_ms、status、initial_build、graph_node_count
250
+ │ │ └─ transform_miss_count(cache miss 文件数;0 = 完全命中)
251
+ │ └─ TRANSFORM_FILE 条目(每个 cache miss 文件写一条,命中则不写)
252
+ │ └─ file、duration_ms
253
+ │ (需设置 METRO_TRANSFORM_LOG 且 __DEV__ 才生效)
254
+ │
255
+ ├─ withPersistentCache → config.cacheStores
256
+ │ ├─ 缓存路径优先级:METRO_CACHE_DIR > options.cacheDir > projectRoot/.metro-cache
257
+ │ └─ 保留 @expo/metro-config FileStore 子类行为(NativeWind skipCache 标志)
258
+ │
259
+ ├─ withWorkspaceNodeModules → watchFolders + resolver.nodeModulesPaths
260
+ │ ├─ 向上查找祖先目录的 node_modules,加入 watchFolders
261
+ │ └─ 若 .pnpm 是指向外部路径的 symlink,也将外部真实路径加入 watchFolders
262
+ │
263
+ ├─ withWasmSupport → resolver.assetExts
264
+ │ └─ 将 .wasm 加入 assetExts,修复 expo-sqlite web worker 打包
265
+ │
266
+ ├─ withCssInterop → 为 expo-image / expo-camera 等注入 NativeWind cssInterop
267
+ │
268
+ ├─ withEsbuildMinify → transformer.minifierPath(仅生产构建)
269
+ │ └─ 切换为 metro-minify-esbuild,清空 terser 专属 minifierConfig
270
+ │
271
+ ├─ withLucideResolver → resolver.resolveRequest
272
+ │ └─ lucide-react-native/dist/** 子路径 → 绝对文件路径(绕过 exports 检查)
273
+ │ └─ 自动检测 .mjs(>= 1.9)vs .js(1.8.x)扩展名
229
274
  │
230
275
  ├─ withDevStubs → resolver.resolveRequest
231
276
  │ ├─ @sentry/react-native → dist/stubs/sentry-react-native-stub.js (全平台)
232
277
  │ └─ @expo/log-box → dist/stubs/no-op-logbox.js (仅 web)
233
278
  │
234
- └─ withEntryInjection → resolver.resolveRequest
279
+ ├─ withExpoNotificationsStub → resolver.resolveRequest
280
+ │ └─ expo-notifications → dist/stubs/expo-notifications-stub.js (仅 Android)
281
+ │ ├─ Expo Go:no-op + 调试 Alert(含参数校验)
282
+ │ └─ Dev Build:透传真实 expo-notifications
283
+ │
284
+ ├─ withExpoMediaLibraryStub → resolver.resolveRequest
285
+ │ └─ expo-media-library → dist/stubs/expo-media-library-stub.js (全平台)
286
+ │ ├─ Expo Go / Web:no-op + Alert 提示(不崩溃)
287
+ │ └─ Dev Build(原生):透传真实 expo-media-library
288
+ │
289
+ ├─ withExpoCalendarStub → resolver.resolveRequest
290
+ │ └─ expo-calendar → dist/stubs/expo-calendar-stub.js (全平台)
291
+ │ ├─ Expo Go / Web:no-op + Alert 提示 + 参数校验(不崩溃)
292
+ │ └─ Dev Build(原生):透传真实 expo-calendar
293
+ │
294
+ ├─ withExpoFileSystemStub → resolver.resolveRequest
295
+ │ ├─ expo-file-system/legacy → dist/stubs/expo-file-system-stub.js (全平台)
296
+ │ └─ expo-file-system → dist/stubs/expo-file-system-next-stub.js
297
+ │ ├─ Web:no-op + Dialog 提示(不崩溃)
298
+ │ └─ Expo Go / Dev Build(原生):透传真实模块
299
+ │
300
+ ├─ withExpoHapticsStub → resolver.resolveRequest
301
+ │ └─ expo-haptics → dist/stubs/expo-haptics-stub.js (仅 web)
302
+ │ ├─ Web:no-op + Dialog 提示(不崩溃)
303
+ │ └─ native:透传真实 expo-haptics
304
+ │
305
+ ├─ withExpoContactsStub → resolver.resolveRequest
306
+ │ └─ expo-contacts → dist/stubs/expo-contacts-stub.js (仅 web)
307
+ │ ├─ Web / Expo Go:no-op + Dialog 提示(不崩溃)
308
+ │ └─ Dev Build(原生):透传真实 expo-contacts
309
+ │
310
+ ├─ withExpoImagePickerStub → resolver.resolveRequest
311
+ │ └─ expo-image-picker → dist/stubs/expo-image-picker-stub.js (仅 web)
312
+ │ ├─ 桌面 Web:launchCameraAsync → getUserMedia + #__devkit_camera_overlay__ 弹窗
313
+ │ │ ├─ 点"拍照":canvas.toDataURL → ImagePickerResult { canceled:false, assets }
314
+ │ │ └─ 点"取消":返回 { canceled:true, assets:null }
315
+ │ ├─ 移动端浏览器:透传 expo 原实现(capture 属性正常工作)
316
+ │ └─ getUserMedia 不可用:降级透传 expo 原实现(不崩溃)
317
+ │
318
+ └─ withEntryInjection → resolver.resolveRequest(仅 __DEV__)
235
319
  └─ expo-router/entry-classic → dist/stubs/expo-router-entry-stub.js
236
320
  ├─ require('./entry-inject') ← 注入脚本(bundle 首部执行)
237
321
  │ ├─ globalThis.__DEVKIT_INJECTED__ = true
@@ -262,6 +346,549 @@ sentry-react-native-stub.js
262
346
  | 子路径 | 文件 | 内容 |
263
347
  |---|---|---|
264
348
  | `.` | `dist/index.js` | `SentryCapture`、`MetroSymbolicator`、全部类型 |
265
- | `./metro` | `dist/metro.js` | `withDevStubs`、`withEntryInjection` |
349
+ | `./metro` | `dist/metro.js` | `withDevkit`、`withDevStubs`、`withEntryInjection`、`withPersistentCache`、`withTransformLogger`、`withExpoNotificationsStub`、`withExpoMediaLibraryStub`、`withExpoCalendarStub` 等全部 Metro wrapper |
350
+ | `./babel-plugin-jsx-source` | `dist/babel/plugin-jsx-source.js` | Babel 插件:为 JSX 注入 source 信息 |
351
+ | `./babel-preset` | `dist/babel/preset.js` | Babel Preset:集成 jsx-source 和 lucide 插件 |
266
352
  | `./sentry-react-native-stub` | `dist/stubs/sentry-react-native-stub.js` | `@sentry/react-native` 模块替换 stub |
267
353
  | `./no-op-logbox` | `dist/stubs/no-op-logbox.js` | LogBox no-op stub |
354
+ | `./expo-notifications-stub` | `dist/stubs/expo-notifications-stub.js` | `expo-notifications` Expo Go Android stub |
355
+ | `./expo-media-library-stub` | `dist/stubs/expo-media-library-stub.js` | `expo-media-library` Expo Go / Web stub |
356
+ | `./expo-calendar-stub` | `dist/stubs/expo-calendar-stub.js` | `expo-calendar` Expo Go / Web stub |
357
+ | `./expo-file-system-stub` | `dist/stubs/expo-file-system-stub.js` | `expo-file-system/legacy` Web stub |
358
+ | `./expo-file-system-next-stub` | `dist/stubs/expo-file-system-next-stub.js` | `expo-file-system` 新版 API Web stub |
359
+ | `./expo-haptics-stub` | `dist/stubs/expo-haptics-stub.js` | `expo-haptics` Web stub |
360
+ | `./expo-contacts-stub` | `dist/stubs/expo-contacts-stub.js` | `expo-contacts` Web / Expo Go stub |
361
+ | `./expo-image-picker-stub` | `dist/stubs/expo-image-picker-stub.js` | `expo-image-picker` 桌面 Web stub |
362
+
363
+ ---
364
+
365
+ ## devkit-lint
366
+
367
+ `devkit-lint` 是内置的静态检查命令,一次执行涵盖四个阶段:
368
+
369
+ ```
370
+ oxlint → biome → tsc --noEmit → app.json schema 校验
371
+ ```
372
+
373
+ 所有阶段都会跑完再退出,最终汇总哪个阶段失败。
374
+
375
+ ### 使用
376
+
377
+ ```json
378
+ // package.json
379
+ {
380
+ "scripts": {
381
+ "lint": "devkit-lint"
382
+ }
383
+ }
384
+ ```
385
+
386
+ ```sh
387
+ bun run lint
388
+ ```
389
+
390
+ ### app.json 字段校验
391
+
392
+ `devkit-lint` 在最后一步用 [`@expo/schemer`](https://github.com/expo/expo/tree/main/packages/@expo/schemer) 对 `app.json` 做 JSON Schema 校验,拦截字段值错误(枚举值不合法、类型错误等)。
393
+
394
+ 典型场景:`"orientation": "all"` 不是合法的 Expo 枚举值(合法值为 `"default" | "portrait" | "landscape"`),这类错误原本只会在 Gradle/AAPT 阶段(构建约 1 分钟后)以晦涩的资源链接报错暴露,现在在 lint 阶段秒级报出:
395
+
396
+ ```
397
+ [app.json schema errors]
398
+ /path/to/project/app.json: field 'orientation' — must be equal to one of the allowed values (got: "all")
399
+
400
+ ────────────────────────────────────────────────────────────
401
+ RESULT: FAILED — Found errors in: app.json
402
+ ```
403
+
404
+ #### Schema 来源与存储
405
+
406
+ Expo 随每个 SDK 版本在其文档站发布版本化 JSON Schema(位于 `docs/public/static/schemas/v{VERSION}/app-config-schema.json`)。devkit 将对应版本的 schema 文件复制到 `src/schemas/app-config-schema-v55.json`,随 CLI bundle(`dist/cli/lint.js`)一起内联打包。
407
+
408
+ 这意味着:
409
+
410
+ - **完全离线**:不在运行时请求 `exp.host` 或任何外部服务,在无网络的 CI 容器中也能正常工作
411
+ - **版本固定**:schema 与 Expo SDK 55 对应,校验结果与 Expo 官方行为一致
412
+ - **零运行时依赖**:`@expo/schemer`(及其依赖 `ajv`)在构建时内联进 `dist/cli/lint.js`,消费方无需安装额外依赖
413
+
414
+ #### 升级 Expo SDK 时的 schema 更新
415
+
416
+ 当项目升级 Expo SDK 版本时,需同步更新 schema 文件:
417
+
418
+ ```sh
419
+ # 从 Expo 源码仓库复制新版 schema(以升级到 v56 为例)
420
+ cp /path/to/expo/docs/public/static/schemas/v56.0.0/app-config-schema.json \
421
+ packages/devkit/src/schemas/app-config-schema-v56.json
422
+
423
+ # 删除旧版 schema 文件
424
+ rm packages/devkit/src/schemas/app-config-schema-v55.json
425
+ ```
426
+
427
+ 并相应修改 `src/cli/lint.ts` 中的 import 路径:
428
+
429
+ ```ts
430
+ // 改为
431
+ import rawSchema from '../schemas/app-config-schema-v56.json';
432
+ ```
433
+
434
+ ---
435
+
436
+ ## Stubs 模块说明
437
+
438
+ ### entry-inject.js
439
+
440
+ 由 `expo-router-entry-stub.js` 在 `expo-router/entry-classic` 执行前 require,因此代码运行时机早于 expo-router 初始化和任何路由渲染。
441
+
442
+ **功能:**
443
+ - 设置全局标记 `globalThis.__DEVKIT_INJECTED__ = true`(供测试验证)
444
+ - 安装 HMR postMessage 控制器
445
+ - 安装 LGUI 可视化编辑器控制器
446
+ - 安装 CSS 注入控制器
447
+ - 安装路由消息控制器
448
+
449
+ ---
450
+
451
+ ### expo-router-entry-stub.js
452
+
453
+ Metro 的 `resolveRequest` 将 `expo-router/entry-classic` 重定向到此文件。
454
+
455
+ **执行顺序:**
456
+ 1. entry-inject(注入脚本)
457
+ 2. expo-router/entry-classic(原 expo-router 入口,负责注册 App)
458
+
459
+ ---
460
+
461
+ ### hmr-control.js
462
+
463
+ HMR postMessage 控制器,监听 window 上的 postMessage 消息来控制 HMR。
464
+
465
+ **消息格式:**
466
+ ```ts
467
+ window.postMessage({ type: 'devkit:hmr', action: 'enable' | 'disable' }, '*')
468
+ ```
469
+
470
+ **副作用:**
471
+ - 设置 `globalThis.__DEVKIT_HMR_ENABLED__`(初始值 true),随每条消息更新
472
+ - 在 `__DEV__` 模式下调用 `expo/src/async-require/hmr` 的 `enable()` / `disable()`,实际暂停或恢复 Fast Refresh
473
+
474
+ ---
475
+
476
+ ### css-control.js
477
+
478
+ CSS 注入控制器,为 LGUI 编辑器提供可视化高亮样式。
479
+
480
+ **高亮属性:**
481
+ - `data-editor-active`:选中元素(2px 实线边框)
482
+ - `data-editor-hover`:悬停元素(1px 实线边框)
483
+ - `data-editor-each`:同源兄弟元素(1px 虚线边框)
484
+ - `data-editor-full-width`:全宽元素(使用内缩 offset)
485
+
486
+ **导出函数:**
487
+ - `injectSelectorModeStyle()`:注入选择模式样式,返回移除函数
488
+ - `setupCSSInjectionControl()`:注入编辑器高亮 CSS(页面加载时注入,常驻)
489
+
490
+ ---
491
+
492
+ ### lgui-control.js
493
+
494
+ LGUI 可视化编辑器 postMessage 控制器,实现可视化编辑器与 iframe 内页面的双向通信。
495
+
496
+ **消息格式(父窗口 → iframe):**
497
+ ```ts
498
+ window.postMessage({ type: 'editor-inject' }, '*') // 初始化编辑器
499
+ window.postMessage({ type: 'editor-destroy' }, '*') // 销毁编辑器
500
+ ```
501
+
502
+ **消息格式(iframe → 父窗口):**
503
+ ```ts
504
+ parent.postMessage({ type: 'iframe-target-change', target: ElementInfo }, '*')
505
+ parent.postMessage({ type: 'iframe-scroll', target: Rect }, '*')
506
+ ```
507
+
508
+ **功能:**
509
+ - 监听鼠标事件(mouseover、mouseleave、click),处理 hover/active 状态
510
+ - 与父窗口通信,发送选中元素信息(位置、组件名、源码位置等)
511
+ - 观察选中节点属性变化,实时同步信息
512
+ - 高亮同源兄弟节点
513
+
514
+ ---
515
+
516
+ ### router-control.js
517
+
518
+ LGUI 路由控制器,监听父窗口 postMessage 处理路由导航和页面刷新。
519
+
520
+ **消息格式(父窗口 → iframe):**
521
+ ```ts
522
+ window.postMessage({ type: 'editor-location-update', pageName: string }, '*') // 更新路由
523
+ window.postMessage({ type: 'editor-refresh' }, '*') // 刷新页面
524
+ ```
525
+
526
+ **功能:**
527
+ - `editor-location-update`:更新路由(使用 `history.pushState`)
528
+ - `editor-refresh`:刷新页面(使用 `location.reload`)
529
+
530
+ ---
531
+
532
+ ### sentry-react-native-stub.js
533
+
534
+ `@sentry/react-native` 模块替换 stub,由 `withDevStubs()` 在 Metro 层自动注入。
535
+
536
+ **功能:**
537
+ - 将用户传入的 DSN 替换为无害的覆盖值(默认 `https://stubPublicKey@o0.ingest.sentry.io/0`)
538
+ - 自动注入内置 SentryCapture,监听并符号化 Sentry 错误事件
539
+ - 串联调用方传入的 `beforeSend` / `beforeBreadcrumb`(内置捕获器先执行)
540
+ - 向父窗口发送 `GLOBAL_ERROR` 事件,携带错误信息
541
+
542
+ **环境变量:**
543
+ - `SENTRY_OVERRIDE_DSN`:自定义覆盖 DSN(如指向本地 relay)
544
+
545
+ ---
546
+
547
+ ### no-op-logbox.js
548
+
549
+ LogBox no-op stub,用于 web 平台禁用 Expo 全屏错误遮罩。
550
+
551
+ 由 `withDevStubs()` 在 Metro 层将 `@expo/log-box` 和 `ErrorOverlayWebControls` 重定向到此文件。
552
+
553
+ ---
554
+
555
+ ### expo-notifications-stub.js
556
+
557
+ `expo-notifications` 模块替换 stub,由 `withExpoNotificationsStub()` 在 Metro 层注入(**仅 Android 平台**)。
558
+
559
+ **背景:** Expo SDK 53 起,`expo-notifications` 的 Android native module 已从 Expo Go 中移除,直接 import 会在 Expo Go 启动时崩溃。
560
+
561
+ **运行时行为:**
562
+ - **Expo Go(Android)**:提供 no-op 实现,`requestPermissionsAsync`、`setNotificationChannelAsync`、`scheduleNotificationAsync` 等核心 API 调用时弹出带参数校验的调试 Alert
563
+ - **Development Build(Android)**:透传真实 `expo-notifications`,功能完全正常
564
+ - **iOS(任意)**:不经过此 stub,直接使用真实 `expo-notifications`
565
+
566
+ **手动验证:** 在 `devkit-e2e` App 中扫码进入「Notification Stub 验证」页面,逐按钮触发并对照期望结果。
567
+
568
+ ---
569
+
570
+ ### expo-media-library-stub.js
571
+
572
+ `expo-media-library` 模块替换 stub,由 `withExpoMediaLibraryStub()` 在 Metro 层注入(**全平台**)。
573
+
574
+ **背景:** `expo-media-library` 依赖原生相册 API,在 Expo Go 和 Web 环境中不可用,调用 `saveToLibraryAsync` 等 API 会直接崩溃。
575
+
576
+ **运行时行为:**
577
+ - **Expo Go / Web**:提供 no-op 实现,以下 API 调用时弹出 Alert 提示(不崩溃):
578
+ - `usePermissions()` — 返回 `{ status: 'undetermined', granted: false }`,`requestPermission()` 弹 Alert
579
+ - `requestPermissionsAsync()` / `getPermissionsAsync()` — 前者弹 Alert,后者静默返回 denied
580
+ - `saveToLibraryAsync(uri)` — 弹 Alert 显示操作和 URI(超 60 字符自动截断)
581
+ - `createAssetAsync(uri)` — 弹 Alert 并返回合法的伪资产对象
582
+ - 其他未知 API — Proxy 兜底,静默返回 `undefined`;以 `PermissionsAsync` 结尾的 API 返回 denied 结构
583
+ - **Development Build(原生)**:透传真实 `expo-media-library`,功能完全正常
584
+
585
+ **Alert 消息格式:**
586
+ ```
587
+ 保存到相册
588
+ Expo Go 扫码预览不支持访问手机相册
589
+ 操作: 图片: file:///tmp/test.jpg
590
+ 发布为正式 App 后可正常使用
591
+ ```
592
+
593
+ **手动验证:** 在 `devkit-e2e` App 中扫码进入「Media Library Stub 验证」页面,逐按钮触发并对照期望结果。
594
+
595
+ ---
596
+
597
+ ### expo-calendar-stub.js
598
+
599
+ `expo-calendar` 模块替换 stub,由 `withExpoCalendarStub()` 在 Metro 层注入(**全平台**)。
600
+
601
+ **背景:** `expo-calendar` 依赖原生系统日历 API,在 Expo Go 和 Web 环境中不可用,调用 `getEventsAsync` 等 API 会直接崩溃。
602
+
603
+ **运行时行为:**
604
+ - **Expo Go / Web**:提供 no-op 实现,以下 API 调用时弹出 Alert / Dialog 提示(不崩溃):
605
+ - `useCalendarPermissions()` / `useRemindersPermissions()` — 初始返回 `undetermined`,`requestPermission()` 弹 Alert
606
+ - `requestCalendarPermissionsAsync()` / `requestRemindersPermissionsAsync()` — 弹 Alert 提示,返回 denied
607
+ - `getCalendarPermissionsAsync()` / `getRemindersPermissionsAsync()` — 静默返回 denied
608
+ - `getCalendarsAsync(entityType?)` — 弹 Alert 显示查询类型,返回空数组
609
+ - `createCalendarAsync(details)` — 弹 Alert 含名称和颜色,校验 `details.title` 非空
610
+ - `updateCalendarAsync(id, details)` / `deleteCalendarAsync(id)` — 弹 Alert 含日历 ID,校验 ID 格式
611
+ - `getEventsAsync(calendarIds, startDate, endDate)` — 弹 Alert 含时间范围,校验 calendarIds 非空及日期合法性,返回空数组
612
+ - `createEventAsync(calendarId, eventData)` — 弹 Alert 含标题和时间,Android 下校验 startDate/endDate
613
+ - `updateEventAsync(id, details)` / `deleteEventAsync(id)` — 弹 Alert 含事件 ID,校验 ID 格式
614
+ - 其他未知 API — Proxy 兜底,静默返回 `undefined`;以 `PermissionsAsync` 结尾的 API 返回 denied 结构
615
+ - **Development Build(原生)**:透传真实 `expo-calendar`,功能完全正常
616
+
617
+ **枚举常量**(stub & Dev Build 均可用):
618
+ `EntityTypes`、`Frequency`、`Availability`、`CalendarType`、`EventStatus`、`SourceType`、
619
+ `AttendeeRole`、`AttendeeStatus`、`AttendeeType`、`AlarmMethod`、`EventAccessLevel`、
620
+ `CalendarAccessLevel`、`ReminderStatus`、`DayOfTheWeek`、`MonthOfTheYear`
621
+
622
+ **Alert 消息格式(合规示例):**
623
+ ```
624
+ 创建日历事件
625
+ 秒哒扫码预览不支持访问手机日历
626
+
627
+ 日历 ID: cal1
628
+ 标题: 团队会议
629
+ 开始: 2025/1/1 10:00:00
630
+ 结束: 2025/1/1 11:00:00
631
+
632
+ ✅ 参数合规
633
+ 发布为正式 App 后可正常使用
634
+ ```
635
+
636
+ **手动验证:** 在 `devkit-e2e` App 中扫码进入「Calendar Stub 验证」页面,逐按钮触发并对照期望结果。
637
+
638
+ ---
639
+
640
+ ### expo-file-system-stub.js / expo-file-system-next-stub.js
641
+
642
+ `expo-file-system` 模块替换 stub,由 `withExpoFileSystemStub()` 在 Metro 层注入(**全平台**)。
643
+
644
+ **背景:** `expo-file-system` 的文件 API 在 Web 端不可用:legacy API 底层方法缺失会抛 `UnavailabilityError`;新版 API(`File` / `Directory`)基类缺少 `validatePath()`,`new File(...)` 会抛 `TypeError: this.validatePath is not a function`。
645
+
646
+ **拦截路径:**
647
+ - `expo-file-system/legacy` → `expo-file-system-stub.js`(legacy API)
648
+ - `expo-file-system` → `expo-file-system-next-stub.js`(新版 File/Directory/Paths API)
649
+
650
+ **运行时行为:**
651
+ - **Web**:提供 no-op 实现,核心 API 调用时弹出 Dialog 提示(不崩溃)
652
+ - **Expo Go / Development Build(原生)**:透传真实 `expo-file-system`,功能完全正常
653
+
654
+ ---
655
+
656
+ ### expo-haptics-stub.js
657
+
658
+ `expo-haptics` 模块替换 stub,由 `withExpoHapticsStub()` 在 Metro 层注入(**仅 web 平台**)。
659
+
660
+ **背景:** Web 没有振动/触觉硬件 API,`expo-haptics` 在 web 会运行时崩溃。
661
+
662
+ **运行时行为:**
663
+ - **Web**:提供 no-op 实现,`impactAsync`、`notificationAsync`、`selectionAsync` 调用时弹出 Dialog 提示参数信息(不崩溃);枚举常量(`ImpactFeedbackStyle`、`NotificationFeedbackType` 等)正常可用
664
+ - **Expo Go / Development Build(native)**:透传真实 `expo-haptics`,功能完全正常
665
+
666
+ ---
667
+
668
+ ### expo-contacts-stub.js
669
+
670
+ `expo-contacts` 模块替换 stub,由 `withExpoContactsStub()` 在 Metro 层注入(**仅 web 平台**)。
671
+
672
+ **背景:** `expo-contacts` 依赖 native module,在 Web 和 Expo Go 中不可用。
673
+
674
+ **运行时行为:**
675
+ - **Web / Expo Go**:提供 no-op 实现,联系人 API 调用时弹出 Dialog 提示(不崩溃)
676
+ - **Development Build(原生)**:透传真实 `expo-contacts`,功能完全正常
677
+
678
+ ---
679
+
680
+ ### expo-image-picker-stub.js
681
+
682
+ `expo-image-picker` 模块替换 stub,由 `withExpoImagePickerStub()` 在 Metro 层注入(**仅 web 平台**)。
683
+
684
+ **背景:** `expo-image-picker` 的 `launchCameraAsync` 在 web 上底层使用 `<input type="file" capture="environment">`。桌面浏览器(Chrome、Firefox、Safari)**有意忽略 `capture` 属性**,直接弹出文件选择框而不是摄像头。这是浏览器厂商的硬限制,无法通过任何 HTML 属性或 meta 标签绕过。
685
+
686
+ **运行时行为:**
687
+ - **桌面 Web(非 mobile UA)**:`launchCameraAsync` 调用 `navigator.mediaDevices.getUserMedia` 打开摄像头预览弹窗(`#__devkit_camera_overlay__`),用户点击"📷 拍照"后截取一帧并以 `data:image/jpeg` 格式返回,点击"取消"返回 `{ canceled: true }`
688
+ - **移动端浏览器(Android Chrome / iOS Safari)**:透传 expo 原实现,`capture` 属性在移动端正常调起系统相机
689
+ - **`getUserMedia` 不可用**(无摄像头或浏览器限制):静默降级,透传 expo 原实现(不崩溃)
690
+ - **Native(iOS / Android)**:Metro resolver 不拦截,直接使用原生 `expo-image-picker`,功能完全不变
691
+
692
+ **弹窗 DOM 结构(供 Playwright 定位):**
693
+ ```html
694
+ <div id="__devkit_camera_overlay__"> <!-- 全屏遮罩 -->
695
+ <div> <!-- 面板 -->
696
+ <p>拍照</p>
697
+ <video autoplay muted playsinline> <!-- 摄像头预览 -->
698
+ <div>
699
+ <button>📷 拍照</button>
700
+ <button>取消</button>
701
+ </div>
702
+ <p><!-- 错误信息(getUserMedia 失败时显示) --></p>
703
+ </div>
704
+ </div>
705
+ ```
706
+
707
+ **返回值格式(与 expo 原实现兼容):**
708
+ ```ts
709
+ // 拍照成功
710
+ {
711
+ canceled: false,
712
+ assets: [{
713
+ uri: 'data:image/jpeg;base64,...',
714
+ width: 640,
715
+ height: 480,
716
+ type: 'image',
717
+ fileName: 'photo_1234567890.jpg',
718
+ mimeType: 'image/jpeg',
719
+ base64: null,
720
+ exif: null,
721
+ }]
722
+ }
723
+
724
+ // 取消
725
+ { canceled: true, assets: null }
726
+ ```
727
+
728
+ **E2E 测试:** `devkit-e2e/tests/e2e/image-picker-stub.spec.ts`,使用 `addInitScript` mock `getUserMedia`(注入 canvas stream 替代真实摄像头),覆盖弹窗出现/消失、拍照返回结果、取消、降级等场景。
729
+
730
+ ---
731
+
732
+ ## Babel 插件:jsx-source
733
+
734
+ `babel-plugin-jsx-source` 为 JSX 元素注入 source 属性(文件路径、行列号),用于开发调试。通过 `dataSet` 对象注入,这是 React Web 可识别的数据通道。
735
+
736
+ ### 配置
737
+
738
+ ```js
739
+ // babel.config.js
740
+ module.exports = {
741
+ plugins: [
742
+ ['miaoda-expo-devkit/babel-plugin-jsx-source', { rootDir: __dirname }]
743
+ ]
744
+ };
745
+ ```
746
+
747
+ ### 转换效果
748
+
749
+ 普通 JSX 元素:
750
+ ```tsx
751
+ // 转换前
752
+ <View style={styles.container} />
753
+
754
+ // 转换后
755
+ <View style={styles.container} dataSet={{"mdId": "path/to/file.tsx:10:4"}} />
756
+ ```
757
+
758
+ 纯文本节点(会添加 `componentContent`,使用 URL 编码格式):
759
+ ```tsx
760
+ // 转换前
761
+ <div>hello world</div>
762
+
763
+ // 转换后
764
+ <div dataSet={{"mdId": "path/to/file.tsx:10:4", "componentContent": "%7B%22text%22%3A%22hello%20world%22%7D"}}>hello world</div>
765
+ ```
766
+
767
+ `componentContent` 解码后为:
768
+ ```json
769
+ {"text":"hello world"}
770
+ ```
771
+
772
+ ### 选项
773
+
774
+ | 选项 | 类型 | 默认值 | 说明 |
775
+ |---|---|---|---|
776
+ | `rootDir` | `string` | - | 项目根目录,用于计算相对路径。不提供则使用绝对路径 |
777
+ | `excludePaths` | `string[]` | `[]` | 跳过注入的路径模式列表(相对于 rootDir 的路径片段) |
778
+
779
+ ---
780
+
781
+ ## Babel 插件:lucide-react-native
782
+
783
+ Metro 没有 tree-shaking,直接写 `import { Star } from "lucide-react-native"` 会把整个图标库(约 1500 个图标)打进 bundle。`babel-plugin-lucide-react-native` 在编译阶段将具名导入改写为直接按文件导入,彻底规避这个问题。
784
+
785
+ **转换效果:**
786
+
787
+ ```ts
788
+ // 转换前
789
+ import { Star, BookOpen } from "lucide-react-native";
790
+
791
+ // 转换后(CJS,默认)
792
+ import _Star from "lucide-react-native/dist/cjs/icons/star";
793
+ import _BookOpen from "lucide-react-native/dist/cjs/icons/book-open";
794
+ ```
795
+
796
+ ### 配置
797
+
798
+ 推荐通过内置 Preset 一次性启用所有 Babel 插件:
799
+
800
+ ```js
801
+ // babel.config.js
802
+ module.exports = {
803
+ presets: [['miaoda-expo-devkit/babel-preset', { excludePaths: ['src/components/ui'] }]],
804
+ };
805
+ ```
806
+
807
+ 也可以单独使用:
808
+
809
+ ```js
810
+ module.exports = {
811
+ plugins: [['miaoda-expo-devkit/babel-plugin-lucide-react-native']],
812
+ };
813
+ ```
814
+
815
+ ### 选项
816
+
817
+ | 选项 | 类型 | 默认值 | 说明 |
818
+ |---|---|---|---|
819
+ | `useES` | `boolean` | `false` | 使用 ESM 格式(`dist/esm/icons/`)而非默认的 CJS |
820
+
821
+ ### 支持的写法
822
+
823
+ 插件可识别所有常见的图标引用写法:
824
+
825
+ ```ts
826
+ import { BookOpen, Star, Crown } from "lucide-react-native";
827
+
828
+ // JSX 直接使用
829
+ <BookOpen size={24} />
830
+
831
+ // 赋值给变量
832
+ const icon = BookOpen;
833
+
834
+ // 对象 value
835
+ const ITEMS = [{ icon: BookOpen }, { icon: Star }];
836
+
837
+ // 计算属性 key
838
+ const map = { [BookOpen]: 'read' };
839
+
840
+ // 数组、三元、函数参数
841
+ const list = [BookOpen, Star];
842
+ const active = flag ? BookOpen : Star;
843
+ renderIcon(Crown);
844
+
845
+ // 导入别名
846
+ import { BookOpen as ReadIcon } from "lucide-react-native";
847
+ <ReadIcon />
848
+
849
+ // re-export
850
+ export { Star, BookOpen as ReadIcon } from "lucide-react-native";
851
+ ```
852
+
853
+ ### 版本兼容性
854
+
855
+ 插件在运行时探测 `lucide-react-native` 的实际目录结构,自动适配新旧版本的路径差异(`>= 1.9` 新增 `icons/` 子目录;旧版 `1.8.x` 图标直接位于 `dist/cjs/`),lucide 升级时无需修改配置。
856
+
857
+ ---
858
+
859
+ ## pnpm 配置依赖与兼容补丁
860
+
861
+ DevKit 可以作为 pnpm `configDependency`,在普通依赖解析前集中注入经过验证的 Expo/React Native 兼容补丁。要求 pnpm `>=10.32.0`。
862
+
863
+ 发布新版 DevKit 后,在应用工作区根目录执行:
864
+
865
+ ```bash
866
+ pnpm add --config --registry=https://registry.npmjs.org/ miaoda-expo-devkit@<version>
867
+ ```
868
+
869
+ 由于现有包名不属于 pnpm 的自动插件命名空间,还需要在 `pnpm-workspace.yaml` 中指定 hook:
870
+
871
+ ```yaml
872
+ pnpmfile:
873
+ - node_modules/.pnpm-config/miaoda-expo-devkit/pnpmfile.cjs
874
+ ```
875
+
876
+ 本仓库的 `scripts/build.sh` 会自动把生成 Template 的 `pnpmfile` 改为上述 config dependency 路径,并写入精确版本和完整性校验;无需在生成产物中维护额外的 `.pnpmfile.cjs`。
877
+
878
+ DevKit 当前管理以下补丁:
879
+
880
+ - `@shopify/react-native-skia@2.4.18`
881
+ - `expo@55.0.6`(固定 `expo-audio@55.0.6` 和 `expo-video@55.0.5` 的 Expo 兼容版本表)
882
+ - `expo-modules-core@55.0.15`
883
+ - `react-native-css-interop@0.2.6`
884
+ - `react-native-reanimated@4.2.1`
885
+
886
+ 迁移历史项目时,hook 会计算同名本地 patch 的 SHA-256:已知模板 patch 会自动切换到 DevKit 内置文件;内容不同的项目定制 patch 会继续优先,并输出警告。项目中其他 `patchedDependencies` 不受影响。迁移后必须重新生成并提交 `pnpm-lock.yaml`。
887
+
888
+ 发布前可用一条命令运行轻量集成测试:
889
+
890
+ ```bash
891
+ pnpm --dir packages/devkit test:patches
892
+ ```
893
+
894
+ 该命令会自动构建并打包 DevKit,在临时项目中模拟 pnpm 安装 Config Dependency 后的目录布局,安装五个精确版本的目标依赖,检查补丁后的文件及 lockfile,然后执行 frozen-lockfile 复装并清理临时目录。它不会运行 Expo Prebuild、Gradle 或 Android 编译。