cj-tauri 0.8.0 → 1.1.0

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 (124) hide show
  1. package/README.md +3 -3
  2. package/cjpm.toml +52 -25
  3. package/cli/cjpm.toml +1 -1
  4. package/cli/src/doctor.cj +304 -0
  5. package/cli/src/main.cj +33 -7
  6. package/cli/src/project.cj +59 -1
  7. package/cli/src/resolve.cj +348 -304
  8. package/cli/src/scaffold.cj +139 -22
  9. package/cli/templates/ohos-pc/AppScope/app.json5 +13 -0
  10. package/cli/templates/ohos-pc/AppScope/resources/base/element/string.json +8 -0
  11. package/cli/templates/ohos-pc/AppScope/resources/base/media/background.png +0 -0
  12. package/cli/templates/ohos-pc/AppScope/resources/base/media/foreground.png +0 -0
  13. package/cli/templates/ohos-pc/AppScope/resources/base/media/layered_image.json +6 -0
  14. package/cli/templates/ohos-pc/README.md +79 -0
  15. package/cli/templates/ohos-pc/build-hap.sh +190 -0
  16. package/cli/templates/ohos-pc/build-profile.json5 +46 -0
  17. package/cli/templates/ohos-pc/entry/build-profile.json5 +26 -0
  18. package/cli/templates/ohos-pc/entry/cjpm.toml +43 -0
  19. package/cli/templates/ohos-pc/entry/hvigorfile.ts +6 -0
  20. package/cli/templates/ohos-pc/entry/oh-package.json5 +9 -0
  21. package/cli/templates/ohos-pc/entry/src/main/cangjie/ability_stage.cj +17 -0
  22. package/cli/templates/ohos-pc/entry/src/main/cangjie/app_host.cj +94 -0
  23. package/cli/templates/ohos-pc/entry/src/main/cangjie/index.cj +167 -0
  24. package/cli/templates/ohos-pc/entry/src/main/cangjie/main_ability.cj +139 -0
  25. package/cli/templates/ohos-pc/entry/src/main/module.json5 +49 -0
  26. package/cli/templates/ohos-pc/entry/src/main/resources/base/element/color.json +8 -0
  27. package/cli/templates/ohos-pc/entry/src/main/resources/base/element/string.json +16 -0
  28. package/cli/templates/ohos-pc/entry/src/main/resources/base/media/background.png +0 -0
  29. package/cli/templates/ohos-pc/entry/src/main/resources/base/media/foreground.png +0 -0
  30. package/cli/templates/ohos-pc/entry/src/main/resources/base/media/layered_image.json +6 -0
  31. package/cli/templates/ohos-pc/entry/src/main/resources/base/media/startIcon.png +0 -0
  32. package/cli/templates/ohos-pc/entry/src/main/resources/base/profile/main_pages.json +2 -0
  33. package/cli/templates/ohos-pc/entry/src/main/resources/rawfile/capabilities.json +6 -0
  34. package/cli/templates/ohos-pc/entry/src/main/resources/rawfile/index.html +92 -0
  35. package/cli/templates/ohos-pc/hvigor/hvigor-config.json5 +5 -0
  36. package/cli/templates/ohos-pc/hvigorfile.ts +6 -0
  37. package/cli/templates/ohos-pc/oh-package.json5 +10 -0
  38. package/native/build_linux.sh +30 -2
  39. package/native/build_win.bat +62 -61
  40. package/native/core/bridge_core.c +1626 -0
  41. package/native/{bridge_core.h → core/bridge_core.h} +246 -6
  42. package/native/platforms/harmonyos/README.md +106 -0
  43. package/native/platforms/linux/bridge_linux.c +2485 -0
  44. package/native/platforms/windows/bridge_win.c +3248 -0
  45. package/native/tests/test_bridge_core.c +528 -1
  46. package/package.json +12 -3
  47. package/prebuilt/win32-x64/cj-tauri.exe +0 -0
  48. package/src/api_system.cj +10 -2
  49. package/src/app.cj +248 -85
  50. package/src/arkweb_proxy.cj +56 -0
  51. package/src/arkweb_script.cj +154 -0
  52. package/src/capability.cj +140 -0
  53. package/src/clipboard_bridge.cj +55 -0
  54. package/src/dragdrop.cj +26 -0
  55. package/src/embedded_assets.cj +98 -0
  56. package/src/hexutil.cj +53 -0
  57. package/src/host.cj +197 -26
  58. package/src/host_arkweb.cj +740 -0
  59. package/src/host_factory.cj +48 -0
  60. package/src/host_webkit.cj +202 -26
  61. package/src/host_webview2.cj +176 -3
  62. package/src/hotkey.cj +66 -0
  63. package/src/ipc_hub.cj +49 -0
  64. package/src/ipc_message.cj +2 -1
  65. package/src/jsutil.cj +33 -0
  66. package/src/log_bridge.cj +107 -0
  67. package/src/logging.cj +127 -0
  68. package/src/menu.cj +197 -196
  69. package/src/plugin.cj +40 -0
  70. package/src/plugins/clipboard/plugin_clipboard.cj +144 -0
  71. package/src/{plugin_dialog.cj → plugins/dialog/plugin_dialog.cj} +74 -17
  72. package/src/plugins/fs/plugin_fs.cj +194 -0
  73. package/src/plugins/http/plugin_http.cj +459 -0
  74. package/src/plugins/log/plugin_log.cj +105 -0
  75. package/src/{plugin_menu.cj → plugins/menu/plugin_menu.cj} +4 -2
  76. package/src/plugins/mqtt/mqtt_codec.cj +661 -0
  77. package/src/plugins/mqtt/plugin_mqtt.cj +1446 -0
  78. package/src/plugins/notification/plugin_notification.cj +146 -0
  79. package/src/plugins/plugins.cj +5 -0
  80. package/src/plugins/redis/plugin_redis.cj +2409 -0
  81. package/src/plugins/redis/redis_codec.cj +556 -0
  82. package/src/{plugin_serial.cj → plugins/serial/plugin_serial.cj} +442 -489
  83. package/src/{serial_bridge.cj → plugins/serial/serial_bridge.cj} +185 -184
  84. package/src/{plugin_shell.cj → plugins/shell/plugin_shell.cj} +230 -228
  85. package/src/plugins/sqlite/plugin_sqlite.cj +697 -0
  86. package/src/plugins/sqlite/sqlite_ffi.cj +206 -0
  87. package/src/plugins/store/plugin_store.cj +287 -0
  88. package/src/plugins/ws/plugin_ws.cj +470 -0
  89. package/src/process_hidden.cj +174 -0
  90. package/src/scope.cj +391 -0
  91. package/src/shell_event.cj +69 -68
  92. package/src/tests/capability_test.cj +53 -0
  93. package/src/tests/fake_host.cj +85 -20
  94. package/src/tests/ipc_hub_test.cj +13 -0
  95. package/src/tests/jsutil_test.cj +62 -0
  96. package/src/tests/logging_test.cj +62 -0
  97. package/src/tests/mqtt_codec_test.cj +405 -0
  98. package/src/tests/plugin_clipboard_test.cj +222 -0
  99. package/src/tests/plugin_dialog_test.cj +98 -4
  100. package/src/tests/plugin_http_test.cj +124 -0
  101. package/src/tests/plugin_menu_test.cj +1 -0
  102. package/src/tests/plugin_notification_test.cj +205 -0
  103. package/src/tests/plugin_serial_test.cj +269 -267
  104. package/src/tests/plugin_shell_test.cj +189 -188
  105. package/src/tests/plugin_sqlite_test.cj +230 -0
  106. package/src/tests/plugin_store_test.cj +260 -0
  107. package/src/tests/plugin_test.cj +542 -524
  108. package/src/tests/plugin_ws_test.cj +40 -0
  109. package/src/tests/process_hidden_test.cj +129 -0
  110. package/src/tests/redis_codec_test.cj +396 -0
  111. package/src/tests/scope_test.cj +350 -0
  112. package/src/tests/shell_event_test.cj +0 -1
  113. package/src/tests/system_host_test.cj +81 -79
  114. package/src/tests/urlutil_test.cj +53 -0
  115. package/src/tests/version_test.cj +75 -65
  116. package/src/tray.cj +90 -0
  117. package/src/urlutil.cj +159 -0
  118. package/src/version.cj +23 -2
  119. package/src/window.cj +51 -42
  120. package/native/bridge_core.c +0 -797
  121. package/native/bridge_linux.c +0 -1268
  122. package/native/bridge_win.c +0 -1633
  123. package/src/plugin_fs.cj +0 -118
  124. /package/native/{bridge_js.h → core/bridge_js.h} +0 -0
