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
@@ -1,6 +1,7 @@
1
1
  package cjTauri.tests
2
2
 
3
3
  import cjTauri.*
4
+ import cjTauri.plugins.dialog.*
4
5
  import std.collection.*
5
6
  import stdx.encoding.json.*
6
7
  import std.unittest.*
@@ -18,14 +19,22 @@ func messageArgs(text: String): JsonObject {
18
19
  return o
19
20
  }
20
21
 
22
+ /** 造一个只带 title 的对象 */
23
+ func titleArgs(t: String): JsonObject {
24
+ var o = JsonObject()
25
+ o.put("title", JsonString(t))
26
+ return o
27
+ }
28
+
21
29
  @Test
22
30
  func testDialogPluginSurface(): Unit {
23
31
  let p = DialogPlugin()
24
32
  @Expect(p.name(), "dialog")
25
33
  let names = pluginCommandNames(p)
26
- @Expect(names.size, 3)
34
+ @Expect(names.size, 4)
27
35
  var hasOpen = false
28
36
  var hasSave = false
37
+ var hasPick = false
29
38
  var hasMessage = false
30
39
  for (n in names) {
31
40
  if (n == "dialog:open") {
@@ -34,12 +43,16 @@ func testDialogPluginSurface(): Unit {
34
43
  if (n == "dialog:save") {
35
44
  hasSave = true
36
45
  }
46
+ if (n == "dialog:pickFolder") {
47
+ hasPick = true
48
+ }
37
49
  if (n == "dialog:message") {
38
50
  hasMessage = true
39
51
  }
40
52
  }
41
53
  @Expect(hasOpen, true)
42
54
  @Expect(hasSave, true)
55
+ @Expect(hasPick, true)
43
56
  @Expect(hasMessage, true)
44
57
  }
45
58
 
@@ -49,6 +62,7 @@ func testDialogPluginShimTargetsItsOwnNamespace(): Unit {
49
62
  @Expect(js.contains("t.dialog"), true)
50
63
  @Expect(js.contains("'dialog:open'"), true)
51
64
  @Expect(js.contains("'dialog:save'"), true)
65
+ @Expect(js.contains("'dialog:pickFolder'"), true)
52
66
  @Expect(js.contains("'dialog:message'"), true)
53
67
  }
54
68
 
@@ -57,16 +71,36 @@ func testDialogPluginDeclaresPermissionSets(): Unit {
57
71
  let sets = pluginPermissionSets(DialogPlugin())
58
72
  @Expect(sets.size, 2)
59
73
  let files = sets.get("dialog:files").getOrThrow()
60
- @Expect(files.size, 2)
74
+ @Expect(files.size, 3)
61
75
  var filesHasMessage = false
62
76
  for (m in files) {
63
77
  if (m == "dialog:message") {
64
78
  filesHasMessage = true
65
79
  }
66
80
  }
67
- // 只想挑文件的应用不该顺带拿到「弹任意提示框」
81
+ // 只想挑文件(或目录)的应用不该顺带拿到「弹任意提示框」
68
82
  @Expect(filesHasMessage, false)
69
- @Expect(sets.get("dialog:default").getOrThrow().size, 3)
83
+ @Expect(sets.get("dialog:default").getOrThrow().size, 4)
84
+ }
85
+
86
+ @Test
87
+ func testDialogPickFolderGoesToFolderDialog(): Unit {
88
+ // 选目录走的是独立的宿主方法(不是文件框那条):桩把标题记下来,用例断言转交的确实是 title
89
+ let host = FakeHost("main")
90
+ let cmds = DialogCommands()
91
+ cmds.attachHost(host)
92
+ let ctx = IpcContext("main", IpcHub())
93
+
94
+ cmds.handle("dialog:pickFolder", titleArgs("选工作区"), ctx)
95
+ @Expect(host.folderDialogTitle, "选工作区")
96
+
97
+ // 不给标题时兜底,别把空标题交给平台(空串会让平台用自己的默认文案)
98
+ cmds.handle("dialog:pickFolder", JsonObject(), ctx)
99
+ @Expect(host.folderDialogTitle, "选择文件夹")
100
+
101
+ // 用户取消(桩恒回空串)时命令的返回也是空串——上游按「没选」处理
102
+ let r = cmds.handle("dialog:pickFolder", JsonObject(), ctx)
103
+ @Expect(r.toString(), "\"\"")
70
104
  }
71
105
 
72
106
  @Test
@@ -141,3 +175,63 @@ func testDialogRejectsUnknownCommand(): Unit {
141
175
  }
142
176
  @Expect(threw, true)
143
177
  }
178
+
179
+ /**
180
+ * 桩侧「label → 宿主」解析器(模拟 `app.hostOf`)
181
+ *
182
+ * 用独立函数而不是内联 lambda:多分支要能 return,写成独立函数最稳。
183
+ */
184
+ func routeProbeHost(label: String, mainHost: FakeHost, secondHost: FakeHost): Option<WebViewHost> {
185
+ if (label == "second") {
186
+ return Some(secondHost)
187
+ }
188
+ if (label == "main") {
189
+ return Some(mainHost)
190
+ }
191
+ return None
192
+ }
193
+
194
+ @Test
195
+ func testDialogRoutesToTheWindowThatAsked(): Unit {
196
+ // 多窗口的核心契约:报文里的 window 决定交给哪个宿主。
197
+ // 只用单槽(装配时那一个宿主)时,第二扇窗的框会排进 main 的队列——
198
+ // 2026-10-20 鸿蒙真机实测的串台就是这么来的(docs/鸿蒙ArkWeb接入踩坑.md §10.12)。
199
+ let mainHost = FakeHost("main")
200
+ let secondHost = FakeHost("second")
201
+ let cmds = DialogCommands()
202
+ cmds.attachHost(mainHost)
203
+ cmds.attachHostResolver({ label => routeProbeHost(label, mainHost, secondHost) })
204
+ let hub = IpcHub()
205
+
206
+ cmds.handle("dialog:message", messageArgs("second 的框"), IpcContext("second", hub))
207
+ @Expect(secondHost.messageDialogArgs.contains("second 的框"), true)
208
+ @Expect(secondHost.messageDialogArgs.contains("|second 的框"), true)
209
+ // 关键断言:另一扇窗**一点都没被碰到**
210
+ @Expect(mainHost.messageDialogArgs, "")
211
+
212
+ cmds.handle("dialog:message", messageArgs("main 的框"), IpcContext("main", hub))
213
+ @Expect(mainHost.messageDialogArgs.contains("main 的框"), true)
214
+ // 反向也成立:second 那一份不被覆盖(各持一份状态,不是共用一个槽)
215
+ @Expect(secondHost.messageDialogArgs.contains("second 的框"), true)
216
+ }
217
+
218
+ @Test
219
+ func testDialogReportsUnknownWindowInsteadOfFallingBack(): Unit {
220
+ // 解析器在场而窗口没登记时如实报错:悄悄回落到单槽正是串台的来源
221
+ let mainHost = FakeHost("main")
222
+ let secondHost = FakeHost("second")
223
+ let cmds = DialogCommands()
224
+ cmds.attachHost(mainHost)
225
+ cmds.attachHostResolver({ label => routeProbeHost(label, mainHost, secondHost) })
226
+ var threw = false
227
+ try {
228
+ cmds.handle("dialog:message", messageArgs("谁的框"), IpcContext("ghost", IpcHub()))
229
+ } catch (e: CommandException) {
230
+ threw = true
231
+ @Expect(e.message.contains("ghost"), true)
232
+ }
233
+ @Expect(threw, true)
234
+ // 报错之后两个宿主都没被写过:宁可不弹,也不弹到别人窗口里
235
+ @Expect(mainHost.messageDialogArgs, "")
236
+ @Expect(secondHost.messageDialogArgs, "")
237
+ }
@@ -0,0 +1,124 @@
1
+ package cjTauri.tests
2
+
3
+ import cjTauri.*
4
+ import cjTauri.plugins.http.*
5
+ import std.collection.*
6
+ import std.unittest.*
7
+ import std.unittest.testmacro.*
8
+
9
+ /*
10
+ * http 插件的策略层用例(RFC-003 HTTP 白名单收口)。
11
+ *
12
+ * 只测**纯函数**(URL → host 提取、上限常量、插件声明面):真的发请求那一段归
13
+ * 实机 E2E——单测进程里起真 HTTP 服务会引入对环境的依赖,不符合「渐进目标」的口径。
14
+ */
15
+
16
+ @Test
17
+ func testHttpHostOfExtractsHostLowercased(): Unit {
18
+ @Expect(httpHostOf("https://api.deepseek.com/v1/chat/completions"), "api.deepseek.com")
19
+ // host 大小写不敏感:统一小写,匹配口径才收得住
20
+ @Expect(httpHostOf("https://API.DeepSeek.COM/x"), "api.deepseek.com")
21
+ }
22
+
23
+ @Test
24
+ func testHttpHostOfStripsPortAndUserinfo(): Unit {
25
+ // 端口不参与匹配(白名单声明「信这个主机」)
26
+ @Expect(httpHostOf("http://localhost:2375/docker"), "localhost")
27
+ // userinfo(user:pass@host)不许用来伪造白名单匹配
28
+ @Expect(httpHostOf("https://user:pass@api.example.com/"), "api.example.com")
29
+ }
30
+
31
+ @Test
32
+ func testHttpHostOfRejectsNonHttpSchemes(): Unit {
33
+ // file: 不给出网语义;空 host(ftp://)也当解析不出
34
+ let f = match (httpHostOf("file:///etc/passwd")) {
35
+ case Some(_) => true
36
+ case None => false
37
+ }
38
+ @Expect(f, false)
39
+ let n = match (httpHostOf("notaurl")) {
40
+ case Some(_) => true
41
+ case None => false
42
+ }
43
+ @Expect(n, false)
44
+ }
45
+
46
+ @Test
47
+ func testHttpHostOfIpv6Literal(): Unit {
48
+ // IPv6 字面量整体当 host(不拆端口):去端口逻辑只在非 '[' 开头时走
49
+ @Expect(httpHostOf("http://[::1]:8080/x"), "[::1]")
50
+ }
51
+
52
+ @Test
53
+ func testHttpPluginDeclarations(): Unit {
54
+ let p = HttpPlugin()
55
+ @Expect(p.name(), "http")
56
+ @Expect(p.scopeAware(), true)
57
+ // 权限集 default = get + post + delete + postStream:集声明 != 放行,清单引用才生效
58
+ @Expect(p.permissions().contains("default"), true)
59
+ let cmds = p.commands()
60
+ @Expect(cmds.contains("get"), true)
61
+ @Expect(cmds.contains("post"), true)
62
+ // delete 是给「带 JSON 体的 DELETE」用的(cjh 的工作区删除靠体传 path):
63
+ // 少了它前端只能退化成无体 DELETE,对端拿不到要删哪个
64
+ @Expect(cmds.contains("delete"), true)
65
+ @Expect(cmds.contains("postStream"), true)
66
+ // 推一个事件(流式 http:chunk,短名登记)、无前端 shim
67
+ @Expect(p.events().size == 1, true)
68
+ @Expect(p.jsShim().size == 0, true)
69
+ }
70
+
71
+ @Test
72
+ func testHttpHostOfRejectsWsSchemes(): Unit {
73
+ // http 插件的 host 提取转发核心 urlHostOf(ws/wss 是 ws 插件的合法目标),
74
+ // 但协议收紧到 http(s):ws:// 不是可抓取目标,解析不出 host 按白名单未命中拒
75
+ let f = match (httpHostOf("ws://127.0.0.1:8765/ws")) {
76
+ case Some(_) => true
77
+ case None => false
78
+ }
79
+ @Expect(f, false)
80
+ }
81
+
82
+ @Test
83
+ func testHttpLimitsAreSane(): Unit {
84
+ // 上限是「防撑爆」而非「功能」:8 MiB 与 60s 与既有插件(clipboard 8MiB)同量级
85
+ @Expect(CJ_HTTP_MAX_BYTES == 8 * 1024 * 1024, true)
86
+ @Expect(CJ_HTTP_TIMEOUT_SECONDS > 0, true)
87
+ @Expect(CJ_HTTP_MAX_HEADERS > 0, true)
88
+ }
89
+
90
+ /*
91
+ * 流式分块的 UTF-8 边界(2026-10-16 实机 → 回归):`takeCompleteUtf8` 取走完整序列后
92
+ * 必须把 pending 里的**这些字节**全移掉。曾经写成 `remove(0..(cut-1))`(Range 是右开的,
93
+ * 这样就少移 1 字节),上一个序列的末字节留在 pending 里,下一轮拼出的字节序列非法,
94
+ * fromUtf8 抛 IllegalArgumentException,表现是「中文回复偶发整条 postStream 请求失败」。
95
+ * 判据就一条:整块都是完整序列时,取完 pending 必须**空**。
96
+ */
97
+
98
+ @Test
99
+ func testHttpTakeCompleteUtf8ConsumesWholeCompleteChunk(): Unit {
100
+ let pending = ArrayList<Byte>()
101
+ for (b in "abc你".toArray()) {
102
+ pending.add(b)
103
+ }
104
+ let cmds = HttpCommands()
105
+ @Expect(cmds.takeCompleteUtf8(pending), "abc你")
106
+ @Expect(pending.size, 0)
107
+ }
108
+
109
+ @Test
110
+ func testHttpTakeCompleteUtf8KeepsPartialTail(): Unit {
111
+ // 尾巴不完整(汉字只到了前 2 字节):留在 pending 里等下一块补齐,不能按原样解出来
112
+ let bytes = "你".toArray()
113
+ @Expect(bytes.size, 3)
114
+ let pending = ArrayList<Byte>()
115
+ pending.add(bytes[0])
116
+ pending.add(bytes[1])
117
+ let cmds = HttpCommands()
118
+ @Expect(cmds.takeCompleteUtf8(pending), "")
119
+ @Expect(pending.size, 2)
120
+ // 第 3 字节到了:跨块的字符拼回来,pending 随之清空
121
+ pending.add(bytes[2])
122
+ @Expect(cmds.takeCompleteUtf8(pending), "你")
123
+ @Expect(pending.size, 0)
124
+ }
@@ -1,6 +1,7 @@
1
1
  package cjTauri.tests
2
2
 
3
3
  import cjTauri.*
4
+ import cjTauri.plugins.menu.*
4
5
  import std.collection.*
5
6
  import stdx.encoding.json.*
6
7
  import std.unittest.*
@@ -0,0 +1,205 @@
1
+ package cjTauri.tests
2
+
3
+ import cjTauri.*
4
+ import cjTauri.plugins.notification.*
5
+ import std.collection.*
6
+ import stdx.encoding.json.*
7
+ import std.unittest.*
8
+ import std.unittest.testmacro.*
9
+
10
+ /*
11
+ * notification 插件单元测试:命令面 / shim / 命名权限集闸门 / 参数校验 / 未装配宿主的可读错误
12
+ * + 「宿主已装配」时的转交(FakeHost 记痕)。
13
+ *
14
+ * 真通知落在 C 桥(Linux 走 freedesktop Notifications,Windows 桩阶段未实现),
15
+ * 归实机验证;这里只锁入口逻辑。body 可省是契约的一部分,单独覆盖。
16
+ */
17
+
18
+ /** 取一条「已装配宿主」的命令处理器 */
19
+ func notifCmdWithHost(host: FakeHost): CommandHandler {
20
+ let c = NotificationCommands()
21
+ c.attachHost(host)
22
+ return c
23
+ }
24
+
25
+ func notifErrOf(handler: CommandHandler, cmd: String, args: JsonObject): String {
26
+ try {
27
+ handler.handle(cmd, args, IpcContext("main", IpcHub()))
28
+ return ""
29
+ } catch (e: CommandException) {
30
+ return e.message
31
+ }
32
+ }
33
+
34
+ /** 造 { title, body } */
35
+ func notifArgs(title: String, body: JsonValue): JsonObject {
36
+ var o = JsonObject()
37
+ o.put("title", JsonString(title))
38
+ o.put("body", body)
39
+ return o
40
+ }
41
+
42
+ @Test
43
+ func testNotificationPluginSurface(): Unit {
44
+ let p = NotificationPlugin()
45
+ @Expect(p.name(), "notification")
46
+ let names = pluginCommandNames(p)
47
+ @Expect(names.size, 1)
48
+ @Expect(names[0], "notification:notify")
49
+ }
50
+
51
+ @Test
52
+ func testNotificationPluginShimTargetsItsOwnNamespace(): Unit {
53
+ let js = NotificationPlugin().jsShim()
54
+ @Expect(js.contains("t.notification"), true)
55
+ @Expect(js.contains("'notification:notify'"), true)
56
+ }
57
+
58
+ @Test
59
+ func testNotificationPluginDeclaresPermissionSets(): Unit {
60
+ let sets = pluginPermissionSets(NotificationPlugin())
61
+ @Expect(sets.size, 1)
62
+ let d = sets.get("notification:default").getOrThrow()
63
+ @Expect(d.size, 1)
64
+ @Expect(d[0], "notification:notify")
65
+ }
66
+
67
+ @Test
68
+ func testNotificationPermissionSetGatesCommand(): Unit {
69
+ let out = ArrayList<String>()
70
+ let reg = CapabilityRegistry()
71
+ reg.addFromJson(JsonValue.fromStr("{\"identifier\":\"n\",\"permissions\":[\"notification:default\"]}"))
72
+ let hub = hubWithRegistry(reg, out)
73
+ registerPluginCommands(hub, NotificationPlugin())
74
+ registerPluginPermissionSets(reg, NotificationPlugin())
75
+
76
+ // 集内命令放行:走到「宿主未装配」的业务错误,而不是「未授权」
77
+ hub.handleRawMessage(
78
+ "{\"type\":\"invoke\",\"id\":51,\"cmd\":\"notification:notify\",\"args\":{\"title\":\"t\"}}")
79
+ waitSink(out, 1)
80
+ @Expect(out.size, 1)
81
+ @Expect(strField(out[0], "error").contains("command not allowed"), false)
82
+ }
83
+
84
+ @Test
85
+ func testNotificationWithoutPermissionIsDenied(): Unit {
86
+ let out = ArrayList<String>()
87
+ let reg = CapabilityRegistry()
88
+ reg.addFromJson(JsonValue.fromStr("{\"identifier\":\"empty\",\"permissions\":[]}"))
89
+ let hub = hubWithRegistry(reg, out)
90
+ registerPluginCommands(hub, NotificationPlugin())
91
+ registerPluginPermissionSets(reg, NotificationPlugin())
92
+
93
+ hub.handleRawMessage(
94
+ "{\"type\":\"invoke\",\"id\":52,\"cmd\":\"notification:notify\",\"args\":{\"title\":\"t\"}}")
95
+ waitSink(out, 1)
96
+ @Expect(strField(out[0], "error"), "command not allowed: notification:notify")
97
+ }
98
+
99
+ @Test
100
+ func testNotificationRequiresTitle(): Unit {
101
+ let cmd = NotificationPlugin().commands().get("notify").getOrThrow()
102
+ let ctx = IpcContext("main", IpcHub())
103
+ var threw = false
104
+ try {
105
+ cmd.handle("notification:notify", JsonObject(), ctx)
106
+ } catch (e: CommandException) {
107
+ threw = true
108
+ @Expect(e.message.contains("missing argument \"title\""), true)
109
+ }
110
+ @Expect(threw, true)
111
+ }
112
+
113
+ @Test
114
+ func testNotificationRejectsNonStringTitle(): Unit {
115
+ var args = JsonObject()
116
+ args.put("title", JsonBool(true))
117
+ let cmd = NotificationPlugin().commands().get("notify").getOrThrow()
118
+ var threw = false
119
+ try {
120
+ cmd.handle("notification:notify", args, IpcContext("main", IpcHub()))
121
+ } catch (e: CommandException) {
122
+ threw = true
123
+ @Expect(e.message.contains("must be a string"), true)
124
+ }
125
+ @Expect(threw, true)
126
+ }
127
+
128
+ @Test
129
+ func testNotificationWithoutHostIsReadableError(): Unit {
130
+ @Expect(notifErrOf(NotificationPlugin().commands().get("notify").getOrThrow(), "notification:notify",
131
+ notifArgs("hi", JsonString("body"))).contains("宿主未装配"), true)
132
+ }
133
+
134
+ @Test
135
+ func testNotificationRejectsUnknownCommand(): Unit {
136
+ @Expect(notifErrOf(NotificationPlugin().commands().get("notify").getOrThrow(), "notification:nope",
137
+ JsonObject()).contains("unknown notification command"), true)
138
+ }
139
+
140
+ @Test
141
+ func testNotificationBodyIsOptional(): Unit {
142
+ let host = FakeHost("main")
143
+ let cmd = notifCmdWithHost(host)
144
+
145
+ // 只给 title:body 按空串
146
+ var onlyTitle = JsonObject()
147
+ onlyTitle.put("title", JsonString("标题"))
148
+ let r1 = cmd.handle("notification:notify", onlyTitle, IpcContext("main", IpcHub()))
149
+ match (r1.kind()) {
150
+ case JsonKind.JsNull => ()
151
+ case _ => @Expect(false, true)
152
+ }
153
+ @Expect(host.notifiedArgs, "标题|")
154
+
155
+ // body 显式 null:同样按空串(前端 o.body 传 undefined 时序列化可能落成 null)
156
+ cmd.handle("notification:notify", notifArgs("标题2", JsonNull()), IpcContext("main", IpcHub()))
157
+ @Expect(host.notifiedArgs, "标题2|")
158
+
159
+ // body 有值:原样转交
160
+ cmd.handle("notification:notify", notifArgs("标题3", JsonString("正文")), IpcContext("main", IpcHub()))
161
+ @Expect(host.notifiedArgs, "标题3|正文")
162
+ }
163
+
164
+ /** 桩侧「label → 宿主」解析器见 plugin_dialog_test.cj 的 routeProbeHost(同包可见) */
165
+ @Test
166
+ func testNotificationRoutesToTheWindowThatAsked(): Unit {
167
+ // 与 dialog / clipboard 同一条契约:报文里的 window 决定交给哪个宿主
168
+ let mainHost = FakeHost("main")
169
+ let secondHost = FakeHost("second")
170
+ let cmds = NotificationCommands()
171
+ cmds.attachHost(mainHost)
172
+ cmds.attachHostResolver({ label => routeProbeHost(label, mainHost, secondHost) })
173
+
174
+ cmds.handle("notification:notify", notifArgs("second 的通知", JsonString("正文")),
175
+ IpcContext("second", IpcHub()))
176
+ @Expect(secondHost.notifiedArgs, "second 的通知|正文")
177
+ // 关键断言:另一扇窗的宿主一点都没被碰到
178
+ @Expect(mainHost.notifiedArgs, "")
179
+
180
+ // 反向也成立
181
+ cmds.handle("notification:notify", notifArgs("main 的通知", JsonNull()), IpcContext("main", IpcHub()))
182
+ @Expect(mainHost.notifiedArgs, "main 的通知|")
183
+ @Expect(secondHost.notifiedArgs, "second 的通知|正文")
184
+ }
185
+
186
+ @Test
187
+ func testNotificationReportsUnknownWindowInsteadOfFallingBack(): Unit {
188
+ let mainHost = FakeHost("main")
189
+ let secondHost = FakeHost("second")
190
+ let cmds = NotificationCommands()
191
+ cmds.attachHost(mainHost)
192
+ cmds.attachHostResolver({ label => routeProbeHost(label, mainHost, secondHost) })
193
+
194
+ var threw = false
195
+ try {
196
+ cmds.handle("notification:notify", notifArgs("谁的", JsonNull()), IpcContext("ghost", IpcHub()))
197
+ } catch (e: CommandException) {
198
+ threw = true
199
+ @Expect(e.message.contains("ghost"), true)
200
+ }
201
+ @Expect(threw, true)
202
+ // 报错之后两个宿主都没被写过:不回落单槽
203
+ @Expect(mainHost.notifiedArgs, "")
204
+ @Expect(secondHost.notifiedArgs, "")
205
+ }