cj-tauri 1.1.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +137 -77
  2. package/bin/cj-tauri.js +287 -287
  3. package/cjpm.toml +13 -2
  4. package/cli/cjpm.toml +22 -13
  5. package/cli/src/doctor.cj +308 -304
  6. package/cli/src/main.cj +129 -121
  7. package/cli/src/manifest.cj +632 -0
  8. package/cli/src/manifest_test.cj +87 -0
  9. package/cli/src/project.cj +770 -754
  10. package/cli/src/resolve.cj +348 -348
  11. package/cli/src/scaffold.cj +46 -38
  12. package/cli/src/sync.cj +526 -0
  13. package/cli/src/util.cj +9 -0
  14. package/cli/templates/app/README.md +30 -59
  15. package/cli/templates/app/{ui → assets/ui}/index.html +2 -0
  16. package/cli/templates/app/cj-tauri.toml +32 -0
  17. package/cli/templates/app/cjpm.toml +21 -4
  18. package/cli/templates/app/src/main.cj +29 -16
  19. package/cli/templates/app-react/README.md +34 -45
  20. package/cli/templates/app-react/{ui → assets/ui}/index.html +25 -25
  21. package/cli/templates/app-react/{ui → assets/ui}/src/App.css +67 -67
  22. package/cli/templates/app-react/{ui → assets/ui}/src/App.jsx +124 -124
  23. package/cli/templates/app-react/cj-tauri.toml +33 -0
  24. package/cli/templates/app-react/cjpm.toml +21 -4
  25. package/cli/templates/app-react/src/main.cj +18 -29
  26. package/cli/templates/app-vue/README.md +34 -38
  27. package/cli/templates/app-vue/{ui → assets/ui}/index.html +25 -25
  28. package/cli/templates/app-vue/{ui → assets/ui}/src/App.vue +176 -176
  29. package/cli/templates/app-vue/cj-tauri.toml +33 -0
  30. package/cli/templates/app-vue/cjpm.toml +21 -4
  31. package/cli/templates/app-vue/src/main.cj +18 -29
  32. package/cli/templates/{ohos-pc → ohos-skeleton}/README.md +5 -0
  33. package/cli/templates/{ohos-pc → ohos-skeleton}/build-hap.sh +15 -0
  34. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/cjpm.toml +8 -0
  35. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/dialog_host.cj +217 -0
  36. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/index.cj +527 -0
  37. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/module.json5 +8 -0
  38. package/native/build_win.bat +62 -62
  39. package/native/core/bridge_core.c +1681 -1626
  40. package/native/core/bridge_core.h +675 -618
  41. package/native/core/bridge_js.h +108 -108
  42. package/native/platforms/harmonyos/README.md +115 -6
  43. package/native/platforms/linux/bridge_linux.c +2787 -2485
  44. package/native/platforms/windows/bridge_win.c +3740 -3248
  45. package/native/tests/gtk_threading_probe.c +143 -143
  46. package/native/tests/test_bridge_core.c +1339 -1228
  47. package/package.json +53 -51
  48. package/prebuilt/linux-x64/cj-tauri +0 -0
  49. package/src/api_system.cj +67 -67
  50. package/src/app.cj +325 -940
  51. package/src/app_events.cj +166 -0
  52. package/src/app_host_ops.cj +287 -0
  53. package/src/app_run.cj +283 -0
  54. package/src/app_spec.cj +76 -0
  55. package/src/arkweb_menu.cj +216 -0
  56. package/src/arkweb_script.cj +0 -12
  57. package/src/capability.cj +409 -409
  58. package/src/clipboard_bridge.cj +178 -0
  59. package/src/dialog_spec.cj +79 -0
  60. package/src/dragdrop.cj +26 -26
  61. package/src/embedded_assets.cj +98 -98
  62. package/src/host.cj +752 -604
  63. package/src/host_arkweb.cj +201 -17
  64. package/src/host_webkit.cj +663 -548
  65. package/src/host_webview2.cj +663 -548
  66. package/src/hotkey.cj +66 -66
  67. package/src/ipc_hub.cj +337 -337
  68. package/src/ipc_message.cj +286 -286
  69. package/src/launch.cj +62 -0
  70. package/src/logging.cj +127 -127
  71. package/src/menu.cj +197 -197
  72. package/src/platform/platform_app_context.cj +77 -0
  73. package/src/platform/platform_assets.cj +33 -0
  74. package/src/platform/platform_discover.cj +176 -0
  75. package/src/platform/platform_env.cj +29 -0
  76. package/src/platform/platform_feature.cj +31 -0
  77. package/src/platform/platform_page_source.cj +87 -0
  78. package/src/platform/platform_target_info.cj +32 -0
  79. package/src/platform_host_provider.cj +87 -0
  80. package/src/plugin.cj +333 -299
  81. package/src/plugins/clipboard/plugin_clipboard.cj +152 -144
  82. package/src/plugins/dialog/plugin_dialog.cj +209 -209
  83. package/src/plugins/fs/plugin_fs.cj +105 -1
  84. package/src/plugins/http/http_backend.cj +131 -0
  85. package/src/plugins/http/http_desktop.cj +195 -0
  86. package/src/plugins/http/http_ohos.cj +204 -0
  87. package/src/plugins/http/plugin_http.cj +310 -459
  88. package/src/plugins/log/plugin_log.cj +105 -105
  89. package/src/plugins/menu/plugin_menu.cj +8 -0
  90. package/src/plugins/notification/plugin_notification.cj +154 -146
  91. package/src/plugins/serial/plugin_serial.cj +17 -1
  92. package/src/plugins/serial/serial_bridge.cj +9 -1
  93. package/src/plugins/serial/serial_manager_ohos.cj +92 -0
  94. package/src/plugins/serial/serial_ohos.cj +529 -0
  95. package/src/plugins/shell/plugin_shell.cj +29 -1
  96. package/src/plugins/sqlite/plugin_sqlite.cj +8 -0
  97. package/src/plugins/ws/plugin_ws.cj +488 -470
  98. package/src/process_hidden.cj +174 -174
  99. package/src/scope.cj +391 -391
  100. package/src/shell_event.cj +69 -69
  101. package/src/tests/app_spec_test.cj +126 -0
  102. package/src/tests/capability_test.cj +243 -243
  103. package/src/tests/dialog_spec_test.cj +73 -0
  104. package/src/tests/fake_host.cj +201 -186
  105. package/src/tests/ipc_hub_test.cj +487 -487
  106. package/src/tests/logging_test.cj +62 -62
  107. package/src/tests/platform_contract_test.cj +251 -0
  108. package/src/tests/plugin_clipboard_test.cj +222 -222
  109. package/src/tests/plugin_dialog_test.cj +237 -237
  110. package/src/tests/plugin_fs_test.cj +207 -0
  111. package/src/tests/plugin_http_test.cj +124 -124
  112. package/src/tests/plugin_notification_test.cj +205 -205
  113. package/src/tests/plugin_serial_test.cj +48 -0
  114. package/src/tests/plugin_test.cj +66 -3
  115. package/src/tests/plugin_ws_test.cj +40 -40
  116. package/src/tests/process_hidden_test.cj +129 -129
  117. package/src/tests/scope_test.cj +350 -350
  118. package/src/tests/system_host_test.cj +99 -81
  119. package/src/tests/urlutil_test.cj +53 -53
  120. package/src/tests/version_test.cj +75 -75
  121. package/src/tests/window_registry_test.cj +123 -123
  122. package/src/tray.cj +90 -90
  123. package/src/urlutil.cj +159 -159
  124. package/src/version.cj +44 -44
  125. package/src/window.cj +51 -51
  126. package/src/window_registry.cj +116 -116
  127. package/cli/templates/ohos-pc/entry/src/main/cangjie/index.cj +0 -167
  128. package/prebuilt/win32-x64/cj-tauri.exe +0 -0
  129. package/src/host_factory.cj +0 -48
  130. /package/cli/templates/app/{capabilities → assets/capabilities}/default.json +0 -0
  131. /package/cli/templates/app-react/{capabilities → assets/capabilities}/default.json +0 -0
  132. /package/cli/templates/app-react/{ui → assets/ui}/package.json +0 -0
  133. /package/cli/templates/app-react/{ui → assets/ui}/src/main.jsx +0 -0
  134. /package/cli/templates/app-react/{ui → assets/ui}/vite.config.js +0 -0
  135. /package/cli/templates/app-vue/{capabilities → assets/capabilities}/default.json +0 -0
  136. /package/cli/templates/app-vue/{ui → assets/ui}/package.json +0 -0
  137. /package/cli/templates/app-vue/{ui → assets/ui}/src/main.js +0 -0
  138. /package/cli/templates/app-vue/{ui → assets/ui}/vite.config.js +0 -0
  139. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/app.json5 +0 -0
  140. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/element/string.json +0 -0
  141. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/media/background.png +0 -0
  142. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/media/foreground.png +0 -0
  143. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/media/layered_image.json +0 -0
  144. /package/cli/templates/{ohos-pc → ohos-skeleton}/build-profile.json5 +0 -0
  145. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/build-profile.json5 +0 -0
  146. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/hvigorfile.ts +0 -0
  147. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/oh-package.json5 +0 -0
  148. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/cangjie/ability_stage.cj +0 -0
  149. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/cangjie/app_host.cj +0 -0
  150. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/cangjie/main_ability.cj +0 -0
  151. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/element/color.json +0 -0
  152. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/element/string.json +0 -0
  153. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/background.png +0 -0
  154. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/foreground.png +0 -0
  155. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/layered_image.json +0 -0
  156. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/startIcon.png +0 -0
  157. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/profile/main_pages.json +0 -0
  158. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/rawfile/capabilities.json +0 -0
  159. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/rawfile/index.html +0 -0
  160. /package/cli/templates/{ohos-pc → ohos-skeleton}/hvigor/hvigor-config.json5 +0 -0
  161. /package/cli/templates/{ohos-pc → ohos-skeleton}/hvigorfile.ts +0 -0
  162. /package/cli/templates/{ohos-pc → ohos-skeleton}/oh-package.json5 +0 -0