package/src/api_system.cj CHANGED
@@ -14,7 +14,7 @@ public class SystemCommands <: CommandHandler {
14
14
  case "system:version" => this.version()
15
15
  case "system:ping" => this.ping(args)
16
16
  case "system:echo" => this.echo(args)
17
- case "system:devtools" => this.devTools(ipc)
17
+ case "system:devtools" => this.devTools(ipc)
18
18
  case "system:host" => this.hostCapabilities(ipc)
19
19
  case _ => throw CommandException("unknown system command: ${cmd}")
20
20
  }
@@ -24,6 +24,7 @@ public class SystemCommands <: CommandHandler {
24
24
  var obj = JsonObject()
25
25
  obj.put("name", JsonString("cj-tauri"))
26
26
  obj.put("version", JsonString(CJ_TAURI_VERSION))
27
+ obj.put("protocol", JsonString(CJ_IPC_PROTOCOL))
27
28
  obj.put("cangjie", JsonString("1.0.5"))
28
29
  obj.put("platform", JsonString(currentPlatform()))
29
30
  return obj
@@ -52,8 +53,15 @@ public class SystemCommands <: CommandHandler {
52
53
  * 「这个应用准不准」,两者都要过才叫真能用——放行了但平台没有,事件永远不会出现。
53
54
  * 取**调用方那个窗口**的位域(多窗口下可能不同,如 Linux 托盘取决于桥怎么编的)。
54
55
  * 与 `system:*` 的其它命令一样,需在清单里声明 `system:host` 才可调用(三处联动)。
56
+ *
57
+ * 另带 `logFile`(RFC-003 第 5 期):日志落盘是**进程级**的,不是某一台宿主的能力,
58
+ * 所以它没有对应的布尔位——空串=没开(stderr 照旧),非空=诊断都落在那个文件里,
59
+ * 远程报障先问这一个字段,比让用户去翻平台目录快。
55
60
  */
56
61
  private func hostCapabilities(ipc: IpcContext): JsonValue {
57
- return HostCapabilities.fromBits(currentPlatform(), ipc.hostCapabilities()).toJson()
62
+ let obj = HostCapabilities.fromBits(currentPlatform(), ipc.hostCapabilities())
63
+ .toJson().asObject()
64
+ obj.put("logFile", JsonString(currentLogFilePath()))
65
+ return obj
58
66
  }
59
67
  }
package/src/app.cj CHANGED
@@ -183,6 +183,126 @@ public class TauriApp <: AppHost {
183
183
  return this.setMenuItemStateTo(DEFAULT_WINDOW_LABEL, id, enabled, checked)
184
184
  }
185
185
 
186
+ /**
187
+ * 设置默认窗口的拖放策略(RFC-002 §5;必须在 `start()` / `startUrl()` 之前调)
188
+ *
189
+ * 打开后页面经 `dragdrop:files` 事件收到拖入的文件路径数组(`paths: [...]`),
190
+ * 该事件需在能力清单 `events` 里声明;不设 = 不注册 drop target(默认关)。
191
+ * 平台没有该能力(如 Windows 桩阶段)时宿主只记日志,不报错——能力位经 `system:host` 如实上报。
192
+ */
193
+ public func setDragDrop(policy: DragDropPolicy): TauriApp {
194
+ return this.setDragDropTo(DEFAULT_WINDOW_LABEL, policy)
195
+ }
196
+
197
+ /** 指定窗口版本(多窗口):语义同 `setDragDrop`,作用于 `label` 那个窗口 */
198
+ public func setDragDropTo(label: String, policy: DragDropPolicy): TauriApp {
199
+ match (this.hostOf(label)) {
200
+ case Some(host) =>
201
+ host.setDragDropPolicy(policy.enabled, policy.files)
202
+ case None =>
203
+ eprintln("[cj-tauri] setDragDrop: 未注册的窗口 label \"${label}\"(策略未设置)")
204
+ }
205
+ return this
206
+ }
207
+
208
+ /**
209
+ * 注册**默认窗口**的全局快捷键(RFC-003 第 5 期)
210
+ *
211
+ * 全局 = 焦点不在本应用时也生效(Linux = X11 grab,能力位 `hotkey` 如实上报)。
212
+ * 触发时页面收 `hotkey:press` 事件(载荷 `{"id":…}`,需在能力清单 `events` 声明);
213
+ * 应用侧钩子挂 `onShellEvent`(信封 `kind == "hotkey"`)。运行期再调 = 整表替换。
214
+ */
215
+ public func setGlobalHotkeys(keys: HotkeyModel): TauriApp {
216
+ return this.setGlobalHotkeysTo(DEFAULT_WINDOW_LABEL, keys)
217
+ }
218
+
219
+ /** 指定窗口版本(多窗口):语义同 `setGlobalHotkeys`,作用于 `label` 那个窗口 */
220
+ public func setGlobalHotkeysTo(label: String, keys: HotkeyModel): TauriApp {
221
+ match (this.hostOf(label)) {
222
+ case Some(host) =>
223
+ host.setGlobalHotkeys(keys.toWire())
224
+ case None =>
225
+ eprintln("[cj-tauri] setGlobalHotkeys: 未注册的窗口 label \"${label}\"(快捷键未设置)")
226
+ }
227
+ return this
228
+ }
229
+
230
+ /**
231
+ * 设置**托盘**(RFC-002 §7.C;托盘是应用级资源,走默认窗口的宿主)
232
+ *
233
+ * 图标 + 提示 + 右键菜单一次给定;重复调用 = 整体重建,`TrayModel("")` = 移除。
234
+ * 菜单项被点时页面收 `tray:click` 事件(载荷 `{"id":…}`,需在能力清单 `events` 声明);
235
+ * 应用侧钩子挂 `onShellEvent`(信封 `kind == "tray"`)。平台不支持时桥如实打日志、托盘不出现。
236
+ */
237
+ public func setTray(tray: TrayModel): TauriApp {
238
+ match (this.hostOf(DEFAULT_WINDOW_LABEL)) {
239
+ case Some(host) =>
240
+ host.setTray(tray.toWire())
241
+ case None =>
242
+ eprintln("[cj-tauri] setTray: 默认窗口未注册(托盘未设置)")
243
+ }
244
+ return this
245
+ }
246
+
247
+ /**
248
+ * 开启日志落盘(RFC-003 第 5 期)
249
+ *
250
+ * 单文件产物是 Windows 的 GUI 子系统、双击时没有控制台,stderr 无处可去——开了它,C 桥与
251
+ * 仓颉侧**两侧**的诊断都落到这一个文件(`system:host` 的 `logFile` 回读生效路径)。
252
+ * **越早调越好**(宿主的装配期日志比 `run()` 还早):最晚在 `run()` / `runUrl()` 之前。
253
+ * 超预算按份轮转(`log.1` … `log.keep`);不调用 = 不落盘,行为与之前完全一致。
254
+ * `CJ_TAURI_LOG_MAX_KB` / `CJ_TAURI_LOG_KEEP` 可覆盖这里的预算——报障时让用户改环境变量,
255
+ * 不必动代码。被拒(路径打不开 / 已激活)时桥打一行 stderr 并**保持不落盘**,这里不假装成功。
256
+ */
257
+ public func setLogFile(path: String): TauriApp {
258
+ return this.setLogFile(path, CJ_LOG_DEFAULT_MAX_KB, CJ_LOG_DEFAULT_KEEP)
259
+ }
260
+
261
+ /** 显式给预算的版本(仓颉没有默认参数值,所以拆成两个重载) */
262
+ public func setLogFile(path: String, maxKB: Int64, keep: Int64): TauriApp {
263
+ let kb = logBudgetFromEnv("CJ_TAURI_LOG_MAX_KB", maxKB)
264
+ let kp = logBudgetFromEnv("CJ_TAURI_LOG_KEEP", keep)
265
+ let _ = enableLogFile(path, kb, kp) // 成败都由桥出声:成功落文件、被拒落 stderr
266
+ return this
267
+ }
268
+
269
+ /**
270
+ * 落到平台惯例目录的日志文件(`appName` 作文件名)
271
+ *
272
+ * Linux `$XDG_STATE_HOME/cj-tauri/<app>.log`(缺省 `~/.local/state/…`)、
273
+ * Windows `%LOCALAPPDATA%\cj-tauri\logs\<app>.log`;`CJ_TAURI_LOG` 给了路径时优先用它——
274
+ * 报障的人只需带一个环境变量启动,不必知道平台目录在哪(也不受应用是否设过名字影响)。
275
+ */
276
+ public func setLogToDefaultFile(appName: String): TauriApp {
277
+ return this.setLogToDefaultFile(appName, CJ_LOG_DEFAULT_MAX_KB, CJ_LOG_DEFAULT_KEEP)
278
+ }
279
+
280
+ /** 显式给预算的版本(同上,重载而非默认参数) */
281
+ public func setLogToDefaultFile(appName: String, maxKB: Int64, keep: Int64): TauriApp {
282
+ return this.setLogFile(logPathOrDefault(logEnvOr("CJ_TAURI_LOG", ""), appName), maxKB, keep)
283
+ }
284
+
285
+ /**
286
+ * 显示 / 隐藏**默认窗口**(对标 Tauri 2 的 `window.show()` / `window.hide()`)
287
+ *
288
+ * 典型用途是「最小化到托盘」的还原路径:托盘菜单项或全局快捷键触发时 `setWindowVisible(true)`。
289
+ * `run()` / `runUrl()` 之前调用同样有效(宿主记下,建窗时定稿)。
290
+ */
291
+ public func setWindowVisible(visible: Bool): TauriApp {
292
+ return this.setWindowVisibleTo(DEFAULT_WINDOW_LABEL, visible)
293
+ }
294
+
295
+ /** 指定窗口版本(多窗口):语义同 `setWindowVisible`,作用于 `label` 那个窗口 */
296
+ public func setWindowVisibleTo(label: String, visible: Bool): TauriApp {
297
+ match (this.hostOf(label)) {
298
+ case Some(host) =>
299
+ host.setWindowVisible(visible)
300
+ case None =>
301
+ eprintln("[cj-tauri] setWindowVisible: 未注册的窗口 label \"${label}\"(窗口未改可见性)")
302
+ }
303
+ return this
304
+ }
305
+
186
306
  /** 指定窗口版本(多窗口):语义同 `setMenuItemState`,作用于 `label` 那个窗口 */
187
307
  public func setMenuItemStateTo(label: String, id: String, enabled: Bool, checked: Bool): TauriApp {
188
308
  this.applyMenuState(label, id, enabled, checked)
@@ -324,6 +444,34 @@ public class TauriApp <: AppHost {
324
444
  return this
325
445
  }
326
446
 
447
+ /**
448
+ * 运行期给某插件的 scope 追加一条白名单模式(**不落盘**,重启即失效):插件名如 `"http"`,
449
+ * 模式写法同 `src/scope.cj`(主机名 / 路径,支持 `*` `**` `?` 与变量)。
450
+ *
451
+ * 调用时机是硬约束:**只应在应用已取得用户明确确认之后调**——框架替你做不了确认,它不理解
452
+ * 你的 UI 语义。典型落法是先在原生确认框里问到「是」
453
+ * (`host.showMessageDialog("confirm", …, "允许 <主机> 出网?")` 返回 true)再调本方法;
454
+ * 页面自己点不了原生对话框,所以「确认」这一步天然挡得住被注入的页面自行加白。
455
+ * 另外,清单 `scopes.<插件>.deny` 命中即拒,这里加白掀不掉 deny(判定顺序仍是先 deny 后 allow)。
456
+ *
457
+ * 需要重启仍生效就在启动时把存过的模式重新叠一遍(持久化与存放位置由应用自己决定)。
458
+ *
459
+ * 返回 false = 这条模式**没被采纳**(空串、或含 `*` / `?` 与制表/换行这类脏字符):加白的语义是
460
+ * 「往白名单里加一条具体值」,带通配透传进来等于全放行,所以注册中心当场拒。调用方应把 false
461
+ * 当成「没放行」如实回给用户,别写成成功。
462
+ */
463
+ public func allowScopePattern(plugin: String, pattern: String): Bool {
464
+ return this.capabilities.addRuntimeScopeAllow(plugin, pattern)
465
+ }
466
+
467
+ /**
468
+ * 某插件的 scope 此刻是否放行某个值(口径与判定完全一致:清单 + 运行期叠加,先 deny 后 allow)。
469
+ * 没写 scope 的插件恒为 true(旧行为:全量放行)。
470
+ */
471
+ public func isScopeAllowed(plugin: String, value: String): Bool {
472
+ return this.capabilities.scopeAllowsValue(plugin, value)
473
+ }
474
+
327
475
  /**
328
476
  * 直接添加 capability
329
477
  */
@@ -481,11 +629,21 @@ public class TauriApp <: AppHost {
481
629
  }
482
630
  this.started = true
483
631
 
632
+ // 环境变量兜底(RFC-003 第 5 期):应用没显式开日志时,`CJ_TAURI_LOG` 仍能把它开起来——
633
+ // 已经分发出去的 exe 改不了代码,报障时靠这一个变量拿凭证。放在装配的最早处,
634
+ // 让宿主与插件的启动期日志都赶得上;不设这个变量 = 一行不动,行为与之前完全一致。
635
+ if (currentLogFilePath().size == 0) {
636
+ let envPath = logEnvOr("CJ_TAURI_LOG", "")
637
+ if (envPath.size > 0) {
638
+ let _ = this.setLogFile(envPath)
639
+ }
640
+ }
641
+
484
642
  // 注入内置系统命令
485
643
  this.hub.register("system:version", SystemCommands())
486
644
  this.hub.register("system:ping", SystemCommands())
487
645
  this.hub.register("system:echo", SystemCommands())
488
- this.hub.register("system:devtools", SystemCommands())
646
+ this.hub.register("system:devtools", SystemCommands())
489
647
  this.hub.register("system:host", SystemCommands())
490
648
 
491
649
  // 未显式挂载 capability 时,自动扫描 capabilities/ 目录(对标 Tauri)
@@ -513,6 +671,8 @@ public class TauriApp <: AppHost {
513
671
  }
514
672
  // 集注册完才可能判定「清单引用的集有没有插件提供」,故放在插件装配之后
515
673
  warnUnknownPermissionSets(this.capabilities)
674
+ // 路径 scope 的缺省行为提示(RFC-003 第 4 期):没为吃 scope 的插件写规则 = 全量放行,如实告知
675
+ warnUnscopedPlugins(this.plugins, this.capabilities)
516
676
 
517
677
  // 装配:宿主消息 → IPC hub;hub 输出 → 宿主 JS
518
678
  this.wireHostCallbacks(this.host)
@@ -526,6 +686,9 @@ public class TauriApp <: AppHost {
526
686
  this.hub.hostCapabilitiesSink = { label => this.windowCapabilities(label) }
527
687
 
528
688
  this.host.setWindowConfig(this.config)
689
+ // 最小化到托盘同属「建窗时定稿」的窗口行为(窗口过程要提前知道这条策略),
690
+ // 所以与窗口配置一起在 start() 之前交给宿主——能力位不含托盘时平台会拒绝并出声
691
+ this.host.setMinimizeToTray(this.config.minimizeToTray)
529
692
  }
530
693
 
531
694
  /**
@@ -632,90 +795,90 @@ public class TauriApp <: AppHost {
632
795
  * 多窗口下每个宿主都挂**同一只**派发器:报文自带发起窗口的 label(架构文档 §7.3),
633
796
  * 路由不需要按句柄分表——所以这里传的是同一个闭包,不是每窗口一份。
634
797
  */
635
- /**
636
- * 应用层的 shell 事件钩子(对标 Tauri 的 `on_menu_event`)
637
- *
638
- * 回调参数是 `(信封 JSON, 发生事件的窗口 label)`。给原始 JSON 而不是解析后的对象:钩子多半
639
- * 是应用自己写的诊断 / 日志(探针就靠它断言),原样透传最不容易丢信息。label 是必需的——
640
- * 钩子是应用级的,一份回调要能分辨事件来自哪个窗口。
641
- *
642
- * 与前端事件**两条路都走**:先按注册顺序跑钩子(应用侧决策,例如改菜单项状态),再投前端
643
- * 事件(页面自己 `listen("menu:click", …)`)。钩子里抛异常只留一行 stderr,不挡后面的钩子
644
- * 与前端事件。
645
- */
646
- public func onShellEvent(handler: (String, String) -> Unit): TauriApp {
647
- this.shellHooks.add(handler)
648
- return this
649
- }
650
-
651
- /**
652
- * 装配本窗口的 shell 事件分发(应用层是唯一安装者)
653
- *
654
- * 必须在宿主 `start()` / `startUrl()` **之前**装:窗口一建出来菜单点击就可能来,装晚了会漏。
655
- * 宿主层的 `setShellHandler` 是**可变槽位**(后装覆盖先装),所以应用要处理 shell 事件请用
656
- * `onShellEvent()`(应用级钩子,可叠加多条),不要和框架抢宿主槽位——抢赢的那个会让框架的
657
- * 前端事件分发失效。
658
- */
659
- private func wireShellEvents(host: WebViewHost, label: String): Unit {
660
- host.setShellHandler({ json => this.dispatchShellEvent(label, json) })
661
- }
662
-
663
- /**
664
- * shell 事件 → 应用层钩子 + 前端事件(按窗口投递)
665
- *
666
- * 路由靠**闭包捕获的 label**:C 侧已按宿主句柄把事件分给了本窗口的回调(见 host.cj 的
667
- * `ShellRoute`),这里再按 label 投前端事件,多窗口下不会串。事件放不放行由 capability 清单
668
- * 决定——没在清单里声明 `menu:click` 就是静默丢弃(最小权限不破)。
669
- *
670
- * 本函数跑在**宿主 UI 线程**上(C 桥的 shell 回调就在那儿),所以中途不碰 GTK / WebKit 之外
671
- * 的东西:投递走 `emitToWindow` → `jsSink`,与其它事件同一条路。
672
- */
673
- private func dispatchShellEvent(label: String, json: String): Unit {
674
- for (hook in this.shellHooks) {
675
- try {
676
- hook(json, label)
677
- } catch (e: Exception) {
678
- eprintln("[cj-tauri] shell hook 抛出异常(已忽略):${e}")
679
- }
680
- }
681
- match (parseShellEnvelope(json)) {
682
- case None => eprintln("[cj-tauri] shell event 信封解析失败(已丢弃):${json}")
683
- case Some(obj) =>
684
- var kind = ""
685
- if (let Some(k) <- shellString(obj, "kind")) {
686
- kind = k
687
- }
688
- match (shellEventNameFor(kind)) {
689
- case None => eprintln("[cj-tauri] shell event: 未知 kind \"${kind}\"(未投递):${json}")
690
- case Some(event) =>
691
- if (kind == "menu") {
692
- this.syncMenuStateFromShellObj(label, obj)
693
- }
694
- try {
695
- this.hub.emitToWindow(event, obj, label)
696
- } catch (e: Exception) {
697
- eprintln("[cj-tauri] shell event 投递失败(已忽略):${e}")
698
- }
699
- }
700
- }
701
- }
702
-
703
- /**
704
- * 菜单事件里的平台真值 → 应用层模型:id / enabled / checked 三者齐了才写
705
- *
706
- * 缺字段不拿默认值顶(见 shellBool 的注释)——这条路径写错的表现是「勾选莫名回退」,
707
- * 比少同步一次难查得多。
708
- */
709
- private func syncMenuStateFromShellObj(label: String, obj: JsonObject): Unit {
710
- if (let Some(id) <- shellString(obj, "id")) {
711
- if (let Some(enabled) <- shellBool(obj, "enabled")) {
712
- if (let Some(checked) <- shellBool(obj, "checked")) {
713
- this.syncMenuStateFromShell(label, id, enabled, checked)
714
- }
715
- }
716
- }
717
- }
718
-
798
+ /**
799
+ * 应用层的 shell 事件钩子(对标 Tauri 的 `on_menu_event`)
800
+ *
801
+ * 回调参数是 `(信封 JSON, 发生事件的窗口 label)`。给原始 JSON 而不是解析后的对象:钩子多半
802
+ * 是应用自己写的诊断 / 日志(探针就靠它断言),原样透传最不容易丢信息。label 是必需的——
803
+ * 钩子是应用级的,一份回调要能分辨事件来自哪个窗口。
804
+ *
805
+ * 与前端事件**两条路都走**:先按注册顺序跑钩子(应用侧决策,例如改菜单项状态),再投前端
806
+ * 事件(页面自己 `listen("menu:click", …)`)。钩子里抛异常只留一行 stderr,不挡后面的钩子
807
+ * 与前端事件。
808
+ */
809
+ public func onShellEvent(handler: (String, String) -> Unit): TauriApp {
810
+ this.shellHooks.add(handler)
811
+ return this
812
+ }
813
+
814
+ /**
815
+ * 装配本窗口的 shell 事件分发(应用层是唯一安装者)
816
+ *
817
+ * 必须在宿主 `start()` / `startUrl()` **之前**装:窗口一建出来菜单点击就可能来,装晚了会漏。
818
+ * 宿主层的 `setShellHandler` 是**可变槽位**(后装覆盖先装),所以应用要处理 shell 事件请用
819
+ * `onShellEvent()`(应用级钩子,可叠加多条),不要和框架抢宿主槽位——抢赢的那个会让框架的
820
+ * 前端事件分发失效。
821
+ */
822
+ private func wireShellEvents(host: WebViewHost, label: String): Unit {
823
+ host.setShellHandler({ json => this.dispatchShellEvent(label, json) })
824
+ }
825
+
826
+ /**
827
+ * shell 事件 → 应用层钩子 + 前端事件(按窗口投递)
828
+ *
829
+ * 路由靠**闭包捕获的 label**:C 侧已按宿主句柄把事件分给了本窗口的回调(见 host.cj 的
830
+ * `ShellRoute`),这里再按 label 投前端事件,多窗口下不会串。事件放不放行由 capability 清单
831
+ * 决定——没在清单里声明 `menu:click` 就是静默丢弃(最小权限不破)。
832
+ *
833
+ * 本函数跑在**宿主 UI 线程**上(C 桥的 shell 回调就在那儿),所以中途不碰 GTK / WebKit 之外
834
+ * 的东西:投递走 `emitToWindow` → `jsSink`,与其它事件同一条路。
835
+ */
836
+ private func dispatchShellEvent(label: String, json: String): Unit {
837
+ for (hook in this.shellHooks) {
838
+ try {
839
+ hook(json, label)
840
+ } catch (e: Exception) {
841
+ eprintln("[cj-tauri] shell hook 抛出异常(已忽略):${e}")
842
+ }
843
+ }
844
+ match (parseShellEnvelope(json)) {
845
+ case None => eprintln("[cj-tauri] shell event 信封解析失败(已丢弃):${json}")
846
+ case Some(obj) =>
847
+ var kind = ""
848
+ if (let Some(k) <- shellString(obj, "kind")) {
849
+ kind = k
850
+ }
851
+ match (shellEventNameFor(kind)) {
852
+ case None => eprintln("[cj-tauri] shell event: 未知 kind \"${kind}\"(未投递):${json}")
853
+ case Some(event) =>
854
+ if (kind == "menu") {
855
+ this.syncMenuStateFromShellObj(label, obj)
856
+ }
857
+ try {
858
+ this.hub.emitToWindow(event, obj, label)
859
+ } catch (e: Exception) {
860
+ eprintln("[cj-tauri] shell event 投递失败(已忽略):${e}")
861
+ }
862
+ }
863
+ }
864
+ }
865
+
866
+ /**
867
+ * 菜单事件里的平台真值 → 应用层模型:id / enabled / checked 三者齐了才写
868
+ *
869
+ * 缺字段不拿默认值顶(见 shellBool 的注释)——这条路径写错的表现是「勾选莫名回退」,
870
+ * 比少同步一次难查得多。
871
+ */
872
+ private func syncMenuStateFromShellObj(label: String, obj: JsonObject): Unit {
873
+ if (let Some(id) <- shellString(obj, "id")) {
874
+ if (let Some(enabled) <- shellBool(obj, "enabled")) {
875
+ if (let Some(checked) <- shellBool(obj, "checked")) {
876
+ this.syncMenuStateFromShell(label, id, enabled, checked)
877
+ }
878
+ }
879
+ }
880
+ }
881
+
719
882
  private func wireHostCallbacks(host: WebViewHost): Unit {
720
883
  host.setMessageHandler({ raw =>
721
884
  this.hub.handleRawMessage(raw)
@@ -0,0 +1,56 @@
1
+ package cjTauri
2
+
3
+ /**
4
+ * HarmonyOS ArkWeb 的**桥接函数**(ArkUI-free 的那一半)
5
+ *
6
+ * ## 为什么组件本体不在这里
7
+ *
8
+ * 2026-10-19 真机定位到一个必须先说清的硬约束:**框架包(独立编译单元,`output-type = "static"`)
9
+ * 一旦 import `kit.ArkUI.*` / `kit.ArkWeb.*` / `ohos.arkui.component.web.Web`,应用就会在启动瞬间
10
+ * 死在包初始化**——`InitCJLibrary → cangjie_entry::package_global_init → cjTauri::package_global_init`
11
+ * 里那条 `bl _CGP9kit.ArkUIiiHv@plt` 跳进未映射地址,`hilog` 里只有一行
12
+ * `SIGSEGV(SEGV_MAPERR)@0x0004exxx`,窗口起不来、我方日志一行都没有。
13
+ *
14
+ * 判据来自一组对照实验(都在这台真机上跑的):
15
+ * 1. 入口包自己写 `@Component` + `Web`(不引用框架)→ 正常启动,还拉起了 `:gpu` / `:render`;
16
+ * 2. 入口包只引用框架的一个字符串常量(`DEFAULT_WINDOW_LABEL`)→ 照样崩在包初始化;
17
+ * 3. 把 `arkweb_view.cj`(框架里唯一碰 ArkUI 的文件)移出编译 → 立刻正常启动
18
+ * (`onCreated` / `onWindowStageCreate` 都有、`SIGSEGV` 计数为 0)。
19
+ *
20
+ * 也就是说:**同一张 PLT 表,在入口包里调用 `_CGP9kit.ArkUIiiHv` 是好的,在框架包里就解析不了。**
21
+ * 所以 ArkUI 声明式组件只能由**应用模块**持有(这也正是 ArkUI 的模型:组件树属于应用的 UIAbility),
22
+ * 框架这边只保留与 UI 无关的接线——报文、脚本队列、label 注入。
23
+ *
24
+ * 应用侧要写的东西见 `docs/鸿蒙ArkWeb接入踩坑.md`:一个 `@Component`,`build()` 里放 `Web(...)` 并
25
+ * 把下面两个函数塞进 `javaScriptProxy` 的 `funcList`。
26
+ */
27
+
28
+ /**
29
+ * 页面 → 原生:`javaScriptProxy` 的 `post`(同步调用,返回值无意义)
30
+ *
31
+ * ArkWeb 的 proxy 只能挂在组件上,且回调签名固定为 `(String) -> String`;窗口身份靠
32
+ * **闭包捕获 label** 传进来(应用侧写 `{ raw => arkWebProxyPost(this.windowLabel, raw) }`),不靠全局单槽——
33
+ * 多窗口下静态单槽会让后建的窗口认领先前窗口的报文(AGENTS §4 的「静态单槽」坑)。
34
+ */
35
+ @When[env == "ohos"]
36
+ public func arkWebProxyPost(label: String, raw: String): String {
37
+ match (ArkWebRuntime.hostFor(label)) {
38
+ case Some(h) => h.acceptPageMessage(raw)
39
+ case None => ()
40
+ }
41
+ return ""
42
+ }
43
+
44
+ /**
45
+ * 原生 → 页面:`javaScriptProxy` 的 `poll`(同步调用,返回待执行脚本的 JSON 数组)
46
+ *
47
+ * 页面侧 `setInterval` 每 10ms 拉一次(见 `ARKWEB_BRIDGE_JS` 的 pump)——
48
+ * 这样框架线程不需要定时器、也不需要碰 ArkUI。
49
+ */
50
+ @When[env == "ohos"]
51
+ public func arkWebProxyPoll(label: String): String {
52
+ match (ArkWebRuntime.hostFor(label)) {
53
+ case Some(h) => return h.pollScriptsJson()
54
+ case None => return "[]"
55
+ }
56
+ }
@@ -0,0 +1,154 @@
1
+ package cjTauri
2
+
3
+ /**
4
+ * 鸿蒙 ArkWeb 侧的页面桥脚本与注入片段
5
+ *
6
+ * ## 为什么要另存一份(而不是复用 `native/core/bridge_js.h`)
7
+ *
8
+ * 那一份是 **C 宏**(`CJ_BRIDGE_JS(POST_STMT, RELOAD_STMT)`),只有编 C 桥的平台能用;
9
+ * 鸿蒙没有 C 桥(宿主在仓颉侧直接驱动 ArkWeb),拿不到宏展开结果。所以这里按同一份语义
10
+ * 落成仓颉常量——**改动要两边同步**,判断「哪边漏了」的判据是:页面上 `invoke` / `emit` /
11
+ * `listen` / `reload` / `_dispatch` 五个入口的行为是否与桌面一致(桥只负责路由与回执匹配,
12
+ * 不含业务)。
13
+ *
14
+ * ## 与桌面版的唯一差异
15
+ *
16
+ * `post`:桌面走 `window.chrome.webview.postMessage` / `window.webkit.messageHandlers`,
17
+ * 鸿蒙走 `javaScriptProxy` 注入的同步方法(`window.__CJ_TAURI_HOST__.post(json)`)。
18
+ * 同步调用不携带返回值语义——它是「投递即返回」,回执仍由 `_dispatch` 从仓颉侧推回来。
19
+ *
20
+ * ## 转义口径
21
+ *
22
+ * 用 raw string(`#"..."#`):三引号字符串会先处理反斜杠转义(AGENTS §4 踩过,内联 JS 会被
23
+ * 拆断而 stderr 一行提示都没有),raw string 原样保留。因此本文件里的 JS **不要**出现 `"#`
24
+ * 这个序列(那是 raw string 的收尾)。
25
+ */
26
+ @When[env == "ohos"]
27
+ public let ARKWEB_PROXY_NAME: String = "__CJ_TAURI_HOST__"
28
+
29
+ @When[env == "ohos"]
30
+ let ARKWEB_BRIDGE_JS: String = #"window.__CJ_TAURI__ = (function () {
31
+ var seq = 0;
32
+ var pending = {};
33
+ var listeners = {};
34
+ var coalesceSlots = Object.create(null);
35
+ var coalesceTimer = null;
36
+ var coalesceRaf = 0;
37
+ function flushCoalesced() {
38
+ if (coalesceTimer !== null) { clearTimeout(coalesceTimer); coalesceTimer = null; }
39
+ coalesceRaf = 0;
40
+ var due = coalesceSlots;
41
+ coalesceSlots = Object.create(null);
42
+ for (var name in due) {
43
+ var arr = listeners[name] || [];
44
+ for (var i = 0; i < arr.length; i++) arr[i](due[name]);
45
+ }
46
+ }
47
+ function scheduleCoalesced() {
48
+ if (coalesceTimer !== null || coalesceRaf) return;
49
+ coalesceTimer = setTimeout(flushCoalesced, 50);
50
+ }
51
+ var cachedLabel = null;
52
+ function labelOf() {
53
+ if (cachedLabel === null) {
54
+ var v = (typeof window !== 'undefined') ? window.__CJ_TAURI_LABEL__ : null;
55
+ cachedLabel = (typeof v === 'string' && v.length > 0) ? v : 'main';
56
+ }
57
+ return cachedLabel;
58
+ }
59
+ function post(obj) {
60
+ try {
61
+ var host = window.__CJ_TAURI_HOST__;
62
+ if (host && typeof host.post === 'function') host.post(JSON.stringify(obj));
63
+ } catch (e) { }
64
+ }
65
+ /*
66
+ * 原生 -> 页面的推送:页面**只负责叫一声**,脚本由原生在 post 回调里用
67
+ * `controller.runJavaScript` 推回来。
68
+ *
69
+ * 为什么不是「页面 poll 取返回值」:2026-10-19 真机实测,`javaScriptProxy` 的
70
+ * funcList 返回值**传不回 JS**(换上无条件返回固定串的桩,页面拿到的仍是 null),
71
+ * 而 post 方向一切正常。所以同步取数模型不成立,只能反过来推。
72
+ * 心跳周期的代价只是一次同步调用;队列空时原生什么都不做。
73
+ */
74
+ var pumping = false;
75
+ function pump() {
76
+ if (pumping) return;
77
+ pumping = true;
78
+ try {
79
+ var host = window.__CJ_TAURI_HOST__;
80
+ if (host && typeof host.post === 'function') {
81
+ host.post('{"type":"pull","window":' + JSON.stringify(labelOf()) + '}');
82
+ }
83
+ } catch (e2) { } finally { pumping = false; }
84
+ }
85
+ try { setInterval(pump, 50); } catch (e3) { }
86
+ return {
87
+ invoke: function (cmd, args) {
88
+ var id = ++seq;
89
+ var p = new Promise(function (resolve, reject) { pending[id] = { resolve: resolve, reject: reject }; });
90
+ post({ type: 'invoke', id: id, cmd: cmd, args: args || {}, window: labelOf() });
91
+ return p;
92
+ },
93
+ listen: function (event, cb) {
94
+ if (!listeners[event]) listeners[event] = [];
95
+ listeners[event].push(cb);
96
+ return function () {
97
+ var arr = listeners[event] || [];
98
+ var i = arr.indexOf(cb);
99
+ if (i >= 0) arr.splice(i, 1);
100
+ };
101
+ },
102
+ emit: function (event, payload) {
103
+ var id = ++seq;
104
+ var p = new Promise(function (resolve, reject) { pending[id] = { resolve: resolve, reject: reject }; });
105
+ post({ type: 'emit', id: id, event: event, payload: payload === undefined ? {} : payload, window: labelOf() });
106
+ return p;
107
+ },
108
+ reload: function () {
109
+ post({ type: 'reload', window: labelOf() });
110
+ },
111
+ _dispatch: function (msg) {
112
+ if (!msg) return;
113
+ if (msg.type === 'resolve') {
114
+ var p = pending[msg.id];
115
+ if (!p) return;
116
+ delete pending[msg.id];
117
+ if (msg.ok) p.resolve(msg.data); else p.reject(new Error(msg.error || 'invoke failed'));
118
+ } else if (msg.type === 'event') {
119
+ if (msg.window && msg.window !== labelOf()) return;
120
+ if (msg.coalesce) {
121
+ coalesceSlots[msg.event] = msg.payload;
122
+ scheduleCoalesced();
123
+ return;
124
+ }
125
+ var arr = listeners[msg.event] || [];
126
+ for (var i = 0; i < arr.length; i++) arr[i](msg.payload);
127
+ }
128
+ }
129
+ };
130
+ })();"#
131
+
132
+ /**
133
+ * 窗口 label 注入片段(等价于桌面 C 侧的 document-start 注入)
134
+ *
135
+ * 单引号包住 JSON 字符串:label 由应用给定,实际取值来自 `WindowConfig.label` / 默认 label,
136
+ * 里层用 JSON 转义保证引号与反斜杠安全(不做手工拼接转义)。
137
+ */
138
+ @When[env == "ohos"]
139
+ public func arkWebLabelScript(label: String): String {
140
+ return "window.__CJ_TAURI_LABEL__ = ${jsStringLiteral(label)};"
141
+ }
142
+
143
+ /**
144
+ * 把一段「已经是 JSON」的报文推给页面(不经 JSON 二次解析)
145
+ *
146
+ * 桌面两平台由 C 桥拼 `_dispatch(报文)` 并 ExecuteScript;这里产出同形状的脚本字符串,
147
+ * 由 UI 线程的组件交给 `controller.runJavaScript`。报文本身来自 `IpcHub` 的序列化结果,
148
+ * 所以这里**只包一层括号**、不做转义(做了会变成字符串字面量,页面收到的是文本而非对象)。
149
+ */
150
+ @When[env == "ohos"]
151
+ func arkWebDispatchScript(payloadJson: String): String {
152
+ return "window.__CJ_TAURI__ && window.__CJ_TAURI__._dispatch(${payloadJson});"
153
+ }
154
+