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.
- package/README.md +3 -3
- package/cjpm.toml +52 -25
- package/cli/cjpm.toml +1 -1
- package/cli/src/doctor.cj +304 -0
- package/cli/src/main.cj +33 -7
- package/cli/src/project.cj +59 -1
- package/cli/src/resolve.cj +348 -304
- package/cli/src/scaffold.cj +139 -22
- package/cli/templates/ohos-pc/AppScope/app.json5 +13 -0
- package/cli/templates/ohos-pc/AppScope/resources/base/element/string.json +8 -0
- package/cli/templates/ohos-pc/AppScope/resources/base/media/background.png +0 -0
- package/cli/templates/ohos-pc/AppScope/resources/base/media/foreground.png +0 -0
- package/cli/templates/ohos-pc/AppScope/resources/base/media/layered_image.json +6 -0
- package/cli/templates/ohos-pc/README.md +79 -0
- package/cli/templates/ohos-pc/build-hap.sh +190 -0
- package/cli/templates/ohos-pc/build-profile.json5 +46 -0
- package/cli/templates/ohos-pc/entry/build-profile.json5 +26 -0
- package/cli/templates/ohos-pc/entry/cjpm.toml +43 -0
- package/cli/templates/ohos-pc/entry/hvigorfile.ts +6 -0
- package/cli/templates/ohos-pc/entry/oh-package.json5 +9 -0
- package/cli/templates/ohos-pc/entry/src/main/cangjie/ability_stage.cj +17 -0
- package/cli/templates/ohos-pc/entry/src/main/cangjie/app_host.cj +94 -0
- package/cli/templates/ohos-pc/entry/src/main/cangjie/index.cj +167 -0
- package/cli/templates/ohos-pc/entry/src/main/cangjie/main_ability.cj +139 -0
- package/cli/templates/ohos-pc/entry/src/main/module.json5 +49 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/element/color.json +8 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/element/string.json +16 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/media/background.png +0 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/media/foreground.png +0 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/media/layered_image.json +6 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/media/startIcon.png +0 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/profile/main_pages.json +2 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/rawfile/capabilities.json +6 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/rawfile/index.html +92 -0
- package/cli/templates/ohos-pc/hvigor/hvigor-config.json5 +5 -0
- package/cli/templates/ohos-pc/hvigorfile.ts +6 -0
- package/cli/templates/ohos-pc/oh-package.json5 +10 -0
- package/native/build_linux.sh +30 -2
- package/native/build_win.bat +62 -61
- package/native/core/bridge_core.c +1626 -0
- package/native/{bridge_core.h → core/bridge_core.h} +246 -6
- package/native/platforms/harmonyos/README.md +106 -0
- package/native/platforms/linux/bridge_linux.c +2485 -0
- package/native/platforms/windows/bridge_win.c +3248 -0
- package/native/tests/test_bridge_core.c +528 -1
- package/package.json +12 -3
- package/prebuilt/win32-x64/cj-tauri.exe +0 -0
- package/src/api_system.cj +10 -2
- package/src/app.cj +248 -85
- package/src/arkweb_proxy.cj +56 -0
- package/src/arkweb_script.cj +154 -0
- package/src/capability.cj +140 -0
- package/src/clipboard_bridge.cj +55 -0
- package/src/dragdrop.cj +26 -0
- package/src/embedded_assets.cj +98 -0
- package/src/hexutil.cj +53 -0
- package/src/host.cj +197 -26
- package/src/host_arkweb.cj +740 -0
- package/src/host_factory.cj +48 -0
- package/src/host_webkit.cj +202 -26
- package/src/host_webview2.cj +176 -3
- package/src/hotkey.cj +66 -0
- package/src/ipc_hub.cj +49 -0
- package/src/ipc_message.cj +2 -1
- package/src/jsutil.cj +33 -0
- package/src/log_bridge.cj +107 -0
- package/src/logging.cj +127 -0
- package/src/menu.cj +197 -196
- package/src/plugin.cj +40 -0
- package/src/plugins/clipboard/plugin_clipboard.cj +144 -0
- package/src/{plugin_dialog.cj → plugins/dialog/plugin_dialog.cj} +74 -17
- package/src/plugins/fs/plugin_fs.cj +194 -0
- package/src/plugins/http/plugin_http.cj +459 -0
- package/src/plugins/log/plugin_log.cj +105 -0
- package/src/{plugin_menu.cj → plugins/menu/plugin_menu.cj} +4 -2
- package/src/plugins/mqtt/mqtt_codec.cj +661 -0
- package/src/plugins/mqtt/plugin_mqtt.cj +1446 -0
- package/src/plugins/notification/plugin_notification.cj +146 -0
- package/src/plugins/plugins.cj +5 -0
- package/src/plugins/redis/plugin_redis.cj +2409 -0
- package/src/plugins/redis/redis_codec.cj +556 -0
- package/src/{plugin_serial.cj → plugins/serial/plugin_serial.cj} +442 -489
- package/src/{serial_bridge.cj → plugins/serial/serial_bridge.cj} +185 -184
- package/src/{plugin_shell.cj → plugins/shell/plugin_shell.cj} +230 -228
- package/src/plugins/sqlite/plugin_sqlite.cj +697 -0
- package/src/plugins/sqlite/sqlite_ffi.cj +206 -0
- package/src/plugins/store/plugin_store.cj +287 -0
- package/src/plugins/ws/plugin_ws.cj +470 -0
- package/src/process_hidden.cj +174 -0
- package/src/scope.cj +391 -0
- package/src/shell_event.cj +69 -68
- package/src/tests/capability_test.cj +53 -0
- package/src/tests/fake_host.cj +85 -20
- package/src/tests/ipc_hub_test.cj +13 -0
- package/src/tests/jsutil_test.cj +62 -0
- package/src/tests/logging_test.cj +62 -0
- package/src/tests/mqtt_codec_test.cj +405 -0
- package/src/tests/plugin_clipboard_test.cj +222 -0
- package/src/tests/plugin_dialog_test.cj +98 -4
- package/src/tests/plugin_http_test.cj +124 -0
- package/src/tests/plugin_menu_test.cj +1 -0
- package/src/tests/plugin_notification_test.cj +205 -0
- package/src/tests/plugin_serial_test.cj +269 -267
- package/src/tests/plugin_shell_test.cj +189 -188
- package/src/tests/plugin_sqlite_test.cj +230 -0
- package/src/tests/plugin_store_test.cj +260 -0
- package/src/tests/plugin_test.cj +542 -524
- package/src/tests/plugin_ws_test.cj +40 -0
- package/src/tests/process_hidden_test.cj +129 -0
- package/src/tests/redis_codec_test.cj +396 -0
- package/src/tests/scope_test.cj +350 -0
- package/src/tests/shell_event_test.cj +0 -1
- package/src/tests/system_host_test.cj +81 -79
- package/src/tests/urlutil_test.cj +53 -0
- package/src/tests/version_test.cj +75 -65
- package/src/tray.cj +90 -0
- package/src/urlutil.cj +159 -0
- package/src/version.cj +23 -2
- package/src/window.cj +51 -42
- package/native/bridge_core.c +0 -797
- package/native/bridge_linux.c +0 -1268
- package/native/bridge_win.c +0 -1633
- package/src/plugin_fs.cj +0 -118
- /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,
|
|
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,
|
|
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,
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|