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
@@ -0,0 +1,405 @@
1
+ package cjTauri.tests
2
+
3
+ import cjTauri.*
4
+ import cjTauri.plugins.mqtt.*
5
+ import std.collection.*
6
+ import std.unittest.*
7
+ import std.unittest.testmacro.*
8
+
9
+ /*
10
+ * MQTT 3.1.1 编解码单元测试(锁 src/plugins/mqtt_codec.cj 的纯函数)。
11
+ *
12
+ * 锁四件事:① 变长剩余长度的编解码边界(1..4 字节、超范围即拒、字节不够不猜);
13
+ * ② CONNECT / PUBLISH 的**逐字节线格式**——标志位域错一位对端就断连且通常不给原因,只能靠期望值锁死;
14
+ * ③ 解码对畸形报文一律拒绝(长度字段与实收字节对不上、类型码非法、QoS=3 保留值);
15
+ * ④ 主题与过滤器校验(通配符必须独占一层、中文主题按**字节**计长)。
16
+ *
17
+ * 期望值一律写成十六进制字符串:字段顺序、长度、标志位一眼可见,比逐字段对象断言更能锁住线上格式。
18
+ */
19
+
20
+ /** 十六进制文本 → 字节(测试里内联构造线上报文;不合法直接让用例失败) */
21
+ func mqttHex(hex: String): Array<UInt8> {
22
+ return hexToBytes(hex).getOrThrow()
23
+ }
24
+
25
+ // ---------------------------------------------------------------- 剩余长度(变长整数)
26
+
27
+ @Test
28
+ func testRemainingLengthEncodeBoundaries(): Unit {
29
+ @Expect(bytesToHex(mqttEncodeRemainingLength(0)), "00")
30
+ @Expect(bytesToHex(mqttEncodeRemainingLength(127)), "7f")
31
+ @Expect(bytesToHex(mqttEncodeRemainingLength(128)), "8001")
32
+ @Expect(bytesToHex(mqttEncodeRemainingLength(16383)), "ff7f")
33
+ @Expect(bytesToHex(mqttEncodeRemainingLength(16384)), "808001")
34
+ @Expect(bytesToHex(mqttEncodeRemainingLength(2097151)), "ffff7f")
35
+ @Expect(bytesToHex(mqttEncodeRemainingLength(2097152)), "80808001")
36
+ // 协议上限 2^28-1:四字节全用满
37
+ @Expect(bytesToHex(mqttEncodeRemainingLength(268435455)), "ffffff7f")
38
+ }
39
+
40
+ @Test
41
+ func testRemainingLengthRejectsOutOfRange(): Unit {
42
+ var tooBig = false
43
+ try {
44
+ mqttEncodeRemainingLength(268435456)
45
+ } catch (e: CommandException) {
46
+ tooBig = true
47
+ @Expect(e.message.contains("剩余长度超范围"), true)
48
+ }
49
+ @Expect(tooBig, true)
50
+ var negative = false
51
+ try {
52
+ mqttEncodeRemainingLength(-1)
53
+ } catch (e: CommandException) {
54
+ negative = true
55
+ }
56
+ @Expect(negative, true)
57
+ }
58
+
59
+ @Test
60
+ func testRemainingLengthDecode(): Unit {
61
+ let one = mqttDecodeRemainingLength(mqttHex("7f"), 0).getOrThrow()
62
+ @Expect(one.value, 127)
63
+ @Expect(one.consumed, 1)
64
+ let two = mqttDecodeRemainingLength(mqttHex("8001"), 0).getOrThrow()
65
+ @Expect(two.value, 128)
66
+ @Expect(two.consumed, 2)
67
+ // 带偏移解码:固定头占 1 字节,剩余长度从下标 1 开始
68
+ let off = mqttDecodeRemainingLength(mqttHex("3003aabbcc"), 1).getOrThrow()
69
+ @Expect(off.value, 3)
70
+ @Expect(off.consumed, 1)
71
+ }
72
+
73
+ @Test
74
+ func testRemainingLengthDecodeRejectsMalformed(): Unit {
75
+ // 只有续接位字节、还没等到收尾字节 → None(读循环要继续读,不算畸形)
76
+ @Expect(mqttDecodeRemainingLength(mqttHex("80"), 0).isNone(), true)
77
+ // 偏移越界 → None
78
+ @Expect(mqttDecodeRemainingLength(mqttHex("80"), 5).isNone(), true)
79
+ // 连续 5 个续接位字节:协议最多 4 字节,这种是畸形报文而不是「还没读完」
80
+ @Expect(mqttDecodeRemainingLength(mqttHex("ffffffff7f"), 0).isNone(), true)
81
+ }
82
+
83
+ // ---------------------------------------------------------------- UTF-8 字段与组帧
84
+
85
+ @Test
86
+ func testUtf8FieldLengthCountsBytes(): Unit {
87
+ // 「通知」是 6 字节 → 长度字段 0006。按**码点**数写成 0002 的话对端整条解析错位
88
+ @Expect(bytesToHex(mqttUtf8Field("通知")), "0006e9809ae79fa5")
89
+ @Expect(bytesToHex(mqttUtf8Field("")), "0000")
90
+ // 二进制字段同形:内容原样、长度同样按字节(含 NUL 也不截断)
91
+ @Expect(bytesToHex(mqttBinaryField(mqttHex("00ff10"))), "000300ff10")
92
+ }
93
+
94
+ @Test
95
+ func testFrameUsesVariableLengthHeader(): Unit {
96
+ // body 128 字节跨过 127 边界 → 剩余长度是两字节 8001,整条 = 1 + 2 + 128
97
+ let body = Array<UInt8>(128, repeat: 0x41)
98
+ let framed = mqttFrame(MQTT_PKT_PUBLISH, 0, body)
99
+ @Expect(framed.size, 131)
100
+ @Expect(bytesToHex(framed[0..3]), "308001")
101
+ }
102
+
103
+ // ---------------------------------------------------------------- CONNECT
104
+
105
+ @Test
106
+ func testEncodeConnectMinimal(): Unit {
107
+ let o = MqttConnectOptions()
108
+ o.clientId = "cj1"
109
+ // 10(类型) | 0f(剩余长度 15) | 0004 "MQTT" | 04(级别) | 02(cleanSession) | 003c(保活 60) | 0003 "cj1"
110
+ @Expect(bytesToHex(mqttEncodeConnect(o)), "100f00044d5154540402003c0003636a31")
111
+ }
112
+
113
+ @Test
114
+ func testEncodeConnectFlagsWithWillUserPassword(): Unit {
115
+ let o = MqttConnectOptions()
116
+ o.clientId = "c"
117
+ o.username = Some("u")
118
+ o.password = Some("p")
119
+ o.willTopic = Some("t")
120
+ o.willPayload = "bye".toArray()
121
+ o.willQos = 1
122
+ o.willRetain = true
123
+ o.keepaliveSec = 10
124
+ o.cleanSession = true
125
+ // 标志位:clean 0x02 | will 0x04 | willQos1 0x08 | willRetain 0x20 | pass 0x40 | user 0x80 = 0xee
126
+ // body 27 字节 = 0x1b;顺序是 遗嘱主题 → 遗嘱载荷 → 用户名 → 密码(协议定死,不可换序)
127
+ let expected = "10" + "1b" + "0004" + "4d515454" + "04" + "ee" + "000a" +
128
+ "000163" + "000174" + "0003627965" + "000175" + "000170"
129
+ @Expect(bytesToHex(mqttEncodeConnect(o)), expected)
130
+ }
131
+
132
+ @Test
133
+ func testEncodeConnectWithoutCleanSession(): Unit {
134
+ let o = MqttConnectOptions()
135
+ o.clientId = "c"
136
+ o.cleanSession = false
137
+ // body 13 字节 = 0x0d;不带 clean 时该位为 0(持久会话:broker 要留着订阅)
138
+ @Expect(bytesToHex(mqttEncodeConnect(o)), "100d00044d5154540400003c000163")
139
+ }
140
+
141
+ @Test
142
+ func testEncodeConnectRejectsPasswordWithoutUsername(): Unit {
143
+ // 密码标志只能在用户名标志为 1 时置位(§3.1.2.9):只给密码是非法组合,当场拒
144
+ let o = MqttConnectOptions()
145
+ o.clientId = "c"
146
+ o.password = Some("p")
147
+ var threw = false
148
+ try {
149
+ mqttEncodeConnect(o)
150
+ } catch (e: CommandException) {
151
+ threw = true
152
+ @Expect(e.message.contains("必须同时给 username"), true)
153
+ }
154
+ @Expect(threw, true)
155
+ }
156
+
157
+ @Test
158
+ func testDefaultClientIdIsStable(): Unit {
159
+ @Expect(mqttDefaultClientId(7), "cj-tauri-7")
160
+ }
161
+
162
+ // ---------------------------------------------------------------- PUBLISH / SUBSCRIBE / 确认与心跳
163
+
164
+ @Test
165
+ func testEncodePublishQos0(): Unit {
166
+ // 30 | 07 | 0003 "a/b" | "hi"(QoS 0 不带报文标识符,body = 主题字段 5 + 载荷 2)
167
+ @Expect(bytesToHex(mqttEncodePublish("a/b", "hi".toArray(), 0, false, false, 0)),
168
+ "30070003612f626869")
169
+ }
170
+
171
+ @Test
172
+ func testEncodePublishQos1RetainDup(): Unit {
173
+ // 标志位:QoS1 0x02 | retain 0x01 | dup 0x08 = 0x0b → 固定头 0x3b
174
+ // body = 主题字段 3 + 报文标识符 2 + 载荷 1 = 6 = 0x06
175
+ @Expect(bytesToHex(mqttEncodePublish("t", "x".toArray(), 1, true, true, 7)),
176
+ "3b06000174000778")
177
+ }
178
+
179
+ @Test
180
+ func testEncodePublishRejectsOversizePayload(): Unit {
181
+ // 上限 256 KiB:超了直接拒——静默切分会让对端收到多条「看不出是一批」的消息
182
+ var threw = false
183
+ try {
184
+ mqttEncodePublish("t", Array<UInt8>(262145, repeat: 0), 0, false, false, 0)
185
+ } catch (e: CommandException) {
186
+ threw = true
187
+ @Expect(e.message.contains("载荷太大"), true)
188
+ }
189
+ @Expect(threw, true)
190
+ // 正好在上限上则放行:剩余长度升到 3 字节(262147 = 0x83 0x80 0x10)
191
+ let atLimit = mqttEncodePublish("t", Array<UInt8>(262144, repeat: 0), 0, false, false, 0)
192
+ @Expect(atLimit.size, 262151)
193
+ @Expect(bytesToHex(atLimit[0..4]), "30838010")
194
+ }
195
+
196
+ @Test
197
+ func testEncodePublishRejectsQosWithoutPacketId(): Unit {
198
+ var threw = false
199
+ try {
200
+ mqttEncodePublish("t", "x".toArray(), 1, false, false, 0)
201
+ } catch (e: CommandException) {
202
+ threw = true
203
+ @Expect(e.message.contains("缺少报文标识符"), true)
204
+ }
205
+ @Expect(threw, true)
206
+ }
207
+
208
+ @Test
209
+ func testEncodeSubscribeAndUnsubscribe(): Unit {
210
+ // SUBSCRIBE 标志位协议固定 0b0010 → 固定头 0x82;body = 标识符 2 + 过滤器字段 5 + QoS 1
211
+ @Expect(bytesToHex(mqttEncodeSubscribe(1, "a/b", 1)), "820800010003612f6201")
212
+ // UNSUBSCRIBE 同形但不带 QoS 字节;固定头 0xa2
213
+ @Expect(bytesToHex(mqttEncodeUnsubscribe(2, "a/b")), "a20700020003612f62")
214
+ // 缺报文标识符当场拒(0 是非法值)
215
+ var threw = false
216
+ try {
217
+ mqttEncodeSubscribe(0, "a/b", 0)
218
+ } catch (e: CommandException) {
219
+ threw = true
220
+ }
221
+ @Expect(threw, true)
222
+ }
223
+
224
+ @Test
225
+ func testEncodeAckFlags(): Unit {
226
+ // PUBACK / PUBREC / PUBCOMP 标志为 0;PUBREL 标志协议固定 0b0010(错了对端当非法报文丢弃,QoS 2 卡住)
227
+ @Expect(bytesToHex(mqttEncodeAck(MQTT_PKT_PUBACK, 9)), "40020009")
228
+ @Expect(bytesToHex(mqttEncodeAck(MQTT_PKT_PUBREC, 9)), "50020009")
229
+ @Expect(bytesToHex(mqttEncodeAck(MQTT_PKT_PUBREL, 9)), "62020009")
230
+ @Expect(bytesToHex(mqttEncodeAck(MQTT_PKT_PUBCOMP, 9)), "70020009")
231
+ }
232
+
233
+ @Test
234
+ func testEncodeKeepaliveAndDisconnect(): Unit {
235
+ @Expect(bytesToHex(mqttEncodePingreq()), "c000")
236
+ @Expect(bytesToHex(mqttEncodeDisconnect()), "e000")
237
+ }
238
+
239
+ // ---------------------------------------------------------------- 解码与解析
240
+
241
+ @Test
242
+ func testDecodePacketRoundTrip(): Unit {
243
+ let raw = mqttEncodePublish("a/b", "hi".toArray(), 0, false, false, 0)
244
+ let p = mqttDecodePacket(raw).getOrThrow()
245
+ @Expect(Int64(p.typeCode), 3)
246
+ @Expect(Int64(p.flags), 0)
247
+ @Expect(bytesToHex(p.body), "0003612f626869")
248
+ let pub = mqttParsePublish(p).getOrThrow()
249
+ @Expect(pub.topic, "a/b")
250
+ @Expect(bytesToHex(pub.payload), "6869")
251
+ @Expect(pub.qos, 0)
252
+ @Expect(pub.hasPacketId, false)
253
+ }
254
+
255
+ @Test
256
+ func testDecodePacketRejectsMalformed(): Unit {
257
+ // 剩余长度说 7,body 实际只有 5 字节 → None(不许按「前 5 字节先解析」猜)
258
+ @Expect(mqttDecodePacket(mqttHex("30070003616263")).isNone(), true)
259
+ // 类型码 0 与 15 都不是合法控制报文
260
+ @Expect(mqttDecodePacket(mqttHex("0000")).isNone(), true)
261
+ @Expect(mqttDecodePacket(mqttHex("f000")).isNone(), true)
262
+ // 连固定头都不够
263
+ @Expect(mqttDecodePacket(mqttHex("30")).isNone(), true)
264
+ // 剩余长度字节不完整
265
+ @Expect(mqttDecodePacket(mqttHex("3080")).isNone(), true)
266
+ }
267
+
268
+ @Test
269
+ func testParseConnack(): Unit {
270
+ let c = mqttParseConnack(mqttDecodePacket(mqttHex("20020000")).getOrThrow()).getOrThrow()
271
+ @Expect(c.sessionPresent, false)
272
+ @Expect(c.returnCode, 0)
273
+ // 会话标志只看最低位:0x01 → true;返回码 5 = 未授权
274
+ let m = mqttParseConnack(mqttDecodePacket(mqttHex("20020105")).getOrThrow()).getOrThrow()
275
+ @Expect(m.sessionPresent, true)
276
+ @Expect(m.returnCode, 5)
277
+ // 类型不对 → None(拿 SUBACK 的字节喂它)
278
+ @Expect(mqttParseConnack(mqttDecodePacket(mqttHex("9003000100")).getOrThrow()).isNone(), true)
279
+ }
280
+
281
+ @Test
282
+ func testParseSuback(): Unit {
283
+ // 报文标识符 0001,授予 [qos1, 0x80(该过滤器订阅失败)]
284
+ let granted = mqttParseSuback(mqttDecodePacket(mqttHex("900400010180")).getOrThrow()).getOrThrow()
285
+ @Expect(granted.size, 2)
286
+ @Expect(granted[0], 1)
287
+ @Expect(granted[1], 128)
288
+ // body 不足 3 字节(只有标识符、没有授予码)→ None
289
+ @Expect(mqttParseSuback(mqttDecodePacket(mqttHex("90020001")).getOrThrow()).isNone(), true)
290
+ }
291
+
292
+ @Test
293
+ func testParsePublishChineseTopicByBytes(): Unit {
294
+ // 中文主题「通知」= 6 字节:整条 = 30 | 0a | 0006 通知 | "ok"
295
+ let raw = mqttEncodePublish("通知", "ok".toArray(), 0, false, false, 0)
296
+ @Expect(bytesToHex(raw), "300a0006e9809ae79fa56f6b")
297
+ let pub = mqttParsePublish(mqttDecodePacket(raw).getOrThrow()).getOrThrow()
298
+ @Expect(pub.topic, "通知")
299
+ @Expect(bytesToHex(pub.payload), "6f6b")
300
+ }
301
+
302
+ @Test
303
+ func testParsePublishQos1CarriesPacketId(): Unit {
304
+ let raw = mqttEncodePublish("t", "x".toArray(), 1, false, false, 42)
305
+ let pub = mqttParsePublish(mqttDecodePacket(raw).getOrThrow()).getOrThrow()
306
+ @Expect(pub.qos, 1)
307
+ @Expect(pub.hasPacketId, true)
308
+ @Expect(pub.packetId, 42)
309
+ @Expect(pub.topic, "t")
310
+ @Expect(bytesToHex(pub.payload), "78")
311
+ }
312
+
313
+ @Test
314
+ func testParsePublishRejectsReservedQos3(): Unit {
315
+ // 标志位 QoS=3 是协议保留值 → 37 行以内的位域解析必须拒(36 = 类型 3 + 标志 6 = QoS 3)
316
+ let p = mqttDecodePacket(mqttHex("3603000174")).getOrThrow()
317
+ @Expect(Int64(p.flags), 6)
318
+ @Expect(mqttParsePublish(p).isNone(), true)
319
+ // 主题长度字段比 body 还长 → None
320
+ @Expect(mqttParsePublish(mqttDecodePacket(mqttHex("300300ff61")).getOrThrow()).isNone(), true)
321
+ }
322
+
323
+ @Test
324
+ func testParsePacketId(): Unit {
325
+ @Expect(mqttParsePacketId(mqttDecodePacket(mqttHex("40020009")).getOrThrow()), 9)
326
+ // PINGRESP 没有 body → -1(调用方按 <0 判「这条不带标识符」)
327
+ @Expect(mqttParsePacketId(mqttDecodePacket(mqttHex("d000")).getOrThrow()), -1)
328
+ }
329
+
330
+ // ---------------------------------------------------------------- 参数校验
331
+
332
+ @Test
333
+ func testValidHostAndPort(): Unit {
334
+ @Expect(mqttValidHost("127.0.0.1"), true)
335
+ @Expect(mqttValidHost("broker.example.com"), true)
336
+ @Expect(mqttValidHost("mqtt-broker_1"), true)
337
+ @Expect(mqttValidHost(""), false)
338
+ @Expect(mqttValidHost("broker:1883"), false) // 端口必须单独给
339
+ @Expect(mqttValidHost("a b"), false)
340
+ @Expect(mqttValidHost("测试"), false) // 非 ASCII 域名不收(国际化域名要先转 punycode)
341
+ @Expect(mqttValidPort(1), true)
342
+ @Expect(mqttValidPort(1883), true)
343
+ @Expect(mqttValidPort(65535), true)
344
+ @Expect(mqttValidPort(0), false)
345
+ @Expect(mqttValidPort(65536), false)
346
+ @Expect(mqttValidPort(-1), false)
347
+ }
348
+
349
+ @Test
350
+ func testValidPublishTopic(): Unit {
351
+ @Expect(mqttValidPublishTopic("sensor/temp"), true)
352
+ @Expect(mqttValidPublishTopic("通知/温度"), true)
353
+ // PUBLISH 的主题里通配符是非法字符(那是订阅侧的东西)
354
+ @Expect(mqttValidPublishTopic(""), false)
355
+ @Expect(mqttValidPublishTopic("sensor/+"), false)
356
+ @Expect(mqttValidPublishTopic("sensor/#"), false)
357
+ }
358
+
359
+ @Test
360
+ func testValidTopicFilter(): Unit {
361
+ @Expect(mqttValidTopicFilter("sensor/temp"), true)
362
+ @Expect(mqttValidTopicFilter("#"), true)
363
+ @Expect(mqttValidTopicFilter("sensor/#"), true)
364
+ @Expect(mqttValidTopicFilter("sensor/+/temp"), true)
365
+ @Expect(mqttValidTopicFilter("+/+"), true)
366
+ // 空层级是合法的(`a//b` 就是三层)
367
+ @Expect(mqttValidTopicFilter("a//b"), true)
368
+ @Expect(mqttValidTopicFilter("a/"), true)
369
+ // 通配符必须独占一整层:粘着别的字符就非法
370
+ @Expect(mqttValidTopicFilter("sensor+/temp"), false)
371
+ @Expect(mqttValidTopicFilter("sensor/temp+"), false)
372
+ @Expect(mqttValidTopicFilter("sensor/#/temp"), false)
373
+ @Expect(mqttValidTopicFilter("sensor/#suffix"), false)
374
+ @Expect(mqttValidTopicFilter(""), false)
375
+ }
376
+
377
+ // ---------------------------------------------------------------- 展示助手
378
+
379
+ @Test
380
+ func testBytesToText(): Unit {
381
+ @Expect(mqttBytesToText("hello".toArray()).getOrThrow(), "hello")
382
+ @Expect(mqttBytesToText(Array<UInt8>()).getOrThrow(), "")
383
+ @Expect(mqttBytesToText("温度=26".toArray()).getOrThrow(), "温度=26")
384
+ // 制表/换行/回车算文本
385
+ @Expect(mqttBytesToText("a\tb\n".toArray()).getOrThrow(), "a\tb\n")
386
+ // NUL / 控制字符 / 非法 UTF-8 → None(页面据此改用十六进制显示,不猜编码)
387
+ @Expect(mqttBytesToText(mqttHex("686900")).isNone(), true)
388
+ @Expect(mqttBytesToText(mqttHex("01ff")).isNone(), true)
389
+ @Expect(mqttBytesToText(mqttHex("e980")).isNone(), true)
390
+ }
391
+
392
+ @Test
393
+ func testClampQosAndDisplayNames(): Unit {
394
+ @Expect(mqttClampQos(-3), 0)
395
+ @Expect(mqttClampQos(0), 0)
396
+ @Expect(mqttClampQos(2), 2)
397
+ @Expect(mqttClampQos(9), 2)
398
+ @Expect(mqttReturnCodeText(0), "连接已接受")
399
+ @Expect(mqttReturnCodeText(4).contains("用户名或密码错误"), true)
400
+ @Expect(mqttReturnCodeText(99).contains("未知返回码"), true)
401
+ @Expect(mqttPacketTypeName(MQTT_PKT_CONNECT), "CONNECT")
402
+ @Expect(mqttPacketTypeName(MQTT_PKT_PUBLISH), "PUBLISH")
403
+ @Expect(mqttPacketTypeName(MQTT_PKT_PINGRESP), "PINGRESP")
404
+ @Expect(mqttPacketTypeName(UInt8(15)), "UNKNOWN(15)")
405
+ }
@@ -0,0 +1,222 @@
1
+ package cjTauri.tests
2
+
3
+ import cjTauri.*
4
+ import cjTauri.plugins.clipboard.*
5
+ import std.collection.*
6
+ import stdx.encoding.json.*
7
+ import std.unittest.*
8
+ import std.unittest.testmacro.*
9
+
10
+ /*
11
+ * clipboard 插件单元测试:命令面 / shim / 命名权限集闸门 / 参数校验 / 未装配宿主的可读错误
12
+ * + 「宿主已装配」时的读写转交(用 FakeHost 替身记痕)。
13
+ *
14
+ * 真剪贴板读写落在 C 桥(GTK / Win32 的剪贴板调用必须在宿主 UI 线程),归实机验证
15
+ * (examples/plugin-clipboard);这里只锁「不碰剪贴板就能判定的入口逻辑」。
16
+ */
17
+
18
+ /** 造 { text } 参数 */
19
+ func clipTextArgs(text: String): JsonObject {
20
+ var o = JsonObject()
21
+ o.put("text", JsonString(text))
22
+ return o
23
+ }
24
+
25
+ /** 取一条「已装配宿主」的命令处理器:剪贴板命令要先经 attachHost 才有宿主可用 */
26
+ func clipCmdWithHost(short: String, host: FakeHost): CommandHandler {
27
+ let _ = short
28
+ let c = ClipboardCommands()
29
+ c.attachHost(host)
30
+ return c
31
+ }
32
+
33
+ /** 直接调命令,把 CommandException 的原文取出来(没抛就返回空串)*/
34
+ func clipErrOf(handler: CommandHandler, cmd: String, args: JsonObject): String {
35
+ try {
36
+ handler.handle(cmd, args, IpcContext("main", IpcHub()))
37
+ return ""
38
+ } catch (e: CommandException) {
39
+ return e.message
40
+ }
41
+ }
42
+
43
+ @Test
44
+ func testClipboardPluginSurface(): Unit {
45
+ let p = ClipboardPlugin()
46
+ @Expect(p.name(), "clipboard")
47
+ let names = pluginCommandNames(p)
48
+ @Expect(names.size, 2)
49
+ var hasRead = false
50
+ var hasWrite = false
51
+ for (n in names) {
52
+ if (n == "clipboard:read") {
53
+ hasRead = true
54
+ }
55
+ if (n == "clipboard:write") {
56
+ hasWrite = true
57
+ }
58
+ }
59
+ @Expect(hasRead, true)
60
+ @Expect(hasWrite, true)
61
+ }
62
+
63
+ @Test
64
+ func testClipboardPluginShimTargetsItsOwnNamespace(): Unit {
65
+ let js = ClipboardPlugin().jsShim()
66
+ @Expect(js.contains("t.clipboard"), true)
67
+ @Expect(js.contains("'clipboard:write'"), true)
68
+ @Expect(js.contains("'clipboard:read'"), true)
69
+ }
70
+
71
+ @Test
72
+ func testClipboardPluginDeclaresPermissionSets(): Unit {
73
+ let sets = pluginPermissionSets(ClipboardPlugin())
74
+ @Expect(sets.size, 2)
75
+ // readonly 只放行读:剪贴板里可能有用户敏感内容,「只写不读」是合理的最小授权面
76
+ let ro = sets.get("clipboard:readonly").getOrThrow()
77
+ @Expect(ro.size, 1)
78
+ @Expect(ro[0], "clipboard:read")
79
+ @Expect(sets.get("clipboard:default").getOrThrow().size, 2)
80
+ }
81
+
82
+ @Test
83
+ func testClipboardPermissionSetGatesCommands(): Unit {
84
+ let out = ArrayList<String>()
85
+ let reg = CapabilityRegistry()
86
+ reg.addFromJson(JsonValue.fromStr("{\"identifier\":\"c\",\"permissions\":[\"clipboard:readonly\"]}"))
87
+ let hub = hubWithRegistry(reg, out)
88
+ registerPluginCommands(hub, ClipboardPlugin())
89
+ registerPluginPermissionSets(reg, ClipboardPlugin())
90
+
91
+ // 集内命令放行:这条会走到「宿主未装配」的业务错误,而不是「未授权」
92
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":41,\"cmd\":\"clipboard:read\"}")
93
+ waitSink(out, 1)
94
+ @Expect(out.size, 1)
95
+ @Expect(strField(out[0], "error").contains("command not allowed"), false)
96
+
97
+ // 集外命令仍拒:readonly 不夹带写能力
98
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":42,\"cmd\":\"clipboard:write\",\"args\":{\"text\":\"x\"}}")
99
+ @Expect(out.size, 2)
100
+ @Expect(strField(out[1], "error"), "command not allowed: clipboard:write")
101
+ }
102
+
103
+ @Test
104
+ func testClipboardWriteRequiresTextArgument(): Unit {
105
+ let cmd = ClipboardPlugin().commands().get("write").getOrThrow()
106
+ let ctx = IpcContext("main", IpcHub())
107
+ var threw = false
108
+ try {
109
+ cmd.handle("clipboard:write", JsonObject(), ctx)
110
+ } catch (e: CommandException) {
111
+ threw = true
112
+ @Expect(e.message.contains("missing argument \"text\""), true)
113
+ }
114
+ @Expect(threw, true)
115
+ }
116
+
117
+ @Test
118
+ func testClipboardWriteRejectsNonString(): Unit {
119
+ var args = JsonObject()
120
+ args.put("text", JsonInt(7))
121
+ let cmd = ClipboardPlugin().commands().get("write").getOrThrow()
122
+ let ctx = IpcContext("main", IpcHub())
123
+ var threw = false
124
+ try {
125
+ cmd.handle("clipboard:write", args, ctx)
126
+ } catch (e: CommandException) {
127
+ threw = true
128
+ @Expect(e.message.contains("must be a string"), true)
129
+ }
130
+ @Expect(threw, true)
131
+ }
132
+
133
+ @Test
134
+ func testClipboardWithoutHostIsReadableError(): Unit {
135
+ // 没经 .plugin() 装配(没有宿主)时调用:给的是可读错误,而不是空指针崩掉
136
+ @Expect(clipErrOf(ClipboardPlugin().commands().get("read").getOrThrow(), "clipboard:read", JsonObject())
137
+ .contains("宿主未装配"), true)
138
+ @Expect(clipErrOf(ClipboardPlugin().commands().get("write").getOrThrow(), "clipboard:write",
139
+ clipTextArgs("hi")).contains("宿主未装配"), true)
140
+ }
141
+
142
+ @Test
143
+ func testClipboardRejectsUnknownCommand(): Unit {
144
+ @Expect(clipErrOf(ClipboardPlugin().commands().get("read").getOrThrow(), "clipboard:nope", JsonObject())
145
+ .contains("unknown clipboard command"), true)
146
+ }
147
+
148
+ @Test
149
+ func testClipboardWriteHandsTextToHost(): Unit {
150
+ let host = FakeHost("main")
151
+ let cmd = clipCmdWithHost("write", host)
152
+ let r = cmd.handle("clipboard:write", clipTextArgs("hello 剪贴板"), IpcContext("main", IpcHub()))
153
+ // 成功回 JsonNull(写命令没有返回值,与 Tauri 的 writeText 对齐)
154
+ match (r.kind()) {
155
+ case JsonKind.JsNull => ()
156
+ case _ =>
157
+ @Expect(false, true) // 成功路径不该回别的类型
158
+ }
159
+ @Expect(host.clipboardWritten, "hello 剪贴板")
160
+ }
161
+
162
+ @Test
163
+ func testClipboardReadReturnsHostValue(): Unit {
164
+ let host = FakeHost("main")
165
+ let cmd = clipCmdWithHost("read", host)
166
+
167
+ // 空 / 非文本 / 平台失败都归 None,转成 JsonNull——这是合法结果,不是错误
168
+ let empty = cmd.handle("clipboard:read", JsonObject(), IpcContext("main", IpcHub()))
169
+ match (empty.kind()) {
170
+ case JsonKind.JsNull => ()
171
+ case _ => @Expect(false, true)
172
+ }
173
+
174
+ // 有内容:原样转成 JsonString 回给前端
175
+ host.clipboardReadValue = Some("pasted 文本")
176
+ let got = cmd.handle("clipboard:read", JsonObject(), IpcContext("main", IpcHub()))
177
+ @Expect(got.asString().getValue(), "pasted 文本")
178
+ }
179
+
180
+ /** 桩侧「label → 宿主」解析器见 plugin_dialog_test.cj 的 routeProbeHost(同包可见) */
181
+ @Test
182
+ func testClipboardRoutesToTheWindowThatAsked(): Unit {
183
+ // 与 dialog 同一条契约:报文里的 window 决定交给哪个宿主。
184
+ // 只装单槽时第二扇窗的读写会落到默认窗那个宿主上(多窗口下的串台来源)。
185
+ let mainHost = FakeHost("main")
186
+ let secondHost = FakeHost("second")
187
+ let cmds = ClipboardCommands()
188
+ cmds.attachHost(mainHost)
189
+ cmds.attachHostResolver({ label => routeProbeHost(label, mainHost, secondHost) })
190
+
191
+ cmds.handle("clipboard:write", clipTextArgs("second 的剪贴板"), IpcContext("second", IpcHub()))
192
+ @Expect(secondHost.clipboardWritten, "second 的剪贴板")
193
+ // 关键断言:另一扇窗的宿主一点都没被碰到
194
+ @Expect(mainHost.clipboardWritten, "")
195
+
196
+ // 读同样按窗口解析
197
+ secondHost.clipboardReadValue = Some("from second")
198
+ let got = cmds.handle("clipboard:read", JsonObject(), IpcContext("second", IpcHub()))
199
+ @Expect(got.asString().getValue(), "from second")
200
+ @Expect(mainHost.clipboardReadValue.isNone(), true)
201
+ }
202
+
203
+ @Test
204
+ func testClipboardReportsUnknownWindowInsteadOfFallingBack(): Unit {
205
+ let mainHost = FakeHost("main")
206
+ let secondHost = FakeHost("second")
207
+ let cmds = ClipboardCommands()
208
+ cmds.attachHost(mainHost)
209
+ cmds.attachHostResolver({ label => routeProbeHost(label, mainHost, secondHost) })
210
+
211
+ var threw = false
212
+ try {
213
+ cmds.handle("clipboard:write", clipTextArgs("x"), IpcContext("ghost", IpcHub()))
214
+ } catch (e: CommandException) {
215
+ threw = true
216
+ @Expect(e.message.contains("ghost"), true)
217
+ }
218
+ @Expect(threw, true)
219
+ // 报错之后两个宿主都没被写过:不回落单槽,宁可不写
220
+ @Expect(mainHost.clipboardWritten, "")
221
+ @Expect(secondHost.clipboardWritten, "")
222
+ }