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,470 +1,488 @@
1
- package cjTauri.plugins.ws
2
-
3
- import cjTauri.*
4
-
5
- import std.collection.*
6
- import std.sync.*
7
- import stdx.encoding.json.*
8
- import stdx.encoding.url.*
9
- import stdx.net.http.*
10
- import stdx.net.tls.*
11
- // 缺它 TLS 握手时拿不到默认实现:连 ws:// 也会报
12
- // "Global crypto kit is not set. If you want to use the default implementation,
13
- // make sure that package `stdx.crypto.kit` is imported."(2026-10-20 cjh 接线实测)
14
- import stdx.crypto.kit.*
15
-
16
- /** 单帧(含分帧重组累计)上限:8 MiB——一条超长帧直接断连,防内存被撑爆 */
17
- public let CJ_WS_MAX_FRAME: Int64 = 8 * 1024 * 1024
18
-
19
- /** 连接状态字面量(ws:state 事件的 state 字段) */
20
- public let CJ_WS_STATE_CONNECTED: String = "connected"
21
- public let CJ_WS_STATE_CLOSED: String = "closed"
22
-
23
- /**
24
- * 官方插件 ws:WebSocket 客户端(stdx.net.http 自带 RFC6455 实现——升级握手与帧
25
- * 编解码都是库的事,本插件只做治理与并发,与 http 插件的取舍同一结论:用成熟库)
26
- *
27
- * 命令:ws:connect({url})/ ws:send({text})/ ws:close()
28
- * 事件:ws:frame(对端帧——cjh 这类 JSON 帧原样透传,前端按 type 分发;解不动的包
29
- * 一层 `{"type":"raw"}`)/ ws:state(connected / closed)
30
- * 权限:**不自动放行**——"permissions": ["ws:default"] 或在 commands 里明文写。
31
- *
32
- * 安全语义与 http 插件同构:
33
- * - 主机白名单在清单 scopes.ws(判定复用共享层 scopeAllows/globMatch,host 提取用核心
34
- * urlHostOf——http/https/ws/wss 同一套解析);**没写 scopes.ws = 一律拒绝**;
35
- * - 只认 host 不认端口(「我信这个主机」);连接目标必须是 ws:// 或 wss://。
36
- *
37
- * 并发模型(mqtt 插件同款):读线程独占 read()(stdx WebSocket 不支持多线程读);
38
- * 写全部在 sess.lock 内串行(不支持多线程写);读 + 写并发是全双工本身的语义
39
- * (cjh 服务端同款用法:读循环线程 + 持锁写)。
40
- */
41
- public class WsPlugin <: Plugin {
42
- public init() {}
43
-
44
- // 单活动连接:cjh 客户端同一时刻只连一个后端;重连须先 close(确定性优先,
45
- // 不在命令里做隐式替换——旧读线程还没退出时隐式重连会制造两套 gen 竞态)
46
- private let sess: WsSession = WsSession()
47
- // 引用一下 DefaultCryptoKit:stdx.crypto.kit 的**包初始化**负责注册全局加密套件,
48
- // 但零警告门禁要求 import 必须有符号使用——这个字段就是干这个的(不参与逻辑)
49
- private let kitMarker = DefaultCryptoKit()
50
-
51
- public func name(): String {
52
- return "ws"
53
- }
54
-
55
- public func commands(): HashMap<String, CommandHandler> {
56
- let m = HashMap<String, CommandHandler>()
57
- // 键写**短名**:前缀由框架补(写全名会得到 ws:ws:connect,且所有命令都成未注册)
58
- m.add("connect", WsCommands(this.sess))
59
- m.add("send", WsCommands(this.sess))
60
- m.add("close", WsCommands(this.sess))
61
- return m
62
- }
63
-
64
- /** 命名权限集(v1.1):default = 连接 + 发送 + 关闭 */
65
- public func permissions(): HashMap<String, ArrayList<String>> {
66
- let m = HashMap<String, ArrayList<String>>()
67
- let all = ArrayList<String>()
68
- all.add("connect")
69
- all.add("send")
70
- all.add("close")
71
- m.add("default", all)
72
- return m
73
- }
74
-
75
- /** 推事件:对端帧经 ws:frame、连接状态经 ws:state(应用要在清单 events 里放行短名) */
76
- public func events(): ArrayList<String> {
77
- let ev = ArrayList<String>()
78
- ev.add("frame")
79
- ev.add("state")
80
- return ev
81
- }
82
-
83
- /** 吃 scope:主机白名单的判定在清单 scopes.ws(没写 = 全拒,见类注释) */
84
- public func scopeAware(): Bool {
85
- return true
86
- }
87
-
88
- public func setup(app: TauriApp): Unit {
89
- this.sess.setApp(app)
90
- }
91
- }
92
-
93
- /**
94
- * ws 会话:单活动连接。所有字段都在 lock 内访问(除 setApp 的写也持锁);
95
- * app 是读线程回投事件的通道(mqtt 同款:命令线程装配、读线程只读引用)。
96
- */
97
- public class WsSession {
98
- public let lock: Mutex = Mutex()
99
- public var connected: Bool = false
100
- public var url: String = ""
101
- public var host: String = ""
102
- public var lastError: String = ""
103
- public var rxCount: Int64 = 0
104
- public var txCount: Int64 = 0
105
- // 每次连接自增:读线程带出自己的 gen,回来发现不等 = 会话已被 close/替换,静默退出
106
- public var gen: Int64 = 0
107
- public var app: Option<TauriApp> = None
108
- // 连接本体只在 lock 内触碰
109
- public var ws: Option<WebSocket> = None
110
- public var client: Option<Client> = None
111
-
112
- public func setApp(app: TauriApp): Unit {
113
- this.lock.lock()
114
- this.app = Some(app)
115
- this.lock.unlock()
116
- }
117
- }
118
-
119
- /** ws 命令处理器:connect 收 {url},send 收 {text},close 无参 */
120
- public class WsCommands <: CommandHandler {
121
- private let sess: WsSession
122
-
123
- public init(sess: WsSession) {
124
- this.sess = sess
125
- }
126
-
127
- public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
128
- match (cmd) {
129
- case "ws:connect" => return this.connect(args, ipc)
130
- case "ws:send" => return this.send(args)
131
- case "ws:close" => return this.close()
132
- case _ => throw CommandException("unknown ws command: ${cmd}")
133
- }
134
- }
135
-
136
- private func connect(args: JsonObject, ipc: IpcContext): JsonValue {
137
- let url = this.requireString(args, "url")
138
- // 协议收紧:只认 ws/wss(urlHostOf 放行 http(s) 是给 http 插件用的)
139
- let lower = url.toAsciiLower()
140
- if (!lower.startsWith("ws://") && !lower.startsWith("wss://")) {
141
- throw CommandException("ws: url must start with ws:// or wss://")
142
- }
143
- let host = match (urlHostOf(url)) {
144
- case Some(h) => h
145
- case None => throw CommandException("ws: host is outside the authorized scope")
146
- }
147
- this.checkHostScope(url, host, ipc)
148
- return this.doConnect(url, host)
149
- }
150
-
151
- private func doConnect(url: String, host: String): JsonValue {
152
- this.sess.lock.lock()
153
- let busy = this.sess.connected
154
- this.sess.lock.unlock()
155
- if (busy) {
156
- throw CommandException("ws: already connected; call ws:close first")
157
- }
158
- // stdx 的升级函数**本来就要求 ws/wss scheme**(报错原文 "the scheme must be
159
- // ws or wss")——最初把 ws:// 转成 http:// 再传是画蛇添足,反而被库拒(实测踩过)
160
- let client = ClientBuilder()
161
- .tlsConfig(tlsConfigForHost(host))
162
- .build()
163
- var sockOpt: Option<WebSocket> = None
164
- try {
165
- let parsed = URL.parse(url)
166
- let pair = WebSocket.upgradeFromClient(client, parsed)
167
- sockOpt = Some(pair[0])
168
- } catch (e: Exception) {
169
- client.close()
170
- eprintln("[cj-tauri] ws: connect failed: ${e}")
171
- throw CommandException("ws: connect failed: ${e.message}")
172
- }
173
- // 登记会话并起读线程
174
- this.sess.lock.lock()
175
- this.sess.ws = sockOpt
176
- this.sess.client = Some(client)
177
- this.sess.connected = true
178
- this.sess.url = url
179
- this.sess.host = host
180
- this.sess.lastError = ""
181
- this.sess.gen += 1
182
- let gen = this.sess.gen
183
- this.sess.lock.unlock()
184
- // 走到这里的 sockOpt 必为 Some(catch 分支已经 throw),match 里再写 None 分支会被
185
- // 编译器判 unreachable pattern——零警告门禁下直接取用
186
- let sock = sockOpt.getOrThrow()
187
- spawn { => wsReaderThread(this.sess, sock, client, gen) }
188
- eprintln("[cj-tauri] ws: connected ${url} (host=${host})")
189
- wsEmitState(this.sess, CJ_WS_STATE_CONNECTED, "")
190
- let obj = JsonObject()
191
- obj.put("connected", JsonBool(true))
192
- obj.put("url", JsonString(url))
193
- return obj
194
- }
195
-
196
- private func send(args: JsonObject): JsonValue {
197
- let text = this.requireString(args, "text")
198
- this.sess.lock.lock()
199
- let ok = this.sendLocked(text)
200
- let err = this.sess.lastError
201
- this.sess.lock.unlock()
202
- if (!ok) {
203
- throw CommandException("ws: send failed(未连接或对端已断): ${err}")
204
- }
205
- let obj = JsonObject()
206
- obj.put("sent", JsonBool(true))
207
- return obj
208
- }
209
-
210
- /** 仅在持有 sess.lock 时调用(stdx WebSocket 不支持多线程写) */
211
- private func sendLocked(text: String): Bool {
212
- if (!this.sess.connected) {
213
- return false
214
- }
215
- match (this.sess.ws) {
216
- case Some(w) => return this.writeText(w, text)
217
- case None => return false
218
- }
219
- }
220
-
221
- private func writeText(w: WebSocket, text: String): Bool {
222
- try {
223
- w.write(WebSocketFrameType.TextWebFrame, text.toArray())
224
- this.sess.txCount += 1
225
- return true
226
- } catch (e: Exception) {
227
- this.sess.lastError = "${e}"
228
- return false
229
- }
230
- }
231
-
232
- private func close(): JsonValue {
233
- this.sess.lock.lock()
234
- let was = this.sess.connected
235
- let sockOpt = this.sess.ws
236
- let clientOpt = this.sess.client
237
- this.sess.connected = false
238
- this.sess.ws = None
239
- this.sess.client = None
240
- this.sess.gen += 1
241
- this.sess.lock.unlock()
242
- // 关连接放在锁外:closeConn 可能让阻塞中的 read() 抛错,读线程随后发现 gen
243
- // 变了会自行退出并兜底收资源——锁内不做任何可能阻塞的 IO
244
- if (was) {
245
- match (sockOpt) {
246
- case Some(w) => this.closeSock(w)
247
- case None => ()
248
- }
249
- }
250
- match (clientOpt) {
251
- case Some(c) => c.close()
252
- case None => ()
253
- }
254
- if (was) {
255
- eprintln("[cj-tauri] ws: closed by command")
256
- wsEmitState(this.sess, CJ_WS_STATE_CLOSED, "")
257
- }
258
- let obj = JsonObject()
259
- obj.put("closed", JsonBool(was))
260
- return obj
261
- }
262
-
263
- private func closeSock(w: WebSocket): Unit {
264
- try {
265
- // 先走协议关(发 Close 帧)再硬关底层;客户端场景不必等对端回关
266
- let empty = Array<UInt8>(0, repeat: 0)
267
- w.write(WebSocketFrameType.CloseWebFrame, empty)
268
- } catch (e: Exception) {
269
- let _ = e
270
- }
271
- try {
272
- w.closeConn()
273
- } catch (e: Exception) {
274
- let _ = e
275
- }
276
- }
277
-
278
- /** 主机白名单判定(共享层语义:先 deny 后 allow,allow 必须显式命中) */
279
- private func checkHostScope(url: String, host: String, ipc: IpcContext): Unit {
280
- // 分支体保持单语句(块体会被解析器当 lambda——AGENTS §4 实测过)
281
- match (ipc.scopeRuleFor("ws")) {
282
- case None => this.rejectOutsideScope(url, "no host whitelist declared")
283
- case Some(rule) => this.checkRuleAllows(url, host, rule)
284
- }
285
- }
286
-
287
- private func checkRuleAllows(url: String, host: String, rule: ScopeRule): Unit {
288
- if (!scopeAllows(rule, host)) {
289
- this.rejectOutsideScope(url, host)
290
- }
291
- }
292
-
293
- private func rejectOutsideScope(url: String, why: String): Nothing {
294
- eprintln("[cj-tauri] scope denied (ws): ${url} -> ${why}")
295
- throw CommandException("ws: host is outside the authorized scope")
296
- }
297
-
298
- private func requireString(args: JsonObject, key: String): String {
299
- match (args.get(key)) {
300
- case Some(v) => match (v.kind()) {
301
- case JsonKind.JsString => return v.asString().getValue()
302
- case _ => throw CommandException("ws: argument \"${key}\" must be a string")
303
- }
304
- case None => throw CommandException("ws: missing argument \"${key}\"")
305
- }
306
- }
307
- }
308
-
309
- /** 推事件给页面(读线程 / 命令线程都可能调):mqtt 同款,app 引用只读 */
310
- func wsEmit(sess: WsSession, event: String, obj: JsonObject): Unit {
311
- var app: Option<TauriApp> = None
312
- sess.lock.lock()
313
- app = sess.app
314
- sess.lock.unlock()
315
- match (app) {
316
- case Some(a) => a.emit(event, obj)
317
- case None => ()
318
- }
319
- }
320
-
321
- /** 推连接状态;同时落一行 stderr(页面没接住事件时 stderr 仍可对账——AGENTS §1) */
322
- func wsEmitState(sess: WsSession, state: String, err: String): Unit {
323
- let obj = JsonObject()
324
- obj.put("state", JsonString(state))
325
- sess.lock.lock()
326
- obj.put("url", JsonString(sess.url))
327
- obj.put("host", JsonString(sess.host))
328
- obj.put("rx", JsonInt(sess.rxCount))
329
- obj.put("tx", JsonInt(sess.txCount))
330
- sess.lock.unlock()
331
- obj.put("error", JsonString(err))
332
- eprintln("[cj-tauri] ws: state=${state} error=${err}")
333
- wsEmit(sess, "ws:state", obj)
334
- }
335
-
336
- /** 把一帧文本投给页面:cjh 的帧是 JSON 对象——能解就原样透传(前端按 type 分发),
337
- 解不动的包一层 raw(原文不丢,前端自己看着办) */
338
- func wsDeliver(sess: WsSession, bytes: Array<UInt8>): Unit {
339
- let text = String.fromUtf8(bytes)
340
- var obj = JsonObject()
341
- var wrapped = false
342
- try {
343
- let v = JsonValue.fromStr(text)
344
- match (jsonAsObject(v)) {
345
- case Some(o) => obj = o
346
- case None => wrapped = true
347
- }
348
- } catch (e: Exception) {
349
- let _ = e
350
- wrapped = true
351
- }
352
- if (wrapped) {
353
- let raw = JsonObject()
354
- raw.put("type", JsonString("raw"))
355
- raw.put("text", JsonString(text))
356
- obj = raw
357
- }
358
- wsEmit(sess, "ws:frame", obj)
359
- }
360
-
361
- func jsonAsObject(v: JsonValue): Option<JsonObject> {
362
- match (v.kind()) {
363
- case JsonKind.JsObject => return Some(v.asObject())
364
- case _ => return None
365
- }
366
- }
367
-
368
- func wsAppend(buf: ArrayList<UInt8>, payload: Array<UInt8>): Unit {
369
- for (i in 0..payload.size) {
370
- buf.add(payload[i])
371
- }
372
- }
373
-
374
- /**
375
- * 读线程:独占 sock.read()(stdx 不支持多线程读)。
376
- * gen 不匹配 = 会话已被 close/替换 → 旧连接的残余帧直接丢弃退出,绝不投给页面。
377
- * 分段帧(fin == false 的首帧 + ContinuationWebFrame 续帧)攒齐再投。
378
- */
379
- func wsReaderThread(sess: WsSession, sock: WebSocket, client: Client, gen: Int64): Unit {
380
- var buf = ArrayList<UInt8>()
381
- while (true) {
382
- try {
383
- let frame = sock.read()
384
- sess.lock.lock()
385
- let stale = (sess.gen != gen) || (!sess.connected)
386
- if (!stale) {
387
- sess.rxCount += 1
388
- }
389
- sess.lock.unlock()
390
- if (stale) {
391
- break
392
- }
393
- let ft = frame.frameType
394
- if (ft == WebSocketFrameType.CloseWebFrame) {
395
- break
396
- }
397
- if (ft == WebSocketFrameType.PingWebFrame || ft == WebSocketFrameType.PongWebFrame) {
398
- // 控制帧:心跳由库管,这里出现了就忽略(cjh 服务端不主动发——实测再校)
399
- continue
400
- }
401
- let payload = frame.payload // prop 不是函数(stdx 文档确认,写 () 编译不过)
402
- if (payload.size <= 0) {
403
- continue
404
- }
405
- if (buf.size + payload.size > CJ_WS_MAX_FRAME) {
406
- eprintln("[cj-tauri] ws: 分帧重组超限(>${CJ_WS_MAX_FRAME} 字节),断开连接")
407
- break
408
- }
409
- if (ft == WebSocketFrameType.ContinuationWebFrame) {
410
- wsAppend(buf, payload)
411
- if (frame.fin) {
412
- wsDeliver(sess, buf.toArray())
413
- buf = ArrayList<UInt8>()
414
- }
415
- continue
416
- }
417
- if (frame.fin) {
418
- wsDeliver(sess, payload)
419
- continue
420
- }
421
- // 分段帧首帧:攒着等续帧
422
- wsAppend(buf, payload)
423
- } catch (e: Exception) {
424
- // 正常退出路径:close / 对端断开都会让阻塞中的 read 抛错
425
- let _ = e
426
- break
427
- }
428
- }
429
- sess.lock.lock()
430
- let mine = (sess.gen == gen) && sess.connected
431
- if (mine) {
432
- sess.connected = false
433
- sess.ws = None
434
- sess.client = None
435
- sess.gen += 1
436
- sess.lastError = "connection closed by peer"
437
- }
438
- sess.lock.unlock()
439
- if (mine) {
440
- eprintln("[cj-tauri] ws: connection closed by peer")
441
- wsEmitState(sess, CJ_WS_STATE_CLOSED, "connection closed by peer")
442
- }
443
- // 资源兜底:连接已断或已被替换,double-close 的错吞掉即可
444
- try {
445
- sock.closeConn()
446
- } catch (e: Exception) {
447
- let _ = e
448
- }
449
- try {
450
- client.close()
451
- } catch (e: Exception) {
452
- let _ = e
453
- }
454
- }
455
-
456
- /**
457
- * 建 TLS 客户端配置:**域名必须显式写 `serverName`(SNI)**(与 http 插件里那份同源、同口径)。
458
- *
459
- * stdx 的 `TlsClientConfig()` 默认不发 SNI,多租户 HTTPS 边缘(实测 Cloudflare 后面的 api 域名)
460
- * 收不到 SNI 就以 `handshake failure`(40) 拒掉握手——wss 同样中招(错误发生在 TLS 层,与
461
- * WebSocket 升级无关)。IP 字面量不带 SNI(RFC 6066 不许,且直连 IP 原本可用)。
462
- * 不放核心共享层的原因见 http 插件里那份的注释(核心一引用 stdx.net.tls 就会给所有应用拉上 OpenSSL)。
463
- */
464
- private func tlsConfigForHost(host: String): TlsClientConfig {
465
- var cfg = TlsClientConfig()
466
- if (!isIpLiteralHost(host)) {
467
- cfg.serverName = host
468
- }
469
- return cfg
470
- }
1
+ package cjTauri.plugins.ws
2
+
3
+ import cjTauri.*
4
+
5
+ import std.collection.*
6
+ import std.sync.*
7
+ import stdx.encoding.json.*
8
+ import stdx.encoding.url.*
9
+ import stdx.net.http.*
10
+ import stdx.net.tls.*
11
+ // 缺它 TLS 握手时拿不到默认实现:连 ws:// 也会报
12
+ // "Global crypto kit is not set. If you want to use the default implementation,
13
+ // make sure that package `stdx.crypto.kit` is imported."(2026-10-20 cjh 接线实测)
14
+ import stdx.crypto.kit.*
15
+
16
+ /** 单帧(含分帧重组累计)上限:8 MiB——一条超长帧直接断连,防内存被撑爆 */
17
+ public let CJ_WS_MAX_FRAME: Int64 = 8 * 1024 * 1024
18
+
19
+ /** 连接状态字面量(ws:state 事件的 state 字段) */
20
+ public let CJ_WS_STATE_CONNECTED: String = "connected"
21
+ public let CJ_WS_STATE_CLOSED: String = "closed"
22
+
23
+ /**
24
+ * 官方插件 ws:WebSocket 客户端(stdx.net.http 自带 RFC6455 实现——升级握手与帧
25
+ * 编解码都是库的事,本插件只做治理与并发,与 http 插件的取舍同一结论:用成熟库)
26
+ *
27
+ * 命令:ws:connect({url})/ ws:send({text})/ ws:close()
28
+ * 事件:ws:frame(对端帧——cjh 这类 JSON 帧原样透传,前端按 type 分发;解不动的包
29
+ * 一层 `{"type":"raw"}`)/ ws:state(connected / closed)
30
+ * 权限:**不自动放行**——"permissions": ["ws:default"] 或在 commands 里明文写。
31
+ *
32
+ * 安全语义与 http 插件同构:
33
+ * - 主机白名单在清单 scopes.ws(判定复用共享层 scopeAllows/globMatch,host 提取用核心
34
+ * urlHostOf——http/https/ws/wss 同一套解析);**没写 scopes.ws = 一律拒绝**;
35
+ * - 只认 host 不认端口(「我信这个主机」);连接目标必须是 ws:// 或 wss://。
36
+ *
37
+ * 并发模型(mqtt 插件同款):读线程独占 read()(stdx WebSocket 不支持多线程读);
38
+ * 写全部在 sess.lock 内串行(不支持多线程写);读 + 写并发是全双工本身的语义
39
+ * (cjh 服务端同款用法:读循环线程 + 持锁写)。
40
+ */
41
+ public class WsPlugin <: Plugin {
42
+ public init() {}
43
+
44
+ // 单活动连接:cjh 客户端同一时刻只连一个后端;重连须先 close(确定性优先,
45
+ // 不在命令里做隐式替换——旧读线程还没退出时隐式重连会制造两套 gen 竞态)
46
+ private let sess: WsSession = WsSession()
47
+ // 引用一下 DefaultCryptoKit:stdx.crypto.kit 的**包初始化**负责注册全局加密套件,
48
+ // 但零警告门禁要求 import 必须有符号使用——这个字段就是干这个的(不参与逻辑)
49
+ private let kitMarker = DefaultCryptoKit()
50
+
51
+ public func name(): String {
52
+ return "ws"
53
+ }
54
+
55
+ public func commands(): HashMap<String, CommandHandler> {
56
+ let m = HashMap<String, CommandHandler>()
57
+ // 键写**短名**:前缀由框架补(写全名会得到 ws:ws:connect,且所有命令都成未注册)
58
+ m.add("connect", WsCommands(this.sess))
59
+ m.add("send", WsCommands(this.sess))
60
+ m.add("close", WsCommands(this.sess))
61
+ return m
62
+ }
63
+
64
+ /** 命名权限集(v1.1):default = 连接 + 发送 + 关闭 */
65
+ public func permissions(): HashMap<String, ArrayList<String>> {
66
+ let m = HashMap<String, ArrayList<String>>()
67
+ let all = ArrayList<String>()
68
+ all.add("connect")
69
+ all.add("send")
70
+ all.add("close")
71
+ m.add("default", all)
72
+ return m
73
+ }
74
+
75
+ /** 推事件:对端帧经 ws:frame、连接状态经 ws:state(应用要在清单 events 里放行短名) */
76
+ public func events(): ArrayList<String> {
77
+ let ev = ArrayList<String>()
78
+ ev.add("frame")
79
+ ev.add("state")
80
+ return ev
81
+ }
82
+
83
+ /** 吃 scope:主机白名单的判定在清单 scopes.ws(没写 = 全拒,见类注释) */
84
+ public func scopeAware(): Bool {
85
+ return true
86
+ }
87
+
88
+ public func setup(app: TauriApp): Unit {
89
+ this.sess.setApp(app)
90
+ }
91
+ }
92
+
93
+ /**
94
+ * ws 会话:单活动连接。所有字段都在 lock 内访问(除 setApp 的写也持锁);
95
+ * app 是读线程回投事件的通道(mqtt 同款:命令线程装配、读线程只读引用)。
96
+ */
97
+ public class WsSession {
98
+ public let lock: Mutex = Mutex()
99
+ public var connected: Bool = false
100
+ public var url: String = ""
101
+ public var host: String = ""
102
+ public var lastError: String = ""
103
+ public var rxCount: Int64 = 0
104
+ public var txCount: Int64 = 0
105
+ // 每次连接自增:读线程带出自己的 gen,回来发现不等 = 会话已被 close/替换,静默退出
106
+ public var gen: Int64 = 0
107
+ public var app: Option<TauriApp> = None
108
+ // 连接本体只在 lock 内触碰
109
+ public var ws: Option<WebSocket> = None
110
+ public var client: Option<Client> = None
111
+
112
+ public func setApp(app: TauriApp): Unit {
113
+ this.lock.lock()
114
+ this.app = Some(app)
115
+ this.lock.unlock()
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 鸿蒙侧 ws 如实降级:stdx 的 WebSocket 升级依赖 OpenSSL(帧掩码随机数走 RAND_bytes),
121
+ * 而仓颉运行时在设备上装不了 OpenSSL 3(真机:`Can not load openssl library or function
122
+ * RAND_bytes`),SDK 也没有 `ohos.net.websocket` 绑定(183 个绑定逐一确认)。
123
+ * connect 当场拒绝并说明原因,不留「openssl 半死」的模糊报错(同 http:postStream 的口径)。
124
+ */
125
+ @When[env == "ohos"]
126
+ func wsUnavailableOnOhos(): Unit {
127
+ throw CommandException(
128
+ "ws:connect is not available on HarmonyOS: stdx WebSocket requires OpenSSL (RAND_bytes), which the Cangjie runtime cannot load on-device, and there is no ohos.net.websocket binding. Use http:post for request/response traffic.")
129
+ }
130
+
131
+ @When[env != "ohos"]
132
+ func wsUnavailableOnOhos(): Unit {
133
+ }
134
+
135
+ /** ws 命令处理器:connect 收 {url},send 收 {text},close 无参 */
136
+ public class WsCommands <: CommandHandler {
137
+ private let sess: WsSession
138
+ public init(sess: WsSession) {
139
+ this.sess = sess
140
+ }
141
+
142
+ public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
143
+ match (cmd) {
144
+ case "ws:connect" => return this.connect(args, ipc)
145
+ case "ws:send" => return this.send(args)
146
+ case "ws:close" => return this.close()
147
+ case _ => throw CommandException("unknown ws command: ${cmd}")
148
+ }
149
+ }
150
+
151
+ private func connect(args: JsonObject, ipc: IpcContext): JsonValue {
152
+ let url = this.requireString(args, "url")
153
+ // 协议收紧:只认 ws/wss(urlHostOf 放行 http(s) 是给 http 插件用的)
154
+ let lower = url.toAsciiLower()
155
+ if (!lower.startsWith("ws://") && !lower.startsWith("wss://")) {
156
+ throw CommandException("ws: url must start with ws:// or wss://")
157
+ }
158
+ let host = match (urlHostOf(url)) {
159
+ case Some(h) => h
160
+ case None => throw CommandException("ws: host is outside the authorized scope")
161
+ }
162
+ this.checkHostScope(url, host, ipc)
163
+ return this.doConnect(url, host)
164
+ }
165
+
166
+ private func doConnect(url: String, host: String): JsonValue {
167
+ // 鸿蒙侧如实降级(见 wsUnavailableOnOhos):desktop 该函数是空实现。
168
+ // 放在 scope 判定**之后**——安全判定先于可用性判定,越界请求该报 scope 错而不是平台错
169
+ wsUnavailableOnOhos()
170
+ this.sess.lock.lock()
171
+ let busy = this.sess.connected
172
+ this.sess.lock.unlock()
173
+ if (busy) {
174
+ throw CommandException("ws: already connected; call ws:close first")
175
+ }
176
+ // stdx 的升级函数**本来就要求 ws/wss scheme**(报错原文 "the scheme must be
177
+ // ws or wss")——最初把 ws:// 转成 http:// 再传是画蛇添足,反而被库拒(实测踩过)
178
+ let client = ClientBuilder()
179
+ .tlsConfig(tlsConfigForHost(host))
180
+ .build()
181
+ var sockOpt: Option<WebSocket> = None
182
+ try {
183
+ let parsed = URL.parse(url)
184
+ let pair = WebSocket.upgradeFromClient(client, parsed)
185
+ sockOpt = Some(pair[0])
186
+ } catch (e: Exception) {
187
+ client.close()
188
+ eprintln("[cj-tauri] ws: connect failed: ${e}")
189
+ throw CommandException("ws: connect failed: ${e.message}")
190
+ }
191
+ // 登记会话并起读线程
192
+ this.sess.lock.lock()
193
+ this.sess.ws = sockOpt
194
+ this.sess.client = Some(client)
195
+ this.sess.connected = true
196
+ this.sess.url = url
197
+ this.sess.host = host
198
+ this.sess.lastError = ""
199
+ this.sess.gen += 1
200
+ let gen = this.sess.gen
201
+ this.sess.lock.unlock()
202
+ // 走到这里的 sockOpt 必为 Some(catch 分支已经 throw),match 里再写 None 分支会被
203
+ // 编译器判 unreachable pattern——零警告门禁下直接取用
204
+ let sock = sockOpt.getOrThrow()
205
+ spawn { => wsReaderThread(this.sess, sock, client, gen) }
206
+ eprintln("[cj-tauri] ws: connected ${url} (host=${host})")
207
+ wsEmitState(this.sess, CJ_WS_STATE_CONNECTED, "")
208
+ let obj = JsonObject()
209
+ obj.put("connected", JsonBool(true))
210
+ obj.put("url", JsonString(url))
211
+ return obj
212
+ }
213
+
214
+ private func send(args: JsonObject): JsonValue {
215
+ let text = this.requireString(args, "text")
216
+ this.sess.lock.lock()
217
+ let ok = this.sendLocked(text)
218
+ let err = this.sess.lastError
219
+ this.sess.lock.unlock()
220
+ if (!ok) {
221
+ throw CommandException("ws: send failed(未连接或对端已断): ${err}")
222
+ }
223
+ let obj = JsonObject()
224
+ obj.put("sent", JsonBool(true))
225
+ return obj
226
+ }
227
+
228
+ /** 仅在持有 sess.lock 时调用(stdx WebSocket 不支持多线程写) */
229
+ private func sendLocked(text: String): Bool {
230
+ if (!this.sess.connected) {
231
+ return false
232
+ }
233
+ match (this.sess.ws) {
234
+ case Some(w) => return this.writeText(w, text)
235
+ case None => return false
236
+ }
237
+ }
238
+
239
+ private func writeText(w: WebSocket, text: String): Bool {
240
+ try {
241
+ w.write(WebSocketFrameType.TextWebFrame, text.toArray())
242
+ this.sess.txCount += 1
243
+ return true
244
+ } catch (e: Exception) {
245
+ this.sess.lastError = "${e}"
246
+ return false
247
+ }
248
+ }
249
+
250
+ private func close(): JsonValue {
251
+ this.sess.lock.lock()
252
+ let was = this.sess.connected
253
+ let sockOpt = this.sess.ws
254
+ let clientOpt = this.sess.client
255
+ this.sess.connected = false
256
+ this.sess.ws = None
257
+ this.sess.client = None
258
+ this.sess.gen += 1
259
+ this.sess.lock.unlock()
260
+ // 关连接放在锁外:closeConn 可能让阻塞中的 read() 抛错,读线程随后发现 gen
261
+ // 变了会自行退出并兜底收资源——锁内不做任何可能阻塞的 IO
262
+ if (was) {
263
+ match (sockOpt) {
264
+ case Some(w) => this.closeSock(w)
265
+ case None => ()
266
+ }
267
+ }
268
+ match (clientOpt) {
269
+ case Some(c) => c.close()
270
+ case None => ()
271
+ }
272
+ if (was) {
273
+ eprintln("[cj-tauri] ws: closed by command")
274
+ wsEmitState(this.sess, CJ_WS_STATE_CLOSED, "")
275
+ }
276
+ let obj = JsonObject()
277
+ obj.put("closed", JsonBool(was))
278
+ return obj
279
+ }
280
+
281
+ private func closeSock(w: WebSocket): Unit {
282
+ try {
283
+ // 先走协议关(发 Close 帧)再硬关底层;客户端场景不必等对端回关
284
+ let empty = Array<UInt8>(0, repeat: 0)
285
+ w.write(WebSocketFrameType.CloseWebFrame, empty)
286
+ } catch (e: Exception) {
287
+ let _ = e
288
+ }
289
+ try {
290
+ w.closeConn()
291
+ } catch (e: Exception) {
292
+ let _ = e
293
+ }
294
+ }
295
+
296
+ /** 主机白名单判定(共享层语义:先 deny 后 allow,allow 必须显式命中) */
297
+ private func checkHostScope(url: String, host: String, ipc: IpcContext): Unit {
298
+ // 分支体保持单语句(块体会被解析器当 lambda——AGENTS §4 实测过)
299
+ match (ipc.scopeRuleFor("ws")) {
300
+ case None => this.rejectOutsideScope(url, "no host whitelist declared")
301
+ case Some(rule) => this.checkRuleAllows(url, host, rule)
302
+ }
303
+ }
304
+
305
+ private func checkRuleAllows(url: String, host: String, rule: ScopeRule): Unit {
306
+ if (!scopeAllows(rule, host)) {
307
+ this.rejectOutsideScope(url, host)
308
+ }
309
+ }
310
+
311
+ private func rejectOutsideScope(url: String, why: String): Nothing {
312
+ eprintln("[cj-tauri] scope denied (ws): ${url} -> ${why}")
313
+ throw CommandException("ws: host is outside the authorized scope")
314
+ }
315
+
316
+ private func requireString(args: JsonObject, key: String): String {
317
+ match (args.get(key)) {
318
+ case Some(v) => match (v.kind()) {
319
+ case JsonKind.JsString => return v.asString().getValue()
320
+ case _ => throw CommandException("ws: argument \"${key}\" must be a string")
321
+ }
322
+ case None => throw CommandException("ws: missing argument \"${key}\"")
323
+ }
324
+ }
325
+ }
326
+
327
+ /** 推事件给页面(读线程 / 命令线程都可能调):mqtt 同款,app 引用只读 */
328
+ func wsEmit(sess: WsSession, event: String, obj: JsonObject): Unit {
329
+ var app: Option<TauriApp> = None
330
+ sess.lock.lock()
331
+ app = sess.app
332
+ sess.lock.unlock()
333
+ match (app) {
334
+ case Some(a) => a.emit(event, obj)
335
+ case None => ()
336
+ }
337
+ }
338
+
339
+ /** 推连接状态;同时落一行 stderr(页面没接住事件时 stderr 仍可对账——AGENTS §1) */
340
+ func wsEmitState(sess: WsSession, state: String, err: String): Unit {
341
+ let obj = JsonObject()
342
+ obj.put("state", JsonString(state))
343
+ sess.lock.lock()
344
+ obj.put("url", JsonString(sess.url))
345
+ obj.put("host", JsonString(sess.host))
346
+ obj.put("rx", JsonInt(sess.rxCount))
347
+ obj.put("tx", JsonInt(sess.txCount))
348
+ sess.lock.unlock()
349
+ obj.put("error", JsonString(err))
350
+ eprintln("[cj-tauri] ws: state=${state} error=${err}")
351
+ wsEmit(sess, "ws:state", obj)
352
+ }
353
+
354
+ /** 把一帧文本投给页面:cjh 的帧是 JSON 对象——能解就原样透传(前端按 type 分发),
355
+ 解不动的包一层 raw(原文不丢,前端自己看着办) */
356
+ func wsDeliver(sess: WsSession, bytes: Array<UInt8>): Unit {
357
+ let text = String.fromUtf8(bytes)
358
+ var obj = JsonObject()
359
+ var wrapped = false
360
+ try {
361
+ let v = JsonValue.fromStr(text)
362
+ match (jsonAsObject(v)) {
363
+ case Some(o) => obj = o
364
+ case None => wrapped = true
365
+ }
366
+ } catch (e: Exception) {
367
+ let _ = e
368
+ wrapped = true
369
+ }
370
+ if (wrapped) {
371
+ let raw = JsonObject()
372
+ raw.put("type", JsonString("raw"))
373
+ raw.put("text", JsonString(text))
374
+ obj = raw
375
+ }
376
+ wsEmit(sess, "ws:frame", obj)
377
+ }
378
+
379
+ func jsonAsObject(v: JsonValue): Option<JsonObject> {
380
+ match (v.kind()) {
381
+ case JsonKind.JsObject => return Some(v.asObject())
382
+ case _ => return None
383
+ }
384
+ }
385
+
386
+ func wsAppend(buf: ArrayList<UInt8>, payload: Array<UInt8>): Unit {
387
+ for (i in 0..payload.size) {
388
+ buf.add(payload[i])
389
+ }
390
+ }
391
+
392
+ /**
393
+ * 读线程:独占 sock.read()(stdx 不支持多线程读)。
394
+ * gen 不匹配 = 会话已被 close/替换 → 旧连接的残余帧直接丢弃退出,绝不投给页面。
395
+ * 分段帧(fin == false 的首帧 + ContinuationWebFrame 续帧)攒齐再投。
396
+ */
397
+ func wsReaderThread(sess: WsSession, sock: WebSocket, client: Client, gen: Int64): Unit {
398
+ var buf = ArrayList<UInt8>()
399
+ while (true) {
400
+ try {
401
+ let frame = sock.read()
402
+ sess.lock.lock()
403
+ let stale = (sess.gen != gen) || (!sess.connected)
404
+ if (!stale) {
405
+ sess.rxCount += 1
406
+ }
407
+ sess.lock.unlock()
408
+ if (stale) {
409
+ break
410
+ }
411
+ let ft = frame.frameType
412
+ if (ft == WebSocketFrameType.CloseWebFrame) {
413
+ break
414
+ }
415
+ if (ft == WebSocketFrameType.PingWebFrame || ft == WebSocketFrameType.PongWebFrame) {
416
+ // 控制帧:心跳由库管,这里出现了就忽略(cjh 服务端不主动发——实测再校)
417
+ continue
418
+ }
419
+ let payload = frame.payload // prop 不是函数(stdx 文档确认,写 () 编译不过)
420
+ if (payload.size <= 0) {
421
+ continue
422
+ }
423
+ if (buf.size + payload.size > CJ_WS_MAX_FRAME) {
424
+ eprintln("[cj-tauri] ws: 分帧重组超限(>${CJ_WS_MAX_FRAME} 字节),断开连接")
425
+ break
426
+ }
427
+ if (ft == WebSocketFrameType.ContinuationWebFrame) {
428
+ wsAppend(buf, payload)
429
+ if (frame.fin) {
430
+ wsDeliver(sess, buf.toArray())
431
+ buf = ArrayList<UInt8>()
432
+ }
433
+ continue
434
+ }
435
+ if (frame.fin) {
436
+ wsDeliver(sess, payload)
437
+ continue
438
+ }
439
+ // 分段帧首帧:攒着等续帧
440
+ wsAppend(buf, payload)
441
+ } catch (e: Exception) {
442
+ // 正常退出路径:close / 对端断开都会让阻塞中的 read 抛错
443
+ let _ = e
444
+ break
445
+ }
446
+ }
447
+ sess.lock.lock()
448
+ let mine = (sess.gen == gen) && sess.connected
449
+ if (mine) {
450
+ sess.connected = false
451
+ sess.ws = None
452
+ sess.client = None
453
+ sess.gen += 1
454
+ sess.lastError = "connection closed by peer"
455
+ }
456
+ sess.lock.unlock()
457
+ if (mine) {
458
+ eprintln("[cj-tauri] ws: connection closed by peer")
459
+ wsEmitState(sess, CJ_WS_STATE_CLOSED, "connection closed by peer")
460
+ }
461
+ // 资源兜底:连接已断或已被替换,double-close 的错吞掉即可
462
+ try {
463
+ sock.closeConn()
464
+ } catch (e: Exception) {
465
+ let _ = e
466
+ }
467
+ try {
468
+ client.close()
469
+ } catch (e: Exception) {
470
+ let _ = e
471
+ }
472
+ }
473
+
474
+ /**
475
+ * 建 TLS 客户端配置:**域名必须显式写 `serverName`(SNI)**(与 http 插件里那份同源、同口径)。
476
+ *
477
+ * stdx 的 `TlsClientConfig()` 默认不发 SNI,多租户 HTTPS 边缘(实测 Cloudflare 后面的 api 域名)
478
+ * 收不到 SNI 就以 `handshake failure`(40) 拒掉握手——wss 同样中招(错误发生在 TLS 层,与
479
+ * WebSocket 升级无关)。IP 字面量不带 SNI(RFC 6066 不许,且直连 IP 原本可用)。
480
+ * 不放核心共享层的原因见 http 插件里那份的注释(核心一引用 stdx.net.tls 就会给所有应用拉上 OpenSSL)。
481
+ */
482
+ private func tlsConfigForHost(host: String): TlsClientConfig {
483
+ var cfg = TlsClientConfig()
484
+ if (!isIpLiteralHost(host)) {
485
+ cfg.serverName = host
486
+ }
487
+ return cfg
488
+ }