cj-tauri 2.0.0 → 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 (158) hide show
  1. package/README.md +113 -99
  2. package/bin/cj-tauri.js +287 -287
  3. package/cjpm.toml +63 -52
  4. package/cli/cjpm.toml +22 -22
  5. package/cli/src/doctor.cj +308 -306
  6. package/cli/src/main.cj +129 -129
  7. package/cli/src/manifest.cj +632 -558
  8. package/cli/src/manifest_test.cj +87 -0
  9. package/cli/src/project.cj +770 -770
  10. package/cli/src/resolve.cj +348 -348
  11. package/cli/src/scaffold.cj +369 -369
  12. package/cli/src/sync.cj +526 -526
  13. package/cli/src/util.cj +168 -168
  14. package/cli/templates/app/README.md +51 -48
  15. package/cli/templates/app/assets/capabilities/default.json +7 -7
  16. package/cli/templates/app/assets/ui/index.html +120 -118
  17. package/cli/templates/app/cj-tauri.toml +32 -22
  18. package/cli/templates/app/cjpm.toml +34 -34
  19. package/cli/templates/app/src/main.cj +101 -90
  20. package/cli/templates/app-react/README.md +56 -56
  21. package/cli/templates/app-react/assets/capabilities/default.json +7 -7
  22. package/cli/templates/app-react/assets/ui/index.html +25 -25
  23. package/cli/templates/app-react/assets/ui/package.json +19 -19
  24. package/cli/templates/app-react/assets/ui/src/App.css +67 -67
  25. package/cli/templates/app-react/assets/ui/src/App.jsx +124 -124
  26. package/cli/templates/app-react/assets/ui/src/main.jsx +6 -6
  27. package/cli/templates/app-react/assets/ui/vite.config.js +24 -24
  28. package/cli/templates/app-react/cj-tauri.toml +33 -23
  29. package/cli/templates/app-react/cjpm.toml +34 -34
  30. package/cli/templates/app-react/src/main.cj +91 -91
  31. package/cli/templates/app-vue/README.md +56 -56
  32. package/cli/templates/app-vue/assets/capabilities/default.json +7 -7
  33. package/cli/templates/app-vue/assets/ui/index.html +25 -25
  34. package/cli/templates/app-vue/assets/ui/package.json +18 -18
  35. package/cli/templates/app-vue/assets/ui/src/App.vue +176 -176
  36. package/cli/templates/app-vue/assets/ui/src/main.js +4 -4
  37. package/cli/templates/app-vue/assets/ui/vite.config.js +24 -24
  38. package/cli/templates/app-vue/cj-tauri.toml +33 -23
  39. package/cli/templates/app-vue/cjpm.toml +34 -34
  40. package/cli/templates/app-vue/src/main.cj +91 -91
  41. package/cli/templates/ohos-skeleton/AppScope/app.json5 +13 -13
  42. package/cli/templates/ohos-skeleton/AppScope/resources/base/element/string.json +8 -8
  43. package/cli/templates/ohos-skeleton/AppScope/resources/base/media/layered_image.json +6 -6
  44. package/cli/templates/ohos-skeleton/README.md +84 -79
  45. package/cli/templates/ohos-skeleton/build-hap.sh +15 -0
  46. package/cli/templates/ohos-skeleton/build-profile.json5 +46 -46
  47. package/cli/templates/ohos-skeleton/entry/build-profile.json5 +26 -26
  48. package/cli/templates/ohos-skeleton/entry/cjpm.toml +51 -43
  49. package/cli/templates/ohos-skeleton/entry/hvigorfile.ts +6 -6
  50. package/cli/templates/ohos-skeleton/entry/oh-package.json5 +9 -9
  51. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/ability_stage.cj +17 -17
  52. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/app_host.cj +94 -94
  53. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/dialog_host.cj +217 -0
  54. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/index.cj +527 -167
  55. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/main_ability.cj +139 -139
  56. package/cli/templates/ohos-skeleton/entry/src/main/module.json5 +57 -49
  57. package/cli/templates/ohos-skeleton/entry/src/main/resources/base/element/color.json +8 -8
  58. package/cli/templates/ohos-skeleton/entry/src/main/resources/base/element/string.json +16 -16
  59. package/cli/templates/ohos-skeleton/entry/src/main/resources/base/media/layered_image.json +6 -6
  60. package/cli/templates/ohos-skeleton/entry/src/main/resources/base/profile/main_pages.json +2 -2
  61. package/cli/templates/ohos-skeleton/entry/src/main/resources/rawfile/capabilities.json +6 -6
  62. package/cli/templates/ohos-skeleton/entry/src/main/resources/rawfile/index.html +92 -92
  63. package/cli/templates/ohos-skeleton/hvigor/hvigor-config.json5 +5 -5
  64. package/cli/templates/ohos-skeleton/hvigorfile.ts +6 -6
  65. package/cli/templates/ohos-skeleton/oh-package.json5 +10 -10
  66. package/native/build_win.bat +62 -62
  67. package/native/core/bridge_core.c +1681 -1626
  68. package/native/core/bridge_core.h +675 -618
  69. package/native/core/bridge_js.h +108 -108
  70. package/native/platforms/harmonyos/README.md +115 -6
  71. package/native/platforms/linux/bridge_linux.c +2787 -2485
  72. package/native/platforms/windows/bridge_win.c +3740 -3248
  73. package/native/tests/gtk_threading_probe.c +143 -143
  74. package/native/tests/test_bridge_core.c +1339 -1228
  75. package/package.json +53 -53
  76. package/prebuilt/linux-x64/cj-tauri +0 -0
  77. package/src/api_system.cj +67 -67
  78. package/src/app.cj +325 -969
  79. package/src/app_events.cj +166 -0
  80. package/src/app_host_ops.cj +287 -0
  81. package/src/app_run.cj +283 -0
  82. package/src/app_spec.cj +76 -76
  83. package/src/arkweb_menu.cj +216 -0
  84. package/src/arkweb_script.cj +142 -142
  85. package/src/capability.cj +409 -409
  86. package/src/clipboard_bridge.cj +178 -0
  87. package/src/dialog_spec.cj +79 -0
  88. package/src/dragdrop.cj +26 -26
  89. package/src/embedded_assets.cj +98 -98
  90. package/src/host.cj +752 -607
  91. package/src/host_arkweb.cj +924 -778
  92. package/src/host_webkit.cj +663 -570
  93. package/src/host_webview2.cj +663 -570
  94. package/src/hotkey.cj +66 -66
  95. package/src/ipc_hub.cj +337 -337
  96. package/src/ipc_message.cj +286 -286
  97. package/src/launch.cj +62 -62
  98. package/src/logging.cj +127 -127
  99. package/src/menu.cj +197 -197
  100. package/src/platform/platform_app_context.cj +77 -77
  101. package/src/platform/platform_assets.cj +33 -33
  102. package/src/platform/platform_discover.cj +176 -176
  103. package/src/platform/platform_env.cj +29 -29
  104. package/src/platform/platform_feature.cj +31 -31
  105. package/src/platform/platform_page_source.cj +87 -87
  106. package/src/platform/platform_target_info.cj +32 -32
  107. package/src/platform_host_provider.cj +87 -87
  108. package/src/plugin.cj +333 -333
  109. package/src/plugins/clipboard/plugin_clipboard.cj +152 -152
  110. package/src/plugins/dialog/plugin_dialog.cj +209 -209
  111. package/src/plugins/fs/plugin_fs.cj +105 -1
  112. package/src/plugins/http/http_backend.cj +131 -0
  113. package/src/plugins/http/http_desktop.cj +195 -0
  114. package/src/plugins/http/http_ohos.cj +204 -0
  115. package/src/plugins/http/plugin_http.cj +310 -459
  116. package/src/plugins/log/plugin_log.cj +105 -105
  117. package/src/plugins/menu/plugin_menu.cj +158 -158
  118. package/src/plugins/notification/plugin_notification.cj +154 -154
  119. package/src/plugins/serial/plugin_serial.cj +458 -454
  120. package/src/plugins/serial/serial_bridge.cj +194 -194
  121. package/src/plugins/serial/serial_manager_ohos.cj +92 -0
  122. package/src/plugins/serial/serial_ohos.cj +529 -0
  123. package/src/plugins/shell/plugin_shell.cj +258 -258
  124. package/src/plugins/sqlite/plugin_sqlite.cj +705 -710
  125. package/src/plugins/sqlite/sqlite_ffi.cj +206 -211
  126. package/src/plugins/ws/plugin_ws.cj +488 -470
  127. package/src/process_hidden.cj +174 -174
  128. package/src/scope.cj +391 -391
  129. package/src/shell_event.cj +69 -69
  130. package/src/tests/app_spec_test.cj +126 -127
  131. package/src/tests/capability_test.cj +243 -243
  132. package/src/tests/dialog_spec_test.cj +73 -0
  133. package/src/tests/fake_host.cj +201 -201
  134. package/src/tests/ipc_hub_test.cj +487 -487
  135. package/src/tests/logging_test.cj +62 -62
  136. package/src/tests/platform_contract_test.cj +251 -251
  137. package/src/tests/plugin_clipboard_test.cj +222 -222
  138. package/src/tests/plugin_dialog_test.cj +237 -237
  139. package/src/tests/plugin_fs_test.cj +207 -0
  140. package/src/tests/plugin_http_test.cj +124 -124
  141. package/src/tests/plugin_notification_test.cj +205 -205
  142. package/src/tests/plugin_serial_test.cj +48 -0
  143. package/src/tests/plugin_test.cj +605 -585
  144. package/src/tests/plugin_ws_test.cj +40 -40
  145. package/src/tests/process_hidden_test.cj +129 -129
  146. package/src/tests/scope_test.cj +350 -350
  147. package/src/tests/system_host_test.cj +99 -81
  148. package/src/tests/urlutil_test.cj +53 -53
  149. package/src/tests/version_test.cj +75 -75
  150. package/src/tests/window_registry_test.cj +123 -123
  151. package/src/tray.cj +90 -90
  152. package/src/urlutil.cj +159 -159
  153. package/src/version.cj +44 -44
  154. package/src/window.cj +51 -51
  155. package/src/window_registry.cj +116 -116
  156. package/prebuilt/win32-x64/cj-tauri.exe +0 -0
  157. package/src/plugins/serial/serial_ohos_stub.cj +0 -61
  158. package/src/plugins/sqlite/sqlite_ohos_stub.cj +0 -91
