cj-tauri 0.8.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/cjpm.toml +52 -25
- package/cli/cjpm.toml +1 -1
- package/cli/src/doctor.cj +304 -0
- package/cli/src/main.cj +33 -7
- package/cli/src/project.cj +59 -1
- package/cli/src/resolve.cj +348 -304
- package/cli/src/scaffold.cj +139 -22
- package/cli/templates/ohos-pc/AppScope/app.json5 +13 -0
- package/cli/templates/ohos-pc/AppScope/resources/base/element/string.json +8 -0
- package/cli/templates/ohos-pc/AppScope/resources/base/media/background.png +0 -0
- package/cli/templates/ohos-pc/AppScope/resources/base/media/foreground.png +0 -0
- package/cli/templates/ohos-pc/AppScope/resources/base/media/layered_image.json +6 -0
- package/cli/templates/ohos-pc/README.md +79 -0
- package/cli/templates/ohos-pc/build-hap.sh +190 -0
- package/cli/templates/ohos-pc/build-profile.json5 +46 -0
- package/cli/templates/ohos-pc/entry/build-profile.json5 +26 -0
- package/cli/templates/ohos-pc/entry/cjpm.toml +43 -0
- package/cli/templates/ohos-pc/entry/hvigorfile.ts +6 -0
- package/cli/templates/ohos-pc/entry/oh-package.json5 +9 -0
- package/cli/templates/ohos-pc/entry/src/main/cangjie/ability_stage.cj +17 -0
- package/cli/templates/ohos-pc/entry/src/main/cangjie/app_host.cj +94 -0
- package/cli/templates/ohos-pc/entry/src/main/cangjie/index.cj +167 -0
- package/cli/templates/ohos-pc/entry/src/main/cangjie/main_ability.cj +139 -0
- package/cli/templates/ohos-pc/entry/src/main/module.json5 +49 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/element/color.json +8 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/element/string.json +16 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/media/background.png +0 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/media/foreground.png +0 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/media/layered_image.json +6 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/media/startIcon.png +0 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/profile/main_pages.json +2 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/rawfile/capabilities.json +6 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/rawfile/index.html +92 -0
- package/cli/templates/ohos-pc/hvigor/hvigor-config.json5 +5 -0
- package/cli/templates/ohos-pc/hvigorfile.ts +6 -0
- package/cli/templates/ohos-pc/oh-package.json5 +10 -0
- package/native/build_linux.sh +30 -2
- package/native/build_win.bat +62 -61
- package/native/core/bridge_core.c +1626 -0
- package/native/{bridge_core.h → core/bridge_core.h} +246 -6
- package/native/platforms/harmonyos/README.md +106 -0
- package/native/platforms/linux/bridge_linux.c +2485 -0
- package/native/platforms/windows/bridge_win.c +3248 -0
- package/native/tests/test_bridge_core.c +528 -1
- package/package.json +12 -3
- package/prebuilt/win32-x64/cj-tauri.exe +0 -0
- package/src/api_system.cj +10 -2
- package/src/app.cj +248 -85
- package/src/arkweb_proxy.cj +56 -0
- package/src/arkweb_script.cj +154 -0
- package/src/capability.cj +140 -0
- package/src/clipboard_bridge.cj +55 -0
- package/src/dragdrop.cj +26 -0
- package/src/embedded_assets.cj +98 -0
- package/src/hexutil.cj +53 -0
- package/src/host.cj +197 -26
- package/src/host_arkweb.cj +740 -0
- package/src/host_factory.cj +48 -0
- package/src/host_webkit.cj +202 -26
- package/src/host_webview2.cj +176 -3
- package/src/hotkey.cj +66 -0
- package/src/ipc_hub.cj +49 -0
- package/src/ipc_message.cj +2 -1
- package/src/jsutil.cj +33 -0
- package/src/log_bridge.cj +107 -0
- package/src/logging.cj +127 -0
- package/src/menu.cj +197 -196
- package/src/plugin.cj +40 -0
- package/src/plugins/clipboard/plugin_clipboard.cj +144 -0
- package/src/{plugin_dialog.cj → plugins/dialog/plugin_dialog.cj} +74 -17
- package/src/plugins/fs/plugin_fs.cj +194 -0
- package/src/plugins/http/plugin_http.cj +459 -0
- package/src/plugins/log/plugin_log.cj +105 -0
- package/src/{plugin_menu.cj → plugins/menu/plugin_menu.cj} +4 -2
- package/src/plugins/mqtt/mqtt_codec.cj +661 -0
- package/src/plugins/mqtt/plugin_mqtt.cj +1446 -0
- package/src/plugins/notification/plugin_notification.cj +146 -0
- package/src/plugins/plugins.cj +5 -0
- package/src/plugins/redis/plugin_redis.cj +2409 -0
- package/src/plugins/redis/redis_codec.cj +556 -0
- package/src/{plugin_serial.cj → plugins/serial/plugin_serial.cj} +442 -489
- package/src/{serial_bridge.cj → plugins/serial/serial_bridge.cj} +185 -184
- package/src/{plugin_shell.cj → plugins/shell/plugin_shell.cj} +230 -228
- package/src/plugins/sqlite/plugin_sqlite.cj +697 -0
- package/src/plugins/sqlite/sqlite_ffi.cj +206 -0
- package/src/plugins/store/plugin_store.cj +287 -0
- package/src/plugins/ws/plugin_ws.cj +470 -0
- package/src/process_hidden.cj +174 -0
- package/src/scope.cj +391 -0
- package/src/shell_event.cj +69 -68
- package/src/tests/capability_test.cj +53 -0
- package/src/tests/fake_host.cj +85 -20
- package/src/tests/ipc_hub_test.cj +13 -0
- package/src/tests/jsutil_test.cj +62 -0
- package/src/tests/logging_test.cj +62 -0
- package/src/tests/mqtt_codec_test.cj +405 -0
- package/src/tests/plugin_clipboard_test.cj +222 -0
- package/src/tests/plugin_dialog_test.cj +98 -4
- package/src/tests/plugin_http_test.cj +124 -0
- package/src/tests/plugin_menu_test.cj +1 -0
- package/src/tests/plugin_notification_test.cj +205 -0
- package/src/tests/plugin_serial_test.cj +269 -267
- package/src/tests/plugin_shell_test.cj +189 -188
- package/src/tests/plugin_sqlite_test.cj +230 -0
- package/src/tests/plugin_store_test.cj +260 -0
- package/src/tests/plugin_test.cj +542 -524
- package/src/tests/plugin_ws_test.cj +40 -0
- package/src/tests/process_hidden_test.cj +129 -0
- package/src/tests/redis_codec_test.cj +396 -0
- package/src/tests/scope_test.cj +350 -0
- package/src/tests/shell_event_test.cj +0 -1
- package/src/tests/system_host_test.cj +81 -79
- package/src/tests/urlutil_test.cj +53 -0
- package/src/tests/version_test.cj +75 -65
- package/src/tray.cj +90 -0
- package/src/urlutil.cj +159 -0
- package/src/version.cj +23 -2
- package/src/window.cj +51 -42
- package/native/bridge_core.c +0 -797
- package/native/bridge_linux.c +0 -1268
- package/native/bridge_win.c +0 -1633
- package/src/plugin_fs.cj +0 -118
- /package/native/{bridge_js.h → core/bridge_js.h} +0 -0
|
@@ -0,0 +1,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
|
+
/** 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
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
package cjTauri
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 后台(静默)子进程:起一个**控制台程序**,但不要那个控制台窗口。
|
|
5
|
+
*
|
|
6
|
+
* 为什么需要它:cj-tauri 的应用产物是 GUI 子系统(Windows 的 `--subsystem=windows`),父进程
|
|
7
|
+
* 没有控制台;此时用 `std.process.launch` 拉起一个**控制台子系统**的程序(cjh、ffmpeg 这类 CLI),
|
|
8
|
+
* Windows 会给子进程单独配一个控制台窗口——用户看到的就是「点了一下,旁边蹦出一个黑窗口」。
|
|
9
|
+
* `std.process.launch` 的签名里没有可以传创建标志的地方,所以这条下沉到 C 桥
|
|
10
|
+
* (平台差异只允许出现在 `@When` 与 `bridge_*.c`,AGENTS §2):
|
|
11
|
+
* Windows = `CreateProcessW` + `DETACHED_PROCESS`(子进程**根本没有** console:`CREATE_NO_WINDOW`
|
|
12
|
+
* 只是不显示窗口,它仍持有一个隐藏 console、conhost 照挂;三个标准句柄显式指到 NUL,否则子进程一
|
|
13
|
+
* println 就可能死在自己的控制台上),Linux = `posix_spawn` + 自成会话(终端 Ctrl-C 不连带打死)。
|
|
14
|
+
*
|
|
15
|
+
* 与 `std.process.launch` 的取舍:这条**拿不到子进程的 stdout / stderr**(Windows 指向 NUL、
|
|
16
|
+
* Linux 指向 /dev/null)。要读它的输出就别用这个——那需要管道,是另一件事。
|
|
17
|
+
*
|
|
18
|
+
* 句柄口径:只留 pid(Windows 的进程句柄当场关掉,不占句柄表;Linux 的子进程是分离的,
|
|
19
|
+
* 父进程退出后它照样活着)。要让它结束就 `stop()`,要判它还在不在就 `isAlive()`。
|
|
20
|
+
*/
|
|
21
|
+
public class BackgroundProcess {
|
|
22
|
+
private let pid: Int64
|
|
23
|
+
|
|
24
|
+
init(pid: Int64) {
|
|
25
|
+
this.pid = pid
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 进程号(恒 > 0:拿到它的路径只有 `launchHidden` 成功那一条) */
|
|
29
|
+
public func processId(): Int64 {
|
|
30
|
+
return pid
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 还在跑吗(查不到即按「不在」上报——调用方据此决定要不要重新拉起) */
|
|
34
|
+
public func isAlive(): Bool {
|
|
35
|
+
return hiddenPidAlive(pid)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 让它结束:先按正常终止(Windows = TerminateProcess;Linux = SIGTERM,3 秒不走再 SIGKILL)。
|
|
40
|
+
* 幂等——进程已经不在了也算成功。
|
|
41
|
+
*/
|
|
42
|
+
public func stop(): Unit {
|
|
43
|
+
if (!hiddenPidKill(pid)) {
|
|
44
|
+
eprintln("[cj-tauri] 终止子进程失败(pid=${pid})")
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 静默启动一个后台进程:`exePath` 是程序路径,`args` 是参数表(**不要自己加引号**,
|
|
51
|
+
* 空格 / 引号由平台按各自规则转义)。
|
|
52
|
+
*
|
|
53
|
+
* 返回 `None` 的三种情况:路径为空、参数里混进了内部分隔符(0x1F)、平台起不来进程
|
|
54
|
+
* (原因已由 C 桥打进 stderr)。**绝不在没起进程时返回一个 pid**。
|
|
55
|
+
*
|
|
56
|
+
* 典型用法(应用侧 AI 后端:随包的 cjh):
|
|
57
|
+
*
|
|
58
|
+
* ```cangjie
|
|
59
|
+
* match (launchHidden(bin, ["web", "--port", "8765"])) {
|
|
60
|
+
* case Some(p) => backendPid = p.processId()
|
|
61
|
+
* case None => eprintln("[app] 后端没起来")
|
|
62
|
+
* }
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
public func launchHidden(exePath: String, args: Array<String>): Option<BackgroundProcess> {
|
|
66
|
+
if (exePath.isEmpty()) {
|
|
67
|
+
eprintln("[cj-tauri] launchHidden: 程序路径为空")
|
|
68
|
+
return None
|
|
69
|
+
}
|
|
70
|
+
let wire = match (joinHiddenArgs(args)) {
|
|
71
|
+
case Some(w) => w
|
|
72
|
+
case None => return None
|
|
73
|
+
}
|
|
74
|
+
let pid = spawnHiddenPid(exePath, wire)
|
|
75
|
+
if (pid <= 0) {
|
|
76
|
+
return None
|
|
77
|
+
}
|
|
78
|
+
return Some(BackgroundProcess(pid))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 把参数表拼成 C 桥认的「线格式」:按 0x1F(单元分隔符)连接,由平台侧再切成 argv。
|
|
83
|
+
*
|
|
84
|
+
* 这个字节不会出现在正常参数里,所以一旦出现就**当场拒绝**(`None`)——默默拼错比拒绝更糟:
|
|
85
|
+
* 参数被劈成两个、而调用方拿到的是「启动成功」。
|
|
86
|
+
*
|
|
87
|
+
* 抽成纯函数是为了单测:真正难的不是拼接,是「拼出来还能不能原样切开」这条不变量
|
|
88
|
+
* (`src/tests/process_hidden_test.cj`)。
|
|
89
|
+
*/
|
|
90
|
+
public func joinHiddenArgs(args: Array<String>): Option<String> {
|
|
91
|
+
let sep = "\u{1F}"
|
|
92
|
+
var wire = ""
|
|
93
|
+
var first = true
|
|
94
|
+
for (a in args) {
|
|
95
|
+
if (let Some(_) <- a.indexOf(sep)) {
|
|
96
|
+
eprintln("[cj-tauri] launchHidden: 参数里不允许出现 0x1F 分隔符(${a})")
|
|
97
|
+
return None
|
|
98
|
+
}
|
|
99
|
+
if (!first) {
|
|
100
|
+
wire += sep
|
|
101
|
+
}
|
|
102
|
+
wire += a
|
|
103
|
+
first = false
|
|
104
|
+
}
|
|
105
|
+
return Some(wire)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 静默启动的 C 桥出口:成功回 pid(> 0),失败回 0(平台已把原因写进 stderr)。
|
|
110
|
+
*
|
|
111
|
+
* 鸿蒙侧没有 C 桥产物(`native/platforms/harmonyos/` 目前只有契约),照桌面那份写会让模块
|
|
112
|
+
* 加载期就 SIGSEGV(与 `log_bridge.cj` 同一条坑,AGENTS §4)——所以按 `env` 分叉,如实降级。
|
|
113
|
+
* 判据用 `env` 而不是 `arch`:桌面 aarch64 Linux 也有 C 桥。
|
|
114
|
+
*/
|
|
115
|
+
@When[env != "ohos"]
|
|
116
|
+
@C
|
|
117
|
+
foreign func cj_bridge_spawn_hidden(exe: CString, args: CString): Int64
|
|
118
|
+
|
|
119
|
+
/** 终止 pid(幂等);鸿蒙侧无桥,不用声明。 */
|
|
120
|
+
@When[env != "ohos"]
|
|
121
|
+
@C
|
|
122
|
+
foreign func cj_bridge_kill_pid(pid: Int64): Int32
|
|
123
|
+
|
|
124
|
+
/** 探活(1 = 活着);鸿蒙侧无桥,不用声明。 */
|
|
125
|
+
@When[env != "ohos"]
|
|
126
|
+
@C
|
|
127
|
+
foreign func cj_bridge_pid_alive(pid: Int64): Int32
|
|
128
|
+
|
|
129
|
+
@When[env != "ohos"]
|
|
130
|
+
func spawnHiddenPid(exePath: String, argsWire: String): Int64 {
|
|
131
|
+
var pid: Int64 = 0
|
|
132
|
+
unsafe {
|
|
133
|
+
pid = cj_bridge_spawn_hidden(LibC.mallocCString(exePath), LibC.mallocCString(argsWire))
|
|
134
|
+
}
|
|
135
|
+
return pid
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 鸿蒙侧:没有桥就没有后台静默启动,如实报 0 并说清代价(用 std.process.launch 会带出控制台) */
|
|
139
|
+
@When[env == "ohos"]
|
|
140
|
+
func spawnHiddenPid(exePath: String, argsWire: String): Int64 {
|
|
141
|
+
let _ = argsWire
|
|
142
|
+
eprintln("[cj-tauri] 本平台没有 C 桥,无法后台静默启动:${exePath}")
|
|
143
|
+
return 0
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@When[env != "ohos"]
|
|
147
|
+
func hiddenPidAlive(pid: Int64): Bool {
|
|
148
|
+
var alive: Int32 = 0
|
|
149
|
+
unsafe {
|
|
150
|
+
alive = cj_bridge_pid_alive(pid)
|
|
151
|
+
}
|
|
152
|
+
return alive == 1
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@When[env == "ohos"]
|
|
156
|
+
func hiddenPidAlive(pid: Int64): Bool {
|
|
157
|
+
let _ = pid
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@When[env != "ohos"]
|
|
162
|
+
func hiddenPidKill(pid: Int64): Bool {
|
|
163
|
+
var rc: Int32 = 0
|
|
164
|
+
unsafe {
|
|
165
|
+
rc = cj_bridge_kill_pid(pid)
|
|
166
|
+
}
|
|
167
|
+
return rc == 1
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
@When[env == "ohos"]
|
|
171
|
+
func hiddenPidKill(pid: Int64): Bool {
|
|
172
|
+
let _ = pid
|
|
173
|
+
return false
|
|
174
|
+
}
|