cj-tauri 2.0.0 → 2.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 (158) hide show
  1. package/README.md +113 -99
  2. package/bin/cj-tauri.js +287 -287
  3. package/cjpm.toml +63 -52
  4. package/cli/cjpm.toml +22 -22
  5. package/cli/src/doctor.cj +308 -306
  6. package/cli/src/main.cj +129 -129
  7. package/cli/src/manifest.cj +632 -558
  8. package/cli/src/manifest_test.cj +87 -0
  9. package/cli/src/project.cj +770 -770
  10. package/cli/src/resolve.cj +348 -348
  11. package/cli/src/scaffold.cj +369 -369
  12. package/cli/src/sync.cj +526 -526
  13. package/cli/src/util.cj +168 -168
  14. package/cli/templates/app/README.md +51 -48
  15. package/cli/templates/app/assets/capabilities/default.json +7 -7
  16. package/cli/templates/app/assets/ui/index.html +120 -118
  17. package/cli/templates/app/cj-tauri.toml +32 -22
  18. package/cli/templates/app/cjpm.toml +34 -34
  19. package/cli/templates/app/src/main.cj +101 -90
  20. package/cli/templates/app-react/README.md +56 -56
  21. package/cli/templates/app-react/assets/capabilities/default.json +7 -7
  22. package/cli/templates/app-react/assets/ui/index.html +25 -25
  23. package/cli/templates/app-react/assets/ui/package.json +19 -19
  24. package/cli/templates/app-react/assets/ui/src/App.css +67 -67
  25. package/cli/templates/app-react/assets/ui/src/App.jsx +124 -124
  26. package/cli/templates/app-react/assets/ui/src/main.jsx +6 -6
  27. package/cli/templates/app-react/assets/ui/vite.config.js +24 -24
  28. package/cli/templates/app-react/cj-tauri.toml +33 -23
  29. package/cli/templates/app-react/cjpm.toml +34 -34
  30. package/cli/templates/app-react/src/main.cj +91 -91
  31. package/cli/templates/app-vue/README.md +56 -56
  32. package/cli/templates/app-vue/assets/capabilities/default.json +7 -7
  33. package/cli/templates/app-vue/assets/ui/index.html +25 -25
  34. package/cli/templates/app-vue/assets/ui/package.json +18 -18
  35. package/cli/templates/app-vue/assets/ui/src/App.vue +176 -176
  36. package/cli/templates/app-vue/assets/ui/src/main.js +4 -4
  37. package/cli/templates/app-vue/assets/ui/vite.config.js +24 -24
  38. package/cli/templates/app-vue/cj-tauri.toml +33 -23
  39. package/cli/templates/app-vue/cjpm.toml +34 -34
  40. package/cli/templates/app-vue/src/main.cj +91 -91
  41. package/cli/templates/ohos-skeleton/AppScope/app.json5 +13 -13
  42. package/cli/templates/ohos-skeleton/AppScope/resources/base/element/string.json +8 -8
  43. package/cli/templates/ohos-skeleton/AppScope/resources/base/media/layered_image.json +6 -6
  44. package/cli/templates/ohos-skeleton/README.md +84 -79
  45. package/cli/templates/ohos-skeleton/build-hap.sh +15 -0
  46. package/cli/templates/ohos-skeleton/build-profile.json5 +46 -46
  47. package/cli/templates/ohos-skeleton/entry/build-profile.json5 +26 -26
  48. package/cli/templates/ohos-skeleton/entry/cjpm.toml +51 -43
  49. package/cli/templates/ohos-skeleton/entry/hvigorfile.ts +6 -6
  50. package/cli/templates/ohos-skeleton/entry/oh-package.json5 +9 -9
  51. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/ability_stage.cj +17 -17
  52. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/app_host.cj +94 -94
  53. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/dialog_host.cj +217 -0
  54. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/index.cj +527 -167
  55. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/main_ability.cj +139 -139
  56. package/cli/templates/ohos-skeleton/entry/src/main/module.json5 +57 -49
  57. package/cli/templates/ohos-skeleton/entry/src/main/resources/base/element/color.json +8 -8
  58. package/cli/templates/ohos-skeleton/entry/src/main/resources/base/element/string.json +16 -16
  59. package/cli/templates/ohos-skeleton/entry/src/main/resources/base/media/layered_image.json +6 -6
  60. package/cli/templates/ohos-skeleton/entry/src/main/resources/base/profile/main_pages.json +2 -2
  61. package/cli/templates/ohos-skeleton/entry/src/main/resources/rawfile/capabilities.json +6 -6
  62. package/cli/templates/ohos-skeleton/entry/src/main/resources/rawfile/index.html +92 -92
  63. package/cli/templates/ohos-skeleton/hvigor/hvigor-config.json5 +5 -5
  64. package/cli/templates/ohos-skeleton/hvigorfile.ts +6 -6
  65. package/cli/templates/ohos-skeleton/oh-package.json5 +10 -10
  66. package/native/build_win.bat +62 -62
  67. package/native/core/bridge_core.c +1681 -1626
  68. package/native/core/bridge_core.h +675 -618
  69. package/native/core/bridge_js.h +108 -108
  70. package/native/platforms/harmonyos/README.md +115 -6
  71. package/native/platforms/linux/bridge_linux.c +2787 -2485
  72. package/native/platforms/windows/bridge_win.c +3740 -3248
  73. package/native/tests/gtk_threading_probe.c +143 -143
  74. package/native/tests/test_bridge_core.c +1339 -1228
  75. package/package.json +53 -53
  76. package/prebuilt/linux-x64/cj-tauri +0 -0
  77. package/src/api_system.cj +67 -67
  78. package/src/app.cj +325 -969
  79. package/src/app_events.cj +166 -0
  80. package/src/app_host_ops.cj +287 -0
  81. package/src/app_run.cj +283 -0
  82. package/src/app_spec.cj +76 -76
  83. package/src/arkweb_menu.cj +216 -0
  84. package/src/arkweb_script.cj +142 -142
  85. package/src/capability.cj +409 -409
  86. package/src/clipboard_bridge.cj +178 -0
  87. package/src/dialog_spec.cj +79 -0
  88. package/src/dragdrop.cj +26 -26
  89. package/src/embedded_assets.cj +98 -98
  90. package/src/host.cj +752 -607
  91. package/src/host_arkweb.cj +924 -778
  92. package/src/host_webkit.cj +663 -570
  93. package/src/host_webview2.cj +663 -570
  94. package/src/hotkey.cj +66 -66
  95. package/src/ipc_hub.cj +337 -337
  96. package/src/ipc_message.cj +286 -286
  97. package/src/launch.cj +62 -62
  98. package/src/logging.cj +127 -127
  99. package/src/menu.cj +197 -197
  100. package/src/platform/platform_app_context.cj +77 -77
  101. package/src/platform/platform_assets.cj +33 -33
  102. package/src/platform/platform_discover.cj +176 -176
  103. package/src/platform/platform_env.cj +29 -29
  104. package/src/platform/platform_feature.cj +31 -31
  105. package/src/platform/platform_page_source.cj +87 -87
  106. package/src/platform/platform_target_info.cj +32 -32
  107. package/src/platform_host_provider.cj +87 -87
  108. package/src/plugin.cj +333 -333
  109. package/src/plugins/clipboard/plugin_clipboard.cj +152 -152
  110. package/src/plugins/dialog/plugin_dialog.cj +209 -209
  111. package/src/plugins/fs/plugin_fs.cj +105 -1
  112. package/src/plugins/http/http_backend.cj +131 -0
  113. package/src/plugins/http/http_desktop.cj +195 -0
  114. package/src/plugins/http/http_ohos.cj +204 -0
  115. package/src/plugins/http/plugin_http.cj +310 -459
  116. package/src/plugins/log/plugin_log.cj +105 -105
  117. package/src/plugins/menu/plugin_menu.cj +158 -158
  118. package/src/plugins/notification/plugin_notification.cj +154 -154
  119. package/src/plugins/serial/plugin_serial.cj +458 -454
  120. package/src/plugins/serial/serial_bridge.cj +194 -194
  121. package/src/plugins/serial/serial_manager_ohos.cj +92 -0
  122. package/src/plugins/serial/serial_ohos.cj +529 -0
  123. package/src/plugins/shell/plugin_shell.cj +258 -258
  124. package/src/plugins/sqlite/plugin_sqlite.cj +705 -710
  125. package/src/plugins/sqlite/sqlite_ffi.cj +206 -211
  126. package/src/plugins/ws/plugin_ws.cj +488 -470
  127. package/src/process_hidden.cj +174 -174
  128. package/src/scope.cj +391 -391
  129. package/src/shell_event.cj +69 -69
  130. package/src/tests/app_spec_test.cj +126 -127
  131. package/src/tests/capability_test.cj +243 -243
  132. package/src/tests/dialog_spec_test.cj +73 -0
  133. package/src/tests/fake_host.cj +201 -201
  134. package/src/tests/ipc_hub_test.cj +487 -487
  135. package/src/tests/logging_test.cj +62 -62
  136. package/src/tests/platform_contract_test.cj +251 -251
  137. package/src/tests/plugin_clipboard_test.cj +222 -222
  138. package/src/tests/plugin_dialog_test.cj +237 -237
  139. package/src/tests/plugin_fs_test.cj +207 -0
  140. package/src/tests/plugin_http_test.cj +124 -124
  141. package/src/tests/plugin_notification_test.cj +205 -205
  142. package/src/tests/plugin_serial_test.cj +48 -0
  143. package/src/tests/plugin_test.cj +605 -585
  144. package/src/tests/plugin_ws_test.cj +40 -40
  145. package/src/tests/process_hidden_test.cj +129 -129
  146. package/src/tests/scope_test.cj +350 -350
  147. package/src/tests/system_host_test.cj +99 -81
  148. package/src/tests/urlutil_test.cj +53 -53
  149. package/src/tests/version_test.cj +75 -75
  150. package/src/tests/window_registry_test.cj +123 -123
  151. package/src/tray.cj +90 -90
  152. package/src/urlutil.cj +159 -159
  153. package/src/version.cj +44 -44
  154. package/src/window.cj +51 -51
  155. package/src/window_registry.cj +116 -116
  156. package/prebuilt/win32-x64/cj-tauri.exe +0 -0
  157. package/src/plugins/serial/serial_ohos_stub.cj +0 -61
  158. package/src/plugins/sqlite/sqlite_ohos_stub.cj +0 -91
