cj-tauri 1.1.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +64 -18
  2. package/cjpm.toml +52 -52
  3. package/cli/cjpm.toml +10 -1
  4. package/cli/src/doctor.cj +4 -2
  5. package/cli/src/main.cj +11 -3
  6. package/cli/src/manifest.cj +558 -0
  7. package/cli/src/project.cj +38 -22
  8. package/cli/src/scaffold.cj +369 -361
  9. package/cli/src/sync.cj +526 -0
  10. package/cli/src/util.cj +168 -159
  11. package/cli/templates/app/README.md +48 -80
  12. package/cli/templates/app/{capabilities → assets/capabilities}/default.json +7 -7
  13. package/cli/templates/app/{ui → assets/ui}/index.html +118 -118
  14. package/cli/templates/app/cj-tauri.toml +22 -0
  15. package/cli/templates/app/cjpm.toml +34 -17
  16. package/cli/templates/app/src/main.cj +90 -88
  17. package/cli/templates/app-react/README.md +56 -67
  18. package/cli/templates/app-react/{capabilities → assets/capabilities}/default.json +7 -7
  19. package/cli/templates/app-react/{ui → assets/ui}/package.json +19 -19
  20. package/cli/templates/app-react/{ui → assets/ui}/src/main.jsx +6 -6
  21. package/cli/templates/app-react/{ui → assets/ui}/vite.config.js +24 -24
  22. package/cli/templates/app-react/cj-tauri.toml +23 -0
  23. package/cli/templates/app-react/cjpm.toml +34 -17
  24. package/cli/templates/app-react/src/main.cj +91 -102
  25. package/cli/templates/app-vue/README.md +56 -60
  26. package/cli/templates/app-vue/{capabilities → assets/capabilities}/default.json +7 -7
  27. package/cli/templates/app-vue/{ui → assets/ui}/package.json +18 -18
  28. package/cli/templates/app-vue/{ui → assets/ui}/src/main.js +4 -4
  29. package/cli/templates/app-vue/{ui → assets/ui}/vite.config.js +24 -24
  30. package/cli/templates/app-vue/cj-tauri.toml +23 -0
  31. package/cli/templates/app-vue/cjpm.toml +34 -17
  32. package/cli/templates/app-vue/src/main.cj +91 -102
  33. package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/app.json5 +13 -13
  34. package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/element/string.json +8 -8
  35. package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/media/layered_image.json +6 -6
  36. package/cli/templates/{ohos-pc → ohos-skeleton}/README.md +79 -79
  37. package/cli/templates/{ohos-pc → ohos-skeleton}/build-profile.json5 +46 -46
  38. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/build-profile.json5 +26 -26
  39. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/cjpm.toml +43 -43
  40. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/hvigorfile.ts +6 -6
  41. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/oh-package.json5 +9 -9
  42. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/cangjie/ability_stage.cj +17 -17
  43. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/cangjie/app_host.cj +94 -94
  44. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/cangjie/index.cj +167 -167
  45. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/cangjie/main_ability.cj +139 -139
  46. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/module.json5 +49 -49
  47. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/element/color.json +8 -8
  48. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/element/string.json +16 -16
  49. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/layered_image.json +6 -6
  50. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/profile/main_pages.json +2 -2
  51. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/rawfile/capabilities.json +6 -6
  52. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/rawfile/index.html +92 -92
  53. package/cli/templates/{ohos-pc → ohos-skeleton}/hvigor/hvigor-config.json5 +5 -5
  54. package/cli/templates/{ohos-pc → ohos-skeleton}/hvigorfile.ts +6 -6
  55. package/cli/templates/{ohos-pc → ohos-skeleton}/oh-package.json5 +10 -10
  56. package/package.json +4 -2
  57. package/prebuilt/win32-x64/cj-tauri.exe +0 -0
  58. package/src/app.cj +49 -20
  59. package/src/app_spec.cj +76 -0
  60. package/src/arkweb_script.cj +142 -154
  61. package/src/host.cj +20 -17
  62. package/src/host_arkweb.cj +778 -740
  63. package/src/host_webkit.cj +22 -0
  64. package/src/host_webview2.cj +22 -0
  65. package/src/launch.cj +62 -0
  66. package/src/platform/platform_app_context.cj +77 -0
  67. package/src/platform/platform_assets.cj +33 -0
  68. package/src/platform/platform_discover.cj +176 -0
  69. package/src/platform/platform_env.cj +29 -0
  70. package/src/platform/platform_feature.cj +31 -0
  71. package/src/platform/platform_page_source.cj +87 -0
  72. package/src/platform/platform_target_info.cj +32 -0
  73. package/src/platform_host_provider.cj +87 -0
  74. package/src/plugin.cj +36 -2
  75. package/src/plugins/clipboard/plugin_clipboard.cj +8 -0
  76. package/src/plugins/menu/plugin_menu.cj +158 -150
  77. package/src/plugins/notification/plugin_notification.cj +8 -0
  78. package/src/plugins/serial/plugin_serial.cj +454 -442
  79. package/src/plugins/serial/serial_bridge.cj +194 -186
  80. package/src/plugins/serial/serial_ohos_stub.cj +61 -0
  81. package/src/plugins/shell/plugin_shell.cj +258 -230
  82. package/src/plugins/sqlite/plugin_sqlite.cj +710 -697
  83. package/src/plugins/sqlite/sqlite_ffi.cj +211 -206
  84. package/src/plugins/sqlite/sqlite_ohos_stub.cj +91 -0
  85. package/src/tests/app_spec_test.cj +127 -0
  86. package/src/tests/fake_host.cj +23 -8
  87. package/src/tests/platform_contract_test.cj +251 -0
  88. package/src/tests/plugin_test.cj +585 -542
  89. package/src/version.cj +1 -1
  90. package/src/host_factory.cj +0 -48
  91. /package/cli/templates/app-react/{ui → assets/ui}/index.html +0 -0
  92. /package/cli/templates/app-react/{ui → assets/ui}/src/App.css +0 -0
  93. /package/cli/templates/app-react/{ui → assets/ui}/src/App.jsx +0 -0
  94. /package/cli/templates/app-vue/{ui → assets/ui}/index.html +0 -0
  95. /package/cli/templates/app-vue/{ui → assets/ui}/src/App.vue +0 -0
  96. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/media/background.png +0 -0
  97. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/media/foreground.png +0 -0
  98. /package/cli/templates/{ohos-pc → ohos-skeleton}/build-hap.sh +0 -0
  99. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/background.png +0 -0
  100. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/foreground.png +0 -0
  101. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/startIcon.png +0 -0