package/src/scope.cj CHANGED
@@ -1,391 +1,391 @@
1
- package cjTauri
2
-
3
- import std.collection.*
4
- import std.env.*
5
- import std.fs.*
6
- import stdx.encoding.json.*
7
-
8
- /**
9
- * 路径 scope:把「某插件的路径参数能落在哪里」写成白名单(RFC-003 第 4 期)。
10
- *
11
- * 为什么放在框架共享层、而不是留给插件自己写:展开变量、规范化、glob 匹配、deny 优先
12
- * 这几件事只要有第二份实现,就一定会有一份是错的——安全校验不吃「各插件各写一套」。
13
- * 插件只调 `IpcContext.resolveScopedPath("<插件名>", 页面给的路径)` 取值。
14
- *
15
- * 清单写法(capabilities 目录的 json,键是插件名):
16
- * "scopes": { "fs": { "allow": ["$HOME/notes/**"], "deny": ["**/id_rsa"] } }
17
- *
18
- * 语义:完全没有该插件的条目 = 旧行为(全量放行,启动时由 warnUnscopedPlugins 提示);
19
- * 有条目就按「先 deny 后 allow」判定——allow 必须显式命中,写了空 allow 等于该插件路径全拒。
20
- */
21
- public class ScopeRule {
22
- public let allow: ArrayList<String>
23
- public let deny: ArrayList<String>
24
-
25
- public init() {
26
- this.allow = ArrayList<String>()
27
- this.deny = ArrayList<String>()
28
- }
29
-
30
- public func isEmpty(): Bool {
31
- return this.allow.size == 0 && this.deny.size == 0
32
- }
33
- }
34
-
35
- /**
36
- * 解析一条 scope 规则:`{ "allow": [...], "deny": [...] }`。
37
- * 数组里非字符串的元素忽略;模式在解析时就展开路径变量(此后只管匹配)。
38
- */
39
- public func parseScopeRule(json: JsonValue): Option<ScopeRule> {
40
- match (json.kind()) {
41
- case JsonKind.JsObject => ()
42
- case _ => return None
43
- }
44
- let obj = json.asObject()
45
- let rule = ScopeRule()
46
- // 模式与页面路径走同一套分隔符口径:$HOME 在 Windows 展开出的是 USERPROFILE 原值
47
- // (C:\Users\...,反斜杠),不归一的话拿它匹配 normalizeScopePath 的正斜杠结果永远不中
48
- // ——`$HOME/**` 在 Windows 上形同虚设(实测 Desktop 文件被拒)。
49
- if (let Some(a) <- obj.get("allow")) {
50
- collectScopePatterns(a, rule.allow)
51
- }
52
- if (let Some(d) <- obj.get("deny")) {
53
- collectScopePatterns(d, rule.deny)
54
- }
55
- return Some(rule)
56
- }
57
-
58
- private func collectScopePatterns(v: JsonValue, out: ArrayList<String>): Unit {
59
- match (v.kind()) {
60
- case JsonKind.JsArray =>
61
- let arr = v.asArray()
62
- for (i in 0..arr.size()) {
63
- if (let Some(x) <- arr.get(i)) {
64
- match (x.kind()) {
65
- case JsonKind.JsString =>
66
- out.add(toScopeSlashes(expandScopeVars(x.asString().getValue())))
67
- case _ => ()
68
- }
69
- }
70
- }
71
- // 单条字符串也收:写 "fs": { "allow": "$HOME/**" } 与写成数组等价,少一处踩坑
72
- case JsonKind.JsString =>
73
- out.add(toScopeSlashes(expandScopeVars(v.asString().getValue())))
74
- case _ => ()
75
- }
76
- }
77
-
78
- /**
79
- * 判定:路径是否被这条 scope 放行。
80
- * 顺序是先 deny 后 allow——命中 deny 一律拒(哪怕同时命中 allow);allow 必须显式命中。
81
- */
82
- public func scopeAllows(rule: ScopeRule, normalizedPath: String): Bool {
83
- for (d in rule.deny) {
84
- if (globMatch(d, normalizedPath)) {
85
- return false
86
- }
87
- }
88
- for (a in rule.allow) {
89
- if (globMatch(a, normalizedPath)) {
90
- return true
91
- }
92
- }
93
- return false
94
- }
95
-
96
- /**
97
- * glob 匹配:`*` 不跨路径分隔符、`**` 跨分隔符、`?` 匹配**一个码点**(不是一字节——
98
- * 汉字是 3 字节,按字节吃掉一个会把它切碎)。
99
- *
100
- * 按 UTF-8 字节比较(非 ASCII 字节 >= 0x80,与所有 ASCII 元字符都不撞),
101
- * 所以 `?` 要多吃掉后续的续接字节(0x80-0xBF)才算一个码点。
102
- */
103
- public func globMatch(pattern: String, target: String): Bool {
104
- return globMatchAt(foldCase(pattern).toArray(), 0, foldCase(target).toArray(), 0)
105
- }
106
-
107
- /** 仅 Windows 折叠大小写(文件系统不区分大小写);Linux 保持敏感 */
108
- @When[os == "Windows"]
109
- func foldCase(s: String): String {
110
- return s.toAsciiLower()
111
- }
112
-
113
- @When[os == "Linux"]
114
- func foldCase(s: String): String {
115
- return s
116
- }
117
-
118
- private func isContinuationByte(b: UInt8): Bool {
119
- return (b & 0xC0u8) == 0x80u8
120
- }
121
-
122
- private func globMatchAt(p: Array<UInt8>, pi: Int64, t: Array<UInt8>, ti: Int64): Bool {
123
- var i = pi
124
- var j = ti
125
- while (i < p.size) {
126
- let pc = p[i]
127
- if (pc == 42u8) {
128
- // 星号(可能连写 = 跨分隔符的 `**`)
129
- var k = i
130
- while (k < p.size && p[k] == 42u8) {
131
- k++
132
- }
133
- let crossSlash = (k - i) >= 2
134
- if (k >= p.size) {
135
- // 模式尾部是星号
136
- if (crossSlash) {
137
- return true
138
- }
139
- var m = j
140
- while (m < t.size) {
141
- if (t[m] == 47u8) {
142
- return false
143
- }
144
- m++
145
- }
146
- return true
147
- }
148
- var m = j
149
- while (m <= t.size) {
150
- if (globMatchAt(p, k, t, m)) {
151
- return true
152
- }
153
- if (m >= t.size) {
154
- break
155
- }
156
- if (!crossSlash && t[m] == 47u8) {
157
- break
158
- }
159
- m++
160
- }
161
- // `**/` 之后那个斜杠是可选的:`/a/**/b` 要能匹配 `/a/b`(零个目录)
162
- if (crossSlash && p[k] == 47u8) {
163
- return globMatchAt(p, k + 1, t, j)
164
- }
165
- return false
166
- }
167
- if (pc == 63u8) {
168
- // '?':一个码点,且不吃分隔符
169
- if (j >= t.size || t[j] == 47u8) {
170
- return false
171
- }
172
- j++
173
- while (j < t.size && isContinuationByte(t[j])) {
174
- j++
175
- }
176
- i++
177
- continue
178
- }
179
- if (j >= t.size || t[j] != pc) {
180
- return false
181
- }
182
- i++
183
- j++
184
- }
185
- return j == t.size
186
- }
187
-
188
- /* ===== 路径处理 ===== */
189
-
190
- /**
191
- * 工作目录的惰性缓存(顶层只放一个 `None` 常量,真正的解析推迟到首次用到 `$CWD`)
192
- *
193
- * **为什么不能写成 `let cache = ScopeCwdCache()`**:鸿蒙上框架包由 `InitCJLibrary`
194
- * 在动态库加载期执行包全局初始化(`cjTauri::package_global_init`),那一刻仓颉堆还没起来;
195
- * 任何在初始化期做分配的顶层构造器都会让应用当场 SEGV——实测 2026-10-19:
196
- * 崩溃落在 `[anon:cangjie_heap]`(SEGV_ACCERR / SEGV_MAPERR),调用链是
197
- * `InitCJLibrary → cangjie_entry::package_global_init → cjTauri::package_global_init`,
198
- * 窗口起不来、hilog 里连一行我方日志都没有。桌面两平台是静态全链、初始化期建对象没事,
199
- * 所以这个坑只在鸿蒙的多动态库形态下暴露。
200
- */
201
- var scopeCwdCache: Option<String> = None
202
-
203
- /** 进程工作目录(项目根;`canonicalize(".")` 顺带解决绝对化与软链接) */
204
- public func scopeCwdPath(): String {
205
- match (scopeCwdCache) {
206
- case Some(v) => return v
207
- case None => ()
208
- }
209
- var value = "."
210
- try {
211
- value = toScopeSlashes(canonicalize(".").toString())
212
- } catch (e: Exception) {
213
- let _ = e
214
- }
215
- scopeCwdCache = Some(value)
216
- return value
217
- }
218
-
219
- /**
220
- * 清单模式与目标路径**统一**的反斜杠归一(两平台同一口径,d8014ad 的回归用例锁定):
221
- * Windows 的 $HOME 展开出反斜杠路径,不归一就拿它匹配不上正斜杠目标;
222
- * Linux 侧同样归一——清单是应用作者写的(可能照 Windows 风格写 $CWD\notes),
223
- * 模式与目标走同一个函数才不会「一边归一了一边没归」造成永远匹配不上。
224
- * Linux 文件名里真含反斜杠属于 exotic 场景,模式与目标同函数归一后行为仍一致。
225
- */
226
- func toScopeSlashes(s: String): String {
227
- return s.replace("\\", "/")
228
- }
229
-
230
- /**
231
- * 展开路径变量:`$HOME` / `$TEMP` / `$CWD`。
232
- *
233
- * 认不出的变量**原样保留**(连 `$` 一起留在结果里)——于是模式匹配不上 = 拒。
234
- * 这是有意的失败方向:拼错变量名只会让规则变严,不会偷偷放行。
235
- */
236
- public func expandScopeVars(raw: String): String {
237
- let bytes = raw.toArray()
238
- var out = ""
239
- var seg: Int64 = 0
240
- var i: Int64 = 0
241
- while (i < bytes.size) {
242
- if (bytes[i] != 36u8) { // '$'
243
- i++
244
- continue
245
- }
246
- var j = i + 1
247
- while (j < bytes.size && isVarNameByte(bytes[j])) {
248
- j++
249
- }
250
- if (j == i + 1) {
251
- i++
252
- continue
253
- }
254
- out += raw[seg..i]
255
- let name = raw[(i + 1)..j]
256
- match (scopeVarValue(name)) {
257
- case Some(v) => out += v
258
- case None => out += raw[i..j]
259
- }
260
- seg = j
261
- i = j
262
- }
263
- out += raw[seg..raw.size]
264
- return out
265
- }
266
-
267
- private func isVarNameByte(b: UInt8): Bool {
268
- if (b >= 97u8 && b <= 122u8) {
269
- return true
270
- }
271
- if (b >= 65u8 && b <= 90u8) {
272
- return true
273
- }
274
- if (b >= 48u8 && b <= 57u8) {
275
- return true
276
- }
277
- return b == 95u8
278
- }
279
-
280
- func scopeVarValue(name: String): Option<String> {
281
- if (name == "HOME") {
282
- return firstEnvOf(["HOME", "USERPROFILE"])
283
- }
284
- if (name == "TEMP") {
285
- return firstEnvOf(["TEMP", "TMPDIR"])
286
- }
287
- if (name == "CWD") {
288
- return Some(scopeCwdPath())
289
- }
290
- return None
291
- }
292
-
293
- func firstEnvOf(names: Array<String>): Option<String> {
294
- for (n in names) {
295
- if (let Some(v) <- getVariable(n)) {
296
- if (v.size > 0) {
297
- return Some(v)
298
- }
299
- }
300
- }
301
- return None
302
- }
303
-
304
- /**
305
- * 规范化路径(scope 模式与页面给的路径走同一个函数,保证两侧口径一致):
306
- * 1) 展开变量、统一分隔符;
307
- * 2) 路径存在 → `std.fs.canonicalize`(绝对化 + 解析软链接;白名单目录里一条指向
308
- * 别处的软链接也会被照出真身);
309
- * 3) 不存在(写新文件、或带星号的模式)→ 词法规范化:绝对化 + 折叠 `.` 与 `..`。
310
- */
311
- public func normalizeScopePath(raw: String): String {
312
- let expanded = toScopeSlashes(expandScopeVars(raw))
313
- try {
314
- return toScopeSlashes(canonicalize(expanded).toString())
315
- } catch (e: Exception) {
316
- let _ = e
317
- ()
318
- }
319
- return lexicalScopePath(expanded)
320
- }
321
-
322
- func isScopeAbsolute(p: String): Bool {
323
- let b = p.toArray()
324
- if (b.size == 0) {
325
- return false
326
- }
327
- if (b[0] == 47u8) { // '/'
328
- return true
329
- }
330
- // Windows 盘符:"X:/"
331
- if (b.size >= 3 && b[1] == 58u8 && b[2] == 47u8) {
332
- return isAsciiAlpha(b[0])
333
- }
334
- return false
335
- }
336
-
337
- private func isAsciiAlpha(b: UInt8): Bool {
338
- return (b >= 97u8 && b <= 122u8) || (b >= 65u8 && b <= 90u8)
339
- }
340
-
341
- /** 文本层面的规范化:不碰文件系统,因此对「还不存在的路径」也成立 */
342
- func lexicalScopePath(p: String): String {
343
- let abs = if (isScopeAbsolute(p)) {
344
- p
345
- } else {
346
- scopeCwdPath() + "/" + p
347
- }
348
- let bytes = abs.toArray()
349
- var root = ""
350
- var body = abs
351
- if (bytes.size > 0 && bytes[0] == 47u8) {
352
- root = "/"
353
- body = abs[1..abs.size]
354
- } else if (bytes.size >= 3 && bytes[1] == 58u8 && isAsciiAlpha(bytes[0])) {
355
- root = abs[0..2] // "X:"
356
- body = abs[2..abs.size]
357
- }
358
- let b2 = body.toArray()
359
- let segs = ArrayList<String>()
360
- var start: Int64 = 0
361
- var i: Int64 = 0
362
- while (i <= b2.size) {
363
- if (i == b2.size || b2[i] == 47u8) {
364
- let seg = body[start..i]
365
- if (seg == "..") {
366
- if (segs.size > 0) {
367
- segs.remove(at: segs.size - 1)
368
- }
369
- } else if (seg != "." && seg.size > 0) {
370
- segs.add(seg)
371
- }
372
- start = i + 1
373
- }
374
- i++
375
- }
376
- var out = root
377
- for (s in segs) {
378
- if (out.size == 0 || out == "/") {
379
- out += s
380
- } else {
381
- out += "/" + s
382
- }
383
- }
384
- if (out.size == 0) {
385
- if (root.size > 0) {
386
- return root
387
- }
388
- return "/"
389
- }
390
- return out
391
- }
1
+ package cjTauri
2
+
3
+ import std.collection.*
4
+ import std.env.*
5
+ import std.fs.*
6
+ import stdx.encoding.json.*
7
+
8
+ /**
9
+ * 路径 scope:把「某插件的路径参数能落在哪里」写成白名单(RFC-003 第 4 期)。
10
+ *
11
+ * 为什么放在框架共享层、而不是留给插件自己写:展开变量、规范化、glob 匹配、deny 优先
12
+ * 这几件事只要有第二份实现,就一定会有一份是错的——安全校验不吃「各插件各写一套」。
13
+ * 插件只调 `IpcContext.resolveScopedPath("<插件名>", 页面给的路径)` 取值。
14
+ *
15
+ * 清单写法(capabilities 目录的 json,键是插件名):
16
+ * "scopes": { "fs": { "allow": ["$HOME/notes/**"], "deny": ["**/id_rsa"] } }
17
+ *
18
+ * 语义:完全没有该插件的条目 = 旧行为(全量放行,启动时由 warnUnscopedPlugins 提示);
19
+ * 有条目就按「先 deny 后 allow」判定——allow 必须显式命中,写了空 allow 等于该插件路径全拒。
20
+ */
21
+ public class ScopeRule {
22
+ public let allow: ArrayList<String>
23
+ public let deny: ArrayList<String>
24
+
25
+ public init() {
26
+ this.allow = ArrayList<String>()
27
+ this.deny = ArrayList<String>()
28
+ }
29
+
30
+ public func isEmpty(): Bool {
31
+ return this.allow.size == 0 && this.deny.size == 0
32
+ }
33
+ }
34
+
35
+ /**
36
+ * 解析一条 scope 规则:`{ "allow": [...], "deny": [...] }`。
37
+ * 数组里非字符串的元素忽略;模式在解析时就展开路径变量(此后只管匹配)。
38
+ */
39
+ public func parseScopeRule(json: JsonValue): Option<ScopeRule> {
40
+ match (json.kind()) {
41
+ case JsonKind.JsObject => ()
42
+ case _ => return None
43
+ }
44
+ let obj = json.asObject()
45
+ let rule = ScopeRule()
46
+ // 模式与页面路径走同一套分隔符口径:$HOME 在 Windows 展开出的是 USERPROFILE 原值
47
+ // (C:\Users\...,反斜杠),不归一的话拿它匹配 normalizeScopePath 的正斜杠结果永远不中
48
+ // ——`$HOME/**` 在 Windows 上形同虚设(实测 Desktop 文件被拒)。
49
+ if (let Some(a) <- obj.get("allow")) {
50
+ collectScopePatterns(a, rule.allow)
51
+ }
52
+ if (let Some(d) <- obj.get("deny")) {
53
+ collectScopePatterns(d, rule.deny)
54
+ }
55
+ return Some(rule)
56
+ }
57
+
58
+ private func collectScopePatterns(v: JsonValue, out: ArrayList<String>): Unit {
59
+ match (v.kind()) {
60
+ case JsonKind.JsArray =>
61
+ let arr = v.asArray()
62
+ for (i in 0..arr.size()) {
63
+ if (let Some(x) <- arr.get(i)) {
64
+ match (x.kind()) {
65
+ case JsonKind.JsString =>
66
+ out.add(toScopeSlashes(expandScopeVars(x.asString().getValue())))
67
+ case _ => ()
68
+ }
69
+ }
70
+ }
71
+ // 单条字符串也收:写 "fs": { "allow": "$HOME/**" } 与写成数组等价,少一处踩坑
72
+ case JsonKind.JsString =>
73
+ out.add(toScopeSlashes(expandScopeVars(v.asString().getValue())))
74
+ case _ => ()
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 判定:路径是否被这条 scope 放行。
80
+ * 顺序是先 deny 后 allow——命中 deny 一律拒(哪怕同时命中 allow);allow 必须显式命中。
81
+ */
82
+ public func scopeAllows(rule: ScopeRule, normalizedPath: String): Bool {
83
+ for (d in rule.deny) {
84
+ if (globMatch(d, normalizedPath)) {
85
+ return false
86
+ }
87
+ }
88
+ for (a in rule.allow) {
89
+ if (globMatch(a, normalizedPath)) {
90
+ return true
91
+ }
92
+ }
93
+ return false
94
+ }
95
+
96
+ /**
97
+ * glob 匹配:`*` 不跨路径分隔符、`**` 跨分隔符、`?` 匹配**一个码点**(不是一字节——
98
+ * 汉字是 3 字节,按字节吃掉一个会把它切碎)。
99
+ *
100
+ * 按 UTF-8 字节比较(非 ASCII 字节 >= 0x80,与所有 ASCII 元字符都不撞),
101
+ * 所以 `?` 要多吃掉后续的续接字节(0x80-0xBF)才算一个码点。
102
+ */
103
+ public func globMatch(pattern: String, target: String): Bool {
104
+ return globMatchAt(foldCase(pattern).toArray(), 0, foldCase(target).toArray(), 0)
105
+ }
106
+
107
+ /** 仅 Windows 折叠大小写(文件系统不区分大小写);Linux 保持敏感 */
108
+ @When[os == "Windows"]
109
+ func foldCase(s: String): String {
110
+ return s.toAsciiLower()
111
+ }
112
+
113
+ @When[os == "Linux"]
114
+ func foldCase(s: String): String {
115
+ return s
116
+ }
117
+
118
+ private func isContinuationByte(b: UInt8): Bool {
119
+ return (b & 0xC0u8) == 0x80u8
120
+ }
121
+
122
+ private func globMatchAt(p: Array<UInt8>, pi: Int64, t: Array<UInt8>, ti: Int64): Bool {
123
+ var i = pi
124
+ var j = ti
125
+ while (i < p.size) {
126
+ let pc = p[i]
127
+ if (pc == 42u8) {
128
+ // 星号(可能连写 = 跨分隔符的 `**`)
129
+ var k = i
130
+ while (k < p.size && p[k] == 42u8) {
131
+ k++
132
+ }
133
+ let crossSlash = (k - i) >= 2
134
+ if (k >= p.size) {
135
+ // 模式尾部是星号
136
+ if (crossSlash) {
137
+ return true
138
+ }
139
+ var m = j
140
+ while (m < t.size) {
141
+ if (t[m] == 47u8) {
142
+ return false
143
+ }
144
+ m++
145
+ }
146
+ return true
147
+ }
148
+ var m = j
149
+ while (m <= t.size) {
150
+ if (globMatchAt(p, k, t, m)) {
151
+ return true
152
+ }
153
+ if (m >= t.size) {
154
+ break
155
+ }
156
+ if (!crossSlash && t[m] == 47u8) {
157
+ break
158
+ }
159
+ m++
160
+ }
161
+ // `**/` 之后那个斜杠是可选的:`/a/**/b` 要能匹配 `/a/b`(零个目录)
162
+ if (crossSlash && p[k] == 47u8) {
163
+ return globMatchAt(p, k + 1, t, j)
164
+ }
165
+ return false
166
+ }
167
+ if (pc == 63u8) {
168
+ // '?':一个码点,且不吃分隔符
169
+ if (j >= t.size || t[j] == 47u8) {
170
+ return false
171
+ }
172
+ j++
173
+ while (j < t.size && isContinuationByte(t[j])) {
174
+ j++
175
+ }
176
+ i++
177
+ continue
178
+ }
179
+ if (j >= t.size || t[j] != pc) {
180
+ return false
181
+ }
182
+ i++
183
+ j++
184
+ }
185
+ return j == t.size
186
+ }
187
+
188
+ /* ===== 路径处理 ===== */
189
+
190
+ /**
191
+ * 工作目录的惰性缓存(顶层只放一个 `None` 常量,真正的解析推迟到首次用到 `$CWD`)
192
+ *
193
+ * **为什么不能写成 `let cache = ScopeCwdCache()`**:鸿蒙上框架包由 `InitCJLibrary`
194
+ * 在动态库加载期执行包全局初始化(`cjTauri::package_global_init`),那一刻仓颉堆还没起来;
195
+ * 任何在初始化期做分配的顶层构造器都会让应用当场 SEGV——实测 2026-10-19:
196
+ * 崩溃落在 `[anon:cangjie_heap]`(SEGV_ACCERR / SEGV_MAPERR),调用链是
197
+ * `InitCJLibrary → cangjie_entry::package_global_init → cjTauri::package_global_init`,
198
+ * 窗口起不来、hilog 里连一行我方日志都没有。桌面两平台是静态全链、初始化期建对象没事,
199
+ * 所以这个坑只在鸿蒙的多动态库形态下暴露。
200
+ */
201
+ var scopeCwdCache: Option<String> = None
202
+
203
+ /** 进程工作目录(项目根;`canonicalize(".")` 顺带解决绝对化与软链接) */
204
+ public func scopeCwdPath(): String {
205
+ match (scopeCwdCache) {
206
+ case Some(v) => return v
207
+ case None => ()
208
+ }
209
+ var value = "."
210
+ try {
211
+ value = toScopeSlashes(canonicalize(".").toString())
212
+ } catch (e: Exception) {
213
+ let _ = e
214
+ }
215
+ scopeCwdCache = Some(value)
216
+ return value
217
+ }
218
+
219
+ /**
220
+ * 清单模式与目标路径**统一**的反斜杠归一(两平台同一口径,d8014ad 的回归用例锁定):
221
+ * Windows 的 $HOME 展开出反斜杠路径,不归一就拿它匹配不上正斜杠目标;
222
+ * Linux 侧同样归一——清单是应用作者写的(可能照 Windows 风格写 $CWD\notes),
223
+ * 模式与目标走同一个函数才不会「一边归一了一边没归」造成永远匹配不上。
224
+ * Linux 文件名里真含反斜杠属于 exotic 场景,模式与目标同函数归一后行为仍一致。
225
+ */
226
+ func toScopeSlashes(s: String): String {
227
+ return s.replace("\\", "/")
228
+ }
229
+
230
+ /**
231
+ * 展开路径变量:`$HOME` / `$TEMP` / `$CWD`。
232
+ *
233
+ * 认不出的变量**原样保留**(连 `$` 一起留在结果里)——于是模式匹配不上 = 拒。
234
+ * 这是有意的失败方向:拼错变量名只会让规则变严,不会偷偷放行。
235
+ */
236
+ public func expandScopeVars(raw: String): String {
237
+ let bytes = raw.toArray()
238
+ var out = ""
239
+ var seg: Int64 = 0
240
+ var i: Int64 = 0
241
+ while (i < bytes.size) {
242
+ if (bytes[i] != 36u8) { // '$'
243
+ i++
244
+ continue
245
+ }
246
+ var j = i + 1
247
+ while (j < bytes.size && isVarNameByte(bytes[j])) {
248
+ j++
249
+ }
250
+ if (j == i + 1) {
251
+ i++
252
+ continue
253
+ }
254
+ out += raw[seg..i]
255
+ let name = raw[(i + 1)..j]
256
+ match (scopeVarValue(name)) {
257
+ case Some(v) => out += v
258
+ case None => out += raw[i..j]
259
+ }
260
+ seg = j
261
+ i = j
262
+ }
263
+ out += raw[seg..raw.size]
264
+ return out
265
+ }
266
+
267
+ private func isVarNameByte(b: UInt8): Bool {
268
+ if (b >= 97u8 && b <= 122u8) {
269
+ return true
270
+ }
271
+ if (b >= 65u8 && b <= 90u8) {
272
+ return true
273
+ }
274
+ if (b >= 48u8 && b <= 57u8) {
275
+ return true
276
+ }
277
+ return b == 95u8
278
+ }
279
+
280
+ func scopeVarValue(name: String): Option<String> {
281
+ if (name == "HOME") {
282
+ return firstEnvOf(["HOME", "USERPROFILE"])
283
+ }
284
+ if (name == "TEMP") {
285
+ return firstEnvOf(["TEMP", "TMPDIR"])
286
+ }
287
+ if (name == "CWD") {
288
+ return Some(scopeCwdPath())
289
+ }
290
+ return None
291
+ }
292
+
293
+ func firstEnvOf(names: Array<String>): Option<String> {
294
+ for (n in names) {
295
+ if (let Some(v) <- getVariable(n)) {
296
+ if (v.size > 0) {
297
+ return Some(v)
298
+ }
299
+ }
300
+ }
301
+ return None
302
+ }
303
+
304
+ /**
305
+ * 规范化路径(scope 模式与页面给的路径走同一个函数,保证两侧口径一致):
306
+ * 1) 展开变量、统一分隔符;
307
+ * 2) 路径存在 → `std.fs.canonicalize`(绝对化 + 解析软链接;白名单目录里一条指向
308
+ * 别处的软链接也会被照出真身);
309
+ * 3) 不存在(写新文件、或带星号的模式)→ 词法规范化:绝对化 + 折叠 `.` 与 `..`。
310
+ */
311
+ public func normalizeScopePath(raw: String): String {
312
+ let expanded = toScopeSlashes(expandScopeVars(raw))
313
+ try {
314
+ return toScopeSlashes(canonicalize(expanded).toString())
315
+ } catch (e: Exception) {
316
+ let _ = e
317
+ ()
318
+ }
319
+ return lexicalScopePath(expanded)
320
+ }
321
+
322
+ func isScopeAbsolute(p: String): Bool {
323
+ let b = p.toArray()
324
+ if (b.size == 0) {
325
+ return false
326
+ }
327
+ if (b[0] == 47u8) { // '/'
328
+ return true
329
+ }
330
+ // Windows 盘符:"X:/"
331
+ if (b.size >= 3 && b[1] == 58u8 && b[2] == 47u8) {
332
+ return isAsciiAlpha(b[0])
333
+ }
334
+ return false
335
+ }
336
+
337
+ private func isAsciiAlpha(b: UInt8): Bool {
338
+ return (b >= 97u8 && b <= 122u8) || (b >= 65u8 && b <= 90u8)
339
+ }
340
+
341
+ /** 文本层面的规范化:不碰文件系统,因此对「还不存在的路径」也成立 */
342
+ func lexicalScopePath(p: String): String {
343
+ let abs = if (isScopeAbsolute(p)) {
344
+ p
345
+ } else {
346
+ scopeCwdPath() + "/" + p
347
+ }
348
+ let bytes = abs.toArray()
349
+ var root = ""
350
+ var body = abs
351
+ if (bytes.size > 0 && bytes[0] == 47u8) {
352
+ root = "/"
353
+ body = abs[1..abs.size]
354
+ } else if (bytes.size >= 3 && bytes[1] == 58u8 && isAsciiAlpha(bytes[0])) {
355
+ root = abs[0..2] // "X:"
356
+ body = abs[2..abs.size]
357
+ }
358
+ let b2 = body.toArray()
359
+ let segs = ArrayList<String>()
360
+ var start: Int64 = 0
361
+ var i: Int64 = 0
362
+ while (i <= b2.size) {
363
+ if (i == b2.size || b2[i] == 47u8) {
364
+ let seg = body[start..i]
365
+ if (seg == "..") {
366
+ if (segs.size > 0) {
367
+ segs.remove(at: segs.size - 1)
368
+ }
369
+ } else if (seg != "." && seg.size > 0) {
370
+ segs.add(seg)
371
+ }
372
+ start = i + 1
373
+ }
374
+ i++
375
+ }
376
+ var out = root
377
+ for (s in segs) {
378
+ if (out.size == 0 || out == "/") {
379
+ out += s
380
+ } else {
381
+ out += "/" + s
382
+ }
383
+ }
384
+ if (out.size == 0) {
385
+ if (root.size > 0) {
386
+ return root
387
+ }
388
+ return "/"
389
+ }
390
+ return out
391
+ }