cj-tauri 1.1.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +137 -77
  2. package/bin/cj-tauri.js +287 -287
  3. package/cjpm.toml +13 -2
  4. package/cli/cjpm.toml +22 -13
  5. package/cli/src/doctor.cj +308 -304
  6. package/cli/src/main.cj +129 -121
  7. package/cli/src/manifest.cj +632 -0
  8. package/cli/src/manifest_test.cj +87 -0
  9. package/cli/src/project.cj +770 -754
  10. package/cli/src/resolve.cj +348 -348
  11. package/cli/src/scaffold.cj +46 -38
  12. package/cli/src/sync.cj +526 -0
  13. package/cli/src/util.cj +9 -0
  14. package/cli/templates/app/README.md +30 -59
  15. package/cli/templates/app/{ui → assets/ui}/index.html +2 -0
  16. package/cli/templates/app/cj-tauri.toml +32 -0
  17. package/cli/templates/app/cjpm.toml +21 -4
  18. package/cli/templates/app/src/main.cj +29 -16
  19. package/cli/templates/app-react/README.md +34 -45
  20. package/cli/templates/app-react/{ui → assets/ui}/index.html +25 -25
  21. package/cli/templates/app-react/{ui → assets/ui}/src/App.css +67 -67
  22. package/cli/templates/app-react/{ui → assets/ui}/src/App.jsx +124 -124
  23. package/cli/templates/app-react/cj-tauri.toml +33 -0
  24. package/cli/templates/app-react/cjpm.toml +21 -4
  25. package/cli/templates/app-react/src/main.cj +18 -29
  26. package/cli/templates/app-vue/README.md +34 -38
  27. package/cli/templates/app-vue/{ui → assets/ui}/index.html +25 -25
  28. package/cli/templates/app-vue/{ui → assets/ui}/src/App.vue +176 -176
  29. package/cli/templates/app-vue/cj-tauri.toml +33 -0
  30. package/cli/templates/app-vue/cjpm.toml +21 -4
  31. package/cli/templates/app-vue/src/main.cj +18 -29
  32. package/cli/templates/{ohos-pc → ohos-skeleton}/README.md +5 -0
  33. package/cli/templates/{ohos-pc → ohos-skeleton}/build-hap.sh +15 -0
  34. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/cjpm.toml +8 -0
  35. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/dialog_host.cj +217 -0
  36. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/index.cj +527 -0
  37. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/module.json5 +8 -0
  38. package/native/build_win.bat +62 -62
  39. package/native/core/bridge_core.c +1681 -1626
  40. package/native/core/bridge_core.h +675 -618
  41. package/native/core/bridge_js.h +108 -108
  42. package/native/platforms/harmonyos/README.md +115 -6
  43. package/native/platforms/linux/bridge_linux.c +2787 -2485
  44. package/native/platforms/windows/bridge_win.c +3740 -3248
  45. package/native/tests/gtk_threading_probe.c +143 -143
  46. package/native/tests/test_bridge_core.c +1339 -1228
  47. package/package.json +53 -51
  48. package/prebuilt/linux-x64/cj-tauri +0 -0
  49. package/src/api_system.cj +67 -67
  50. package/src/app.cj +325 -940
  51. package/src/app_events.cj +166 -0
  52. package/src/app_host_ops.cj +287 -0
  53. package/src/app_run.cj +283 -0
  54. package/src/app_spec.cj +76 -0
  55. package/src/arkweb_menu.cj +216 -0
  56. package/src/arkweb_script.cj +0 -12
  57. package/src/capability.cj +409 -409
  58. package/src/clipboard_bridge.cj +178 -0
  59. package/src/dialog_spec.cj +79 -0
  60. package/src/dragdrop.cj +26 -26
  61. package/src/embedded_assets.cj +98 -98
  62. package/src/host.cj +752 -604
  63. package/src/host_arkweb.cj +201 -17
  64. package/src/host_webkit.cj +663 -548
  65. package/src/host_webview2.cj +663 -548
  66. package/src/hotkey.cj +66 -66
  67. package/src/ipc_hub.cj +337 -337
  68. package/src/ipc_message.cj +286 -286
  69. package/src/launch.cj +62 -0
  70. package/src/logging.cj +127 -127
  71. package/src/menu.cj +197 -197
  72. package/src/platform/platform_app_context.cj +77 -0
  73. package/src/platform/platform_assets.cj +33 -0
  74. package/src/platform/platform_discover.cj +176 -0
  75. package/src/platform/platform_env.cj +29 -0
  76. package/src/platform/platform_feature.cj +31 -0
  77. package/src/platform/platform_page_source.cj +87 -0
  78. package/src/platform/platform_target_info.cj +32 -0
  79. package/src/platform_host_provider.cj +87 -0
  80. package/src/plugin.cj +333 -299
  81. package/src/plugins/clipboard/plugin_clipboard.cj +152 -144
  82. package/src/plugins/dialog/plugin_dialog.cj +209 -209
  83. package/src/plugins/fs/plugin_fs.cj +105 -1
  84. package/src/plugins/http/http_backend.cj +131 -0
  85. package/src/plugins/http/http_desktop.cj +195 -0
  86. package/src/plugins/http/http_ohos.cj +204 -0
  87. package/src/plugins/http/plugin_http.cj +310 -459
  88. package/src/plugins/log/plugin_log.cj +105 -105
  89. package/src/plugins/menu/plugin_menu.cj +8 -0
  90. package/src/plugins/notification/plugin_notification.cj +154 -146
  91. package/src/plugins/serial/plugin_serial.cj +17 -1
  92. package/src/plugins/serial/serial_bridge.cj +9 -1
  93. package/src/plugins/serial/serial_manager_ohos.cj +92 -0
  94. package/src/plugins/serial/serial_ohos.cj +529 -0
  95. package/src/plugins/shell/plugin_shell.cj +29 -1
  96. package/src/plugins/sqlite/plugin_sqlite.cj +8 -0
  97. package/src/plugins/ws/plugin_ws.cj +488 -470
  98. package/src/process_hidden.cj +174 -174
  99. package/src/scope.cj +391 -391
  100. package/src/shell_event.cj +69 -69
  101. package/src/tests/app_spec_test.cj +126 -0
  102. package/src/tests/capability_test.cj +243 -243
  103. package/src/tests/dialog_spec_test.cj +73 -0
  104. package/src/tests/fake_host.cj +201 -186
  105. package/src/tests/ipc_hub_test.cj +487 -487
  106. package/src/tests/logging_test.cj +62 -62
  107. package/src/tests/platform_contract_test.cj +251 -0
  108. package/src/tests/plugin_clipboard_test.cj +222 -222
  109. package/src/tests/plugin_dialog_test.cj +237 -237
  110. package/src/tests/plugin_fs_test.cj +207 -0
  111. package/src/tests/plugin_http_test.cj +124 -124
  112. package/src/tests/plugin_notification_test.cj +205 -205
  113. package/src/tests/plugin_serial_test.cj +48 -0
  114. package/src/tests/plugin_test.cj +66 -3
  115. package/src/tests/plugin_ws_test.cj +40 -40
  116. package/src/tests/process_hidden_test.cj +129 -129
  117. package/src/tests/scope_test.cj +350 -350
  118. package/src/tests/system_host_test.cj +99 -81
  119. package/src/tests/urlutil_test.cj +53 -53
  120. package/src/tests/version_test.cj +75 -75
  121. package/src/tests/window_registry_test.cj +123 -123
  122. package/src/tray.cj +90 -90
  123. package/src/urlutil.cj +159 -159
  124. package/src/version.cj +44 -44
  125. package/src/window.cj +51 -51
  126. package/src/window_registry.cj +116 -116
  127. package/cli/templates/ohos-pc/entry/src/main/cangjie/index.cj +0 -167
  128. package/prebuilt/win32-x64/cj-tauri.exe +0 -0
  129. package/src/host_factory.cj +0 -48
  130. /package/cli/templates/app/{capabilities → assets/capabilities}/default.json +0 -0
  131. /package/cli/templates/app-react/{capabilities → assets/capabilities}/default.json +0 -0
  132. /package/cli/templates/app-react/{ui → assets/ui}/package.json +0 -0
  133. /package/cli/templates/app-react/{ui → assets/ui}/src/main.jsx +0 -0
  134. /package/cli/templates/app-react/{ui → assets/ui}/vite.config.js +0 -0
  135. /package/cli/templates/app-vue/{capabilities → assets/capabilities}/default.json +0 -0
  136. /package/cli/templates/app-vue/{ui → assets/ui}/package.json +0 -0
  137. /package/cli/templates/app-vue/{ui → assets/ui}/src/main.js +0 -0
  138. /package/cli/templates/app-vue/{ui → assets/ui}/vite.config.js +0 -0
  139. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/app.json5 +0 -0
  140. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/element/string.json +0 -0
  141. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/media/background.png +0 -0
  142. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/media/foreground.png +0 -0
  143. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/media/layered_image.json +0 -0
  144. /package/cli/templates/{ohos-pc → ohos-skeleton}/build-profile.json5 +0 -0
  145. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/build-profile.json5 +0 -0
  146. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/hvigorfile.ts +0 -0
  147. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/oh-package.json5 +0 -0
  148. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/cangjie/ability_stage.cj +0 -0
  149. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/cangjie/app_host.cj +0 -0
  150. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/cangjie/main_ability.cj +0 -0
  151. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/element/color.json +0 -0
  152. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/element/string.json +0 -0
  153. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/background.png +0 -0
  154. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/foreground.png +0 -0
  155. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/layered_image.json +0 -0
  156. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/startIcon.png +0 -0
  157. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/profile/main_pages.json +0 -0
  158. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/rawfile/capabilities.json +0 -0
  159. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/rawfile/index.html +0 -0
  160. /package/cli/templates/{ohos-pc → ohos-skeleton}/hvigor/hvigor-config.json5 +0 -0
  161. /package/cli/templates/{ohos-pc → ohos-skeleton}/hvigorfile.ts +0 -0
  162. /package/cli/templates/{ohos-pc → ohos-skeleton}/oh-package.json5 +0 -0