@@ -1,230 +1,258 @@
1
- package cjTauri.plugins.shell
2
-
3
- import cjTauri.*
4
-
5
- import std.collection.*
6
- import std.fs.*
7
- import std.process.*
8
- import stdx.encoding.json.*
9
-
10
- /**
11
- * 官方插件 shell:把文件 / URL 交给系统默认程序打开;执行子进程并取回输出(对标 Tauri 的 shell 插件)
12
- *
13
- * 命令:shell:open / shell:exec
14
- * 权限:**不自动放行**,应用要在 capabilities 里显式声明,两种写法等价:
15
- * "commands": ["shell:open"] // 明文逐条
16
- * "permissions": ["shell:allow-open"] // 命名权限集(v1.1,只放行「打开」)
17
- * 前端:window.__CJ_TAURI__.shell.exec({ program: "git", args: ["rev-parse", "HEAD"] })
18
- *
19
- * 安全边界(改代码时不要放松):本插件**不经过 shell**——program 与每个参数都作为独立 argv 元素直接
20
- * 交给子进程,没有 `cmd /c` / `bash -c` 的字符串拼接,所以参数里带空格、引号、`&&`、`>` 都只是普通字符,
21
- * 不构成命令注入。但「放行 shell:exec」这件事本身就等于把「执行任意程序」交给页面:能力清单是唯一的闸门,
22
- * 页面拿到什么命令就等于能跑什么程序。只想让用户打开链接 / 文件的应用请引用 `shell:allow-open`。
23
- *
24
- * 限制:`shell:exec` 是同步调用(子进程不退出就不返回),且**没有**流式输出 / 超时 / 取消——但它跑在命令
25
- * worker 线程上(命令分发已异步化,见 AGENTS.md §2 的线程模型),所以不会冻住窗口。长驻 / 交互式程序
26
- * (编辑器、REPL、npm run dev)仍不要走这里。
27
- */
28
- public class ShellPlugin <: Plugin {
29
- public init() {}
30
-
31
- public func name(): String {
32
- return "shell"
33
- }
34
-
35
- public func commands(): HashMap<String, CommandHandler> {
36
- let m = HashMap<String, CommandHandler>()
37
- let handler = ShellCommands()
38
- m.add("open", handler)
39
- m.add("exec", handler)
40
- return m
41
- }
42
-
43
- /**
44
- * 命名权限集(v1.1):allow-open 只含「交给系统默认程序打开」这条,exec(执行任意程序)不在其中。
45
- * 只想打开链接 / 文件的应用引用 allow-open 即可,不必顺带拿到执行能力;集内写短名,框架自动补 `shell:`。
46
- */
47
- public func permissions(): HashMap<String, ArrayList<String>> {
48
- let m = HashMap<String, ArrayList<String>>()
49
- let openOnly = ArrayList<String>()
50
- openOnly.add("open")
51
- m.add("allow-open", openOnly)
52
- let all = ArrayList<String>()
53
- all.add("open")
54
- all.add("exec")
55
- m.add("default", all)
56
- return m
57
- }
58
-
59
- /**
60
- * 前端 shim:往桥对象上挂一个 shell 命名空间(与 fs / dialog 同款,不碰 invoke / listen / emit)
61
- */
62
- public func jsShim(): String {
63
- return "(function () { var t = window.__CJ_TAURI__ || (window.__CJ_TAURI__ = {});" +
64
- " t.shell = {" +
65
- " open: function (args) { return window.__CJ_TAURI__.invoke('shell:open', args); }," +
66
- " exec: function (args) { return window.__CJ_TAURI__.invoke('shell:exec', args); }" +
67
- " }; })();\n"
68
- }
69
- }
70
-
71
- /**
72
- * 单条命令最多回传多少字节(每个流各算):超出部分截断并标注。
73
- * executeWithOutput 已经把输出全量收进内存,这里挡的是「再把它整个塞进一条 IPC 消息」——
74
- * 一条命令把几十 MB 灌进 WebView 没有意义,只会把窗口卡住。
75
- */
76
- public let SHELL_MAX_CAPTURE_BYTES: Int64 = 262144
77
-
78
- /**
79
- * 把捕获到的字节解成字符串:非 UTF-8 时退化成摘要而不是抛异常(子进程往 stdout 写什么我们管不着),
80
- * 超出上限的截断并加一行标注,让前端知道「这不是全部」。
81
- */
82
- public func decodeCaptured(bytes: Array<Byte>): String {
83
- let limit = if (bytes.size > SHELL_MAX_CAPTURE_BYTES) {
84
- SHELL_MAX_CAPTURE_BYTES
85
- } else {
86
- Int64(bytes.size)
87
- }
88
- // 显式逐字节拷贝:Array 的区间切片在仓颉里语义较绕,这里只用到「下标读写」这一条确定的行为
89
- let limited = Array<Byte>(limit, repeat: 0)
90
- for (i in 0..limit) {
91
- limited[i] = bytes[Int64(i)]
92
- }
93
- var text = ""
94
- try {
95
- text = String.fromUtf8(limited)
96
- } catch (e: Exception) {
97
- text = "<输出不是合法 UTF-8,${bytes.size} 字节>"
98
- }
99
- if (bytes.size > SHELL_MAX_CAPTURE_BYTES) {
100
- text = text + "\n[已截断:本次输出 ${bytes.size} 字节,只回传前 ${SHELL_MAX_CAPTURE_BYTES} 字节]"
101
- }
102
- return text
103
- }
104
-
105
- /**
106
- * 跑子进程并返回 (退出码, stdout, stderr)。
107
- * 程序不存在 / 没有执行权限等情况统一转成 CommandException,前端拿到的是可读原文。
108
- */
109
- func runCaptured(program: String, argv: Array<String>, cwd: String): (Int64, Array<Byte>, Array<Byte>) {
110
- try {
111
- if (cwd.size == 0) {
112
- return executeWithOutput(program, argv)
113
- }
114
- return executeWithOutput(program, argv, workingDirectory: Path(cwd))
115
- } catch (e: Exception) {
116
- throw CommandException("shell:exec failed: ${e}")
117
- }
118
- }
119
-
120
- /**
121
- * 把 target 交给系统默认程序打开。
122
- *
123
- * 平台差异按 AGENTS.md §2 的规矩只出现在 @When 条件编译里:
124
- * - Linux 用 `xdg-open`(XDG 标准,桌面环境缺省都有);
125
- * - Windows 用 `rundll32` 的 `FileProtocolHandler`(等价于 ShellExecute:URL 与本地文件都能开,
126
- * 且**不必**经过 `cmd /c start`,省掉一层引号解析与注入面)。
127
- * 只启动、不等待:用户关不关那个程序与本应用无关,所以用 launch 而不是 execute(后者会阻塞 UI 线程)。
128
- */
129
- @When[os == "Linux"]
130
- func launchOpenTarget(target: String): Unit {
131
- let _ = launch("xdg-open", [target])
132
- }
133
-
134
- @When[os == "Windows"]
135
- func launchOpenTarget(target: String): Unit {
136
- let _ = launch("rundll32.exe", ["url.dll,FileProtocolHandler", target])
137
- }
138
-
139
- /**
140
- * shell 插件的命令处理器(两个命令共用一份,按全名分派,与内置 SystemCommands 同形)
141
- */
142
- class ShellCommands <: CommandHandler {
143
- public init() {}
144
-
145
- public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
146
- let _ = ipc
147
- match (cmd) {
148
- case "shell:open" => JsonBool(this.open(args))
149
- case "shell:exec" => this.exec(args)
150
- case _ => throw CommandException("unknown shell command: ${cmd}")
151
- }
152
- }
153
-
154
- /** 取必需的字符串参数;缺失或类型不对时抛业务错误,前端拿到的是可读原文 */
155
- private func requireString(args: JsonObject, key: String): String {
156
- match (args.get(key)) {
157
- case Some(v) => match (v.kind()) {
158
- case JsonKind.JsString => return v.asString().getValue()
159
- case _ => throw CommandException("shell: argument \"${key}\" must be a string")
160
- }
161
- case None => throw CommandException("shell: missing argument \"${key}\"")
162
- }
163
- }
164
-
165
- /** 取可选字符串参数:缺失或类型不对都退回默认值(cwd 不是必需的) */
166
- private func optionalString(args: JsonObject, key: String, fallback: String): String {
167
- match (args.get(key)) {
168
- case Some(v) => match (v.kind()) {
169
- case JsonKind.JsString => return v.asString().getValue()
170
- case _ => fallback
171
- }
172
- case None => fallback
173
- }
174
- }
175
-
176
- /** 取可选字符串数组参数(args):缺省为空数组,元素不是字符串时报错而不是悄悄丢掉 */
177
- private func optionalStringArray(args: JsonObject, key: String): Array<String> {
178
- match (args.get(key)) {
179
- case Some(v) => match (v.kind()) {
180
- case JsonKind.JsArray =>
181
- let arr = v.asArray()
182
- if (arr.size() == 0) {
183
- return []
184
- }
185
- let out = Array<String>(Int64(arr.size()), repeat: "")
186
- for (i in 0..arr.size()) {
187
- let item = arr.get(i).getOrThrow()
188
- match (item.kind()) {
189
- case JsonKind.JsString => out[Int64(i)] = item.asString().getValue()
190
- case _ => throw CommandException(
191
- "shell: argument \"${key}\" must contain only strings")
192
- }
193
- }
194
- return out
195
- case _ => throw CommandException("shell: argument \"${key}\" must be an array")
196
- }
197
- case None => return []
198
- }
199
- }
200
-
201
- /** shell:open:把 target(URL 或本地路径)交给系统默认程序 */
202
- private func open(args: JsonObject): Bool {
203
- let target = this.requireString(args, "target")
204
- if (target.size == 0) {
205
- throw CommandException("shell:open: argument \"target\" must not be empty")
206
- }
207
- try {
208
- launchOpenTarget(target)
209
- return true
210
- } catch (e: Exception) {
211
- throw CommandException("shell:open failed: ${e}")
212
- }
213
- }
214
-
215
- /** shell:exec:跑一个程序并回收 (退出码, stdout, stderr);argv 直传,不经过 shell */
216
- private func exec(args: JsonObject): JsonValue {
217
- let program = this.requireString(args, "program")
218
- if (program.size == 0) {
219
- throw CommandException("shell:exec: argument \"program\" must not be empty")
220
- }
221
- let argv = this.optionalStringArray(args, "args")
222
- let cwd = this.optionalString(args, "cwd", "")
223
- let r = runCaptured(program, argv, cwd)
224
- var obj = JsonObject()
225
- obj.put("code", JsonInt(r[0]))
226
- obj.put("stdout", JsonString(decodeCaptured(r[1])))
227
- obj.put("stderr", JsonString(decodeCaptured(r[2])))
228
- return obj
229
- }
230
- }
1
+ package cjTauri.plugins.shell
2
+
3
+ import cjTauri.*
4
+
5
+ import std.collection.*
6
+ // std.fs / std.process 只被桌面两端实现用到(ohos 桩不引用,RFC §4.6.4 P-1);
7
+ // 将来加新目标(iOS/Android)时按 P-1 重新评估门禁
8
+ @When[env != "ohos"]
9
+ import std.fs.*
10
+ @When[env != "ohos"]
11
+ import std.process.*
12
+ import stdx.encoding.json.*
13
+
14
+ /**
15
+ * 官方插件 shell:把文件 / URL 交给系统默认程序打开;执行子进程并取回输出(对标 Tauri 的 shell 插件)
16
+ *
17
+ * 命令:shell:open / shell:exec
18
+ * 权限:**不自动放行**,应用要在 capabilities 里显式声明,两种写法等价:
19
+ * "commands": ["shell:open"] // 明文逐条
20
+ * "permissions": ["shell:allow-open"] // 命名权限集(v1.1,只放行「打开」)
21
+ * 前端:window.__CJ_TAURI__.shell.exec({ program: "git", args: ["rev-parse", "HEAD"] })
22
+ *
23
+ * 安全边界(改代码时不要放松):本插件**不经过 shell**——program 与每个参数都作为独立 argv 元素直接
24
+ * 交给子进程,没有 `cmd /c` / `bash -c` 的字符串拼接,所以参数里带空格、引号、`&&`、`>` 都只是普通字符,
25
+ * 不构成命令注入。但「放行 shell:exec」这件事本身就等于把「执行任意程序」交给页面:能力清单是唯一的闸门,
26
+ * 页面拿到什么命令就等于能跑什么程序。只想让用户打开链接 / 文件的应用请引用 `shell:allow-open`。
27
+ *
28
+ * 限制:`shell:exec` 是同步调用(子进程不退出就不返回),且**没有**流式输出 / 超时 / 取消——但它跑在命令
29
+ * worker 线程上(命令分发已异步化,见 AGENTS.md §2 的线程模型),所以不会冻住窗口。长驻 / 交互式程序
30
+ * (编辑器、REPL、npm run dev)仍不要走这里。
31
+ */
32
+ public class ShellPlugin <: Plugin {
33
+
34
+ /** 自描述:进程/打开目标仅桌面两端实现(ohos 侧按 P-1 落桩,批次 3-PR3) */
35
+ public func describe(): PluginManifest {
36
+ let hosts = ArrayList<String>()
37
+ hosts.add("webkitgtk")
38
+ hosts.add("webview2")
39
+ return PluginManifest(hosts, ArrayList<String>())
40
+ }
41
+ public init() {}
42
+
43
+ public func name(): String {
44
+ return "shell"
45
+ }
46
+
47
+ public func commands(): HashMap<String, CommandHandler> {
48
+ let m = HashMap<String, CommandHandler>()
49
+ let handler = ShellCommands()
50
+ m.add("open", handler)
51
+ m.add("exec", handler)
52
+ return m
53
+ }
54
+
55
+ /**
56
+ * 命名权限集(v1.1):allow-open 只含「交给系统默认程序打开」这条,exec(执行任意程序)不在其中。
57
+ * 只想打开链接 / 文件的应用引用 allow-open 即可,不必顺带拿到执行能力;集内写短名,框架自动补 `shell:`。
58
+ */
59
+ public func permissions(): HashMap<String, ArrayList<String>> {
60
+ let m = HashMap<String, ArrayList<String>>()
61
+ let openOnly = ArrayList<String>()
62
+ openOnly.add("open")
63
+ m.add("allow-open", openOnly)
64
+ let all = ArrayList<String>()
65
+ all.add("open")
66
+ all.add("exec")
67
+ m.add("default", all)
68
+ return m
69
+ }
70
+
71
+ /**
72
+ * 前端 shim:往桥对象上挂一个 shell 命名空间(与 fs / dialog 同款,不碰 invoke / listen / emit)
73
+ */
74
+ public func jsShim(): String {
75
+ return "(function () { var t = window.__CJ_TAURI__ || (window.__CJ_TAURI__ = {});" +
76
+ " t.shell = {" +
77
+ " open: function (args) { return window.__CJ_TAURI__.invoke('shell:open', args); }," +
78
+ " exec: function (args) { return window.__CJ_TAURI__.invoke('shell:exec', args); }" +
79
+ " }; })();\n"
80
+ }
81
+ }
82
+
83
+ /**
84
+ * 单条命令最多回传多少字节(每个流各算):超出部分截断并标注。
85
+ * executeWithOutput 已经把输出全量收进内存,这里挡的是「再把它整个塞进一条 IPC 消息」——
86
+ * 一条命令把几十 MB 灌进 WebView 没有意义,只会把窗口卡住。
87
+ */
88
+ public let SHELL_MAX_CAPTURE_BYTES: Int64 = 262144
89
+
90
+ /**
91
+ * 把捕获到的字节解成字符串:非 UTF-8 时退化成摘要而不是抛异常(子进程往 stdout 写什么我们管不着),
92
+ * 超出上限的截断并加一行标注,让前端知道「这不是全部」。
93
+ */
94
+ public func decodeCaptured(bytes: Array<Byte>): String {
95
+ let limit = if (bytes.size > SHELL_MAX_CAPTURE_BYTES) {
96
+ SHELL_MAX_CAPTURE_BYTES
97
+ } else {
98
+ Int64(bytes.size)
99
+ }
100
+ // 显式逐字节拷贝:Array 的区间切片在仓颉里语义较绕,这里只用到「下标读写」这一条确定的行为
101
+ let limited = Array<Byte>(limit, repeat: 0)
102
+ for (i in 0..limit) {
103
+ limited[i] = bytes[Int64(i)]
104
+ }
105
+ var text = ""
106
+ try {
107
+ text = String.fromUtf8(limited)
108
+ } catch (e: Exception) {
109
+ text = "<输出不是合法 UTF-8,${bytes.size} 字节>"
110
+ }
111
+ if (bytes.size > SHELL_MAX_CAPTURE_BYTES) {
112
+ text = text + "\n[已截断:本次输出 ${bytes.size} 字节,只回传前 ${SHELL_MAX_CAPTURE_BYTES} 字节]"
113
+ }
114
+ return text
115
+ }
116
+
117
+ /**
118
+ * 跑子进程并返回 (退出码, stdout, stderr)。
119
+ * 程序不存在 / 没有执行权限等情况统一转成 CommandException,前端拿到的是可读原文。
120
+ */
121
+ @When[env != "ohos"]
122
+ func runCaptured(program: String, argv: Array<String>, cwd: String): (Int64, Array<Byte>, Array<Byte>) {
123
+ try {
124
+ if (cwd.size == 0) {
125
+ return executeWithOutput(program, argv)
126
+ }
127
+ return executeWithOutput(program, argv, workingDirectory: Path(cwd))
128
+ } catch (e: Exception) {
129
+ throw CommandException("shell:exec failed: ${e}")
130
+ }
131
+ }
132
+
133
+ /**
134
+ * 把 target 交给系统默认程序打开。
135
+ *
136
+ * 平台差异按 AGENTS.md §2 的规矩只出现在 @When 条件编译里:
137
+ * - Linux 用 `xdg-open`(XDG 标准,桌面环境缺省都有);
138
+ * - Windows 用 `rundll32` 的 `FileProtocolHandler`(等价于 ShellExecute:URL 与本地文件都能开,
139
+ * 且**不必**经过 `cmd /c start`,省掉一层引号解析与注入面)。
140
+ * 只启动、不等待:用户关不关那个程序与本应用无关,所以用 launch 而不是 execute(后者会阻塞 UI 线程)。
141
+ */
142
+ // 鸿蒙目标下 os 仍报 Linux(探针实测),这段必须显式排除 ohos——否则会与下面的 ohos 桩重名
143
+ @When[os == "Linux" && env != "ohos"]
144
+ func launchOpenTarget(target: String): Unit {
145
+ let _ = launch("xdg-open", [target])
146
+ }
147
+
148
+ @When[os == "Windows"]
149
+ func launchOpenTarget(target: String): Unit {
150
+ let _ = launch("rundll32.exe", ["url.dll,FileProtocolHandler", target])
151
+ }
152
+
153
+ // ---------- ohos 桩(RFC §4.6.4 P-1):编得过、运行期可照做地报错 ----------
154
+ // 平台实现分段的既有先例(launchOpenTarget 的 Linux/Windows 两段);鸿蒙的原生进程/打开
155
+ // 通路批次 4 走 NDK FFI 接通后,删掉这两段桩、补上真实实现段。
156
+
157
+ @When[env == "ohos"]
158
+ func launchOpenTarget(target: String): Unit {
159
+ throw CommandException("shell: shell:open 在 ohos 端暂不支持(P-1 桩,批次 4 走 NDK FFI 接通,见 RFC §4.6.4)")
160
+ }
161
+
162
+ @When[env == "ohos"]
163
+ func runCaptured(program: String, argv: Array<String>, cwd: String): (Int64, Array<Byte>, Array<Byte>) {
164
+ throw CommandException("shell: shell:exec 在 ohos 端暂不支持(P-1 桩,批次 4 走 NDK FFI 接通,见 RFC §4.6.4)")
165
+ }
166
+
167
+ /**
168
+ * shell 插件的命令处理器(两个命令共用一份,按全名分派,与内置 SystemCommands 同形)
169
+ */
170
+ class ShellCommands <: CommandHandler {
171
+ public init() {}
172
+
173
+ public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
174
+ let _ = ipc
175
+ match (cmd) {
176
+ case "shell:open" => JsonBool(this.open(args))
177
+ case "shell:exec" => this.exec(args)
178
+ case _ => throw CommandException("unknown shell command: ${cmd}")
179
+ }
180
+ }
181
+
182
+ /** 取必需的字符串参数;缺失或类型不对时抛业务错误,前端拿到的是可读原文 */
183
+ private func requireString(args: JsonObject, key: String): String {
184
+ match (args.get(key)) {
185
+ case Some(v) => match (v.kind()) {
186
+ case JsonKind.JsString => return v.asString().getValue()
187
+ case _ => throw CommandException("shell: argument \"${key}\" must be a string")
188
+ }
189
+ case None => throw CommandException("shell: missing argument \"${key}\"")
190
+ }
191
+ }
192
+
193
+ /** 取可选字符串参数:缺失或类型不对都退回默认值(cwd 不是必需的) */
194
+ private func optionalString(args: JsonObject, key: String, fallback: String): String {
195
+ match (args.get(key)) {
196
+ case Some(v) => match (v.kind()) {
197
+ case JsonKind.JsString => return v.asString().getValue()
198
+ case _ => fallback
199
+ }
200
+ case None => fallback
201
+ }
202
+ }
203
+
204
+ /** 取可选字符串数组参数(args):缺省为空数组,元素不是字符串时报错而不是悄悄丢掉 */
205
+ private func optionalStringArray(args: JsonObject, key: String): Array<String> {
206
+ match (args.get(key)) {
207
+ case Some(v) => match (v.kind()) {
208
+ case JsonKind.JsArray =>
209
+ let arr = v.asArray()
210
+ if (arr.size() == 0) {
211
+ return []
212
+ }
213
+ let out = Array<String>(Int64(arr.size()), repeat: "")
214
+ for (i in 0..arr.size()) {
215
+ let item = arr.get(i).getOrThrow()
216
+ match (item.kind()) {
217
+ case JsonKind.JsString => out[Int64(i)] = item.asString().getValue()
218
+ case _ => throw CommandException(
219
+ "shell: argument \"${key}\" must contain only strings")
220
+ }
221
+ }
222
+ return out
223
+ case _ => throw CommandException("shell: argument \"${key}\" must be an array")
224
+ }
225
+ case None => return []
226
+ }
227
+ }
228
+
229
+ /** shell:open:把 target(URL 或本地路径)交给系统默认程序 */
230
+ private func open(args: JsonObject): Bool {
231
+ let target = this.requireString(args, "target")
232
+ if (target.size == 0) {
233
+ throw CommandException("shell:open: argument \"target\" must not be empty")
234
+ }
235
+ try {
236
+ launchOpenTarget(target)
237
+ return true
238
+ } catch (e: Exception) {
239
+ throw CommandException("shell:open failed: ${e}")
240
+ }
241
+ }
242
+
243
+ /** shell:exec:跑一个程序并回收 (退出码, stdout, stderr);argv 直传,不经过 shell */
244
+ private func exec(args: JsonObject): JsonValue {
245
+ let program = this.requireString(args, "program")
246
+ if (program.size == 0) {
247
+ throw CommandException("shell:exec: argument \"program\" must not be empty")
248
+ }
249
+ let argv = this.optionalStringArray(args, "args")
250
+ let cwd = this.optionalString(args, "cwd", "")
251
+ let r = runCaptured(program, argv, cwd)
252
+ var obj = JsonObject()
253
+ obj.put("code", JsonInt(r[0]))
254
+ obj.put("stdout", JsonString(decodeCaptured(r[1])))
255
+ obj.put("stderr", JsonString(decodeCaptured(r[2])))
256
+ return obj
257
+ }
258
+ }