tmui-cli 2.0.4 → 2.0.6

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 (197) hide show
  1. package/README.md +2 -2
  2. package/data/components.json +1 -1
  3. package/data/config.json +1 -1
  4. package/data/doc-index.json +1 -1
  5. package/data/examples/radio.vue +4 -0
  6. package/data/guides/cli.md +8 -4
  7. package/data/guides//346/233/264/346/226/260/346/227/245/345/277/227.md +2 -0
  8. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/01./346/217/222/344/273/266/347/233/256/345/275/225.md +0 -0
  9. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/tmOcr /347/246/273/347/272/277/346/226/207/346/241/243/350/257/206/345/210/253.md +0 -0
  10. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xActionmodalS /345/272/225/351/203/250/345/206/205/345/256/271/346/212/275/345/261/211.md +0 -0
  11. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xActionsheetS /345/272/225/351/203/250/345/212/250/344/275/234/350/217/234/345/215/225.md" +2 -0
  12. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xAddphonerepeatcalendarS /347/263/273/347/273/237/346/227/245/345/216/206.md +0 -0
  13. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xAppiconS /345/272/224/347/224/250/345/233/276/346/240/207/344/270/216/350/247/222/346/240/207.md" +14 -7
  14. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xBluetoothS /350/223/235/347/211/231.md +0 -0
  15. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xBlurU /350/203/214/346/231/257/346/257/233/347/216/273/347/222/203.md +0 -0
  16. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xCamreaU /345/216/237/347/224/237/347/233/270/346/234/272.md +0 -0
  17. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xChatmarkdownU AI/350/201/212/345/244/251Markdown.md +0 -0
  18. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xChinesetopinyinS /344/270/255/346/226/207/350/275/254/346/213/274/351/237/263.md +0 -0
  19. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xChooseFileS /346/226/207/344/273/266/351/200/211/346/213/251.md +0 -0
  20. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xChooseMediaS /345/252/222/344/275/223/351/200/211/346/213/251/345/231/250.md +0 -0
  21. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xCorrectS /346/226/207/346/241/243/346/240/241/346/255/243/344/270/216/350/257/206/345/210/253.md" +253 -3
  22. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xCryptoS /345/212/240/350/247/243/345/257/206.md +0 -0
  23. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xDesktopcardS /346/241/214/351/235/242/345/215/241/347/211/207/344/270/216/345/256/236/345/206/265.md +0 -0
  24. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xEditorU /345/257/214/346/226/207/346/234/254/347/274/226/350/276/221.md +0 -0
  25. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xFacedetectAlibabaS /351/230/277/351/207/214/344/272/221/345/256/236/344/272/272/350/256/244/350/257/201.md +0 -0
  26. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xFacerecU /347/246/273/347/272/277/344/272/272/350/204/270/350/257/206/345/210/253.md +0 -0
  27. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xFfmpegS /351/237/263/350/247/206/351/242/221/345/244/204/347/220/206.md +0 -0
  28. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xHtmlU HTML/345/256/211/345/205/250/346/270/262/346/237/223.md +0 -0
  29. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xKeyboardS /350/275/257/351/224/256/347/233/230.md +0 -0
  30. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xLoadingS /345/205/250/345/261/217/345/212/240/350/275/275.md +0 -0
  31. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xLocationS /345/256/232/344/275/215.md +0 -0
  32. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xMakephoneS /346/213/250/346/211/223/347/224/265/350/257/235.md +0 -0
  33. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xMediaplayS /345/252/222/344/275/223/346/222/255/346/224/276/344/270/216/345/275/225/345/210/266.md +0 -0
  34. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xMimeS /346/226/207/344/273/266/347/261/273/345/236/213/345/227/205/346/216/242.md" +10 -0
  35. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xMlkitScannigS /345/205/250/345/261/217/346/211/253/347/240/201.md +0 -0
  36. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xMlkitScannigU /346/211/253/347/240/201/345/256/271/345/231/250.md +0 -0
  37. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xMnnkitS /347/253/257/344/276/247/345/244/247/346/250/241/345/236/213.md +0 -0
  38. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xModalS /345/257/271/350/257/235/346/241/206.md +0 -0
  39. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xModel3dU.md +0 -0
  40. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xMoreshareS /346/233/264/345/244/232/345/210/206/344/272/253/351/235/242/346/235/277.md +0 -0
  41. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xMqttS MQTT/346/266/210/346/201/257.md +0 -0
  42. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xMusicnotifyS /351/200/232/347/237/245/346/240/217/351/237/263/344/271/220/346/222/255/346/224/276/345/231/250.md +0 -0
  43. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xNetworkstatusS /347/275/221/347/273/234/347/212/266/346/200/201.md +0 -0
  44. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xNotifyS /346/234/254/345/234/260/351/200/232/347/237/245.md +0 -0
  45. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xOcrS /347/246/273/347/272/277/346/226/207/346/241/243/350/257/206/345/210/253.md +0 -0
  46. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xOfficeU Office /346/226/207/346/241/243/347/274/226/350/276/221.md" +1996 -0
  47. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xOnnxkitS /347/246/273/347/272/277/350/257/255/351/237/263/350/257/206/345/210/253/344/270/216/345/220/210/346/210/220.md +0 -0
  48. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xPdfS PDF/345/220/210/346/210/220.md" +24 -0
  49. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xPdfU PDF/351/242/204/350/247/210.md" +8 -1
  50. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xPermissionS /346/235/203/351/231/220.md +0 -0
  51. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xPhonecontactS /351/200/232/350/256/257/345/275/225.md +0 -0
  52. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xPhotokitS /345/233/276/347/211/207/346/260/264/345/215/260.md +0 -0
  53. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xPiniaS.md +122 -0
  54. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xPreviewMediaS /345/233/276/347/211/207/350/247/206/351/242/221/346/267/267/345/220/210/351/242/204/350/247/210.md +0 -0
  55. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xPrintS /347/263/273/347/273/237/346/211/223/345/215/260.md +0 -0
  56. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xScreenshotS /346/210/252/345/233/276/344/270/216/351/230/262/346/210/252/345/261/217.md +0 -0
  57. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xScreentgreyS /345/272/224/347/224/250/347/275/256/347/201/260.md +0 -0
  58. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xSendsmsS /345/217/221/351/200/201/347/237/255/344/277/241.md +0 -0
  59. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xShareS /347/263/273/347/273/237/345/210/206/344/272/253.md +0 -0
  60. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xSkiaU Skia/347/224/273/345/270/203.md +0 -0
  61. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xSqliteS SQLite/346/225/260/346/215/256/345/272/223.md +0 -0
  62. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xSseS SSE/346/216/250/351/200/201.md +0 -0
  63. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xStartintentS /346/211/223/345/274/200/345/215/217/350/256/256/351/223/276/346/216/245.md +0 -0
  64. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xSvgU SVG/347/273/204/344/273/266.md +0 -0
  65. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xSwiperU /345/216/237/347/224/237/350/275/256/346/222/255.md +0 -0
  66. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xTexttovoiceS /346/226/207/346/234/254/350/275/254/350/257/255/351/237/263.md +0 -0
  67. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xTianditumapU /345/244/251/345/234/260/345/233/276.md +0 -0
  68. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xTipsS /346/266/210/346/201/257/346/217/220/347/244/272.md +0 -0
  69. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xToastS /350/275/273/346/217/220/347/244/272.md +0 -0
  70. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xUdpS UDP/351/200/232/344/277/241.md +0 -0
  71. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xUtilsS Base64/344/270/216/346/226/207/344/273/266/344/272/222/350/275/254.md +0 -0
  72. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xVibrateS /351/234/207/345/212/250.md +0 -0
  73. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xWebviewU /346/267/267/345/220/210WebView.md +0 -0
  74. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xWechatshareS /345/276/256/344/277/241/345/274/200/346/224/276/345/271/263/345/217/260.md +0 -0
  75. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xYoloS YOLO/347/233/256/346/240/207/346/243/200/346/265/213.md +0 -0
  76. package/data/tmui4x/10./346/217/222/344/273/266/345/272/223/xZipS ZIP/345/216/213/347/274/251/350/247/243/345/216/213.md +0 -0
  77. package/data/tmui4x/60.AI/350/207/252/345/212/250/345/214/226/01.Agent.md +42 -16
  78. package/data/tmui4x/60.AI/350/207/252/345/212/250/345/214/226/01./345/211/215/350/250/200.md +20 -0
  79. package/data/tmui4x/60.AI/350/207/252/345/212/250/345/214/226/04.SKILL.md +9 -8
  80. package/data/tmui4x-doc-index.json +40 -4
  81. package/data/uni-app-x-docs/.vuepress/utils/cssJson.json +1 -1
  82. package/data/uni-app-x-docs/.vuepress/utils/example-mapping.json +7 -0
  83. package/data/uni-app-x-docs/.vuepress/utils/utsApiJson.json +1 -1
  84. package/data/uni-app-x-docs/.vuepress/utils/utsComJson.json +1 -1
  85. package/data/uni-app-x-docs/.vuepress/utils/vueJson.json +1 -1
  86. package/data/uni-app-x-docs/_sidebar.md +6 -1
  87. package/data/uni-app-x-docs/api/get-location.md +6 -6
  88. package/data/uni-app-x-docs/api/modal.md +2 -1
  89. package/data/uni-app-x-docs/api/request.md +1 -0
  90. package/data/uni-app-x-docs/api/toast.md +2 -2
  91. package/data/uni-app-x-docs/api/window.md +2 -0
  92. package/data/uni-app-x-docs/collocation/app.md +10 -5
  93. package/data/uni-app-x-docs/collocation/manifest-android.md +15 -15
  94. package/data/uni-app-x-docs/collocation/manifest-ios.md +17 -16
  95. package/data/uni-app-x-docs/collocation/manifest-modules.md +10 -10
  96. package/data/uni-app-x-docs/component/map.md +10 -8
  97. package/data/uni-app-x-docs/component/video.md +8 -4
  98. package/data/uni-app-x-docs/css/_sidebar.md +0 -1
  99. package/data/uni-app-x-docs/css/common/function.md +8 -4
  100. package/data/uni-app-x-docs/css/common/selector.md +2 -0
  101. package/data/uni-app-x-docs/mp/README.md +0 -4
  102. package/data/uni-app-x-docs/mp/native-component-and-wxs.md +166 -0
  103. package/data/uni-app-x-docs/native/download/android.md +106 -19
  104. package/data/uni-app-x-docs/native/download/ios.md +11 -9
  105. package/data/uni-app-x-docs/page.md +2 -0
  106. package/data/uni-app-x-docs/project.md +6 -7
  107. package/data/uni-app-x-docs/release-note-alpha.md +142 -0
  108. package/data/uni-app-x-docs/release.md +263 -0
  109. package/data/uni-app-x-docs/select/native-render-and-self-render.md +386 -0
  110. package/data/uni-app-x-docs/tutorial/android-16kb.md +5 -1
  111. package/data/uni-app-x-docs/tutorial/app-env.md +1 -1
  112. package/data/uni-app-x-docs/tutorial/app-update-wgt.md +13 -0
  113. package/data/uni-app-x-docs/tutorial/store.md +0 -1
  114. package/data/uni-app-x-docs/uts/README.md +27 -25
  115. package/data/uni-app-x-docs/vue/README.md +64 -29
  116. package/data/uni-app-x-docs/vue/built-in.md +1 -1
  117. package/data/uni-app-x-docs/vue/component.md +1 -1
  118. package/data/uni-app-x-docs/vue/composition-api.md +6 -19
  119. package/data/uni-app-x-docs/vue/global-api.md +1 -1
  120. package/data/uniappx/_sidebar.md +6 -1
  121. package/data/uniappx/api/create-editor-context-async.md +3 -3
  122. package/data/uniappx/api/get-app-base-info.md +14 -14
  123. package/data/uniappx/api/get-file-system-manager.md +2 -2
  124. package/data/uniappx/api/get-location.md +6 -6
  125. package/data/uniappx/api/modal.md +2 -1
  126. package/data/uniappx/api/on-tab-bar-mid-button-tap.md +4 -4
  127. package/data/uniappx/api/request.md +1 -0
  128. package/data/uniappx/api/toast.md +4 -4
  129. package/data/uniappx/api/window.md +2 -0
  130. package/data/uniappx/collocation/app.md +10 -5
  131. package/data/uniappx/collocation/manifest-android.md +15 -15
  132. package/data/uniappx/collocation/manifest-ios.md +17 -16
  133. package/data/uniappx/collocation/manifest-modules.md +10 -10
  134. package/data/uniappx/component/checkbox.md +2 -2
  135. package/data/uniappx/component/input.md +9 -15
  136. package/data/uniappx/component/label.md +4 -3
  137. package/data/uniappx/component/map.md +19 -14
  138. package/data/uniappx/component/swiper.md +31 -67
  139. package/data/uniappx/component/switch.md +3 -9
  140. package/data/uniappx/component/textarea.md +4 -8
  141. package/data/uniappx/component/uni-ui-x/uni-badge-view.md +2 -2
  142. package/data/uniappx/component/uni-ui-x/uni-collapse.md +11 -55
  143. package/data/uniappx/component/uni-ui-x/uni-fab-button.md +1 -1
  144. package/data/uniappx/component/uni-ui-x/uni-link.md +1 -1
  145. package/data/uniappx/component/uni-ui-x/uni-number-box.md +2 -2
  146. package/data/uniappx/component/uni-ui-x/uni-rate.md +2 -2
  147. package/data/uniappx/component/uni-ui-x/uni-refresh-box.md +1 -1
  148. package/data/uniappx/component/uni-ui-x/uni-tab.md +44 -2
  149. package/data/uniappx/component/video.md +16 -13
  150. package/data/uniappx/component/web-view.md +10 -21
  151. package/data/uniappx/css/_sidebar.md +1 -2
  152. package/data/uniappx/css/common/function.md +7 -4
  153. package/data/uniappx/css/common/selector.md +14 -14
  154. package/data/uniappx/mp/README.md +0 -4
  155. package/data/uniappx/mp/native-component-and-wxs.md +151 -0
  156. package/data/uniappx/native/download/android.md +106 -19
  157. package/data/uniappx/native/download/ios.md +11 -9
  158. package/data/uniappx/page.md +2 -0
  159. package/data/uniappx/project.md +5 -6
  160. package/data/uniappx/release-note-alpha.md +142 -0
  161. package/data/uniappx/release.md +263 -0
  162. package/data/uniappx/select/native-render-and-self-render.md +382 -0
  163. package/data/uniappx/tutorial/android-16kb.md +5 -1
  164. package/data/uniappx/tutorial/app-env.md +1 -1
  165. package/data/uniappx/tutorial/app-update-wgt.md +9 -0
  166. package/data/uniappx/uts/README.md +27 -25
  167. package/data/uniappx/vue/README.md +11 -82
  168. package/data/uniappx/vue/built-in.md +2 -2
  169. package/data/uniappx/vue/component.md +1 -1
  170. package/data/uniappx/vue/composition-api.md +2 -86
  171. package/data/uniappx/vue/global-api.md +1 -1
  172. package/data/uniappx/vue/modifier.md +3 -3
  173. package/data/uniappx-doc-index.json +44 -16
  174. package/dist/main.mjs +65 -65
  175. package/package.json +4 -3
  176. package/templates/uniappx-vapor/.editorconfig +9 -0
  177. package/templates/uniappx-vapor/App.uvue +51 -0
  178. package/templates/uniappx-vapor/index.html +20 -0
  179. package/templates/uniappx-vapor/main.uts +9 -0
  180. package/templates/uniappx-vapor/manifest.json +67 -0
  181. package/templates/uniappx-vapor/pages/index/index.uvue +24 -0
  182. package/templates/uniappx-vapor/pages.json +17 -0
  183. package/templates/uniappx-vapor/platformConfig.json +6 -0
  184. package/templates/uniappx-vapor/static/logo.png +0 -0
  185. package/templates/uniappx-vapor/uni.scss +76 -0
  186. package/templates/uniappx-vdom/.editorconfig +9 -0
  187. package/templates/uniappx-vdom/App.uvue +51 -0
  188. package/templates/uniappx-vdom/index.html +20 -0
  189. package/templates/uniappx-vdom/main.uts +9 -0
  190. package/templates/uniappx-vdom/manifest.json +66 -0
  191. package/templates/uniappx-vdom/pages/index/index.uvue +24 -0
  192. package/templates/uniappx-vdom/pages.json +17 -0
  193. package/templates/uniappx-vdom/platformConfig.json +6 -0
  194. package/templates/uniappx-vdom/static/logo.png +0 -0
  195. package/templates/uniappx-vdom/uni.scss +76 -0
  196. package/data/uni-app-x-docs/css/css_diff_web.md +0 -802
  197. package/data/uniappx/css/css_diff_web.md +0 -802
