cj-tauri 1.1.2 → 2.0.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 (101) hide show
  1. package/README.md +64 -18
  2. package/cjpm.toml +52 -52
  3. package/cli/cjpm.toml +10 -1
  4. package/cli/src/doctor.cj +4 -2
  5. package/cli/src/main.cj +11 -3
  6. package/cli/src/manifest.cj +558 -0
  7. package/cli/src/project.cj +38 -22
  8. package/cli/src/scaffold.cj +369 -361
  9. package/cli/src/sync.cj +526 -0
  10. package/cli/src/util.cj +168 -159
  11. package/cli/templates/app/README.md +48 -80
  12. package/cli/templates/app/{capabilities → assets/capabilities}/default.json +7 -7
  13. package/cli/templates/app/{ui → assets/ui}/index.html +118 -118
  14. package/cli/templates/app/cj-tauri.toml +22 -0
  15. package/cli/templates/app/cjpm.toml +34 -17
  16. package/cli/templates/app/src/main.cj +90 -88
  17. package/cli/templates/app-react/README.md +56 -67
  18. package/cli/templates/app-react/{capabilities → assets/capabilities}/default.json +7 -7
  19. package/cli/templates/app-react/{ui → assets/ui}/package.json +19 -19
  20. package/cli/templates/app-react/{ui → assets/ui}/src/main.jsx +6 -6
  21. package/cli/templates/app-react/{ui → assets/ui}/vite.config.js +24 -24
  22. package/cli/templates/app-react/cj-tauri.toml +23 -0
  23. package/cli/templates/app-react/cjpm.toml +34 -17
  24. package/cli/templates/app-react/src/main.cj +91 -102
  25. package/cli/templates/app-vue/README.md +56 -60
  26. package/cli/templates/app-vue/{capabilities → assets/capabilities}/default.json +7 -7
  27. package/cli/templates/app-vue/{ui → assets/ui}/package.json +18 -18
  28. package/cli/templates/app-vue/{ui → assets/ui}/src/main.js +4 -4
  29. package/cli/templates/app-vue/{ui → assets/ui}/vite.config.js +24 -24
  30. package/cli/templates/app-vue/cj-tauri.toml +23 -0
  31. package/cli/templates/app-vue/cjpm.toml +34 -17
  32. package/cli/templates/app-vue/src/main.cj +91 -102
  33. package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/app.json5 +13 -13
  34. package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/element/string.json +8 -8
  35. package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/media/layered_image.json +6 -6
  36. package/cli/templates/{ohos-pc → ohos-skeleton}/README.md +79 -79
  37. package/cli/templates/{ohos-pc → ohos-skeleton}/build-profile.json5 +46 -46
  38. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/build-profile.json5 +26 -26
  39. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/cjpm.toml +43 -43
  40. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/hvigorfile.ts +6 -6
  41. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/oh-package.json5 +9 -9
  42. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/cangjie/ability_stage.cj +17 -17
  43. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/cangjie/app_host.cj +94 -94
  44. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/cangjie/index.cj +167 -167
  45. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/cangjie/main_ability.cj +139 -139
  46. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/module.json5 +49 -49
  47. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/element/color.json +8 -8
  48. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/element/string.json +16 -16
  49. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/layered_image.json +6 -6
  50. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/profile/main_pages.json +2 -2
  51. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/rawfile/capabilities.json +6 -6
  52. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/rawfile/index.html +92 -92
  53. package/cli/templates/{ohos-pc → ohos-skeleton}/hvigor/hvigor-config.json5 +5 -5
  54. package/cli/templates/{ohos-pc → ohos-skeleton}/hvigorfile.ts +6 -6
  55. package/cli/templates/{ohos-pc → ohos-skeleton}/oh-package.json5 +10 -10
  56. package/package.json +4 -2
  57. package/prebuilt/win32-x64/cj-tauri.exe +0 -0
  58. package/src/app.cj +49 -20
  59. package/src/app_spec.cj +76 -0
  60. package/src/arkweb_script.cj +142 -154
  61. package/src/host.cj +20 -17
  62. package/src/host_arkweb.cj +778 -740
  63. package/src/host_webkit.cj +22 -0
  64. package/src/host_webview2.cj +22 -0
  65. package/src/launch.cj +62 -0
  66. package/src/platform/platform_app_context.cj +77 -0
  67. package/src/platform/platform_assets.cj +33 -0
  68. package/src/platform/platform_discover.cj +176 -0
  69. package/src/platform/platform_env.cj +29 -0
  70. package/src/platform/platform_feature.cj +31 -0
  71. package/src/platform/platform_page_source.cj +87 -0
  72. package/src/platform/platform_target_info.cj +32 -0
  73. package/src/platform_host_provider.cj +87 -0
  74. package/src/plugin.cj +36 -2
  75. package/src/plugins/clipboard/plugin_clipboard.cj +8 -0
  76. package/src/plugins/menu/plugin_menu.cj +158 -150
  77. package/src/plugins/notification/plugin_notification.cj +8 -0
  78. package/src/plugins/serial/plugin_serial.cj +454 -442
  79. package/src/plugins/serial/serial_bridge.cj +194 -186
  80. package/src/plugins/serial/serial_ohos_stub.cj +61 -0
  81. package/src/plugins/shell/plugin_shell.cj +258 -230
  82. package/src/plugins/sqlite/plugin_sqlite.cj +710 -697
  83. package/src/plugins/sqlite/sqlite_ffi.cj +211 -206
  84. package/src/plugins/sqlite/sqlite_ohos_stub.cj +91 -0
  85. package/src/tests/app_spec_test.cj +127 -0
  86. package/src/tests/fake_host.cj +23 -8
  87. package/src/tests/platform_contract_test.cj +251 -0
  88. package/src/tests/plugin_test.cj +585 -542
  89. package/src/version.cj +1 -1
  90. package/src/host_factory.cj +0 -48
  91. /package/cli/templates/app-react/{ui → assets/ui}/index.html +0 -0
  92. /package/cli/templates/app-react/{ui → assets/ui}/src/App.css +0 -0
  93. /package/cli/templates/app-react/{ui → assets/ui}/src/App.jsx +0 -0
  94. /package/cli/templates/app-vue/{ui → assets/ui}/index.html +0 -0
  95. /package/cli/templates/app-vue/{ui → assets/ui}/src/App.vue +0 -0
  96. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/media/background.png +0 -0
  97. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/media/foreground.png +0 -0
  98. /package/cli/templates/{ohos-pc → ohos-skeleton}/build-hap.sh +0 -0
  99. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/background.png +0 -0
  100. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/foreground.png +0 -0
  101. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/startIcon.png +0 -0