package/src/ipc_hub.cj CHANGED
@@ -1,337 +1,337 @@
1
- package cjTauri
2
-
3
- import std.collection.*
4
- import stdx.encoding.json.*
5
-
6
- /**
7
- * 命令处理器接口(对标 Tauri command)
8
- * 实现方注册到 IpcHub,前端 invoke 时经 capability 校验后调用。
9
- */
10
- public interface CommandHandler {
11
- /**
12
- * 处理命令并返回结果 JSON 值。
13
- * @param cmd 命令名
14
- * @param args 前端传入的参数对象
15
- * @param ipc IPC 上下文(可发事件、取窗口信息)
16
- * @return 成功返回值;如需失败可在实现中抛 CommandException
17
- */
18
- func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue
19
- }
20
-
21
- /**
22
- * 命令执行异常:message 将作为 error 返回给前端
23
- */
24
- public class CommandException <: Exception {
25
- public init(msg: String) {
26
- super(msg)
27
- }
28
- }
29
-
30
- /**
31
- * IPC 上下文:命令执行时可访问
32
- */
33
- public class IpcContext {
34
- public let window: String
35
- private let hub: IpcHub
36
-
37
- public init(window: String, hub: IpcHub) {
38
- this.window = window
39
- this.hub = hub
40
- }
41
-
42
- /**
43
- * 向当前窗口推送事件
44
- */
45
- public func emit(event: String, payload: JsonValue): Unit {
46
- this.hub.emitToWindow(event, payload, this.window)
47
- }
48
-
49
- /**
50
- * 向当前窗口推送「只保最新」的事件:同一帧内同名事件只投最后一次 payload,中间值丢弃
51
- *
52
- * 高频进度类事件用;需要每条都到达的继续用 `emit`。选型理由见 docs/IPC-通信机制.md §6 第 5 项。
53
- */
54
- public func emitLatest(event: String, payload: JsonValue): Unit {
55
- this.hub.emitLatest(event, payload, this.window)
56
- }
57
-
58
- /**
59
- * 打开开发者工具(宿主实现),供内置 system:devtools 与应用自定义命令使用
60
- */
61
- public func openDevTools(): Unit {
62
- this.hub.openDevTools()
63
- }
64
-
65
- /**
66
- * 本窗口的宿主能力位(`CJ_CAP_*` 按位或),供内置 system:host 使用
67
- *
68
- * 取的是**发起命令那个窗口**的位域:能力位是宿主级事实,多窗口下可能不同(如 Linux 托盘
69
- * 取决于桥怎么编的)。窗口没登记 / 宿主还没建句柄时返回 0——后者不代表「平台没这本事」,
70
- * 所以页面自检应在窗口起来之后做(与 examples/menu 探针的口径一致)。
71
- */
72
- public func hostCapabilities(): Int32 {
73
- return this.hub.hostCapabilities(this.window)
74
- }
75
-
76
- /**
77
- * 解析并校验一个路径参数(RFC-003 第 4 期)。
78
- *
79
- * 带路径参数的插件**一律**用它取代自己拼路径:清单里为插件写了 scope 就按
80
- * allow/deny 判定,越界抛业务错误;没写就维持旧行为(全量放行,启动时有提示)。
81
- * 返回值是规范化后的路径(绝对、已解析软链接),直接交给 std.fs 用即可。
82
- */
83
- public func resolveScopedPath(plugin: String, rawPath: String): String {
84
- return this.hub.resolveScopedPath(plugin, rawPath)
85
- }
86
-
87
- /**
88
- * 取某插件的 scope 规则**本体**(不给判定):给「匹配语义不是文件路径」的插件用——
89
- * 如 http 插件把 allow/deny 模式当**主机名**匹配(不能先走 normalizeScopePath 的
90
- * 文件路径折叠)。判定本身仍走共享层 `scopeAllows`,插件不自己写第二份 glob。
91
- */
92
- public func scopeRuleFor(plugin: String): Option<ScopeRule> {
93
- return this.hub.scopeRuleFor(plugin)
94
- }
95
- }
96
-
97
- /**
98
- * IPC 中心:命令注册、能力校验、消息分发、事件推送
99
- */
100
- public class IpcHub {
101
- private var commands: HashMap<String, CommandHandler> = HashMap<String, CommandHandler>()
102
- private var capabilities: CapabilityRegistry = CapabilityRegistry()
103
- // 事件 / 回执投递通道:由宿主层注入(把 JSON 字符串投递给前端 JS)。
104
- // 第二个参数是**目标窗口 label**:批次 2 起由 TauriApp 按窗口注册表派发,
105
- // 空串 = 广播(投给所有已注册窗口),非空 = 只投该窗口。
106
- public var jsSink: (String, String) -> Unit = { _, _ => }
107
- // 开发者工具通道:由 TauriApp 注入宿主实现
108
- public var devToolsSink: () -> Unit = { => }
109
- // 宿主能力位通道:由 TauriApp 注入(window label → CJ_CAP_* 位域),供 system:host 用。
110
- // 为什么走注入而不是让 IpcHub 自己拿窗口注册表:注册表归 TauriApp(AppHost 契约),
111
- // hub 只认「label → 位域」这一个函数——依赖方向保持 app → ipc_hub 单向。
112
- public var hostCapabilitiesSink: (String) -> Int32 = { _ => 0 }
113
- // 后端事件监听器(对标 Tauri 的 Rust 侧 Listener):事件名 → 处理函数。
114
- // 只在前端 emit 时被调用;后端自己 emit 给前端的事件**不**回流到这里(否则「后端广播 → 监听器再广播」会自激)。
115
- private var eventListeners: HashMap<String, ArrayList<(JsonValue) -> Unit>> =
116
- HashMap<String, ArrayList<(JsonValue) -> Unit>>()
117
-
118
- public init() {}
119
-
120
- public func setCapabilities(reg: CapabilityRegistry): Unit {
121
- this.capabilities = reg
122
- }
123
-
124
- /**
125
- * 打开开发者工具(宿主注入的实现;未注入时空操作)
126
- */
127
- public func openDevTools(): Unit {
128
- this.devToolsSink()
129
- }
130
-
131
- /**
132
- * 宿主能力位(宿主注入的实现;未注入 / 窗口未知时返回 0)
133
- */
134
- public func hostCapabilities(window: String): Int32 {
135
- return this.hostCapabilitiesSink(window)
136
- }
137
-
138
- public func register(cmd: String, handler: CommandHandler): Unit {
139
- this.commands.add(cmd, handler)
140
- }
141
-
142
- public func hasCommand(cmd: String): Bool {
143
- return this.commands.contains(cmd)
144
- }
145
-
146
- /**
147
- * 路径 scope 校验(RFC-003 第 4 期):IpcContext.resolveScopedPath 的实现体。
148
- *
149
- * 校验只在**真的有插件来问**时发生——没有为它声明 scope 的插件完全不受影响(旧清单行为不变)。
150
- * 拒绝时 stderr 留一行凭证(前缀统一好 grep),前端拿到的是可读的业务错误而不是宿主崩溃。
151
- */
152
- public func resolveScopedPath(plugin: String, rawPath: String): String {
153
- let resolved = normalizeScopePath(rawPath)
154
- match (this.capabilities.scopeForPlugin(plugin)) {
155
- case Some(rule) =>
156
- if (!scopeAllows(rule, resolved)) {
157
- eprintln("[cj-tauri] scope denied (${plugin}): ${rawPath} -> ${resolved}")
158
- throw CommandException("${plugin}: path is outside the authorized scope")
159
- }
160
- case None => ()
161
- }
162
- return resolved
163
- }
164
-
165
- /**
166
- * 取某插件的 scope 规则**本体**(不给判定):给「匹配语义不是文件路径」的插件用——
167
- * 如 http 插件把 allow/deny 模式当**主机名**匹配(不能先走 normalizeScopePath 的
168
- * 文件路径折叠,那会把 `api.example.com` 当成相对路径去拼接)。判定本身仍走共享层
169
- * `scopeAllows`,插件不自己写第二份 glob。
170
- */
171
- public func scopeRuleFor(plugin: String): Option<ScopeRule> {
172
- return this.capabilities.scopeForPlugin(plugin)
173
- }
174
-
175
- /**
176
- * 注册后端事件监听器(对标 Tauri Rust 侧的 `app.listen(...)`)
177
- *
178
- * 调用时机:装配期(插件在自己的 setup() 里注册)。同一事件可注册多个监听器,按注册顺序调用;
179
- * 暂时不提供注销(emit 分发是遍历快照式的短回调,注册期外再改表属于未定义用法)。
180
- */
181
- public func listenEvent(event: String, handler: (JsonValue) -> Unit): Unit {
182
- if (!this.eventListeners.contains(event)) {
183
- this.eventListeners.add(event, ArrayList<(JsonValue) -> Unit>())
184
- }
185
- this.eventListeners.get(event).getOrThrow().add(handler)
186
- }
187
-
188
- /**
189
- * 处理来自前端的原始消息(JSON 字符串):invoke 与 emit 两条路径
190
- *
191
- * 全链路:解析 → capability 校验(调用线程上同步完成)→ 执行(worker 线程)→ 结果/错误回投
192
- */
193
- public func handleRawMessage(raw: String): Unit {
194
- if (let Some(req) <- InvokeRequest.parse(raw)) {
195
- this.handleInvoke(req)
196
- return
197
- }
198
- if (let Some(ev) <- EmitRequest.parse(raw)) {
199
- this.handleEmit(ev)
200
- return
201
- }
202
- // 两条解析都不认:只要报文里读得出 id 就回一条 reject——前端那条 promise 否则会永久 pending,
203
- // 比一条明确的报错难排查得多(前端 invoke 没有超时参数)。
204
- eprintln("[cj-tauri] invalid IPC message: ${raw}")
205
- if (let Some(id) <- IpcMessage.peekId(raw)) {
206
- // 报文非法时读不出窗口,只能广播一份:id 只会挂在某一个页面的 pending 上,
207
- // 别的窗口的 _dispatch 找不到它、忽略即可——这样发起方那条 promise 才不会永久 pending。
208
- this.reject(id, "invalid IPC message", "")
209
- }
210
- }
211
-
212
- /**
213
- * 校验与执行分两段,这是当前的线程模型(改之前想清楚,见 RFC-001 §8 第 8 条):
214
- * 1/2. 「未授权」「未注册」在**调用线程上同步**拒绝——前端拿到的报错要即时、顺序确定,
215
- * 也免得为一条注定失败的请求白开一个线程;
216
- * 3. 真正执行挪到 worker 线程(spawn)。此前 handler 直接在宿主 UI 线程上跑完,
217
- * 慢命令(`shell:exec` 跑一个进程、等原生对话框)会把窗口钉住——不重绘、不响应事件。
218
- * 结果经 jsSink 回投即可:两平台桥的 runJs 都是「加锁入队 + 投递到宿主 UI 线程」
219
- * (bridge_linux.c 的 g_idle_add → flush_pending_js,bridge_win.c 的 PostMessageW(WM_CJT_FLUSH) → flush_js),
220
- * 从任意线程调用都安全。也正因如此,worker 里**不要**绕过桥直接调 GTK / WebKit:
221
- * 仓颉轻量线程的堆上协程栈会被 JSC 的栈边界校验 abort(AGENTS.md §4)。
222
- * 同一命令被并发调用**不保证执行顺序**(前端按 promise id 匹配,不会串包)。
223
- */
224
- private func handleInvoke(req: InvokeRequest): Unit {
225
- // 1. 能力校验(默认最小权限)
226
- if (!this.capabilities.canInvoke(req.cmd)) {
227
- this.reject(req.id, "command not allowed: ${req.cmd}", req.window)
228
- return
229
- }
230
- // 2. 命令是否存在
231
- if (!this.commands.contains(req.cmd)) {
232
- this.reject(req.id, "command not registered: ${req.cmd}", req.window)
233
- return
234
- }
235
- // 3. 交给 worker 线程执行:调用线程立刻返回,UI 不被命令阻塞
236
- let _ = spawn { => this.runCommand(req) }
237
- }
238
-
239
- /** worker 线程上的实际执行:跑完(或抛错)就把 resolve / reject 回投给前端 */
240
- private func runCommand(req: InvokeRequest): Unit {
241
- try {
242
- let handler = this.commands.get(req.cmd).getOrThrow()
243
- let ctx = IpcContext(req.window, this)
244
- let result = handler.handle(req.cmd, req.args, ctx)
245
- this.jsSink(IpcMessage.resolve(req.id, result), req.window)
246
- } catch (e: CommandException) {
247
- // 应用级拒绝(作者主动抛的业务失败)也要留痕:前端可能压根没接 catch,
248
- // 只回 reject 的话「这条命令失败了」在页面之外毫无线索——事件监听器路早有同款日志。
249
- // 与下面的非预期失败分开措辞,便于 `grep command rejected` / `grep command failed` 分流。
250
- eprintln("[cj-tauri] command rejected (${req.cmd}): ${e.message}")
251
- this.reject(req.id, e.message, req.window)
252
- } catch (e: Exception) {
253
- eprintln("[cj-tauri] command failed (${req.cmd}): ${e}")
254
- this.reject(req.id, "command failed: ${e}", req.window)
255
- }
256
- }
257
-
258
- /**
259
- * 回一条 reject;window 是回投目标(空串 = 广播,只用在读不出窗口的非法报文路径)
260
- */
261
- private func reject(id: Int64, error: String, window: String): Unit {
262
- this.jsSink(IpcMessage.reject(id, error), window)
263
- }
264
-
265
- /**
266
- * 前端 emit 事件:校验(同步,调用线程)→ 投给后端监听器(worker 线程)→ resolve。
267
- *
268
- * 为什么校验同步、投递异步:与命令同一套理由——未授权要在调用线程上立刻拒(报错即时、顺序确定),
269
- * 而回调可能慢,不能钉住宿主 UI 线程。
270
- * 「没有监听器」不是错误:事件是广播语义,没人听很正常(Tauri 同样不报),照常 resolve。
271
- * 与 Tauri 的差异(不广播回页面)见 EmitRequest 的文档注释。
272
- */
273
- private func handleEmit(req: EmitRequest): Unit {
274
- if (!this.capabilities.canEmit(req.event)) {
275
- eprintln("[cj-tauri] event not allowed: ${req.event}")
276
- this.reject(req.id, "event not allowed: ${req.event}", req.window)
277
- return
278
- }
279
- let _ = spawn { => this.dispatchEvent(req) }
280
- }
281
-
282
- /** worker 线程上把事件投给监听器,然后 resolve 发送方 */
283
- private func dispatchEvent(req: EmitRequest): Unit {
284
- match (this.eventListeners.get(req.event)) {
285
- case Some(handlers) =>
286
- for (h in handlers) {
287
- // 单个监听器抛错只记 stderr:事件是广播,一个听众崩了不该牵连别的听众和发送方
288
- try {
289
- h(req.payload)
290
- } catch (e: Exception) {
291
- eprintln("[cj-tauri] event listener failed (${req.event}): ${e}")
292
- }
293
- }
294
- case None => ()
295
- }
296
- this.jsSink(IpcMessage.resolve(req.id, JsonObject()), req.window)
297
- }
298
-
299
- /**
300
- * 广播事件到所有窗口(对标 Tauri 的 `emit`):报文不带 window 字段,各窗口的桥自行放行。
301
- * 批次 2 起投递侧按 label 派发,空串即「已注册的每个窗口各投一份」。
302
- */
303
- public func emitAll(event: String, payload: JsonValue): Unit {
304
- this.emitToWindow(event, payload, "")
305
- }
306
-
307
- /**
308
- * 推送「只保最新」的事件到指定窗口(经 capability 校验,与 `emitToWindow` 同一套白名单)
309
- *
310
- * 与 `emitToWindow` 只差页面侧的合并语义(报文多 `coalesce: true`):同一帧内同名事件只投
311
- * 最后一次 payload。**默认不合并**——静默丢数据比慢更难排查,要不要这条路必须调用方显式选
312
- * (Tauri v2 核心同样不做合并,对照见 docs/IPC-通信机制.md §6 第 5 项)。
313
- *
314
- * @param window 接收窗口的 label;空串 = 广播(语义同 `emitToWindow`)
315
- */
316
- public func emitLatest(event: String, payload: JsonValue, window: String): Unit {
317
- if (!this.capabilities.canEmit(event)) {
318
- eprintln("[cj-tauri] event not allowed: ${event}")
319
- return
320
- }
321
- this.jsSink(IpcMessage.eventLatest(event, payload, window), window)
322
- }
323
-
324
- /**
325
- * 推送事件到指定窗口(经 capability 校验)
326
- *
327
- * @param window 接收窗口的 label;空串 = 广播。此前这个参数被丢掉(报文里也无 window 字段),
328
- * 定向推送实际退化成广播——协议补上字段后,多窗口的过滤由各页面的桥完成。
329
- */
330
- public func emitToWindow(event: String, payload: JsonValue, window: String): Unit {
331
- if (!this.capabilities.canEmit(event)) {
332
- eprintln("[cj-tauri] event not allowed: ${event}")
333
- return
334
- }
335
- this.jsSink(IpcMessage.event(event, payload, window), window)
336
- }
337
- }
1
+ package cjTauri
2
+
3
+ import std.collection.*
4
+ import stdx.encoding.json.*
5
+
6
+ /**
7
+ * 命令处理器接口(对标 Tauri command)
8
+ * 实现方注册到 IpcHub,前端 invoke 时经 capability 校验后调用。
9
+ */
10
+ public interface CommandHandler {
11
+ /**
12
+ * 处理命令并返回结果 JSON 值。
13
+ * @param cmd 命令名
14
+ * @param args 前端传入的参数对象
15
+ * @param ipc IPC 上下文(可发事件、取窗口信息)
16
+ * @return 成功返回值;如需失败可在实现中抛 CommandException
17
+ */
18
+ func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue
19
+ }
20
+
21
+ /**
22
+ * 命令执行异常:message 将作为 error 返回给前端
23
+ */
24
+ public class CommandException <: Exception {
25
+ public init(msg: String) {
26
+ super(msg)
27
+ }
28
+ }
29
+
30
+ /**
31
+ * IPC 上下文:命令执行时可访问
32
+ */
33
+ public class IpcContext {
34
+ public let window: String
35
+ private let hub: IpcHub
36
+
37
+ public init(window: String, hub: IpcHub) {
38
+ this.window = window
39
+ this.hub = hub
40
+ }
41
+
42
+ /**
43
+ * 向当前窗口推送事件
44
+ */
45
+ public func emit(event: String, payload: JsonValue): Unit {
46
+ this.hub.emitToWindow(event, payload, this.window)
47
+ }
48
+
49
+ /**
50
+ * 向当前窗口推送「只保最新」的事件:同一帧内同名事件只投最后一次 payload,中间值丢弃
51
+ *
52
+ * 高频进度类事件用;需要每条都到达的继续用 `emit`。选型理由见 docs/IPC-通信机制.md §6 第 5 项。
53
+ */
54
+ public func emitLatest(event: String, payload: JsonValue): Unit {
55
+ this.hub.emitLatest(event, payload, this.window)
56
+ }
57
+
58
+ /**
59
+ * 打开开发者工具(宿主实现),供内置 system:devtools 与应用自定义命令使用
60
+ */
61
+ public func openDevTools(): Unit {
62
+ this.hub.openDevTools()
63
+ }
64
+
65
+ /**
66
+ * 本窗口的宿主能力位(`CJ_CAP_*` 按位或),供内置 system:host 使用
67
+ *
68
+ * 取的是**发起命令那个窗口**的位域:能力位是宿主级事实,多窗口下可能不同(如 Linux 托盘
69
+ * 取决于桥怎么编的)。窗口没登记 / 宿主还没建句柄时返回 0——后者不代表「平台没这本事」,
70
+ * 所以页面自检应在窗口起来之后做(与 examples/menu 探针的口径一致)。
71
+ */
72
+ public func hostCapabilities(): Int32 {
73
+ return this.hub.hostCapabilities(this.window)
74
+ }
75
+
76
+ /**
77
+ * 解析并校验一个路径参数(RFC-003 第 4 期)。
78
+ *
79
+ * 带路径参数的插件**一律**用它取代自己拼路径:清单里为插件写了 scope 就按
80
+ * allow/deny 判定,越界抛业务错误;没写就维持旧行为(全量放行,启动时有提示)。
81
+ * 返回值是规范化后的路径(绝对、已解析软链接),直接交给 std.fs 用即可。
82
+ */
83
+ public func resolveScopedPath(plugin: String, rawPath: String): String {
84
+ return this.hub.resolveScopedPath(plugin, rawPath)
85
+ }
86
+
87
+ /**
88
+ * 取某插件的 scope 规则**本体**(不给判定):给「匹配语义不是文件路径」的插件用——
89
+ * 如 http 插件把 allow/deny 模式当**主机名**匹配(不能先走 normalizeScopePath 的
90
+ * 文件路径折叠)。判定本身仍走共享层 `scopeAllows`,插件不自己写第二份 glob。
91
+ */
92
+ public func scopeRuleFor(plugin: String): Option<ScopeRule> {
93
+ return this.hub.scopeRuleFor(plugin)
94
+ }
95
+ }
96
+
97
+ /**
98
+ * IPC 中心:命令注册、能力校验、消息分发、事件推送
99
+ */
100
+ public class IpcHub {
101
+ private var commands: HashMap<String, CommandHandler> = HashMap<String, CommandHandler>()
102
+ private var capabilities: CapabilityRegistry = CapabilityRegistry()
103
+ // 事件 / 回执投递通道:由宿主层注入(把 JSON 字符串投递给前端 JS)。
104
+ // 第二个参数是**目标窗口 label**:批次 2 起由 TauriApp 按窗口注册表派发,
105
+ // 空串 = 广播(投给所有已注册窗口),非空 = 只投该窗口。
106
+ public var jsSink: (String, String) -> Unit = { _, _ => }
107
+ // 开发者工具通道:由 TauriApp 注入宿主实现
108
+ public var devToolsSink: () -> Unit = { => }
109
+ // 宿主能力位通道:由 TauriApp 注入(window label → CJ_CAP_* 位域),供 system:host 用。
110
+ // 为什么走注入而不是让 IpcHub 自己拿窗口注册表:注册表归 TauriApp(AppHost 契约),
111
+ // hub 只认「label → 位域」这一个函数——依赖方向保持 app → ipc_hub 单向。
112
+ public var hostCapabilitiesSink: (String) -> Int32 = { _ => 0 }
113
+ // 后端事件监听器(对标 Tauri 的 Rust 侧 Listener):事件名 → 处理函数。
114
+ // 只在前端 emit 时被调用;后端自己 emit 给前端的事件**不**回流到这里(否则「后端广播 → 监听器再广播」会自激)。
115
+ private var eventListeners: HashMap<String, ArrayList<(JsonValue) -> Unit>> =
116
+ HashMap<String, ArrayList<(JsonValue) -> Unit>>()
117
+
118
+ public init() {}
119
+
120
+ public func setCapabilities(reg: CapabilityRegistry): Unit {
121
+ this.capabilities = reg
122
+ }
123
+
124
+ /**
125
+ * 打开开发者工具(宿主注入的实现;未注入时空操作)
126
+ */
127
+ public func openDevTools(): Unit {
128
+ this.devToolsSink()
129
+ }
130
+
131
+ /**
132
+ * 宿主能力位(宿主注入的实现;未注入 / 窗口未知时返回 0)
133
+ */
134
+ public func hostCapabilities(window: String): Int32 {
135
+ return this.hostCapabilitiesSink(window)
136
+ }
137
+
138
+ public func register(cmd: String, handler: CommandHandler): Unit {
139
+ this.commands.add(cmd, handler)
140
+ }
141
+
142
+ public func hasCommand(cmd: String): Bool {
143
+ return this.commands.contains(cmd)
144
+ }
145
+
146
+ /**
147
+ * 路径 scope 校验(RFC-003 第 4 期):IpcContext.resolveScopedPath 的实现体。
148
+ *
149
+ * 校验只在**真的有插件来问**时发生——没有为它声明 scope 的插件完全不受影响(旧清单行为不变)。
150
+ * 拒绝时 stderr 留一行凭证(前缀统一好 grep),前端拿到的是可读的业务错误而不是宿主崩溃。
151
+ */
152
+ public func resolveScopedPath(plugin: String, rawPath: String): String {
153
+ let resolved = normalizeScopePath(rawPath)
154
+ match (this.capabilities.scopeForPlugin(plugin)) {
155
+ case Some(rule) =>
156
+ if (!scopeAllows(rule, resolved)) {
157
+ eprintln("[cj-tauri] scope denied (${plugin}): ${rawPath} -> ${resolved}")
158
+ throw CommandException("${plugin}: path is outside the authorized scope")
159
+ }
160
+ case None => ()
161
+ }
162
+ return resolved
163
+ }
164
+
165
+ /**
166
+ * 取某插件的 scope 规则**本体**(不给判定):给「匹配语义不是文件路径」的插件用——
167
+ * 如 http 插件把 allow/deny 模式当**主机名**匹配(不能先走 normalizeScopePath 的
168
+ * 文件路径折叠,那会把 `api.example.com` 当成相对路径去拼接)。判定本身仍走共享层
169
+ * `scopeAllows`,插件不自己写第二份 glob。
170
+ */
171
+ public func scopeRuleFor(plugin: String): Option<ScopeRule> {
172
+ return this.capabilities.scopeForPlugin(plugin)
173
+ }
174
+
175
+ /**
176
+ * 注册后端事件监听器(对标 Tauri Rust 侧的 `app.listen(...)`)
177
+ *
178
+ * 调用时机:装配期(插件在自己的 setup() 里注册)。同一事件可注册多个监听器,按注册顺序调用;
179
+ * 暂时不提供注销(emit 分发是遍历快照式的短回调,注册期外再改表属于未定义用法)。
180
+ */
181
+ public func listenEvent(event: String, handler: (JsonValue) -> Unit): Unit {
182
+ if (!this.eventListeners.contains(event)) {
183
+ this.eventListeners.add(event, ArrayList<(JsonValue) -> Unit>())
184
+ }
185
+ this.eventListeners.get(event).getOrThrow().add(handler)
186
+ }
187
+
188
+ /**
189
+ * 处理来自前端的原始消息(JSON 字符串):invoke 与 emit 两条路径
190
+ *
191
+ * 全链路:解析 → capability 校验(调用线程上同步完成)→ 执行(worker 线程)→ 结果/错误回投
192
+ */
193
+ public func handleRawMessage(raw: String): Unit {
194
+ if (let Some(req) <- InvokeRequest.parse(raw)) {
195
+ this.handleInvoke(req)
196
+ return
197
+ }
198
+ if (let Some(ev) <- EmitRequest.parse(raw)) {
199
+ this.handleEmit(ev)
200
+ return
201
+ }
202
+ // 两条解析都不认:只要报文里读得出 id 就回一条 reject——前端那条 promise 否则会永久 pending,
203
+ // 比一条明确的报错难排查得多(前端 invoke 没有超时参数)。
204
+ eprintln("[cj-tauri] invalid IPC message: ${raw}")
205
+ if (let Some(id) <- IpcMessage.peekId(raw)) {
206
+ // 报文非法时读不出窗口,只能广播一份:id 只会挂在某一个页面的 pending 上,
207
+ // 别的窗口的 _dispatch 找不到它、忽略即可——这样发起方那条 promise 才不会永久 pending。
208
+ this.reject(id, "invalid IPC message", "")
209
+ }
210
+ }
211
+
212
+ /**
213
+ * 校验与执行分两段,这是当前的线程模型(改之前想清楚,见 RFC-001 §8 第 8 条):
214
+ * 1/2. 「未授权」「未注册」在**调用线程上同步**拒绝——前端拿到的报错要即时、顺序确定,
215
+ * 也免得为一条注定失败的请求白开一个线程;
216
+ * 3. 真正执行挪到 worker 线程(spawn)。此前 handler 直接在宿主 UI 线程上跑完,
217
+ * 慢命令(`shell:exec` 跑一个进程、等原生对话框)会把窗口钉住——不重绘、不响应事件。
218
+ * 结果经 jsSink 回投即可:两平台桥的 runJs 都是「加锁入队 + 投递到宿主 UI 线程」
219
+ * (bridge_linux.c 的 g_idle_add → flush_pending_js,bridge_win.c 的 PostMessageW(WM_CJT_FLUSH) → flush_js),
220
+ * 从任意线程调用都安全。也正因如此,worker 里**不要**绕过桥直接调 GTK / WebKit:
221
+ * 仓颉轻量线程的堆上协程栈会被 JSC 的栈边界校验 abort(AGENTS.md §4)。
222
+ * 同一命令被并发调用**不保证执行顺序**(前端按 promise id 匹配,不会串包)。
223
+ */
224
+ private func handleInvoke(req: InvokeRequest): Unit {
225
+ // 1. 能力校验(默认最小权限)
226
+ if (!this.capabilities.canInvoke(req.cmd)) {
227
+ this.reject(req.id, "command not allowed: ${req.cmd}", req.window)
228
+ return
229
+ }
230
+ // 2. 命令是否存在
231
+ if (!this.commands.contains(req.cmd)) {
232
+ this.reject(req.id, "command not registered: ${req.cmd}", req.window)
233
+ return
234
+ }
235
+ // 3. 交给 worker 线程执行:调用线程立刻返回,UI 不被命令阻塞
236
+ let _ = spawn { => this.runCommand(req) }
237
+ }
238
+
239
+ /** worker 线程上的实际执行:跑完(或抛错)就把 resolve / reject 回投给前端 */
240
+ private func runCommand(req: InvokeRequest): Unit {
241
+ try {
242
+ let handler = this.commands.get(req.cmd).getOrThrow()
243
+ let ctx = IpcContext(req.window, this)
244
+ let result = handler.handle(req.cmd, req.args, ctx)
245
+ this.jsSink(IpcMessage.resolve(req.id, result), req.window)
246
+ } catch (e: CommandException) {
247
+ // 应用级拒绝(作者主动抛的业务失败)也要留痕:前端可能压根没接 catch,
248
+ // 只回 reject 的话「这条命令失败了」在页面之外毫无线索——事件监听器路早有同款日志。
249
+ // 与下面的非预期失败分开措辞,便于 `grep command rejected` / `grep command failed` 分流。
250
+ eprintln("[cj-tauri] command rejected (${req.cmd}): ${e.message}")
251
+ this.reject(req.id, e.message, req.window)
252
+ } catch (e: Exception) {
253
+ eprintln("[cj-tauri] command failed (${req.cmd}): ${e}")
254
+ this.reject(req.id, "command failed: ${e}", req.window)
255
+ }
256
+ }
257
+
258
+ /**
259
+ * 回一条 reject;window 是回投目标(空串 = 广播,只用在读不出窗口的非法报文路径)
260
+ */
261
+ private func reject(id: Int64, error: String, window: String): Unit {
262
+ this.jsSink(IpcMessage.reject(id, error), window)
263
+ }
264
+
265
+ /**
266
+ * 前端 emit 事件:校验(同步,调用线程)→ 投给后端监听器(worker 线程)→ resolve。
267
+ *
268
+ * 为什么校验同步、投递异步:与命令同一套理由——未授权要在调用线程上立刻拒(报错即时、顺序确定),
269
+ * 而回调可能慢,不能钉住宿主 UI 线程。
270
+ * 「没有监听器」不是错误:事件是广播语义,没人听很正常(Tauri 同样不报),照常 resolve。
271
+ * 与 Tauri 的差异(不广播回页面)见 EmitRequest 的文档注释。
272
+ */
273
+ private func handleEmit(req: EmitRequest): Unit {
274
+ if (!this.capabilities.canEmit(req.event)) {
275
+ eprintln("[cj-tauri] event not allowed: ${req.event}")
276
+ this.reject(req.id, "event not allowed: ${req.event}", req.window)
277
+ return
278
+ }
279
+ let _ = spawn { => this.dispatchEvent(req) }
280
+ }
281
+
282
+ /** worker 线程上把事件投给监听器,然后 resolve 发送方 */
283
+ private func dispatchEvent(req: EmitRequest): Unit {
284
+ match (this.eventListeners.get(req.event)) {
285
+ case Some(handlers) =>
286
+ for (h in handlers) {
287
+ // 单个监听器抛错只记 stderr:事件是广播,一个听众崩了不该牵连别的听众和发送方
288
+ try {
289
+ h(req.payload)
290
+ } catch (e: Exception) {
291
+ eprintln("[cj-tauri] event listener failed (${req.event}): ${e}")
292
+ }
293
+ }
294
+ case None => ()
295
+ }
296
+ this.jsSink(IpcMessage.resolve(req.id, JsonObject()), req.window)
297
+ }
298
+
299
+ /**
300
+ * 广播事件到所有窗口(对标 Tauri 的 `emit`):报文不带 window 字段,各窗口的桥自行放行。
301
+ * 批次 2 起投递侧按 label 派发,空串即「已注册的每个窗口各投一份」。
302
+ */
303
+ public func emitAll(event: String, payload: JsonValue): Unit {
304
+ this.emitToWindow(event, payload, "")
305
+ }
306
+
307
+ /**
308
+ * 推送「只保最新」的事件到指定窗口(经 capability 校验,与 `emitToWindow` 同一套白名单)
309
+ *
310
+ * 与 `emitToWindow` 只差页面侧的合并语义(报文多 `coalesce: true`):同一帧内同名事件只投
311
+ * 最后一次 payload。**默认不合并**——静默丢数据比慢更难排查,要不要这条路必须调用方显式选
312
+ * (Tauri v2 核心同样不做合并,对照见 docs/IPC-通信机制.md §6 第 5 项)。
313
+ *
314
+ * @param window 接收窗口的 label;空串 = 广播(语义同 `emitToWindow`)
315
+ */
316
+ public func emitLatest(event: String, payload: JsonValue, window: String): Unit {
317
+ if (!this.capabilities.canEmit(event)) {
318
+ eprintln("[cj-tauri] event not allowed: ${event}")
319
+ return
320
+ }
321
+ this.jsSink(IpcMessage.eventLatest(event, payload, window), window)
322
+ }
323
+
324
+ /**
325
+ * 推送事件到指定窗口(经 capability 校验)
326
+ *
327
+ * @param window 接收窗口的 label;空串 = 广播。此前这个参数被丢掉(报文里也无 window 字段),
328
+ * 定向推送实际退化成广播——协议补上字段后,多窗口的过滤由各页面的桥完成。
329
+ */
330
+ public func emitToWindow(event: String, payload: JsonValue, window: String): Unit {
331
+ if (!this.capabilities.canEmit(event)) {
332
+ eprintln("[cj-tauri] event not allowed: ${event}")
333
+ return
334
+ }
335
+ this.jsSink(IpcMessage.event(event, payload, window), window)
336
+ }
337
+ }