package/src/app.cj CHANGED
@@ -1,940 +1,325 @@
1
- package cjTauri
2
-
3
- import std.collection.*
4
- import std.env.*
5
- import stdx.encoding.json.*
6
-
7
- /**
8
- * 附加窗口的登记项(试验性 API `TauriApp.addWindow` 的装配状态)
9
- *
10
- * 为什么宿主在这里就建好、html 也先留着:窗口表要在 run() 之前定稿(`hostOf` 当场可用),
11
- * 而原生窗口要等 run() 才创建——两者分开存,正好对上宿主自己的两段式节奏
12
- * (宿主对象构造时就有、原生窗口 start() 时才建)。
13
- */
14
- class ExtraWindowSpec {
15
- let label: String
16
- let host: WebViewHost
17
- let html: String
18
-
19
- init(label: String, host: WebViewHost, html: String) {
20
- this.label = label
21
- this.host = host
22
- this.html = html
23
- }
24
- }
25
-
26
- /**
27
- * TauriApp 装配(对标 tauri::Builder / App)
28
- *
29
- * 用法:
30
- * let app = TauriApp()
31
- * app.register("greet", GreetCommand())
32
- * app.addCapabilityJson(...) // 或 addCapability(cap)
33
- * app.run(html) // 启动宿主并阻塞
34
- *
35
- * 多窗口(试验性,批次 3 的最小切片):`addWindow(label, config, html)` 登记附加窗口,
36
- * run() / runUrl() 统一启动;事件按 label 路由(`emitToWindow` / `hostOf`)。
37
- */
38
- public class TauriApp <: AppHost {
39
- private var hub: IpcHub = IpcHub()
40
- private var capabilities: CapabilityRegistry = CapabilityRegistry()
41
- private var host: WebViewHost = createHost(DEFAULT_WINDOW_LABEL)
42
- private var config: WindowConfig = WindowConfig()
43
- // 窗口注册表(批次 2):单窗口阶段只有一条 "main",多窗口时是「label → 宿主」的唯一权威来源
44
- private var windows: WindowRegistry = WindowRegistry()
45
- // 附加窗口(批次 3 多窗口 UI 的最小切片,试验性):addWindow() 登记、run() / runUrl() 统一启动
46
- private var extraWindows: ArrayList<ExtraWindowSpec> = ArrayList<ExtraWindowSpec>()
47
- // 应用层留存的菜单模型(label → 模型):setMenu() 记一份,供「只改某一维状态」反查另一维
48
- private var menus: HashMap<String, MenuModel> = HashMap<String, MenuModel>()
49
- // 应用层的 shell 事件钩子(对标 Tauri 的 on_menu_event):框架分发时先跑钩子、再投前端事件
50
- private var shellHooks: ArrayList<(String, String) -> Unit> = ArrayList<(String, String) -> Unit>()
51
- private var started: Bool = false
52
- private var plugins: ArrayList<Plugin> = ArrayList<Plugin>()
53
- // 各插件 jsShim() 收集成的「预执行脚本」列表,prepare() 时定稿,run / runUrl 时交给宿主
54
- private var pluginScripts: ArrayList<String> = ArrayList<String>()
55
-
56
- public init() {}
57
-
58
- /**
59
- * 设置窗口配置(标题 / 尺寸),对标 tauri.conf.json 的 app.windows
60
- */
61
- public func window(config: WindowConfig): TauriApp {
62
- this.config = config
63
- return this
64
- }
65
-
66
- /**
67
- * 登记一个附加窗口(**试验性**:批次 3「多窗口 UI」的最小切片,先给出可验证的通路)
68
- *
69
- * 语义:本方法只**登记 + 装配宿主**,原生窗口在 run() / runUrl() 里统一创建——
70
- * 与默认窗口「构造时有宿主对象、start() 时才建窗口」是同一套节奏(WebViewHost 的契约)。
71
- * 因此必须在 run() / runUrl() 之前调用;登记后 `hostOf(label)` 立刻可用。
72
- *
73
- * 现在**只接内联 HTML**(URL 变体等真实需求出现再加,判据见架构文档 §5);
74
- * 空 label / 重复 label 在调用点直接抛异常(WindowRegistry.add 的约定,不延迟到 run)。
75
- *
76
- * 已知限制(**未解决**,架构文档 §7.5 的残余项):`onDestroy` 与对话框结果回调不带宿主 id,
77
- * 多窗口下分不清是哪个窗口关的;对话框仍是同一时刻一个(桥侧互斥)。本方法不解这两条。
78
- */
79
- public func addWindow(label: String, config: WindowConfig, html: String): TauriApp {
80
- if (this.started) {
81
- throw CommandException("addWindow 必须在 run() / runUrl() 之前调用(页面加载前窗口表要定稿)")
82
- }
83
- let host = createHost(label)
84
- host.setWindowConfig(config)
85
- // label 注入与默认窗口走同一条通道(document-start 预执行脚本,桥 JS 懒读,先后顺序无所谓)
86
- host.addInitScript("window.__CJ_TAURI_LABEL__ = ${JsonString(label)};")
87
- // 登记放在这里(而不是 run 里):重复 label / 空 label 当场报错,调用点即可见
88
- this.windows.add(WindowRecord(label, host, config))
89
- this.extraWindows.add(ExtraWindowSpec(label, host, html))
90
- return this
91
- }
92
-
93
- /**
94
- * 运行时打开开发者工具(等价于前端 invoke("system:devtools"))
95
- */
96
- public func openDevTools(): Unit {
97
- this.host.openDevTools()
98
- }
99
-
100
- /**
101
- * 宿主实例(插件的 setup() 取用)
102
- *
103
- * 需要窗口 / 原生能力的插件命令(原生对话框等)走它;纯仓颉插件(如 fs)用不到。
104
- * 宿主对象在构造 TauriApp 时就已存在,但**原生窗口要等 run()/runUrl() 才创建**——
105
- * 依赖窗口的操作必须在启动之后调用,实现方自己会给出可读的降级结果。
106
- */
107
- public func webviewHost(): WebViewHost {
108
- return this.host
109
- }
110
-
111
- /**
112
- * 按 label 取窗口宿主(AppHost 契约,对标 `app.get_webview_window(label)`);未注册返回 None
113
- *
114
- * 单窗口阶段只可能命中 "main";多窗口落地后这是插件拿到「别的窗口」的唯一入口。
115
- */
116
- public func hostOf(label: String): Option<WebViewHost> {
117
- match (this.windows.get(label)) {
118
- case Some(rec) => return Some(rec.host)
119
- case None =>
120
- // 默认窗口的注册表登记发生在 prepare()(那时窗口配置才定稿),所以 run() 之前
121
- // 查 "main" 会落空——但宿主对象在 TauriApp 构造时就已经存在。
122
- // 这里按 label 兜住,让「run() 之前 hostOf(label) 可用」对默认窗与 addWindow
123
- // 登记的窗一致:预置菜单 / 拖放策略都必须在 start() 之前调(RFC-菜单-托盘-拖放 §5.2),
124
- // 默认窗拿不到宿主就等于这条通路对最常见的用法直接不存在。
125
- if (label == DEFAULT_WINDOW_LABEL) {
126
- return Some(this.host)
127
- }
128
- return None
129
- }
130
- }
131
-
132
- /**
133
- * 退出应用(AppHost 契约,对标 Tauri 的 `app.exit()`):收掉所有窗口的事件循环,run() / runUrl() 随即返回
134
- *
135
- * 单窗口下与宿主自己的 `quit()` 等价;多窗口时这里是「先收各窗口的循环、再各自收尾」的唯一入口——
136
- * 所以命令处理器里退应用走它,不要直接调 `webviewHost().quit()`(那只是本窗口的低层原语)。
137
- */
138
- public func quit(): Unit {
139
- for (rec in this.windows.targets("")) {
140
- rec.host.quit()
141
- }
142
- }
143
-
144
- /**
145
- * 设置**默认窗口**的菜单栏(对标 Tauri 的 `Menu::set_as_app_menu` / `Window::set_menu`)
146
- *
147
- * 建议在 `run()` / `runUrl()` **之前**调用:预置类宿主能力要赶在页面加载前定稿
148
- * (RFC-002 §5.2)。之后调用也能用(宿主会把整棵树重建到 UI 线程上),只是页面上会看到
149
- * 菜单「晚一步」出现。
150
- *
151
- * 应用层会把模型留一份(见 `setMenuTo`),这样「只改某一维状态」的 API 才有
152
- * 「另一维现在是什么」可查——平台侧一次调用会同时写 enabled 与 checked。
153
- */
154
- public func setMenu(menu: MenuModel): TauriApp {
155
- return this.setMenuTo(DEFAULT_WINDOW_LABEL, menu)
156
- }
157
-
158
- /**
159
- * 设置**指定窗口**的菜单栏(多窗口)
160
- *
161
- * 同一个 `MenuModel` 实例给多个窗口时,两窗留存的「模型状态」是**同一个对象**——运行期改
162
- * 一个窗口的勾选会顺带改到另一个窗口的模型(平台上两份菜单仍是独立的)。要各自独立,
163
- * 就每个窗口各建一份模型。
164
- */
165
- public func setMenuTo(label: String, menu: MenuModel): TauriApp {
166
- match (this.hostOf(label)) {
167
- case Some(host) =>
168
- this.menus.add(label, menu)
169
- host.setMenu(menu.toWire())
170
- case None =>
171
- eprintln("[cj-tauri] setMenu: 未注册的窗口 label \"${label}\"(菜单未设置)")
172
- }
173
- return this
174
- }
175
-
176
- /**
177
- * 运行期改默认窗口某个菜单项的状态(enabled 与 checked 一起写)
178
- *
179
- * 只想改其中一维就用 `setMenuItemEnabled` / `setMenuItemChecked`——它们会拿模型里的
180
- * 另一维补齐,免得把用户刚点出来的勾选抹掉。
181
- */
182
- public func setMenuItemState(id: String, enabled: Bool, checked: Bool): TauriApp {
183
- return this.setMenuItemStateTo(DEFAULT_WINDOW_LABEL, id, enabled, checked)
184
- }
185
-
186
- /**
187
- * 设置默认窗口的拖放策略(RFC-002 §5;必须在 `start()` / `startUrl()` 之前调)
188
- *
189
- * 打开后页面经 `dragdrop:files` 事件收到拖入的文件路径数组(`paths: [...]`),
190
- * 该事件需在能力清单 `events` 里声明;不设 = 不注册 drop target(默认关)。
191
- * 平台没有该能力(如 Windows 桩阶段)时宿主只记日志,不报错——能力位经 `system:host` 如实上报。
192
- */
193
- public func setDragDrop(policy: DragDropPolicy): TauriApp {
194
- return this.setDragDropTo(DEFAULT_WINDOW_LABEL, policy)
195
- }
196
-
197
- /** 指定窗口版本(多窗口):语义同 `setDragDrop`,作用于 `label` 那个窗口 */
198
- public func setDragDropTo(label: String, policy: DragDropPolicy): TauriApp {
199
- match (this.hostOf(label)) {
200
- case Some(host) =>
201
- host.setDragDropPolicy(policy.enabled, policy.files)
202
- case None =>
203
- eprintln("[cj-tauri] setDragDrop: 未注册的窗口 label \"${label}\"(策略未设置)")
204
- }
205
- return this
206
- }
207
-
208
- /**
209
- * 注册**默认窗口**的全局快捷键(RFC-003 第 5 期)
210
- *
211
- * 全局 = 焦点不在本应用时也生效(Linux = X11 grab,能力位 `hotkey` 如实上报)。
212
- * 触发时页面收 `hotkey:press` 事件(载荷 `{"id":…}`,需在能力清单 `events` 声明);
213
- * 应用侧钩子挂 `onShellEvent`(信封 `kind == "hotkey"`)。运行期再调 = 整表替换。
214
- */
215
- public func setGlobalHotkeys(keys: HotkeyModel): TauriApp {
216
- return this.setGlobalHotkeysTo(DEFAULT_WINDOW_LABEL, keys)
217
- }
218
-
219
- /** 指定窗口版本(多窗口):语义同 `setGlobalHotkeys`,作用于 `label` 那个窗口 */
220
- public func setGlobalHotkeysTo(label: String, keys: HotkeyModel): TauriApp {
221
- match (this.hostOf(label)) {
222
- case Some(host) =>
223
- host.setGlobalHotkeys(keys.toWire())
224
- case None =>
225
- eprintln("[cj-tauri] setGlobalHotkeys: 未注册的窗口 label \"${label}\"(快捷键未设置)")
226
- }
227
- return this
228
- }
229
-
230
- /**
231
- * 设置**托盘**(RFC-002 §7.C;托盘是应用级资源,走默认窗口的宿主)
232
- *
233
- * 图标 + 提示 + 右键菜单一次给定;重复调用 = 整体重建,`TrayModel("")` = 移除。
234
- * 菜单项被点时页面收 `tray:click` 事件(载荷 `{"id":…}`,需在能力清单 `events` 声明);
235
- * 应用侧钩子挂 `onShellEvent`(信封 `kind == "tray"`)。平台不支持时桥如实打日志、托盘不出现。
236
- */
237
- public func setTray(tray: TrayModel): TauriApp {
238
- match (this.hostOf(DEFAULT_WINDOW_LABEL)) {
239
- case Some(host) =>
240
- host.setTray(tray.toWire())
241
- case None =>
242
- eprintln("[cj-tauri] setTray: 默认窗口未注册(托盘未设置)")
243
- }
244
- return this
245
- }
246
-
247
- /**
248
- * 开启日志落盘(RFC-003 第 5 期)
249
- *
250
- * 单文件产物是 Windows 的 GUI 子系统、双击时没有控制台,stderr 无处可去——开了它,C 桥与
251
- * 仓颉侧**两侧**的诊断都落到这一个文件(`system:host` 的 `logFile` 回读生效路径)。
252
- * **越早调越好**(宿主的装配期日志比 `run()` 还早):最晚在 `run()` / `runUrl()` 之前。
253
- * 超预算按份轮转(`log.1` … `log.keep`);不调用 = 不落盘,行为与之前完全一致。
254
- * `CJ_TAURI_LOG_MAX_KB` / `CJ_TAURI_LOG_KEEP` 可覆盖这里的预算——报障时让用户改环境变量,
255
- * 不必动代码。被拒(路径打不开 / 已激活)时桥打一行 stderr 并**保持不落盘**,这里不假装成功。
256
- */
257
- public func setLogFile(path: String): TauriApp {
258
- return this.setLogFile(path, CJ_LOG_DEFAULT_MAX_KB, CJ_LOG_DEFAULT_KEEP)
259
- }
260
-
261
- /** 显式给预算的版本(仓颉没有默认参数值,所以拆成两个重载) */
262
- public func setLogFile(path: String, maxKB: Int64, keep: Int64): TauriApp {
263
- let kb = logBudgetFromEnv("CJ_TAURI_LOG_MAX_KB", maxKB)
264
- let kp = logBudgetFromEnv("CJ_TAURI_LOG_KEEP", keep)
265
- let _ = enableLogFile(path, kb, kp) // 成败都由桥出声:成功落文件、被拒落 stderr
266
- return this
267
- }
268
-
269
- /**
270
- * 落到平台惯例目录的日志文件(`appName` 作文件名)
271
- *
272
- * Linux `$XDG_STATE_HOME/cj-tauri/<app>.log`(缺省 `~/.local/state/…`)、
273
- * Windows `%LOCALAPPDATA%\cj-tauri\logs\<app>.log`;`CJ_TAURI_LOG` 给了路径时优先用它——
274
- * 报障的人只需带一个环境变量启动,不必知道平台目录在哪(也不受应用是否设过名字影响)。
275
- */
276
- public func setLogToDefaultFile(appName: String): TauriApp {
277
- return this.setLogToDefaultFile(appName, CJ_LOG_DEFAULT_MAX_KB, CJ_LOG_DEFAULT_KEEP)
278
- }
279
-
280
- /** 显式给预算的版本(同上,重载而非默认参数) */
281
- public func setLogToDefaultFile(appName: String, maxKB: Int64, keep: Int64): TauriApp {
282
- return this.setLogFile(logPathOrDefault(logEnvOr("CJ_TAURI_LOG", ""), appName), maxKB, keep)
283
- }
284
-
285
- /**
286
- * 显示 / 隐藏**默认窗口**(对标 Tauri 2 的 `window.show()` / `window.hide()`)
287
- *
288
- * 典型用途是「最小化到托盘」的还原路径:托盘菜单项或全局快捷键触发时 `setWindowVisible(true)`。
289
- * `run()` / `runUrl()` 之前调用同样有效(宿主记下,建窗时定稿)。
290
- */
291
- public func setWindowVisible(visible: Bool): TauriApp {
292
- return this.setWindowVisibleTo(DEFAULT_WINDOW_LABEL, visible)
293
- }
294
-
295
- /** 指定窗口版本(多窗口):语义同 `setWindowVisible`,作用于 `label` 那个窗口 */
296
- public func setWindowVisibleTo(label: String, visible: Bool): TauriApp {
297
- match (this.hostOf(label)) {
298
- case Some(host) =>
299
- host.setWindowVisible(visible)
300
- case None =>
301
- eprintln("[cj-tauri] setWindowVisible: 未注册的窗口 label \"${label}\"(窗口未改可见性)")
302
- }
303
- return this
304
- }
305
-
306
- /** 指定窗口版本(多窗口):语义同 `setMenuItemState`,作用于 `label` 那个窗口 */
307
- public func setMenuItemStateTo(label: String, id: String, enabled: Bool, checked: Bool): TauriApp {
308
- this.applyMenuState(label, id, enabled, checked)
309
- return this
310
- }
311
-
312
- /**
313
- * 只改默认窗口某个菜单项的**启用**状态;勾选态取自应用层留存的模型
314
- *
315
- * 模型里没有这个 id 时抛 `CommandException`(**不**静默拿 false 顶上):静默写会抹掉
316
- * 用户看到的勾选,报错至少说得清「哪个 id 在哪个窗口找不到」。
317
- */
318
- public func setMenuItemEnabled(id: String, enabled: Bool): TauriApp {
319
- return this.setMenuItemEnabledTo(DEFAULT_WINDOW_LABEL, id, enabled)
320
- }
321
-
322
- /** 指定窗口版本(多窗口):语义同 `setMenuItemEnabled` */
323
- public func setMenuItemEnabledTo(label: String, id: String, enabled: Bool): TauriApp {
324
- let cur = this.requireMenuItem(label, id)
325
- this.applyMenuState(label, id, enabled, cur.checked)
326
- return this
327
- }
328
-
329
- /** 只改默认窗口某个菜单项的**勾选**状态;启用态取自应用层留存的模型 */
330
- public func setMenuItemChecked(id: String, checked: Bool): TauriApp {
331
- return this.setMenuItemCheckedTo(DEFAULT_WINDOW_LABEL, id, checked)
332
- }
333
-
334
- /** 指定窗口版本(多窗口):语义同 `setMenuItemChecked` */
335
- public func setMenuItemCheckedTo(label: String, id: String, checked: Bool): TauriApp {
336
- let cur = this.requireMenuItem(label, id)
337
- this.applyMenuState(label, id, cur.enabled, checked)
338
- return this
339
- }
340
-
341
- /**
342
- * 取「另一维状态」的当前值(从应用层留存的模型里查)
343
- *
344
- * 模型不存在通常意味着应用用 `WebViewHost.setMenu()` 直设了菜单、绕过了应用层——
345
- * 报错里点名这条出路,比让人对着「状态怎么被抹了」猜要好。
346
- */
347
- private func requireMenuItem(label: String, id: String): MenuItem {
348
- match (this.menus.get(label)) {
349
- case None =>
350
- throw CommandException(
351
- "菜单模型不存在:窗口 \"${label}\" 的菜单是用 app.setMenu() 设的吗?" +
352
- "(用 WebViewHost.setMenu 直设的菜单应用层没有模型,改单项状态请用 setMenuItemStateTo)"
353
- )
354
- case Some(model) =>
355
- match (model.findById(id)) {
356
- case Some(item) => return item
357
- case None => throw CommandException("菜单项不存在:\"${id}\"(窗口 \"${label}\")")
358
- }
359
- }
360
- }
361
-
362
- /**
363
- * 写菜单项状态:平台侧 + 应用层模型同步(模型里的 item 是可变对象,见 src/menu.cj)
364
- *
365
- * 模型不跟上会出错:勾选项在平台上一被点就自己翻了 checked,模型若还记旧值,
366
- * 下一次「只改启用」就会拿旧值去写,把刚点出来的勾选抹回去。
367
- * 模型缺失只提示、不拦——平台侧照写(与 `WebViewHost.setMenuItemState` 的「找不到静默忽略」一致)。
368
- */
369
- private func applyMenuState(label: String, id: String, enabled: Bool, checked: Bool): Unit {
370
- match (this.menus.get(label)) {
371
- case Some(model) =>
372
- match (model.findById(id)) {
373
- case Some(item) =>
374
- item.enabled = enabled
375
- item.checked = checked
376
- case None => eprintln("[cj-tauri] setMenuItemState: 模型里没有 \"${id}\"(只改平台侧)")
377
- }
378
- case None => eprintln("[cj-tauri] setMenuItemState: 窗口 \"${label}\" 无应用层菜单模型(只改平台侧)")
379
- }
380
- match (this.hostOf(label)) {
381
- case Some(host) => host.setMenuItemState(id, enabled, checked)
382
- case None => eprintln("[cj-tauri] setMenuItemState: 未注册的窗口 label \"${label}\"(未生效)")
383
- }
384
- }
385
-
386
- /**
387
- * 把 shell 事件里的**平台真值**回写到应用层模型(kind=menu、id 命中时)
388
- *
389
- * 为什么必须做:勾选项在平台上一被点就自己翻了 checked,模型还记着旧值——之后一次
390
- * 「只改启用」拿着旧值去写,就把用户刚点出来的勾选抹回去了。
391
- * 模型 / 项不存在时静默:事件也可能来自没用应用层菜单的窗口(宿主层直设菜单的老路径)。
392
- */
393
- private func syncMenuStateFromShell(label: String, id: String, enabled: Bool, checked: Bool): Unit {
394
- match (this.menus.get(label)) {
395
- case Some(model) =>
396
- match (model.findById(id)) {
397
- case Some(item) =>
398
- item.enabled = enabled
399
- item.checked = checked
400
- case None => ()
401
- }
402
- case None => ()
403
- }
404
- }
405
-
406
- /**
407
- * 注册命令处理器
408
- */
409
- public func register(cmd: String, handler: CommandHandler): TauriApp {
410
- this.hub.register(cmd, handler)
411
- return this
412
- }
413
-
414
- /**
415
- * 装配插件(对标 Tauri v2 的 .plugin(...))
416
- *
417
- * 插件的命令会自动加上 "<插件名>:" 前缀后注册;事件登记、前端 JS 与未授权提示
418
- * 都在 run()/runUrl() 的 prepare() 阶段统一收尾(那时能力清单才加载完)。
419
- * 必须在 run()/runUrl() 之前调用——页面加载前命令表要定稿。
420
- */
421
- public func plugin(p: Plugin): TauriApp {
422
- if (!isValidPluginName(p.name())) {
423
- throw Exception("invalid plugin name: \"${p.name()}\"(要求非空、且不含 ':')")
424
- }
425
- this.plugins.add(p)
426
- return this
427
- }
428
-
429
- /**
430
- * 从 JSON 添加 capability 清单
431
- */
432
- public func addCapabilityJson(json: JsonValue): TauriApp {
433
- this.capabilities.addFromJson(json)
434
- return this
435
- }
436
-
437
- /**
438
- * 从目录加载所有 *.json capability 清单(对标 Tauri 的 capabilities/ 目录)
439
- * 与 run() 的自动扫描等价,只是作用在指定目录上。
440
- */
441
- public func loadCapabilities(dir: String): TauriApp {
442
- let n = loadCapabilitiesFromDir(dir, this.capabilities)
443
- eprintln("[cj-tauri] 已从 ${dir} 加载 ${n} 个 capability 清单")
444
- return this
445
- }
446
-
447
- /**
448
- * 运行期给某插件的 scope 追加一条白名单模式(**不落盘**,重启即失效):插件名如 `"http"`,
449
- * 模式写法同 `src/scope.cj`(主机名 / 路径,支持 `*` `**` `?` 与变量)。
450
- *
451
- * 调用时机是硬约束:**只应在应用已取得用户明确确认之后调**——框架替你做不了确认,它不理解
452
- * 你的 UI 语义。典型落法是先在原生确认框里问到「是」
453
- * (`host.showMessageDialog("confirm", …, "允许 <主机> 出网?")` 返回 true)再调本方法;
454
- * 页面自己点不了原生对话框,所以「确认」这一步天然挡得住被注入的页面自行加白。
455
- * 另外,清单 `scopes.<插件>.deny` 命中即拒,这里加白掀不掉 deny(判定顺序仍是先 deny 后 allow)。
456
- *
457
- * 需要重启仍生效就在启动时把存过的模式重新叠一遍(持久化与存放位置由应用自己决定)。
458
- *
459
- * 返回 false = 这条模式**没被采纳**(空串、或含 `*` / `?` 与制表/换行这类脏字符):加白的语义是
460
- * 「往白名单里加一条具体值」,带通配透传进来等于全放行,所以注册中心当场拒。调用方应把 false
461
- * 当成「没放行」如实回给用户,别写成成功。
462
- */
463
- public func allowScopePattern(plugin: String, pattern: String): Bool {
464
- return this.capabilities.addRuntimeScopeAllow(plugin, pattern)
465
- }
466
-
467
- /**
468
- * 某插件的 scope 此刻是否放行某个值(口径与判定完全一致:清单 + 运行期叠加,先 deny 后 allow)。
469
- * 没写 scope 的插件恒为 true(旧行为:全量放行)。
470
- */
471
- public func isScopeAllowed(plugin: String, value: String): Bool {
472
- return this.capabilities.scopeAllowsValue(plugin, value)
473
- }
474
-
475
- /**
476
- * 直接添加 capability
477
- */
478
- public func addCapability(cap: Capability): TauriApp {
479
- this.capabilities.add(cap)
480
- return this
481
- }
482
-
483
- /**
484
- * 向所有窗口推送事件(对标 Tauri 的 `emit`;经 capability 校验,未声明的事件不投递)
485
- */
486
- public func emit(event: String, payload: JsonValue): Unit {
487
- this.hub.emitAll(event, payload)
488
- }
489
-
490
- /**
491
- * 向所有窗口推送「只保最新」的事件:同一帧内同名事件只投最后一次 payload,中间值丢弃
492
- *
493
- * 给进度 / 动画 / 滚动位置这类高频且「中间值没有意义」的事件用;需要每条都到达的场合继续 `emit`。
494
- * 能力校验与 `emit` 共用一份 `events` 白名单(不额外放行)。与 Tauri v2 的差异:它核心不做合并,
495
- * 高频事件要么应用层自己限流、要么改用 `Channel`——我们的选择是给一条显式的 API,
496
- * 理由与源码级对照见 docs/IPC-通信机制.md §6 第 5 项。
497
- */
498
- public func emitLatest(event: String, payload: JsonValue): Unit {
499
- this.hub.emitLatest(event, payload, "")
500
- }
501
-
502
- /**
503
- * 向指定窗口推送事件(对标 Tauri 的 `app.emit_to(label, event, payload)`)
504
- *
505
- * 多窗口的定向投递入口。label 未注册时**什么都不投**并打一条 stderr——不回落成广播:
506
- * 写错 label 的表现就该是「谁都没收到」,静默变广播比丢事件更难查(见 WindowRegistry.targets)。
507
- * 空串 = 广播,与 `emit` 等价。
508
- */
509
- public func emitToWindow(event: String, payload: JsonValue, window: String): Unit {
510
- this.hub.emitToWindow(event, payload, window)
511
- }
512
-
513
- /**
514
- * 注册后端事件监听器:前端 `emit(event, payload)` 会调用到这里
515
- * (对标 Tauri Rust 侧的 `app.listen(...)`)
516
- *
517
- * 与 `emit` 是相反方向:`emit` 是后端 → 前端,本方法收前端 → 后端。
518
- * 后端自己 emit 出去的事件**不**回流到监听器(否则「收到就再广播」会自激)。
519
- * 装配期调用(`run()` / `runUrl()` 之前,或插件的 setup() 里);注册本身不需要能力授权——
520
- * 权限卡在前端那侧(emit 的事件必须在 capability 的 events 里声明)。
521
- */
522
- public func listenEvent(event: String, handler: (JsonValue) -> Unit): TauriApp {
523
- this.hub.listenEvent(event, handler)
524
- return this
525
- }
526
-
527
- /**
528
- * 读取 CLI 注入的前端 dev server 地址(CJ_TAURI_DEV_URL)
529
- *
530
- * 模板可以直接用:
531
- * if (let Some(url) <- TauriApp.devUrl()) { app.runUrl(url) }
532
- * else { app.run(html) }
533
- */
534
- public static func devUrl(): Option<String> {
535
- if (let Some(v) <- getVariable("CJ_TAURI_DEV_URL")) {
536
- if (v.size > 0) {
537
- return Some(v)
538
- }
539
- }
540
- return None
541
- }
542
-
543
- /**
544
- * 启动应用:装配 IPC → 启动 WebView 宿主 → 阻塞运行
545
- */
546
- public func run(html: String): Unit {
547
- this.prepare()
548
- if (this.dumpManifestIfRequested()) {
549
- return
550
- }
551
- this.registerPluginScripts()
552
- this.registerDefaultWindow()
553
- this.host.start(html)
554
- this.startExtraWindows()
555
- this.waitForExit()
556
- // 事件循环已退出:释放全部宿主编(幂等;句柄由 C 侧回收,见 docs/架构演进-多平台与多窗口.md §6.1)
557
- this.disposeWindows()
558
- }
559
-
560
- /**
561
- * 按 URL 启动应用(http(s):// 或 file://)
562
- *
563
- * 与 run(html) 平行:同一段装配,只是页面来源换成 URL。有了真实来源,
564
- * 相对路径资源、动态 import、前端路由才成立(file:// 页面禁止 fetch/XHR)。
565
- * 开发时可配合 TauriApp.devUrl() 指向前端 dev server(Vite 的 HMR 就这样接进来)。
566
- */
567
- public func runUrl(url: String): Unit {
568
- this.prepare()
569
- if (this.dumpManifestIfRequested()) {
570
- return
571
- }
572
- this.registerPluginScripts()
573
- this.registerDefaultWindow()
574
- this.host.startUrl(url)
575
- this.startExtraWindows()
576
- this.waitForExit()
577
- this.disposeWindows()
578
- }
579
-
580
- /**
581
- * 清单导出(RFC §8 第 7 条的遗留项:让 `cj-tauri info` 消费 describePluginsJson)
582
- *
583
- * 设了 CJ_TAURI_MANIFEST 时,装配一完成就把清单打到 stdout 并返回 true——**不创建窗口**。
584
- * 于是 CLI / CI 在既没有 X 显示、也没装 WebView2 Runtime 的机器上照样能拿到真实的装配结果。
585
- * 为什么只能这样拿:CLI 进程里没有应用自己的 TauriApp,插件是应用 main() 里装配的,
586
- * 所以「不启动窗口,但把 main() 跑到装配完成」是唯一能取到真值的路子(详见 manifestDumpMode)。
587
- */
588
- private func dumpManifestIfRequested(): Bool {
589
- match (manifestDumpMode()) {
590
- case Some(mode) =>
591
- // flush 显式给:CLI 用管道抓 stdout,不能指望进程退出时的缓冲冲刷
592
- print(renderPluginManifest(this.plugins, mode), flush: true)
593
- eprintln("[cj-tauri] 已导出插件清单(mode=${mode}):本轮未创建窗口")
594
- return true
595
- case None => return false
596
- }
597
- }
598
-
599
- /**
600
- * 把插件 jsShim 注册到宿主的「预执行脚本」通道
601
- *
602
- * 必须在 start / startUrl 之前调用:宿主创建 WebView 时一次性交给 C 桥,
603
- * 桥以 document-start 注入(早于页面脚本,两平台语义一致)。
604
- * HTML 与 URL 两种来源共用这条通道——v1 那个「runUrl 模式下 shim 注入不进去」的局限就此消掉(RFC §5.4),
605
- * 所以这里不再需要对使用者的额外提醒。
606
- */
607
- private func registerPluginScripts(): Unit {
608
- for (js in this.pluginScripts) {
609
- this.host.addInitScript(js)
610
- }
611
- if (this.pluginScripts.size > 0) {
612
- eprintln("[cj-tauri] 插件 JS 已注册为预执行脚本:${this.pluginScripts.size} 段")
613
- }
614
- }
615
-
616
- /**
617
- * 重新加载当前页面(整页 reload,等价于浏览器刷新)
618
- */
619
- public func reload(): Unit {
620
- this.host.reload()
621
- }
622
-
623
- /**
624
- * 启动前的装配:内置命令、capability、宿主回调与 sink
625
- */
626
- private func prepare(): Unit {
627
- if (this.started) {
628
- throw CommandException("app already started")
629
- }
630
- this.started = true
631
-
632
- // 环境变量兜底(RFC-003 第 5 期):应用没显式开日志时,`CJ_TAURI_LOG` 仍能把它开起来——
633
- // 已经分发出去的 exe 改不了代码,报障时靠这一个变量拿凭证。放在装配的最早处,
634
- // 让宿主与插件的启动期日志都赶得上;不设这个变量 = 一行不动,行为与之前完全一致。
635
- if (currentLogFilePath().size == 0) {
636
- let envPath = logEnvOr("CJ_TAURI_LOG", "")
637
- if (envPath.size > 0) {
638
- let _ = this.setLogFile(envPath)
639
- }
640
- }
641
-
642
- // 注入内置系统命令
643
- this.hub.register("system:version", SystemCommands())
644
- this.hub.register("system:ping", SystemCommands())
645
- this.hub.register("system:echo", SystemCommands())
646
- this.hub.register("system:devtools", SystemCommands())
647
- this.hub.register("system:host", SystemCommands())
648
-
649
- // 未显式挂载 capability 时,自动扫描 capabilities/ 目录(对标 Tauri)
650
- autoLoadCapabilities(this.capabilities)
651
-
652
- this.hub.setCapabilities(this.capabilities)
653
-
654
- // 插件装配:注册命令(带 "<插件名>:" 前缀)→ 注册命名权限集 → setup → 汇总前端 JS → 未授权提示。
655
- // 顺序有意放在能力清单加载之后:提示要反映真实的放行情况。
656
- for (p in this.plugins) {
657
- registerPluginCommands(this.hub, p)
658
- registerPluginPermissionSets(this.capabilities, p)
659
- p.setup(this)
660
- }
661
- this.pluginScripts = pluginInitScripts(this.plugins)
662
- if (this.plugins.size > 0) {
663
- eprintln("[cj-tauri] 已装配 ${this.plugins.size} 个插件:")
664
- eprintln(describePlugins(this.plugins))
665
- let sets = describePermissionSets(this.plugins)
666
- if (sets.size > 0) {
667
- eprintln("[cj-tauri] 命名权限集(在 capabilities 的 permissions 里按集名引用):")
668
- eprintln(sets)
669
- }
670
- warnUnauthorizedPlugins(this.plugins, this.capabilities)
671
- }
672
- // 集注册完才可能判定「清单引用的集有没有插件提供」,故放在插件装配之后
673
- warnUnknownPermissionSets(this.capabilities)
674
- // 路径 scope 的缺省行为提示(RFC-003 第 4 期):没为吃 scope 的插件写规则 = 全量放行,如实告知
675
- warnUnscopedPlugins(this.plugins, this.capabilities)
676
-
677
- // 装配:宿主消息 → IPC hub;hub 输出 → 宿主 JS
678
- this.wireHostCallbacks(this.host)
679
- this.hub.jsSink = { json, window =>
680
- this.deliverJs(json, window)
681
- }
682
- this.hub.devToolsSink = { =>
683
- this.host.openDevTools()
684
- }
685
- // system:host 的取值通道:按发起命令的窗口 label 取该宿主的能力位
686
- this.hub.hostCapabilitiesSink = { label => this.windowCapabilities(label) }
687
-
688
- this.host.setWindowConfig(this.config)
689
- // 最小化到托盘同属「建窗时定稿」的窗口行为(窗口过程要提前知道这条策略),
690
- // 所以与窗口配置一起在 start() 之前交给宿主——能力位不含托盘时平台会拒绝并出声
691
- this.host.setMinimizeToTray(this.config.minimizeToTray)
692
- }
693
-
694
- /**
695
- * 某个窗口的宿主能力位(`system:host` 的取值实现)
696
- *
697
- * 未登记的 label 给 0 **并出声**:写错 label 的表现会是「能力位全 false」,看起来像
698
- * 「这个平台不支持菜单」,比一条日志难查得多。
699
- */
700
- private func windowCapabilities(label: String): Int32 {
701
- match (this.hostOf(label)) {
702
- case Some(h) => return h.hostCapabilities()
703
- case None =>
704
- eprintln("[cj-tauri] system:host: 未注册的窗口 label \"${label}\"(能力位按 0 返回)")
705
- return 0
706
- }
707
- }
708
-
709
- /**
710
- * 把默认窗口登记进注册表(label = "main")并给它注入 label
711
- *
712
- * 必须在 start() / startUrl() 之前:① 页面一加载就可能发 invoke / emit,回执要按 label 找到窗口;
713
- * ② label 走宿主的预执行脚本通道(document-start)注入,晚于 start 就没机会了。
714
- * 桥 JS 是**懒读** `window.__CJ_TAURI_LABEL__` 的(首次用到才取、取到即缓存),
715
- * 所以注入脚本与桥 JS 的先后顺序无所谓——两平台的注入时机本就不同。
716
- * 多窗口落地前只登记这一条;将来增补窗口走同一个 add() + 注入,路由侧不用改。
717
- */
718
- private func registerDefaultWindow(): Unit {
719
- let label = DEFAULT_WINDOW_LABEL
720
- this.windows.add(WindowRecord(label, this.host, this.config))
721
- // JSON 字符串字面量与 JS 字符串字面量同构(双引号、同样的转义),拼进赋值语句是安全的
722
- this.host.addInitScript("window.__CJ_TAURI_LABEL__ = ${JsonString(label)};")
723
- // shell 事件分发要在 start 之前装(菜单点击随时可能来,装晚了就漏)
724
- this.wireShellEvents(this.host, label)
725
- }
726
-
727
- /**
728
- * 把一段回执 / 事件投递给目标窗口(批次 2 的 label 路由)
729
- *
730
- * @param window 目标窗口 label;空串 = 广播(按登记顺序投给每个窗口)
731
- *
732
- * 未注册的 label 打 stderr 而非静默丢弃:`emitToWindow` 写错 label 的表现是「前端什么都没
733
- * 收到」,只剩这条日志能定位。空注册表(清单导出模式等)到不了这里——那时没有页面能发消息。
734
- */
735
- private func deliverJs(json: String, window: String): Unit {
736
- // 直派:JSON 本身就是合法 JS 对象字面量,直接嵌进 eval 交给桥的 _dispatch。
737
- // 不走 window.postMessage —— 那样每条响应要多一次结构化克隆 + 一次 message 事件派发,
738
- // 而且那个监听器是全页面(含 iframe / opener)都能投递的伪造入口。
739
- let js = "window.__CJ_TAURI__._dispatch(${json});"
740
- let targets = this.windows.targets(window)
741
- if (targets.size == 0 && window.size > 0) {
742
- eprintln("[cj-tauri] 未注册的窗口 label:\"${window}\"(回执 / 事件未投递)")
743
- return
744
- }
745
- for (rec in targets) {
746
- rec.host.runJs(js)
747
- }
748
- }
749
-
750
- /**
751
- * 阻塞等待:宿主事件循环退出(窗口关闭 / quit)后返回
752
- *
753
- * 公开是 AppHost 契约要求的(插件等不必自己写轮询);内部按宿主自己的节奏收尾。
754
- */
755
- public func waitForExit(): Unit {
756
- var spins: Int64 = 0
757
- while (!this.allWindowsExited() && spins < 360_000) { // 最长 ~1 小时(10ms 步进)
758
- sleep(Duration.millisecond * 10)
759
- spins += 1
760
- }
761
- }
762
-
763
- /**
764
- * 全部已登记窗口的循环是否都已退出
765
- *
766
- * 多窗口的「退出」是**每个窗口都收工**:主窗关了而副窗还开着时,应用不该返回
767
- * (对标 Tauri:`app.exit()` 收掉全部窗口)。注册表为空(清单导出模式等)时回落到
768
- * 默认宿主,与单窗口时代的行为逐字等价。
769
- */
770
- private func allWindowsExited(): Bool {
771
- let recs = this.windows.targets("")
772
- if (recs.size == 0) {
773
- return this.host.shouldQuit()
774
- }
775
- for (rec in recs) {
776
- if (!rec.host.shouldQuit()) {
777
- return false
778
- }
779
- }
780
- return true
781
- }
782
-
783
- /**
784
- * 释放全部宿主编(幂等,见 WebViewHost.dispose 的契约)
785
- */
786
- private func disposeWindows(): Unit {
787
- for (rec in this.windows.targets("")) {
788
- rec.host.dispose()
789
- }
790
- }
791
-
792
- /**
793
- * 把一个宿主挂上应用级回调
794
- *
795
- * 多窗口下每个宿主都挂**同一只**派发器:报文自带发起窗口的 label(架构文档 §7.3),
796
- * 路由不需要按句柄分表——所以这里传的是同一个闭包,不是每窗口一份。
797
- */
798
- /**
799
- * 应用层的 shell 事件钩子(对标 Tauri 的 `on_menu_event`)
800
- *
801
- * 回调参数是 `(信封 JSON, 发生事件的窗口 label)`。给原始 JSON 而不是解析后的对象:钩子多半
802
- * 是应用自己写的诊断 / 日志(探针就靠它断言),原样透传最不容易丢信息。label 是必需的——
803
- * 钩子是应用级的,一份回调要能分辨事件来自哪个窗口。
804
- *
805
- * 与前端事件**两条路都走**:先按注册顺序跑钩子(应用侧决策,例如改菜单项状态),再投前端
806
- * 事件(页面自己 `listen("menu:click", …)`)。钩子里抛异常只留一行 stderr,不挡后面的钩子
807
- * 与前端事件。
808
- */
809
- public func onShellEvent(handler: (String, String) -> Unit): TauriApp {
810
- this.shellHooks.add(handler)
811
- return this
812
- }
813
-
814
- /**
815
- * 装配本窗口的 shell 事件分发(应用层是唯一安装者)
816
- *
817
- * 必须在宿主 `start()` / `startUrl()` **之前**装:窗口一建出来菜单点击就可能来,装晚了会漏。
818
- * 宿主层的 `setShellHandler` 是**可变槽位**(后装覆盖先装),所以应用要处理 shell 事件请用
819
- * `onShellEvent()`(应用级钩子,可叠加多条),不要和框架抢宿主槽位——抢赢的那个会让框架的
820
- * 前端事件分发失效。
821
- */
822
- private func wireShellEvents(host: WebViewHost, label: String): Unit {
823
- host.setShellHandler({ json => this.dispatchShellEvent(label, json) })
824
- }
825
-
826
- /**
827
- * shell 事件 → 应用层钩子 + 前端事件(按窗口投递)
828
- *
829
- * 路由靠**闭包捕获的 label**:C 侧已按宿主句柄把事件分给了本窗口的回调(见 host.cj 的
830
- * `ShellRoute`),这里再按 label 投前端事件,多窗口下不会串。事件放不放行由 capability 清单
831
- * 决定——没在清单里声明 `menu:click` 就是静默丢弃(最小权限不破)。
832
- *
833
- * 本函数跑在**宿主 UI 线程**上(C 桥的 shell 回调就在那儿),所以中途不碰 GTK / WebKit 之外
834
- * 的东西:投递走 `emitToWindow` → `jsSink`,与其它事件同一条路。
835
- */
836
- private func dispatchShellEvent(label: String, json: String): Unit {
837
- for (hook in this.shellHooks) {
838
- try {
839
- hook(json, label)
840
- } catch (e: Exception) {
841
- eprintln("[cj-tauri] shell hook 抛出异常(已忽略):${e}")
842
- }
843
- }
844
- match (parseShellEnvelope(json)) {
845
- case None => eprintln("[cj-tauri] shell event 信封解析失败(已丢弃):${json}")
846
- case Some(obj) =>
847
- var kind = ""
848
- if (let Some(k) <- shellString(obj, "kind")) {
849
- kind = k
850
- }
851
- match (shellEventNameFor(kind)) {
852
- case None => eprintln("[cj-tauri] shell event: 未知 kind \"${kind}\"(未投递):${json}")
853
- case Some(event) =>
854
- if (kind == "menu") {
855
- this.syncMenuStateFromShellObj(label, obj)
856
- }
857
- try {
858
- this.hub.emitToWindow(event, obj, label)
859
- } catch (e: Exception) {
860
- eprintln("[cj-tauri] shell event 投递失败(已忽略):${e}")
861
- }
862
- }
863
- }
864
- }
865
-
866
- /**
867
- * 菜单事件里的平台真值 → 应用层模型:id / enabled / checked 三者齐了才写
868
- *
869
- * 缺字段不拿默认值顶(见 shellBool 的注释)——这条路径写错的表现是「勾选莫名回退」,
870
- * 比少同步一次难查得多。
871
- */
872
- private func syncMenuStateFromShellObj(label: String, obj: JsonObject): Unit {
873
- if (let Some(id) <- shellString(obj, "id")) {
874
- if (let Some(enabled) <- shellBool(obj, "enabled")) {
875
- if (let Some(checked) <- shellBool(obj, "checked")) {
876
- this.syncMenuStateFromShell(label, id, enabled, checked)
877
- }
878
- }
879
- }
880
- }
881
-
882
- private func wireHostCallbacks(host: WebViewHost): Unit {
883
- host.setMessageHandler({ raw =>
884
- this.hub.handleRawMessage(raw)
885
- })
886
- }
887
-
888
- /**
889
- * 启动 addWindow() 登记过的附加窗口
890
- *
891
- * 放在默认窗口 start() 之后:主窗先出现,附加窗口紧随其后(顺序只影响观感与探针取证节奏)。
892
- * 每个附加窗口在启动前挂上应用级回调——与默认窗口同一只派发器。
893
- */
894
- private func startExtraWindows(): Unit {
895
- for (w in this.extraWindows) {
896
- this.wireHostCallbacks(w.host)
897
- // shell 事件分发要在 start 之前装(菜单点击随时可能来,装晚了就漏)
898
- this.wireShellEvents(w.host, w.label)
899
- w.host.start(w.html)
900
- eprintln("[cj-tauri] 附加窗口已启动:label=\"${w.label}\"")
901
- }
902
- }
903
- }
904
-
905
- /**
906
- * 清单导出模式(环境变量 CJ_TAURI_MANIFEST)
907
- *
908
- * 取值:`json` → 机器可读 JSON(`describePluginsJson`);其它非空值 → 人类可读表(`describePlugins`);
909
- * 未设置(或空串)→ None,即正常启动窗口。
910
- *
911
- * 为什么走环境变量,而不是给 TauriApp 加个方法:清单要**装配完成之后**才有值,而装配发生在
912
- * run() / runUrl() 内部——加方法就得让每个应用的 main() 多写一个分支,既有应用全都要改。
913
- * 环境变量让既有应用一行不动就能被 CLI 取出清单(就是 `cj-tauri info` 拿插件清单的通道)。
914
- */
915
- func manifestDumpMode(): Option<String> {
916
- if (let Some(v) <- getVariable("CJ_TAURI_MANIFEST")) {
917
- if (v.size == 0) {
918
- return None
919
- }
920
- if (v == "json") {
921
- return Some("json")
922
- }
923
- return Some("text")
924
- }
925
- return None
926
- }
927
-
928
- /**
929
- * 渲染清单文本:`json` 模式给机器(工具 / CI 直接解析),其余给人(启动日志里那份表的同款形态)。
930
- * 抽成纯函数是为了单测能直接断言,不必真去跑一次宿主。
931
- */
932
- func renderPluginManifest(plugins: ArrayList<Plugin>, mode: String): String {
933
- if (mode == "json") {
934
- return describePluginsJson(plugins).toJsonString()
935
- }
936
- if (plugins.size == 0) {
937
- return "(未装配插件)\n"
938
- }
939
- return describePlugins(plugins)
940
- }
1
+ package cjTauri
2
+
3
+ import std.collection.*
4
+ import std.env.*
5
+ import stdx.encoding.json.*
6
+ import cjTauri.platform.*
7
+
8
+ /**
9
+ * 附加窗口的登记项(试验性 API `TauriApp.addWindow` 的装配状态)
10
+ *
11
+ * 为什么宿主在这里就建好、html 也先留着:窗口表要在 run() 之前定稿(`hostOf` 当场可用),
12
+ * 而原生窗口要等 run() 才创建——两者分开存,正好对上宿主自己的两段式节奏
13
+ * (宿主对象构造时就有、原生窗口 start() 时才建)。
14
+ */
15
+ class ExtraWindowSpec {
16
+ let label: String
17
+ let host: WebViewHost
18
+ let html: String
19
+
20
+ init(label: String, host: WebViewHost, html: String) {
21
+ this.label = label
22
+ this.host = host
23
+ this.html = html
24
+ }
25
+ }
26
+
27
+ /**
28
+ * TauriApp 装配(对标 tauri::Builder / App)
29
+ *
30
+ * 用法:
31
+ * let app = TauriApp()
32
+ * app.register("greet", GreetCommand())
33
+ * app.addCapabilityJson(...) // 或 addCapability(cap)
34
+ * app.run(html) // 启动宿主并阻塞
35
+ *
36
+ * 多窗口(试验性,批次 3 的最小切片):`addWindow(label, config, html)` 登记附加窗口,
37
+ * run() / runUrl() 统一启动;事件按 label 路由(`emitToWindow` / `hostOf`)。
38
+ *
39
+ * **本文件只放「应用是什么」的声明与装配 API**(字段、构造、注册命令 / 插件 / 能力、窗口与宿主查询)。
40
+ * 其余同职责的分片(都是 `extend TauriApp`,包边界与对外 API 完全不变):
41
+ * - `src/app_run.cj` —— 启动 / 退出 / 清单导出(run 三入口、prepare、事件循环等待)
42
+ * - `src/app_events.cj` —— 事件收发与 shell 事件分发(emit 三兄弟、listenEvent、deliverJs)
43
+ * - `src/app_host_ops.cj` —— 宿主能力预置(菜单 / 托盘 / 快捷键 / 拖放 / 可见性 / 日志)
44
+ *
45
+ * 拆分的代价(有意接受):仓颉的 `extend` 碰不到 `private` 成员,所以**被跨文件使用的字段与
46
+ * 少数内部方法在这里是包内可见**(不写修饰符)而不是 `private`。对包外完全不可见——
47
+ * 子包(`cjTauri.plugins.*` / `cjTauri.tests`)只认 `public`,应用侧看到的 API 一行未变。
48
+ */
49
+ public class TauriApp <: AppHost {
50
+ // 以下字段不写 private:同包分片(app_run / app_events / app_host_ops)经 extend 访问它们,
51
+ // 而 extend 看不到 private 成员。包可见已是最小代价,见类注释。
52
+ var hub: IpcHub = IpcHub()
53
+ var capabilities: CapabilityRegistry = CapabilityRegistry()
54
+ // 批次 1(RFC-002 §5.4):注册表优先、内置实现兜底
55
+ var host: WebViewHost = resolveHost(DEFAULT_WINDOW_LABEL)
56
+ // Asset 页面的解析器(批次 1 可注入;缺省按 cwd 相对路径读盘,清单派生在批次 2)
57
+ var assetResolver: Option<AssetResolver> = None
58
+ var config: WindowConfig = WindowConfig()
59
+ // 窗口注册表(批次 2):单窗口阶段只有一条 "main",多窗口时是「label → 宿主」的唯一权威来源
60
+ var windows: WindowRegistry = WindowRegistry()
61
+ // 附加窗口(批次 3 多窗口 UI 的最小切片,试验性):addWindow() 登记、run() / runUrl() 统一启动
62
+ var extraWindows: ArrayList<ExtraWindowSpec> = ArrayList<ExtraWindowSpec>()
63
+ // 应用层留存的菜单模型(label → 模型):setMenu() 记一份,供「只改某一维状态」反查另一维
64
+ var menus: HashMap<String, MenuModel> = HashMap<String, MenuModel>()
65
+ // 应用层的 shell 事件钩子(对标 Tauri 的 on_menu_event):框架分发时先跑钩子、再投前端事件
66
+ var shellHooks: ArrayList<(String, String) -> Unit> = ArrayList<(String, String) -> Unit>()
67
+ var started: Bool = false
68
+ var plugins: ArrayList<Plugin> = ArrayList<Plugin>()
69
+ // 各插件 jsShim() 收集成的「预执行脚本」列表,prepare() 时定稿,run / runUrl 时交给宿主
70
+ var pluginScripts: ArrayList<String> = ArrayList<String>()
71
+
72
+ public init() {}
73
+
74
+ /**
75
+ * 设置窗口配置(标题 / 尺寸),对标 tauri.conf.json 的 app.windows
76
+ */
77
+ public func window(config: WindowConfig): TauriApp {
78
+ this.config = config
79
+ return this
80
+ }
81
+
82
+ /**
83
+ * 注入资产解析器(批次 1,RFC-002 §6)
84
+ *
85
+ * 供 run(Asset("ui/index.html")) 取页面与资产;不注入时按 cwd 相对路径读盘,
86
+ * 与现有模板 textOrFile 的桌面行为等价。鸿蒙 rawfile / 内嵌常量的实现由
87
+ * 各端适配层提供,批次 2 随清单派生接入。
88
+ */
89
+ public func assets(resolver: AssetResolver): TauriApp {
90
+ this.assetResolver = Some(resolver)
91
+ return this
92
+ }
93
+
94
+ /**
95
+ * 按逻辑名读一份资产文本(如 `ui/vendor/marked.min.js`),走装配时登记的解析器:
96
+ * 桌面 = DirectoryAssetResolver(assets/ 下按名读盘),鸿蒙 = RawfileAssetResolver(hap rawfile)。
97
+ * 给业务侧的窄口——vendor 库注入这类「装配期要拿资产原文」的需求不必自己碰解析器字段。
98
+ * 未登记解析器或读不到都回 None,调用方按「没有 = 降级」处理(如 stderr 提示 + 退化渲染)。
99
+ */
100
+ public func readAsset(name: String): Option<String> {
101
+ match (this.assetResolver) {
102
+ case Some(r) => return r.readText(name)
103
+ case None => return None
104
+ }
105
+ }
106
+
107
+ /**
108
+ * 登记一个附加窗口(**试验性**:批次 3「多窗口 UI」的最小切片,先给出可验证的通路)
109
+ *
110
+ * 语义:本方法只**登记 + 装配宿主**,原生窗口在 run() / runUrl() 里统一创建——
111
+ * 与默认窗口「构造时有宿主对象、start() 时才建窗口」是同一套节奏(WebViewHost 的契约)。
112
+ * 因此必须在 run() / runUrl() 之前调用;登记后 `hostOf(label)` 立刻可用。
113
+ *
114
+ * 现在**只接内联 HTML**(URL 变体等真实需求出现再加,判据见架构文档 §5);
115
+ * 空 label / 重复 label 在调用点直接抛异常(WindowRegistry.add 的约定,不延迟到 run)。
116
+ *
117
+ * 已知限制(**未解决**,架构文档 §7.5 的残余项):`onDestroy` 与对话框结果回调不带宿主 id,
118
+ * 多窗口下分不清是哪个窗口关的;对话框仍是同一时刻一个(桥侧互斥)。本方法不解这两条。
119
+ */
120
+ public func addWindow(label: String, config: WindowConfig, html: String): TauriApp {
121
+ if (this.started) {
122
+ throw CommandException("addWindow 必须在 run() / runUrl() 之前调用(页面加载前窗口表要定稿)")
123
+ }
124
+ let host = resolveHost(label)
125
+ host.setWindowConfig(config)
126
+ // label 注入与默认窗口走同一条通道(document-start 预执行脚本,桥 JS 懒读,先后顺序无所谓)
127
+ host.addInitScript("window.__CJ_TAURI_LABEL__ = ${JsonString(label)};")
128
+ // 登记放在这里(而不是 run 里):重复 label / 空 label 当场报错,调用点即可见
129
+ this.windows.add(WindowRecord(label, host, config))
130
+ this.extraWindows.add(ExtraWindowSpec(label, host, html))
131
+ return this
132
+ }
133
+
134
+ /**
135
+ * 运行时打开开发者工具(等价于前端 invoke("system:devtools"))
136
+ */
137
+ public func openDevTools(): Unit {
138
+ this.host.openDevTools()
139
+ }
140
+
141
+ /**
142
+ * 重新加载当前页面(整页 reload,等价于浏览器刷新)
143
+ */
144
+ public func reload(): Unit {
145
+ this.host.reload()
146
+ }
147
+
148
+ /**
149
+ * 宿主实例(插件的 setup() 取用)
150
+ *
151
+ * 需要窗口 / 原生能力的插件命令(原生对话框等)走它;纯仓颉插件(如 fs)用不到。
152
+ * 宿主对象在构造 TauriApp 时就已存在,但**原生窗口要等 run()/runUrl() 才创建**——
153
+ * 依赖窗口的操作必须在启动之后调用,实现方自己会给出可读的降级结果。
154
+ */
155
+ public func webviewHost(): WebViewHost {
156
+ return this.host
157
+ }
158
+
159
+ /**
160
+ * 按 label 取窗口宿主(AppHost 契约,对标 `app.get_webview_window(label)`);未注册返回 None
161
+ *
162
+ * 单窗口阶段只可能命中 "main";多窗口落地后这是插件拿到「别的窗口」的唯一入口。
163
+ */
164
+ public func hostOf(label: String): Option<WebViewHost> {
165
+ match (this.windows.get(label)) {
166
+ case Some(rec) => return Some(rec.host)
167
+ case None =>
168
+ // 默认窗口的注册表登记发生在 prepare()(那时窗口配置才定稿),所以 run() 之前
169
+ // 查 "main" 会落空——但宿主对象在 TauriApp 构造时就已经存在。
170
+ // 这里按 label 兜住,让「run() 之前 hostOf(label) 可用」对默认窗与 addWindow
171
+ // 登记的窗一致:预置菜单 / 拖放策略都必须在 start() 之前调(RFC-菜单-托盘-拖放 §5.2),
172
+ // 默认窗拿不到宿主就等于这条通路对最常见的用法直接不存在。
173
+ if (label == DEFAULT_WINDOW_LABEL) {
174
+ return Some(this.host)
175
+ }
176
+ return None
177
+ }
178
+ }
179
+
180
+ /**
181
+ * 退出应用(AppHost 契约,对标 Tauri 的 `app.exit()`):收掉所有窗口的事件循环,run() / runUrl() 随即返回
182
+ *
183
+ * 单窗口下与宿主自己的 `quit()` 等价;多窗口时这里是「先收各窗口的循环、再各自收尾」的唯一入口——
184
+ * 所以命令处理器里退应用走它,不要直接调 `webviewHost().quit()`(那只是本窗口的低层原语)。
185
+ */
186
+ public func quit(): Unit {
187
+ for (rec in this.windows.targets("")) {
188
+ rec.host.quit()
189
+ }
190
+ }
191
+
192
+ /**
193
+ * 阻塞等待:宿主事件循环退出(窗口关闭 / quit)后返回
194
+ *
195
+ * 公开是 AppHost 契约要求的(插件等不必自己写轮询);内部按宿主自己的节奏收尾。
196
+ *
197
+ * 为什么它在这里而不在 `app_run.cj`:接口成员只能由类体实现,`extend` 里补写不算实现
198
+ * (编译报 `unimplemented interface function 'waitForExit'`)。判定与退出判定
199
+ * `allWindowsExited()` 因此分居两个文件——那一个已经放宽成包内可见。
200
+ */
201
+ public func waitForExit(): Unit {
202
+ var spins: Int64 = 0
203
+ while (!this.allWindowsExited() && spins < 360_000) { // 最长 ~1 小时(10ms 步进)
204
+ sleep(Duration.millisecond * 10)
205
+ spins += 1
206
+ }
207
+ }
208
+
209
+ /**
210
+ * 全部已登记窗口的循环是否都已退出
211
+ *
212
+ * 多窗口的「退出」是**每个窗口都收工**:主窗关了而副窗还开着时,应用不该返回
213
+ * (对标 Tauri:`app.exit()` 收掉全部窗口)。注册表为空(清单导出模式等)时回落到
214
+ * 默认宿主,与单窗口时代的行为逐字等价。
215
+ *
216
+ * 为什么它和 `waitForExit` 一起留在类体:类体的方法**看不到** `extend` 里新增的成员
217
+ * (编译报 `'allWindowsExited' is not a member of class 'TauriApp'`),所以「类体 → extend」
218
+ * 这条方向不能有调用。判定与被判定只能同侧。
219
+ */
220
+ private func allWindowsExited(): Bool {
221
+ let recs = this.windows.targets("")
222
+ if (recs.size == 0) {
223
+ return this.host.shouldQuit()
224
+ }
225
+ for (rec in recs) {
226
+ if (!rec.host.shouldQuit()) {
227
+ return false
228
+ }
229
+ }
230
+ return true
231
+ }
232
+
233
+ /**
234
+ * 注册命令处理器
235
+ */
236
+ public func register(cmd: String, handler: CommandHandler): TauriApp {
237
+ this.hub.register(cmd, handler)
238
+ return this
239
+ }
240
+
241
+ /**
242
+ * 装配插件(对标 Tauri v2 的 .plugin(...))
243
+ *
244
+ * 插件的命令会自动加上 "<插件名>:" 前缀后注册;事件登记、前端 JS 与未授权提示
245
+ * 都在 run()/runUrl() 的 prepare() 阶段统一收尾(那时能力清单才加载完)。
246
+ * 必须在 run()/runUrl() 之前调用——页面加载前命令表要定稿。
247
+ */
248
+ public func plugin(p: Plugin): TauriApp {
249
+ if (!isValidPluginName(p.name())) {
250
+ throw Exception("invalid plugin name: \"${p.name()}\"(要求非空、且不含 ':')")
251
+ }
252
+ this.plugins.add(p)
253
+ return this
254
+ }
255
+
256
+ /**
257
+ * 从 JSON 添加 capability 清单
258
+ */
259
+ public func addCapabilityJson(json: JsonValue): TauriApp {
260
+ this.capabilities.addFromJson(json)
261
+ return this
262
+ }
263
+
264
+ /**
265
+ * 从目录加载所有 *.json capability 清单(对标 Tauri 的 capabilities/ 目录)
266
+ * 与 run() 的自动扫描等价,只是作用在指定目录上。
267
+ */
268
+ public func loadCapabilities(dir: String): TauriApp {
269
+ let n = loadCapabilitiesFromDir(dir, this.capabilities)
270
+ eprintln("[cj-tauri] 已从 ${dir} 加载 ${n} 个 capability 清单")
271
+ return this
272
+ }
273
+
274
+ /**
275
+ * 运行期给某插件的 scope 追加一条白名单模式(**不落盘**,重启即失效):插件名如 `"http"`,
276
+ * 模式写法同 `src/scope.cj`(主机名 / 路径,支持 `*` `**` `?` 与变量)。
277
+ *
278
+ * 调用时机是硬约束:**只应在应用已取得用户明确确认之后调**——框架替你做不了确认,它不理解
279
+ * 你的 UI 语义。典型落法是先在原生确认框里问到「是」
280
+ * (`host.showMessageDialog("confirm", …, "允许 <主机> 出网?")` 返回 true)再调本方法;
281
+ * 页面自己点不了原生对话框,所以「确认」这一步天然挡得住被注入的页面自行加白。
282
+ * 另外,清单 `scopes.<插件>.deny` 命中即拒,这里加白掀不掉 deny(判定顺序仍是先 deny 后 allow)。
283
+ *
284
+ * 需要重启仍生效就在启动时把存过的模式重新叠一遍(持久化与存放位置由应用自己决定)。
285
+ *
286
+ * 返回 false = 这条模式**没被采纳**(空串、或含 `*` / `?` 与制表/换行这类脏字符):加白的语义是
287
+ * 「往白名单里加一条具体值」,带通配透传进来等于全放行,所以注册中心当场拒。调用方应把 false
288
+ * 当成「没放行」如实回给用户,别写成成功。
289
+ */
290
+ public func allowScopePattern(plugin: String, pattern: String): Bool {
291
+ return this.capabilities.addRuntimeScopeAllow(plugin, pattern)
292
+ }
293
+
294
+ /**
295
+ * 某插件的 scope 此刻是否放行某个值(口径与判定完全一致:清单 + 运行期叠加,先 deny 后 allow)。
296
+ * 没写 scope 的插件恒为 true(旧行为:全量放行)。
297
+ */
298
+ public func isScopeAllowed(plugin: String, value: String): Bool {
299
+ return this.capabilities.scopeAllowsValue(plugin, value)
300
+ }
301
+
302
+ /**
303
+ * 直接添加 capability
304
+ */
305
+ public func addCapability(cap: Capability): TauriApp {
306
+ this.capabilities.add(cap)
307
+ return this
308
+ }
309
+
310
+ /**
311
+ * 读取 CLI 注入的前端 dev server 地址(CJ_TAURI_DEV_URL)
312
+ *
313
+ * 模板可以直接用:
314
+ * if (let Some(url) <- TauriApp.devUrl()) { app.runUrl(url) }
315
+ * else { app.run(html) }
316
+ */
317
+ public static func devUrl(): Option<String> {
318
+ if (let Some(v) <- getVariable("CJ_TAURI_DEV_URL")) {
319
+ if (v.size > 0) {
320
+ return Some(v)
321
+ }
322
+ }
323
+ return None
324
+ }
325
+ }