@@ -0,0 +1,1996 @@
1
+ # x-office-u Office 文档编辑
2
+
3
+ 基于 LibreOffice 内核(Collabora 的 COKit)的 Office 文档编辑组件。doc/docx/xls/xlsx/ppt/pptx/odf
4
+ 完整编辑,三端同一套编辑前端,文档解析与编辑全部在设备本地完成。
5
+
6
+ ### 兼容性
7
+
8
+ | Harmony | iOS | Android | WEB | 小程序 |
9
+ | --- | --- | --- | --- | --- |
10
+ | 支持 | 支持 | 支持 | 需自建部署 | 不支持 |
11
+
12
+ App 三端能力对等:编辑 UI 是同一套 Collabora Online 的 JS 前端,跑在各端 WebView 里
13
+ (Android WebView / iOS WKWebView / HarmonyOS ArkUI Web),各端只提供 WebView 宿主
14
+ 与引擎桥。因此三端不存在功能差集。
15
+
16
+ Web 端走 wasm,见文末「Web 端」。小程序不支持:既没有 native 扩展也起不了 wasm 线程。
17
+
18
+ ### 引擎产物不随插件仓库分发
19
+
20
+ 引擎是 200~300MB 量级的 native 产物,不进 git。插件在**没有引擎**的情况下也能编译、
21
+ 能加载,此时 `getRuntimeInfo().engineReady` 为 `false`,`open()` 会以
22
+ `errCode: 1801` 失败并在 `errMsg` 里给出该跑哪个脚本。出货前必须先编引擎:
23
+
24
+ ```bash
25
+ # 编辑前端(COOL):三端共用同一份,只需编一次。WebView 里的整套编辑 UI
26
+ # (工具栏、对话框、批注、公式栏)都在这里,缺了它插件只有 API 没有界面。
27
+ cd third_party/collabora/online
28
+ ./autogen.sh && ./configure --enable-androidapp --with-lo-builddir="$PWD/engine"
29
+ make # 产出 browser/dist,约 1 分钟
30
+
31
+ cd uni_modules/x-office-u/native-src
32
+
33
+ # HarmonyOS:本仓给 LibreOffice 构建系统新增了 OHOS 平台(上游零支持),
34
+ # 补丁在 third_party/collabora/online/engine 下,详见 harmony/build_engine_oh.sh 头注释
35
+ bash harmony/build_engine_oh.sh # 交叉编译引擎到 aarch64-linux-ohos
36
+ bash harmony/build_oh.sh --with-lok # 打成 utssdk/app-harmony/libs/x-office-native.har
37
+
38
+ # Android:上游官方支持,只需固定参数
39
+ bash android/build_engine_android.sh
40
+ bash android/build_android.sh --with-lok # 打成 utssdk/app-android/libs/x-office-native.aar
41
+
42
+ # iOS:上游官方支持,宿主必须 macOS + Xcode
43
+ bash ios/build_engine_ios.sh
44
+ bash ios/build_ios.sh --with-lok # 聚合成 utssdk/app-ios/Frameworks/XOfficeCore.xcframework
45
+ ```
46
+
47
+ 不带 `--with-lok` 时三个打包脚本只编桥接层,产出的是不含引擎的空壳,用于跑通
48
+ UTS / Kotlin / Swift / ArkTS 侧全链路与 CI 回归。
49
+
50
+ #### 三端不能共用一个引擎构建目录
51
+
52
+ 引擎是**单目标树**:`config_host.mk` 里只有一个 `OS=`,`autogen.sh` 每跑一次就把整棵树
53
+ 重新配置到新目标上。三端如果都在 `engine/` 里就地编,后编的会把先编的
54
+ `workdir` / `instdir` 连同已经链好的 `.so` 全部作废。
55
+
56
+ LibreOffice 支持 `BUILDDIR != SRCDIR`,所以每个目标一个独立构建目录,共用同一份源码:
57
+
58
+ | 目标 | BUILDDIR | 引擎产物 |
59
+ | --- | --- | --- |
60
+ | HarmonyOS | `online/engine`(就地,历史原因) | `engine/ohos/libs/arm64-v8a/liblo-native-code.so` |
61
+ | Android | `online/engine-build/android` | `<BUILDDIR>/android/jniLibs/arm64-v8a/liblo-native-code.so` |
62
+ | iOS | `online/engine-build/ios` | `<BUILDDIR>/workdir/CustomTarget/ios/ios-all-static-libs.list` |
63
+
64
+ 用 `X_OFFICE_ENGINE_BUILD` 可以覆盖。`common/engine_builddir.sh` 里的守卫会在
65
+ `autogen.sh` 之前比对该目录当前配的 `OS=`,不一致就直接拒绝——`autogen.sh` 是就地覆盖
66
+ 配置的,跑错目录没有回头路。比对忽略大小写:configure 写进去的是 `OS=iOS`,
67
+ 而脚本传的是 `IOS`,直接字符串比会让 iOS 编过一次之后再也无法重跑。
68
+
69
+ #### macOS 上编 Android 引擎
70
+
71
+ 能编,但要绕一个上游的写死值。`distro-configs/CPAndroidCommon.conf` 第一行是
72
+ `--build=x86_64-unknown-linux-gnu`,而 `configure.ac` 是拿 `$build_os` 去选 NDK 的
73
+ `prebuilt/` 子目录,于是在 macOS 上会去找根本不存在的 `prebuilt/linux-x86_64/bin/clang`,
74
+ 第一个 C 编译器检查就报 `C compiler cannot create executables`。
75
+
76
+ `build_engine_android.sh` 会在 Darwin 宿主上自动追加 `--build=$(config.guess)` 盖回来
77
+ (`autogen.sh` 就地展开 `--with-distro`,这行排在它后面,autoconf 的 `--build` 后者胜出)。
78
+ 注意 `config.guess` 在树里是 644 没有执行位,必须经 shell 调用,上游 `configure` 自己
79
+ 也是这么干的;直接 exec 会 permission denied 并静默得到空串。
80
+
81
+ #### 挂后台跑
82
+
83
+ 引擎全量构建三小时以上,`nohup` 挡不住——它只忽略 SIGHUP,进程仍在调用方的进程组里,
84
+ 进程组一拆整棵子树跟着没。用 `common/run_detached.sh`(python3 的 `start_new_session`,
85
+ 即真正的 setsid;macOS 没有 `setsid` 命令):
86
+
87
+ ```bash
88
+ PID=$(bash common/run_detached.sh build/logs/engine-ios.log bash ios/build_engine_ios.sh)
89
+
90
+ # 两端引擎都在跑时,等它们收工并自动重打包
91
+ bash common/run_detached.sh build/logs/finish-all.log \
92
+ bash common/finish_all.sh <iOS PID> <Android PID>
93
+ tail -f build/logs/finish-all.status
94
+ ```
95
+
96
+ #### 只改了引擎里一两个源文件时别跑整脚本
97
+
98
+ `build_engine_oh.sh` 是为「从零 clone 到出包」设计的,改一个 `.cxx` 再跑它会退化成近似
99
+ 全量(三小时):`[3/4] autogen.sh` 会重跑 configure、重新生成 `config_host.mk` 与
100
+ `config_host/*.h`,而 gbuild 里几乎每个 object 都依赖这些配置头,mtime 一变就级联;
101
+ `[4/4] make build` 又是顶层全量遍历,含 260+ 个 external 归档的检查。而补丁已在树中、
102
+ `autogen.input` 没变时,前两步本来就无事可做。
103
+
104
+ 最小增量是两条命令(实测改 `bridges` 里一个源文件:重编 5 秒 + 重链 17 秒):
105
+
106
+ ```bash
107
+ # 必须是 GNU make 4.x:macOS 自带的 3.81 会在 LinkTarget.mk 上报
108
+ # "extraneous `endef'"。build_engine_oh.sh 靠这行 PATH 拿到 gmake,手跑增量时要自己带上。
109
+ export PATH="/opt/homebrew/opt/make/libexec/gnubin:/opt/homebrew/opt/gnu-sed/libexec/gnubin:/opt/homebrew/opt/gnu-tar/libexec/gnubin:$PATH"
110
+
111
+ cd third_party/collabora/online/engine
112
+ make -C bridges -j8 # 只重编改动的 object,重打 libgcc3_uno.a 并交付 instdir/
113
+ make -C ohos/source all # 归档变新即触发重链,再 strip 到 ohos/libs/arm64-v8a/
114
+ ```
115
+
116
+ 实测改 `desktop/source/lib/init.cxx`:重编 13 秒 + 重链 13 秒。
117
+
118
+ 第二条等价于 `CustomTarget_lo_ohos.mk` 里那条链接规则,只是省掉了
119
+ `AllModulesButInstsetNative` 的全量遍历。把 `bridges` 换成你改的模块即可。
120
+ 之后照常 `bash harmony/build_oh.sh --with-lok` 重打 HAR(约 1 分钟)。
121
+
122
+ 改完务必确认新产物的 build-id 变了(`llvm-readelf -na <so> | grep -A2 'Build ID'`),
123
+ build-id 没变说明根本没重链,后面所有验证都是在看旧产物。
124
+
125
+ 另外判断脱离出去的构建有没有结束要看日志末尾的 exit 标记,**不能靠 `ps -p`**:
126
+ `run_detached.sh` 用的是 `start_new_session`,脱离后的进程在沙箱里 `ps` 看不到,
127
+ 会误判成已结束。
128
+
129
+ 打包脚本会先把 `browser/dist` 过一遍 `common/stage_cool_dist.sh` 再入包,删掉这份
130
+ 构建里没有入口能走到的部分(未压缩的 js 源码、wasm 版才用的 `l10n-all.js`、
131
+ coolwsd 管理控制台、非 en 的帮助截图),49MB → 26MB。两个开关:
132
+
133
+ | 环境变量 | 作用 |
134
+ | --- | --- |
135
+ | `X_OFFICE_COOL_FULL=1` | 原样入包不裁剪,排查前端问题时用 |
136
+ | `X_OFFICE_COOL_TEMPLATES=1` | 保留「从模板新建文档」的模板库(+8MB)。本插件 API 只有 `open(既有路径)`,默认不带 |
137
+
138
+ 体积提示:单端引擎 200~300MB,远超 HBuilderX 云打包免费额度(60MB),需走离线打包。
139
+ 鸿蒙侧实测 `x-office-native.har` 108MB(引擎 .so 179MB + 引擎资源包 36MB + 前端 5.7MB)。
140
+
141
+ #### 前端在各端的落地方式不同
142
+
143
+ | 端 | 随包位置 | 页面地址 |
144
+ | --- | --- | --- |
145
+ | Android | `assets/x-office-u/` | `file:///android_asset/x-office-u/cool.html` |
146
+ | iOS | bundle 内 | bundle 的 `file://` |
147
+ | HarmonyOS | `rawfile/x-office-u/cool.zip` | 首启解到沙箱后的 `file://` |
148
+
149
+ 三端页面源统一是 `file://`(`Origin` 都是字面量 `null`),与上游 Android 的
150
+ `file:///android_asset/dist/cool.html` 同构,`cool:` 那侧的 CORS 放行规则因此完全一致。
151
+
152
+ 鸿蒙这一端要多解一次包,不是为了省体积,是因为 `resource://rawfile/xxx.html?a=1`
153
+ 一律 `ERR_FILE_NOT_FOUND` —— rawfile 把整个 URL 当条目名去查表。而前端要的文档路径、
154
+ 权限、语言全靠这串 query 传进去,没有替代通道。
155
+
156
+ 代价是 `file://` 页面默认不能跨源,三端各要开一个口:Android
157
+ `setAllowUniversalAccessFromFileURLs(true)`;iOS 靠 `setURLSchemeHandler` 注册 `cool:`
158
+ (注册过的 scheme 才允许被 fetch),页面同目录资源另由
159
+ `loadFileURL(_:allowingReadAccessTo:)` 给读权限;鸿蒙是 `customizeSchemes`
160
+ (`isSupportCORS` / `isSupportFetch`)加
161
+ `setPathAllowingUniversalAccess([<沙箱>/x-office-u])`。鸿蒙那个名单还会**覆盖**
162
+ `fileAccess`:设了之后 `file://` 只能读名单内的路径,所以给的是整个根目录
163
+ 而不是 `cool/` 子目录。
164
+
165
+ ##### Android 别改用 `WebViewAssetLoader` 的 https 虚拟域
166
+
167
+ `https://appassets.androidplatform.net/assets/...` 看着比 `file://` 现代,实际会让
168
+ **安卓永远打不开文档,而且一条错误都不打**。页面源一旦是 https,回程那条发往 `cool:`
169
+ 的 XHR 就成了「安全页面取不安全资源」,内核按混合内容直接拦掉,接着一路静音:
170
+
171
+ - 前端的 `ProxySocket.getSessionId()` 只给 XHR 挂了 `load` 监听,没挂 `error`
172
+ (`js/global.js`),请求被拦掉连 `_signalErrorClose()` 都走不到;
173
+ - 于是 socket 的 `readyState` 永远停在 0。`HULLO` 是走 JS 桥发的,引擎那边照常连上、
174
+ 日志正常,但 `js/global.js` 里发 `load url=` 的 `onopen` 处理器第一句就是
175
+ `if (readyState === 1)`,永不成立 —— 引擎没收到加载请求,宿主也就等不到 `LOADED`,
176
+ `open()` 的回调一个都不回。
177
+
178
+ 现场只剩一条无关的 `E WebViewAssetLoader: ... x-office-u/branding-mobile.css`
179
+ (就是下面「那 4 条 `ERR_FILE_NOT_FOUND` 是正常的」说的那批可选资源),照着它查会
180
+ 走进死胡同。`allowFileAccess` 保持关不影响这条路:那个开关管的是文件系统,
181
+ `file:///android_asset` 与 `file:///android_res` 始终可读(上游 app targetSdk 35、
182
+ 同样没开它)。
183
+
184
+ #### 重建前端产物(改了 `browser/src` 之后)
185
+
186
+ 三端消费的是同一份 `third_party/collabora/online/browser/dist`,重建一次三端一起生效:
187
+
188
+ ```bash
189
+ export PATH="/opt/homebrew/opt/make/libexec/gnubin:$PATH" # 要 GNU make 4.x
190
+ make -C third_party/collabora/online/browser dist/bundle.js # 约 15 秒
191
+ ```
192
+
193
+ 会顺带跑 `tsc` 与 `tsc-strict`(351 个 strict 文件)和一次 ES2022 语法体检,
194
+ 所以改错了这里就会拦下来。跑完再照常 `build_oh.sh --with-lok` 打包。
195
+
196
+ **如果它报 `Cannot find module '../findStrictErrors'`**:`node_modules/.bin/` 里的
197
+ shim 本该是指向各包 bin 入口的符号链接,但这份 `node_modules` 是在不支持符号链接的
198
+ 卷上装的,71 个 shim 全落成了实体副本 —— 副本放在 `.bin/` 下,里头的相对
199
+ `require('../xxx')` 就从 `.bin/` 出发解析,全部指错。按各包 `package.json` 的 `bin`
200
+ 字段重建符号链接即可(`tsc-emit` 不受影响,因为 Makefile 直接调
201
+ `node_modules/typescript/bin/tsc`,没走 `.bin`)。
202
+
203
+ #### online(COOL)侧的改动也靠补丁保存
204
+
205
+ `third_party/` 不入库,所以那 12 个文件的改动固化在
206
+ `native-src/common/patches/0001-online-x-office-app.patch`(`common/`、`kit/`、
207
+ `wsd/`、`configure.ac`,以及上面那个前端修复)。引擎侧的 7 个补丁在
208
+ `native-src/harmony/patches/`,由 `build_engine_oh.sh` 自动应用;
209
+ online 侧这一份目前**还没接进构建脚本**,新克隆需要手动
210
+ `git apply` 一次(在 `third_party/collabora/online/` 下)。
211
+
212
+ #### 引擎起来之前要摆好的环境变量
213
+
214
+ `kit_cpp_init()` 之前 `native-src/common/XOfficeEngine.cpp` 会设几个环境变量,鸿蒙与
215
+ Android 都要(iOS 不需要,上游 iOS 分支自己带了一套等价的 bundle 布局):
216
+
217
+ | 变量 | 没有它会怎样 |
218
+ | --- | --- |
219
+ | `URE_UNO_INI_URI` | UNO bootstrap 起不来 |
220
+ | `TMPDIR` | `Desktop::CreateTemporaryDirectory()` 建不出目录 |
221
+ | `XDG_CACHE_HOME` | fontconfig 缓存写不下去,每次启动重扫全部字体 |
222
+ | `HOME` | `$SYSUSERHOME` / `$SYSUSERCONFIG` 展开失败 |
223
+
224
+ `URE_UNO_INI_URI` 是硬性的。`cppu::getUnoIniUri()`(`cppuhelper/source/paths.cxx`)猜
225
+ `unorc` 位置的默认逻辑在这两端都指不到我们的资源目录:鸿蒙落到 `get_this_libpath()`,
226
+ 那是 `.so` 所在的 `libs/arm64`;Android 分支更硬,写死 `file:///assets/program`。而资源
227
+ 是首启解到沙箱的。读不到 `unorc` 就没有 `UNO_TYPES` / `UNO_SERVICES`。
228
+
229
+ 这条路失败的现场极难认:`cokit_hook_2()` 初始化失败后会 `delete` 那个半成品
230
+ `COKitImpl`,而它的析构第一件事就是取 SolarMutex —— 此时 VCL 还没起来,`SalInstance`
231
+ 是空的,当场 SIGSEGV,栈顶只看得到 `SalInstance::GetYieldMutex()`,看不到任何真因
232
+ (更糟的是 `delete` 之后它还把这个野指针返回给调用方)。所以引擎的失败路径根本走不
233
+ 回来,`sharedOfficeHandle()` 在进引擎之前先自己查一遍 `sofficerc` / `fundamentalrc` /
234
+ `unorc` / `services.rdb` / `types.rdb` 齐不齐,缺哪个就打日志直接返回,不进引擎。
235
+
236
+ 排查用 native 日志(tag 一律 `x-office-u`):鸿蒙 `hdc hilog | grep x-office-u`,
237
+ Android `adb logcat -s x-office-u`,iOS 看 Xcode 控制台。
238
+
239
+ #### aarch64 UNO 桥的 `__clear_cache`(鸿蒙专属,补丁 0002)
240
+
241
+ `bridges/source/cpp_uno/gcc3_linux_aarch64/cpp2uno.cxx` 的 `VtableFactory::flushCode()`
242
+ 在非 ANDROID / 非 MACOSX 分支下是这么取函数指针的:
243
+
244
+ ```cpp
245
+ static void (*clear_cache)(...) = ...(dlsym(RTLD_DEFAULT, "__clear_cache"));
246
+ (*clear_cache)(begin, end); // 没有判空
247
+ ```
248
+
249
+ `__clear_cache` 是 compiler-rt 的内建实现,静态链进各目标且符号隐藏,OHOS 上没有任何
250
+ 动态库导出它,所以 `dlsym` 恒为 `NULL`,这行直接跳到地址 0。上游只给 Android 和 macOS
251
+ 开了 `__builtin___clear_cache` 分支,补丁 0002 把 OHOS 也放进去。
252
+
253
+ 这个崩溃的现场同样具有欺骗性,值得记一笔:
254
+
255
+ - `flushCode` 被编译成**尾调用**(`ldp x29,x30` 之后 `br x2`),所以栈里根本没有它自己
256
+ 这一帧,最内层可见的是调用方 `VtableFactory::createVtables`,看上去像 vtable 生成本身
257
+ 出了问题。
258
+ - 触发点毫无特征。任何一次抛 UNO 异常都要现场造 C++ 接口代理,而打开文档时
259
+ `LayoutManager` 复位会经 UCB 去 stat 一个不存在的配置文件 —— 那本是完全正常、可恢复的
260
+ 异常,却成了压垮进程的第一根稻草。于是栈顶看着像「docx 导入过滤器崩了」,
261
+ 实际和文件内容、writerfilter、字体都无关。
262
+ - Android 不受影响(走 ANDROID 分支,编译成静态绑定的 `bl __clear_cache`),所以这是
263
+ 纯鸿蒙端问题,双端对照反而会误导。
264
+
265
+ 认这个崩溃只需要看两点:`SIGSEGV` 目标地址为 `0x0`、且栈顶 `pc 0 Not mapped`。
266
+ 崩溃日志里的 pc 是**已经 -4 调整过**的(指向 `bl` 指令本身而非返回地址),
267
+ 符号化时直接把偏移喂给 `llvm-symbolizer --obj=<未 strip 的 so>` 即可,不用换算
268
+ (该 `.so` 可执行 LOAD 段的 `VirtAddr` 与 `Offset` 差 `0x4000`,按文件偏移解会得到
269
+ 一整套看似合理但完全错误的函数名 —— 用 `libx_office_u.so` 那个带符号的帧交叉校验)。
270
+
271
+ 未 strip 的产物在 `engine/ohos/obj/local/arm64-v8a/`,HAR 里那份是 strip 过的。
272
+
273
+ 「跳空指针」这一类隐患(`dlsym` 取到 NULL 后无判空就调用)已审计完,只有上面这一处:
274
+
275
+ | 位置 | 为什么不是问题 |
276
+ | --- | --- |
277
+ | `sal/osl/unx/random.cxx` | 判空后退回 `open("/dev/urandom")` |
278
+ | `sal/osl/unx/process_impl.cxx` | 结果查 `!= nullptr` |
279
+ | `vcl/source/window/builder.cxx` 的 `.ui` 自定义控件工厂 | 整块在 `#ifndef DISABLE_DYNLOADING` 内,OHOS 不编译 |
280
+ | `gcc3_linux_x86-64` / `gcc3_linux_intel` / `gcc3_linux_arm` | 不是本工程的架构 |
281
+
282
+ **但「dlsym 查不到」本身还有第二种危害形态**,比跳空指针更隐蔽:查不到时不崩,而是
283
+ 悄悄换出一个语义不等价的替代品。上面那张表最初把 `abi.cxx` 的 RTTI 查找判成「不是问题,
284
+ 因为它 `dlsym` 失败会自己合成 `type_info`」——恰恰是那个合成动作有问题,见下一节。
285
+
286
+ #### UNO 异常的 RTTI 查找域(鸿蒙专属,补丁 0003)
287
+
288
+ 补丁 0002 修好之后暴露出来的下一个问题:打开任意文档时 `SIGABRT`,
289
+
290
+ ```text
291
+ LastFatalMessage: terminating due to uncaught exception of type
292
+ com::sun::star::ucb::InteractiveAugmentedIOException
293
+ ```
294
+
295
+ 而栈里第 21 帧的 `utl::UCBContentHelper::IsDocument()` 明明写着
296
+ `catch (cpo::uno::Exception const &) { return false; }`。异常抛出了,`catch` 没匹配上。
297
+
298
+ `bridges/source/cpp_uno/gcc3_linux_aarch64/abi.cxx` 的 `Rtti` 这样查 typeinfo:
299
+
300
+ ```cpp
301
+ Rtti(): app_(dlopen(nullptr, RTLD_LAZY)) {}
302
+ ...
303
+ rtti = static_cast<std::type_info *>(dlsym(app_, "_ZTIN3com3sun4star3ucb31Interactive...E"));
304
+ if (rtti == nullptr) {
305
+ // 查不到就现造一个,名字是 strdup 出来的
306
+ rtti = new __cxxabiv1::__si_class_type_info(rttiName, base);
307
+ }
308
+ ```
309
+
310
+ `dlopen(nullptr)` 给的是**主程序**的搜索域。鸿蒙上 `liblo-native-code.so` 是被
311
+ `libx_office_u.so` 以 `DT_NEEDED` 带进来的,而后者是 ArkTS 的 NAPI 模块加载器 `dlopen`
312
+ 进来的(局部域),主程序域里看不到引擎的符号,于是 `dlsym` 恒为 `NULL`。
313
+
314
+ 关键在于失败之后:合成出来的 `__si_class_type_info` 名字是 `strdup` 的新指针。而
315
+ libc++abi 判基类关系走的是 `is_equal(this, dst_type, /*use_strcmp=*/false)`——
316
+ **指针比较**。于是合成节点和编译期绑定的真 typeinfo 永远不相等,
317
+ `catch (cpo::uno::Exception const &)` 匹配不上,异常一路穿到宿主运行时 `std::terminate`。
318
+
319
+ 也就是说,**所有经 `cppu::throwException()`(UNO Any → C++ throw)抛出的异常在鸿蒙上都
320
+ 是不可捕获的**,而 LibreOffice 内部把 UNO 异常当正常控制流用。补丁 0003 让 OHOS 用
321
+ `dladdr` + `dlopen(<自身路径>, RTLD_NOLOAD)` 拿到引擎自己这个 `.so` 的句柄再查。
322
+
323
+ 几个容易带偏的点:
324
+
325
+ - typeinfo 符号本身**是导出的**(`.dynsym` 里有 1395 个 `_ZTIN3com3sun4star*`),
326
+ 所以「符号没导出」的方向是错的,问题只在查找句柄。
327
+ - C++ 原生 `throw` 完全不受影响,用的是编译期绑定的真 typeinfo。所以
328
+ `open:fail Unsupported URL <...>: "type detection failed"`(`LoadEnv` 里裸 `throw`)
329
+ 一直能正常返回——**同一个函数里,有的异常能捕获有的会崩进程**,只看它是不是走 UNO Any。
330
+ - 触发路径和文档内容无关:`LayoutManager` 复位要问「用户 UI 配置存储在不在」,
331
+ `IsDocument()` 对不存在的路径抛 IO 异常,这本是正常可恢复的一步。
332
+ - 崩的是 `SIGABRT` 不是 `SIGSEGV`,且 `LastFatalMessage` 会直接点名异常类型——
333
+ 看到这行就该往「catch 没匹配上」而不是「哪里写坏了」的方向查。
334
+
335
+ 桥接层另有一道独立的兜底:`common/XOfficeEngine.cpp` 的所有对外函数都套了
336
+ `guarded()` / `guardedJson()`,里面是 `catch (...)`。`catch (...)` 与 typeinfo 无关、
337
+ 永远匹配,所以即便以后再出现这类不可捕获的异常,也只会降级成一条
338
+ `open:fail internal engine error <类型名>` 加一条 native 日志,不会再带走整个进程。
339
+ 类型名是用 `abi::__cxa_current_exception_type()` 取的,这个接口在 `catch (...)` 里照样有效。
340
+
341
+ #### 报到 `internal engine error <...>` 说明异常穿出了 C 接口(补丁 0004)
342
+
343
+ 上面那道兜底只能报出异常**类型名**,报不出消息——`catch (...)` 拿不到对象。所以一旦看到
344
+ `open:fail internal engine error <...>`,真正该修的是「为什么异常能走到这一层」:COKit 的
345
+ C 接口契约是不抛异常,失败返回 `nullptr` 并把原因留在 `getError()`。
346
+
347
+ 已知一处是 `lo_documentLoadWithOptions()` 把 `frame::Desktop::create(xContext)` 写在了那个
348
+ 大 `try` 之外,而它拿不到服务时抛 `DeploymentException`。补丁 0004 给它单独套了一层
349
+ `catch`。以后再遇到别的类型名,按同样思路找 `try` 覆盖不到的语句,别只在桥接层加分支。
350
+
351
+ 为了不必每次都靠猜,`impl::openDocument()` 对引擎调用做了**逐步标注**:
352
+ `documentLoadWithOptions` / `registerCallback` / `initializeForRendering` / `getDocumentSize` /
353
+ `getParts` 各记一个 step 名,整段再套一层 stderr 捕获。所以报错长这样——
354
+ `open:fail initializeForRendering threw <...>; engine said: ...`,一眼能定位到调用点。
355
+
356
+ #### `doc_iniUnoCommands` 不能在鸿蒙上建 NSS 安全环境(鸿蒙专属,补丁 0005)
357
+
358
+ 现象是打开任意文档报
359
+ `open:fail initializeForRendering threw <com::sun::star::uno::DeploymentException>`。
360
+
361
+ `doc_initializeForRendering` 第一件事就是 `doc_iniUnoCommands()`,而它会去建
362
+ `com.sun.star.xml.crypto.SEInitializer`(NSS 那套安全环境,只有文档签名用得到)。
363
+ `SEInitializer::create()` 是生成的单例服务工厂,**拿不到服务时抛 `DeploymentException`**
364
+ 而不是返回空引用,所以紧跟其后的 `is()` 判空形同虚设;而 `doc_initializeForRendering`
365
+ 全函数没有 `try/catch`,异常直接穿出 C 接口。
366
+
367
+ 上游本来就把这段排除在移动端之外(`#if !defined IOS && !defined ANDROID && !defined
368
+ __EMSCRIPTEN__`),只是没有 `OHOS` 这一项,于是鸿蒙落进桌面分支。这和补丁 0002 的
369
+ `__clear_cache` 是同一类问题:**上游所有移动端判断都按 `IOS`/`ANDROID` 写死,鸿蒙两个都不满足**。
370
+ 以后凡是「Android/iOS 正常、只有鸿蒙炸」的症状,先去看那段代码有没有这种形式的 `#if`。
371
+
372
+ 注意同文件 `doc_addCertificate()` 里还有一处同样的 `SEInitializer::create()`,没有加守卫——
373
+ 那是签名 API,我们不调用,所以留着。也因此不能靠 `strings` 里有没有
374
+ `com.sun.star.xml.crypto.SEInitializer` 来判断补丁生效,那个字符串由后者贡献。
375
+
376
+ #### SolarMutex 的交接有两半,鸿蒙一半都没有(鸿蒙专属,补丁 0006)
377
+
378
+ 这个的症状最难查:**前端白屏,而且没有任何报错**。COOL 服务端一路正常 —— 文档 staged 到位、
379
+ `DocumentBroker` 与 `ClientSession` 都建起来、`Loading [...] for session [001]`、`perm: edit`、
380
+ `lokitversion` 也回给前端了 —— 然后停在
381
+ `ToClient-001: Requesting document load from child`,之后 kit 侧再无一条日志。
382
+
383
+ 分辨的关键是别被 `lokit_main_001` 阻塞误导:`DOCS_SHARE_PROCESS` 下它就该停在条件变量上
384
+ (`kit/Kit.cpp` 的 `termination->cv.wait`),真正干活的是 `lokit_runloop` 线程上的
385
+ `pollCallback()`。采样那条线程的 CPU 时间就露馅了 —— 累计 1 个 tick 且不增长,
386
+ 说明它不是在轮询,是从进 `runLoop` 起就阻塞:
387
+
388
+ ```
389
+ hdc shell "cat /proc/<pid>/task/<tid>/stat" # 取第 14/15 个字段 utime+stime,隔几秒采两次
390
+ ```
391
+
392
+ 根因是上游把 SolarMutex 的交接拆成两半,两半的守卫都不含 `OHOS`:`lo_initialize()` 里
393
+ `#ifdef IOS` 那段在初始化期 `InitVCL()` 并 `release()` 把锁交出去,`lo_runLoop()` 里
394
+ `#if defined(IOS) || defined(ANDROID)` 那段 `acquire()` 接住。少了第一半,锁没人交出来,
395
+ `lo_runLoop()` 的 `SolarMutexGuard` 就永远等着。
396
+
397
+ **两处必须一起改**:只放开 `lo_runLoop` 那一处会更糟,那是从一把没人释放的锁上去 `acquire()`。
398
+
399
+ 我们该走 iOS 这条而不是上游 Android 那条,因为生命周期就是 iOS 那套:启动时先建好唯一引擎实例、
400
+ 主循环另起线程(见 `XOfficeMobileApp.cpp` 的 `runKitLoopInAThread` 一段)。
401
+
402
+ **Android 插件也踩了这把锁,缺的是另一半**(补丁
403
+ `native-src/android/patches/0001-engine-android-solarmutex-handoff.patch`)。
404
+ `lo_runLoop()` 的守卫本来就有 `ANDROID`,一进门 `acquire()`;但 `lo_initialize()`
405
+ 的 iOS 那段 `InitVCL` + `release` 不能加 `ANDROID`(`HAVE_FEATURE_ANDROID_KIT`
406
+ 的扩展仓库同步必须排在 `InitVCL` 前面)。unipoll 下 Android 走的是后面那句
407
+ `else InitVCL()`,锁留在初始化线程。上游 Collabora Android 没事,因为它的
408
+ `runLoop` 和 `InitVCL` 在同一条 `lokit_main` 上,`acquire()` 是递归的;我们把
409
+ Android 也改成 iOS 生命周期之后,两条线程,不在 `lo_initialize()` 返回前
410
+ `ReleaseSolarMutex()`,`lokit_runloop` 的 CPU 从进 `runLoop` 起就是 0,文档停在
411
+ `Requesting document load from child`,回程只有握手那一包,画布空白。
412
+
413
+ 锁交出去之后立刻撞上下一枪:`documentLoad` 5ms 闪退,
414
+ `SalAbort: Unspecified application error`,进程 `_exit(1)`,没有 tombstone。
415
+ 空 SalAbort + 退出码 1 是 VCL 信号处理器的招牌(`Desktop::Exception`),
416
+ 不是 osl 那条 `_exit(255)`。崩点是
417
+ `LiblangtagDataRef::setupDataPath()` 对 `lo_get_app_data_dir()` 做
418
+ `OString()`:官方 Collabora Android 在 Java Bootstrap 里填 `data_dir`,
419
+ 我们是 C++ 嵌入、没走那条入口,指针一直是 NULL,`strlen(NULL)` 就是
420
+ SIGSEGV。启动前调一次 `cokit_initialize()`(`XOfficeNative.ensureAndroidKitBootstrap`),
421
+ 引擎侧对空指针做了判空,见
422
+ `native-src/android/patches/0002-engine-android-documentload-no-abort.patch`。
423
+
424
+ #### `closeDocument()` 不能在 UI 线程上等 COOLWSD
425
+
426
+ `closeDocument()` 要等 `COOLWSD::run()` 返回(`g_coolwsdRunningMutex` 被 `app` 线程整段持有),
427
+ 拿到锁才说明 kit 的 `~Document()` 跑完了,兜底的 `DocumentData::deallocate()` 才排在它后面——
428
+ 那张全局 map 没有锁,顺序是唯一的保护,所以这个等待不能省。
429
+
430
+ 但**不能在调用线程上等**。这个函数是从 UI 线程调进来的,文档没加载成功时 `run()` 可能迟迟不返回,
431
+ 鸿蒙看门狗 6 秒就判 `THREAD_BLOCK` 把进程杀掉(`THREAD_BLOCK_3S` → `THREAD_BLOCK_6S` →
432
+ `processExit:1`)。现场表现是「选完文件就闪退」,日志里只有一条 appfreeze,
433
+ 完全看不出是卡在这个 lock 上。所以等待整段挪到 `doc_teardown` 线程。
434
+
435
+ `docId` 必须在 `closeDocument()` 里取值传给后台线程,不能让它去读 `g_appDocId`:
436
+ 等待期间用户完全可能又开了下一篇文档,那时全局里已是新编号,回收会打到活着的文档上。
437
+
438
+ #### 一个进程只有一个 COOLWSD,`run()` 永不返回(三端都一样)
439
+
440
+ 这是整条编辑链路最根本的形态约束,弄错了症状会在很远的地方冒出来。
441
+
442
+ `common/Common.hpp` 里我们把 `X_OFFICE_APP` 放在了 `DOCS_SHARE_PROCESS 1` 那一侧
443
+ (和 iOS / MACOS / QTAPP 同侧),而 `build_mobileapp.sh` 三端都开 `-DX_OFFICE_APP=1`。
444
+ 所以**三端全是共享进程形态**:一个 kit 进程同时托管多篇文档,靠 `mobileAppDocId` 区分。
445
+ 它必须留在这一侧,因为它和 `runKitLoopInAThread()` 是一套的(那一侧的形态里引擎实例由
446
+ 宿主提前建好、主循环单开线程跑)。
447
+
448
+ 这个形态的直接后果是 **`COOLWSD::run()` 不会返回**:`COOLWSD.cpp:4180` 那个主循环的
449
+ 退出条件是 `SigUtil::getShutdownRequestFlag()`,而它在这个形态下恒为 false ——
450
+ 上游在同文件 4255 行专门注明了「always returns false on iOS, thus the above while loop
451
+ never exits」,后面整段收尾代码都被 `#if !defined(IOS)` 挡掉。
452
+
453
+ 所以对照写法只有一种是对的:
454
+
455
+ | | 上游 Android(`DOCS_SHARE_PROCESS=0`) | 我们三端 = 上游 iOS(`=1`) |
456
+ | --- | --- | --- |
457
+ | COOLWSD | `while(true){ new COOLWSD; run(); }`,一轮服务一篇文档 | `new COOLWSD; run();` **一次**,`run()` 之后上游直接 `assert(false)` |
458
+ | 客户端 fd | 每轮循环开头建一个 | **每篇文档**建一个(`CODocument.mm:68`),连同一个长命监听 socket |
459
+ | 关文档 | 靠 `run()` 返回收尾 | 只关通知管道一端,转发线程醒来后关掉 `closeFd` 与 `clientFd` |
460
+
461
+ 照 Android 那套写会踩两个连续的坑,而且两个都极难从现场反推:
462
+
463
+ 1. `closeDocument()` 若等 `run()` 返回,那就是永久等待。在 UI 线程上等 → 鸿蒙看门狗
464
+ 6 秒判 `THREAD_BLOCK` 杀进程,现场是「选完文件就闪退」,日志里只有一条 appfreeze。
465
+ 2. 把等待挪到后台线程之后,闪退没了,但循环第二轮永远不会开始 → 客户端 fd 与
466
+ 监听 fd 都不会更新。现场是「**第一篇文档能开,之后任何文档都停在『正在打开』,
467
+ 退出页面重进也没用,只有杀掉 App 才恢复**」。
468
+
469
+ `DocumentData::deallocate()` 也不要在宿主侧兜底:进程内没有安全的时机去等 kit 的
470
+ `~Document()`(`kit/Kit.cpp:898`)跑完,上游 iOS 同样把自己那句注释掉了
471
+ (`DocumentViewController.mm:611`)。
472
+
473
+ 另外 `runApp2JsThread()` 的两个 fd 必须**按值捕获**。全局里那两份随时会被下一篇文档
474
+ 换掉,而上一条转发线程可能还没退出,读全局会 poll 到别人的 socket 上去。
475
+
476
+ #### 前端消息必须过一条泵,HULLO 不能在 UI 线程上等连接
477
+
478
+ 上一条把 `closeDocument()` 的等待挪走之后,顺带把原本靠它撑着的一个隐式保证也拆了:
479
+ 「上一篇文档彻底收摊之后才会开下一篇」。于是**开完一篇再开另一篇必然崩**,故障线程叫
480
+ `accept_poll`,断言是
481
+
482
+ ```
483
+ Assertion failed: pair2.fd[0] == pair.connectingFd (net/FakeSocket.cpp: fakeSocketAccept4: 559)
484
+ ```
485
+
486
+ 根因是客户端 fd 建错了地方:当时它跟着 `app` 线程的循环,在**每轮 `run()` 开头**才新建。
487
+ 重开文档时全局里那个 fd 还是上一篇文档那个已经连过的,HULLO 拿它再
488
+ `fakeSocketConnect()` 一次,accept 侧就撞上上面那条断言。
489
+
490
+ 正确的归属见上一条:客户端 fd 属于**文档**,在 `openDocument()` 里建,
491
+ `closeDocument()` 里置 `-1`。`awaitServerSocket()` 只负责等那个一次性的监听 socket
492
+ (前端可能比 `app` 线程更早跑到 HULLO,那一刻直接返回失败就是「第一篇文档偶发打不开」)。
493
+
494
+ 而这个等待不能在 UI 线程上做,否则就是上一条那个看门狗坑的翻版——注意
495
+ `fakeSocketConnect()` 本身就是阻塞的,原来它就跑在 UI 线程上,属于同一类隐患。
496
+ 所以 `fromFrontend()` 拆成两段:UI 线程只把消息塞进 `g_inbound`,
497
+ 真正的处理在 `js2app` 线程上(`handleFrontendMessage()`),那里允许阻塞。
498
+
499
+ 泵线程里每条消息都包了 try/catch:这条线程死掉整个编辑会话就没人喂了,
500
+ 而且异常穿出 `std::thread` 是 `terminate`。`openDocument()` 里要清 `g_inbound`——
501
+ 上一篇文档没处理完的消息带的 view id 与编号都是旧的,发过去只会换回一堆 error。
502
+
503
+ #### 会话身份必须整份读写,不能拆成几个全局各自读
504
+
505
+ 现场是「**反复多开几次文档就闪退**」,开的次数越多越容易撞上:
506
+
507
+ ```
508
+ Assertion failed: _mobileAppDocId > 0 && "Unexpected to have no mobileAppDocId in a mobile app"
509
+ (wsd/DocumentBroker.cpp: DocumentBroker: 279)
510
+ Tid:13238, Name:websrv_poll
511
+ ... ClientRequestDispatcher::handleIncomingMessage -> StreamSocket::handlePoll -> SocketPoll::poll
512
+ ```
513
+
514
+ 一篇文档的身份是三样东西:URL、`mobileAppDocId`、`fakeClientFd`。上游把它们挂在文档
515
+ 对象上(`ios/Mobile/CODocument.mm` 的 `fakeClientFd` / `appDocId` / `copyFileURL`),
516
+ 天然不会撕裂。我们一次只显示一篇,于是退化成进程级状态 —— 那就必须自己当成一个整体。
517
+
518
+ 拆成三个全局各自读就会这样:HULLO 在 `js2app` 线程上处理,先读 fd,接着
519
+ `awaitServerSocket()` 与 `fakeSocketConnect()` **都可能阻塞**,最后才去读 URL 与编号
520
+ 拼握手串。这中间 UI 线程一旦 `closeDocument()` 把三个值清零,拼出来的载荷就成了 `" 0"`,
521
+ 接收侧(`wsd/ClientRequestDispatcher.cpp:902` 按空格切)解出编号 0,然后 abort。
522
+
523
+ 所以收成一个 `DocSession`,配一把 `g_docMutex`:`openDocument()` 在锁里整份立起来,
524
+ `closeDocument()` 在锁里整份取下并清空,HULLO 一进来就**取一次快照**,之后只认副本。
525
+ 连接完成、发布管道之前还要再确认一次「这篇文档还是当前那篇」——连接期间用户完全可能
526
+ 已经换了文档,那时这套 fd 谁都不认领(app2js 还没起),得自己收干净。
527
+
528
+ fd 的归属也一并理清,三者各有唯一的关闭方:
529
+
530
+ | fd | 谁关 |
531
+ | --- | --- |
532
+ | `clientFd` | HULLO 持有,起了 app2js 就移交给它;HULLO 中途放弃时自己关 |
533
+ | `closePipe[0]` | `closeDocument()`(那次关闭本身就是唤醒信号) |
534
+ | `closePipe[1]` | app2js 线程 |
535
+
536
+ `closeDocument()` **不能碰 `clientFd`**:那一刻 app2js 可能正 poll 着它,而 FakeSocket
537
+ 对已关闭的 fd 是 assert 而不是返回错误。代价是「HULLO 还没来就关掉文档」会漏一个 fd 槽位,
538
+ 这个上游同样存在(`CODocument` 在 init 里就分配了 `fakeClientFd`),量级可以忽略。
539
+
540
+ `runApp2JsThread()` 的两个 fd 也必须**按值捕获**,理由同上:全局里那份随时会被下一篇
541
+ 文档换掉,而上一条转发线程可能还没退出,读全局会 poll 到别人的 socket 上去。
542
+
543
+ ##### 同一条断言还有第二个成因:握手串必须是首帧
544
+
545
+ 上面那套改完之后,「反复多开几次就闪退」**又复现了同一条断言**,栈也一模一样。
546
+ 符号化确认路径是
547
+ `handleIncomingMessage → handleClientWsUpgrade → RequestVettingStation::handleRequest
548
+ → createDocBroker`,也就是握手那一帧,编号仍然解成了 0 —— 但这次宿主拼串的地方明明
549
+ 是对的(`session.valid()` 已经拦掉 `docId == 0` 和空 URL)。
550
+
551
+ 问题在 FakeSocket 的写语义:`fakeSocketWrite()` 只检查 fd 存不存在、有没有 shutdown,
552
+ **不检查连没连上**(`net/FakeSocket.cpp:693`)。往一个刚 `fakeSocketSocket()` 出来、
553
+ 还没 `fakeSocketConnect()` 的 fd 写,会直接把数据 `emplace_back` 进对端读缓冲,
554
+ 一次写就是一帧。于是抢在握手前面写进去的消息,会成为 COOLWSD 读到的**首帧**。
555
+
556
+ 而 MOBILEAPP 形态下首帧是按 `"<url> <appDocId>"` 解的:
557
+ `clientvisiblearea x=0 y=0 ...` 这种消息**有空格**,于是不会落进「没给编号」那一支,
558
+ 而是把第二个 token 当编号去解 —— `x=0` 解不出数,编号取 0,随后 abort。
559
+
560
+ 抢跑的是**上一篇文档的尾巴消息**:`closeDocument()` 之后 `openDocument()` 立刻把
561
+ `clientFd` 换成新的未连接 fd,而旧页面排在 `g_inbound` 里的消息还没处理完,
562
+ 转发时读到的已经是新 fd 了。所以开的次数越多越容易撞上。
563
+
564
+ 修法是给 `DocSession` 加一个 `connected`:握手串写出去之后才置位(置位时同样要确认
565
+ 「还是当前那篇」),转发路径见到未置位就丢消息并记一条 `handshake not sent yet, dropped`。
566
+ 会落到这里的都是上一篇文档的尾巴,对新文档没有意义。
567
+
568
+ 顺带记一下排查手段:崩溃日志里 `libx_office_u.so` 的帧只有导出符号能符号化,
569
+ `DocumentBroker` 这些都在静态库里、又没编 `-g`,设备侧解不出来。用本地 `.so` 的
570
+ `.dynsym` 按地址反查即可(`llvm-nm --defined-only -S --numeric-sort`,找
571
+ `addr <= pc < addr + size` 的那个符号),这一步是分辨「HULLO 路径」还是
572
+ 「Proxy / Batch 路径」的唯一依据 —— 后者在 `ClientRequestDispatcher.cpp:2770`
573
+ 与 `DocumentBroker.hpp:292` 是**显式传 0** 的,光看断言文本会往那边猜错。
574
+
575
+ #### 文档 URL 必须完整 percent-encode,只挑几个特殊字符换不行
576
+
577
+ 现场是「**文档外壳加载出来了,内容一直不显示,也不报错**」,而且只有中文文件名才复现:
578
+
579
+ ```
580
+ coolwsd: ERR [ lokit_runloop ] ERR Exception while loading url
581
+ [file:///…/inbox/1/%25E4%25B8%25AA%25E4%25BA%25BA…docx] for session [001]: std::bad_alloc
582
+ kit/Kit.cpp:1428
583
+ coolwsd: ERR ToMaster-001: Failed to get LoKitDocument instance for […]
584
+ kit/ChildSession.cpp:1127
585
+ ```
586
+
587
+ `%25` 是 `%` 的编码,所以这串是**二次编码**:引擎在找一个名字里带字面百分号的文件。
588
+ (同一轮日志里前端那条 socket URL 是正常的一次编码 `%E4%B8%AA`,可以对照着看。)
589
+
590
+ 原因是 `coolFileUrl()` 当初只转义了 `%` 空格 `#` `?` `&` `+` 六个字符,中文原样留着。
591
+ 但 COOL 这条链的约定是**入参已经编码好**:
592
+
593
+ - `wsd/ClientRequestDispatcher.cpp:908` 写着「URLs are percent-encoded,所以空格可以
594
+ 可靠地分隔 token」——握手载荷 `"<url> <appDocId>"` 就靠这个切;
595
+ - 上游 Android 送 `mTempFile.toURI().toString()`(`LOActivity.java:470`),
596
+ `java.io.File.toURI()` 会把非 ASCII 编成 UTF-8 的 `%XX`;
597
+ - 上游 iOS 送 `NSURL.absoluteString`,同样是编好的。
598
+
599
+ 递进去生 UTF-8,下游就按「已编码」的前提又编一轮。改成逐段 `encodeURIComponent()` 后
600
+ 拼回 `/`(整串编会把 `/` 也变成 `%2F`,路径就断了),段内的 `%` `#` `?` `&` `+` 空格
601
+ 与非 ASCII 一并交给它。
602
+
603
+ 纯 ASCII 文件名下两种写法结果完全一样,所以这个坑在早期的测试文件上一直没暴露。
604
+
605
+ #### 独立的 `l10n-all.js` 不能往 `cool.html` 里补,它已经在 `bundle.js` 里了
606
+
607
+ `browser/Makefile.am:1477` 在 `ENABLE_MOBILEAPP` 下 `--exclude='l10n-all.js'`,不是漏了:
608
+ `browser/release/COOL_JS.m4` 的**第一行**就是它,也就是说它是 `bundle.js` 的第一段
609
+ (`bundle.js` 开头那句 `var onlylang=window.LANG,hyphen=onlylang.indexOf("-")` 就是它的
610
+ 压缩版)。独立文件只是给服务端按语言单独请求用的。
611
+
612
+ 所以别往 `cool.html` 里加 `<script src="l10n-all.js">` —— 那会让同一份字典表在
613
+ `bundle.js` 之前再跑一遍,白搭 7M 未压缩 JS 的同步解析,`stage_cool_dist.sh` 里直接 `rm` 掉。
614
+
615
+ 真正决定语言的只有 `window.LANG`:由 `?lang=` 经 `js/global.js:2322` 设出(那行还要求
616
+ `window.ThisIsAMobileApp`,它来自 `cool.html` 里 `init-app-type=mobile` → `MobileAppInitializer`)。
617
+ `l10n-all.js` 的中文分支只认 `zh-CN` / `zh_CN` / `zh-Hans-CN` / `zh_Hans_CN` 四种写法,
618
+ 而鸿蒙给的是 `zh-Hans`,所以宿主侧要过 `coolLanguageTag()` 归一。
619
+
620
+ #### 握手接不上要往宿主抛一条,否则就是「一直转圈,不报错」
621
+
622
+ `officeLastEngineError()` 只在初始化时读一次,`open()` 之后引擎侧再出问题就没人读它了。
623
+ HULLO 那几条失败路径(COOLWSD 没起监听、没有活着的会话、`fakeSocketConnect` 失败、
624
+ 连接期间文档被换掉)原先只写 hilog,用户那边看到的就是文档永远停在「正在打开」且毫无提示。
625
+ 现在这几处都补了 `xoffice::recordEngineError()`。
626
+
627
+ #### 界面中文:App 形态只认 `window.LOCALIZATIONS`,`l10n/*.json` 那条路是给服务端的
628
+
629
+ 菜单全是英文时,别去翻 `l10n/` 下的文件,那条路在 App 形态压根不走。
630
+
631
+ `js/global.js:2084` 的 `String.prototype.toLocaleString` 在 `ThisIsAMobileApp` 分支里
632
+ **只查 `window.LOCALIZATIONS`**,查不到就原样返回英文原文。而 `cool.html` 里那三个
633
+ `<link rel="localizations">` 指向的 `l10n/localizations.json`,值是
634
+ `%SERVICE_ROOT%/browser/%VERSION%/l10n/ui-zh_CN.json` —— 两个占位符由 coolwsd 在响应里
635
+ 替换,我们是 `file://` 直接加载,没人替换;而且 `ui-*.json` 那批文件压根没生成
636
+ (`browser/Makefile.am:31` 的 `L10N_LANGS` 来自 `engine/distro-configs/Langs.conf`)。
637
+
638
+ 真正生效的是 `l10n-all.js`:它按 `window.LANG` 挑好字典设进 `window.LOCALIZATIONS`。
639
+ 但**不用去引它** —— 它已经是 `bundle.js` 的第一段,详见上面那节。`stage_cool_dist.sh`
640
+ 只需把独立的那份 7.1MB 删掉。
641
+
642
+ 于是唯一要做的就是把 `window.LANG` 给对,而坑在语言标签本身。`l10n-all.js` 对带地区的语言是**逐个字面量**比对的
643
+ (`browser/util/create-l10n-all-js.py` 的 `script_variants`),中文那组只列了
644
+ `zh-CN` / `zh_CN` / `zh-Hans-CN` / `zh_Hans_CN`,**裸的 `zh-Hans` 不在其中**。
645
+ 而鸿蒙 `i18n.System.getSystemLanguage()` 恰好只给 `zh-Hans`(安卓给 `zh-CN`、
646
+ iOS 给 `zh-Hans-CN`,本来就能匹配)。所以三端统一过一道 `coolLanguageTag()`。
647
+
648
+ 引擎自己产生的那批字符串(UNO 命令标签、侧边栏)是另一层,要 `engine/translations`
649
+ 仓在场并带 `--with-lang=zh-CN` 重编引擎才有 —— 我们这次的构建日志里明确回退了:
650
+ `--with-lang is not supported without the translations repo checked out ... falling back to en-US`。
651
+ COOL 自己的界面串(菜单、工具栏、对话框)不受这个影响,走上面那条就够。
652
+
653
+ #### (已推翻)同一个 `lang` 不能既喂前端又喂引擎,引擎那份得掐掉
654
+
655
+ 上面把 `window.LANG` 修对之后,出现了一个乍看无关的回归:**文档外壳正常加载,内容区
656
+ 永远空白,也不报错**。纯 ASCII 文件名一样复现,所以跟路径、跟 percent-encode 都没关系。
657
+ 引擎侧的真实现场是:
658
+
659
+ ```
660
+ ERR Exception while loading url [...]: std::bad_alloc kit/Kit.cpp:1428
661
+ ERR Failed to get LoKitDocument instance for [...] kit/ChildSession.cpp:1127
662
+ ```
663
+
664
+ 根因是 `?lang=` 这一个值被两条完全不同的链路共用:
665
+
666
+ | 消费方 | 路径 | 我们要不要 |
667
+ | --- | --- | --- |
668
+ | 前端界面翻译 | `window.LANG` → `l10n-all.js` → `window.LOCALIZATIONS` | 要 |
669
+ | 引擎文档语言 | `js/global.js:2340` 塞进 `load url=… lang=…` → COOLWSD → `documentLoadWithOptions` 的 `Language=`(`kit/Kit.cpp:2192`) | **不能要** |
670
+
671
+ 第二条在我们这个引擎上是有害的:给出一个 LibreOffice 认得的中文标签,它会真去切 CJK
672
+ 语言环境,而引擎是在没有 `engine/translations` 仓的情况下编的(就是上一节那条
673
+ `falling back to en-US`),那条路上直接抛 `std::bad_alloc`。
674
+
675
+ 早先没暴露纯属巧合 —— 鸿蒙原始标签 `zh-Hans` 是个 LibreOffice 认不出的裸标签,它跳过了;
676
+ 归一成 `zh-CN` 之后才真正生效。所以这个 bug 是「把语言标签修对」顺带引出来的。
677
+
678
+ 当时的处理办法是在宿主层把 `load` 消息里的 ` lang=…` 摘掉,前端那份 `window.LANG`
679
+ 保持不动。
680
+
681
+ **这条已经被真机复验推翻,代码也撤回了。** 日志确认改写确实生效(`forwarding without
682
+ lang:` 那行在场,转给引擎的消息里没有 `lang=`),但 `std::bad_alloc` **一模一样地复现**。
683
+ 所以 `Language=` 不是原因,摘掉它只是白丢引擎侧的文档默认语言。
684
+
685
+ 留着这一节是因为推理链本身没错、只是结论错了 —— 别再照它走第二遍。真正的原因当时还没找到,
686
+ 诊断手段见下节的 `onAllocationFailed()` 探针。
687
+
688
+ #### 落地路径不能复用,COOLWSD 的 docKey 就是文件路径
689
+
690
+ 现场是「第一次能开,退出去重开同一篇就 `open:fail LOADFAIL cmd=load kind=docunloading`」,
691
+ 引擎日志里写得很直白:
692
+
693
+ ```
694
+ WRN Document […] has been modified behind our back. Informing all clients.
695
+ Expected: …T04:49:57.070406Z, Actual: …T04:50:30.370886Z
696
+ WRN The document […] could not be uploaded to storage because there is a newer
697
+ version there, and no active clients exist to resolve the conflict. Stopping.
698
+ INF Finished polling doc […]. stop: true, CloseReason: [conflict]
699
+ INF Rejecting loading session [002] with error: cmd=load kind=docunloading
700
+ ```
701
+
702
+ `docKey` 是从 URI 推出来的(`wsd/RequestDetails.cpp`),也就是**文件路径本身**。
703
+ 鸿蒙侧必须把选择器给的授权 URI 拷进沙箱才能给引擎,而原先每个 editor 实例固定用
704
+ `inbox/<sessionKey>/<文件名>` 这一条路径 —— 重开就是同一个 docKey,而上一篇的
705
+ `DocumentBroker` 未必已经消失。重新拷贝改掉了 mtime,broker 一比时间戳就判「文件被人
706
+ 在背后改了」,走冲突处理直接把自己停掉,新会话跟着被拒。
707
+
708
+ 办法是每次打开多套一层子目录(`stageDocument()`),对引擎就是一篇全新文档,撞不上任何
709
+ 残留 broker。旧目录顺手递归删掉,`cacheDir` 不会长 —— 引擎可能还持着上一份的 fd,
710
+ 但 POSIX 下 unlink 不影响已打开的 fd。
711
+
712
+ **子目录名必须带时间戳,不能只用进程内计数器。** 第一版用的是 `inbox/<handle>/<_stageSeq>`,
713
+ `_stageSeq` 是模块级变量,应用重启就归零,而 `cacheDir` 是跨进程存活的 —— 于是重启后第一次
714
+ 打开就撞上上一进程留下的同名目录。鸿蒙的 `fileIo.mkdirSync(path, true)` 即便 `recursion=true`,
715
+ 撞已存在的路径也是直接抛 EEXIST(同一个坑见 `XOfficeResources.ets` 的 `ensureDir`),表现成
716
+ 「重启后一篇都打不开,只报 `open:fail cannot copy the picked document into the app sandbox`」。
717
+ 现在是 `inbox/<handle>/<Date.now()>-<seq>`,外加 `accessSync` 护栏。
718
+
719
+ 顺带:这条报错以前不带原因,现场只剩一句「拷不进沙箱」。而这一步能失败的原因至少有三类
720
+ (目录已存在、授权 URI 已过期、空间不足),光看那句话分不出来,所以 `stageFailedMessage()`
721
+ 现在会把底层异常拼在后面 —— hilog 缓冲十几分钟就滚掉,事后基本捞不回来。
722
+
723
+ 安卓与 iOS 不落地(选择器直接给真实路径,mtime 不变),所以这条是鸿蒙专属。
724
+
725
+ #### ohpm 报 `zlib: unexpected end of file`:先查边车文件,再怀疑拷贝竞态
726
+
727
+ ```
728
+ ohpm ERROR: Fetch local package: ".../libs/x-office-native.har" failed.
729
+ ZlibError: zlib: unexpected end of file
730
+ ohpm ERROR: Run install command failed
731
+ Original Error: readPkgJson failed!
732
+ ```
733
+
734
+ **首要成因是 AppleDouble 边车文件,而且它一度骗过了这里的自检。**
735
+
736
+ 仓库放在 exFAT 卷上(`/Volumes/app`),exFAT 存不了扩展属性,于是 macOS 给每个带
737
+ xattr 的条目在旁边写一个 `._xxx`。这些文件 `stat` 报 4096 字节,读却返回 0 字节。
738
+ `tar` 打包时因此报 `Truncated input file (needed 4096 bytes, only 0 available)`
739
+ 并写出一个损坏的成员,解包走到它就 `Truncated tar archive` 非零退出 —— 真实内容
740
+ 其实全在包里,但 ohpm 拿到的是一个解不到底的包。`COPYFILE_DISABLE=1` 只管住 `tar`
741
+ 自己不去生成 `._`,管不住 `cp` 已经落在盘上的那些。
742
+
743
+ 所以 `build_oh.sh` 打包前会先 `find "$HAR_STAGE" -name '._*' -delete`;
744
+ `stage_cool_dist.sh` 在**开头和结尾各清一次**(结尾那次是必须的:往 `global.js`
745
+ 追加静音代码那一步会让 macOS 重新生成一个 `._global.js`)。
746
+
747
+ 这个坑不止鸿蒙:iOS 那边前端与引擎资源是 `cp -R` / `rsync` 进
748
+ `utssdk/app-ios/Resources/` 的,拷完会新生成一批(实测 4865 个,每个 4096 字节,
749
+ 约 20MB),会原样进 app bundle,所以 `build_ios.sh` 末尾也清一次,范围取整个
750
+ `app-ios` —— 产物自身的边车(`Frameworks/._XOfficeCore.xcframework`)落在父目录里。
751
+ 安卓不受影响:aar 由 gradle 打 zip,实测包内 0 个。
752
+ 仓库层面另外在 `.gitignore` 里加了 `._*`,免得它们混进提交。
753
+
754
+ 自检也换掉了。原来是:
755
+
756
+ ```bash
757
+ gzip -t <har> # 只验外层 gzip 流
758
+ ```
759
+
760
+ `gzip -t` 验不出 tar 层的坏成员 —— gzip 流完好、解包却走不到底,正是上面那种,
761
+ 于是它给出假 OK 放行了坏包。现在改成把整份清单读到底并真的看 `tar` 的退出码:
762
+
763
+ ```bash
764
+ tar -tzf <har> > /dev/null # 走不到底就非零退出
765
+ ```
766
+
767
+ 清单另存成文件再 `grep -cx 'package/oh-package.json5'`,不接管道:脚本开了
768
+ `set -o pipefail`,`grep -q` 找到就退出会让 `tar` 吃到 SIGPIPE 返回 141,整条管道
769
+ 被判成失败 —— 包完全是好的,却报「har 里缺 oh-package.json5」。
770
+
771
+ 排掉边车文件之后,还剩一种真的是竞态:这个 har 有 110MB 上下,HBuilderX 每次运行
772
+ 都要把它整份拷进
773
+ `unpackage/dist/dev/app-harmony/uni_modules/x-office-u/utssdk/app-harmony/libs/`,
774
+ 而 ohpm 偶尔会在拷完之前就去解它。这一种**重跑一次就好**。判断方法是在源包上跑一遍
775
+ 上面那条 `tar -tzf`:源包能解到底就是拷贝竞态。
776
+
777
+ 顺带一提,`unzip -l <har>` 一定会报
778
+ `End-of-central-directory signature not found`,那是**正常的**:har 是 tar+gzip,不是 zip。
779
+ 别把这条当成包坏了的证据(我就为此绕过一轮)。
780
+
781
+ #### 日志里的文档 URL 是双重编码的,那是上游的显示变体,不是 bug
782
+
783
+ 排「文档打不开」时会看到同一篇文档在日志里有两副面孔:
784
+
785
+ ```
786
+ INF SHA1 for DocKey [...] of [/data/.../个人租房合同.docx] ← 解码后的真实路径
787
+ INF Document ctor for [...] url [/data/.../个人租房合同.docx] ← 同上
788
+ INF Loading url [file:///.../%25E4%25B8%25AA%25E4%25BA%25BA....docx] ← 双重编码
789
+ ERR Exception while loading url [file:///.../%25E4%25B8%25AA....docx]: ... ← 双重编码
790
+ ```
791
+
792
+ 后两条打的是 `getJailedFilePathAnonym()`,也就是 `_uriJailedAnonym`。它在
793
+ `wsd/DocumentBroker.cpp:1442-1445` 被编码了两次:先 `Poco::URI::encode(localPath, "#?")`,
794
+ 再套一层 `Poco::URI(Poco::Path(...))`。真正传给 `documentLoadWithOptions()` 的是
795
+ `getJailedFilePath()`(`_uriJailed`),只编码一次,是对的。
796
+
797
+ 所以**别照着这两行去追编码问题**,那是死路。要确认引擎实际收到什么,看
798
+ `SHA1 for DocKey ... of [...]` 那行的路径,或者在宿主侧把 `load` 消息打出来。
799
+
800
+ #### 默认别开调试日志,那是卡顿的主要来源
801
+
802
+ `Log::initialize()` 的级别、以及 `SAL_LOG`,两个都**默认压到很低**,排障时才用环境变量开:
803
+
804
+ | 环境变量 | 默认 | 作用 |
805
+ | --- | --- | --- |
806
+ | `X_OFFICE_LOG_LEVEL` | `information` | COOLWSD / Kit 的 `LOG_*` |
807
+ | `X_OFFICE_SAL_LOG` | 不开 | 引擎自己的 `SAL_*`,写到 `<root>/x-office-sal.log`(**当前构建下无效**,见下节) |
808
+
809
+ COOLWSD 那档别降到 `warning`:那一档把「引擎请求加载哪篇文档」这类每篇一条的关键线索
810
+ 也一起关了。`information` 是按文档 / 会话打的,不按 tile,基本不花钱。
811
+
812
+ #### 引擎的 `SAL_INFO` / `SAL_WARN` 在本构建里是**编译期消掉的**,别指望它
813
+
814
+ ```bash
815
+ $ grep -n 'ENABLE_DEBUG\|ENABLE_SAL_LOG' engine/config_host.mk
816
+ 174:export ENABLE_DEBUG=
817
+ 219:export ENABLE_SAL_LOG=
818
+ ```
819
+
820
+ 两个都是空的,于是 `SAL_INFO` / `SAL_WARN` 全部展开成空操作。**给 `SAL_LOG` /
821
+ `SAL_LOG_FILE` 设任何值都不会有一行输出,日志文件根本不会被创建** —— 别把「没有日志文件」
822
+ 当成「代码没走到」。要真拿到引擎日志,只能带 `--enable-sal-log` 重编整个引擎(几小时)。
823
+
824
+ 所以看引擎内部得靠**进程内探针**。现成两个:
825
+
826
+ | 探针 | 在哪 | 机制 | 抓什么 |
827
+ | --- | --- | --- | --- |
828
+ | `onAllocationFailed()` | `XOfficeMobileApp.cpp` | `std::set_new_handler` | `operator new` 失败的现场 |
829
+ | `__wrap___cxa_throw()` | `engine/ohos/Bootstrap/xoffice-throw-probe.cxx` | 链接期 `-Wl,--wrap` | 指定异常类型的**抛出点** |
830
+
831
+ 两个都自己走 `_Unwind_Backtrace` + `dladdr` 打栈(`backtrace(3)` 不能用,OHOS 的 musl
832
+ libc 没有它),报完就把控制权原样交回去,不改变失败行为。new_handler 那个成立在
833
+ 「引擎与本桥的 `DT_NEEDED` 里是同一份 `libc++_shared.so`」上,所以全进程共享。
834
+
835
+ 抛出点探针踩过一个坑,值得记下来。
836
+
837
+ #### 抓抛出点只能用链接期 `--wrap`,**不能**在插件 .so 里覆盖 `__cxa_throw`
838
+
839
+ 第一版是在 `libx_office_u.so` 里直接定义一个 `__cxa_throw` 覆盖掉 libc++ 的,再用
840
+ `dlsym(RTLD_NEXT, "__cxa_throw")` 找回真身转发。理论上可行(Linux/Android 上是标准做法),
841
+ **在鸿蒙上会把整个应用打死**:
842
+
843
+ ```
844
+ Reason:Signal:SIGABRT(SI_TKILL) Tid:5672, Name:lokit_runloop
845
+ #02 libx_office_u.so(__cxa_throw+148)
846
+ #03 liblo-native-code.so ← ModuleManager::identify()
847
+ ```
848
+
849
+ `dlsym(RTLD_NEXT, …)` 在 OHOS 的 ld-musl(带命名空间)上**返回空**,于是兜底那句
850
+ `abort()` 生效。而且它触发在引擎启动期第一个异常上 —— 那个异常是
851
+ `JobExecutor::notifyEvent()` 里 `ModuleManager::identify()` 抛的、上游自己 `catch` 掉的,
852
+ 本来完全无害。也就是说:覆盖 `__cxa_throw` 一旦找不回真身,**任何一个正常异常都会变成崩溃**,
853
+ 而这个前提没法在设备上先验证(`/data/local/tmp` 上 SELinux 不让跑独立可执行文件)。
854
+
855
+ 改成给引擎那次链接加 `-Wl,--wrap=__cxa_throw`:`__real___cxa_throw` 由链接器在链接期
856
+ 绑到 `libc++_shared` 的真身,运行期零查找,不存在「找不回真身」这个状态。代价是探针必须
857
+ 编进引擎 .so 里 —— `--wrap` 只作用于本次链接的目标文件,而引擎的 throw 都在它自己那边。
858
+
859
+ 验证方式(新旧 build-id 要对得上,否则你看的是旧包):
860
+
861
+ ```bash
862
+ llvm-nm engine/ohos/obj/local/arm64-v8a/liblo-native-code.so | grep cxa_throw
863
+ # U __cxa_throw ← 真身,运行期由 libc++_shared 提供
864
+ # t __wrap___cxa_throw ← 探针
865
+ llvm-objdump -d … | grep -c __wrap___cxa_throw # 16335 处 throw 已改指探针
866
+ ```
867
+
868
+ 重链只要十几秒(`cd engine/ohos/source && gmake link-so`),不是重编引擎。
869
+
870
+ 同一套路可以照抄去抓别的:需要看谁在动某个全局状态时,装个进程内钩子比重编引擎便宜几个数量级。
871
+
872
+ #### `std::bad_alloc` 在 LibreOffice 里**多半不是内存不够**
873
+
874
+ new_handler 探针跑完一整轮**没有开火**,而 `std::bad_alloc` 照旧抛 —— 于是可以确定它
875
+ 不是 `operator new` 失败,是被显式 `throw` 的。这一步很关键,因为「`bad_alloc` = 内存不够」
876
+ 这个直觉在这个代码库里是错的:`include/rtl/ustring.hxx` 里那几个带编码参数的 OUString
877
+ 构造函数长这样
878
+
879
+ ```cpp
880
+ rtl_string2UString(&pData, value, length, encoding, convertFlags, true);
881
+ if (pData == NULL) throw std::bad_alloc();
882
+ ```
883
+
884
+ 而 `rtl_string2UString()` 返回空既可能是内存不够、**也可能是文本编码转换失败**。
885
+ `OUStringBuffer::ensureCapacity()`、`OUString::intern()` 等等同理。所以看到 `bad_alloc`
886
+ 第一件事不是查内存,是**拿抛出点的栈**。
887
+
888
+ #### 鸿蒙禁止匿名可执行内存:UNO 桥抛不出异常,文档就加载不出来
889
+
890
+ 上面那个「内容区一直空白、只有一句 `std::bad_alloc`」的现场,抓到抛出点之后是这条链
891
+ (自下往上):
892
+
893
+ ```text
894
+ lo_documentLoadWithOptions → Desktop::loadComponentFromURL → LoadEnv::…
895
+ → SfxFrameLoader_Impl::load → XFrameImpl::setComponent
896
+ → LayoutManager::implts_reset → ModuleUIConfigurationManager::createDefault
897
+ → framework::PresetHandler::getOrCreateRootStorageUser
898
+ → FSStorageFactory::createInstanceWithArguments
899
+ → utl::UCBContentHelper::IsDocument → ucbhelper::Content::isDocument
900
+ → fileaccess::BaseContent::execute → TaskManager::endTask
901
+ → fileaccess::throw_handler ← 文件访问出错,要抛 IOException
902
+ → ucbhelper::cancelCommandExecution
903
+ → cppu::throwException ← 正在把这个异常抛出来
904
+ → uno2cppMapping → CppInterfaceProxy::create
905
+ → VtableFactory::getVtables
906
+ → VtableFactory::createVtables ← 在这里抛 std::bad_alloc
907
+ ```
908
+
909
+ 因果链:
910
+
911
+ 1. **HarmonyOS NEXT(API 12 起)禁止应用把匿名内存置为可执行**,官方说法是「防止恶意应用
912
+ 向匿名内存注入指令绕过代码签名管控」。Flutter 的 Dart VM 撞的是同一面墙
913
+ (`mprotect failed: 22`)。
914
+ 2. `gcc3_linux_aarch64` 这套 C++/UNO 桥要在**运行期**把 vtable 跳转片段的指令写进内存再
915
+ 执行,靠的正是 `VtableFactory` 的 `allocExec`:mmap 匿名内存 + `mprotect(PROT_EXEC)`。
916
+ 这一步在鸿蒙必然失败,`createBlock()` 于是永远返回 false,`createVtables()` 抛
917
+ `std::bad_alloc`。
918
+ 3. `cppu::throwException()` 是**靠这个桥**把 UNO 异常的 `Any` 转成带类型的 C++ throw 的。
919
+ 于是**任何**经它抛出的 UNO 异常都变成 `bad_alloc`,原始错误信息全部丢失。
920
+ 4. 而 `UCBContentHelper::IsDocument()` 里是 `catch (uno::Exception) { return false; }`
921
+ —— 「文件不存在」本来就是要被吞掉的正常情况。`PresetHandler` 例行探测一下 UI 配置
922
+ 存储在不在,这个本该无声返回 false 的调用,把整篇文档的加载打死了:`bad_alloc` 不匹配
923
+ 那里任何一个 catch。
924
+
925
+ **所以之前查的文件名、URL 编码、语言环境、落地路径全都不是原因** —— 只要引擎内部有任何
926
+ 一次 UNO 异常经 `cppu::throwException()` 抛出,就会撞上这个。
927
+
928
+ 修法照上游给 iOS 的路子(`bridges/source/cpp_uno/gcc3_ios/ios64_helper.s`):运行期生成的
929
+ 那 8 条指令里只有 `functionIndex` 与 `vtableOffset` 两个数在变,把所有组合在构建期摆成
930
+ 一张表,vtable 槽位直接指进去。这样内存块里只剩指针,一行代码都不用生成,也就不需要
931
+ 可执行内存。见 `patches/0007-engine-ohos-vtable-no-exec-mem.patch`:
932
+
933
+ | 改动 | 位置 |
934
+ | --- | --- |
935
+ | 新增预生成片段表(1024 × 4 片、每片 20 字节,`.text` 增 80KB) | `gcc3_linux_aarch64/ohos_snippets.s` |
936
+ | `generateCodeSnippet` → 查表的 `ohosCodeSnippet`,`getBlockSize` 不再为代码留位置 | `gcc3_linux_aarch64/cpp2uno.cxx` |
937
+ | `__OHOS__` 下不再 `mprotect(PROT_EXEC)` | `shared/vtablefactory.cxx` |
938
+ | `OS=OHOS` 时把 `ohos_snippets` 加进 `bridge_asm_objects` | `bridges/Library_cpp_uno.mk` |
939
+
940
+ 后两处必须同时成立:块里不放代码,才敢不要执行权限。改一处就得改另一处。
941
+
942
+ 片段里用 `adrp/add` 而不是直接 `b`,因为引擎 `.so` 的 `.text` 有上百 MB,`b` 的 ±128MB
943
+ 覆盖不住。链接后要验一下跳转目标真解析对了(链接器会把 `adrp+add` 收成单条 `adr`):
944
+
945
+ ```bash
946
+ llvm-nm --print-size liblo-native-code.so | grep -E "vtableSlotCall$|xofficeCodeSnippets$"
947
+ # 片段里 adr 的目标地址必须等于 vtableSlotCall 的地址
948
+ llvm-objdump -d --start-address=<片段表地址> --stop-address=<+20> liblo-native-code.so
949
+ ```
950
+
951
+ 只影响鸿蒙:安卓允许匿名可执行内存,iOS 走的就是 `gcc3_ios` 那套自带预生成表的桥。
952
+
953
+ 预生成范围(`functionIndex < 1024`、`vtableOffset/8 < 4`)超出时返回空指针,与上游 iOS 桥
954
+ 一致;真撞上了要把表调大重编。
955
+
956
+ 原因是这条链路上每一片 tile、每一条前端消息都各打一条 `LOG_DBG`,鸿蒙这边它们还要
957
+ 逐条过 `OH_LOG_Print`(`common/Log.hpp` 的 OHOS 分支)。`SAL_LOG` 更贵,每条都是一次
958
+ 文件写,而之前默认选择器里的 `+INFO.vcl.schedule` 是 VCL 调度器**每个定时器 tick 一条**。
959
+ 现场表现是「能打开也能用,但整体渲染卡、平移不跟手」。
960
+
961
+ 反过来也别把 COOLWSD 降到 `warning`:那一档把「引擎请求加载哪篇文档」这类**每篇一条**的
962
+ 关键线索也一起关了,上面那个空白页就是因此查了好几轮。默认落在 `information`
963
+ ——它是按文档 / 会话打的,不按 tile,基本不花钱。
964
+
965
+ 顺带记一句形态本身的成本:tile 是引擎渲染完经假 socket 送进 WebView 画布的,
966
+ 不是原生视图直接绘制,所以即使日志关干净,跟手程度也比不了原生控件。
967
+ 三端用的都是**系统 Web 内核**(鸿蒙 ArkWeb / 安卓 System WebView / iOS WKWebView),
968
+ 包里没有、也不需要带浏览器内核。
969
+
970
+ #### 另存的大小与耗时得宿主自己量
971
+
972
+ 编辑态的另存是引擎异步写盘的,完成信号只有一句 `SAVEAS`,**不带大小也不带耗时**。
973
+ 所以 `_finishSave()` 里 `byteSize` 走 `officeFileSize()`(三端各自 stat),
974
+ `durationMs` 用派发时记下的时间戳。写死 0 的话界面上就是「0kb 耗时 0ms」,
975
+ 看着像保存失败。
976
+
977
+ iOS 侧那个 `officeFileSize` 要注意:UTS 的 `number` 在 iOS 上是 `NSNumber`,
978
+ Swift 返回的 `Double` 必须 `NSNumber(value = ...)` 包一层,否则报
979
+ `cannot convert return expression of type 'Double' to return type 'NSNumber'`。
980
+ 同一个坑在 `officeCreateSession` 里已经踩过一次。
981
+
982
+ 另外调用方普遍会写 `uni.env.CACHE_PATH + "/" + name`,而 `CACHE_PATH` 自带尾斜杠,
983
+ 于是路径里多一道 `//`。落盘没问题(POSIX 会折叠),但这个路径要原样回给调用方、
984
+ 也要拼进 `file://` URL,所以在 `_runSave()` 里过一道 `collapseSlashes()`。
985
+
986
+ #### 中日韩字体要靠设备提供,引擎自己没有
987
+
988
+ 引擎随包带了 174 个字体,但**全是拉丁与阿拉伯字系,一个中日韩字体都没有**,
989
+ 所以中文文档里每个汉字都落到 notdef,屏幕上是一排框框(拉丁字母却是正常的,
990
+ 这个反差就是判断依据)。设备侧本来就有能用的:鸿蒙 `/system/fonts/HarmonyOS_Sans_SC.ttf`、
991
+ 安卓 `NotoSansCJK`、iOS PingFang,缺的只是让引擎知道去哪找。
992
+
993
+ 引擎那边的字体目录是 `psp::getFontPath()` 从 `installDir` 推出来的
994
+ (`vcl/unx/generic/fontmanager/helper.cxx:175`),外部路径塞不进去。但它底下的
995
+ fontconfig 认 `getenv("FONTCONFIG_FILE")`(`fontconfig/src/fccfg.c:2596`),
996
+ 所以 `setupFontConfig()` 自己写一份配置指过去就行,**不用改引擎也不用重编**。
997
+
998
+ 值得记一下当前的字体是怎么进来的:fontconfig 内置的兜底配置指向
999
+ `/usr/share/fonts` 与 `/usr/local/var/cache/fontconfig`,设备上一个都不存在,
1000
+ 也就是说 fontconfig 的字体目录本来是空的——那 174 个字体是 VCL 用
1001
+ `FcConfigAppFontAddDir()` 以 app font 身份加进去的,不经过配置文件。
1002
+ 这也是为什么「只缺中日韩」而不是「一个字都没有」。
1003
+
1004
+ 几个别踩的点:
1005
+
1006
+ - 必须在引擎初始化**之前** `setenv`。fontconfig 是第一次取字体列表时读这个变量的,
1007
+ 而那一刻发生在引擎起 VCL 的时候。
1008
+ - 配置里别写 DOCTYPE。fontconfig 不校验外部 DTD,写了反而要求 `fonts.dtd` 在场。
1009
+ - `<cachedir>` 要给一个沙箱内的可写路径,否则 fontconfig 报
1010
+ `No writable cache directories`,每次启动都要重扫全部字体目录,首屏明显变慢。
1011
+ - `<dir>` 是递归扫的,给顶层目录就够;不存在的目录 fontconfig 直接跳过,
1012
+ 所以三端的系统字体路径可以一起列上,不必条件编译。
1013
+
1014
+ 排障时把 `SAL_LOG` 的 `+INFO.vcl.fonts` 打开(默认选择器里已经有),
1015
+ `PrintFontManager::initialize: collected N fonts` 那个 N 只有一百多,
1016
+ 就说明设备的 `/system/fonts` 没被扫进来。
1017
+
1018
+ 顺带一提,PDF 的字形回退是另一条路:pdfium 有自己的 POSIX 字体路径,默认指向
1019
+ `/usr/share/fonts` 一类不存在的目录,所以 `PDFiumLibrary.cxx` 里单独给
1020
+ `__OHOS__` 传了 `m_pUserFontPaths = {"/system/fonts"}`。改字体相关的东西时两处都要想到。
1021
+
1022
+ #### 崩溃会把用户配置写坏,所以配置目录会自愈
1023
+
1024
+ 引擎的 `sofficerc` 开了 `SecureUserConfig=true` / `SecureUserConfigNumCopies=2`,写了一半的
1025
+ 用户配置会让 configmgr 直接拒绝启动,而 Desktop 服务依赖配置提供者,跟着建不起来。也就是
1026
+ **一次崩溃能把之后所有打开操作永久卡住**,而且报出来的现象(Desktop 服务建不起来)和真因
1027
+ (上一次崩在建配置存储的过程中)完全对不上号,很容易往错的方向查。
1028
+
1029
+ `XOfficeEngine.cpp` 为此做了自愈:进引擎前在用户配置目录落一个
1030
+ `.x-office-init-pending`,`documentLoad` **正常返回**(成功或干净失败都算)就撤掉;下次启动
1031
+ 发现标记还在,就判定上次死在引擎里,清空 `user/` 重来。
1032
+
1033
+ 两个设计取舍值得留一笔:
1034
+
1035
+ - 标记撤销不看成功失败,只看「有没有正常回来」。干净的失败说明进程活着、配置没被写坏,
1036
+ 下次没必要清;否则一份打不开的坏文档会让每次启动都白清一遍配置。
1037
+ - 用户配置存储是在加载过程中建的,之后的 `initializeForRendering` / `getDocumentSize`
1038
+ 不碰它,所以在 `documentLoad` 返回处撤标记不会漏掉真正会造成损坏的窗口。
1039
+
1040
+ 排查时如果看到 `hilog` 里有 `previous run died inside the engine, resetting ...`,
1041
+ 说明这次启动清过一次配置——上一次运行是崩掉的,别把它当本次的问题。
1042
+
1043
+ ### 编辑链路
1044
+
1045
+ WebView 里的前端和引擎之间隔着一层 online 的 MOBILEAPP 代码(`kit/` + `wsd/`,45 个源
1046
+ 文件)。它把平时跑在服务器上的 COOLWSD 塞进 App 进程,两端用 `FakeSocket`(一个纯内存的
1047
+ socket 仿真)对话,因此整条链路不开任何端口、不落一次网络请求。
1048
+
1049
+ 三端打包脚本都会先跑 `common/mobileapp/build_mobileapp.sh` 编出 `libxofficemobileapp.a`
1050
+ 再链进去,不需要单独执行。少了这一层插件仍然能加载,但 `getRuntimeInfo().frontendReady`
1051
+ 是 `false`,只剩直连 LOK 的只读预览。
1052
+
1053
+ 上下行分两条路,不对称:
1054
+
1055
+ | 方向 | 通道 |
1056
+ | --- | --- |
1057
+ | 前端 → 引擎 | 前端调 `window.COOLMessageHandler.postMobileMessage()`,各端 WebView 的 JS 桥接到 native |
1058
+ | 引擎 → 前端 | 前端向 `cool://<host>/cool/.../ws/mobilesocket` 发 XHR,native 拦下来吐出排队的消息 |
1059
+
1060
+ 回程之所以绕 `cool:` 自定义 scheme 而不是直接 `evaluateJavascript` 灌数据,是因为瓦片是
1061
+ 二进制的,塞进 JS 字符串要先 base64,一次编辑几十张瓦片,开销压不住。三端各用自己的
1062
+ 拦截点:iOS `WKURLSchemeHandler`、Android `shouldInterceptRequest`、鸿蒙
1063
+ `setWebSchemeHandler` + `customizeSchemes`。
1064
+
1065
+ `save` / `saveAs` / `postUnoCommand` / `setPart` 这些插件 API 在编辑态下不走 LOK 直连,
1066
+ 而是和前端**共用同一条 FakeSocket**。直连会让引擎再加载一份文档,保存下去是没编辑过的
1067
+ 那一份。
1068
+
1069
+ #### 只能有一个 `soffice_main`,且必须先 `startEditing` 再 `createSession`
1070
+
1071
+ 改这条链路时最容易踩的坑,两次都表现为「进页面即闪退且没有任何日志」,因为引擎的
1072
+ `osl` 信号处理器会把 `SIGSEGV` 转成 `Application::Abort()` + `_exit(1)`,连 tombstone
1073
+ 都不留。
1074
+
1075
+ 引擎主循环(`soffice_main()`)在本插件里只能有一条,它的归属由 `SAL_KIT_OPTIONS` 里的
1076
+ `unipoll` 决定,而这个环境变量是 **COKit 初始化那一刻**读的:
1077
+
1078
+ - 没有 `unipoll`:引擎自己在 `lo_startmain` 里起一条线程跑 `soffice_main()`
1079
+ - 有 `unipoll`:引擎不起,改由嵌入方调 `runLoop()` 带起来(我们的
1080
+ `runKitLoopInAThread()`)
1081
+
1082
+ 落 `unipoll` 的是 `setupKitEnvironment()`,它在 `mobileapp::start()` 的第一步。所以三端
1083
+ UTS 的初始化顺序是硬约束:**`officeStartEditing()` 必须排在 `officeCreateSession()`
1084
+ 之前**。反过来会两条主循环同时动 VCL 与 `KitSocketPoll` 的共享状态。
1085
+ `mobileapp::start()` 里有 `xoffice::engineInitialized()` 前置检查兜着,顺序不对会拒绝
1086
+ 进编辑态并打 `LOG_ERR`,退化成只读直连而不是崩。
1087
+
1088
+ 另一半是 `common/Common.hpp` 的 `DOCS_SHARE_PROCESS`,它必须和 `runKitLoopInAThread()`
1089
+ 同时开(`X_OFFICE_APP` 已加进那个条件,与 `IOS`/`QTAPP`/`MACOS`/`_WIN32` 并列):
1090
+
1091
+ | | `DOCS_SHARE_PROCESS=1`(本插件) | `=0`(服务器 / 上游 Android) |
1092
+ | --- | --- | --- |
1093
+ | 引擎实例 | 宿主提前建好,多文档共用 | `lokit_main()` 里现建,一文档一进程 |
1094
+ | 主循环 | 单开一条 `lokit_runloop` 线程 | `lokit_main()` 里 `startMainLoop()` |
1095
+ | `runLoop()` 的 `data` | 忽略,`pollCallback()` 遍历 `KSPolls` | 当 `KitSocketPoll*` 解引用 |
1096
+ | `setupKitEnvironment()` | 嵌入方负责 | `lokit_main()` 自己调 |
1097
+
1098
+ 混着用的后果:`pollCallback()` 拿 `runKitLoopInAThread()` 传的占位指针当
1099
+ `KitSocketPoll*` 读,`_document` 读出栈垃圾,`Document::needsQuickPoll()` 里
1100
+ `ldr x8, [x0, #0xe0]` 直接 `SIGSEGV`。注意 `data` 也不能改传 `nullptr`——`vcl` 那边
1101
+ (`vcl/headless/svpinst.cxx`)是靠 `mpPollClosure` 非空决定要不要调回调的。
1102
+
1103
+ #### `unipoll` 一旦落下,只读直连就依赖编辑链路
1104
+
1105
+ 这是上面那条顺序约束的另一面,也是最容易误判的一处:**只读直连不是独立的退路**。
1106
+
1107
+ `setupKitEnvironment()` 落下 `SAL_KIT_OPTIONS=unipoll` 之后,引擎不再自己起
1108
+ `soffice_main`,主循环得由 `runKitLoopInAThread()` 带。所以只要编辑链路走过了
1109
+ `setupKitEnvironment()` 却没走到 `runKitLoopInAThread()`(进程内 COOLWSD 起不来、
1110
+ 中途抛异常等),引擎里就没有 Desktop / framework 服务在跑 —— 此时连只读直连的
1111
+ `documentLoad()` 也会抛 `DeploymentException`。
1112
+
1113
+ 症状极具误导性:报的是 `open:fail internal engine error
1114
+ <com::sun::star::uno::DeploymentException>`,看起来像文档或权限问题,实际根因在
1115
+ 几百行之前的编辑链路启动。而且 `doc_initializeForRendering()` 在引擎里连 `try/catch`
1116
+ 都没有(`desktop/source/lib/init.cxx`),里面 `doc_iniUnoCommands()` 要查一大批 UNO
1117
+ 服务,异常直接穿出 C 接口,`getError()` 里什么都没有。
1118
+
1119
+ 三处防线:
1120
+
1121
+ 1. `runKitLoopInAThread()` 紧跟引擎初始化,前面不放任何会失败的步骤,成功后置
1122
+ `g_loopRunning`
1123
+ 2. `openDocument()` 进门先查 `unipollLatched() && !mobileapp::loopRunning()`,命中就直接
1124
+ 报「引擎主循环没在跑」并带上编辑链路记下的原因,不让它走到抛异常那一步
1125
+ 3. `mobileapp::start()` 自己兜住异常(它是各端桥直接调的,不经过 `guarded()`,
1126
+ 往 NAPI / JNI / Swift 里扔 C++ 异常等于当场终止进程),失败原因写进
1127
+ `recordEngineError()`
1128
+
1129
+ #### 引擎起不来时怎么拿到原因
1130
+
1131
+ `officeCreateSession()` 返回 0 只说明「失败」,原因分两截,都已经接出来了:
1132
+
1133
+ **引擎自己的解释走 stderr。** `lo_initialize()` 末尾是这么处理 bootstrap 失败的
1134
+ (`desktop/source/lib/init.cxx`):
1135
+
1136
+ ```cpp
1137
+ catch (cpo::uno::Exception& exception)
1138
+ fprintf(stderr, "Bootstrapping exception '%s'\n", ...);
1139
+ ```
1140
+
1141
+ 异常被吃掉,`bInitialized` 仍是 false,调用方只看到返回 null。而三端的 stderr 都不进
1142
+ 日志系统,这行话等于蒸发——iOS 更彻底,新版 macOS 连 `log stream --device-name` 都撤了,
1143
+ 真机 stderr 基本拿不到。所以 `sharedOfficeHandle()` 在调 `kit_cpp_init()` 的那一小段
1144
+ 窗口里用 `StderrCapture` 把 fd 2 接到临时文件(`RAII`,析构必还原),失败时把尾部
1145
+ 800 字节拼进错误消息。
1146
+
1147
+ **这个原因经 `lastEngineError()` 回到 UTS。** 三端都有:
1148
+ `xoffice::lastEngineError()` → JNI `nativeLastEngineError` / NAPI `nativeLastEngineError`
1149
+ / ObjC `+lastEngineError` → `officeLastEngineError()`,最后拼进 1801 的 `errMsg`。
1150
+ 所以错误直接显示在 HBuilderX 控制台,不必再去接设备日志。
1151
+
1152
+ 引擎没留下任何 stderr 时才退回报输入(`installDir` / `profile` /
1153
+ `CONFIGURATION_LAYERS` / `SAL_KIT_OPTIONS`),用来区分路径问题和环境问题。
1154
+
1155
+ #### iOS 的 `installDir` 必须是 `program/`,不能是品牌根
1156
+
1157
+ 上游 iOS 官方 App 两件事叠在一起,资源摊在 bundle 根刚刚好对得上:
1158
+
1159
+ 1. `sal/rtl/bootstrap.cxx` 把 `$APP_DATA_DIR` **写死**成 `[[NSBundle mainBundle] bundlePath]`,
1160
+ 排在 `getenv` / `rtl::Bootstrap::set` 前面,插件侧设环境变量无效。
1161
+ 2. `lo_initialize()` 只在传入 user profile 时
1162
+ `Bootstrap::set(BRAND_BASE_DIR, aAppURL + "/..")`。官方
1163
+ `cok_init_2(nullptr, nullptr)` 根本不走这行。
1164
+
1165
+ 我们把资源隔离在 `x-office-engine/`,又传了 profile。`installDir` 若指向品牌根,
1166
+ `BRAND_BASE_DIR` 就变成 bundle 根,`UNO_TYPES=file://$APP_DATA_DIR/udkapi.rdb`
1167
+ 去找 `Foo.app/udkapi.rdb`,`initialize_uno()` 抛 `DeploymentException`。
1168
+ `lo_initialize()` 的大 try 吃掉这次之后,外面的
1169
+ `officecfg::...::QuickEditing::get()` 再抛一次,于是 `guarded()` 只能报出
1170
+ `sharedOfficeHandle aborted by C++ exception <DeploymentException>`,
1171
+ 编辑链路没把 kit loop 跑起来,`open()` 就是 1804。
1172
+
1173
+ 修法是三端对齐:`installDir` = `x-office-engine/program/`。`lo_initialize()`
1174
+ 那行 `/..` 于是指回品牌根;`program/rc` 与 `program/fundamentalrc` 用 `$ORIGIN`
1175
+ 写 `UNO_TYPES` / `UNO_SERVICES`,不再碰 `$APP_DATA_DIR`。`build_ios.sh` 每次
1176
+ 打包都会重写这两份。ICU 数据仍叫 `icudtNNl.dat` 躺在品牌根,靠
1177
+ `LIBREOFFICE_ICU_DATA` 指过去(官方 Xcode 工程会把它重命名成 bundle 根的
1178
+ `ICU.dat`)。
1179
+
1180
+ 改完要重链 `XOfficeCore.framework`,只换 Resources 不够——`installDir()` 在
1181
+ framework 里。
1182
+
1183
+ #### iOS 菜单出来、画布空白:也是 SolarMutex
1184
+
1185
+ 和安卓「load success、画布仍空白」同一把锁。iOS 官方在 `InitVCL()` 之后只
1186
+ `release()` 一层;后面 `SfxApplication::GetOrCreate` / `officecfg` 还会再握上。
1187
+ 官方 App 传 `cok_init_2(nullptr, nullptr)`,锁计数碰巧回到 0。我们传了
1188
+ user profile,必须在 `lo_initialize()` 返回前 `ReleaseSolarMutex()`,见
1189
+ `native-src/ios/patches/0001-engine-ios-solarmutex-handoff.patch`。
1190
+
1191
+ 增量:`make -C desktop -j8`(在 `engine-build/ios` 里)再
1192
+ `bash ios/build_ios.sh --with-lok`。只重链桥、不重编 `desktop`,framework
1193
+ 里仍是旧的 `init.o`。
1194
+
1195
+ #### COOLWSD 的日志:必须 `Log::initialize()`,鸿蒙还得自己接 hilog
1196
+
1197
+ 调查「前端连上了但文档不出来」时踩到的坑:进程内 COOLWSD 与 Kit 的日志**一条都没有**,
1198
+ 不是少,是零。两个独立原因叠在一起:
1199
+
1200
+ 一、`Log-poco.cpp` 的 `isEnabled()` 开头就是「拿不到 logger 直接 `return false`」,
1201
+ 所以 `Log::initialize()` 没跑过时每个级别都判死,全部 `LOG_*` 退化成空操作。上游三端
1202
+ 各自在自己的入口调了(Android 的 `JNI_OnLoad`、iOS 的 `AppDelegate`),我们是嵌进宿主
1203
+ 进程的、没有那个入口,于是漏掉了。现在收在 `startImpl()` 开头。
1204
+
1205
+ 二、`Log.hpp` 的 `LOG_LOG` 按平台分派,`__ANDROID__` 走 logcat、`__EMSCRIPTEN__` 走
1206
+ console,其余走 Poco 通道最终落 stderr——而鸿蒙的 stderr 既不进 hilog 也不进 HBuilderX
1207
+ 控制台。补了 `OHOS` 分支走 `OH_LOG_Print`(`%{public}s` 不能省,hilog 默认把字符串
1208
+ 打成 `<private>`)。
1209
+
1210
+ 级别跟 `NDEBUG` 走,对应上游的 `ENABLE_DEBUG`:现在没定义 `NDEBUG`(online 的 assert
1211
+ 是活的)所以是 `debug`,出货打开 `NDEBUG` 后自动降到 `information`。要在出货形态下临时
1212
+ 开全量,用 `X_OFFICE_LOG_LEVEL` 覆盖。
1213
+
1214
+ ```
1215
+ hdc shell hilog -x -T coolwsd # 服务端视角(COOLWSD / Kit / DocumentBroker)
1216
+ hdc shell hilog -x -T x-office-u # 我们自己的桥
1217
+ adb logcat -s coolwsd -s x-office-u
1218
+ ```
1219
+
1220
+ 服务端视角是唯一能分清「没收到 `load`」和「收到了但加载失败」的信息来源,白屏类问题
1221
+ 先看这个,别从前端日志的缺失反推。
1222
+
1223
+ #### 「界面出来了、内容不显示、也不报错」按三段探针定位
1224
+
1225
+ 编辑链路的每一段失败都是静默的:前端 `ProxySocket` 的 XHR 只挂了 `load` 没挂 `error`,
1226
+ `_signalErrorClose()` 那几句又都是 `app.console.debug`(打包时静音的级别),引擎没收到
1227
+ `load` 更不会说话。所以宿主侧在三段各留了探针(Android 见 `XOfficeWebView.kt`,
1228
+ 鸿蒙见 `XOfficeHarmonyCore.ets`),按出现与缺失对照:
1229
+
1230
+ | 看到 | 停在哪 |
1231
+ | --- | --- |
1232
+ | 没有 `frontend page ready` | 前端页面没加载完,去看资源与页面地址 |
1233
+ | 有 `to engine: HULLO`,没有 `to engine: load url=` | 假 socket 没 open,回程通道的问题(往下两行) |
1234
+ | 没有 `cool: open -> 6B` | 内核压根没把 `cool:` 交给拦截点:页面源不对(见「别改用 https 虚拟域」)或 scheme 没放行 |
1235
+ | 有 `cool: 404` | 内核给的 path 与 native 侧 `isMobileSocketPath()` 的判定对不上 |
1236
+ | `load url=` 里出现 `www/unifile://` | iOS 把 `unifile://cache/...` 交给了 `getResourcePath`,拼到 www 下面。应走 `convert2AbsFullPath`,控制台应有 `ios path: unifile://… -> /var/mobile/…/Library/Caches/…` |
1237
+ | 有 `load url=`,没有 `from engine: LOADED` | 引擎侧的事。先看 `cool frame #N` / `from engine:` 里有没有 `error: cmd=storage` / `cmd=internal`(这两种以前不当失败、宿主会一直「正在打开」);再看有没有 `app2js exit: peer EOF`(回程线程被对端关掉了)。`coolwsd` 标签在 Android 上经常一条都没有,别只靠它 |
1238
+ | 有 `LOADED`,画面还是空 | 前端渲染层。先看有没有 `drop frontend BYE` / `POPSTATE blocked`(PDF 只读被 closeapp 拆了 map);再看 `PROBE` 的 `tiles` / `canvas` / `docLayerMap`。`docLayerMap=null` 就是 map 已被拆,见 `null.options` 那一节 |
1239
+ | 切文档报错 / 缩放后只能重开 | iOS `cool:` handler 对已 `stop` 的 XHR 又 `didFinish`。前端只要 load 不是 200 就关假 socket。`UIManager: Button ... slide-presentation-follow` 是 Writer/PDF 上的正常噪声 |
1240
+ | PDF 缩放后画布只剩「1、2」、点第二页也空 | 叠页预览的页码底图还在,瓦片没回来。看有没有 `cool: write stashed` / `cool XHR aborted, keep socket` / `tiles recover zoomend`。`PROBE` 里 `fileBased=1 parts=2 zooming=0` 而 `tiles=0` 就是这条 |
1241
+
1242
+ `engine url` 那行是给第 5 种情况用的:递给引擎的串必须正好是磁盘路径**编码一次**的
1243
+ 结果,多编一次的现场就是「外壳加载出来了、内容一直不显示、也不报错」。
1244
+
1245
+ #### 前端的宿主命令不能转给引擎
1246
+
1247
+ 前端有一批命令是通过 `window.postMobileMessage()` 发给**宿主**的,不是协议消息。转给
1248
+ 引擎的后果不是「少个功能」而是刷屏:进程内 COOLWSD 不认识它们,会回一条
1249
+ `error: cmd=<x> kind=unknown`,前端收到就弹 AlertDialog;而 `MOBILEWIZARD` /
1250
+ `hideProgressbar` 是移动端面板每次开合都发的,实测一秒几十条,弹框糊满屏幕。
1251
+
1252
+ 清单以上游 Android 的 `LOActivity.beforeMessageFromWebView()` 为准——**那个函数返回
1253
+ `false` 的就是宿主自己消费掉的**。我们三端共用一份 C++ 宿主层,所以分派收在
1254
+ `XOfficeMobileApp.cpp` 的 `consumeHostCommand()` 里,分两组:
1255
+
1256
+ | 处理 | 命令 | 为什么 |
1257
+ | --- | --- | --- |
1258
+ | 吞掉 | `LIGHT_SCREEN` `DIM_SCREEN` `MOBILEWIZARD` `EDITMODE` `hideProgressbar` | 高频状态同步。而且编辑态与加载完成是从引擎回程流嗅探的,比前端自述可靠 |
1259
+ | 排进宿主队列 | `HYPERLINK` `PRINT` `SAVE` `downloadas` `exportfile` `REQUESTFILECOPY` | 人手触发,频率低 |
1260
+
1261
+ 两组必须分开:高频那组**不能**排队,宿主队列只在有出站流量时被 drain,空闲时会无界增长。
1262
+ 排队那组加 `HOST ` 前缀,因为同一条队列还在传引擎的 `postDirectMessage` 通知,那边也有
1263
+ 一条 `SAVE <result>`,不加前缀和前端的 `SAVE` 分不开。
1264
+
1265
+ `loadwithpassword` 看着像宿主命令,但上游是返回 `true`(要转给引擎)的,别误加。
1266
+
1267
+ #### 日志里那 4 条 `ERR_FILE_NOT_FOUND` 是正常的
1268
+
1269
+ `branding.css` / `branding.js` / `branding-mobile.css` / `introdocs/introdocs.js` 只有商业版
1270
+ 才提供,社区版 `browser/dist` 不含。`cool.html` 用裸的 `<link>` / `<script src>` 引它们,
1271
+ 没有回退分支,404 后直接跳过。上游各端 app 同样在刷这几条。
1272
+
1273
+ 鸿蒙侧 `XOfficeHarmonyView.ets` 把这 4 个降到了 `console.debug`,其余资源缺失仍报
1274
+ `console.error`——不能整条降级图省事,真缺 `bundle.js` 那种必须一眼看见。
1275
+ Android 在 logcat 里对应的是内核自己打的
1276
+ `E AndroidProtocolHandler: Unable to open asset URL: file:///android_asset/x-office-u/branding.css`
1277
+ (用过 `WebViewAssetLoader` 的那阵子是 `E WebViewAssetLoader: Error opening asset path: ...`,
1278
+ `AssetsPathHandler` 内部记的 `FileNotFoundException`)。这几条只进 logcat、不进
1279
+ HBuilderX 控制台,没去动 —— 但要记住**它们不是故障线索**:真出问题时它们照样只有这 4 条,
1280
+ 照着查会走进死胡同。
1281
+
1282
+ #### `coolkitconfig.xcu` 必须随包,否则 user 配置层指向空气
1283
+
1284
+ `setupKitEnvironment()` 拼 `CONFIGURATION_LAYERS` 时,最后一层 user 配置在
1285
+ 非 `IOS`/`_WIN32`/`MACOS` 分支下取 `$COOLKITCONFIG_XCU`,没设就退到
1286
+ `file://` + `COOLWSD_CONFIGDIR`,而我们的 `COOLWSD_CONFIGDIR` 是 `.`,于是会拼出
1287
+ 相对路径 `user:*file://./coolkitconfig.xcu`——指向一个不存在的文件。
1288
+
1289
+ 我们编 MOBILEAPP 层时不能定义 `IOS`(那会让 `common/MobileApp.hpp` 去 import
1290
+ `CODocument.h`,上游 iOS 的 ObjC 胶水已经被我们自己的实现替掉了),所以走不到上游
1291
+ iOS 那条 `${BRAND_BASE_DIR}/coolkitconfig.xcu` 分支。改法是用上游留的
1292
+ `COOLKITCONFIG_XCU` 口子给绝对路径,在 `setupKitEnvironment()` 之前设好,三端同一套。
1293
+
1294
+ 这一层不是可选装饰,`coolkitconfig.xcu` 带的是 COOL 的内核默认值:主题配色、CJK 排版
1295
+ (竖排 / 注音 / 强调号)、默认字体、关掉 GIO UCP、关掉「句首两个大写」自动更正。
1296
+
1297
+ 文件不在引擎 instdir 里(它是 online 侧的文件),所以两处打包脚本各自补:
1298
+
1299
+ | 端 | 落点 | 由谁放 |
1300
+ | --- | --- | --- |
1301
+ | Android / 鸿蒙 | `program/coolkitconfig.xcu`(`installDir` 就是解包后的 `program/`) | `common/pack_engine_resources.sh` |
1302
+ | iOS | `x-office-engine/program/coolkitconfig.xcu`(品牌根也留一份) | `ios/build_ios.sh` |
1303
+
1304
+ #### 前端报 `null.options`,真正的事故在几百毫秒前
1305
+
1306
+ 现场是双指缩放时抛:
1307
+
1308
+ ```
1309
+ Uncaught TypeError: Cannot read properties of null (reading 'options')
1310
+ at e._updateTileTwips (bundle.js)
1311
+ at dt.applyZoom -> d.zoomTo -> d.onMultiTouchEnd
1312
+ ```
1313
+
1314
+ null 的是 `this._map`(`CanvasTileLayer._updateTileTwips()` 读
1315
+ `this._map.options.zoom`),而 `docLayer._map` 只有一个地方会变成 null:
1316
+ `Layer.js` 的 `Map.removeLayer()`,它由 `L.Map.remove()` 调到。也就是说
1317
+ **map 早就被拆了**,缩放只是第一个踩到废墟的人。
1318
+
1319
+ 拆完之后为什么看起来没事:瓦片画在 `app.sectionContainer` 自己的 canvas 上,
1320
+ 触摸也由它派发,两者都不属于被摘掉的 Leaflet mapPane。所以文档照常显示、手势照常触发,
1321
+ 只有摸到 `docLayer._map` 的代码会抛。`map.remove()` 里还有一句 `app.socket.close()`,
1322
+ 会话其实已经死了 —— 表现就是「文档在,但怎么都动不了」。
1323
+
1324
+ `app.map.remove()` 在 COOL 里的来源:
1325
+
1326
+ | 入口 | 位置 |
1327
+ | --- | --- |
1328
+ | `closeapp` 动作 | `docdispatcher.ts` —— 注意 `app.map.remove()` 那行在 if/else **外面**,所以移动端分支给宿主发了 `BYE` 之后照样会拆本地 map |
1329
+ | `close: ownertermination` | `Socket.ts` |
1330
+ | WOPI `Action_Close` | `Map.WOPI.js` |
1331
+
1332
+ 而 `closeapp` 的派发源里有两类对嵌入式宿主特别危险:
1333
+
1334
+ - `UIManager.enterReadonlyOrClose()` —— 编辑态降级为只读,**已经是只读就直接 closeapp**。
1335
+ PDF 在 COOL 里本来就是只读,所以它没有「降级」这一档可退。
1336
+ 它的调用方包括 `onGoBack(popStateEvent)`:COOL 压了 `app-started` 这个历史状态,
1337
+ 好让系统返回手势先退出移动向导,于是**任何 popstate 都可能直接关掉只读文档**。
1338
+ - `Control.LokDialog._sendCloseWindow` 与 `Control.JSDialogBuilder` —— 守卫相同:
1339
+ 文档还没 `_docLoaded`、且还没有任何对话框事件发给过 core(`window._firstDialogHandled`)时
1340
+ 关掉对话框,就关掉整个 app(上游注释针对的是 CSV / 宏安全警告这类加载前对话框)。
1341
+
1342
+ 排查用 `XOfficeHarmonySession.installDiagnostics()`:它在 `onPageEnd` 注入,
1343
+ 把 `L.Map.prototype.remove`、`dispatcher.prototype.dispatch`(只报动作名里带 close 的)
1344
+ 和 `popstate` 各包一层,经 `postMobileError` 回报,日志前缀分别是
1345
+ `MAP_REMOVE` / `DISPATCH` / `POPSTATE`,都带上 `docLoaded` 与 `firstDialogHandled`。
1346
+ 另有 `probeMapState()` 在首次前端异常后回读一次 `app.map` 与 `docLayer` 的关系
1347
+ (`docLayerMap=null` 即已被拆,`other` 则是 `app.map` 指向了旧实例)。
1348
+ 混淆过的调用栈读不出东西,所以判断靠这几个标志而不是栈。
1349
+
1350
+ 注入本身是 fire-and-forget 的,没有回执就分不清「探针没开火」是真没发生、
1351
+ 还是根本没装上,所以那段 JS 末尾会 `return "map=… dispatcher=…"`,宿主侧打成
1352
+ `x-office-u diagnostics installed map=true dispatcher=true`。**先确认这一行在,
1353
+ 再去看有没有 `MAP_REMOVE`。**
1354
+
1355
+ ##### 已落地的修:缩放路径不再依赖 `_map`
1356
+
1357
+ 上面那条链条最外层的伤害不是异常本身,而是它抛在 `applyZoom` 中途:
1358
+ `map._zoom` 与 `docLayer._tileZoom` 都已经改成新值了,视口矩形还没重建。
1359
+ 单窗口版式的水平居中偏移(比视口窄的文档居中、Writer 给批注留边)存在
1360
+ `rebuildSingleWindowView()` 里,它没跑到,于是比视口窄的文档贴到左边 ——
1361
+ 用户看到的「PDF 放大后一平移就跳到左边、根本没法看」就是这个,和 map 被拆是同一件事。
1362
+
1363
+ `_updateTileTwips()` 要的其实只是 map 的**基准 zoom 选项**(一个常量),
1364
+ `this._map` 和 `app.map` 给出同一个数,而 `Map.removeLayer()` 只会把前者置空。
1365
+ 所以改成 `const map = this._map || app.map;`,缩放路径上最后一处 map 解引用就没了
1366
+ (`applyZoom` 后半段只用 `app.map`;`map.fire('zoomend')` 也安全 ——
1367
+ `removeLayer()` 同时 `off` 掉了该 layer 的监听,拆掉之后不会再回调到废墟里)。
1368
+
1369
+ 同时在回退生效时用 `app.console.error('map detached before _updateTileTwips')`
1370
+ 一次性上报(`_reportedMapDetached` 去重)。error 级会经 `logServer` 变成宿主侧的
1371
+ `jserror`,而 `postMobileError` 收到非探针消息会顺手触发 `probeMapState()` ——
1372
+ 于是「谁拆的 map」这个根因,下一次复现就能连着 `MAP_REMOVE` 的栈一起拿到。
1373
+
1374
+ 改的是 `browser/src/layer/tile/CanvasTileLayer.js`,三端共用同一份 `browser/dist`,
1375
+ 所以重建前端就三端一起生效,见下面「重建前端产物」。
1376
+
1377
+ ### 调用
1378
+
1379
+ ```vue
1380
+ <x-office-u
1381
+ ref="office"
1382
+ :src="docPath"
1383
+ style="width:100%;height:100%;"
1384
+ @ready="onReady"
1385
+ @load="onLoad"
1386
+ @statechange="onStateChange"
1387
+ @error="onError"
1388
+ @back="onBack"
1389
+ ></x-office-u>
1390
+ ```
1391
+
1392
+ ```ts
1393
+ import type { XOfficeDocumentInfo, XOfficeFail, XOfficeStateChange } from "@/uni_modules/x-office-u"
1394
+
1395
+ const office = ref<XOfficeUComponentPublicInstance | null>(null)
1396
+ const docPath = ref<string>("")
1397
+
1398
+ function onReady() : void {
1399
+ // 引擎桥已连通,可以 open。src 非空时组件会自动打开,不必再手动调
1400
+ }
1401
+
1402
+ function onLoad(info : XOfficeDocumentInfo) : void {
1403
+ // 只读判断的字段叫 isReadonly 不叫 readonly:readonly 是类成员修饰符关键字,
1404
+ // 作为必填字段编到鸿蒙 ArkTS 会直接语法报错
1405
+ console.log(info.kind, info.partCount, info.isReadonly)
1406
+ }
1407
+
1408
+ function onStateChange(change : XOfficeStateChange) : void {
1409
+ // 工具栏的 撤销/重做/保存 按钮按这个置灰
1410
+ console.log(change.modified, change.canUndo, change.canRedo)
1411
+ }
1412
+
1413
+ function onError(fail : XOfficeFail) : void {
1414
+ console.error(fail.errCode, fail.errMsg)
1415
+ }
1416
+
1417
+ function onBack() : void {
1418
+ // 前端顶栏返回键(含系统返回手势)被点击。前端不会自行切只读或关文档,
1419
+ // 怎么返回由页面决定:典型做法是有未保存修改先 save() 再 navigateBack
1420
+ uni.navigateBack()
1421
+ }
1422
+
1423
+ // 原地保存
1424
+ office.value?.save()
1425
+
1426
+ // 另存为 docx
1427
+ office.value?.saveAs({
1428
+ path: "/storage/emulated/0/Download/out.docx",
1429
+ success: (res) => { console.log(res.path, res.byteSize) }
1430
+ })
1431
+
1432
+ // 导出 PDF
1433
+ office.value?.exportPdf({ path: "/storage/emulated/0/Download/out.pdf" })
1434
+
1435
+ // OCR / 纯文本落成 Word(只支持 doc / docx;Web 端不支持)
1436
+ office.value?.createFromTextByDoc({
1437
+ text: "识别结果\n第二段",
1438
+ path: uni.env.CACHE_PATH + "ocr.docx",
1439
+ format: "docx"
1440
+ })
1441
+
1442
+ // 没被组件包装的能力直接下 UNO 命令
1443
+ office.value?.postUnoCommand(".uno:Bold", "")
1444
+
1445
+ // 移动端打开后先是只读展示;右下角铅笔已隐藏,用这个进入编辑
1446
+ office.value?.enterEditMode()
1447
+ ```
1448
+
1449
+ ### 参数
1450
+
1451
+ | 字段 | 说明 | 默认 |
1452
+ | --- | --- | --- |
1453
+ | src | 文档路径。非空时 `@ready` 后自动打开 | `""` |
1454
+ | password | 打开密码,加密文档需要 | `""` |
1455
+ | readonly | 只读打开,屏蔽全部编辑入口 | `false` |
1456
+
1457
+ ### 事件
1458
+
1459
+ | 事件 | 负载 | 说明 |
1460
+ | --- | --- | --- |
1461
+ | ready | — | 引擎桥连通、可以 `open` |
1462
+ | load | `XOfficeDocumentInfo` | 文档打开成功 |
1463
+ | statechange | `XOfficeStateChange` | modified / canUndo / canRedo / currentPart 变化 |
1464
+ | error | `XOfficeFail` | 引擎初始化、打开或保存失败 |
1465
+ | back | — | 前端顶栏返回键(含系统返回手势)被点击。前端不自行切只读 / 关文档,由外层页面决定如何返回(App 三端;web 端无顶栏,不会触发) |
1466
+
1467
+ 顶栏右上角的汉堡主菜单按钮已隐藏(宿主自绘菜单);返回键行为见 `back` 事件。
1468
+ 右下角进入编辑的铅笔悬浮按钮也已隐藏,改由 `ref.enterEditMode()` 进入编辑态
1469
+ (移动端打开后仍先是只读展示,只是不再露出那颗 FAB)。
1470
+ 编辑态左上角的打勾按钮 = 退出编辑回到只读展示 + 宿主自动原地保存(写回 `open()`
1471
+ 传入的原始路径,见下文「工作副本」;失败走 `error` 事件,成功后 `statechange`
1472
+ 的 `modified` 变 false)。
1473
+ 这些都是前端(`browser/src`)与宿主改动,重建前端产物后三端一起生效。
1474
+ `enterEditMode()` 本身走 WebView 注入,不重建前端也能用。
1475
+
1476
+ ### 方法
1477
+
1478
+ | 方法 | 说明 |
1479
+ | --- | --- |
1480
+ | `open(options)` | 打开文档。`options.path` 必填 |
1481
+ | `save(options?)` | 原地保存回原路径原格式(写回 `open()` 传入的原始文件,见「工作副本」) |
1482
+ | `saveAs(options)` | 另存为其他路径 / 格式。`options.path` 必填 |
1483
+ | `exportPdf(options)` | 导出 PDF,等价于 `saveAs({ format: "pdf" })` |
1484
+ | `createFromTextByDoc(options)` | 把纯文本建成 Word 并打开。只支持 `doc` / `docx`。内部先写 UTF-8 txt,引擎打开后再另存为 Word。适合 OCR 识别结果。App 三端可用,Web 端会 fail |
1485
+ | `close()` | 关闭当前文档,组件可复用 |
1486
+ | `undo()` / `redo()` | 撤销 / 重做 |
1487
+ | `enterEditMode()` | 从移动端只读展示进入编辑态(原右下角铅笔 FAB)。`readonly` 打开时无效 |
1488
+ | `setPart(index)` | 切页 / 切工作表 / 切幻灯片 |
1489
+ | `getFormatState()` | 光标处格式状态,供业务自绘工具栏 |
1490
+ | `postUnoCommand(command, args?)` | 直连 LibreOffice 的 UNO 命令 |
1491
+ | `destroy()` | 释放。组件卸载时自动调用 |
1492
+
1493
+ #### `open()` 收什么路径
1494
+
1495
+ 引擎只能做裸 POSIX open,所以 `options.path` 最终必须是应用沙箱里的真实路径。系统选择
1496
+ 器给的不是路径而是**授权 URI**,两者不能混:
1497
+
1498
+ | 来源 | 给出的东西 | 插件怎么处理 |
1499
+ | --- | --- | --- |
1500
+ | 鸿蒙 `DocumentViewPicker` | `file://docs/storage/Users/...`,仅临时授权、只能按 fd 读 | 自动拷进 `<cacheDir>/x-office-u/inbox/<handle>/` 再打开 |
1501
+ | Android `x-chooseFile-s` | 已是 cache 副本的真实路径 | 直接用 |
1502
+ | iOS `UIDocumentPicker` | 真实路径 | 直接用 |
1503
+
1504
+ 判据是 `file://` 后的 authority 段必须为空:`file:///a` 的第三个斜杠后就是路径,
1505
+ 而 `file://docs/a` 的 `docs` 是 authority。后者直接丢给引擎会得到
1506
+ `Unsupported URL <...>: "type detection failed"` —— 字面意思是格式识别失败,
1507
+ 实际是路径不指向任何文件。
1508
+
1509
+ **由此带来一个语义**:这种情况下编辑的是副本,`save()` 也写回副本,用户原始文件不变。
1510
+ 这不是实现取巧 —— `select()` 只给读权限,写回原文件在鸿蒙上本来就做不到(要么用保存
1511
+ 选择器,要么申请 `ohos.permission.FILE_ACCESS_PERSIST` 做持久化授权)。要落到用户可见
1512
+ 位置得走 `saveAs()` / `exportPdf()`。
1513
+
1514
+ #### 工作副本与原地保存
1515
+
1516
+ 编辑链路打开时,插件会先把文档复制到应用缓存
1517
+ (`<cacheDir 或 tmp>/x-office-u/work/<handle>/<时间戳-序号>/`),交给引擎的是这份
1518
+ 工作副本:引擎的「原地保存」写回的是它自己的会话文件,而外部传入的原始路径未必
1519
+ 可写(只读目录、受限作用域路径等),副本则必在可写沙箱内。每次打开都是全新目录
1520
+ (COOLWSD 的 docKey 就是文件路径,复用会撞上上一篇的 DocumentBroker)。
1521
+
1522
+ `save()`(不带参数)的完整链路 = 引擎写回工作副本 → 成功后宿主把副本**覆盖回
1523
+ 原始路径** → 回调里的 `path` / `byteSize` 报告的是原始文件。写回失败会报
1524
+ `save:fail`,不会让改动悄悄只留在缓存里。编辑态顶栏的打勾按钮触发的就是这同一条
1525
+ 原地保存。`close()` / `destroy()` 清理工作副本目录。
1526
+
1527
+ 鸿蒙选择器场景例外:此时「原始路径」本身就是 inbox 副本(见上表),不再二次拷贝,
1528
+ 保存写回的就是该副本。
1529
+
1530
+ ### 只读属性
1531
+
1532
+ `state`、`modified`、`canUndo`、`canRedo`、`currentPart`。
1533
+
1534
+ ### 支持的另存格式
1535
+
1536
+ `odt` `ott` `docx` `doc` `rtf` `txt` `ods` `xlsx` `xls` `csv` `odp` `pptx` `ppt` `odg`
1537
+ `pdf` `html` `epub`。
1538
+
1539
+ `saveAs` 传了 `format` 但路径扩展名不匹配时,组件会按 format 补正扩展名,避免写出
1540
+ 扩展名与内容不符的文件。
1541
+
1542
+ ### Web 端
1543
+
1544
+ Web 端没有 native 引擎,用的是 LibreOffice 的 wasm 产物:把 COOL 前端整包(含
1545
+ `soffice.wasm`)部署到一个基址下,组件用 iframe 装进来。默认基址 `/x-office-u/`,
1546
+ 可用 `window.X_OFFICE_WEB_BASE` 覆盖。产物来源二选一:
1547
+
1548
+ ```bash
1549
+ # 自己编:单仓已有 wasm/ 目录,走上游的 LibreOfficeWASM32.conf + EMSCRIPTEN_INTEL_GCC.mk
1550
+ # 或者直接用 ZetaOffice 的预编译产物,省掉一次 emscripten 全量构建
1551
+ ```
1552
+
1553
+ **必须**给承载页和该基址同时下发这两个响应头,否则 `SharedArrayBuffer` 不可用、
1554
+ wasm 线程起不来,组件会直接报 `errCode: 1801` 而不是等到超时:
1555
+
1556
+ ```
1557
+ Cross-Origin-Opener-Policy: same-origin
1558
+ Cross-Origin-Embedder-Policy: require-corp
1559
+ ```
1560
+
1561
+ 代价明确:约 32MB wasm + 15MB data,brotli 后约 52MB 首次下载。Safari 对
1562
+ SharedArrayBuffer / wasm 线程支持较弱,实测体验明显差于 Chromium,故 package.json
1563
+ 里 safari 标 `×`。
1564
+
1565
+ Web 端与 App 端的语义差异:`save` / `saveAs` / `exportPdf` 落点由浏览器下载决定,
1566
+ `path` 退化为建议文件名,返回的 `XOfficeSaveResult.path` 是文件名而非绝对路径;
1567
+ `getFormatState()` 拿不到同步状态(wasm 前端只在变化时推消息),返回默认值,
1568
+ 业务请改用 `@statechange`;`createFromTextByDoc` 需要可写本地路径,Web 端会
1569
+ 直接 fail。
1570
+
1571
+ ### 许可证
1572
+
1573
+ 本插件的桥接层代码与构建脚本按仓库许可证分发。引擎侧(LibreOffice / Collabora Online)
1574
+ 是 MPL-2.0,**对 MPL 文件的修改必须公开**——`third_party/collabora/online/engine` 下
1575
+ 的 OHOS 移植补丁属于这类修改,建议直接推 Gerrit 上游,既合规又能减少长期维护负担。
1576
+
1577
+ ## 更新日志
1578
+
1579
+ ### 未发布
1580
+
1581
+ - 蒸汽 Android:组件 `onBeforeUnmount` 经常晚于 `UniPageActivity.onDestroy`
1582
+ 或不走,`destroy()` 松 `_element` 来不及,`ProxyModule.b` 仍沿
1583
+ `XOfficeEditor._element` 钉住已销毁页。组件改挂页面 `onUnload`;Android
1584
+ 再盯宿主 Activity 销毁并立刻 `destroy()`。运行时自己的 `UniAppActivity`
1585
+ 静态引用修不了。
1586
+
1587
+ - 蒸汽 Android:`libx_office_u.so` 用 NDK 30 编,需要
1588
+ `std::__ndk1::__hash_memory`。基座 `pickFirst` 留下的是 richtext 那份旧
1589
+ `libc++_shared.so`,没有这个符号;进程里又已经 load 过,第二份同名库装
1590
+ 不进去。桥和引擎改为依赖私有 `libc++_office.so`(同一份 NDK 30 libc++,
1591
+ SONAME 改名),不再和基座抢 `libc++_shared`。
1592
+
1593
+ - 蒸汽 Android:Debug 基座压缩 `.so` 后,PackageManager 常只抽出 NSS、抽不出
1594
+ 189MB 的 `liblo-native-code.so`。禁止先 `loadLibrary` 装一半再从
1595
+ `filesDir` 加载引擎(Android 7+ linker 命名空间对不上)。改为:系统目录
1596
+ 齐了就整组从那里加载,否则从 APK 解齐到 `filesDir/x-office-u/jni` 再整组
1597
+ `System.load`。蒸汽 Debug 基座会在 Manifest 写
1598
+ `extractNativeLibs=true` 并压缩 `.so`,插件不能再声明
1599
+ `useLegacyPackaging: false`,否则 AGP 9 的 `packageDebug` 直接失败。
1600
+ 报错带上真实缺失列表 / `UnsatisfiedLinkError`。
1601
+
1602
+ - 蒸汽 Android:`XOfficeEditor` 的 `onStateChange` / `onReady` / `onError` /
1603
+ `onBack` 改为私有字段。蒸汽会给 public 函数字段生成 JS 桥并写成
1604
+ `editor.onReady()`,可空函数在 Kotlin 里必须 `?.invoke`,直接编不过。
1605
+ 对外仍只走 `setOnXxxCallback`。
1606
+
1607
+ - 新增 `createFromTextByDoc`:把纯文本(OCR 识别结果等)建成 Word
1608
+ 并打开。只支持 `doc` / `docx`。内部先把 UTF-8 文本落到工作目录
1609
+ `.txt`,引擎打开后再 `saveAs` 到目标路径,最后打开这份 Word,
1610
+ 后续 `save()` 写回 Word 路径。Web 端明确 fail。演示页有「从文本
1611
+ 创建 Word」按钮。
1612
+
1613
+ - 隐藏 COOL 移动端右下角编辑悬浮按钮(`#mobile-edit-button`),改为
1614
+ `ref.enterEditMode()` 进入编辑态。FAB 被藏后顶栏 PermissionMode 替代
1615
+ 入口一并关掉。前端 CSS / Permission.js / UIManager / MobileTopBar
1616
+ 已改;宿主页就绪时再注入一次样式,旧包前端也能立刻藏掉。需重建
1617
+ 前端后重打三端,样式注入这条不依赖重建即可生效。
1618
+
1619
+ - iOS 光标回归排查包:上一轮 global.js 的触摸标志修复给兼容鼠标事件盖了
1620
+ `guessEmulatedFromTouch` 戳,会把该事件在所有下游(mouseOnly/touchOnly
1621
+ 包装器、双击合成等)里的分类整体改成 touch,行为面过宽;现收窄为只保持
1622
+ 全局标志粘性、不改单个事件的分类(showCursor 只读全局标志)。同时在
1623
+ iOS 垫片加了光标全链路诊断:点按与 500ms/2000ms 揭示节点输出 CARET
1624
+ 快照(触摸标志 / 光标可见位 / 引擎矩形 / 显示门条件 / cursor-handler
1625
+ 状态 / socket 投递状态),揭示窗口内输出 MSG(invalidatecursor 等引擎
1626
+ 消息到达时刻),经 jserror: 打到 HBuilderX 控制台,用于定位「点按不出
1627
+ 光标」断在哪一环。
1628
+
1629
+ - 演示页补「另存 xlsx 到缓存目录」按钮,表格另存可测。
1630
+
1631
+ - 编辑态打勾按钮改为「退出编辑 + 自动原地保存」。打勾此前只是退出编辑展示,
1632
+ 改动停留在引擎会话里,重开文件就是没保存;上一轮把顶栏按钮统一改抛事件后
1633
+ 打勾又变成无任何动作。现在前端打勾先发 XOFFICE_EDITDONE 再走原有的退出
1634
+ 编辑动作,宿主收到后在有改动时执行原地保存(失败走 error 事件)。
1635
+
1636
+ - 编辑链路打开文档三端都改为「先落缓存工作副本」:open() 把文档复制到
1637
+ <缓存>/x-office-u/work/<handle>/<时间戳-序号>/ 再交给引擎;原地 save()
1638
+ 的完整链路 = 引擎写回副本 → 宿主把副本覆盖回原始路径 → 回调报告原始路径,
1639
+ 写回失败报 save:fail,不会让改动悄悄留在缓存里。原始路径未必可写
1640
+ (只读目录 / 受限作用域路径),副本必在可写沙箱。鸿蒙选择器的 inbox
1641
+ 副本不再二次拷贝。close / destroy 清理工作目录。
1642
+
1643
+ - 安卓 / 鸿蒙打开 PDF 自动适配容器(此前只有 iOS 垫片在做):把 tryFitDrawing
1644
+ 移植进前端 setInitialZoom / _fitWidthZoom(_drawingFitZoom,只缩不放、
1645
+ 一次性、resize 时补跑),三端行为一致。
1646
+
1647
+ - 修 iOS 点按落光标仍要滑一下的问题,这轮找到真正的丢帧点:垫片的序号守卫
1648
+ 会把乱序到达的低序号引擎帧整段丢掉——连发 drain 时多个 doSend XHR 并发,
1649
+ WKWebView 不保证完成顺序,proxyWrite 又是整队交换,高序号响应可能先到;
1650
+ invalidatecursor / cursorvisible 被丢掉后光标自然不落,滑动带来新帧才显。
1651
+ 守卫改为按已处理序号集合精确去重,不再跳过未处理帧。另加两道保险:
1652
+ 300ms 心跳 drain 兜底 native 出站通知合并标志卡死导致的帧滞留;
1653
+ 触摸标志的纠正从垫片冒泡阶段前移到 global.js 捕获阶段源头
1654
+ (真实触摸后 2.5 秒内的兼容鼠标事件不再把 lastEventWasTouch 翻假)。
1655
+
1656
+ - 前端顶栏返回键改为抛 `back` 事件给组件,右上角汉堡主菜单按钮隐藏。
1657
+ 返回键原来走 COOL 的 `enterReadonlyOrClose`:编辑态第一下切只读、
1658
+ 第二下 `closeapp` 拆 map,宿主只能事后收拾。现在前端把点击原样
1659
+ 发给宿主(`XOFFICE_BACK`),三端编辑器类新增 `setOnBackCallback`,
1660
+ 组件发 `back` 事件,返回/保存/关文档全由外层页面决定。改
1661
+ `Control.UIManager.ts` + `device-mobile.css`,需重建前端并重打三端
1662
+ (iOS Resources / AAR / HAR 已重打)。
1663
+
1664
+ - 修三端 PDF 滑动跳变:第二次拖动时页面跳回顶/左再重新跟随。
1665
+ PDF 走叠页布局 `ViewLayoutFileBased`,它的 `viewedRectangle` 是
1666
+ 可见页在文档空间的包围盒(`pX1` 恒 0、`pY1` 是首个可见页页顶),
1667
+ 而 `MouseControl` 的触摸平移/甩动惯性拿它当视口位置去构造
1668
+ `scrollTo` 的绝对目标,`FileBased.scrollTo` 又直接把它赋给
1669
+ `scrollProperties`——每次新手势都把视口钉回页顶/左缘。doc/excel
1670
+ 是单窗口布局,两值恰好相等所以没事。现对叠页布局改用逐事件的
1671
+ section 局部坐标增量走 `scroll()`,局部坐标不含滚动偏移、跨页
1672
+ 也不跳。改 `MouseControl.ts`,需重建前端并重打三端(已重打)。
1673
+
1674
+ - 修 iOS 编辑模式点文本区域光标不显示、拖一下文档才出现。
1675
+ WebKit 在 touchend 后补发兼容 pointer/mouse 事件(`pointerType='mouse'`),
1676
+ `global.js` 的 document 捕获监听拿它把 `touch.lastEventWasTouch`
1677
+ 翻成 false;引擎的 `invalidatecursor` 几十到几百毫秒后才回来,
1678
+ `showCursor` 走「非触摸屏」分支把 cursor-handler 藏掉。垫片在
1679
+ 冒泡阶段把标志纠正回来(近 2 秒有过真实 touch 即算),并在光标
1680
+ 揭示窗口内强制置真。只改 iOS 垫片,重打 iOS 自定义基座即可。
1681
+
1682
+ - 修 iOS 点选后要滑一下才出编辑光标。点下去位置已经进引擎,但
1683
+ `cursorvisible` 还没到时 `_updateCursorAndOverlay` 会把刚画上的
1684
+ 光标再藏掉;滑动把那条消息带出来才显示。现点选后 3 秒内不许
1685
+ 藏光标,并把光标 DOM / 表格选区同步摆到点击处。只需重打 iOS
1686
+ 自定义基座。
1687
+
1688
+ - 修上一轮 iOS 垫片启动即闪退。WKWebView 一创建就可能加载 about:blank,
1689
+ 垫片在 document-start 里改了全局 XMLHttpRequest,把 WebKit 自己的请求
1690
+ 也钩进去了。现只在 cool.html 上、且只对 cool: 回程挂钩;点选补光标
1691
+ 不再 fire('move')。只需重打 iOS 自定义基座。
1692
+
1693
+ - 修 iOS 放大后平移/缩到多页、表格四向滚动、PPT 翻页只剩占位。根因不是
1694
+ 没要瓦片:滚动会 abort 还在飞的 `cool:` write,abort 不走 `load`,偶发
1695
+ 也是 `status=0`。ProxySocket 只在 `load && 200` 时 `parseIncomingArray`,
1696
+ 而 `proxyWrite` 已经把 native 队列 swap 空了,响应体在 `xhr.response`
1697
+ 里被垫片丢掉。首屏能出字,一滚就空白。现改为 abort / `status=0` 也把
1698
+ ArrayBuffer 吃进去,重复序号跳过。只需重打 iOS 自定义基座(Swift 垫片)。
1699
+
1700
+ - 修 iOS 打开 PDF 默认过大、首次切到后面页只剩序号。COOL 对绘图/PDF
1701
+ 不跑 `_fitWidthZoom`,停在 100%;切页在叠页预览里只滚动,没把新页
1702
+ 瓦片要回来,空页正中就只画序号。打开后按当前一页适配窗口(只缩小
1703
+ 不放大),`setPart` 后再要一遍瓦片。只改 iOS 垫片,不动三端前端,
1704
+ 不用重编引擎 / AAR / HAR。
1705
+
1706
+ - 修 iOS 打开 PDF 后双指缩放只剩页码「1、2」、点第二页也空。PDF 在
1707
+ 移动端是叠页预览(fileBasedView),空页正中本来就会画页码;缩放时
1708
+ `cool:` XHR 被 WKWebView 取消,上一轮已经不再 `didFinish` 以免假
1709
+ socket 被关,但 `proxyWrite` 已经把瓦片出队,取消后数据丢了,前端
1710
+ 永远等不到新瓦片。现在取消而未送达的 write 先留下、下次再给;iOS
1711
+ 忽略 `user-scalable=no`,关掉 WKWebView 自己的捏合,只让 COOL 缩放;
1712
+ `zoomend` 后再要一遍瓦片和页预览。只热更 UTS,不用重编引擎。
1713
+
1714
+ - 修 iOS 切文档页面报错、小文档双指缩放后不再出画。根因是
1715
+ `WKURLSchemeHandler`:导航和缩放过会 cancel 还在飞的 `cool:` XHR,
1716
+ 我们仍对已 `stop` 的任务 `didFinish`。前端 ProxySocket 只要 `load`
1717
+ 不是 200 就 `_signalErrorClose()`,假 socket 死掉,只能重新打开。
1718
+ 已 `stop` 的任务不再回数据、也不再 `proxyWrite`(避免把新会话队列
1719
+ 偷走)。重开前先 `stopLoading`。演示文稿 follow 按钮在 Writer/PDF
1720
+ 上本来就没有,那两条 UIManager 日志不再当错误刷。
1721
+
1722
+ - 修 iOS 引擎已 `LOADED`、画布仍空白。路径已经解开,kit 也回了
1723
+ `status:`。真正拆画布的是 COOL 前端:PDF 天生只读,加载时压了
1724
+ `app-started` 历史状态,WKWebView 的 popstate / 返回键走
1725
+ `enterReadonlyOrClose` → `closeapp` → 给引擎发 `BYE` 并 `map.remove()`。
1726
+ 菜单(HTML 壳)还在,瓦片层被拆空。嵌入式宿主丢掉前端 `BYE`,拦住
1727
+ `app-started` 的 popstate,并关掉 `UI_Close` 默认拆 map。另外 iOS 上
1728
+ `setOnStateChangeCallback` 等逃逸回调补了 `@UTSJS.keepAlive`(日志里那句
1729
+ 「callback回调函数已释放」),`LOADED` 后会打一条 `PROBE` 方便对照
1730
+ tiles / canvas。只热更 UTS,不用重编引擎。
1731
+
1732
+ - 修 iOS 选文件后菜单在、画布空白。`x-chooseFile-s` 回的是
1733
+ `unifile://cache/x-choosefile/...`,iOS 却走了 `UTSiOS.getResourcePath`,
1734
+ 把它当 www 相对路径拼成 `.../www/unifile://cache/...`。这条串以 `/` 开头,
1735
+ `isLocalFilePath` 放行,引擎 POSIX open 打不开,前端已经发出 `load url=`,
1736
+ 回程没有 `status:` / 瓦片。改成和 `x-udp-s` 一样:已存在的沙箱绝对路径
1737
+ 原样用,`unifile://` / `/static` 走 `convert2AbsFullPath`。只热更 UTS,
1738
+ 不用重编引擎。
1739
+
1740
+ - 修 iOS 菜单出来、画布空白。和安卓当初那次同一把锁:`lokit_runloop`
1741
+ 堵在 `lo_runLoop()` 门口的 `SolarMutex::acquire()`。iOS 官方在 `InitVCL()`
1742
+ 之后只 `release()` 一层,后面 `SfxApplication` / `officecfg` 还会再握上;
1743
+ 官方 App 不传 user profile,锁计数碰巧回到 0。我们传了 profile,没把
1744
+ `ReleaseSolarMutex()` 抄到 iOS 上。见
1745
+ `native-src/ios/patches/0001-engine-ios-solarmutex-handoff.patch`。
1746
+ 要增量重编引擎 `desktop` 再 `build_ios.sh --with-lok`,只换 framework
1747
+ 里的桥不够。HBuilderX 控制台现在也会打 `to engine:` / `from engine:`。
1748
+
1749
+ - 修 iOS 打开文档立刻 1804:`engine main loop is not running`,编辑链路说
1750
+ `sharedOfficeHandle aborted by C++ exception <DeploymentException>`。
1751
+ 根因是两套路径约定叠在一起。上游 iOS 官方 App 把引擎资源摊在 bundle 根,
1752
+ `$APP_DATA_DIR` 写死成 `bundlePath`,而且 `cok_init_2(nullptr, nullptr)`
1753
+ 根本不设 `BRAND_BASE_DIR`。我们把资源隔离在 `x-office-engine/`,又传了
1754
+ user profile,于是 `lo_initialize()` 把 `BRAND_BASE_DIR` 设成
1755
+ `installDir/..`(桌面 / Android 的 program/ 约定)——installDir 若指向品牌根,
1756
+ 这一步就指到 bundle 根,`UNO_TYPES` 去找 `Foo.app/udkapi.rdb`,bootstrap
1757
+ 失败。失败后 `QuickEditing::get()` 还在 `lo_initialize()` 的大 try 外面再抛
1758
+ 一次,所以看到的是异常类型而不是「Bootstrapping exception ...」。
1759
+ 现在 iOS 的 `installDir` 也指 `x-office-engine/program/`,`program/rc` 与
1760
+ `program/fundamentalrc` 用 `$ORIGIN` 定位品牌根,不再依赖 `$APP_DATA_DIR`。
1761
+ 还要把 `XOfficeCore.framework` 重链一遍(`build_ios.sh --with-lok`),
1762
+ 只换 Resources 不够。
1763
+
1764
+ - 文档里的平移 / 缩放不再带动外层页面一起滚。组件常被放进 `scroll-view`,
1765
+ 安卓原先不调 `requestDisallowInterceptTouchEvent`,MOVE 就被外层抢走;
1766
+ 现在沿祖先链禁拦截,并关掉 WebView 的 nested scroll。鸿蒙 Web 用
1767
+ `nestedScroll: SELF_ONLY`,iOS 滑动期间暂时关掉外层 `UIScrollView`。
1768
+
1769
+ - 修安卓打开文档闪退。SolarMutex 交出去之后 `lokit_runloop` 真正进了
1770
+ `documentLoad`,5ms 后 `SalAbort: Unspecified application error`、进程
1771
+ `_exit(1)`。空 SalAbort + 退出码 1 是 VCL 把 SIGSEGV 转成 Abort 的招牌。
1772
+ 崩在设语言:`LiblangtagDataRef::setupDataPath()` 对
1773
+ `lo_get_app_data_dir()` 做 `OString()`,而我们没走官方 Java Bootstrap,
1774
+ 这个指针一直是 NULL。现在启动前调一次 `cokit_initialize()` 填上
1775
+ `data_dir` / JavaVM / AssetManager;引擎侧对空指针做了判空,漏调也不再
1776
+ 杀进程。另外 kit 加载改走磁盘上真实存在的路径,避免 Poco 把中文文件名
1777
+ 编成 `%25E4...`。见
1778
+ `native-src/android/patches/0002-engine-android-documentload-no-abort.patch`。
1779
+ 要重装整包:引擎 `.so` 和 `libx_office_u.so` 都换了。
1780
+
1781
+ - 修安卓「load 已经 success、画布仍空白」。现场是 `commandresult: load
1782
+ success: true`、`perm: edit`、`coolserver` / `lokitversion` 都回了,然后
1783
+ 再也没有 `status:` 和瓦片。`lokit_main_001` 停在条件变量上是
1784
+ `DOCS_SHARE_PROCESS` 的正常形态;真正干活的 `lokit_runloop` 从进
1785
+ `runLoop` 起 CPU 就是 0 —— 堵在 `lo_runLoop()` 门口的
1786
+ `SolarMutex::acquire()`。根因是我们把 Android 也改成了 iOS 生命周期
1787
+ (启动时 `sharedOfficeHandle()`,再 `runKitLoopInAThread()`),而引擎
1788
+ Android 的 unipoll 路径在初始化线程 `InitVCL()` 之后不交锁;上游
1789
+ Collabora Android 同线程递归 acquire 没事。在 `lo_initialize()` 返回前
1790
+ `ReleaseSolarMutex()`,见
1791
+ `native-src/android/patches/0001-engine-android-solarmutex-handoff.patch`。
1792
+ 要重编 `liblo-native-code.so`,只重编 AAR 不够。
1793
+
1794
+ - 真机抓到安卓编辑链路的第二段静默失败。`file://` 落地、假 socket `open`、
1795
+ `HULLO` / `coolclient` / `load url=` 都发出去了,引擎也建了 `docbroker` +
1796
+ `lokit_main`,但回程只来一包握手(`coolserver` / `lokitversion` /
1797
+ `clipboardkey` + 若干 `progress:`),随后 FakeSocket 对端 EOF,`app2js`
1798
+ 线程退出,`status:` 再也到不了前端,`docLoaded` 一直是 false,画布不建。
1799
+ 更糟的是会话在 `download()` / `addSession` 阶段失败时引擎回的是
1800
+ `error: cmd=storage kind=loadfailed` 或 `error: cmd=internal kind=load`,
1801
+ 以前嗅探只认 `cmd=load`,这两种都不进 `LOADFAIL`,宿主就一直停在「正在打开」。
1802
+ 现在这两种都当加载失败往上抛,并在回程第一包把协议行打到 `x-office-u` 日志
1803
+ (不依赖已经经常哑掉的 `coolwsd` 标签)。
1804
+ - 修安卓文档永远打不开、且一条错误都不打(日志里只有一条无关的
1805
+ `E WebViewAssetLoader: Error opening asset path: x-office-u/branding-mobile.css`,
1806
+ 那是社区版必然缺的可选资源)。前端页面原先走 `WebViewAssetLoader` 的 https 虚拟域
1807
+ `https://appassets.androidplatform.net/...`,而回程数据要前端自己向 `cool:` 发 XHR 取
1808
+ —— 安全页面取非安全 scheme 属混合内容,被内核直接拦掉。之后一路静音:
1809
+ `ProxySocket.getSessionId()` 只给 XHR 挂了 `load`、没挂 `error`(`js/global.js`),
1810
+ 连 `_signalErrorClose()` 都走不到,socket 的 `readyState` 永远停在 0;而 `HULLO` 是走
1811
+ JS 桥发的,引擎那边照常连上、日志也正常,但发 `load url=` 的 `onopen` 第一句就是
1812
+ `if (readyState === 1)`,永不成立——引擎从没收到加载请求,宿主也就等不到 `LOADED`,
1813
+ `open()` 的回调一个都不回。改成 `file:///android_asset/x-office-u/cool.html`:那是上游
1814
+ `LOActivity.loadDocument` 用的地址,也让三端页面源统一成 `file://`(`Origin` 都是字面量
1815
+ `null`)。同时补 `setAllowUniversalAccessFromFileURLs(true)` 放行跨源取 `cool:`,与 iOS 的
1816
+ `allowingReadAccessTo`、鸿蒙的 `setPathAllowingUniversalAccess` 是同一件事。
1817
+ `shouldInterceptRequest` 于是只剩 `cool:` 一条分支,assets 交回内核自己读,
1818
+ `androidx.webkit` 依赖一并去掉。
1819
+ - Android 的 `onConsoleMessage` 现在也放过 warning 级。混合内容被拦、跨源被拒这类内核
1820
+ 诊断就落在这一级,只放 error 等于把上面那个问题唯一的线索也丢掉。
1821
+ - Android 侧补上编辑链路的三段探针,鸿蒙早就有、安卓一直是全黑的。链路里每一段失败
1822
+ 都不报错(前端 `ProxySocket` 的 XHR 没挂 `error`、引擎没收到 `load` 也不会说话),
1823
+ 现场只剩「界面出来了、内容不显示」,光看日志分不出停在哪:
1824
+ - `to engine: <消息>`:前端发给引擎的头几条。有 `HULLO` 没 `load url=` 就说明假
1825
+ socket 没 open,问题在回程通道;两条都在则前端已把文档交给引擎。
1826
+ - `cool: open -> 6B` / `cool: write -> NB`:回程请求真的到了 `shouldInterceptRequest`。
1827
+ `open` 那次一律记(它是建立假 socket 的唯一入口),`write` 只记开头几条。
1828
+ `cool: 404` 一律记 —— 落到 404 说明内核给的 path 和 native 侧的判定对不上。
1829
+ - `engine has outbound data` / `from engine: <通知>`:出站通知有没有回到宿主,
1830
+ `LOADED` 到底来没来(`open()` 的回调就等它)。
1831
+ 另外 `_openForEditing` 打出递给引擎的 `engine url`(与鸿蒙侧同一行日志,二次编码
1832
+ 就是这么认出来的),`_initEngine` 打出 `editing=0/1`,出站通知挂不上时补一条
1833
+ `editing disabled: outbound notifier rejected`。
1834
+ - Android 可调试构建(`FLAG_DEBUGGABLE`)下打开 WebView 的 DevTools,电脑上
1835
+ `chrome://inspect` 直接连编辑前端。上游 `LOActivity` 也是这么 gate 的。编辑 UI 是
1836
+ 几万行 JS,Network 面板能直接看到回程那条 `cool:` 请求成没成,比在宿主侧一层层
1837
+ 加日志快得多。
1838
+
1839
+ - Android 编译失败:`coolFileUrl` 里 `encodeURIComponent(parts[i])` 报
1840
+ `actual type is 'String?', but 'String' was expected`。UTS 数组下标在
1841
+ Android 上是 `T | null`,而 `encodeURIComponent` 的入参和返回值分别是
1842
+ `string` / `string | null`。改成 `for...of` 取段、`?? part` 再推进
1843
+ `string[]`,与同文件 `fileExtension` 的三端写法一致。
1844
+
1845
+ - 修「编辑链路半途失败会把只读直连一起弄坏」。现象是鸿蒙报
1846
+ `open:fail internal engine error <com::sun::star::uno::DeploymentException>`,
1847
+ 看着像文档或权限问题,实际根因隔了几百行:`_editing` 是 false,`open()` 走的是只读直连。
1848
+ 而只读直连此时已经不能用了——`officeStartEditing()` 里 `setupKitEnvironment()` 已经落下
1849
+ `SAL_KIT_OPTIONS=unipoll`,引擎从此不再自己起 `soffice_main`,主循环该由
1850
+ `runKitLoopInAThread()` 带;编辑链路在那之后失败,就没人跑主循环,引擎里没有
1851
+ Desktop / framework 服务,`documentLoad()` 必抛 `DeploymentException`。
1852
+ 又因为 `doc_initializeForRendering()` 在引擎里连 `try/catch` 都没有
1853
+ (`desktop/source/lib/init.cxx`,里面 `doc_iniUnoCommands()` 要查一大批 UNO 服务),
1854
+ 异常直接穿出 C 接口,`getError()` 里什么都没有,最后只能以我们最外层守卫的
1855
+ 「internal engine error <类型名>」冒出来。三处改动:
1856
+ `runKitLoopInAThread()` 紧跟引擎初始化、前面不放任何会失败的步骤,成功后置
1857
+ `g_loopRunning`(新增 `mobileapp::loopRunning()`);`openDocument()` 进门先查
1858
+ `unipollLatched() && !loopRunning()`,命中直接报「引擎主循环没在跑」并附上编辑链路的
1859
+ 原因;`mobileapp::start()` 自己兜住异常并把原因写进新增的 `recordEngineError()`——
1860
+ 它是各端桥直接调的,不经过 `guarded()`,往 NAPI / JNI / Swift 里扔 C++ 异常
1861
+ 等于当场终止进程。
1862
+
1863
+ - `guarded()` / `guardedJson()` 吞掉的异常现在也记进 `lastEngineError()`。之前只
1864
+ `logNative` 类型名,而 `startEditing()` 这种返回 `bool` 的调用失败后调用方只看到
1865
+ `false`,编辑链路静默退化,原因彻底丢失。三端 UTS 的
1866
+ `editing disabled: ...` 诊断改为直接打印 `officeLastEngineError()`,Android 与 iOS
1867
+ 之前根本没有这行诊断,一并补上。
1868
+
1869
+ - 引擎初始化失败现在能看到真正的原因,不再只报一句「见 native 日志」。起因是 iOS 报
1870
+ `1801 engine:fail COKit init failed, see native log tag x-office-u`,而那句提示在 iOS 上
1871
+ 根本没法照做:`logNative()` 在非 Android/鸿蒙分支只 `fprintf(stderr)`,iOS 的 stderr 既不进
1872
+ os_log、HBuilderX 控制台也读不到,新版 macOS 又撤掉了 `log stream --device-name`,
1873
+ `ios-deploy` 在 Wi-Fi 配对下也看不到设备。
1874
+ 引擎其实自己知道原因:`lo_initialize()` 末尾 `catch (cpo::uno::Exception&)` 里
1875
+ `fprintf(stderr, "Bootstrapping exception '%s'\n", ...)`,吃掉异常、`bInitialized` 保持
1876
+ false、只往 stderr 打一行。所以新增 `StderrCapture`(RAII,只在 `kit_cpp_init()` 那一小段
1877
+ 窗口接管 fd 2,析构必还原,写不进 profileDir 时退到 `TMPDIR` 再退到 `/tmp`),把引擎那行话
1878
+ 抓出来,经新增的 `xoffice::lastEngineError()` 一路接到 UTS:JNI `nativeLastEngineError`、
1879
+ NAPI `nativeLastEngineError`、ObjC `+lastEngineError`,再由三端 `officeLastEngineError()`
1880
+ 拼进 1801 的 `errMsg`。引擎一句话都没留时才退回报输入(`installDir` / `profile` /
1881
+ `CONFIGURATION_LAYERS` / `SAL_KIT_OPTIONS`)。
1882
+
1883
+ - 补上 `coolkitconfig.xcu`,修 user 配置层指向不存在文件的问题。`setupKitEnvironment()` 拼
1884
+ `CONFIGURATION_LAYERS` 的最后一层在非 `IOS`/`_WIN32`/`MACOS` 分支下取 `$COOLKITCONFIG_XCU`,
1885
+ 没设就退到 `file://` + `COOLWSD_CONFIGDIR`,而我们的 `COOLWSD_CONFIGDIR` 是 `.`,结果是
1886
+ 相对路径 `user:*file://./coolkitconfig.xcu`。我们不能定义 `IOS`(会让
1887
+ `common/MobileApp.hpp` 去 import 上游那份已被替掉的 ObjC 胶水 `CODocument.h`),
1888
+ 所以走不到上游 iOS 的 `${BRAND_BASE_DIR}` 分支。改法是在 `setupKitEnvironment()` 之前用
1889
+ 上游留的 `COOLKITCONFIG_XCU` 给出绝对路径。文件不在引擎 instdir 里,两处打包脚本各自补:
1890
+ Android/鸿蒙进 `program/`(`installDir` 就是解包后的 `program/`),iOS 进资源根。
1891
+ 这一层带的是 COOL 的内核默认值——主题配色、CJK 排版、默认字体、关掉 GIO UCP、
1892
+ 关掉句首两个大写的自动更正。
1893
+
1894
+ - 修安卓进页面即闪退(无 tombstone、logcat 里只有 `SalAbort` 与 `EXIT_SELF`):我们给上游
1895
+ 平台链加 `X_OFFICE_APP` 分支时放行了 `runKitLoopInAThread()`,却没同时把
1896
+ `DOCS_SHARE_PROCESS` 打开。这两个是一套的——那条分支的形态是「引擎实例由宿主提前建好、
1897
+ 主循环单开一条线程」,`runLoop()` 的 `data` 参数在这个形态下是个被忽略的占位(上游传的
1898
+ 是未初始化栈变量的地址);而 `DOCS_SHARE_PROCESS=0` 那侧的 `pollCallback()` 会把 `data`
1899
+ 当 `KitSocketPoll*` 解引用。于是每次 VCL yield 都在拿栈垃圾当对象读,`_document` 读出来
1900
+ 是个小整数(实测 3),`Document::needsQuickPoll()` 里 `ldr x8, [x0, #0xe0]` 当场
1901
+ `SIGSEGV@0xe3`。引擎的 `osl` 信号处理器把它转成 `Application::Abort()` + `_exit(1)`,
1902
+ 所以既没有崩溃日志也没有 tombstone。改法是把 `X_OFFICE_APP` 加进 `common/Common.hpp`
1903
+ 的 `DOCS_SHARE_PROCESS` 条件,与 `IOS`/`QTAPP`/`MACOS`/`_WIN32` 并列;同时把上游那个
1904
+ 未初始化栈变量换成函数内 `static`(`vcl` 侧靠 `mpPollClosure` 非空决定要不要调回调,
1905
+ 不能传 `nullptr`)。
1906
+ - 修同一条链上的第二个隐患:两个 `soffice_main()` 并发。`SAL_KIT_OPTIONS` 是 COKit 初始化
1907
+ 那一刻读的,`setupKitEnvironment()` 落 `unipoll` 必须早于初始化。三端 UTS 原先都是先
1908
+ `createSession()` 再 `startEditing()`,于是引擎按非 unipoll 在 `lo_startmain` 里起了
1909
+ 一条 `soffice_main`,编辑链路的 `runLoop()` 又起第二条,两条同时动 VCL 与
1910
+ `KitSocketPoll` 的共享状态。现在三端都改成 `startEditing()` 在前、`createSession()`
1911
+ 复用同一个 COKit 实例;`mobileapp::start()` 另加一道 `xoffice::engineInitialized()`
1912
+ 前置检查,顺序不对就拒绝进编辑态并打 `LOG_ERR`,宁可退化成只读直连也不带着两条主循环跑。
1913
+ 另外 `DOCS_SHARE_PROCESS` 形态下 `lokit_main()` 不再自己调 `setupKitEnvironment()`
1914
+ (见 `kit/Kit.cpp` 的 `#if MOBILEAPP && !DOCS_SHARE_PROCESS`),上游把这一步交给嵌入方,
1915
+ 这个顺序因此从「最好如此」变成了硬约束。
1916
+ - 修复鸿蒙打开任意文档必崩(`SIGSEGV@0x0`,栈顶 `pc 0 Not mapped`):aarch64 UNO 桥的
1917
+ `VtableFactory::flushCode()` 走了 `dlsym(RTLD_DEFAULT, "__clear_cache")` 分支,
1918
+ 而 OHOS 上没有动态库导出这个符号,返回 `NULL` 后无判空直接调用。新增补丁
1919
+ `patches/0002-engine-ohos-clear-cache.patch` 让 OHOS 走 `__builtin___clear_cache`。
1920
+ 详见 readme「aarch64 UNO 桥的 `__clear_cache`」。
1921
+ - 修鸿蒙打开任意文档必崩之二(`SIGABRT`,`LastFatalMessage` 点名
1922
+ `com::sun::star::ucb::InteractiveAugmentedIOException` 未被捕获):aarch64 UNO 桥用
1923
+ `dlopen(nullptr)` 的主程序域查 typeinfo,而引擎是以局部域 `dlopen` 进来的,查不到时
1924
+ 它会现造一个 `type_info`,而 libc++abi 判基类走指针比较,于是所有经
1925
+ `cppu::throwException()` 抛出的 UNO 异常都变成不可捕获——连 LibreOffice 自己写在
1926
+ `UCBContentHelper::IsDocument()` 里的 `catch` 都接不住。新增补丁
1927
+ `patches/0003-engine-ohos-uno-rtti-handle.patch` 改用 `dladdr` +
1928
+ `dlopen(<自身路径>, RTLD_NOLOAD)` 取引擎自己的句柄。
1929
+ 详见 readme「UNO 异常的 RTTI 查找域」。
1930
+ - 用户配置自愈:崩在引擎里会留下写了一半的用户配置,而引擎的 `sofficerc` 开了
1931
+ `SecureUserConfig`,半成品配置会让 configmgr 拒绝启动、连带 Desktop 服务建不起来——
1932
+ 于是**一次崩溃会把之后所有打开操作永久卡住**,用户只能靠清应用数据恢复。现在进引擎
1933
+ 前在用户配置目录落一个 `.x-office-init-pending` 标记,`documentLoad` 正常返回就撤;
1934
+ 启动时发现残留标记即判定上次死在引擎里,自动清空 `user/` 重来。本插件不暴露任何需要
1935
+ 持久化的设置,`user/` 里没有值得保留的内容。三端共用。
1936
+ - 修 LOK 的 `documentLoad` 会让异常穿出 C 接口:`lo_documentLoadWithOptions()` 里
1937
+ `frame::Desktop::create()` 写在那个大 `try` 之外,而它拿不到服务时抛
1938
+ `DeploymentException`。C 接口的契约是失败返回 `nullptr` + 原因留在 `getError()`,
1939
+ 异常穿出去等于原因彻底丢失,桥接层只能报出一个异常类型名。新增补丁
1940
+ `patches/0004-engine-lok-documentload-no-throw.patch` 给它单独套一层 `catch` 记下
1941
+ `Message`。非鸿蒙专属,三端都受影响(Android / iOS 需各自重链引擎才生效)。
1942
+ - 桥接层三端统一加异常兜底:`XOfficeEngine.cpp` 的对外函数全部经 `guarded()` /
1943
+ `guardedJson()`(内为 `catch (...)`,与 typeinfo 无关、永远匹配),异常类型名用
1944
+ `abi::__cxa_current_exception_type()` 取出后写 native 日志。此后同类问题只会降级成
1945
+ `open:fail internal engine error <类型名>`,不会再让 Node-API / JNI / Objective-C
1946
+ 边界外的宿主进程 `std::terminate`。
1947
+ - `open()` 接受系统选择器的授权 URI:鸿蒙 `DocumentViewPicker` 给的
1948
+ `file://docs/storage/...` 只有临时授权、只能按 fd 读,引擎那边是裸 POSIX open,
1949
+ 之前会报与真因无关的 `Unsupported URL <...>: "type detection failed"`。现在鸿蒙侧
1950
+ 先拷进 `<cacheDir>/x-office-u/inbox/<handle>/` 再打开。Android 侧 `x-chooseFile-s`
1951
+ 本就返回 cache 副本、iOS 给的是真实路径,两端只加了兜底报错。
1952
+ **注意**:编辑的是副本,`save()` 也写副本,落回用户原位置需走另存。
1953
+ - 支持打开 pdf(走 Draw 的 pdfium 导入过滤器),`docKindOfPath` 把 pdf 归为
1954
+ `drawing`,导出 PDF 时才会选中 `draw_pdf_Export`。
1955
+ - 鸿蒙编辑链路未启用时打日志说明是哪一个前置条件没过(前端未解包 / MOBILEAPP 层
1956
+ 不在位 / 进程内 COOLWSD 起不来),三者原先都静默退化成只读直连,无法区分。
1957
+ - `build_engine_oh.sh` 的补丁应用改为遍历 `patches/*.patch`,各自独立做幂等检查。
1958
+ - 修 Android 整包编译失败(组件本体,`--uni_module` 覆盖不到所以之前没暴露):
1959
+ - `x-office-u.uvue` 里 `open` / `autoOpen` 写在调用者之后。`<script setup>` 顶层函数在
1960
+ Android 编成 setup() 内的局部 `val fn = ::gen_fn`,局部声明不能前向引用,报
1961
+ `Unresolved reference 'autoOpen' / 'open'`。现按 `open ← autoOpen ← bindEditor` 排序。
1962
+ - `current.save()` 无参调用改为恒定传 `null`。契约里 `save(options ?: XOfficeSaveOptions)`
1963
+ 的 `?:` 只表示可空、不生成 Kotlin 默认值,无参调用报
1964
+ `No value passed for parameter 'options'`。
1965
+
1966
+ ### 0.1.0(2026-08-28)
1967
+
1968
+ - 首个版本。基于 LibreOffice 内核(Collabora COKit)的 Office 文档编辑视图组件。
1969
+ - 三端 API 对齐:Android / iOS / HarmonyOS 共用 `utssdk/interface.uts` 契约与
1970
+ `utssdk/libs/formats.uts` 的格式推导、过滤器映射。
1971
+ - HarmonyOS:给 LibreOffice 构建系统新增 OHOS 平台(上游零支持)——新增
1972
+ `OHOS_AARCH64_GCC.mk` / `ohos.mk` / `CPOHOSAarch64.conf` / `ohos` 模块,
1973
+ 并修正 icu / openssl / expat / argon2 / poco / pdfium 的交叉编译。
1974
+ - 打开 / 保存 / 另存 / 导出 PDF / 撤销重做 / 切 part / UNO 直连 / 光标格式状态查询。
1975
+ - 编辑前端(COOL)三端共用一份,打包前过 `native-src/common/stage_cool_dist.sh`
1976
+ 裁掉这份构建里走不到的资源(未压缩 js 源码、wasm 版专用的 `l10n-all.js`、
1977
+ 服务端管理控制台、非 en 帮助截图、模板库),49MB → 22MB。
1978
+ - `XOfficeDocumentInfo` 的只读字段定名 `isReadonly`:`readonly` 是类成员修饰符
1979
+ 关键字,作为必填字段编成 ArkTS 的 `readonly!: boolean` 会让 es2abc 报
1980
+ 10705000 语法错误。
1981
+ - 引擎产物不随仓库分发;未编引擎时插件仍可编译运行,`engineReady` 报 `false`。
1982
+ - 三端引擎各用独立 `BUILDDIR`(`online/engine-build/<目标>`,鸿蒙保持就地)。引擎是
1983
+ 单目标树,三端在同一目录里轮流 `autogen.sh` 会互相冲掉已编产物。新增
1984
+ `common/engine_builddir.sh` 守卫,在配置前比对目标 `OS=`,不一致直接拒绝;
1985
+ 比对忽略大小写,否则 configure 写的 `OS=iOS` 与脚本传的 `IOS` 不匹配,
1986
+ iOS 编过一次后就无法重跑。
1987
+ - 修正 macOS 宿主编 Android 引擎:`CPAndroidCommon.conf` 写死
1988
+ `--build=x86_64-unknown-linux-gnu`,导致 configure 去找 NDK 里不存在的
1989
+ `prebuilt/linux-x86_64/bin/clang`。现自动追加 `--build=$(config.guess)` 覆盖
1990
+ (经 shell 调用,该文件无执行位)。
1991
+ - 新增 `common/run_detached.sh`:`nohup` 不足以让三小时的构建活过调用方进程组,
1992
+ 改用 `start_new_session` 真正开新会话。配套 `common/finish_all.sh` 等两端引擎
1993
+ 收工后自动用 `--with-lok` 重打包。
1994
+ - 修正打包脚本里写错的引擎产物路径:Android 是 `<BUILDDIR>/android/jniLibs/`
1995
+ 而非 `android/source/jniLibs/`,鸿蒙是 `engine/ohos/libs/` 而非
1996
+ `workdir/CustomTarget/ohos/libs/`。