@@ -1,459 +1,310 @@
1
- package cjTauri.plugins.http
2
-
3
- import cjTauri.*
4
-
5
- import std.collection.*
6
- import std.io.*
7
- import stdx.encoding.json.*
8
- import stdx.net.http.*
9
- import stdx.net.tls.*
10
-
11
- /** 单次响应体上限:8 MiB(LLM 非流式回复通常远小于它;够装一份大 README) */
12
- public let CJ_HTTP_MAX_BYTES: Int64 = 8 * 1024 * 1024
13
-
14
- /** 请求超时(读写同一预算):ClientBuilder 没有 connectTimeout,目标不可达会卡在
15
- * TCP connect 上——超时预算按「整体别太久」给,长挂由前端超时/取消兜 */
16
- public let CJ_HTTP_TIMEOUT_SECONDS: Int64 = 60
17
-
18
- /** 页面可带的请求头上限(条数与单条长度):防用一堆超长头把内存撑爆 */
19
- public let CJ_HTTP_MAX_HEADERS: Int64 = 16
20
- public let CJ_HTTP_MAX_HEADER_VALUE: Int64 = 4096
21
-
22
- /**
23
- * 官方插件 http:**页面可控的出网口**(RFC-003 HTTP 白名单收口)
24
- *
25
- * 命令:http:get({url})/ http:post({url, body, headers?})/ http:delete({url, body}),返回
26
- * { status, contentType, body, truncated };失败抛 CommandException(业务错误)。
27
- * 权限:**不自动放行**——"permissions": ["http:default"] 或在 commands 里明文写。
28
- *
29
- * 安全语义(与 fs 的路径 scope 同构,判定逻辑复用共享层 scopeAllows/globMatch):
30
- * - **主机白名单**:清单写 "scopes": { "http": { "allow": ["api.deepseek.com"] } }。
31
- * allow 必须显式命中、先 deny 后 allow;**没写 scopes.http = 一律拒绝**并打一行 stderr
32
- * (与 fs「空 allow = 全拒」同方向:出网是高危面,不做「没写就放行」的旧行为)。
33
- * - **只认 host,不认端口**:匹配目标是 URL 里的 host(小写化);端口不参与匹配——
34
- * 白名单声明「我信这个主机」,同一台主机的端口不会让信任变多。
35
- * - **禁自动重定向**(autoRedirect(false)):3xx 原样回给页面——重定向到别的主机
36
- * 不会替页面**追出去**(白名单只判第一跳;要跟就由页面显式再发一次、再过一遍白名单)。
37
- * - Host / Content-Length 头不许页面自带(前者能改写白名单判过的目标,后者由库自己算)。
38
- *
39
- * TLS:显式 tlsConfig(TlsClientConfig())——仓颉 HTTP 客户端**不带默认 TLS**,
40
- * 不配的话 https 直接抛 HttpException(AGENTS §4,movie 54 次取图全灭的教训)。
41
- *
42
- * 性能口径(为什么**每个请求新建 Client**而不是共享一个):
43
- * - 最大的性能收益本来就在**流式**:一次 LLM 对话只有一次请求,而这条流要挂几十秒——
44
- * 建连 + TLS 握手(约百毫秒量级)摊在整条流里可忽略;频繁短请求不是本插件的靶场景。
45
- * - 跨线程共享 Client 的连接池是否线程安全,本仓**没有验证过**(music 示例宁可每次
46
- * 建连也不埋「偶发串包」的坑,见 examples/music/src/api.cj 的注释);命令 worker 是
47
- * 并发的,用锁包共享 Client 会把并发请求串行化,反而伤多窗口/多会话场景。
48
- * - 真到了「高频短请求」成为瓶颈那天,先实测 stdx 连接池的线程安全再改共享——
49
- * 那是一次有判据的优化,不是现在凭感觉做。
50
- */
51
- public class HttpPlugin <: Plugin {
52
- public init() {}
53
-
54
- public func name(): String {
55
- return "http"
56
- }
57
-
58
- public func commands(): HashMap<String, CommandHandler> {
59
- let m = HashMap<String, CommandHandler>()
60
- // 键写**短名**:前缀由框架补(写全名会得到 http:http:get,且所有命令都成未注册)
61
- m.add("get", HttpCommands())
62
- m.add("post", HttpCommands())
63
- m.add("delete", HttpCommands())
64
- m.add("postStream", HttpCommands())
65
- return m
66
- }
67
-
68
- /** 命名权限集(v1.1):default = GET + POST + POST 流式 */
69
- public func permissions(): HashMap<String, ArrayList<String>> {
70
- let m = HashMap<String, ArrayList<String>>()
71
- let all = ArrayList<String>()
72
- all.add("get")
73
- all.add("post")
74
- all.add("delete")
75
- all.add("postStream")
76
- m.add("default", all)
77
- return m
78
- }
79
-
80
- /** 流式命令推事件:每个 body 块经 http:chunk 回投(应用要在清单 events 里放行)。
81
- * 这里写**短名**——框架自动补「<插件名>:」前缀,写全名会得到 http:http:chunk(实测)。 */
82
- public func events(): ArrayList<String> {
83
- let ev = ArrayList<String>()
84
- ev.add("chunk")
85
- return ev
86
- }
87
-
88
- /** 吃 scope:主机白名单的判定在清单 scopes.http(没写 = 全拒,见类注释) */
89
- public func scopeAware(): Bool {
90
- return true
91
- }
92
- }
93
-
94
- /** http 命令处理器:get 收 {url},post / delete 收 {url, body, headers?}(delete 的体同样是 JSON 文本) */
95
- public class HttpCommands <: CommandHandler {
96
- public init() {}
97
-
98
- public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
99
- match (cmd) {
100
- case "http:get" => return this.fetch("GET", args, ipc)
101
- case "http:post" => return this.fetch("POST", args, ipc)
102
- case "http:delete" => return this.fetch("DELETE", args, ipc)
103
- case "http:postStream" => return this.fetchStream(args, ipc)
104
- case _ => throw CommandException("unknown http command: ${cmd}")
105
- }
106
- }
107
-
108
- /**
109
- * 流式 POST(SSE):与 doFetch 同一套白名单/请求头判定,差别只在**读响应体**——
110
- * 按块读、每读一块就把增量经事件通道回投(emitLatest:同一帧内多条只保最新,
111
- * 渲染层本来就该按「最新累计文本」画,高频 delta 不必每条都到)。
112
- *
113
- * 事件 payload:{ status, contentType, delta, received, done, truncated }。
114
- * delta 是**本轮新增**的文本;UTF-8 多字节被块边界切开时,不完整的那几字节
115
- * 留到下一块拼齐再发(按 lead byte 回扫补齐边界,见 emitUtf8Chunks)。
116
- * 结束(EOF / 超限)后发 done=true 的最后一条,命令再返回总字节数。
117
- */
118
- private func fetchStream(args: JsonObject, ipc: IpcContext): JsonValue {
119
- let url = this.requireString(args, "url")
120
- let host = match (httpHostOf(url)) {
121
- case Some(h) => h
122
- case None => throw CommandException("http: host is outside the authorized scope")
123
- }
124
- this.checkHostScope(url, host, ipc)
125
-
126
- var builder = HttpRequestBuilder().url(url).post().body(this.requireString(args, "body"))
127
- builder = builder.header("Accept", "text/event-stream")
128
- builder = this.applyPageHeaders(builder, args)
129
-
130
- let client = ClientBuilder()
131
- .tlsConfig(tlsConfigForHost(host))
132
- .autoRedirect(false)
133
- .readTimeout(Duration.second * CJ_HTTP_TIMEOUT_SECONDS)
134
- .writeTimeout(Duration.second * CJ_HTTP_TIMEOUT_SECONDS)
135
- .build()
136
- var status: Int64 = 0
137
- var ctype = ""
138
- var total: Int64 = 0
139
- var truncated = false
140
- try {
141
- let resp = client.send(builder.build())
142
- status = Int64(resp.status)
143
- match (resp.headers.getFirst("Content-Type")) {
144
- case Some(v) => ctype = v
145
- case None => ()
146
- }
147
- // carry:上一块末尾不完整的 UTF-8 序列(最多 3 字节),拼到下一块开头
148
- let chunk = Array<Byte>(8192, repeat: 0)
149
- let pending = ArrayList<Byte>()
150
- var n = resp.body.read(chunk)
151
- while (n > 0) {
152
- if (total + n > CJ_HTTP_MAX_BYTES) {
153
- truncated = true
154
- break
155
- }
156
- for (i in 0..n) {
157
- pending.add(chunk[i])
158
- }
159
- total += n
160
- n = resp.body.read(chunk)
161
- // 发走所有完整 UTF-8 序列,尾巴(不完整的那几字节)留在 pending。
162
- // 必须 emit(每条都到)而不是 emitLatest:后者按事件名「同帧只保最新」,
163
- // 会把中间 delta 连同数据块一起覆盖掉——实测 chunks 数时好时坏、
164
- // 前端拼文本缺字;对话流的语义是每一块都到达,不合并。
165
- let delta = this.takeCompleteUtf8(pending)
166
- if (delta.size > 0) {
167
- ipc.emit("http:chunk", this.chunkPayload(status, ctype, delta, total, false, false))
168
- }
169
- }
170
- // 收尾:流在汉字中间被掐断时,pending 里剩下的是补不齐的不完整序列(最多 3 字节)。
171
- // 这里**不能**直接 fromUtf8:它对非法序列抛 IllegalArgumentException,一抛就把整条
172
- // 流式命令 reject 掉——页面显示「请求失败」,而正文其实早就收完了。丢这几个字节更诚实,
173
- // 但要把丢弃这件事留一行 stderr(别让失败无声)。
174
- if (pending.size > 0) {
175
- var tail = ""
176
- try {
177
- tail = String.fromUtf8(pending.toArray())
178
- } catch (e: Exception) {
179
- eprintln("[cj-tauri] http:postStream 尾部 ${pending.size} 字节不是合法 UTF-8(流被掐断),已丢弃")
180
- }
181
- ipc.emit("http:chunk", this.chunkPayload(status, ctype, tail, total, true, truncated))
182
- } else {
183
- ipc.emit("http:chunk", this.chunkPayload(status, ctype, "", total, true, truncated))
184
- }
185
- resp.close()
186
- } finally {
187
- client.close()
188
- }
189
- let obj = JsonObject()
190
- obj.put("status", JsonInt(status))
191
- obj.put("contentType", JsonString(ctype))
192
- obj.put("received", JsonInt(total))
193
- obj.put("truncated", JsonBool(truncated))
194
- return obj
195
- }
196
-
197
- /**
198
- * 从 pending 里取走「完整的 UTF-8 序列」并转文本;不完整的尾巴留在原地。
199
- * `public` 是为了让单测可见(`cjTauri.tests` 是子包,跨包只认 public):
200
- * 这是「少移 1 字节」那类坑的唯一判据点,用例在 `src/tests/plugin_http_test.cj`。
201
- */
202
- public func takeCompleteUtf8(pending: ArrayList<Byte>): String {
203
- if (pending.size == 0) {
204
- return ""
205
- }
206
- var keep: Int64 = 0
207
- // 回扫最多 3 字节找 lead byte(110xxxxx/1110xxxx/11110xxx),判序列是否齐
208
- let start = if (pending.size > 3) {
209
- pending.size - 3
210
- } else {
211
- 0
212
- }
213
- var i = pending.size - 1
214
- while (i >= start) {
215
- // ArrayList.get 返回 Option<UInt8>(下标在界内才取值,这里恒在界内)
216
- let b = pending.get(i).getOrThrow()
217
- if (b < 0x80u8) {
218
- keep = 0
219
- break
220
- }
221
- if ((b & 0xC0u8) != 0x80u8) {
222
- // lead byte:判这串还缺几字节
223
- let need: Int64 = if (b >= 0xF0u8) {
224
- 3
225
- } else if (b >= 0xE0u8) {
226
- 2
227
- } else {
228
- 1
229
- }
230
- let have = pending.size - i
231
- keep = if (have < need + 1) {
232
- have
233
- } else {
234
- 0
235
- }
236
- break
237
- }
238
- i--
239
- }
240
- let cut = pending.size - keep
241
- if (cut <= 0) {
242
- return ""
243
- }
244
- let out = String.fromUtf8(pending.toArray()[0..cut])
245
- // 移除已发走的部分(ArrayList.remove 收 Range)。Range 在本机工具链上是**右开**的
246
- // (实测:a[0..3] 取 3 个、remove(0..2) 移 2 个),所以这里必须写 0..cut。
247
- // 写成 0..(cut-1) 会少移 1 字节:上一个序列的末字节留在 pending 里,下一轮拼出来的
248
- // 字节序列非法,fromUtf8 当场抛 IllegalArgumentException,整条流式命令变成
249
- // 「请求失败」(2026-10-16 实机:command failed (http:postStream): Invalid utf8 byte sequence.
250
- // 且中文长回复才偶发——ASCII 结尾的块少移那个字节仍是合法 UTF-8,所以以前看不太出来)。
251
- pending.remove(0..cut)
252
- return out
253
- }
254
-
255
- private func chunkPayload(status: Int64, ctype: String, delta: String, received: Int64, done: Bool, truncated: Bool): JsonObject {
256
- let obj = JsonObject()
257
- obj.put("status", JsonInt(status))
258
- obj.put("contentType", JsonString(ctype))
259
- obj.put("delta", JsonString(delta))
260
- obj.put("received", JsonInt(received))
261
- obj.put("done", JsonBool(done))
262
- obj.put("truncated", JsonBool(truncated))
263
- return obj
264
- }
265
-
266
- private func fetch(method: String, args: JsonObject, ipc: IpcContext): JsonValue {
267
- let url = this.requireString(args, "url")
268
- let host = match (httpHostOf(url)) {
269
- case Some(h) => h
270
- // 解析不出 host / 协议不是 http(s):按白名单未命中处理(不透露解析细节)
271
- case None => throw CommandException("http: host is outside the authorized scope")
272
- }
273
- this.checkHostScope(url, host, ipc)
274
- return this.doFetch(method, url, host, args)
275
- }
276
-
277
- /** 白名单过后的真实请求:与判定分离,让 match 分支保持「单表达式」的形状
278
- * (仓颉的 match 分支体写 `=> {}` 块会被解析器当成 lambda——实测踩过,分支体一律单语句) */
279
- private func doFetch(method: String, url: String, host: String, args: JsonObject): JsonValue {
280
- var builder = HttpRequestBuilder().url(url)
281
- if (method == "GET") {
282
- builder = builder.get()
283
- } else if (method == "DELETE") {
284
- // DELETE 也带 JSON 体:cjh 的工作区删除靠 body 传 path(其 rest_api 的 DELETE 分支
285
- // 读的就是请求体),所以这里不能走「无体 DELETE」那条路
286
- builder = builder.delete().body(this.requireString(args, "body"))
287
- } else {
288
- let body = this.requireString(args, "body")
289
- builder = builder.post().body(body)
290
- }
291
- // 默认头:JSON 客户端最常见形态;页面可用 headers 覆盖/追加(Host/Content-Length 除外)
292
- builder = builder.header("Accept", "application/json, text/event-stream, */*")
293
- builder = this.applyPageHeaders(builder, args)
294
-
295
- let client = ClientBuilder()
296
- .tlsConfig(tlsConfigForHost(host))
297
- .autoRedirect(false)
298
- .readTimeout(Duration.second * CJ_HTTP_TIMEOUT_SECONDS)
299
- .writeTimeout(Duration.second * CJ_HTTP_TIMEOUT_SECONDS)
300
- .build()
301
- var status: Int64 = 0
302
- var ctype = ""
303
- var text = ""
304
- var truncated = false
305
- try {
306
- let resp = client.send(builder.build())
307
- status = Int64(resp.status)
308
- match (resp.headers.getFirst("Content-Type")) {
309
- // 头名大小写在 HTTP 里不敏感,但取法只有 getFirst(**没有 get**,AGENTS §4)
310
- case Some(v) => ctype = v
311
- case None => ()
312
- }
313
- // 按块读到 EOF 或上限:resp.body 的静态类型只有 InputStream,
314
- // readToEnd 编译不过(泛型约束要 Seekable,AGENTS §4);超限截断并如实标记。
315
- // ByteBuffer 没有 size 属性:字节数自己累加(music 的 readBodyBytes 同款不查
316
- // 长度,这里要掐上限,所以另立一版)
317
- let buf = ByteBuffer()
318
- let chunk = Array<Byte>(8192, repeat: 0)
319
- var n = resp.body.read(chunk)
320
- var total: Int64 = 0
321
- while (n > 0) {
322
- if (total + n > CJ_HTTP_MAX_BYTES) {
323
- buf.write(chunk[0..(CJ_HTTP_MAX_BYTES - total)])
324
- truncated = true
325
- break
326
- }
327
- buf.write(chunk[0..n])
328
- total += n
329
- n = resp.body.read(chunk)
330
- }
331
- resp.close()
332
- text = String.fromUtf8(buf.bytes())
333
- } finally {
334
- client.close()
335
- }
336
- let obj = JsonObject()
337
- obj.put("status", JsonInt(status))
338
- obj.put("contentType", JsonString(ctype))
339
- obj.put("body", JsonString(text))
340
- obj.put("truncated", JsonBool(truncated))
341
- return obj
342
- }
343
-
344
- /** 主机白名单判定(共享层语义:先 deny 后 allow,allow 必须显式命中) */
345
- private func checkHostScope(url: String, host: String, ipc: IpcContext): Unit {
346
- // 分支体保持单语句(多语句的块会被解析器当 lambda,见 doFetch 注释)
347
- match (ipc.scopeRuleFor("http")) {
348
- case None => this.rejectOutsideScope(url, "no host whitelist declared")
349
- case Some(rule) => this.checkRuleAllows(url, host, rule)
350
- }
351
- }
352
-
353
- private func checkRuleAllows(url: String, host: String, rule: ScopeRule): Unit {
354
- if (!scopeAllows(rule, host)) {
355
- this.rejectOutsideScope(url, host)
356
- }
357
- }
358
-
359
- private func rejectOutsideScope(url: String, why: String): Nothing {
360
- eprintln("[cj-tauri] scope denied (http): ${url} -> ${why}")
361
- throw CommandException("http: host is outside the authorized scope")
362
- }
363
-
364
- /** 页面自带的请求头(可选 headers 对象,string → string),条数与长度有上限 */
365
- private func applyPageHeaders(builder: HttpRequestBuilder, args: JsonObject): HttpRequestBuilder {
366
- var b = builder
367
- match (args.get("headers")) {
368
- // 分支体保持单语句(块体会被解析器当 lambda,见 doFetch 注释)
369
- case Some(v) => b = this.applyHeadersObject(b, v)
370
- case None => ()
371
- }
372
- return b
373
- }
374
-
375
- private func applyHeadersObject(builder: HttpRequestBuilder, v: JsonValue): HttpRequestBuilder {
376
- var b = builder
377
- // JsonKind 没有 ==(AGENTS §4):枚举比较只能走 match
378
- let isObject = match (v.kind()) {
379
- case JsonKind.JsObject => true
380
- case _ => false
381
- }
382
- if (!isObject) {
383
- return b
384
- }
385
- let hs = v.asObject()
386
- var count: Int64 = 0
387
- // 遍历用 getFields()(store 插件同款先例):JsonObject 不是按索引取的容器
388
- for ((name0, value) in hs.getFields()) {
389
- if (count >= CJ_HTTP_MAX_HEADERS) {
390
- break
391
- }
392
- // 键小写化后跳过禁改的头
393
- let name = name0.toAsciiLower()
394
- if (name == "host" || name == "content-length") {
395
- // 白名单判的是 URL 里的 host,页面不许改写它
396
- continue
397
- }
398
- if (this.isStringKind(value)) {
399
- let hval = value.asString().getValue()
400
- if (hval.size <= CJ_HTTP_MAX_HEADER_VALUE) {
401
- b = b.header(name, hval)
402
- count++
403
- }
404
- }
405
- }
406
- return b
407
- }
408
-
409
- private func isStringKind(v: JsonValue): Bool {
410
- let r = match (v.kind()) {
411
- case JsonKind.JsString => true
412
- case _ => false
413
- }
414
- return r
415
- }
416
-
417
- private func requireString(args: JsonObject, key: String): String {
418
- match (args.get(key)) {
419
- case Some(v) => match (v.kind()) {
420
- case JsonKind.JsString => return v.asString().getValue()
421
- case _ => throw CommandException("http: argument \"${key}\" must be a string")
422
- }
423
- case None => throw CommandException("http: missing argument \"${key}\"")
424
- }
425
- }
426
- }
427
-
428
- /**
429
- * 从 URL 提取 host(小写返回):核心 urlHostOf 的 **协议收紧包装**——
430
- * ws/wss 是 ws 插件的合法目标,但不是可抓取目标;http 插件只认 http/https。
431
- * 解析与大小写口径统一在核心层(urlutil.cj),这里不再有第二份字节扫描。
432
- * public 是为了单测可直接构造(跨包须 public)。
433
- */
434
- public func httpHostOf(rawUrl: String): Option<String> {
435
- let lower = rawUrl.toAsciiLower()
436
- if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
437
- return None
438
- }
439
- return urlHostOf(rawUrl)
440
- }
441
-
442
- /**
443
- * 建 TLS 客户端配置:**域名必须显式写 `serverName`(SNI)**。
444
- *
445
- * stdx 的 `TlsClientConfig()` 默认不发 SNI,而多租户 HTTPS 边缘(实测 Cloudflare 后面的 api 域名)
446
- * 收不到 SNI 就以 `handshake failure`(40) 拒掉握手——表象是「TLS 握手失败」,同一地址
447
- * curl / openssl 打起来一切正常,极易误判成对端故障、甚至误判成白名单问题(AGENTS §4 有这条坑)。
448
- * IP 字面量**不带** SNI:RFC 6066 不许,且「https 直连 IP」原本可用,不能反被改坏。
449
- *
450
- * 这七行故意留在插件里而不进核心共享层:核心一引用 `stdx.net.tls`,不用 TLS 的应用也会被拉上
451
- * OpenSSL 依赖,单文件打包契约跟着变形(§2 的包粒度纪律);ws 插件里那份是同样的七行。
452
- */
453
- private func tlsConfigForHost(host: String): TlsClientConfig {
454
- var cfg = TlsClientConfig()
455
- if (!isIpLiteralHost(host)) {
456
- cfg.serverName = host
457
- }
458
- return cfg
459
- }
1
+ package cjTauri.plugins.http
2
+
3
+ import cjTauri.*
4
+
5
+ import std.collection.*
6
+ import stdx.encoding.json.*
7
+
8
+ /** 单次响应体上限:8 MiB(LLM 非流式回复通常远小于它;够装一份大 README) */
9
+ public let CJ_HTTP_MAX_BYTES: Int64 = 8 * 1024 * 1024
10
+
11
+ /** 请求超时(读写同一预算):Desktop 侧 ClientBuilder 没有 connectTimeout,目标不可达会卡在
12
+ * TCP connect 上——超时预算按「整体别太久」给,长挂由前端超时/取消兜 */
13
+ public let CJ_HTTP_TIMEOUT_SECONDS: Int64 = 60
14
+
15
+ /** 页面可带的请求头上限(条数与单条长度):防用一堆超长头把内存撑爆 */
16
+ public let CJ_HTTP_MAX_HEADERS: Int64 = 16
17
+ public let CJ_HTTP_MAX_HEADER_VALUE: Int64 = 4096
18
+
19
+ /**
20
+ * 官方插件 http:**页面可控的出网口**(RFC-003 HTTP 白名单收口)
21
+ *
22
+ * 命令:http:get({url})/ http:post({url, body, headers?})/ http:delete({url, body}),返回
23
+ * { status, contentType, body, truncated };失败抛 CommandException(业务错误)。
24
+ * 权限:**不自动放行**——"permissions": ["http:default"] 或在 commands 里明文写。
25
+ *
26
+ * 安全语义(与 fs 的路径 scope 同构,判定逻辑复用共享层 scopeAllows/globMatch):
27
+ * - **主机白名单**:清单写 "scopes": { "http": { "allow": ["api.deepseek.com"] } }。
28
+ * allow 必须显式命中、先 deny 后 allow;**没写 scopes.http = 一律拒绝**并打一行 stderr
29
+ * (与 fs「空 allow = 全拒」同方向:出网是高危面,不做「没写就放行」的旧行为)。
30
+ * - **只认 host,不认端口**:匹配目标是 URL 里的 host(小写化);端口不参与匹配——
31
+ * 白名单声明「我信这个主机」,同一台主机的端口不会让信任变多。
32
+ * - **禁自动重定向**(autoRedirect(false)):3xx 原样回给页面——重定向到别的主机
33
+ * 不会替页面**追出去**(白名单只判第一跳;要跟就由页面显式再发一次、再过一遍白名单)。
34
+ * - Host / Content-Length 头不许页面自带(前者能改写白名单判过的目标,后者由库自己算)。
35
+ *
36
+ * 平台分层(2026-10-25 起,见 `http_backend.cj` 顶部注释):本文件只有**命令面与判定**,
37
+ * 真正出网交给 `httpTransportSend` / `httpTransportOpenStream`——桌面是 stdx + 显式 TLS,
38
+ * 鸿蒙是系统网络栈 `ohos.net.http`(仓颉运行时在鸿蒙上装不了 OpenSSL 3,stdx 的 TLS 必然失败)。
39
+ * **命令名、入参、返回值、白名单判定两平台一致**,前端一行都不用改。
40
+ *
41
+ * 性能口径(为什么**每个请求新建 Client**而不是共享一个):
42
+ * - 最大的性能收益本来就在**流式**:一次 LLM 对话只有一次请求,而这条流要挂几十秒——
43
+ * 建连 + TLS 握手(约百毫秒量级)摊在整条流里可忽略;频繁短请求不是本插件的靶场景。
44
+ * - 跨线程共享 Client 的连接池是否线程安全,本仓**没有验证过**(music 示例宁可每次
45
+ * 建连也不埋「偶发串包」的坑,见 examples/music/src/api.cj 的注释);命令 worker 是
46
+ * 并发的,用锁包共享 Client 会把并发请求串行化,反而伤多窗口/多会话场景。
47
+ * - 真到了「高频短请求」成为瓶颈那天,先实测 stdx 连接池的线程安全再改共享——
48
+ * 那是一次有判据的优化,不是现在凭感觉做。
49
+ */
50
+ public class HttpPlugin <: Plugin {
51
+ public init() {}
52
+
53
+ public func name(): String {
54
+ return "http"
55
+ }
56
+
57
+ public func commands(): HashMap<String, CommandHandler> {
58
+ let m = HashMap<String, CommandHandler>()
59
+ // 键写**短名**:前缀由框架补(写全名会得到 http:http:get,且所有命令都成未注册)
60
+ m.add("get", HttpCommands())
61
+ m.add("post", HttpCommands())
62
+ m.add("delete", HttpCommands())
63
+ m.add("postStream", HttpCommands())
64
+ return m
65
+ }
66
+
67
+ /** 命名权限集(v1.1):default = GET + POST + POST 流式 */
68
+ public func permissions(): HashMap<String, ArrayList<String>> {
69
+ let m = HashMap<String, ArrayList<String>>()
70
+ let all = ArrayList<String>()
71
+ all.add("get")
72
+ all.add("post")
73
+ all.add("delete")
74
+ all.add("postStream")
75
+ m.add("default", all)
76
+ return m
77
+ }
78
+
79
+ /** 流式命令推事件:每个 body 块经 http:chunk 回投(应用要在清单 events 里放行)。
80
+ * 这里写**短名**——框架自动补「<插件名>:」前缀,写全名会得到 http:http:chunk(实测)。 */
81
+ public func events(): ArrayList<String> {
82
+ let ev = ArrayList<String>()
83
+ ev.add("chunk")
84
+ return ev
85
+ }
86
+
87
+ /** 吃 scope:主机白名单的判定在清单 scopes.http(没写 = 全拒,见类注释) */
88
+ public func scopeAware(): Bool {
89
+ return true
90
+ }
91
+ }
92
+
93
+ /** http 命令处理器:get 收 {url},post / delete 收 {url, body, headers?}(delete 的体同样是 JSON 文本) */
94
+ public class HttpCommands <: CommandHandler {
95
+ public init() {}
96
+
97
+ public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
98
+ match (cmd) {
99
+ case "http:get" => return this.fetch("GET", args, ipc)
100
+ case "http:post" => return this.fetch("POST", args, ipc)
101
+ case "http:delete" => return this.fetch("DELETE", args, ipc)
102
+ case "http:postStream" => return this.fetchStream(args, ipc)
103
+ case _ => throw CommandException("unknown http command: ${cmd}")
104
+ }
105
+ }
106
+
107
+ /**
108
+ * 流式 POST(SSE):与非流式同一套白名单/请求头判定,差别只在**读响应体**——
109
+ * 按块读、每读一块就把增量经事件通道回投(emit:同一帧内多条都要到,不合并)。
110
+ *
111
+ * 事件 payload:{ status, contentType, delta, received, done, truncated }。
112
+ * delta 是**本轮新增**的文本;UTF-8 多字节被块边界切开时,不完整的那几字节
113
+ * 留到下一块拼齐再发(按 lead byte 回扫补齐边界,见 takeCompleteUtf8)。
114
+ * 结束(EOF / 超限)后发 done=true 的最后一条,命令再返回总字节数。
115
+ *
116
+ * 鸿蒙侧由 `httpTransportOpenStream` 当场如实拒绝(系统网络栈的回调形态不暴露增量块),
117
+ * 所以「命令能注册、能通过白名单、调用时明确报不可用」——不会静默返回空流。
118
+ */
119
+ private func fetchStream(args: JsonObject, ipc: IpcContext): JsonValue {
120
+ let url = this.requireString(args, "url")
121
+ let host = match (httpHostOf(url)) {
122
+ case Some(h) => h
123
+ case None => throw CommandException("http: host is outside the authorized scope")
124
+ }
125
+ this.checkHostScope(url, host, ipc)
126
+
127
+ let headers = parsePageHeaders(args)
128
+ let stream = httpTransportOpenStream(url, host, headers, this.requireString(args, "body"))
129
+ let status = stream.status()
130
+ let ctype = stream.contentType()
131
+ var total: Int64 = 0
132
+ var truncated = false
133
+ try {
134
+ let chunk = Array<Byte>(8192, repeat: 0)
135
+ let pending = ArrayList<Byte>()
136
+ var n = stream.read(chunk)
137
+ while (n > 0) {
138
+ if (total + n > CJ_HTTP_MAX_BYTES) {
139
+ truncated = true
140
+ break
141
+ }
142
+ for (i in 0..n) {
143
+ pending.add(chunk[i])
144
+ }
145
+ total += n
146
+ n = stream.read(chunk)
147
+ // 发走所有完整 UTF-8 序列,尾巴(不完整的那几字节)留在 pending。
148
+ // 必须 emit(每条都到)而不是 emitLatest:后者按事件名「同帧只保最新」,
149
+ // 会把中间 delta 连同数据块一起覆盖掉——实测 chunks 数时好时坏、
150
+ // 前端拼文本缺字;对话流的语义是每一块都到达,不合并。
151
+ let delta = this.takeCompleteUtf8(pending)
152
+ if (delta.size > 0) {
153
+ ipc.emit("http:chunk", this.chunkPayload(status, ctype, delta, total, false, false))
154
+ }
155
+ }
156
+ // 收尾:流在汉字中间被掐断时,pending 里剩下的是补不齐的不完整序列(最多 3 字节)。
157
+ // 这里**不能**直接 fromUtf8:它对非法序列抛 IllegalArgumentException,一抛就把整条
158
+ // 流式命令 reject 掉——页面显示「请求失败」,而正文其实早就收完了。丢这几个字节更诚实,
159
+ // 但要把丢弃这件事留一行 stderr(别让失败无声)。
160
+ var tail = ""
161
+ if (pending.size > 0) {
162
+ try {
163
+ tail = String.fromUtf8(pending.toArray())
164
+ } catch (e: Exception) {
165
+ eprintln("[cj-tauri] http:postStream 尾部 ${pending.size} 字节不是合法 UTF-8(流被掐断),已丢弃")
166
+ }
167
+ }
168
+ ipc.emit("http:chunk", this.chunkPayload(status, ctype, tail, total, true, truncated))
169
+ } finally {
170
+ // 句柄不关等于泄漏连接(桌面侧连客户端一起关);异常路径同样要收
171
+ stream.close()
172
+ }
173
+ let obj = JsonObject()
174
+ obj.put("status", JsonInt(status))
175
+ obj.put("contentType", JsonString(ctype))
176
+ obj.put("received", JsonInt(total))
177
+ obj.put("truncated", JsonBool(truncated))
178
+ return obj
179
+ }
180
+
181
+ /**
182
+ * 从 pending 里取走「完整的 UTF-8 序列」并转文本;不完整的尾巴留在原地。
183
+ * `public` 是为了让单测可见(`cjTauri.tests` 是子包,跨包只认 public):
184
+ * 这是「少移 1 字节」那类坑的唯一判据点,用例在 `src/tests/plugin_http_test.cj`。
185
+ */
186
+ public func takeCompleteUtf8(pending: ArrayList<Byte>): String {
187
+ if (pending.size == 0) {
188
+ return ""
189
+ }
190
+ var keep: Int64 = 0
191
+ // 回扫最多 3 字节找 lead byte(110xxxxx/1110xxxx/11110xxx),判序列是否齐
192
+ let start = if (pending.size > 3) {
193
+ pending.size - 3
194
+ } else {
195
+ 0
196
+ }
197
+ var i = pending.size - 1
198
+ while (i >= start) {
199
+ // ArrayList.get 返回 Option<UInt8>(下标在界内才取值,这里恒在界内)
200
+ let b = pending.get(i).getOrThrow()
201
+ if (b < 0x80u8) {
202
+ keep = 0
203
+ break
204
+ }
205
+ if ((b & 0xC0u8) != 0x80u8) {
206
+ // lead byte:判这串还缺几字节
207
+ let need: Int64 = if (b >= 0xF0u8) {
208
+ 3
209
+ } else if (b >= 0xE0u8) {
210
+ 2
211
+ } else {
212
+ 1
213
+ }
214
+ let have = pending.size - i
215
+ keep = if (have < need + 1) {
216
+ have
217
+ } else {
218
+ 0
219
+ }
220
+ break
221
+ }
222
+ i--
223
+ }
224
+ let cut = pending.size - keep
225
+ if (cut <= 0) {
226
+ return ""
227
+ }
228
+ let out = String.fromUtf8(pending.toArray()[0..cut])
229
+ // 移除已发走的部分(ArrayList.remove 收 Range)。Range 在本机工具链上是**右开**的
230
+ // (实测:a[0..3] 取 3 个、remove(0..2) 移 2 个),所以这里必须写 0..cut。
231
+ // 写成 0..(cut-1) 会少移 1 字节:上一个序列的末字节留在 pending 里,下一轮拼出来的
232
+ // 字节序列非法,fromUtf8 当场抛 IllegalArgumentException,整条流式命令变成
233
+ // 「请求失败」(2026-10-16 实机:command failed (http:postStream): Invalid utf8 byte sequence.
234
+ // 且中文长回复才偶发——ASCII 结尾的块少移那个字节仍是合法 UTF-8,所以以前看不太出来)。
235
+ pending.remove(0..cut)
236
+ return out
237
+ }
238
+
239
+ private func chunkPayload(status: Int64, ctype: String, delta: String, received: Int64, done: Bool, truncated: Bool): JsonObject {
240
+ let obj = JsonObject()
241
+ obj.put("status", JsonInt(status))
242
+ obj.put("contentType", JsonString(ctype))
243
+ obj.put("delta", JsonString(delta))
244
+ obj.put("received", JsonInt(received))
245
+ obj.put("done", JsonBool(done))
246
+ obj.put("truncated", JsonBool(truncated))
247
+ return obj
248
+ }
249
+
250
+ private func fetch(method: String, args: JsonObject, ipc: IpcContext): JsonValue {
251
+ let url = this.requireString(args, "url")
252
+ let host = match (httpHostOf(url)) {
253
+ case Some(h) => h
254
+ // 解析不出 host / 协议不是 http(s):按白名单未命中处理(不透露解析细节)
255
+ case None => throw CommandException("http: host is outside the authorized scope")
256
+ }
257
+ this.checkHostScope(url, host, ipc)
258
+ // GET 不带体;POST / DELETE 的体是必填(缺了就是业务错误,与改造前一致)
259
+ let body = if (method == "GET") {
260
+ ""
261
+ } else {
262
+ this.requireString(args, "body")
263
+ }
264
+ return httpReplyJson(httpTransportSend(method, url, host, parsePageHeaders(args), body))
265
+ }
266
+
267
+ /** 主机白名单判定(共享层语义:先 deny 后 allow,allow 必须显式命中) */
268
+ private func checkHostScope(url: String, host: String, ipc: IpcContext): Unit {
269
+ // 分支体保持单语句(多语句的块会被解析器当 lambda,见下)
270
+ match (ipc.scopeRuleFor("http")) {
271
+ case None => this.rejectOutsideScope(url, "no host whitelist declared")
272
+ case Some(rule) => this.checkRuleAllows(url, host, rule)
273
+ }
274
+ }
275
+
276
+ private func checkRuleAllows(url: String, host: String, rule: ScopeRule): Unit {
277
+ if (!scopeAllows(rule, host)) {
278
+ this.rejectOutsideScope(url, host)
279
+ }
280
+ }
281
+
282
+ private func rejectOutsideScope(url: String, why: String): Nothing {
283
+ eprintln("[cj-tauri] scope denied (http): ${url} -> ${why}")
284
+ throw CommandException("http: host is outside the authorized scope")
285
+ }
286
+
287
+ private func requireString(args: JsonObject, key: String): String {
288
+ match (args.get(key)) {
289
+ case Some(v) => match (v.kind()) {
290
+ case JsonKind.JsString => return v.asString().getValue()
291
+ case _ => throw CommandException("http: argument \"${key}\" must be a string")
292
+ }
293
+ case None => throw CommandException("http: missing argument \"${key}\"")
294
+ }
295
+ }
296
+ }
297
+
298
+ /**
299
+ * 从 URL 提取 host(小写返回):核心 urlHostOf 的 **协议收紧包装**——
300
+ * ws/wss 是 ws 插件的合法目标,但不是可抓取目标;http 插件只认 http/https。
301
+ * 解析与大小写口径统一在核心层(urlutil.cj),这里不再有第二份字节扫描。
302
+ * public 是为了单测可直接构造(跨包须 public)。
303
+ */
304
+ public func httpHostOf(rawUrl: String): Option<String> {
305
+ let lower = rawUrl.toAsciiLower()
306
+ if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
307
+ return None
308
+ }
309
+ return urlHostOf(rawUrl)
310
+ }