@@ -1,740 +1,778 @@
1
- package cjTauri
2
-
3
- /**
4
- * 鸿蒙 ArkWeb 宿主(`@When[env == "ohos"]` 门禁)
5
- *
6
- * ## 为什么判据是 `env == "ohos"`
7
- *
8
- * 鸿蒙目标下 `os` 仍报 `Linux`(探针实测),与桌面 Linux 撞车;`arch` 只能把 x86_64 桌面
9
- * 与 aarch64 分开,可桌面 aarch64 Linux 一样是 `aarch64`——用它当判据会把「跑在 GTK 上」
10
- * 误判成「跑在 ArkWeb 上」。官方条件编译映射表给的是 `env`:`aarch64-linux-ohos` → `env == "ohos"`,
11
- * 桌面 Linux 是 `gnu`,两者才真正互斥(`docs/` 里的条件编译文档「目标平台和条件编译映射表」)。
12
- *
13
- * ## 为什么 ArkUI 组件不在这里
14
- *
15
- * 框架核心包一旦 import `kit.ArkUI` / `ohos.arkui.*`,**所有**消费方(含桌面构建)都要跟着
16
- * 拉 ArkUI 依赖。所以本文件只做「与 UI 无关的那半边」:页面来源定稿、脚本队列、IPC 接线、
17
- * 生命周期标志;建 `Web` 组件的那半边在应用侧(`ArkWebView` 样板),经 [ArkWebRuntime] 取用。
18
- *
19
- * ## 线程模型(与桌面两平台的根本差异)
20
- *
21
- * 桌面:`start()` 里阻塞着跑宿主事件循环,`waitForExit()` 等它退出。
22
- * 鸿蒙:ArkUI 的事件循环归系统所有(UI 线程),`start()` **不能**阻塞——它只登记页面来源,
23
- * 由 UI 线程上的 `Web` 组件在 build 时取走。于是 `TauriApp.run()` 必须跑在非 UI 线程
24
- * (应用在 Ability 里 `spawn`),UI 线程负责建树。
25
- *
26
- * 跨线程只走两条路:① 本文件的队列(`Mutex` 保护)② 页面消息经 `javaScriptProxy`
27
- * 同步进来。**不要在应用线程直接调 ArkWeb/ArkUI**——与桌面 Linux 侧
28
- * 「GTK/WebKit 调用必须在原生 pthread 内」同源的约束。
29
- */
30
-
31
- @When[env == "ohos"]
32
- import std.collection.*
33
-
34
- @When[env == "ohos"]
35
- import std.fs.File
36
-
37
- @When[env == "ohos"]
38
- import std.sync.*
39
-
40
- @When[env == "ohos"]
41
- import stdx.encoding.json.JsonArray
42
-
43
- @When[env == "ohos"]
44
- import stdx.encoding.json.JsonString
45
-
46
- /**
47
- * 鸿蒙宿主的进程级运行时:沙箱目录、各窗口宿主、跨线程队列开关
48
- *
49
- * 应用要做两件事(都是「框架拿不到、只有 Ability 有」的东西):
50
- * 1. 在 `onCreate` 里 `ArkWebRuntime.configure(context.filesDir)`——`run(html)` 没有
51
- * `loadData` 可用(ArkWeb 的仓颉绑定没有这个 API),内联 HTML 只能写进沙箱再以 `file://` 加载;
52
- * 2. 在 `onWindowStageCreate` 里 `windowStage.loadContent("EntryView")`,并在**非 UI 线程**
53
- * `spawn { app.run(html) }`——UI 线程要让给 ArkUI 建树。
54
- */
55
- @When[env == "ohos"]
56
- public class ArkWebRuntime {
57
- /* 应用沙箱目录(filesDir 下的子目录由本类自己创建)。空串 = 尚未 configure。 */
58
- static var sandboxDir: String = ""
59
-
60
- /* label → 宿主。UI 组件按 label 取自己的那一份;多窗口下不许共用一个实例。 */
61
- static let hosts: HashMap<String, ArkWebHost> = HashMap<String, ArkWebHost>()
62
-
63
- /* 应用级退出标志:鸿蒙的「关窗」等于「退出 Ability」,只能由应用侧执行 terminateSelf。 */
64
- static var quitRequested: Bool = false
65
-
66
- /* 队列锁:worker 线程写、UI 线程读(见文件头的线程模型)。 */
67
- static let queueLock: Mutex = Mutex()
68
-
69
- /**
70
- * 配置沙箱目录(必须在 `TauriApp.run()` 之前调用,且只调一次)
71
- *
72
- * 用 `context.filesDir`:鸿蒙上它是应用私有可写目录,进程重启后内容仍在,
73
- * 于是页面文件可以在**每次启动**重建(内容由应用决定,不做持久化依赖)。
74
- */
75
- public static func configure(filesDir: String): Unit {
76
- if (filesDir.size == 0) {
77
- eprintln("[cj-tauri] ArkWebRuntime.configure: filesDir is empty, run(html) will be rejected")
78
- return
79
- }
80
- ArkWebRuntime.sandboxDir = filesDir
81
- eprintln("[cj-tauri] ArkWeb sandbox: ${filesDir}")
82
- }
83
-
84
- /** 登记一个宿主(构造时调;同一 label 重复登记=替换,与进程级唯一窗口的语义一致) */
85
- static func register(host: ArkWebHost): Unit {
86
- synchronized(ArkWebRuntime.queueLock) {
87
- ArkWebRuntime.hosts[host.label()] = host
88
- }
89
- }
90
-
91
- /** 注销(dispose 时调;对应的 UI 组件随后会被系统回收) */
92
- static func unregister(label: String): Unit {
93
- synchronized(ArkWebRuntime.queueLock) {
94
- ArkWebRuntime.hosts.remove(label)
95
- }
96
- }
97
-
98
- /**
99
- * 按 label 取宿主:**UI 侧(`ArkWebView` 组件)唯一的取用入口**
100
- *
101
- * 返回 None 是正常状态(应用先建树、`app.run()` 稍后才登记宿主),组件据此显示空白,
102
- * 下一轮轮询再取——不要在这里报错。
103
- */
104
- public static func hostFor(label: String): Option<ArkWebHost> {
105
- synchronized(ArkWebRuntime.queueLock) {
106
- match (ArkWebRuntime.hosts.get(label)) {
107
- case Some(h) => return Some(h)
108
- case None => return None
109
- }
110
- }
111
- }
112
-
113
- /** 应用级退出请求(`quit()` / 窗口销毁都置它;Ability 侧轮询后自己 `terminateSelf()`) */
114
- public static func requestQuit(): Unit {
115
- ArkWebRuntime.quitRequested = true
116
- }
117
-
118
- /** 是否已请求退出(应用在 Ability 里轮询它来决定要不要 terminateSelf) */
119
- public static func shouldQuit(): Bool {
120
- return ArkWebRuntime.quitRequested
121
- }
122
- }
123
-
124
- /**
125
- * 一条待应用侧呈现的原生消息框(`dialog:message` 命令的载荷)
126
- *
127
- * 为什么要过一道队列:ArkUI 的弹窗 `getUIContext().showAlertDialog(...)` 只能在**应用侧组件**
128
- * 里、且在 UI 线程上调(框架不碰 ArkUI,见文件头),而命令 handler 跑在 worker 线程上。
129
- * 于是框架只描述「弹什么」,呈现与点击结果交给 UI 侧回填。
130
- *
131
- * `kind` 原样透传(`dialog` 插件的语义:`info` / `warning` / `error` 只有一个「确定」,
132
- * `confirm` 才有取消)。按钮文案与布局属于呈现,由应用侧决定,框架不猜。
133
- */
134
- @When[env == "ohos"]
135
- public class ArkWebDialogRequest {
136
- public let id: Int64
137
- public let kind: String
138
- public let title: String
139
- public let message: String
140
-
141
- public init(id: Int64, kind: String, title: String, message: String) {
142
- this.id = id
143
- this.kind = kind
144
- this.title = title
145
- this.message = message
146
- }
147
-
148
- /** 是否「确定 / 取消」两键(其余 kind 只有一个确定键,弹出即成立) */
149
- public func isConfirm(): Bool {
150
- return this.kind == "confirm"
151
- }
152
- }
153
-
154
- /**
155
- * `showMessageDialog` 等 UI 侧回填的上限(毫秒)
156
- *
157
- * 桌面两平台在这里真的阻塞到用户点按钮,鸿蒙上只能等 UI 线程来取。窗口没建、页面没跑
158
- * 的情况下无限期钉住一个 worker 线程比超时更糟,所以给一个宽上限:超时就如实返回 false
159
- * 并在 stderr 里说清是「没弹出来」而不是「用户取消」——两者对调用方都是 false,但日志分得清。
160
- */
161
- @When[env == "ohos"]
162
- let CJ_ARKWEB_DIALOG_WAIT_MS: Int64 = 120000
163
-
164
- /** 等回填的轮询间隔(毫秒):只影响「点了按钮 → 命令返回」的延迟,与页面心跳无关 */
165
- @When[env == "ohos"]
166
- let CJ_ARKWEB_DIALOG_POLL_MS: Int64 = 20
167
-
168
- /**
169
- * 一个鸿蒙窗口的宿主
170
- *
171
- * 与桌面宿主的分工差异:这里**所有**图形相关动作都只是「入队 / 记状态」,真正执行发生在
172
- * UI 线程(`ArkWebView` 组件的 build 与定时器里)。这样框架侧不需要任何 ArkUI 符号,
173
- * 也不会在 worker 线程上碰 ArkUI(那会 abort,见 AGENTS §4 的栈边界坑)。
174
- */
175
- @When[env == "ohos"]
176
- public class ArkWebHost <: WebViewHost {
177
- private let windowLabel: String
178
-
179
- /* 页面来源:start(html) 先存 html、落盘后 pageUrl 才成立;startUrl 直接定 pageUrl。 */
180
- private var pageHtml: String = ""
181
- private var pageUrl: String = ""
182
-
183
- /* document-start 预执行脚本(插件 shim 与 label 注入走这条通道,顺序敏感)。 */
184
- private var initScripts: ArrayList<String> = ArrayList<String>()
185
-
186
- /* 待 UI 线程执行的脚本(runJs / 注入 / 回投都进这里,见 takePendingScripts)。 */
187
- private var pendingScripts: ArrayList<String> = ArrayList<String>()
188
-
189
- private var devToolsEnabled: Bool = false
190
- private var visible: Bool = true
191
- private var disposed: Bool = false
192
- private var quitFlag: Bool = false
193
-
194
- /* 取证计数:页面开始 / 结束加载各几次,桥日志按它对账(AGENTS §1 门禁 2)。 */
195
- private var pageBeginCount: Int64 = 0
196
- private var pageEndCount: Int64 = 0
197
- private var jsInCount: Int64 = 0
198
-
199
- /*
200
- * 原生对话框(showMessageDialog)的三个槽,全部受 ArkWebRuntime.queueLock 保护:
201
- * worker 线程入队并等待、UI 线程取走并回填,跨线程只经这两个动作交接。
202
- * id 单调自增(每窗一份),所以「晚到的答案」撞不上新请求。
203
- */
204
- private var dialogSeq: Int64 = 0
205
- private let pendingDialogs: ArrayList<ArkWebDialogRequest> = ArrayList<ArkWebDialogRequest>()
206
- private let awaitingDialogs: HashSet<Int64> = HashSet<Int64>()
207
- private let dialogAnswers: HashMap<Int64, Bool> = HashMap<Int64, Bool>()
208
- private var dialogRequestCount: Int64 = 0
209
- private var dialogAnswerCount: Int64 = 0
210
-
211
- private var messageHandler: (String) -> Unit = { _ => }
212
- private var shellHandler: (String) -> Unit = { _ => }
213
- private var destroyHandler: () -> Unit = { => }
214
-
215
- public init(label: String) {
216
- this.windowLabel = label
217
- ArkWebRuntime.register(this)
218
- eprintln("[cj-tauri] ArkWeb host created: label=\"${label}\"")
219
- }
220
-
221
- public func label(): String {
222
- return this.windowLabel
223
- }
224
-
225
- /**
226
- * 登记内联 HTML 页面(不阻塞:真正的组件由 UI 线程建)
227
- *
228
- * 与桌面不同,这里**没有** `loadData` 可调(ArkWeb 的仓颉绑定只给了 `loadUrl`),
229
- * 所以 HTML 先落盘到沙箱、再以 `file://` 交给 `Web` 组件。副作用要如实说:页面有了
230
- * 真实来源(`location.protocol == "file:"`),`fetch` / XHR 会被同源策略挡住——
231
- * 与 Tauri 2 在 `run(html)` 下的表现一致,不是本实现独有的限制。
232
- */
233
- public func start(html: String): Unit {
234
- this.pageHtml = html
235
- this.pageUrl = this.writeHtmlToSandbox(html)
236
- eprintln("[cj-tauri] ArkWeb page: label=\"${this.windowLabel}\" source=inline url=\"${this.pageUrl}\"")
237
- }
238
-
239
- /**
240
- * 登记 URL 页面(http(s):// 或 file://)
241
- *
242
- * 这条路径有真实来源,相对资源 / 动态 import / 前端路由都成立;`CJ_TAURI_DEV_URL`
243
- * 的 dev server 就靠它。注入时机见 `injectInitScriptsOnBegin` 的说明。
244
- */
245
- public func startUrl(url: String): Unit {
246
- this.pageHtml = ""
247
- this.pageUrl = url
248
- eprintln("[cj-tauri] ArkWeb page: label=\"${this.windowLabel}\" source=url url=\"${url}\"")
249
- }
250
-
251
- /**
252
- * 注册预执行脚本(必须早于 start / startUrl,与桌面同一契约)
253
- *
254
- * 内联 HTML 走「拼进 head 最前」这条通道(真正早于页面脚本);URL 页面只能等
255
- * `onPageBegin` 再 runJavaScript——那时页面顶层脚本可能已经跑过,**这是本平台的已知限制**,
256
- * 不是静默降级:首次注入会在 stderr 说明一次(见 `noteLateInjection`)。
257
- */
258
- public func addInitScript(js: String): Unit {
259
- if (js.size == 0) {
260
- return
261
- }
262
- this.initScripts.add(js)
263
- }
264
-
265
- public func reload(): Unit {
266
- this.enqueueScript("__cjTauriReloadRequest()", true)
267
- }
268
-
269
- public func setWindowConfig(config: WindowConfig): Unit {
270
- // 鸿蒙应用窗口由 Ability 的 WindowStage 决定,逻辑尺寸概念与桌面不同:
271
- // 这里如实说明不生效,而不是假装设成功(能力位也不含任何窗口控制位)。
272
- let _ = config
273
- eprintln("[cj-tauri] ArkWeb: window config is managed by WindowStage on this platform (ignored)")
274
- }
275
-
276
- public func setDevTools(enabled: Bool): Unit {
277
- this.devToolsEnabled = enabled
278
- // 真正调 setWebDebuggingAccess 的时机在 UI 侧(controller 就绪之后)。
279
- eprintln("[cj-tauri] ArkWeb: web debugging requested: ${enabled}")
280
- }
281
-
282
- public func openDevTools(): Unit {
283
- // ArkWeb 的调试走「设备侧 Web 调试」通道,没有桌面那种独立 DevTools 窗口。
284
- eprintln("[cj-tauri] ArkWeb: openDevTools is not available on this platform (use web debugging)")
285
- }
286
-
287
- /* ---- 以下宿主能力位为 0:一律如实拒绝,不假装成功(AGENTS §2 的 scaffold 纪律) ---- */
288
-
289
- public func setMenu(wire: String): Unit {
290
- let _ = wire
291
- eprintln("[cj-tauri] ArkWeb: menu unsupported (host capability bit is 0)")
292
- }
293
-
294
- public func setMenuItemState(id: String, enabled: Bool, checked: Bool): Unit {
295
- let _ = id
296
- let _ = enabled
297
- let _ = checked
298
- }
299
-
300
- public func setTitlebarSubtitle(text: String): Unit {
301
- let _ = text
302
- }
303
-
304
- public func setDragDropPolicy(enabled: Bool, files: Bool): Unit {
305
- let _ = enabled
306
- let _ = files
307
- eprintln("[cj-tauri] ArkWeb: drag/drop policy unsupported (host capability bit is 0)")
308
- }
309
-
310
- public func setGlobalHotkeys(wire: String): Unit {
311
- let _ = wire
312
- }
313
-
314
- public func setTray(wire: String): Unit {
315
- let _ = wire
316
- }
317
-
318
- public func setWindowVisible(visible: Bool): Unit {
319
- this.visible = visible
320
- // 可见性由 ArkUI 的组件树决定(`visibility` 属性),宿主只记状态供 UI 侧读取。
321
- eprintln("[cj-tauri] ArkWeb: window visible -> ${visible}")
322
- }
323
-
324
- public func setMinimizeToTray(enabled: Bool): Unit {
325
- let _ = enabled
326
- }
327
-
328
- /** 能力位全 0:鸿蒙侧还没有任何一项宿主级能力通过实机取证(AGENTS §1 门禁 4) */
329
- public func hostCapabilities(): Int32 {
330
- return 0
331
- }
332
-
333
- public func setShellHandler(handler: (String) -> Unit): Unit {
334
- this.shellHandler = handler
335
- }
336
-
337
- public func setDestroyHandler(handler: () -> Unit): Unit {
338
- this.destroyHandler = handler
339
- }
340
-
341
- public func showFileDialog(save: Bool, title: String, filter: String): String {
342
- let _ = save
343
- let _ = title
344
- let _ = filter
345
- eprintln("[cj-tauri] ArkWeb: native file dialog unsupported (needs a picker module)")
346
- return ""
347
- }
348
-
349
- public func showFileDialogPreset(save: Bool, title: String, filter: String, defaultName: String): String {
350
- let _ = defaultName
351
- return this.showFileDialog(save, title, filter)
352
- }
353
-
354
- public func showFolderDialog(title: String): String {
355
- let _ = title
356
- eprintln("[cj-tauri] ArkWeb: native folder dialog unsupported (needs a picker module)")
357
- return ""
358
- }
359
-
360
- /**
361
- * 原生消息框(`dialog:message` 命令的落点)
362
- *
363
- * 与桌面的差异:桌面在 C 桥里**同步阻塞**到用户点按钮;鸿蒙的弹窗只有 ArkUI 侧能做,
364
- * 所以这里退成「入队 → 有界轮询等 UI 侧回填」。等待发生在命令 worker 线程上
365
- * (命令分发本来就是异步的,见 AGENTS §2),不占 UI 线程,窗口照常响应。
366
- *
367
- * 返回 false 的三种情况要在日志里分得清:宿主已销毁(拒绝)、UI 侧没来取(超时)、
368
- * 用户点了取消(answered + confirmed=false)。
369
- */
370
- public func showMessageDialog(kind: String, title: String, message: String): Bool {
371
- if (this.disposed) {
372
- eprintln("[cj-tauri] ArkWeb dialog rejected: label=\"${this.windowLabel}\" (host disposed)")
373
- return false
374
- }
375
- var id: Int64 = 0
376
- synchronized(ArkWebRuntime.queueLock) {
377
- this.dialogSeq += 1
378
- id = this.dialogSeq
379
- this.dialogRequestCount += 1
380
- this.pendingDialogs.add(ArkWebDialogRequest(id, kind, title, message))
381
- this.awaitingDialogs.add(id)
382
- }
383
- eprintln(
384
- "[cj-tauri] ArkWeb dialog queued: label=\"${this.windowLabel}\" id=${id} kind=\"${kind}\" title=\"${title}\""
385
- )
386
- var waited: Int64 = 0
387
- while (waited < CJ_ARKWEB_DIALOG_WAIT_MS) {
388
- var answered = false
389
- var confirmed = false
390
- synchronized(ArkWebRuntime.queueLock) {
391
- if (this.dialogAnswers.contains(id)) {
392
- confirmed = this.dialogAnswers[id]
393
- this.dialogAnswers.remove(id)
394
- answered = true
395
- }
396
- }
397
- if (answered) {
398
- eprintln(
399
- "[cj-tauri] ArkWeb dialog answered: label=\"${this.windowLabel}\" id=${id} confirmed=${confirmed}"
400
- )
401
- return confirmed
402
- }
403
- sleep(Duration.millisecond * CJ_ARKWEB_DIALOG_POLL_MS)
404
- waited += CJ_ARKWEB_DIALOG_POLL_MS
405
- }
406
- synchronized(ArkWebRuntime.queueLock) {
407
- this.awaitingDialogs.remove(id)
408
- this.dropPendingDialog(id)
409
- this.dialogAnswers.remove(id)
410
- }
411
- eprintln(
412
- "[cj-tauri] ArkWeb dialog timed out: label=\"${this.windowLabel}\" id=${id} after ${CJ_ARKWEB_DIALOG_WAIT_MS}ms (UI side never answered; this is NOT a user cancel)"
413
- )
414
- return false
415
- }
416
-
417
- /**
418
- * 取一条待呈现的对话框(**只在 UI 线程调**:`ArkWebView` 组件的页面心跳回调里)
419
- *
420
- * 取走即从队列移除,所以同一个请求不会弹两次;返回 None 是绝大多数心跳的正常结果。
421
- */
422
- public func takePendingDialog(): Option<ArkWebDialogRequest> {
423
- synchronized(ArkWebRuntime.queueLock) {
424
- if (this.pendingDialogs.isEmpty()) {
425
- return None
426
- }
427
- let request = this.pendingDialogs[0]
428
- this.pendingDialogs.remove(at: 0)
429
- return Some(request)
430
- }
431
- }
432
-
433
- /**
434
- * 回填用户的选择(**只在 UI 线程调**:弹窗按钮的回调里)
435
- *
436
- * 只接受仍在等待的 id:超时之后等待方已经放弃,这时再记一个答案会把它一直挂在这个槽里
437
- * (没人取走),所以按 `awaitingDialogs` 校验一次,并在日志里说明被丢弃的那次点击。
438
- */
439
- public func answerDialog(id: Int64, confirmed: Bool): Unit {
440
- var accepted = false
441
- synchronized(ArkWebRuntime.queueLock) {
442
- if (this.awaitingDialogs.contains(id)) {
443
- this.awaitingDialogs.remove(id)
444
- this.dialogAnswers[id] = confirmed
445
- this.dialogAnswerCount += 1
446
- accepted = true
447
- }
448
- }
449
- if (accepted) {
450
- eprintln(
451
- "[cj-tauri] ArkWeb dialog answer: label=\"${this.windowLabel}\" id=${id} confirmed=${confirmed}"
452
- )
453
- return
454
- }
455
- eprintln(
456
- "[cj-tauri] ArkWeb dialog answer dropped: label=\"${this.windowLabel}\" id=${id} confirmed=${confirmed} (no longer awaiting)"
457
- )
458
- }
459
-
460
- /** 从待呈现队列里按 id 移除(超时清场用;调用方必须已持有 `queueLock`) */
461
- private func dropPendingDialog(id: Int64): Unit {
462
- var index: Int64 = 0
463
- while (index < this.pendingDialogs.size) {
464
- if (this.pendingDialogs[index].id == id) {
465
- this.pendingDialogs.remove(at: index)
466
- return
467
- }
468
- index += 1
469
- }
470
- }
471
-
472
- public func clipboardWriteText(text: String): Bool {
473
- let _ = text
474
- // 剪贴板在鸿蒙走 pasteboard 模块(`ohos.pasteboard`),尚未接入:如实 false,
475
- // 不返回 true 骗调用方(这正是「发后即忘返回 0 ≠ 发送成功」那条坑的同族)。
476
- eprintln("[cj-tauri] ArkWeb: clipboard write unsupported (pasteboard not wired yet)")
477
- return false
478
- }
479
-
480
- public func clipboardReadText(): Option<String> {
481
- return None
482
- }
483
-
484
- public func notify(title: String, body: String): Bool {
485
- let _ = title
486
- let _ = body
487
- eprintln("[cj-tauri] ArkWeb: notification unsupported (host capability bit is 0)")
488
- return false
489
- }
490
-
491
- /**
492
- * 在页面里执行一段脚本(框架的 `resolve` / `event` 回投就走这条)
493
- *
494
- * 不直接调 `controller.runJavaScript`:那个 API 属于 UI 线程,而本方法几乎总是从
495
- * IPC worker 线程进来(命令异步分发,见 AGENTS §2)。所以只入队,由 UI 侧的定时器取走。
496
- * 这样「回投顺序」也自然跟着队列走。
497
- */
498
- public func runJs(script: String): Unit {
499
- if (script.size == 0 || this.disposed) {
500
- return
501
- }
502
- this.enqueueScript(script, true)
503
- }
504
-
505
- /**
506
- * 请求退出
507
- *
508
- * 鸿蒙没有「关掉这个窗口」这种宿主动作:窗口归 Ability,退出得由应用 `terminateSelf()`。
509
- * 所以这里只置标志(`shouldQuit()` 与 `ArkWebRuntime.shouldQuit()` 都能读到),
510
- * 应用据此结束自己的生命周期;框架不假装自己关掉了窗口。
511
- */
512
- public func quit(): Unit {
513
- if (this.quitFlag) {
514
- return
515
- }
516
- this.quitFlag = true
517
- ArkWebRuntime.requestQuit()
518
- eprintln("[cj-tauri] ArkWeb: quit requested: label=\"${this.windowLabel}\" (app should terminateSelf)")
519
- }
520
-
521
- public func setMessageHandler(handler: (String) -> Unit): Unit {
522
- this.messageHandler = handler
523
- }
524
-
525
- public func shouldQuit(): Bool {
526
- return this.quitFlag || ArkWebRuntime.shouldQuit()
527
- }
528
-
529
- public func dispose(): Unit {
530
- if (this.disposed) {
531
- return
532
- }
533
- this.disposed = true
534
- ArkWebRuntime.unregister(this.windowLabel)
535
- eprintln(
536
- "[cj-tauri] ArkWeb host disposed: label=\"${this.windowLabel}\" pageBegin=${this.pageBeginCount} pageEnd=${this.pageEndCount} jsIn=${this.jsInCount} dialogRequested=${this.dialogRequestCount} dialogAnswered=${this.dialogAnswerCount}"
537
- )
538
- }
539
-
540
- /* ------------------------- UI 侧(ArkWebView 组件)取用面 ------------------------- */
541
-
542
- /**
543
- * 组件要加载的地址(空串 = 还没登记,组件应保持空白并下一轮再问)
544
- *
545
- * 为什么可能为空:应用先建 UI 树、再 `spawn { app.run(html) }`,两者先后不定。
546
- * 组件拿到空串不是错误,是正常的启动竞态,别在这里打日志刷屏。
547
- */
548
- public func currentUrl(): String {
549
- return this.pageUrl
550
- }
551
-
552
- /** 是否要求可见(组件据此设 `visibility`);隐藏态页面照常跑,还原入口由应用给 */
553
- public func isVisible(): Bool {
554
- return this.visible
555
- }
556
-
557
- /** 是否要求开启 Web 调试(组件在建 controller 后调 `setWebDebuggingAccess`) */
558
- public func devToolsWanted(): Bool {
559
- return this.devToolsEnabled
560
- }
561
-
562
- /**
563
- * 取走全部待执行脚本(**必须在 UI 线程调用**)
564
- *
565
- * 取走即清空:这些脚本的语义是「尽快执行一次」,重复执行会把 resolve 投两次
566
- * (页面侧 pending 表按 id 匹配,重复投递会让第二次变空操作,但语义仍应保持一次)。
567
- */
568
- public func takePendingScripts(): ArrayList<String> {
569
- let out = ArrayList<String>()
570
- synchronized(ArkWebRuntime.queueLock) {
571
- for (s in this.pendingScripts) {
572
- out.add(s)
573
- }
574
- this.pendingScripts.clear()
575
- }
576
- return out
577
- }
578
-
579
- /**
580
- * 取走 init 脚本(页面开始加载时调一次:内联 HTML 已拼进 head,这里的只用于 URL 页面)
581
- *
582
- * 不做「取走即清空」:URL 页面的 reload 后仍需要同一批 shim,清空会让第二次加载丢掉插件。
583
- */
584
- public func initScriptsSnapshot(): ArrayList<String> {
585
- let out = ArrayList<String>()
586
- synchronized(ArkWebRuntime.queueLock) {
587
- for (s in this.initScripts) {
588
- out.add(s)
589
- }
590
- }
591
- return out
592
- }
593
-
594
- /**
595
- * 页面轮询泵的原生端点(`javaScriptProxy` 的 `poll` 方法,UI 线程同步调用)
596
- *
597
- * 为什么是「页面拉」而不是「原生推」:ArkUI 的仓颉绑定没有定时器、也没有 UIContext,
598
- * 框架线程既定不了时也不能安全碰 ArkUI(AGENTS §4 的栈边界坑);而页面里 `setInterval`
599
- * 是最普通的东西。于是把「什么时候执行待发脚本」交给页面,原生只做同步的取队列动作。
600
- *
601
- * 返回值是**脚本字符串的 JSON 数组**(不是 JSON 报文):页面用间接 eval 逐条执行,
602
- * 这样 `_dispatch({...})` 这类语句能直接跑在全局作用域,而不是变成字符串。
603
- * 空队列返回 `[]`——页面侧据此连 JSON.parse 都不做。
604
- */
605
- public func pollScriptsJson(): String {
606
- synchronized(ArkWebRuntime.queueLock) {
607
- if (this.pendingScripts.isEmpty()) {
608
- return "[]"
609
- }
610
- let arr = JsonArray()
611
- for (s in this.pendingScripts) {
612
- arr.add(JsonString(s))
613
- }
614
- this.pendingScripts.clear()
615
- return arr.toString()
616
- }
617
- }
618
-
619
- /** 页面开始加载(UI 侧回调;用于注入与取证计数) */
620
- public func notePageBegin(): Unit {
621
- synchronized(ArkWebRuntime.queueLock) {
622
- this.pageBeginCount += 1
623
- }
624
- eprintln("[cj-tauri] ArkWeb page begin: label=\"${this.windowLabel}\" count=${this.pageBeginCount}")
625
- }
626
-
627
- /** 页面加载结束(UI 侧回调);`system:host` 的取证就看这两个计数是否对得上 */
628
- public func notePageEnd(): Unit {
629
- synchronized(ArkWebRuntime.queueLock) {
630
- this.pageEndCount += 1
631
- }
632
- eprintln("[cj-tauri] ArkWeb page end: label=\"${this.windowLabel}\" count=${this.pageEndCount}")
633
- }
634
-
635
- /**
636
- * 页面 → 原生:`javaScriptProxy` 的同步回调(UI 线程)
637
- *
638
- * 报文格式与桌面两平台**同一份**(`{type:"invoke"|"emit", id, cmd, event, window, ...}`),
639
- * 所以 IPC 层不需要为鸿蒙分叉。这里只做计数与转发,不做校验——校验在 `IpcHub`。
640
- */
641
- public func acceptPageMessage(raw: String): Unit {
642
- if (raw.size == 0 || this.disposed) {
643
- return
644
- }
645
- // reload 是**控制消息、不进 IPC hub**:桌面两平台由 C 桥在投递前截掉(CJT_RELOAD_MSG),
646
- // 鸿蒙没有 C 桥这一层,拦截点就在这里。用子串判断而不是解析 JSON:报文由本框架自己的
647
- // 桥脚本产生,形状固定;解析失败会把 reload 静默丢掉,反而更难查。
648
- match (raw.indexOf("\"type\":\"reload\"")) {
649
- case Some(_) =>
650
- eprintln("[cj-tauri] ArkWeb: reload requested by page: label=\"${this.windowLabel}\"")
651
- this.reload()
652
- return
653
- case None => ()
654
- }
655
- // 拉取心跳同理:页面只是「叫一声」,真正的推送由 UI 侧(组件的 post 回调)用
656
- // runJavaScript 完成——`javaScriptProxy` 的返回值传不回页面(踩坑文档 8.1.5.1),
657
- // 所以这条报文不进 IPC hub,这里直接返回。
658
- match (raw.indexOf("\"type\":\"pull\"")) {
659
- case Some(_) => return
660
- case None => ()
661
- }
662
- synchronized(ArkWebRuntime.queueLock) {
663
- this.jsInCount += 1
664
- }
665
- this.messageHandler(raw)
666
- }
667
-
668
- /** 页面消息计数(取证:`js -> native` 次数对账,AGENTS §1 门禁 2) */
669
- public func messageCount(): Int64 {
670
- return this.jsInCount
671
- }
672
-
673
- /* ------------------------------ 内部辅助 ------------------------------ */
674
-
675
- /**
676
- * 入队一段脚本
677
- *
678
- * `direct` 只是留给调用点自说明用的(两处语义相同,暂不分支);保留它是因为将来
679
- * 「document-start 注入」与「普通 runJs」可能需要不同的排队策略(前者要在 onPageBegin 之前)。
680
- */
681
- private func enqueueScript(script: String, direct: Bool): Unit {
682
- let _ = direct
683
- synchronized(ArkWebRuntime.queueLock) {
684
- this.pendingScripts.add(script)
685
- }
686
- }
687
-
688
- /**
689
- * 把内联 HTML 落盘并返回 `file://` 地址
690
- *
691
- * 为什么必须落盘:ArkWeb 的仓颉绑定没有 `loadData`(只有 `loadUrl`),内联字符串
692
- * 无法直接喂给组件。文件名按 label 分,多窗口互不覆盖。
693
- * 失败(未 configure、磁盘不可写)时返回空串——组件据此保持空白,stderr 留凭证,
694
- * **不**抛异常把 `app.run()` 带走(`run()` 抛异常的表现是应用秒退、hilog 一行都没有)。
695
- */
696
- private func writeHtmlToSandbox(html: String): String {
697
- if (ArkWebRuntime.sandboxDir.size == 0) {
698
- eprintln("[cj-tauri] ArkWeb: run(html) rejected: ArkWebRuntime.configure(filesDir) was not called")
699
- return ""
700
- }
701
- let path = "${ArkWebRuntime.sandboxDir}/cj-tauri-${this.windowLabel}.html"
702
- try {
703
- File.writeTo(path, this.injectBootstrap(html).toArray())
704
- } catch (e: Exception) {
705
- eprintln("[cj-tauri] ArkWeb: failed to write page to sandbox: ${path}: ${e.message}")
706
- return ""
707
- }
708
- return "file://${path}"
709
- }
710
-
711
- /**
712
- * 把 label、桥脚本与 init 脚本拼进 HTML 的 head 最前
713
- *
714
- * 为什么走这条而不是 `onPageBegin` 里 runJavaScript:ArkWeb 的仓颉绑定没有
715
- * `javaScriptOnDocumentStart`(桌面两平台都有对应 API),`onPageBegin` 是能拿到的最早时机,
716
- * 那时页面顶层脚本**可能已经跑过**——内联页面因此改走「直接拼进 HTML」,语义才与桌面一致
717
- * (document-start,早于页面脚本)。
718
- *
719
- * 拼装顺序:label → 桥 → init 脚本。init 脚本(插件 shim)可能读 `__CJ_TAURI_LABEL__`,
720
- * 所以 label 必须最先;桥要早于 shim,因为 shim 会调用 `invoke`。
721
- */
722
- private func injectBootstrap(html: String): String {
723
- let scripts = StringBuilder("<script>${arkWebLabelScript(this.windowLabel)}</script>")
724
- scripts.append("<script>${ARKWEB_BRIDGE_JS}</script>")
725
- for (js in this.initScriptsSnapshot()) {
726
- scripts.append("<script>${js}</script>")
727
- }
728
- let block = scripts.toString()
729
- // 插在 head 开标签之后(最早的可执行位置);没有 head 就找 body,都没有就整段前置。
730
- match (html.indexOf("<head>")) {
731
- case Some(at) => return "${html[0..at + 6]}${block}${html[at + 6..html.size]}"
732
- case None => ()
733
- }
734
- match (html.indexOf("<body")) {
735
- case Some(at) => return "${html[0..at]}${block}${html[at..html.size]}"
736
- case None => ()
737
- }
738
- return "${block}${html}"
739
- }
740
- }
1
+ package cjTauri
2
+
3
+ /**
4
+ * 鸿蒙 ArkWeb 宿主(`@When[env == "ohos"]` 门禁)
5
+ *
6
+ * ## 为什么判据是 `env == "ohos"`
7
+ *
8
+ * 鸿蒙目标下 `os` 仍报 `Linux`(探针实测),与桌面 Linux 撞车;`arch` 只能把 x86_64 桌面
9
+ * 与 aarch64 分开,可桌面 aarch64 Linux 一样是 `aarch64`——用它当判据会把「跑在 GTK 上」
10
+ * 误判成「跑在 ArkWeb 上」。官方条件编译映射表给的是 `env`:`aarch64-linux-ohos` → `env == "ohos"`,
11
+ * 桌面 Linux 是 `gnu`,两者才真正互斥(`docs/` 里的条件编译文档「目标平台和条件编译映射表」)。
12
+ *
13
+ * ## 为什么 ArkUI 组件不在这里
14
+ *
15
+ * 框架核心包一旦 import `kit.ArkUI` / `ohos.arkui.*`,**所有**消费方(含桌面构建)都要跟着
16
+ * 拉 ArkUI 依赖。所以本文件只做「与 UI 无关的那半边」:页面来源定稿、脚本队列、IPC 接线、
17
+ * 生命周期标志;建 `Web` 组件的那半边在应用侧(`ArkWebView` 样板),经 [ArkWebRuntime] 取用。
18
+ *
19
+ * ## 线程模型(与桌面两平台的根本差异)
20
+ *
21
+ * 桌面:`start()` 里阻塞着跑宿主事件循环,`waitForExit()` 等它退出。
22
+ * 鸿蒙:ArkUI 的事件循环归系统所有(UI 线程),`start()` **不能**阻塞——它只登记页面来源,
23
+ * 由 UI 线程上的 `Web` 组件在 build 时取走。于是 `TauriApp.run()` 必须跑在非 UI 线程
24
+ * (应用在 Ability 里 `spawn`),UI 线程负责建树。
25
+ *
26
+ * 跨线程只走两条路:① 本文件的队列(`Mutex` 保护)② 页面消息经 `javaScriptProxy`
27
+ * 同步进来。**不要在应用线程直接调 ArkWeb/ArkUI**——与桌面 Linux 侧
28
+ * 「GTK/WebKit 调用必须在原生 pthread 内」同源的约束。
29
+ */
30
+
31
+ @When[env == "ohos"]
32
+ import std.collection.*
33
+
34
+ @When[env == "ohos"]
35
+ import std.fs.File
36
+
37
+ @When[env == "ohos"]
38
+ import std.sync.*
39
+
40
+ @When[env == "ohos"]
41
+ import stdx.encoding.json.JsonArray
42
+
43
+ @When[env == "ohos"]
44
+ import stdx.encoding.json.JsonString
45
+
46
+ // 页面来源契约(load(source) 收敛,RFC §4.3):Asset 由胶水注入的 rawfile 解析器材化
47
+ @When[env == "ohos"]
48
+ import cjTauri.platform.*
49
+
50
+ /**
51
+ * 鸿蒙宿主的进程级运行时:沙箱目录、各窗口宿主、跨线程队列开关
52
+ *
53
+ * 应用要做两件事(都是「框架拿不到、只有 Ability 有」的东西):
54
+ * 1. 在 `onCreate` 里 `ArkWebRuntime.configure(context.filesDir)`——`run(html)` 没有
55
+ * `loadData` 可用(ArkWeb 的仓颉绑定没有这个 API),内联 HTML 只能写进沙箱再以 `file://` 加载;
56
+ * 2. 在 `onWindowStageCreate` 里 `windowStage.loadContent("EntryView")`,并在**非 UI 线程**
57
+ * `spawn { app.run(html) }`——UI 线程要让给 ArkUI 建树。
58
+ */
59
+ @When[env == "ohos"]
60
+ public class ArkWebRuntime {
61
+ /* 应用沙箱目录(filesDir 下的子目录由本类自己创建)。空串 = 尚未 configure。 */
62
+ static var sandboxDir: String = ""
63
+
64
+ /* label → 宿主。UI 组件按 label 取自己的那一份;多窗口下不许共用一个实例。 */
65
+ static let hosts: HashMap<String, ArkWebHost> = HashMap<String, ArkWebHost>()
66
+
67
+ /* 应用级退出标志:鸿蒙的「关窗」等于「退出 Ability」,只能由应用侧执行 terminateSelf。 */
68
+ static var quitRequested: Bool = false
69
+
70
+ /* 队列锁:worker 线程写、UI 线程读(见文件头的线程模型)。 */
71
+ static let queueLock: Mutex = Mutex()
72
+
73
+ /**
74
+ * 配置沙箱目录(必须在 `TauriApp.run()` 之前调用,且只调一次)
75
+ *
76
+ * 用 `context.filesDir`:鸿蒙上它是应用私有可写目录,进程重启后内容仍在,
77
+ * 于是页面文件可以在**每次启动**重建(内容由应用决定,不做持久化依赖)。
78
+ */
79
+ public static func configure(filesDir: String): Unit {
80
+ if (filesDir.size == 0) {
81
+ eprintln("[cj-tauri] ArkWebRuntime.configure: filesDir is empty, run(html) will be rejected")
82
+ return
83
+ }
84
+ ArkWebRuntime.sandboxDir = filesDir
85
+ eprintln("[cj-tauri] ArkWeb sandbox: ${filesDir}")
86
+ }
87
+
88
+ /** 登记一个宿主(构造时调;同一 label 重复登记=替换,与进程级唯一窗口的语义一致) */
89
+ static func register(host: ArkWebHost): Unit {
90
+ synchronized(ArkWebRuntime.queueLock) {
91
+ ArkWebRuntime.hosts[host.label()] = host
92
+ }
93
+ }
94
+
95
+ /** 注销(dispose 时调;对应的 UI 组件随后会被系统回收) */
96
+ static func unregister(label: String): Unit {
97
+ synchronized(ArkWebRuntime.queueLock) {
98
+ ArkWebRuntime.hosts.remove(label)
99
+ }
100
+ }
101
+
102
+ /**
103
+ * 按 label 取宿主:**UI 侧(`ArkWebView` 组件)唯一的取用入口**
104
+ *
105
+ * 返回 None 是正常状态(应用先建树、`app.run()` 稍后才登记宿主),组件据此显示空白,
106
+ * 下一轮轮询再取——不要在这里报错。
107
+ */
108
+ public static func hostFor(label: String): Option<ArkWebHost> {
109
+ synchronized(ArkWebRuntime.queueLock) {
110
+ match (ArkWebRuntime.hosts.get(label)) {
111
+ case Some(h) => return Some(h)
112
+ case None => return None
113
+ }
114
+ }
115
+ }
116
+
117
+ /** 应用级退出请求(`quit()` / 窗口销毁都置它;Ability 侧轮询后自己 `terminateSelf()`) */
118
+ public static func requestQuit(): Unit {
119
+ ArkWebRuntime.quitRequested = true
120
+ }
121
+
122
+ /** 是否已请求退出(应用在 Ability 里轮询它来决定要不要 terminateSelf) */
123
+ public static func shouldQuit(): Bool {
124
+ return ArkWebRuntime.quitRequested
125
+ }
126
+ }
127
+
128
+ /**
129
+ * 一条待应用侧呈现的原生消息框(`dialog:message` 命令的载荷)
130
+ *
131
+ * 为什么要过一道队列:ArkUI 的弹窗 `getUIContext().showAlertDialog(...)` 只能在**应用侧组件**
132
+ * 里、且在 UI 线程上调(框架不碰 ArkUI,见文件头),而命令 handler 跑在 worker 线程上。
133
+ * 于是框架只描述「弹什么」,呈现与点击结果交给 UI 侧回填。
134
+ *
135
+ * `kind` 原样透传(`dialog` 插件的语义:`info` / `warning` / `error` 只有一个「确定」,
136
+ * `confirm` 才有取消)。按钮文案与布局属于呈现,由应用侧决定,框架不猜。
137
+ */
138
+ @When[env == "ohos"]
139
+ public class ArkWebDialogRequest {
140
+ public let id: Int64
141
+ public let kind: String
142
+ public let title: String
143
+ public let message: String
144
+
145
+ public init(id: Int64, kind: String, title: String, message: String) {
146
+ this.id = id
147
+ this.kind = kind
148
+ this.title = title
149
+ this.message = message
150
+ }
151
+
152
+ /** 是否「确定 / 取消」两键(其余 kind 只有一个确定键,弹出即成立) */
153
+ public func isConfirm(): Bool {
154
+ return this.kind == "confirm"
155
+ }
156
+ }
157
+
158
+ /**
159
+ * `showMessageDialog` 等 UI 侧回填的上限(毫秒)
160
+ *
161
+ * 桌面两平台在这里真的阻塞到用户点按钮,鸿蒙上只能等 UI 线程来取。窗口没建、页面没跑
162
+ * 的情况下无限期钉住一个 worker 线程比超时更糟,所以给一个宽上限:超时就如实返回 false
163
+ * 并在 stderr 里说清是「没弹出来」而不是「用户取消」——两者对调用方都是 false,但日志分得清。
164
+ */
165
+ @When[env == "ohos"]
166
+ let CJ_ARKWEB_DIALOG_WAIT_MS: Int64 = 120000
167
+
168
+ /** 等回填的轮询间隔(毫秒):只影响「点了按钮 → 命令返回」的延迟,与页面心跳无关 */
169
+ @When[env == "ohos"]
170
+ let CJ_ARKWEB_DIALOG_POLL_MS: Int64 = 20
171
+
172
+ /**
173
+ * 一个鸿蒙窗口的宿主
174
+ *
175
+ * 与桌面宿主的分工差异:这里**所有**图形相关动作都只是「入队 / 记状态」,真正执行发生在
176
+ * UI 线程(`ArkWebView` 组件的 build 与定时器里)。这样框架侧不需要任何 ArkUI 符号,
177
+ * 也不会在 worker 线程上碰 ArkUI(那会 abort,见 AGENTS §4 的栈边界坑)。
178
+ */
179
+ @When[env == "ohos"]
180
+ public class ArkWebHost <: WebViewHost {
181
+ private let windowLabel: String
182
+ // 资产解析器:胶水在 load 前注入(rawfile 材化);鸿蒙没有 cwd 读盘兜底
183
+ private var assetResolver: Option<AssetResolver> = None
184
+
185
+ /* 页面来源:start(html) 先存 html、落盘后 pageUrl 才成立;startUrl 直接定 pageUrl。 */
186
+ private var pageHtml: String = ""
187
+ private var pageUrl: String = ""
188
+
189
+ /* document-start 预执行脚本(插件 shim 与 label 注入走这条通道,顺序敏感)。 */
190
+ private var initScripts: ArrayList<String> = ArrayList<String>()
191
+
192
+ /* 待 UI 线程执行的脚本(runJs / 注入 / 回投都进这里,见 takePendingScripts)。 */
193
+ private var pendingScripts: ArrayList<String> = ArrayList<String>()
194
+
195
+ private var devToolsEnabled: Bool = false
196
+ private var visible: Bool = true
197
+ private var disposed: Bool = false
198
+ private var quitFlag: Bool = false
199
+
200
+ /* 取证计数:页面开始 / 结束加载各几次,桥日志按它对账(AGENTS §1 门禁 2)。 */
201
+ private var pageBeginCount: Int64 = 0
202
+ private var pageEndCount: Int64 = 0
203
+ private var jsInCount: Int64 = 0
204
+
205
+ /*
206
+ * 原生对话框(showMessageDialog)的三个槽,全部受 ArkWebRuntime.queueLock 保护:
207
+ * worker 线程入队并等待、UI 线程取走并回填,跨线程只经这两个动作交接。
208
+ * id 单调自增(每窗一份),所以「晚到的答案」撞不上新请求。
209
+ */
210
+ private var dialogSeq: Int64 = 0
211
+ private let pendingDialogs: ArrayList<ArkWebDialogRequest> = ArrayList<ArkWebDialogRequest>()
212
+ private let awaitingDialogs: HashSet<Int64> = HashSet<Int64>()
213
+ private let dialogAnswers: HashMap<Int64, Bool> = HashMap<Int64, Bool>()
214
+ private var dialogRequestCount: Int64 = 0
215
+ private var dialogAnswerCount: Int64 = 0
216
+
217
+ private var messageHandler: (String) -> Unit = { _ => }
218
+ private var shellHandler: (String) -> Unit = { _ => }
219
+ private var destroyHandler: () -> Unit = { => }
220
+
221
+ public init(label: String) {
222
+ this.windowLabel = label
223
+ ArkWebRuntime.register(this)
224
+ eprintln("[cj-tauri] ArkWeb host created: label=\"${label}\"")
225
+ }
226
+
227
+ public func label(): String {
228
+ return this.windowLabel
229
+ }
230
+
231
+ /**
232
+ * 登记内联 HTML 页面(不阻塞:真正的组件由 UI 线程建)
233
+ *
234
+ * 与桌面不同,这里**没有** `loadData` 可调(ArkWeb 的仓颉绑定只给了 `loadUrl`),
235
+ * 所以 HTML 先落盘到沙箱、再以 `file://` 交给 `Web` 组件。副作用要如实说:页面有了
236
+ * 真实来源(`location.protocol == "file:"`),`fetch` / XHR 会被同源策略挡住——
237
+ * 与 Tauri 2 在 `run(html)` 下的表现一致,不是本实现独有的限制。
238
+ */
239
+ public func start(html: String): Unit {
240
+ this.pageHtml = html
241
+ this.pageUrl = this.writeHtmlToSandbox(html)
242
+ eprintln("[cj-tauri] ArkWeb page: label=\"${this.windowLabel}\" source=inline url=\"${this.pageUrl}\"")
243
+ }
244
+
245
+ /**
246
+ * 登记 URL 页面(http(s):// 或 file://)
247
+ *
248
+ * 这条路径有真实来源,相对资源 / 动态 import / 前端路由都成立;`CJ_TAURI_DEV_URL`
249
+ * 的 dev server 就靠它。注入时机见 `injectInitScriptsOnBegin` 的说明。
250
+ */
251
+ public func startUrl(url: String): Unit {
252
+ this.pageHtml = ""
253
+ this.pageUrl = url
254
+ eprintln("[cj-tauri] ArkWeb page: label=\"${this.windowLabel}\" source=url url=\"${url}\"")
255
+ }
256
+
257
+ /**
258
+ * 加载页面(唯一入口,RFC §4.3)
259
+ *
260
+ * 与桌面的差异:Asset **没有 cwd 读盘兜底**——hap 里没有工程目录,资产只能来自
261
+ * 胶水注入的 resolver(rawfile 材化)。缺注入或资产缺失时如实报错,不静默白屏。
262
+ */
263
+ public func load(source: PageSource): Unit {
264
+ match (source) {
265
+ case Inline(html) => this.start(html)
266
+ case Url(url) => this.startUrl(url)
267
+ case DevServer(url) => this.startUrl(url)
268
+ case Asset(name) =>
269
+ match (this.assetResolver) {
270
+ case Some(r) =>
271
+ match (r.readText(name)) {
272
+ case Some(html) => this.start(html)
273
+ case None =>
274
+ throw AssetResolutionException("资产未找到:${name}(胶水注入的 rawfile 里没有这个资产,检查清单 [assets] 与骨架 rawfile 拷贝)")
275
+ }
276
+ case None =>
277
+ throw AssetResolutionException("资产 ${name} 未注入 AssetResolver(鸿蒙侧胶水须先把 rawfile 材化并注入解析器,再 load)")
278
+ }
279
+ }
280
+ }
281
+
282
+ /** 注入资产解析器(胶水在 load 前调用,rawfile 材化器) */
283
+ public func setAssetResolver(resolver: Option<AssetResolver>): Unit {
284
+ this.assetResolver = resolver
285
+ }
286
+
287
+ /**
288
+ * 注册预执行脚本(必须早于 load,与桌面同一契约)
289
+ *
290
+ * 内联 HTML 走「拼进 head 最前」这条通道(真正早于页面脚本);URL 页面只能等
291
+ * `onPageBegin` 再 runJavaScript——那时页面顶层脚本可能已经跑过,**这是本平台的已知限制**,
292
+ * 不是静默降级:首次注入会在 stderr 说明一次(见 `noteLateInjection`)。
293
+ */
294
+ public func addInitScript(js: String): Unit {
295
+ if (js.size == 0) {
296
+ return
297
+ }
298
+ this.initScripts.add(js)
299
+ }
300
+
301
+ public func reload(): Unit {
302
+ this.enqueueScript("__cjTauriReloadRequest()", true)
303
+ }
304
+
305
+ public func setWindowConfig(config: WindowConfig): Unit {
306
+ // 鸿蒙应用窗口由 Ability 的 WindowStage 决定,逻辑尺寸概念与桌面不同:
307
+ // 这里如实说明不生效,而不是假装设成功(能力位也不含任何窗口控制位)。
308
+ let _ = config
309
+ eprintln("[cj-tauri] ArkWeb: window config is managed by WindowStage on this platform (ignored)")
310
+ }
311
+
312
+ public func setDevTools(enabled: Bool): Unit {
313
+ this.devToolsEnabled = enabled
314
+ // 真正调 setWebDebuggingAccess 的时机在 UI 侧(controller 就绪之后)。
315
+ eprintln("[cj-tauri] ArkWeb: web debugging requested: ${enabled}")
316
+ }
317
+
318
+ public func openDevTools(): Unit {
319
+ // ArkWeb 的调试走「设备侧 Web 调试」通道,没有桌面那种独立 DevTools 窗口。
320
+ eprintln("[cj-tauri] ArkWeb: openDevTools is not available on this platform (use web debugging)")
321
+ }
322
+
323
+ /* ---- 以下宿主能力位为 0:一律如实拒绝,不假装成功(AGENTS §2 的 scaffold 纪律) ---- */
324
+
325
+ public func setMenu(wire: String): Unit {
326
+ let _ = wire
327
+ eprintln("[cj-tauri] ArkWeb: menu unsupported (host capability bit is 0)")
328
+ }
329
+
330
+ /**
331
+ * P-1/P-2(RFC §4.6.4):ohos 宿主没有菜单栏(ArkWeb 窗口形态,无 GTK/Win32 菜单概念),
332
+ * 菜单状态改动如实报「端不支持」。静默 no-op 会让页面误以为已生效——如实失败才可照做。
333
+ */
334
+ public func setMenuItemState(id: String, enabled: Bool, checked: Bool): Unit {
335
+ throw CommandException("menu: 菜单状态改动在 ohos 端不支持(该端没有菜单栏,P-1 桩,见 RFC §4.6.4)")
336
+ }
337
+
338
+ public func setTitlebarSubtitle(text: String): Unit {
339
+ let _ = text
340
+ }
341
+
342
+ public func setDragDropPolicy(enabled: Bool, files: Bool): Unit {
343
+ let _ = enabled
344
+ let _ = files
345
+ eprintln("[cj-tauri] ArkWeb: drag/drop policy unsupported (host capability bit is 0)")
346
+ }
347
+
348
+ public func setGlobalHotkeys(wire: String): Unit {
349
+ let _ = wire
350
+ }
351
+
352
+ public func setTray(wire: String): Unit {
353
+ let _ = wire
354
+ }
355
+
356
+ public func setWindowVisible(visible: Bool): Unit {
357
+ this.visible = visible
358
+ // 可见性由 ArkUI 的组件树决定(`visibility` 属性),宿主只记状态供 UI 侧读取。
359
+ eprintln("[cj-tauri] ArkWeb: window visible -> ${visible}")
360
+ }
361
+
362
+ public func setMinimizeToTray(enabled: Bool): Unit {
363
+ let _ = enabled
364
+ }
365
+
366
+ /** 能力位全 0:鸿蒙侧还没有任何一项宿主级能力通过实机取证(AGENTS §1 门禁 4) */
367
+ public func hostCapabilities(): Int32 {
368
+ return 0
369
+ }
370
+
371
+ public func setShellHandler(handler: (String) -> Unit): Unit {
372
+ this.shellHandler = handler
373
+ }
374
+
375
+ public func setDestroyHandler(handler: () -> Unit): Unit {
376
+ this.destroyHandler = handler
377
+ }
378
+
379
+ public func showFileDialog(save: Bool, title: String, filter: String): String {
380
+ let _ = save
381
+ let _ = title
382
+ let _ = filter
383
+ eprintln("[cj-tauri] ArkWeb: native file dialog unsupported (needs a picker module)")
384
+ return ""
385
+ }
386
+
387
+ public func showFileDialogPreset(save: Bool, title: String, filter: String, defaultName: String): String {
388
+ let _ = defaultName
389
+ return this.showFileDialog(save, title, filter)
390
+ }
391
+
392
+ public func showFolderDialog(title: String): String {
393
+ let _ = title
394
+ eprintln("[cj-tauri] ArkWeb: native folder dialog unsupported (needs a picker module)")
395
+ return ""
396
+ }
397
+
398
+ /**
399
+ * 原生消息框(`dialog:message` 命令的落点)
400
+ *
401
+ * 与桌面的差异:桌面在 C 桥里**同步阻塞**到用户点按钮;鸿蒙的弹窗只有 ArkUI 侧能做,
402
+ * 所以这里退成「入队 → 有界轮询等 UI 侧回填」。等待发生在命令 worker 线程上
403
+ * (命令分发本来就是异步的,见 AGENTS §2),不占 UI 线程,窗口照常响应。
404
+ *
405
+ * 返回 false 的三种情况要在日志里分得清:宿主已销毁(拒绝)、UI 侧没来取(超时)、
406
+ * 用户点了取消(answered + confirmed=false)。
407
+ */
408
+ public func showMessageDialog(kind: String, title: String, message: String): Bool {
409
+ if (this.disposed) {
410
+ eprintln("[cj-tauri] ArkWeb dialog rejected: label=\"${this.windowLabel}\" (host disposed)")
411
+ return false
412
+ }
413
+ var id: Int64 = 0
414
+ synchronized(ArkWebRuntime.queueLock) {
415
+ this.dialogSeq += 1
416
+ id = this.dialogSeq
417
+ this.dialogRequestCount += 1
418
+ this.pendingDialogs.add(ArkWebDialogRequest(id, kind, title, message))
419
+ this.awaitingDialogs.add(id)
420
+ }
421
+ eprintln(
422
+ "[cj-tauri] ArkWeb dialog queued: label=\"${this.windowLabel}\" id=${id} kind=\"${kind}\" title=\"${title}\""
423
+ )
424
+ var waited: Int64 = 0
425
+ while (waited < CJ_ARKWEB_DIALOG_WAIT_MS) {
426
+ var answered = false
427
+ var confirmed = false
428
+ synchronized(ArkWebRuntime.queueLock) {
429
+ if (this.dialogAnswers.contains(id)) {
430
+ confirmed = this.dialogAnswers[id]
431
+ this.dialogAnswers.remove(id)
432
+ answered = true
433
+ }
434
+ }
435
+ if (answered) {
436
+ eprintln(
437
+ "[cj-tauri] ArkWeb dialog answered: label=\"${this.windowLabel}\" id=${id} confirmed=${confirmed}"
438
+ )
439
+ return confirmed
440
+ }
441
+ sleep(Duration.millisecond * CJ_ARKWEB_DIALOG_POLL_MS)
442
+ waited += CJ_ARKWEB_DIALOG_POLL_MS
443
+ }
444
+ synchronized(ArkWebRuntime.queueLock) {
445
+ this.awaitingDialogs.remove(id)
446
+ this.dropPendingDialog(id)
447
+ this.dialogAnswers.remove(id)
448
+ }
449
+ eprintln(
450
+ "[cj-tauri] ArkWeb dialog timed out: label=\"${this.windowLabel}\" id=${id} after ${CJ_ARKWEB_DIALOG_WAIT_MS}ms (UI side never answered; this is NOT a user cancel)"
451
+ )
452
+ return false
453
+ }
454
+
455
+ /**
456
+ * 取一条待呈现的对话框(**只在 UI 线程调**:`ArkWebView` 组件的页面心跳回调里)
457
+ *
458
+ * 取走即从队列移除,所以同一个请求不会弹两次;返回 None 是绝大多数心跳的正常结果。
459
+ */
460
+ public func takePendingDialog(): Option<ArkWebDialogRequest> {
461
+ synchronized(ArkWebRuntime.queueLock) {
462
+ if (this.pendingDialogs.isEmpty()) {
463
+ return None
464
+ }
465
+ let request = this.pendingDialogs[0]
466
+ this.pendingDialogs.remove(at: 0)
467
+ return Some(request)
468
+ }
469
+ }
470
+
471
+ /**
472
+ * 回填用户的选择(**只在 UI 线程调**:弹窗按钮的回调里)
473
+ *
474
+ * 只接受仍在等待的 id:超时之后等待方已经放弃,这时再记一个答案会把它一直挂在这个槽里
475
+ * (没人取走),所以按 `awaitingDialogs` 校验一次,并在日志里说明被丢弃的那次点击。
476
+ */
477
+ public func answerDialog(id: Int64, confirmed: Bool): Unit {
478
+ var accepted = false
479
+ synchronized(ArkWebRuntime.queueLock) {
480
+ if (this.awaitingDialogs.contains(id)) {
481
+ this.awaitingDialogs.remove(id)
482
+ this.dialogAnswers[id] = confirmed
483
+ this.dialogAnswerCount += 1
484
+ accepted = true
485
+ }
486
+ }
487
+ if (accepted) {
488
+ eprintln(
489
+ "[cj-tauri] ArkWeb dialog answer: label=\"${this.windowLabel}\" id=${id} confirmed=${confirmed}"
490
+ )
491
+ return
492
+ }
493
+ eprintln(
494
+ "[cj-tauri] ArkWeb dialog answer dropped: label=\"${this.windowLabel}\" id=${id} confirmed=${confirmed} (no longer awaiting)"
495
+ )
496
+ }
497
+
498
+ /** 从待呈现队列里按 id 移除(超时清场用;调用方必须已持有 `queueLock`) */
499
+ private func dropPendingDialog(id: Int64): Unit {
500
+ var index: Int64 = 0
501
+ while (index < this.pendingDialogs.size) {
502
+ if (this.pendingDialogs[index].id == id) {
503
+ this.pendingDialogs.remove(at: index)
504
+ return
505
+ }
506
+ index += 1
507
+ }
508
+ }
509
+
510
+ public func clipboardWriteText(text: String): Bool {
511
+ let _ = text
512
+ // 剪贴板在鸿蒙走 pasteboard 模块(`ohos.pasteboard`),尚未接入:如实 false,
513
+ // 不返回 true 骗调用方(这正是「发后即忘返回 0 ≠ 发送成功」那条坑的同族)。
514
+ eprintln("[cj-tauri] ArkWeb: clipboard write unsupported (pasteboard not wired yet)")
515
+ return false
516
+ }
517
+
518
+ public func clipboardReadText(): Option<String> {
519
+ return None
520
+ }
521
+
522
+ public func notify(title: String, body: String): Bool {
523
+ let _ = title
524
+ let _ = body
525
+ eprintln("[cj-tauri] ArkWeb: notification unsupported (host capability bit is 0)")
526
+ return false
527
+ }
528
+
529
+ /**
530
+ * 在页面里执行一段脚本(框架的 `resolve` / `event` 回投就走这条)
531
+ *
532
+ * 不直接调 `controller.runJavaScript`:那个 API 属于 UI 线程,而本方法几乎总是从
533
+ * IPC worker 线程进来(命令异步分发,见 AGENTS §2)。所以只入队,由 UI 侧的定时器取走。
534
+ * 这样「回投顺序」也自然跟着队列走。
535
+ */
536
+ public func runJs(script: String): Unit {
537
+ if (script.size == 0 || this.disposed) {
538
+ return
539
+ }
540
+ this.enqueueScript(script, true)
541
+ }
542
+
543
+ /**
544
+ * 请求退出
545
+ *
546
+ * 鸿蒙没有「关掉这个窗口」这种宿主动作:窗口归 Ability,退出得由应用 `terminateSelf()`。
547
+ * 所以这里只置标志(`shouldQuit()` 与 `ArkWebRuntime.shouldQuit()` 都能读到),
548
+ * 应用据此结束自己的生命周期;框架不假装自己关掉了窗口。
549
+ */
550
+ public func quit(): Unit {
551
+ if (this.quitFlag) {
552
+ return
553
+ }
554
+ this.quitFlag = true
555
+ ArkWebRuntime.requestQuit()
556
+ eprintln("[cj-tauri] ArkWeb: quit requested: label=\"${this.windowLabel}\" (app should terminateSelf)")
557
+ }
558
+
559
+ public func setMessageHandler(handler: (String) -> Unit): Unit {
560
+ this.messageHandler = handler
561
+ }
562
+
563
+ public func shouldQuit(): Bool {
564
+ return this.quitFlag || ArkWebRuntime.shouldQuit()
565
+ }
566
+
567
+ public func dispose(): Unit {
568
+ if (this.disposed) {
569
+ return
570
+ }
571
+ this.disposed = true
572
+ ArkWebRuntime.unregister(this.windowLabel)
573
+ eprintln(
574
+ "[cj-tauri] ArkWeb host disposed: label=\"${this.windowLabel}\" pageBegin=${this.pageBeginCount} pageEnd=${this.pageEndCount} jsIn=${this.jsInCount} dialogRequested=${this.dialogRequestCount} dialogAnswered=${this.dialogAnswerCount}"
575
+ )
576
+ }
577
+
578
+ /* ------------------------- UI 侧(ArkWebView 组件)取用面 ------------------------- */
579
+
580
+ /**
581
+ * 组件要加载的地址(空串 = 还没登记,组件应保持空白并下一轮再问)
582
+ *
583
+ * 为什么可能为空:应用先建 UI 树、再 `spawn { app.run(html) }`,两者先后不定。
584
+ * 组件拿到空串不是错误,是正常的启动竞态,别在这里打日志刷屏。
585
+ */
586
+ public func currentUrl(): String {
587
+ return this.pageUrl
588
+ }
589
+
590
+ /** 是否要求可见(组件据此设 `visibility`);隐藏态页面照常跑,还原入口由应用给 */
591
+ public func isVisible(): Bool {
592
+ return this.visible
593
+ }
594
+
595
+ /** 是否要求开启 Web 调试(组件在建 controller 后调 `setWebDebuggingAccess`) */
596
+ public func devToolsWanted(): Bool {
597
+ return this.devToolsEnabled
598
+ }
599
+
600
+ /**
601
+ * 取走全部待执行脚本(**必须在 UI 线程调用**)
602
+ *
603
+ * 取走即清空:这些脚本的语义是「尽快执行一次」,重复执行会把 resolve 投两次
604
+ * (页面侧 pending 表按 id 匹配,重复投递会让第二次变空操作,但语义仍应保持一次)。
605
+ */
606
+ public func takePendingScripts(): ArrayList<String> {
607
+ let out = ArrayList<String>()
608
+ synchronized(ArkWebRuntime.queueLock) {
609
+ for (s in this.pendingScripts) {
610
+ out.add(s)
611
+ }
612
+ this.pendingScripts.clear()
613
+ }
614
+ return out
615
+ }
616
+
617
+ /**
618
+ * 取走 init 脚本(页面开始加载时调一次:内联 HTML 已拼进 head,这里的只用于 URL 页面)
619
+ *
620
+ * 不做「取走即清空」:URL 页面的 reload 后仍需要同一批 shim,清空会让第二次加载丢掉插件。
621
+ */
622
+ public func initScriptsSnapshot(): ArrayList<String> {
623
+ let out = ArrayList<String>()
624
+ synchronized(ArkWebRuntime.queueLock) {
625
+ for (s in this.initScripts) {
626
+ out.add(s)
627
+ }
628
+ }
629
+ return out
630
+ }
631
+
632
+ /**
633
+ * 页面轮询泵的原生端点(`javaScriptProxy` 的 `poll` 方法,UI 线程同步调用)
634
+ *
635
+ * 为什么是「页面拉」而不是「原生推」:ArkUI 的仓颉绑定没有定时器、也没有 UIContext,
636
+ * 框架线程既定不了时也不能安全碰 ArkUI(AGENTS §4 的栈边界坑);而页面里 `setInterval`
637
+ * 是最普通的东西。于是把「什么时候执行待发脚本」交给页面,原生只做同步的取队列动作。
638
+ *
639
+ * 返回值是**脚本字符串的 JSON 数组**(不是 JSON 报文):页面用间接 eval 逐条执行,
640
+ * 这样 `_dispatch({...})` 这类语句能直接跑在全局作用域,而不是变成字符串。
641
+ * 空队列返回 `[]`——页面侧据此连 JSON.parse 都不做。
642
+ */
643
+ public func pollScriptsJson(): String {
644
+ synchronized(ArkWebRuntime.queueLock) {
645
+ if (this.pendingScripts.isEmpty()) {
646
+ return "[]"
647
+ }
648
+ let arr = JsonArray()
649
+ for (s in this.pendingScripts) {
650
+ arr.add(JsonString(s))
651
+ }
652
+ this.pendingScripts.clear()
653
+ return arr.toString()
654
+ }
655
+ }
656
+
657
+ /** 页面开始加载(UI 侧回调;用于注入与取证计数) */
658
+ public func notePageBegin(): Unit {
659
+ synchronized(ArkWebRuntime.queueLock) {
660
+ this.pageBeginCount += 1
661
+ }
662
+ eprintln("[cj-tauri] ArkWeb page begin: label=\"${this.windowLabel}\" count=${this.pageBeginCount}")
663
+ }
664
+
665
+ /** 页面加载结束(UI 侧回调);`system:host` 的取证就看这两个计数是否对得上 */
666
+ public func notePageEnd(): Unit {
667
+ synchronized(ArkWebRuntime.queueLock) {
668
+ this.pageEndCount += 1
669
+ }
670
+ eprintln("[cj-tauri] ArkWeb page end: label=\"${this.windowLabel}\" count=${this.pageEndCount}")
671
+ }
672
+
673
+ /**
674
+ * 页面 → 原生:`javaScriptProxy` 的同步回调(UI 线程)
675
+ *
676
+ * 报文格式与桌面两平台**同一份**(`{type:"invoke"|"emit", id, cmd, event, window, ...}`),
677
+ * 所以 IPC 层不需要为鸿蒙分叉。这里只做计数与转发,不做校验——校验在 `IpcHub`。
678
+ */
679
+ public func acceptPageMessage(raw: String): Unit {
680
+ if (raw.size == 0 || this.disposed) {
681
+ return
682
+ }
683
+ // reload 是**控制消息、不进 IPC hub**:桌面两平台由 C 桥在投递前截掉(CJT_RELOAD_MSG),
684
+ // 鸿蒙没有 C 桥这一层,拦截点就在这里。用子串判断而不是解析 JSON:报文由本框架自己的
685
+ // 桥脚本产生,形状固定;解析失败会把 reload 静默丢掉,反而更难查。
686
+ match (raw.indexOf("\"type\":\"reload\"")) {
687
+ case Some(_) =>
688
+ eprintln("[cj-tauri] ArkWeb: reload requested by page: label=\"${this.windowLabel}\"")
689
+ this.reload()
690
+ return
691
+ case None => ()
692
+ }
693
+ // 拉取心跳同理:页面只是「叫一声」,真正的推送由 UI 侧(组件的 post 回调)用
694
+ // runJavaScript 完成——`javaScriptProxy` 的返回值传不回页面(踩坑文档 8.1.5.1),
695
+ // 所以这条报文不进 IPC hub,这里直接返回。
696
+ match (raw.indexOf("\"type\":\"pull\"")) {
697
+ case Some(_) => return
698
+ case None => ()
699
+ }
700
+ synchronized(ArkWebRuntime.queueLock) {
701
+ this.jsInCount += 1
702
+ }
703
+ this.messageHandler(raw)
704
+ }
705
+
706
+ /** 页面消息计数(取证:`js -> native` 次数对账,AGENTS §1 门禁 2) */
707
+ public func messageCount(): Int64 {
708
+ return this.jsInCount
709
+ }
710
+
711
+ /* ------------------------------ 内部辅助 ------------------------------ */
712
+
713
+ /**
714
+ * 入队一段脚本
715
+ *
716
+ * `direct` 只是留给调用点自说明用的(两处语义相同,暂不分支);保留它是因为将来
717
+ * 「document-start 注入」与「普通 runJs」可能需要不同的排队策略(前者要在 onPageBegin 之前)。
718
+ */
719
+ private func enqueueScript(script: String, direct: Bool): Unit {
720
+ let _ = direct
721
+ synchronized(ArkWebRuntime.queueLock) {
722
+ this.pendingScripts.add(script)
723
+ }
724
+ }
725
+
726
+ /**
727
+ * 把内联 HTML 落盘并返回 `file://` 地址
728
+ *
729
+ * 为什么必须落盘:ArkWeb 的仓颉绑定没有 `loadData`(只有 `loadUrl`),内联字符串
730
+ * 无法直接喂给组件。文件名按 label 分,多窗口互不覆盖。
731
+ * 失败(未 configure、磁盘不可写)时返回空串——组件据此保持空白,stderr 留凭证,
732
+ * **不**抛异常把 `app.run()` 带走(`run()` 抛异常的表现是应用秒退、hilog 一行都没有)。
733
+ */
734
+ private func writeHtmlToSandbox(html: String): String {
735
+ if (ArkWebRuntime.sandboxDir.size == 0) {
736
+ eprintln("[cj-tauri] ArkWeb: run(html) rejected: ArkWebRuntime.configure(filesDir) was not called")
737
+ return ""
738
+ }
739
+ let path = "${ArkWebRuntime.sandboxDir}/cj-tauri-${this.windowLabel}.html"
740
+ try {
741
+ File.writeTo(path, this.injectBootstrap(html).toArray())
742
+ } catch (e: Exception) {
743
+ eprintln("[cj-tauri] ArkWeb: failed to write page to sandbox: ${path}: ${e.message}")
744
+ return ""
745
+ }
746
+ return "file://${path}"
747
+ }
748
+
749
+ /**
750
+ * 把 label、桥脚本与 init 脚本拼进 HTML 的 head 最前
751
+ *
752
+ * 为什么走这条而不是 `onPageBegin` 里 runJavaScript:ArkWeb 的仓颉绑定没有
753
+ * `javaScriptOnDocumentStart`(桌面两平台都有对应 API),`onPageBegin` 是能拿到的最早时机,
754
+ * 那时页面顶层脚本**可能已经跑过**——内联页面因此改走「直接拼进 HTML」,语义才与桌面一致
755
+ * (document-start,早于页面脚本)。
756
+ *
757
+ * 拼装顺序:label → 桥 → init 脚本。init 脚本(插件 shim)可能读 `__CJ_TAURI_LABEL__`,
758
+ * 所以 label 必须最先;桥要早于 shim,因为 shim 会调用 `invoke`。
759
+ */
760
+ private func injectBootstrap(html: String): String {
761
+ let scripts = StringBuilder("<script>${arkWebLabelScript(this.windowLabel)}</script>")
762
+ scripts.append("<script>${ARKWEB_BRIDGE_JS}</script>")
763
+ for (js in this.initScriptsSnapshot()) {
764
+ scripts.append("<script>${js}</script>")
765
+ }
766
+ let block = scripts.toString()
767
+ // 插在 head 开标签之后(最早的可执行位置);没有 head 就找 body,都没有就整段前置。
768
+ match (html.indexOf("<head>")) {
769
+ case Some(at) => return "${html[0..at + 6]}${block}${html[at + 6..html.size]}"
770
+ case None => ()
771
+ }
772
+ match (html.indexOf("<body")) {
773
+ case Some(at) => return "${html[0..at]}${block}${html[at..html.size]}"
774
+ case None => ()
775
+ }
776
+ return "${block}${html}"
777
+ }
778
+ }