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.
Files changed (124) hide show
  1. package/README.md +3 -3
  2. package/cjpm.toml +52 -25
  3. package/cli/cjpm.toml +1 -1
  4. package/cli/src/doctor.cj +304 -0
  5. package/cli/src/main.cj +33 -7
  6. package/cli/src/project.cj +59 -1
  7. package/cli/src/resolve.cj +348 -304
  8. package/cli/src/scaffold.cj +139 -22
  9. package/cli/templates/ohos-pc/AppScope/app.json5 +13 -0
  10. package/cli/templates/ohos-pc/AppScope/resources/base/element/string.json +8 -0
  11. package/cli/templates/ohos-pc/AppScope/resources/base/media/background.png +0 -0
  12. package/cli/templates/ohos-pc/AppScope/resources/base/media/foreground.png +0 -0
  13. package/cli/templates/ohos-pc/AppScope/resources/base/media/layered_image.json +6 -0
  14. package/cli/templates/ohos-pc/README.md +79 -0
  15. package/cli/templates/ohos-pc/build-hap.sh +190 -0
  16. package/cli/templates/ohos-pc/build-profile.json5 +46 -0
  17. package/cli/templates/ohos-pc/entry/build-profile.json5 +26 -0
  18. package/cli/templates/ohos-pc/entry/cjpm.toml +43 -0
  19. package/cli/templates/ohos-pc/entry/hvigorfile.ts +6 -0
  20. package/cli/templates/ohos-pc/entry/oh-package.json5 +9 -0
  21. package/cli/templates/ohos-pc/entry/src/main/cangjie/ability_stage.cj +17 -0
  22. package/cli/templates/ohos-pc/entry/src/main/cangjie/app_host.cj +94 -0
  23. package/cli/templates/ohos-pc/entry/src/main/cangjie/index.cj +167 -0
  24. package/cli/templates/ohos-pc/entry/src/main/cangjie/main_ability.cj +139 -0
  25. package/cli/templates/ohos-pc/entry/src/main/module.json5 +49 -0
  26. package/cli/templates/ohos-pc/entry/src/main/resources/base/element/color.json +8 -0
  27. package/cli/templates/ohos-pc/entry/src/main/resources/base/element/string.json +16 -0
  28. package/cli/templates/ohos-pc/entry/src/main/resources/base/media/background.png +0 -0
  29. package/cli/templates/ohos-pc/entry/src/main/resources/base/media/foreground.png +0 -0
  30. package/cli/templates/ohos-pc/entry/src/main/resources/base/media/layered_image.json +6 -0
  31. package/cli/templates/ohos-pc/entry/src/main/resources/base/media/startIcon.png +0 -0
  32. package/cli/templates/ohos-pc/entry/src/main/resources/base/profile/main_pages.json +2 -0
  33. package/cli/templates/ohos-pc/entry/src/main/resources/rawfile/capabilities.json +6 -0
  34. package/cli/templates/ohos-pc/entry/src/main/resources/rawfile/index.html +92 -0
  35. package/cli/templates/ohos-pc/hvigor/hvigor-config.json5 +5 -0
  36. package/cli/templates/ohos-pc/hvigorfile.ts +6 -0
  37. package/cli/templates/ohos-pc/oh-package.json5 +10 -0
  38. package/native/build_linux.sh +30 -2
  39. package/native/build_win.bat +62 -61
  40. package/native/core/bridge_core.c +1626 -0
  41. package/native/{bridge_core.h → core/bridge_core.h} +246 -6
  42. package/native/platforms/harmonyos/README.md +106 -0
  43. package/native/platforms/linux/bridge_linux.c +2485 -0
  44. package/native/platforms/windows/bridge_win.c +3248 -0
  45. package/native/tests/test_bridge_core.c +528 -1
  46. package/package.json +12 -3
  47. package/prebuilt/win32-x64/cj-tauri.exe +0 -0
  48. package/src/api_system.cj +10 -2
  49. package/src/app.cj +248 -85
  50. package/src/arkweb_proxy.cj +56 -0
  51. package/src/arkweb_script.cj +154 -0
  52. package/src/capability.cj +140 -0
  53. package/src/clipboard_bridge.cj +55 -0
  54. package/src/dragdrop.cj +26 -0
  55. package/src/embedded_assets.cj +98 -0
  56. package/src/hexutil.cj +53 -0
  57. package/src/host.cj +197 -26
  58. package/src/host_arkweb.cj +740 -0
  59. package/src/host_factory.cj +48 -0
  60. package/src/host_webkit.cj +202 -26
  61. package/src/host_webview2.cj +176 -3
  62. package/src/hotkey.cj +66 -0
  63. package/src/ipc_hub.cj +49 -0
  64. package/src/ipc_message.cj +2 -1
  65. package/src/jsutil.cj +33 -0
  66. package/src/log_bridge.cj +107 -0
  67. package/src/logging.cj +127 -0
  68. package/src/menu.cj +197 -196
  69. package/src/plugin.cj +40 -0
  70. package/src/plugins/clipboard/plugin_clipboard.cj +144 -0
  71. package/src/{plugin_dialog.cj → plugins/dialog/plugin_dialog.cj} +74 -17
  72. package/src/plugins/fs/plugin_fs.cj +194 -0
  73. package/src/plugins/http/plugin_http.cj +459 -0
  74. package/src/plugins/log/plugin_log.cj +105 -0
  75. package/src/{plugin_menu.cj → plugins/menu/plugin_menu.cj} +4 -2
  76. package/src/plugins/mqtt/mqtt_codec.cj +661 -0
  77. package/src/plugins/mqtt/plugin_mqtt.cj +1446 -0
  78. package/src/plugins/notification/plugin_notification.cj +146 -0
  79. package/src/plugins/plugins.cj +5 -0
  80. package/src/plugins/redis/plugin_redis.cj +2409 -0
  81. package/src/plugins/redis/redis_codec.cj +556 -0
  82. package/src/{plugin_serial.cj → plugins/serial/plugin_serial.cj} +442 -489
  83. package/src/{serial_bridge.cj → plugins/serial/serial_bridge.cj} +185 -184
  84. package/src/{plugin_shell.cj → plugins/shell/plugin_shell.cj} +230 -228
  85. package/src/plugins/sqlite/plugin_sqlite.cj +697 -0
  86. package/src/plugins/sqlite/sqlite_ffi.cj +206 -0
  87. package/src/plugins/store/plugin_store.cj +287 -0
  88. package/src/plugins/ws/plugin_ws.cj +470 -0
  89. package/src/process_hidden.cj +174 -0
  90. package/src/scope.cj +391 -0
  91. package/src/shell_event.cj +69 -68
  92. package/src/tests/capability_test.cj +53 -0
  93. package/src/tests/fake_host.cj +85 -20
  94. package/src/tests/ipc_hub_test.cj +13 -0
  95. package/src/tests/jsutil_test.cj +62 -0
  96. package/src/tests/logging_test.cj +62 -0
  97. package/src/tests/mqtt_codec_test.cj +405 -0
  98. package/src/tests/plugin_clipboard_test.cj +222 -0
  99. package/src/tests/plugin_dialog_test.cj +98 -4
  100. package/src/tests/plugin_http_test.cj +124 -0
  101. package/src/tests/plugin_menu_test.cj +1 -0
  102. package/src/tests/plugin_notification_test.cj +205 -0
  103. package/src/tests/plugin_serial_test.cj +269 -267
  104. package/src/tests/plugin_shell_test.cj +189 -188
  105. package/src/tests/plugin_sqlite_test.cj +230 -0
  106. package/src/tests/plugin_store_test.cj +260 -0
  107. package/src/tests/plugin_test.cj +542 -524
  108. package/src/tests/plugin_ws_test.cj +40 -0
  109. package/src/tests/process_hidden_test.cj +129 -0
  110. package/src/tests/redis_codec_test.cj +396 -0
  111. package/src/tests/scope_test.cj +350 -0
  112. package/src/tests/shell_event_test.cj +0 -1
  113. package/src/tests/system_host_test.cj +81 -79
  114. package/src/tests/urlutil_test.cj +53 -0
  115. package/src/tests/version_test.cj +75 -65
  116. package/src/tray.cj +90 -0
  117. package/src/urlutil.cj +159 -0
  118. package/src/version.cj +23 -2
  119. package/src/window.cj +51 -42
  120. package/native/bridge_core.c +0 -797
  121. package/native/bridge_linux.c +0 -1268
  122. package/native/bridge_win.c +0 -1633
  123. package/src/plugin_fs.cj +0 -118
  124. /package/native/{bridge_js.h → core/bridge_js.h} +0 -0
@@ -0,0 +1,2485 @@
1
+ /*
2
+ * cj-tauri C 桥(Linux 平台层):GTK / WebKitGTK 宿主原语
3
+ *
4
+ * 本文件只实现 bridge_core.h 的「平台原语清单」+ 宿主线程装配;平台无关逻辑
5
+ * (IPC 回投队列与批处理、对话框单槽状态机、窗口配置存储、生命周期标志、全部导出)
6
+ * 都在 native/bridge_core.c,两平台共用。分层原因见 docs/架构演进-多平台与多窗口.md §2.2。
7
+ *
8
+ * 背景(关键):仓颉 cjnative 的 main 运行在 M:N 轻量级线程的堆上协程栈,
9
+ * JSC 的 sanitizeStackForVM 用 pthread 栈边界校验 SP 会失败并 abort。
10
+ * 因此 GTK/WebKit 全部调用必须留在本文件创建的原生 pthread(标准 8MB 栈)内,
11
+ * 仓颉侧经 FFI 与本桥交互,消息回调经 CFunc 回到仓颉。
12
+ *
13
+ * 编译(见 native/build_linux.sh):
14
+ * gcc -shared -fPIC -fstack-protector-all bridge_core.c bridge_linux.c \
15
+ * -o libcjtbridge.so $(pkg-config --cflags --libs webkit2gtk-4.1)
16
+ */
17
+ #include <gtk/gtk.h>
18
+ #include <webkit2/webkit2.h>
19
+ #include <pthread.h>
20
+ #include <stdio.h>
21
+ #include <stdlib.h>
22
+ #include <string.h>
23
+ #include <unistd.h>
24
+ #include <dlfcn.h>
25
+ /* 串口(plugin_serial):termios 配置 + 超时读写 */
26
+ #include <errno.h>
27
+ #include <fcntl.h>
28
+ #include <poll.h>
29
+ #include <signal.h>
30
+ #include <spawn.h>
31
+ #include <stdarg.h>
32
+ /* posix_spawn 要用进程环境指针:glibc 的 unistd.h 只在 _GNU_SOURCE 下暴露 environ,
33
+ 共享库构建没定义它——按 POSIX 显式声明(标准符号,不带 _GNU_SOURCE 也合法) */
34
+ extern char **environ;
35
+ #include <sys/types.h>
36
+ #include <termios.h>
37
+ #include <time.h>
38
+ #include "bridge_core.h"
39
+ #ifdef CJ_HAVE_X11
40
+ #include <X11/Xlib.h>
41
+ #endif
42
+ #ifdef CJ_HAVE_TRAY
43
+ #include <libayatana-appindicator/app-indicator.h>
44
+ #endif
45
+
46
+ /* ===== 菜单栏(RFC-002 §5.4 / §5.5)=====
47
+ core 把线路文本交下来,本文件翻译成 GTK 菜单栏。菜单栏是**每窗口**资源(GtkMenuBar 挂在
48
+ 窗口里那层 GtkBox 上),建 / 换 / 改都必须在 GTK 线程上做——跨线程一律 g_idle_add 投过去。 */
49
+ #define CJ_MENU_MAX_ITEMS 64
50
+ #define CJ_MENU_MAX_DEPTH 8
51
+
52
+ typedef struct menu_item_ctx {
53
+ cj_host *h;
54
+ char *app_id; /* 应用给的稳定 id:菜单项被激活时回投仓颉侧 */
55
+ char kind; /* 'n' 普通 / 'c' 勾选(勾选项靠 toggled 信号回投)*/
56
+ int enabled;
57
+ int checked;
58
+ int suppress; /* 程序化改勾选时置 1:把随之而来的 toggled 当噪声挡掉 */
59
+ } menu_item_ctx;
60
+
61
+ typedef struct menu_slot {
62
+ char *app_id; /* 与 menu_item_ctx 各存一份:槽会被重建复用,ctx 随 widget 存亡 */
63
+ GtkWidget *widget; /* 对应的 GtkMenuItem / GtkCheckMenuItem */
64
+ menu_item_ctx *ctx; /* 该 widget 的信号回调上下文(改状态要动它的 suppress)*/
65
+ char kind;
66
+ int enabled;
67
+ int checked;
68
+ } menu_slot;
69
+
70
+ /* ===== 每个宿主的平台私有状态 =====
71
+ 原先是一堆进程级单例(g_window / g_view / g_gtk_thread …):多窗口一落地就互相覆盖,
72
+ 而且「哪个窗口」这件事在 ABI 上根本传不进来。现在挂在自己的 cj_host->plat 上,
73
+ 平台函数一律从入参句柄取状态——平台原语只认句柄,不看全局。 */
74
+ typedef struct plat_host {
75
+ GtkWidget *window; /* 主窗口;窗口销毁时置空(含用户关窗路径)*/
76
+ GtkWidget *view; /* WebKitWebView;宿主线程收尾时 unref 并置空 */
77
+ /* --- 菜单栏(只在 GTK 线程上建 / 换 / 改)--- */
78
+ GtkWidget *box; /* window 里那层竖向 GtkBox:装顶行(菜单栏 + 副标题)+ view */
79
+ GtkWidget *topbar; /* 顶部那一行水平 GtkBox:菜单栏靠左、副标题靠右 */
80
+ GtkWidget *subtitle; /* 副标题 label;文本空串即隐藏(不留一条空行)*/
81
+ GtkWidget *menubar; /* 当前菜单栏;NULL = 没有(空菜单栏不留空条)*/
82
+ menu_slot menu_items[CJ_MENU_MAX_ITEMS];
83
+ int menu_item_count;
84
+ pthread_t thread; /* 跑 gtk_main 的原生线程(对话框判断「调用方是否已在 GTK 线程」用)*/
85
+ int thread_started;
86
+ int thread_set;
87
+ volatile int thread_done; /* 宿主线程跑完(收尾最后一步):只有它为 1 才敢 free 这个结构,
88
+ 见 cj_plat_fini 里「为什么不 join」的注释 */
89
+ /* --- 全局快捷键(CJ_HAVE_X11 编译期开关;grab 线程独占自己的 X 连接,
90
+ 命令经 hk_cmd 投递、触发事件经 hk_evt 回 GTK 线程——Xlib 的多线程安全不想赌,
91
+ 单线程拥有一切 X 资源;仓颉回调仍只发生在 GTK 线程,与菜单/拖放同一纪律)--- */
92
+ int hk_cmd[2]; /* [0] 读端(grab 线程 poll)[1] 写端(cj_plat_set_global_hotkeys 发 'R'/'Q')*/
93
+ int hk_evt[2]; /* [0] 读端(GTK 线程 50ms 轮询)[1] 写端(grab 线程写触发 id 行)*/
94
+ pthread_t hk_thread;
95
+ int hk_thread_started;
96
+ int hk_boot; /* grab 线程是否已起(懒启动:首次 set_global_hotkeys 才建线程与管道)*/
97
+ guint hk_evt_src; /* GTK 侧事件轮询的 source id(收尾时 g_source_remove)*/
98
+ volatile int hk_quit; /* 收尾旗标:grab 线程见到即退出 */
99
+ volatile int hk_done; /* grab 线程跑完(fini 回收状态的另一半条件)*/
100
+ /* --- 窗口可见性 / 最小化到托盘(RFC-003 第 5 期余项)--- */
101
+ int min_to_tray_warned; /* 「托盘没挂上,最小化降级」只出声一次(重新设策略时复位)*/
102
+ #ifdef CJ_HAVE_TRAY
103
+ AppIndicator *tray; /* 当前托盘;NULL = 没有。GTK 线程专属,重建 = 释放旧的再建 */
104
+ #endif
105
+ } plat_host;
106
+
107
+ /* ===== 同步原语(core 只用这些,绝不直接碰 pthread)===== */
108
+
109
+ struct cj_sync {
110
+ pthread_mutex_t mutex;
111
+ pthread_cond_t cond;
112
+ };
113
+
114
+ cj_sync *cj_plat_sync_create(void) {
115
+ cj_sync *s = (cj_sync *)calloc(1, sizeof(cj_sync));
116
+ if (!s) return NULL;
117
+ if (pthread_mutex_init(&s->mutex, NULL) != 0) {
118
+ free(s);
119
+ return NULL;
120
+ }
121
+ if (pthread_cond_init(&s->cond, NULL) != 0) {
122
+ pthread_mutex_destroy(&s->mutex);
123
+ free(s);
124
+ return NULL;
125
+ }
126
+ return s;
127
+ }
128
+
129
+ void cj_plat_sync_destroy(cj_sync *s) {
130
+ if (!s) return;
131
+ pthread_cond_destroy(&s->cond);
132
+ pthread_mutex_destroy(&s->mutex);
133
+ free(s);
134
+ }
135
+
136
+ void cj_plat_sync_lock(cj_sync *s) {
137
+ if (s) pthread_mutex_lock(&s->mutex);
138
+ }
139
+
140
+ void cj_plat_sync_unlock(cj_sync *s) {
141
+ if (s) pthread_mutex_unlock(&s->mutex);
142
+ }
143
+
144
+ void cj_plat_sync_wait(cj_sync *s, int *ready) {
145
+ if (!s || !ready) return;
146
+ while (!*ready) {
147
+ pthread_cond_wait(&s->cond, &s->mutex);
148
+ }
149
+ }
150
+
151
+ void cj_plat_sync_signal(cj_sync *s) {
152
+ if (s) pthread_cond_signal(&s->cond);
153
+ }
154
+
155
+ /* ===== 投递到 GTK 线程的小工具 ===== */
156
+
157
+ /* 带一个字符串参数的投递(URL 需要,且参数所有权要跟着走) */
158
+ typedef struct ui_call {
159
+ cj_host *h;
160
+ char *arg;
161
+ } ui_call;
162
+
163
+ static gboolean open_devtools_idle(gpointer d) {
164
+ cj_host *h = (cj_host *)d;
165
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
166
+ if (p && p->view) {
167
+ WebKitWebInspector *insp = webkit_web_view_get_inspector(WEBKIT_WEB_VIEW(p->view));
168
+ if (insp) {
169
+ webkit_web_inspector_show(insp);
170
+ }
171
+ }
172
+ return G_SOURCE_REMOVE;
173
+ }
174
+
175
+ static gboolean load_url_idle(gpointer d) {
176
+ ui_call *c = (ui_call *)d;
177
+ plat_host *p = (c && c->h) ? (plat_host *)c->h->plat : NULL;
178
+ if (p && p->view && c->arg) {
179
+ webkit_web_view_load_uri(WEBKIT_WEB_VIEW(p->view), c->arg);
180
+ }
181
+ if (c) {
182
+ free(c->arg);
183
+ free(c);
184
+ }
185
+ return G_SOURCE_REMOVE;
186
+ }
187
+
188
+ static gboolean reload_idle(gpointer d) {
189
+ cj_host *h = (cj_host *)d;
190
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
191
+ if (p && p->view) {
192
+ webkit_web_view_reload(WEBKIT_WEB_VIEW(p->view));
193
+ }
194
+ return G_SOURCE_REMOVE;
195
+ }
196
+
197
+ /* 退出:只收主循环,窗口交给 gtk_thread_main 的收尾去销毁(见那段收尾 + on_destroy 信号)。
198
+ 不能在空闲回调里 gtk_widget_destroy:实测「主循环内销毁 WebKit 窗口」会让 libwebkit2gtk 的
199
+ 退出期析构函数在 g_object_unref 里 abort——栈是 StartMainTask → exit → __run_exit_handlers
200
+ → libwebkit2gtk * → g_object_unref → abort,退出码 134(6 轮里 5 轮),而主循环退出后再销毁
201
+ 同一份活儿照样干完,6 轮里干净退出(AB 对照)。onDestroy 仍由 destroy 信号触发一次,
202
+ 收尾里那次 cj_core_window_destroyed 只是幂等兜底;与 Windows 的 DestroyWindow → WM_DESTROY
203
+ 语义仍对齐:quit 一定触发一次 onDestroy。 */
204
+ static gboolean quit_idle(gpointer d) {
205
+ (void)d; /* 窗口销毁已不在这里做,入参用不上了 */
206
+ gtk_main_quit();
207
+ return G_SOURCE_REMOVE;
208
+ }
209
+
210
+ /* 回投:UI 线程取队列拼批执行 */
211
+ static gboolean flush_idle(gpointer d) {
212
+ cj_core_flush_js((cj_host *)d);
213
+ return G_SOURCE_REMOVE;
214
+ }
215
+
216
+ /* ===== 宿主生命周期 ===== */
217
+
218
+ /* 菜单:实现集中在文件末尾;装配路径与 g_idle 回调都要用,先声明 */
219
+ static void menu_apply_wire(cj_host *h, plat_host *p, const char *wire);
220
+ static void menu_apply_item_state(plat_host *p, const char *id, int enabled, int checked);
221
+ static void menu_clear_slots(plat_host *p);
222
+ /* 菜单几何的「事后读回」(低优先级 idle 里读 GTK 的分配值):建完菜单、以及每次用户点击之后
223
+ 各投一次,实机驱动脚本按它把鼠标点到项的中心——见函数定义处的注释 */
224
+ static void menu_post_layout(cj_host *h);
225
+ static void drag_dest_apply(cj_host *h, plat_host *p, int enabled, int files);
226
+ #ifdef CJ_HAVE_TRAY
227
+ static void tray_apply_wire(cj_host *h, plat_host *p, const char *wire);
228
+ static void tray_remove(plat_host *p);
229
+ #endif
230
+ /* 窗口可见性:装配点(建窗后按 h->win_visible 定稿)与运行期 g_idle 投递都要用,先声明 */
231
+ static void window_apply_visible(plat_host *p, int visible);
232
+ /* 最小化到托盘的拦截点:建窗时挂 signal,定义在文件后段的平台原语区 */
233
+ static gboolean on_window_state(GtkWidget *w, GdkEventWindowState *ev, gpointer ud);
234
+
235
+ void cj_plat_init(cj_host *h) {
236
+ plat_host *p;
237
+ if (!h) return;
238
+ p = (plat_host *)calloc(1, sizeof(plat_host));
239
+ if (!p) {
240
+ fprintf(stderr, "[cj-bridge] init failed: out of memory\n");
241
+ return;
242
+ }
243
+ /* fd 0 是合法描述符(stdin):管道字段 calloc 出来的 0 会被误当「已打开」,
244
+ 必须显式置 -1(「未创建」的约定值),首次 set_global_hotkeys 时才真正 pipe() */
245
+ p->hk_cmd[0] = -1;
246
+ p->hk_cmd[1] = -1;
247
+ p->hk_evt[0] = -1;
248
+ p->hk_evt[1] = -1;
249
+ h->plat = p;
250
+ }
251
+
252
+ int cj_plat_fini(cj_host *h) {
253
+ plat_host *p;
254
+ if (!h) return 1;
255
+ p = (plat_host *)h->plat;
256
+ if (!p) return 1;
257
+ /* 全局快捷键 grab 线程先收:置旗标 + 发 'Q',有界等它退出(等不到照旧留着状态不阻塞,
258
+ 与宿主线程同一口径)。evt 轮询 source 挂在 GTK 主循环上,主循环已随窗口退出而结束,
259
+ 这里 remove 是幂等兜底(GSource 句柄已无效时 g_source_remove 只告警,故先判非 0)。 */
260
+ if (p->hk_thread_started) {
261
+ char q = 'Q';
262
+ p->hk_quit = 1;
263
+ if (p->hk_cmd[1] >= 0) {
264
+ ssize_t w = write(p->hk_cmd[1], &q, 1);
265
+ (void)w;
266
+ }
267
+ {
268
+ int spin_hk = 0;
269
+ while (!p->hk_done && spin_hk < 500) { /* 最多约 1s */
270
+ usleep(2000);
271
+ spin_hk++;
272
+ }
273
+ }
274
+ if (p->hk_evt_src) {
275
+ g_source_remove(p->hk_evt_src);
276
+ p->hk_evt_src = 0;
277
+ }
278
+ pthread_detach(p->hk_thread);
279
+ p->hk_thread_started = 0;
280
+ }
281
+ if (p->hk_cmd[0] >= 0) { close(p->hk_cmd[0]); close(p->hk_cmd[1]); p->hk_cmd[0] = p->hk_cmd[1] = -1; }
282
+ if (p->hk_evt[0] >= 0) { close(p->hk_evt[0]); close(p->hk_evt[1]); p->hk_evt[0] = p->hk_evt[1] = -1; }
283
+ /* 这里**不能 join**:宿主线程收尾时要进入仓颉运行时(窗口销毁回调 on_destroy),
284
+ 而在仓颉线程里阻塞 join 会与运行时的「停处理器」握手互等——实测约 1/6 概率整进程挂死,
285
+ 栈是 cj_plat_fini → pthread_join ↔ CJ_CJThreadMexit → CJ_ProcessorStopWithLastCheck。
286
+ 改成 detach + 让宿主线程自己立 thread_done 标记:跑完了才回收状态,没跑完就留着
287
+ (进程随即退出;泄漏一次状态远比死锁安全,也与批次 1 之前的 detach 行为一致)。 */
288
+ if (p->thread_started) {
289
+ pthread_detach(p->thread);
290
+ p->thread_started = 0;
291
+ }
292
+ /* 收尾顺序同样要命:窗口销毁是 GTK/WebKit 一整串多线程拆解,若它与仓颉运行时的收尾
293
+ 并发(run() 返回 → 运行时关停),实测几乎必然以 SIGABRT 收尾(`schd-worker` unhandled
294
+ SIGABRT from runtime frame)。所以给宿主线程一个有界的等待窗口——纯 usleep 轮询标记,
295
+ 不碰 join(join 会与运行时握手互等,见上);等不到也不阻塞进程退出,照旧留着状态。 */
296
+ if (!p->thread_done) {
297
+ int spin = 0;
298
+ while (!p->thread_done && spin < 2000) { /* 最多约 2s */
299
+ usleep(1000);
300
+ spin++;
301
+ }
302
+ }
303
+ if (!p->thread_done) {
304
+ fprintf(stderr, "[cj-bridge] fini: host thread still running, platform state kept\n");
305
+ return 0;
306
+ }
307
+ h->plat = NULL;
308
+ free(p);
309
+ return 1;
310
+ }
311
+
312
+ int cj_plat_is_ui_thread(cj_host *h) {
313
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
314
+ if (!p || !p->thread_set) return 0;
315
+ return pthread_equal(pthread_self(), p->thread) ? 1 : 0;
316
+ }
317
+
318
+ void cj_plat_wake_ui(cj_host *h) {
319
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
320
+ /* 窗口还没建好:消息留在 core 的队列里,等宿主就绪后的第一次投递带走 */
321
+ if (!p || !p->view) return;
322
+ g_idle_add(flush_idle, h);
323
+ }
324
+
325
+ /* ===== 平台原语:投递 / 运行时操作 ===== */
326
+
327
+ /* 回投结果校验:这条 eval 是「仓颉 → 前端」的唯一出口,静默失败会让前端 promise 永久 pending。
328
+ Windows 侧有 log_hr("ExecuteScript", …) 兜同一件事(两平台对称)。 */
329
+ static void on_js_done(GObject *src, GAsyncResult *res, gpointer ud) {
330
+ GError *err = NULL;
331
+ WebKitJavascriptResult *r =
332
+ webkit_web_view_run_javascript_finish(WEBKIT_WEB_VIEW(src), res, &err);
333
+ if (r) {
334
+ g_object_unref(r); /* 每次回投都会新建一个结果对象,不看也要释放 */
335
+ }
336
+ if (err) {
337
+ fprintf(stderr, "[cj-bridge] run js failed: %s\n", err->message);
338
+ g_error_free(err);
339
+ }
340
+ }
341
+
342
+ void cj_plat_post_js(cj_host *h, const char *script_utf8) {
343
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
344
+ if (!p || !p->view || !script_utf8) return;
345
+ webkit_web_view_run_javascript(WEBKIT_WEB_VIEW(p->view), script_utf8, NULL, on_js_done, NULL);
346
+ }
347
+
348
+ void cj_plat_open_devtools(cj_host *h) {
349
+ if (!h) return;
350
+ g_idle_add(open_devtools_idle, h);
351
+ }
352
+
353
+ void cj_plat_load_url(cj_host *h, const char *url) {
354
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
355
+ if (!h || !url || !url[0]) return;
356
+ fprintf(stderr, "[cj-bridge] load url: %s\n", url);
357
+ /* 已启动:投递到 GTK 线程切页面;未启动:pending_url 由宿主线程建窗后加载 */
358
+ if (p && p->view) {
359
+ ui_call *c = (ui_call *)calloc(1, sizeof(ui_call));
360
+ if (!c) return;
361
+ c->h = h;
362
+ c->arg = strdup(url);
363
+ if (!c->arg) {
364
+ free(c);
365
+ return;
366
+ }
367
+ g_idle_add(load_url_idle, c);
368
+ }
369
+ }
370
+
371
+ void cj_plat_reload(cj_host *h) {
372
+ if (!h) return;
373
+ g_idle_add(reload_idle, h);
374
+ }
375
+
376
+ void cj_plat_quit(cj_host *h) {
377
+ if (!h) return;
378
+ g_idle_add(quit_idle, h);
379
+ }
380
+
381
+ /* ===== 原生对话框:这里只管「弹」,状态机(单槽/互斥/等待/作废)在 core ===== */
382
+
383
+ /* "描述|模式|描述|模式" → GTK 过滤器(与 Win32 OPENFILENAME 用同一套写法) */
384
+ static void apply_filter(GtkFileChooser *chooser, const char *filter) {
385
+ char *buf, *seg;
386
+ if (!filter || !filter[0]) {
387
+ return;
388
+ }
389
+ buf = strdup(filter);
390
+ if (!buf) {
391
+ return;
392
+ }
393
+ seg = buf;
394
+ while (seg && seg[0]) {
395
+ char *pat = strchr(seg, '|');
396
+ char *next = NULL;
397
+ if (!pat) {
398
+ break; /* 落单的描述段:忽略 */
399
+ }
400
+ *pat = 0;
401
+ pat += 1;
402
+ next = strchr(pat, '|');
403
+ if (next) {
404
+ *next = 0;
405
+ }
406
+ {
407
+ GtkFileFilter *f = gtk_file_filter_new();
408
+ char *p = pat;
409
+ /* 一段里可以有多个空格分隔的 glob(如 "*.txt *.md") */
410
+ while (p && p[0]) {
411
+ char *sp = strchr(p, ' ');
412
+ if (sp) {
413
+ *sp = 0;
414
+ }
415
+ if (p[0]) {
416
+ gtk_file_filter_add_pattern(f, p);
417
+ }
418
+ p = sp ? sp + 1 : NULL;
419
+ }
420
+ gtk_file_filter_set_name(f, seg);
421
+ gtk_file_chooser_add_filter(chooser, f);
422
+ }
423
+ seg = next ? next + 1 : NULL;
424
+ }
425
+ {
426
+ GtkFileFilter *all = gtk_file_filter_new();
427
+ gtk_file_filter_set_name(all, "所有文件");
428
+ gtk_file_filter_add_pattern(all, "*");
429
+ gtk_file_chooser_add_filter(chooser, all);
430
+ }
431
+ free(buf);
432
+ }
433
+
434
+ /* 已经站在 GTK 线程上:直接弹一次,返回 1 = 确认;选中路径写入 *out_path(由调用方 free) */
435
+ static int show_dialog_here(GtkWidget *parent_w, int kind, const char *title, const char *message,
436
+ const char *filter, char **out_path) {
437
+ GtkWindow *parent = parent_w ? GTK_WINDOW(parent_w) : NULL;
438
+ int ok = 0;
439
+
440
+ *out_path = NULL;
441
+ if (kind == 6) {
442
+ /* 选目录:GET 不到「选目录」的 GTK 动作之外别无他法(kind 6 与文件类 / 消息类并列,
443
+ 见 bridge_core.h 的 dlg_req)。这里不设 filter:目录没有过滤语义。 */
444
+ GtkWidget *dlg = gtk_file_chooser_dialog_new(
445
+ title[0] ? title : "选择文件夹",
446
+ parent,
447
+ GTK_FILE_CHOOSER_ACTION_SELECT_FOLDER,
448
+ "取消", GTK_RESPONSE_CANCEL,
449
+ "选择", GTK_RESPONSE_ACCEPT,
450
+ NULL);
451
+ if (gtk_dialog_run(GTK_DIALOG(dlg)) == GTK_RESPONSE_ACCEPT) {
452
+ char *p = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dlg));
453
+ if (p) {
454
+ *out_path = strdup(p);
455
+ ok = 1;
456
+ g_free(p);
457
+ }
458
+ }
459
+ gtk_widget_destroy(dlg);
460
+ } else if (kind <= 1) {
461
+ GtkWidget *dlg = gtk_file_chooser_dialog_new(
462
+ title[0] ? title : (kind == 1 ? "保存文件" : "打开文件"),
463
+ parent,
464
+ (kind == 1) ? GTK_FILE_CHOOSER_ACTION_SAVE : GTK_FILE_CHOOSER_ACTION_OPEN,
465
+ "取消", GTK_RESPONSE_CANCEL,
466
+ (kind == 1) ? "保存" : "打开", GTK_RESPONSE_ACCEPT,
467
+ NULL);
468
+ apply_filter(GTK_FILE_CHOOSER(dlg), filter);
469
+ if (kind == 1) {
470
+ gtk_file_chooser_set_do_overwrite_confirmation(GTK_FILE_CHOOSER(dlg), TRUE);
471
+ }
472
+ if (gtk_dialog_run(GTK_DIALOG(dlg)) == GTK_RESPONSE_ACCEPT) {
473
+ char *p = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dlg));
474
+ if (p) {
475
+ *out_path = strdup(p);
476
+ ok = 1;
477
+ g_free(p);
478
+ }
479
+ }
480
+ gtk_widget_destroy(dlg);
481
+ } else {
482
+ GtkMessageType type = GTK_MESSAGE_INFO;
483
+ GtkWidget *dlg;
484
+ int res;
485
+ if (kind == 3) {
486
+ type = GTK_MESSAGE_WARNING;
487
+ } else if (kind == 4) {
488
+ type = GTK_MESSAGE_ERROR;
489
+ } else if (kind == 5) {
490
+ type = GTK_MESSAGE_QUESTION;
491
+ }
492
+ /* 用 "%s" 占位:提示文案里带 % 也不会被当成格式串 */
493
+ dlg = gtk_message_dialog_new(parent, GTK_DIALOG_MODAL, type,
494
+ (kind == 5) ? GTK_BUTTONS_OK_CANCEL : GTK_BUTTONS_OK,
495
+ "%s", message[0] ? message : " ");
496
+ if (title[0]) {
497
+ gtk_window_set_title(GTK_WINDOW(dlg), title);
498
+ }
499
+ res = gtk_dialog_run(GTK_DIALOG(dlg));
500
+ ok = (kind == 5) ? (res == GTK_RESPONSE_OK) : 1;
501
+ gtk_widget_destroy(dlg);
502
+ }
503
+ return ok;
504
+ }
505
+
506
+ int cj_plat_run_dialog(cj_host *h, dlg_req *req) {
507
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
508
+ if (!req) return 0;
509
+ req->ok = show_dialog_here(p ? p->window : NULL, req->kind, req->title, req->message,
510
+ req->filter, &req->path);
511
+ return req->ok;
512
+ }
513
+
514
+ /* 别的线程投递进来的对话框请求:g_idle_add 只带一个参数,包一层带上句柄 */
515
+ typedef struct dialog_call {
516
+ cj_host *h;
517
+ dlg_req *req;
518
+ } dialog_call;
519
+
520
+ static gboolean dialog_idle(gpointer d) {
521
+ dialog_call *c = (dialog_call *)d;
522
+ if (c) {
523
+ cj_core_dialog_tick(c->h, c->req);
524
+ free(c);
525
+ }
526
+ return G_SOURCE_REMOVE;
527
+ }
528
+
529
+ void cj_plat_post_dialog(cj_host *h, dlg_req *req) {
530
+ dialog_call *c = (dialog_call *)calloc(1, sizeof(dialog_call));
531
+ if (!c) {
532
+ /* 投不出去就别让调用方干等:作废请求并唤醒等待方 */
533
+ fprintf(stderr, "[cj-bridge] dialog failed: out of memory\n");
534
+ cj_core_dialog_abort(h);
535
+ return;
536
+ }
537
+ c->h = h;
538
+ c->req = req;
539
+ g_idle_add(dialog_idle, c);
540
+ }
541
+
542
+ /* ===== GTK 线程 ===== */
543
+
544
+ static void on_load_changed(WebKitWebView *wv, WebKitLoadEvent ev, gpointer ud) {
545
+ /* no-op */
546
+ }
547
+
548
+ static void on_script_message(WebKitUserContentManager *mgr,
549
+ WebKitJavascriptResult *result, gpointer ud) {
550
+ cj_host *h = (cj_host *)ud;
551
+ JSCValue *v = webkit_javascript_result_get_js_value(result);
552
+ char *s = jsc_value_to_string(v);
553
+ /* 前端 __CJ_TAURI__.reload():宿主控制消息,不进 IPC hub */
554
+ if (s && strcmp(s, CJT_RELOAD_MSG) == 0) {
555
+ fprintf(stderr, "[cj-bridge] frontend requested reload\n");
556
+ g_idle_add(reload_idle, h);
557
+ g_free(s);
558
+ return;
559
+ }
560
+ /* 入站日志:与 Windows 侧同一格式(bridge_win.c 的 js -> native (%d bytes))。
561
+ 此前只有 Windows 打这行,端到端取证时两平台不对称(AGENTS.md §1 的交付凭证就看它)。 */
562
+ fprintf(stderr, "[cj-bridge] js -> native (%d bytes)\n", (int)strlen(s ? s : ""));
563
+ if (h && h->on_message) {
564
+ h->on_message(s);
565
+ }
566
+ g_free(s);
567
+ }
568
+
569
+ static void on_destroy(GtkWidget *w, gpointer ud) {
570
+ cj_host *h = (cj_host *)ud;
571
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
572
+ if (p && p->window == w) {
573
+ p->window = NULL; /* 窗口已自毁:别在收尾里再碰它 */
574
+ /* view 是窗口的子 widget(gtk_container_add 把浮动引用沉给容器):父窗口销毁时子 widget
575
+ 一并销毁、容器那份引用也释放,指针随即失效。收尾要是再 unref 它,就是摸已释放内存——
576
+ 实测打出 g_object_unref: assertion 'G_IS_OBJECT (object)' failed。故一并置空。 */
577
+ p->view = NULL;
578
+ }
579
+ /* 置 should_quit + 触发 onDestroy(每个宿主恰好一次)+ 作废进行中的对话框 */
580
+ cj_core_window_destroyed(h);
581
+ gtk_main_quit();
582
+ }
583
+
584
+ /* 注入前端桥接脚本:暴露 window.__CJ_TAURI__ */
585
+ /* 平台唯一差异行(其余 JS 见 native/bridge_js.h,两平台共用一份,防漂移):
586
+ post 通道 = webkit.messageHandlers;reload 控制消息同通道发字面量。 */
587
+ #define CJ_LIT(s) s
588
+ #define CJ_POST_STMT " window.webkit.messageHandlers.cjtauri.postMessage(JSON.stringify(obj));"
589
+ #define CJ_RELOAD_STMT " window.webkit.messageHandlers.cjtauri.postMessage('__cj_tauri_reload__');"
590
+ #include "bridge_js.h"
591
+
592
+ static const char *BRIDGE_JS = CJ_BRIDGE_JS(CJ_POST_STMT, CJ_RELOAD_STMT);
593
+
594
+ static void *gtk_thread_main(void *arg) {
595
+ cj_host *h = (cj_host *)arg;
596
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
597
+ WebKitUserContentManager *mgr;
598
+ int i;
599
+
600
+ if (!p) return NULL;
601
+ /* 先记下 GTK 线程身份:对话框要靠它判断「调用方是不是已经在本线程上」 */
602
+ p->thread = pthread_self();
603
+ p->thread_set = 1;
604
+ gtk_init(NULL, NULL);
605
+
606
+ p->window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
607
+ gtk_window_set_title(GTK_WINDOW(p->window), h->win_title ? h->win_title : "cj-tauri");
608
+ gtk_window_set_default_size(GTK_WINDOW(p->window), h->win_w, h->win_h);
609
+ /* 窗口图标:失败只意味着用系统默认图标,不阻断启动(GTK 的 err 要自己释放) */
610
+ if (h->win_icon) {
611
+ GError *icon_err = NULL;
612
+ gboolean icon_ok =
613
+ gtk_window_set_icon_from_file(GTK_WINDOW(p->window), h->win_icon, &icon_err);
614
+ fprintf(stderr, "[cj-bridge] window icon: path=%s ok=%d%s%s\n",
615
+ h->win_icon, icon_ok ? 1 : 0,
616
+ icon_err ? " err=" : "", icon_err ? icon_err->message : "");
617
+ if (icon_err) g_error_free(icon_err);
618
+ }
619
+ g_signal_connect(p->window, "destroy", G_CALLBACK(on_destroy), h);
620
+
621
+ /* 注入时机与 Windows 侧对齐:那边是 AddScriptToExecuteOnDocumentCreated(页面脚本执行前)。
622
+ 这里原先用 AT_DOCUMENT_END,会让发行态单文件里的内联 <script type="module"> 抢在注入之前执行
623
+ —— 前端在模块作用域 / onMounted 直接读 window.__CJ_TAURI__ 会拿到 undefined
624
+ (开发态从 URL 加载、模块要现下载,反而掩盖了这个竞态)。所以必须 document-start。 */
625
+ mgr = webkit_user_content_manager_new();
626
+ webkit_user_content_manager_add_script(
627
+ mgr,
628
+ webkit_user_script_new(BRIDGE_JS,
629
+ WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
630
+ WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
631
+ NULL, NULL));
632
+ /* 预执行脚本按注册顺序追加在桥之后(同一 document-start 通道,脚本里可直接用 window.__CJ_TAURI__) */
633
+ for (i = 0; i < h->init_script_count; i++) {
634
+ webkit_user_content_manager_add_script(
635
+ mgr,
636
+ webkit_user_script_new(h->init_scripts[i],
637
+ WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
638
+ WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
639
+ NULL, NULL));
640
+ }
641
+ fprintf(stderr, "[cj-bridge] init scripts injected: %d\n", h->init_script_count);
642
+ webkit_user_content_manager_register_script_message_handler(mgr, "cjtauri");
643
+ g_signal_connect(mgr, "script-message-received::cjtauri",
644
+ G_CALLBACK(on_script_message), h);
645
+
646
+ p->view = webkit_web_view_new_with_user_content_manager(mgr);
647
+ {
648
+ /* 开发者工具开关:settings 交由视图持有(单实例、随窗口存续,不再额外 unref) */
649
+ WebKitSettings *settings = webkit_settings_new();
650
+ webkit_settings_set_enable_developer_extras(settings, h->devtools ? TRUE : FALSE);
651
+ webkit_web_view_set_settings(WEBKIT_WEB_VIEW(p->view), settings);
652
+ }
653
+ g_signal_connect(p->view, "load-changed", G_CALLBACK(on_load_changed), NULL);
654
+ /* 装配:window 里套一层竖向 GtkBox,顶部再套一层水平 GtkBox(菜单栏 + 副标题)。
655
+ 固定套这几层而不是「有菜单才套」——少一种装配分支就少一类时序坑。顶行常驻但
656
+ 默认不显示:菜单栏是按需重建的、副标题是运行期高频改的,两者都有落点时整行才
657
+ 该出现,显隐统一由 topbar_sync_visible() 决定(no_show_all 挡掉 show_all 的蔓延)。 */
658
+ p->box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
659
+ p->topbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
660
+ p->subtitle = gtk_label_new(NULL);
661
+ gtk_label_set_ellipsize(GTK_LABEL(p->subtitle), PANGO_ELLIPSIZE_END);
662
+ gtk_label_set_single_line_mode(GTK_LABEL(p->subtitle), TRUE);
663
+ gtk_widget_set_margin_start(p->subtitle, 8);
664
+ gtk_widget_set_margin_end(p->subtitle, 12);
665
+ gtk_box_pack_end(GTK_BOX(p->topbar), p->subtitle, FALSE, FALSE, 0);
666
+ gtk_widget_set_no_show_all(p->topbar, TRUE);
667
+ gtk_widget_set_no_show_all(p->subtitle, TRUE);
668
+ gtk_box_pack_start(GTK_BOX(p->box), p->topbar, FALSE, FALSE, 0);
669
+ gtk_box_pack_start(GTK_BOX(p->box), p->view, TRUE, TRUE, 0);
670
+ gtk_container_add(GTK_CONTAINER(p->window), p->box);
671
+ /* 装配期就已经 set_menu 过:这时把菜单栏建出来(运行期再改的走 g_idle_add 到本线程)*/
672
+ if (h->pending_menu_text && h->pending_menu_text[0]) {
673
+ menu_apply_wire(h, p, h->pending_menu_text);
674
+ }
675
+ gtk_widget_show_all(p->window);
676
+ /* 可见性定稿:装配期 setWindowVisible(false) 过的(h->win_visible == 0)建好就不显示
677
+ (与拖放 / 托盘同一条「未就绪先记、建窗时读」的路)*/
678
+ if (!h->win_visible) {
679
+ window_apply_visible(p, 0);
680
+ }
681
+ /* 最小化到托盘:拦 GTK 的图标化(真拦不拦看托盘**是否真的挂上了**,见 on_window_state)*/
682
+ g_signal_connect(p->window, "window-state-event", G_CALLBACK(on_window_state), h);
683
+ /* 拖放策略:装配期已 set_drag_drop 过的(pending 字段非 0)这时挂 drag dest;
684
+ 运行期再改的走 cj_plat_set_drag_drop 投 UI 线程重挂。默认关(0=不注册),现有应用零改动 */
685
+ if (h->pending_drop_enabled) {
686
+ drag_dest_apply(h, p, h->pending_drop_enabled, h->pending_drop_files);
687
+ }
688
+ #ifdef CJ_HAVE_TRAY
689
+ /* 托盘:装配期已 set_tray 过的这时建出来(appindicator 不依赖窗口,但要在 GTK 线程) */
690
+ if (h->pending_tray && h->pending_tray[0]) {
691
+ tray_apply_wire(h, p, h->pending_tray);
692
+ }
693
+ #endif
694
+ /* 全局快捷键:应用在 run() 之前 set 时 cj_bridge_init 还没跑(plat 未分配),
695
+ cj_plat_set_global_hotkeys 那会儿拿不到 plat 只能静默返回——托盘靠上面的装配点
696
+ 兜住了,快捷键同样在这里补延迟注册(grab 线程懒启动) */
697
+ if (h->pending_hotkeys && h->pending_hotkeys[0]) {
698
+ cj_plat_set_global_hotkeys(h, h->pending_hotkeys);
699
+ }
700
+ cj_core_set_ready(h);
701
+
702
+ /* 页面来源:URL 优先(cj_bridge_load_url),否则内联 HTML(cj_bridge_start) */
703
+ if (h->pending_url) {
704
+ webkit_web_view_load_uri(WEBKIT_WEB_VIEW(p->view), h->pending_url);
705
+ } else if (h->pending_html) {
706
+ webkit_web_view_load_html(WEBKIT_WEB_VIEW(p->view), h->pending_html, NULL);
707
+ }
708
+
709
+ gtk_main();
710
+
711
+ /* 主循环退出:收尾。用户关窗时窗口已自毁(on_destroy 里已置空),这里只收还活着的对象;
712
+ 一律置空,免得后续 quit_idle / cj_plat_fini 摸到已释放的 widget(此前悬垂指针的来源)。 */
713
+ if (p->window) {
714
+ /* 销毁必须留在主循环之外(quit_idle 只 gtk_main_quit):主循环内销毁 WebKit 窗口会让
715
+ libwebkit2gtk 的退出期析构函数 abort(退出码 134,见 quit_idle 上的注释)*/
716
+ gtk_widget_destroy(p->window); /* 子 widget 随父销毁:容器持有的那份引用这时才释放 */
717
+ p->window = NULL;
718
+ }
719
+ /* 我们从不额外持 view 的引用(那是容器的引用),所以这里没有可 unref 的东西:
720
+ 窗口一旦销毁,view 指针就已失效,只能置空。 */
721
+ p->view = NULL;
722
+ p->box = NULL; /* 随窗口一起销毁了 */
723
+ p->topbar = NULL; /* 同上:顶行与副标题都是窗口的子 widget */
724
+ p->subtitle = NULL;
725
+ p->menubar = NULL;
726
+ menu_clear_slots(p); /* 槽里的 app_id 是我们 malloc 的,跟平台状态一起收 */
727
+ #ifdef CJ_HAVE_TRAY
728
+ tray_remove(p); /* 托盘是应用级资源,不随窗口销毁:这里兜底摘除(幂等) */
729
+ #endif
730
+ /* 兜底:非关窗路径退出主循环时也要通知一次(幂等,正常路径已触发过) */
731
+ cj_core_window_destroyed(h);
732
+ /* 最后一步才立这个标记:cj_plat_fini 见到它才 free 平台状态(此刻本线程已不再碰 p)*/
733
+ p->thread_done = 1;
734
+ return NULL;
735
+ }
736
+
737
+ void cj_plat_start(cj_host *h) {
738
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
739
+ pthread_t t;
740
+ if (!p) {
741
+ fprintf(stderr, "[cj-bridge] start failed: platform not initialized\n");
742
+ if (h) h->should_quit = 1; /* 起不来就别让 run() 干等 */
743
+ return;
744
+ }
745
+ if (pthread_create(&t, NULL, gtk_thread_main, h) != 0) {
746
+ fprintf(stderr, "[cj-bridge] start failed: cannot create host thread\n");
747
+ h->should_quit = 1;
748
+ return;
749
+ }
750
+ /* 不再 detach:cj_plat_fini 需要 join 它,确认线程收尾完毕再释放平台状态 */
751
+ p->thread = t;
752
+ p->thread_started = 1;
753
+ }
754
+
755
+ /* ===== 菜单栏翻译层(RFC-002 §5.4):线路文本 → GtkMenuBar =====
756
+ 格式解析不在这里重复实现:走 core 的 cj_core_menu_walk(两平台共用一份,桩平台已自检)。
757
+ 本层只做三件事:把行翻译成 GTK 控件、把「用户激活」经 core 回投、维护运行期改状态的槽。 */
758
+
759
+ static void menu_clear_slots(plat_host *p) {
760
+ int i;
761
+ if (!p) return;
762
+ for (i = 0; i < p->menu_item_count; i++) {
763
+ free(p->menu_items[i].app_id);
764
+ p->menu_items[i].app_id = NULL;
765
+ p->menu_items[i].widget = NULL;
766
+ p->menu_items[i].ctx = NULL;
767
+ }
768
+ p->menu_item_count = 0;
769
+ }
770
+
771
+ /* 普通项:activate = 用户点选(没有勾选状态可报,checked 恒 0)*/
772
+ static void on_menu_item_activate(GtkMenuItem *item, gpointer data) {
773
+ menu_item_ctx *c = (menu_item_ctx *)data;
774
+ (void)item;
775
+ if (!c || !c->h || c->suppress) return;
776
+ /* 勾选项改状态会挪动菜单栏里的位置,所以每次点击后重投一次几何读回(见 menu_post_layout) */
777
+ menu_post_layout(c->h);
778
+ cj_core_menu_clicked(c->h, c->app_id ? c->app_id : "", c->enabled, c->checked);
779
+ }
780
+
781
+ /* 勾选项:toggled 在 active 变化之后发(含用户点击与程序化设置,后者被 suppress 挡掉)*/
782
+ static void on_menu_item_toggled(GtkCheckMenuItem *item, gpointer data) {
783
+ menu_item_ctx *c = (menu_item_ctx *)data;
784
+ if (!c || !c->h || c->suppress) return;
785
+ c->checked = gtk_check_menu_item_get_active(item) ? 1 : 0;
786
+ menu_post_layout(c->h); /* 同上:勾选指示器状态变了,重读一次几何 */
787
+ cj_core_menu_clicked(c->h, c->app_id ? c->app_id : "", c->enabled, c->checked);
788
+ }
789
+
790
+ /* 菜单项随菜单栏销毁:ctx 归本次构建所有,在这里释放(槽里的那份 app_id 另算)*/
791
+ static void on_menu_item_destroy(GtkWidget *w, gpointer data) {
792
+ menu_item_ctx *c = (menu_item_ctx *)data;
793
+ (void)w;
794
+ if (!c) return;
795
+ free(c->app_id);
796
+ free(c);
797
+ }
798
+
799
+ static void menu_apply_item_state(plat_host *p, const char *id, int enabled, int checked) {
800
+ int i;
801
+ if (!p || !id || !id[0]) return;
802
+ for (i = 0; i < p->menu_item_count; i++) {
803
+ menu_slot *s = &p->menu_items[i];
804
+ if (!s->widget || !s->app_id || strcmp(s->app_id, id) != 0) continue;
805
+ s->enabled = enabled ? 1 : 0;
806
+ s->checked = checked ? 1 : 0;
807
+ gtk_widget_set_sensitive(s->widget, s->enabled ? TRUE : FALSE);
808
+ if (s->ctx) {
809
+ s->ctx->enabled = s->enabled;
810
+ s->ctx->checked = s->checked;
811
+ }
812
+ if (s->kind == 'c') {
813
+ /* 程序化改勾选同样会发 toggled:置 suppress 把它挡掉,免得被当成一次用户点击 */
814
+ if (s->ctx) s->ctx->suppress = 1;
815
+ gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(s->widget),
816
+ s->checked ? TRUE : FALSE);
817
+ if (s->ctx) s->ctx->suppress = 0;
818
+ }
819
+ /* 同上:core 那句 `set menu item:` 只说明「我调过」,这里从 GTK 侧读回真正生效的值。
820
+ 两者不一致(例如 widget 已随菜单栏销毁)就说明这条改动没落到菜单上。 */
821
+ fprintf(stderr, "[cj-bridge] menu item readback: id=%s sensitive=%d active=%d\n", s->app_id,
822
+ gtk_widget_get_sensitive(s->widget) ? 1 : 0,
823
+ (s->kind == 'c')
824
+ ? (gtk_check_menu_item_get_active(GTK_CHECK_MENU_ITEM(s->widget)) ? 1 : 0)
825
+ : 0);
826
+ return;
827
+ }
828
+ fprintf(stderr, "[cj-bridge] menu item not found: %s\n", id);
829
+ }
830
+
831
+ /* 建菜单时的上下文:层级用一张 GtkMenu 栈表示(行按文档顺序来,深度只会 +1 或回退)*/
832
+ typedef struct menu_build_ctx {
833
+ cj_host *h;
834
+ plat_host *p;
835
+ GtkWidget *menubar;
836
+ GtkWidget *stack[CJ_MENU_MAX_DEPTH]; /* stack[d]:深度 d 的行往哪个 GtkMenu 里追加 */
837
+ int ok[CJ_MENU_MAX_DEPTH]; /* 该层是否已有子菜单可挂(没有就退回上一层)*/
838
+ } menu_build_ctx;
839
+
840
+ static void menu_add_row(void *ctx, int depth, char kind, const char *id,
841
+ const char *label, int flags, const char *accel) {
842
+ menu_build_ctx *c = (menu_build_ctx *)ctx;
843
+ plat_host *p;
844
+ GtkWidget *parent_menu;
845
+ GtkWidget *item;
846
+ menu_item_ctx *ic;
847
+ menu_slot *slot;
848
+ char *text;
849
+ int enabled = (flags & 1) ? 1 : 0;
850
+ int checked = (flags & 2) ? 1 : 0;
851
+
852
+ if (!c || !c->p) return;
853
+ p = c->p;
854
+ if (depth < 0) depth = 0;
855
+ if (depth >= CJ_MENU_MAX_DEPTH) depth = CJ_MENU_MAX_DEPTH - 1;
856
+ parent_menu = (depth > 0 && c->ok[depth]) ? c->stack[depth] : c->menubar;
857
+
858
+ if (kind == 's') { /* 分隔线:既没有命令 id,也没有文字 */
859
+ gtk_menu_shell_append(GTK_MENU_SHELL(parent_menu), gtk_separator_menu_item_new());
860
+ return;
861
+ }
862
+ /* 标签:accel 非空时接在文字后面(GTK3 的快捷键要走 accel_group,v1 只做提示文本;
863
+ 这里用空格而不是制表符——GTK 的标签不像 Win32 那样把 \t 之后的部分右对齐)*/
864
+ {
865
+ size_t n = strlen(label) + (accel[0] ? strlen(accel) + 2 : 0) + 1;
866
+ text = (char *)malloc(n);
867
+ if (!text) return;
868
+ snprintf(text, n, "%s%s%s", label, accel[0] ? " " : "", accel);
869
+ }
870
+
871
+ if (kind == 'm') { /* 子菜单:建出 GtkMenu 挂到子菜单项上,并让深一层的行知道往哪挂 */
872
+ GtkWidget *submenu = gtk_menu_new();
873
+ item = gtk_menu_item_new_with_label(text);
874
+ free(text);
875
+ gtk_menu_item_set_submenu(GTK_MENU_ITEM(item), submenu);
876
+ gtk_widget_set_sensitive(item, enabled ? TRUE : FALSE);
877
+ gtk_menu_shell_append(GTK_MENU_SHELL(parent_menu), item);
878
+ if (depth + 1 < CJ_MENU_MAX_DEPTH) {
879
+ c->stack[depth + 1] = submenu;
880
+ c->ok[depth + 1] = 1;
881
+ }
882
+ return;
883
+ }
884
+
885
+ if (p->menu_item_count >= CJ_MENU_MAX_ITEMS) {
886
+ fprintf(stderr, "[cj-bridge] menu: item limit reached, dropped: %s\n", id);
887
+ free(text);
888
+ return;
889
+ }
890
+ item = (kind == 'c') ? gtk_check_menu_item_new_with_label(text)
891
+ : gtk_menu_item_new_with_label(text);
892
+ free(text);
893
+ gtk_widget_set_sensitive(item, enabled ? TRUE : FALSE);
894
+ if (kind == 'c') {
895
+ gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item), checked ? TRUE : FALSE);
896
+ }
897
+ ic = (menu_item_ctx *)calloc(1, sizeof(*ic));
898
+ if (ic) {
899
+ ic->h = c->h;
900
+ ic->app_id = strdup(id);
901
+ ic->kind = kind;
902
+ ic->enabled = enabled;
903
+ ic->checked = checked;
904
+ if (kind == 'c') {
905
+ g_signal_connect(item, "toggled", G_CALLBACK(on_menu_item_toggled), ic);
906
+ } else {
907
+ g_signal_connect(item, "activate", G_CALLBACK(on_menu_item_activate), ic);
908
+ }
909
+ g_signal_connect(item, "destroy", G_CALLBACK(on_menu_item_destroy), ic);
910
+ } else {
911
+ fprintf(stderr, "[cj-bridge] menu: out of memory for item ctx (%s)\n", id);
912
+ }
913
+ gtk_menu_shell_append(GTK_MENU_SHELL(parent_menu), item);
914
+
915
+ slot = &p->menu_items[p->menu_item_count++];
916
+ free(slot->app_id); /* 槽是复用的,清掉上一轮残留 */
917
+ slot->app_id = strdup(id);
918
+ slot->widget = item;
919
+ slot->ctx = ic;
920
+ slot->kind = kind;
921
+ slot->enabled = enabled;
922
+ slot->checked = checked;
923
+ }
924
+
925
+ /* ===== 菜单几何的事后读回(实机驱动用) =====
926
+ 为什么需要:鼠标驱动的探针要把点击落在「项的几何中心」,而**像素偏移是写不住的**——
927
+ 窗口位置每轮都可能不同(实测同一份脚本两轮:一轮窗口在 0,0,一轮在 60,83),
928
+ 写死 +45/+135 这类偏移会点到隔壁项(菜单轮实机第一版就是这么点歪的)。
929
+ 所以让 GTK 自己说:id=x+w+h,按菜单栏顺序打一行;分隔线与子菜单项没有应用 id,打 '-'。
930
+ 为什么走**低优先级** idle:刚 pack 上去那一刻还没走完分配,读到的可能是 0;排到低优先级
931
+ 就等于「当前待处理的事件与重绘先做完」,这时拿到的才是最终值。每次用户点击后再投一次,
932
+ 这样驱动脚本每次点击前都能从日志里取到**最新**几何(勾选指示器一类的状态变化会挪位置)。 */
933
+ typedef struct menu_layout_idle {
934
+ cj_host *h;
935
+ } menu_layout_idle;
936
+
937
+ static gboolean menu_layout_idle_cb(gpointer data) {
938
+ menu_layout_idle *m = (menu_layout_idle *)data;
939
+ GString *s;
940
+ if (!m) return G_SOURCE_REMOVE;
941
+ s = g_string_new(NULL);
942
+ if (m->h) {
943
+ plat_host *p = (plat_host *)m->h->plat;
944
+ /* 窗口可能已经销毁(销毁后 p->menubar 为 NULL):这里不再碰 GTK */
945
+ if (p && p->window && p->menubar) {
946
+ GList *kids = gtk_container_get_children(GTK_CONTAINER(p->menubar));
947
+ GList *it;
948
+ for (it = kids; it; it = it->next) {
949
+ GtkWidget *w = GTK_WIDGET(it->data);
950
+ GtkAllocation a;
951
+ const char *id = "-";
952
+ int i;
953
+ for (i = 0; i < p->menu_item_count; i++) {
954
+ if (p->menu_items[i].widget == w) {
955
+ id = p->menu_items[i].app_id;
956
+ break;
957
+ }
958
+ }
959
+ gtk_widget_get_allocation(w, &a);
960
+ g_string_append_printf(s, "%s=%d+%d+%d ", id, a.x, a.width, a.height);
961
+ }
962
+ g_list_free(kids);
963
+ }
964
+ }
965
+ fprintf(stderr, "[cj-bridge] menu layout: %s\n", s->str);
966
+ g_string_free(s, TRUE);
967
+ free(m);
968
+ return G_SOURCE_REMOVE;
969
+ }
970
+
971
+ static void menu_post_layout(cj_host *h) {
972
+ menu_layout_idle *m = (menu_layout_idle *)calloc(1, sizeof(*m));
973
+ if (!m) return;
974
+ m->h = h;
975
+ g_idle_add_full(G_PRIORITY_LOW, menu_layout_idle_cb, m, NULL);
976
+ }
977
+
978
+ /* 顶部那一行的显隐:菜单栏挂着、或副标题文本非空时整行才出现。
979
+ 空菜单栏不留空条(原有约束)同样适用于空副标题——否则应用没设副标题时窗口顶部
980
+ 会凭空多出一条空行高度。 */
981
+ static void topbar_sync_visible(plat_host *p) {
982
+ const char *t;
983
+
984
+ if (!p) return;
985
+ t = p->subtitle ? gtk_label_get_text(GTK_LABEL(p->subtitle)) : "";
986
+ if (p->subtitle) {
987
+ if (t && t[0]) gtk_widget_show(p->subtitle);
988
+ else gtk_widget_hide(p->subtitle);
989
+ }
990
+ if (p->topbar) {
991
+ if (p->menubar || (t && t[0])) gtk_widget_show(p->topbar);
992
+ else gtk_widget_hide(p->topbar);
993
+ }
994
+ }
995
+
996
+ static void menu_apply_wire(cj_host *h, plat_host *p, const char *wire) {
997
+ menu_build_ctx c;
998
+
999
+ /* p->window 为 NULL 说明窗口已销毁(on_destroy 会置空):晚到的 idle 就别再碰 GTK 了 */
1000
+ if (!p || !p->window || !p->box) return;
1001
+ /* 旧菜单栏整体拆掉:子项随父销毁,各自的 ctx 由 destroy 处理器释放 */
1002
+ if (p->menubar) {
1003
+ gtk_widget_destroy(p->menubar);
1004
+ p->menubar = NULL;
1005
+ }
1006
+ menu_clear_slots(p);
1007
+ if (!wire || !wire[0]) { /* 空串 = 清空菜单栏(不留一条空条)*/
1008
+ topbar_sync_visible(p);
1009
+ fprintf(stderr, "[cj-bridge] menu cleared\n");
1010
+ return;
1011
+ }
1012
+ memset(&c, 0, sizeof(c)); /* 不用 ZeroMemory:那是 Windows 宏,本文件没有它 */
1013
+ c.h = h;
1014
+ c.p = p;
1015
+ c.menubar = gtk_menu_bar_new();
1016
+ c.stack[0] = c.menubar;
1017
+ c.ok[0] = 1;
1018
+ cj_core_menu_walk(wire, menu_add_row, &c);
1019
+ p->menubar = c.menubar;
1020
+ /* 菜单栏装进顶行(topbar 常驻;万一没有则回落 box,保持旧装配可用的形状)*/
1021
+ if (p->topbar) {
1022
+ gtk_box_pack_start(GTK_BOX(p->topbar), p->menubar, FALSE, FALSE, 0);
1023
+ gtk_box_reorder_child(GTK_BOX(p->topbar), p->menubar, 0); /* 菜单栏在副标题左侧 */
1024
+ } else {
1025
+ gtk_box_pack_start(GTK_BOX(p->box), p->menubar, FALSE, FALSE, 0);
1026
+ gtk_box_reorder_child(GTK_BOX(p->box), p->menubar, 0); /* 菜单栏必须在 view 之上 */
1027
+ }
1028
+ gtk_widget_show_all(p->menubar);
1029
+ topbar_sync_visible(p);
1030
+ /* 凭证是「事后从 GTK 侧读回来」,不是「我调过 gtk_menu_bar_new 了」:菜单栏没挂进 box、
1031
+ 或条目一个都没建出来时,光打一句 applied 会把人骗过去(Windows 侧 SetMenu 静默失败
1032
+ 正是这么踩的,见 bridge_win.c 的 GetMenu 复核)。bar_children 是菜单栏的顶层项数
1033
+ (含分隔线),items 是全部槽位(含子菜单里的项),两者不等是正常的。 */
1034
+ {
1035
+ GList *kids = gtk_container_get_children(GTK_CONTAINER(p->menubar));
1036
+ fprintf(stderr,
1037
+ "[cj-bridge] menu applied: items=%d bar=%p parent=%p bar_children=%u visible=%d\n",
1038
+ p->menu_item_count, (void *)p->menubar, (void *)gtk_widget_get_parent(p->menubar),
1039
+ (unsigned)g_list_length(kids), gtk_widget_get_visible(p->menubar) ? 1 : 0);
1040
+ g_list_free(kids);
1041
+ }
1042
+ menu_post_layout(h); /* 布局走完再读一次几何(低优先级 idle),驱动脚本据此定位 */
1043
+ }
1044
+
1045
+ /* ===== 窗口顶部副标题(与菜单栏同一行的右侧)===== */
1046
+ /* 运行期高频更新(标题 / 保存状态 / 字数),一律投 g_idle 到 GTK 线程——
1047
+ 与 set_menu 同一条纪律:仓颉侧任何线程都可能调用,别在主循环外碰 widget。 */
1048
+ typedef struct subtitle_idle {
1049
+ cj_host *h;
1050
+ char *text;
1051
+ } subtitle_idle;
1052
+
1053
+ static gboolean subtitle_idle_cb(gpointer data) {
1054
+ subtitle_idle *s = (subtitle_idle *)data;
1055
+
1056
+ if (!s) return G_SOURCE_REMOVE;
1057
+ {
1058
+ plat_host *p = s->h ? (plat_host *)s->h->plat : NULL;
1059
+ if (p && p->window && p->subtitle) {
1060
+ gtk_label_set_text(GTK_LABEL(p->subtitle), s->text ? s->text : "");
1061
+ topbar_sync_visible(p);
1062
+ /* 凭证:文本回读 + 整行显隐都打一行,避免「我设过了」式的假成功
1063
+ (菜单那轮的教训:SetMenu 返回 FALSE 也照打 applied) */
1064
+ fprintf(stderr, "[cj-bridge] titlebar subtitle: \"%s\" topbar_visible=%d\n",
1065
+ gtk_label_get_text(GTK_LABEL(p->subtitle)),
1066
+ (p->topbar && gtk_widget_get_visible(p->topbar)) ? 1 : 0);
1067
+ }
1068
+ }
1069
+ free(s->text);
1070
+ free(s);
1071
+ return G_SOURCE_REMOVE;
1072
+ }
1073
+
1074
+ int cj_plat_set_titlebar_subtitle(cj_host *h, const char *text) {
1075
+ plat_host *p;
1076
+ subtitle_idle *s;
1077
+
1078
+ if (!h || !text) return -1;
1079
+ p = (plat_host *)h->plat;
1080
+ if (!p || !p->window) return -3;
1081
+ s = (subtitle_idle *)calloc(1, sizeof(*s));
1082
+ if (!s) return -3;
1083
+ s->h = h;
1084
+ s->text = strdup(text);
1085
+ if (!s->text) {
1086
+ free(s);
1087
+ return -3;
1088
+ }
1089
+ if (!g_idle_add(subtitle_idle_cb, s)) {
1090
+ free(s->text);
1091
+ free(s);
1092
+ return -3;
1093
+ }
1094
+ return 0;
1095
+ }
1096
+
1097
+ /* 跨线程换菜单:线路文本进 g_idle 队列,由 GTK 线程消费(payload 谁分配谁在 idle 里释放)*/
1098
+ typedef struct menu_wire_idle {
1099
+ cj_host *h;
1100
+ char *wire;
1101
+ } menu_wire_idle;
1102
+
1103
+ static gboolean menu_wire_idle_cb(gpointer data) {
1104
+ menu_wire_idle *m = (menu_wire_idle *)data;
1105
+ if (!m) return G_SOURCE_REMOVE;
1106
+ {
1107
+ plat_host *p = m->h ? (plat_host *)m->h->plat : NULL;
1108
+ if (p) menu_apply_wire(m->h, p, m->wire);
1109
+ }
1110
+ free(m->wire);
1111
+ free(m);
1112
+ return G_SOURCE_REMOVE;
1113
+ }
1114
+
1115
+ void cj_plat_set_menu(cj_host *h, const char *wire) {
1116
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1117
+ if (!p || !wire) return;
1118
+ if (!p->window) {
1119
+ /* 未就绪:core 已把线路文本记在 h->pending_menu_text,宿主线程装配时取用 */
1120
+ fprintf(stderr, "[cj-bridge] menu deferred: window not ready\n");
1121
+ return;
1122
+ }
1123
+ if (cj_plat_is_ui_thread(h)) {
1124
+ menu_apply_wire(h, p, wire);
1125
+ return;
1126
+ }
1127
+ {
1128
+ menu_wire_idle *m = (menu_wire_idle *)calloc(1, sizeof(*m));
1129
+ if (!m) return;
1130
+ m->h = h;
1131
+ m->wire = strdup(wire);
1132
+ if (!m->wire) {
1133
+ free(m);
1134
+ return;
1135
+ }
1136
+ g_idle_add(menu_wire_idle_cb, m);
1137
+ fprintf(stderr, "[cj-bridge] menu posted to gtk thread (%d bytes)\n", (int)strlen(wire));
1138
+ }
1139
+ }
1140
+
1141
+ /* 跨线程改单项状态:同样走 g_idle(与换菜单分开一个包裹,互不阻塞)*/
1142
+ typedef struct menu_state_idle {
1143
+ cj_host *h;
1144
+ char *id;
1145
+ int enabled;
1146
+ int checked;
1147
+ } menu_state_idle;
1148
+
1149
+ static gboolean menu_state_idle_cb(gpointer data) {
1150
+ menu_state_idle *m = (menu_state_idle *)data;
1151
+ if (!m) return G_SOURCE_REMOVE;
1152
+ {
1153
+ plat_host *p = m->h ? (plat_host *)m->h->plat : NULL;
1154
+ if (p) menu_apply_item_state(p, m->id, m->enabled, m->checked);
1155
+ }
1156
+ free(m->id);
1157
+ free(m);
1158
+ return G_SOURCE_REMOVE;
1159
+ }
1160
+
1161
+ void cj_plat_menu_item_state(cj_host *h, const char *id, int enabled, int checked) {
1162
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1163
+ if (!p || !id || !id[0]) return;
1164
+ if (!p->window || !p->menubar) {
1165
+ fprintf(stderr, "[cj-bridge] menu item state ignored: menu not ready (%s)\n", id);
1166
+ return;
1167
+ }
1168
+ if (cj_plat_is_ui_thread(h)) {
1169
+ menu_apply_item_state(p, id, enabled, checked);
1170
+ return;
1171
+ }
1172
+ {
1173
+ menu_state_idle *m = (menu_state_idle *)calloc(1, sizeof(*m));
1174
+ if (!m) return;
1175
+ m->h = h;
1176
+ m->id = strdup(id);
1177
+ m->enabled = enabled ? 1 : 0;
1178
+ m->checked = checked ? 1 : 0;
1179
+ if (!m->id) {
1180
+ free(m);
1181
+ return;
1182
+ }
1183
+ g_idle_add(menu_state_idle_cb, m);
1184
+ }
1185
+ }
1186
+
1187
+ /* ===== 拖放策略(7.B Linux 侧):两头都支持——未就绪只记(建 view 时读 pending 字段),
1188
+ 就绪后投 UI 线程重挂 drag dest(GTK 对象只能在 GTK 线程碰)===== */
1189
+
1190
+ typedef struct drag_idle {
1191
+ cj_host *h;
1192
+ int enabled;
1193
+ int files;
1194
+ } drag_idle;
1195
+
1196
+ static gboolean drag_idle_cb(gpointer data) {
1197
+ drag_idle *m = (drag_idle *)data;
1198
+ if (!m) return G_SOURCE_REMOVE;
1199
+ {
1200
+ plat_host *p = m->h ? (plat_host *)m->h->plat : NULL;
1201
+ if (p) drag_dest_apply(m->h, p, m->enabled, m->files);
1202
+ }
1203
+ free(m);
1204
+ return G_SOURCE_REMOVE;
1205
+ }
1206
+
1207
+ void cj_plat_set_drag_drop(cj_host *h, int enabled, int files) {
1208
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1209
+ if (!p) return;
1210
+ if (!p->window) {
1211
+ /* 未就绪:core 已把策略写进 h->pending_drop_*,建 view 时读 */
1212
+ return;
1213
+ }
1214
+ if (cj_plat_is_ui_thread(h)) {
1215
+ drag_dest_apply(h, p, enabled, files);
1216
+ return;
1217
+ }
1218
+ {
1219
+ drag_idle *m = (drag_idle *)calloc(1, sizeof(*m));
1220
+ if (!m) return;
1221
+ m->h = h;
1222
+ m->enabled = enabled ? 1 : 0;
1223
+ m->files = files ? 1 : 0;
1224
+ g_idle_add(drag_idle_cb, m);
1225
+ }
1226
+ }
1227
+
1228
+ /* ===== 窗口可见性 / 最小化到托盘(RFC-003 第 5 期余项)=====
1229
+ 两条都只动**窗口层**:gtk_widget_hide() 不销毁窗口与 WebKitWebView,页面照旧跑、轮询照旧,
1230
+ 只是看不见——所以「最小化到托盘」不需要任何页面侧配合。还原入口是应用自己的事
1231
+ (托盘菜单 / 全局快捷键里调 setWindowVisible(true)),框架不替它决定。
1232
+
1233
+ 拦截点在 GTK 的 "window-state-event":用户按下最小化时窗口进入 ICONIFIED,若策略开着
1234
+ **且托盘真的挂上了**,就把窗口 hide 掉并吃掉这个事件(返回 TRUE,GTK 不再图标化),
1235
+ 从用户视角看就是「最小化进了托盘」。托盘没挂上就如实降级为普通最小化 + 一行 stderr
1236
+ (与 Windows 侧 WM_SYSCOMMAND 同一条纪律:宁可说出「不支持」,也不能让窗口藏起来没入口点回来)。 */
1237
+
1238
+ /** 托盘是否真的挂上了(CJ_HAVE_TRAY 没编进来时恒为 0:能力位如实,这里也如实)*/
1239
+ static int tray_installed(plat_host *p) {
1240
+ #ifdef CJ_HAVE_TRAY
1241
+ return p->tray != NULL;
1242
+ #else
1243
+ (void)p;
1244
+ return 0;
1245
+ #endif
1246
+ }
1247
+
1248
+ /** 用户按下最小化(或从最小化恢复)时触发:策略 + 托盘齐备才改写成「藏进托盘」*/
1249
+ static gboolean on_window_state(GtkWidget *w, GdkEventWindowState *ev, gpointer ud) {
1250
+ cj_host *h = (cj_host *)ud;
1251
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1252
+ if (!p || !ev || !h->minimize_to_tray) return FALSE;
1253
+ if (!(ev->new_window_state & GDK_WINDOW_STATE_ICONIFIED)) return FALSE;
1254
+ if (tray_installed(p)) {
1255
+ gtk_widget_hide(w);
1256
+ fprintf(stderr, "[cj-bridge] minimize -> tray (window hidden)\n");
1257
+ return TRUE; /* 吃掉事件:GTK 不再执行默认的图标化 */
1258
+ }
1259
+ if (!p->min_to_tray_warned) {
1260
+ p->min_to_tray_warned = 1; /* 只出声一次:用户每按一次最小化都打会刷屏 */
1261
+ fprintf(stderr, "[cj-bridge] minimize_to_tray: no tray installed - plain minimize\n");
1262
+ }
1263
+ return FALSE;
1264
+ }
1265
+
1266
+ static void window_apply_visible(plat_host *p, int visible) {
1267
+ if (!p || !p->window) return;
1268
+ if (visible) {
1269
+ gtk_widget_show(p->window);
1270
+ gtk_window_present(GTK_WINDOW(p->window)); /* 藏过的窗口 show 不会自己激活:顺手提到前面 */
1271
+ } else {
1272
+ gtk_widget_hide(p->window);
1273
+ }
1274
+ fprintf(stderr, "[cj-bridge] window %s\n", visible ? "shown" : "hidden");
1275
+ }
1276
+
1277
+ typedef struct visible_idle {
1278
+ cj_host *h;
1279
+ int visible;
1280
+ } visible_idle;
1281
+
1282
+ static gboolean visible_idle_cb(gpointer data) {
1283
+ visible_idle *m = (visible_idle *)data;
1284
+ if (!m) return G_SOURCE_REMOVE;
1285
+ {
1286
+ plat_host *p = m->h ? (plat_host *)m->h->plat : NULL;
1287
+ if (p) window_apply_visible(p, m->visible);
1288
+ }
1289
+ free(m);
1290
+ return G_SOURCE_REMOVE;
1291
+ }
1292
+
1293
+ void cj_plat_set_window_visible(cj_host *h, int visible) {
1294
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1295
+ if (!p) return;
1296
+ if (!p->window) {
1297
+ /* 未就绪:core 已把可见性写进 h->win_visible,建窗时读(与拖放同一条路)*/
1298
+ return;
1299
+ }
1300
+ if (cj_plat_is_ui_thread(h)) {
1301
+ window_apply_visible(p, visible);
1302
+ return;
1303
+ }
1304
+ {
1305
+ visible_idle *m = (visible_idle *)calloc(1, sizeof(*m));
1306
+ if (!m) return;
1307
+ m->h = h;
1308
+ m->visible = visible ? 1 : 0;
1309
+ g_idle_add(visible_idle_cb, m);
1310
+ }
1311
+ }
1312
+
1313
+ /* 最小化到托盘这里只**记一条策略**(并复位提醒位):真正的拦截发生在用户按下最小化的那一刻
1314
+ (window-state-event),此刻窗口层没有可做的事——与 Windows 侧同形。 */
1315
+ void cj_plat_set_minimize_to_tray(cj_host *h, int enabled) {
1316
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1317
+ if (!p) return;
1318
+ p->min_to_tray_warned = 0;
1319
+ fprintf(stderr, "[cj-bridge] minimize_to_tray: %s\n", enabled ? "on" : "off");
1320
+ }
1321
+
1322
+ int cj_plat_host_capabilities(cj_host *h) {
1323
+ (void)h;
1324
+ /* 本平台的静态能力:菜单栏已落地(GTK 的菜单栏不需要可选依赖);拖放已落地(7.B,
1325
+ gtk_drag_dest,无新依赖);通知走 freedesktop Notifications(会话总线),
1326
+ Xvfb 下无通知守护进程时命令会失败——能力位表示「协议层可用」,运行期成败由命令如实回传。
1327
+ 全局快捷键按 CJ_HAVE_X11 编译期开关如实(RFC-002 §6.3 可选编译;运行期 X 打不开
1328
+ (Wayland)在 grab 线程里再打一行,位域只表达编译期事实)。
1329
+ 托盘同理按 CJ_HAVE_TRAY 如实(libayatana-appindicator3 编进来了才置位)。 */
1330
+ #ifdef CJ_HAVE_X11
1331
+ #ifdef CJ_HAVE_TRAY
1332
+ return CJ_CAP_MENU | CJ_CAP_DRAG_DROP | CJ_CAP_NOTIFICATION | CJ_CAP_HOTKEY | CJ_CAP_TRAY |
1333
+ CJ_CAP_SUBTITLE;
1334
+ #else
1335
+ return CJ_CAP_MENU | CJ_CAP_DRAG_DROP | CJ_CAP_NOTIFICATION | CJ_CAP_HOTKEY | CJ_CAP_SUBTITLE;
1336
+ #endif
1337
+ #else
1338
+ #ifdef CJ_HAVE_TRAY
1339
+ return CJ_CAP_MENU | CJ_CAP_DRAG_DROP | CJ_CAP_NOTIFICATION | CJ_CAP_TRAY | CJ_CAP_SUBTITLE;
1340
+ #else
1341
+ return CJ_CAP_MENU | CJ_CAP_DRAG_DROP | CJ_CAP_NOTIFICATION | CJ_CAP_SUBTITLE;
1342
+ #endif
1343
+ #endif
1344
+ }
1345
+
1346
+ /* ===== 拖放(RFC-002 §5;7.B Linux 侧)===== */
1347
+
1348
+ /* drag-data-received:把 text/uri-list 原文交 core 解析(解码/上限/报文都在 core),
1349
+ 本回调只做「取数据 + 转交 + finish」。动作语义由 core/上层定,这里不区分 copy/move。 */
1350
+ static void on_drag_data_received(GtkWidget *widget, GdkDragContext *ctx,
1351
+ gint x, gint y, GtkSelectionData *sel,
1352
+ guint info, guint time, gpointer ud) {
1353
+ cj_host *h = (cj_host *)ud;
1354
+ (void)widget; (void)x; (void)y; (void)info;
1355
+ if (sel) {
1356
+ const guchar *data = gtk_selection_data_get_data(sel);
1357
+ gint len = gtk_selection_data_get_length(sel);
1358
+ if (data && len > 0) {
1359
+ char *uri_list = (char *)g_malloc((gsize)len + 1);
1360
+ if (uri_list) {
1361
+ memcpy(uri_list, data, (size_t)len);
1362
+ uri_list[len] = '\0';
1363
+ cj_core_drop_files(h, uri_list);
1364
+ g_free(uri_list);
1365
+ }
1366
+ }
1367
+ }
1368
+ gtk_drag_finish(ctx, sel != NULL && gtk_selection_data_get_length(sel) > 0,
1369
+ FALSE, time);
1370
+ }
1371
+
1372
+ /* 挂/摘 drag dest(装配期与运行期共用:enabled=0 摘除)。files=0 也先照挂——
1373
+ v1 载荷只有文件路径,files 开关留给将来的文本载荷;策略语义在仓颉侧把关 */
1374
+ static void drag_dest_apply(cj_host *h, plat_host *p, int enabled, int files) {
1375
+ (void)files;
1376
+ if (!p || !p->view) return;
1377
+ if (enabled) {
1378
+ static const GtkTargetEntry targets[] = {
1379
+ { (gchar *)"text/uri-list", 0, 0 },
1380
+ };
1381
+ gtk_drag_dest_set(p->view, GTK_DEST_DEFAULT_ALL, targets, 1, GDK_ACTION_COPY);
1382
+ g_signal_connect(p->view, "drag-data-received",
1383
+ G_CALLBACK(on_drag_data_received), h);
1384
+ fprintf(stderr, "[cj-bridge] drag dest attached (uri-list)\n");
1385
+ } else {
1386
+ gtk_drag_dest_unset(p->view);
1387
+ fprintf(stderr, "[cj-bridge] drag dest detached\n");
1388
+ }
1389
+ }
1390
+
1391
+ /* =========================================================================
1392
+ * 托盘(RFC-002 §7.C;Linux = libayatana-appindicator3,可选编译 CJ_HAVE_TRAY)
1393
+ *
1394
+ * 线程纪律与菜单相同:AppIndicator / GtkMenu 只在 GTK 线程上创建与释放
1395
+ * (activate 回调本来就是 GTK 主循环在发,交回 core 不跨线程)。运行期整表重建走
1396
+ * g_idle_add 投到本线程;宿主线程收尾段(gtk_main 返回后)unref 兜底摘除。
1397
+ * 菜单行走 cj_core_menu_walk 的同一遍解析(平铺一层,depth 恒 0),
1398
+ * 头部两行 icon/tooltip 由本层自己切出来。
1399
+ * ========================================================================= */
1400
+ #ifdef CJ_HAVE_TRAY
1401
+
1402
+ /* 托盘菜单项的回调上下文:id 是我们 malloc 的(随 widget 存亡,走 destroy notify 释放) */
1403
+ typedef struct tray_item_ctx {
1404
+ cj_host *h;
1405
+ char *id;
1406
+ } tray_item_ctx;
1407
+
1408
+ static void tray_item_ctx_free(gpointer data, GClosure *closure) {
1409
+ tray_item_ctx *c = (tray_item_ctx *)data;
1410
+ (void)closure;
1411
+ if (!c) return;
1412
+ free(c->id);
1413
+ free(c);
1414
+ }
1415
+
1416
+ static void tray_on_activate(GtkMenuItem *item, gpointer ud) {
1417
+ tray_item_ctx *c = (tray_item_ctx *)ud;
1418
+ (void)item;
1419
+ if (!c || !c->h) return;
1420
+ cj_core_tray_clicked(c->h, c->id);
1421
+ }
1422
+
1423
+ typedef struct tray_build_ctx {
1424
+ cj_host *h;
1425
+ GtkMenuShell *menu;
1426
+ } tray_build_ctx;
1427
+
1428
+ /** 平铺一层的托盘菜单:kind 与菜单线路同码(n/c/s;m 丢弃——v1 不支持子菜单) */
1429
+ static void tray_add_row(void *ctx, int depth, char kind, const char *id,
1430
+ const char *label, int flags, const char *accel) {
1431
+ tray_build_ctx *c = (tray_build_ctx *)ctx;
1432
+ GtkWidget *item;
1433
+ int enabled = (flags & 1) ? 1 : 0;
1434
+ int checked = (flags & 2) ? 1 : 0;
1435
+ (void)depth;
1436
+ (void)accel;
1437
+ if (!c || !c->menu) return;
1438
+ if (kind == 's') {
1439
+ gtk_menu_shell_append(c->menu, gtk_separator_menu_item_new());
1440
+ return;
1441
+ }
1442
+ if (kind == 'm') {
1443
+ fprintf(stderr, "[cj-bridge] tray: submenu dropped (v1 tray menu is flat): %s\n", label);
1444
+ return;
1445
+ }
1446
+ item = (kind == 'c') ? gtk_check_menu_item_new_with_label(label)
1447
+ : gtk_menu_item_new_with_label(label);
1448
+ gtk_widget_set_sensitive(item, enabled ? TRUE : FALSE);
1449
+ if (kind == 'c') {
1450
+ gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item), checked ? TRUE : FALSE);
1451
+ }
1452
+ if (id[0]) {
1453
+ tray_item_ctx *tc = (tray_item_ctx *)calloc(1, sizeof(*tc));
1454
+ if (!tc) return;
1455
+ tc->h = c->h;
1456
+ tc->id = strdup(id);
1457
+ if (!tc->id) {
1458
+ free(tc);
1459
+ return;
1460
+ }
1461
+ g_signal_connect_data(item, "activate", G_CALLBACK(tray_on_activate), tc,
1462
+ tray_item_ctx_free, (GConnectFlags)0);
1463
+ }
1464
+ gtk_menu_shell_append(c->menu, item);
1465
+ }
1466
+
1467
+ /** 头部两行(icon/tooltip)之外的部分——调用方保证至少两行 */
1468
+ static const char *tray_menu_part(const char *wire) {
1469
+ int nl = 0;
1470
+ const char *p = wire;
1471
+ while (*p && nl < 2) {
1472
+ if (*p == '\n') nl++;
1473
+ p++;
1474
+ }
1475
+ return p;
1476
+ }
1477
+
1478
+ /** 跳到下一行行首(最后一行没有 \n 就到串尾) */
1479
+ static const char *tray_next_line(const char *p) {
1480
+ const char *nl = strchr(p, '\n');
1481
+ return nl ? nl + 1 : p + strlen(p);
1482
+ }
1483
+
1484
+ static void tray_apply_wire(cj_host *h, plat_host *p, const char *wire) {
1485
+ char icon[512];
1486
+ char tooltip[512];
1487
+ const char *menu_part;
1488
+ GtkWidget *menu;
1489
+ tray_build_ctx ctx;
1490
+ AppIndicator *ind;
1491
+
1492
+ if (!p) return;
1493
+ if (!wire || !wire[0]) {
1494
+ tray_remove(p);
1495
+ return;
1496
+ }
1497
+ /* 逐行推进:第 1 行 icon、第 2 行 tooltip、剩下的全是菜单行。
1498
+ (第一版「先跳两行再找 tooltip」永远找不着——tooltip 在第 2 行,跳两行已经越过它了) */
1499
+ icon[0] = '\0';
1500
+ tooltip[0] = '\0';
1501
+ menu_part = wire;
1502
+ if (strncmp(menu_part, "icon\t", 5) == 0) {
1503
+ const char *v = menu_part + 5;
1504
+ const char *nl = strchr(v, '\n');
1505
+ size_t n = nl ? (size_t)(nl - v) : strlen(v);
1506
+ if (n >= sizeof(icon)) n = sizeof(icon) - 1;
1507
+ memcpy(icon, v, n);
1508
+ icon[n] = '\0';
1509
+ menu_part = tray_next_line(menu_part);
1510
+ }
1511
+ if (strncmp(menu_part, "tooltip\t", 8) == 0) {
1512
+ const char *v = menu_part + 8;
1513
+ const char *nl = strchr(v, '\n');
1514
+ size_t n = nl ? (size_t)(nl - v) : strlen(v);
1515
+ if (n >= sizeof(tooltip)) n = sizeof(tooltip) - 1;
1516
+ memcpy(tooltip, v, n);
1517
+ tooltip[n] = '\0';
1518
+ menu_part = tray_next_line(menu_part);
1519
+ }
1520
+
1521
+ tray_remove(p);
1522
+ menu = gtk_menu_new();
1523
+ ctx.h = h;
1524
+ ctx.menu = GTK_MENU_SHELL(menu);
1525
+ cj_core_menu_walk(menu_part, tray_add_row, &ctx);
1526
+ gtk_widget_show_all(menu);
1527
+
1528
+ /* 图标:给了路径走 set_icon_full(绝对路径 libappindicator 自己处理);没给用主题图标兜底 */
1529
+ ind = app_indicator_new("cj-tauri-tray", icon[0] ? icon : "dialog-information",
1530
+ APP_INDICATOR_CATEGORY_APPLICATION_STATUS);
1531
+ app_indicator_set_status(ind, APP_INDICATOR_STATUS_ACTIVE);
1532
+ if (icon[0]) {
1533
+ app_indicator_set_icon_full(ind, icon, "cj-tauri tray icon");
1534
+ }
1535
+ if (tooltip[0]) {
1536
+ app_indicator_set_title(ind, tooltip);
1537
+ }
1538
+ app_indicator_set_menu(ind, GTK_MENU(menu));
1539
+ p->tray = ind;
1540
+ fprintf(stderr, "[cj-bridge] tray applied: icon=%s tooltip=%d bytes\n",
1541
+ icon[0] ? icon : "(theme)", (int)strlen(tooltip));
1542
+ }
1543
+
1544
+ static void tray_remove(plat_host *p) {
1545
+ if (!p) return;
1546
+ if (p->tray) {
1547
+ g_object_unref(p->tray); /* 创建引用归我们:unref 即摘除(GtkMenu 随之释放) */
1548
+ p->tray = NULL;
1549
+ fprintf(stderr, "[cj-bridge] tray removed\n");
1550
+ }
1551
+ }
1552
+
1553
+ /* 运行期整表重建的 idle 载荷(与 menu_wire_idle 同形,不复用是让回调各回各的函数) */
1554
+ typedef struct tray_wire_idle {
1555
+ cj_host *h;
1556
+ char *wire;
1557
+ } tray_wire_idle;
1558
+
1559
+ static gboolean tray_wire_idle_cb(gpointer data) {
1560
+ tray_wire_idle *m = (tray_wire_idle *)data;
1561
+ if (!m) return G_SOURCE_REMOVE;
1562
+ {
1563
+ plat_host *p = m->h ? (plat_host *)m->h->plat : NULL;
1564
+ if (p) tray_apply_wire(m->h, p, m->wire);
1565
+ }
1566
+ free(m->wire);
1567
+ free(m);
1568
+ return G_SOURCE_REMOVE;
1569
+ }
1570
+
1571
+ #endif /* CJ_HAVE_TRAY */
1572
+
1573
+ void cj_plat_set_tray(cj_host *h, const char *wire) {
1574
+ #ifndef CJ_HAVE_TRAY
1575
+ (void)h;
1576
+ (void)wire;
1577
+ fprintf(stderr, "[cj-bridge] tray not compiled in (no libayatana-appindicator3) - ignored\n");
1578
+ #else
1579
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1580
+ if (!p || !wire) return;
1581
+ if (!p->window) {
1582
+ /* 未就绪:core 已把线路记在 h->pending_tray,宿主线程装配时取用 */
1583
+ fprintf(stderr, "[cj-bridge] tray deferred: window not ready\n");
1584
+ return;
1585
+ }
1586
+ if (cj_plat_is_ui_thread(h)) {
1587
+ tray_apply_wire(h, p, wire);
1588
+ return;
1589
+ }
1590
+ {
1591
+ tray_wire_idle *m = (tray_wire_idle *)calloc(1, sizeof(*m));
1592
+ if (!m) return;
1593
+ m->h = h;
1594
+ m->wire = strdup(wire);
1595
+ if (!m->wire) {
1596
+ free(m);
1597
+ return;
1598
+ }
1599
+ g_idle_add(tray_wire_idle_cb, m);
1600
+ fprintf(stderr, "[cj-bridge] tray posted to gtk thread (%d bytes)\n", (int)strlen(wire));
1601
+ }
1602
+ #endif
1603
+ }
1604
+
1605
+ /* =========================================================================
1606
+ * 全局快捷键(RFC-003 第 5 期;Linux = X11 XGrabKey,可选编译 CJ_HAVE_X11)
1607
+ *
1608
+ * 线程与管道拓扑(Xlib 的线程安全不想赌——单线程拥有一切 X 资源,命令/事件全走管道):
1609
+ * [任意线程] cj_plat_set_global_hotkeys ─'R'+长度+wire─▶ hk_cmd[1]→[0]
1610
+ * [grab 线程] poll hk_cmd → 整表替换(ungrab 旧表再 grab 新表);KeyPress → id 行 ─▶ hk_evt[1]→[0]
1611
+ * [GTK 线程] g_timeout 50ms 轮询 hk_evt → cj_core_hotkey_pressed(仓颉回调只发生在 GTK 线程,
1612
+ * 与菜单/拖放同一纪律——绝不在 grab 线程碰仓颉运行时)
1613
+ *
1614
+ * X 错误处理:Xlib 的错误处理器是**每线程**状态,grab 线程里安装/恢复不影响 GTK 线程的处理器;
1615
+ * BadAccess(组合键已被别的程序占用)按「该条注册失败」处理,打日志继续,不是致命错。
1616
+ *
1617
+ * 锁定键坑(照修):NumLock/CapsLock/ScrollLock 开着时 modifier state 多一位,
1618
+ * XGrabKey 按 state 精确匹配——同一组合必须把 3 个锁定位的 8 种变体全部 grab,否则
1619
+ * 「开着 NumLock 就失灵」。事件匹配时反过来把锁定位掩掉再比。
1620
+ * ========================================================================= */
1621
+ #define HK_MAX_KEYS 32
1622
+ #define HK_ID_MAX 128
1623
+ #define HK_LOCK_MASKS (Mod2Mask | LockMask | Mod5Mask)
1624
+
1625
+ #ifdef CJ_HAVE_X11
1626
+
1627
+ typedef struct hk_entry {
1628
+ int used;
1629
+ int keycode;
1630
+ unsigned int mods; /* 不含锁定位的修饰键(Shift/Ctrl/Alt/Super)*/
1631
+ char id[HK_ID_MAX];
1632
+ } hk_entry;
1633
+
1634
+ typedef struct hk_state {
1635
+ Display *dpy; /* grab 线程独占的 X 连接;NULL = 打不开(Wayland 等),如实降级 */
1636
+ Window root;
1637
+ hk_entry keys[HK_MAX_KEYS];
1638
+ int count;
1639
+ } hk_state;
1640
+
1641
+ /* Xlib 错误处理器(每线程):只记 BadAccess(组合键被占用),其余交给默认处理 */
1642
+ static int g_hk_badaccess = 0;
1643
+ static int hk_xerror_handler(Display *dpy, XErrorEvent *ev) {
1644
+ (void)dpy;
1645
+ if (ev->error_code == BadAccess) {
1646
+ g_hk_badaccess = 1;
1647
+ return 0;
1648
+ }
1649
+ return 0;
1650
+ }
1651
+
1652
+ /** 修饰键名 → mask;认不出返回 -1(整行丢弃并打日志) */
1653
+ static int hk_mod_bit(const char *name, unsigned int *out) {
1654
+ if (strcmp(name, "Ctrl") == 0 || strcmp(name, "Control") == 0) { *out = ControlMask; return 0; }
1655
+ if (strcmp(name, "Alt") == 0) { *out = Mod1Mask; return 0; }
1656
+ if (strcmp(name, "Shift") == 0) { *out = ShiftMask; return 0; }
1657
+ if (strcmp(name, "Super") == 0 || strcmp(name, "Win") == 0) { *out = Mod4Mask; return 0; }
1658
+ return -1;
1659
+ }
1660
+
1661
+ /** 键名 → keysym:常用别名(加速器文案惯例)+ 其余原样交给 XStringToKeysym */
1662
+ static KeySym hk_keysym_of(const char *name) {
1663
+ if (strcmp(name, "Enter") == 0) { return XStringToKeysym("Return"); }
1664
+ if (strcmp(name, "Esc") == 0) { return XStringToKeysym("Escape"); }
1665
+ return XStringToKeysym(name); /* "a" / "F1" / "Return" / "Up" 都是合法 keysym 名 */
1666
+ }
1667
+
1668
+ /** 注销当前整表(8 种锁定位变体逐个 ungrab,与 grab 一一对应) */
1669
+ static void hk_ungrab_all(hk_state *s) {
1670
+ int i, lock;
1671
+ for (i = 0; i < s->count; i++) {
1672
+ if (!s->keys[i].used) continue;
1673
+ for (lock = 0; lock < 8; lock++) {
1674
+ unsigned int extra = 0;
1675
+ if (lock & 1) extra |= Mod2Mask;
1676
+ if (lock & 2) extra |= LockMask;
1677
+ if (lock & 4) extra |= Mod5Mask;
1678
+ XUngrabKey(s->dpy, s->keys[i].keycode, s->keys[i].mods | extra, s->root);
1679
+ }
1680
+ s->keys[i].used = 0;
1681
+ }
1682
+ s->count = 0;
1683
+ XSync(s->dpy, False);
1684
+ }
1685
+
1686
+ /** grab 一条(8 变体 + XSync 收错误);被占用返回 0(该条出局,其余照常) */
1687
+ static int hk_grab_one(hk_state *s, int keycode, unsigned int mods, const char *id) {
1688
+ int lock;
1689
+ XErrorHandler prev;
1690
+ if (s->count >= HK_MAX_KEYS) {
1691
+ fprintf(stderr, "[cj-bridge] hotkey: table full (%d), dropped: %s\n", HK_MAX_KEYS, id);
1692
+ return 0;
1693
+ }
1694
+ prev = XSetErrorHandler(hk_xerror_handler);
1695
+ g_hk_badaccess = 0;
1696
+ for (lock = 0; lock < 8; lock++) {
1697
+ unsigned int extra = 0;
1698
+ if (lock & 1) extra |= Mod2Mask;
1699
+ if (lock & 2) extra |= LockMask;
1700
+ if (lock & 4) extra |= Mod5Mask;
1701
+ XGrabKey(s->dpy, keycode, mods | extra, s->root, True, GrabModeAsync, GrabModeAsync);
1702
+ }
1703
+ XSync(s->dpy, False);
1704
+ (void)XSetErrorHandler(prev);
1705
+ if (g_hk_badaccess) {
1706
+ /* 已被别的程序 grab:把本条已 grab 上的变体退掉,如实报占用 */
1707
+ for (lock = 0; lock < 8; lock++) {
1708
+ unsigned int extra = 0;
1709
+ if (lock & 1) extra |= Mod2Mask;
1710
+ if (lock & 2) extra |= LockMask;
1711
+ if (lock & 4) extra |= Mod5Mask;
1712
+ XUngrabKey(s->dpy, keycode, mods | extra, s->root);
1713
+ }
1714
+ XSync(s->dpy, False);
1715
+ return 0;
1716
+ }
1717
+ s->keys[s->count].used = 1;
1718
+ s->keys[s->count].keycode = keycode;
1719
+ s->keys[s->count].mods = mods;
1720
+ snprintf(s->keys[s->count].id, HK_ID_MAX, "%s", id);
1721
+ s->count++;
1722
+ return 1;
1723
+ }
1724
+
1725
+ /** 整表替换:ungrab 旧表 → 逐行解析 wire → grab 新表(解析不了的行整行丢弃并打日志) */
1726
+ static void hk_apply_wire(hk_state *s, const char *wire) {
1727
+ char *copy, *line, *nl;
1728
+ hk_ungrab_all(s);
1729
+ if (!wire || !wire[0]) {
1730
+ fprintf(stderr, "[cj-bridge] hotkeys cleared\n");
1731
+ return;
1732
+ }
1733
+ copy = strdup(wire);
1734
+ if (!copy) return;
1735
+ line = copy;
1736
+ while (line && line[0]) {
1737
+ char *colon, *accel, *id, *tok, *save;
1738
+ char accel_orig[128];
1739
+ unsigned int mods = 0;
1740
+ KeySym sym;
1741
+ int ok = 1;
1742
+ nl = strchr(line, '\n');
1743
+ if (nl) *nl = '\0';
1744
+ colon = strchr(line, ':');
1745
+ if (!colon || colon == line) {
1746
+ fprintf(stderr, "[cj-bridge] hotkey line skipped (no 'accel:id'): %s\n", line);
1747
+ goto next_line;
1748
+ }
1749
+ *colon = '\0';
1750
+ accel = line;
1751
+ id = colon + 1;
1752
+ snprintf(accel_orig, sizeof(accel_orig), "%s", accel); /* strtok 会原地改坏 accel,日志留原始文本 */
1753
+ /* 修饰键:'+' 分隔逐个解析,最后一个 token 是键名 */
1754
+ save = NULL;
1755
+ tok = strtok_r(accel, "+", &save);
1756
+ while (tok) {
1757
+ unsigned int bit;
1758
+ char *next = strtok_r(NULL, "+", &save);
1759
+ if (!next) break; /* tok 是键名 */
1760
+ if (hk_mod_bit(tok, &bit) == 0) {
1761
+ mods |= bit;
1762
+ } else {
1763
+ fprintf(stderr, "[cj-bridge] hotkey: unknown modifier \"%s\" in: %s:%s\n", tok, accel, id);
1764
+ ok = 0;
1765
+ break;
1766
+ }
1767
+ tok = next;
1768
+ }
1769
+ if (ok && tok) {
1770
+ sym = hk_keysym_of(tok);
1771
+ if (sym == NoSymbol) {
1772
+ fprintf(stderr, "[cj-bridge] hotkey: unknown key \"%s\" in: %s:%s\n", tok, accel, id);
1773
+ ok = 0;
1774
+ } else {
1775
+ int code = XKeysymToKeycode(s->dpy, sym);
1776
+ if (code == 0) {
1777
+ fprintf(stderr, "[cj-bridge] hotkey: no keycode for \"%s\"\n", tok);
1778
+ ok = 0;
1779
+ } else if (hk_grab_one(s, code, mods, id)) {
1780
+ fprintf(stderr, "[cj-bridge] hotkey grab: %s:%s (code=%d mods=0x%x)\n",
1781
+ accel_orig, id, code, mods);
1782
+ } else {
1783
+ fprintf(stderr, "[cj-bridge] hotkey grab failed (in use?): %s:%s\n", accel_orig, id);
1784
+ }
1785
+ }
1786
+ }
1787
+ next_line:
1788
+ line = nl ? nl + 1 : NULL;
1789
+ }
1790
+ free(copy);
1791
+ }
1792
+
1793
+ /** 定长读(grab 线程的命令协议:'R' + 4 字节长度 + wire) */
1794
+ static int hk_read_full(int fd, void *buf, size_t n) {
1795
+ char *p = (char *)buf;
1796
+ size_t got = 0;
1797
+ while (got < n) {
1798
+ ssize_t r = read(fd, p + got, n - got);
1799
+ if (r <= 0) return -1;
1800
+ got += (size_t)r;
1801
+ }
1802
+ return 0;
1803
+ }
1804
+
1805
+ /** grab 线程主体:独占 X 连接;命令(整表替换 / 退出)走 hk_cmd,触发走 hk_evt */
1806
+ static void *hk_thread_main(void *ud) {
1807
+ cj_host *h = (cj_host *)ud;
1808
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1809
+ hk_state st;
1810
+ if (!p) return NULL;
1811
+ memset(&st, 0, sizeof(st));
1812
+ st.dpy = XOpenDisplay(NULL);
1813
+ if (!st.dpy) {
1814
+ /* Wayland 会话 / 无 X:能力位是编译期静态事实,这里如实降级——事件不会来 */
1815
+ fprintf(stderr, "[cj-bridge] hotkey: XOpenDisplay failed (Wayland? X11 unavailable)\n");
1816
+ } else {
1817
+ st.root = DefaultRootWindow(st.dpy);
1818
+ /* pending 不在这里应用:宿主线程装配点必发 'R'(整表替换),启动时再应用一遍
1819
+ 只会重复注册(实测同一组合 grab 日志出两行)。'R' 在管道里等着,本线程进循环就取到。 */
1820
+ }
1821
+ while (!p->hk_quit) {
1822
+ fd_set rf;
1823
+ struct timeval tv;
1824
+ int xfd = st.dpy ? XConnectionNumber(st.dpy) : -1;
1825
+ int maxfd = (p->hk_cmd[0] > xfd) ? p->hk_cmd[0] : xfd;
1826
+ FD_ZERO(&rf);
1827
+ FD_SET(p->hk_cmd[0], &rf);
1828
+ if (xfd >= 0) FD_SET(xfd, &rf);
1829
+ tv.tv_sec = 0;
1830
+ tv.tv_usec = 500 * 1000; /* 500ms:hk_quit 的兜底检查周期 */
1831
+ if (select(maxfd + 1, &rf, NULL, NULL, &tv) < 0) {
1832
+ if (errno == EINTR) continue;
1833
+ break;
1834
+ }
1835
+ if (p->hk_quit) break;
1836
+ if (FD_ISSET(p->hk_cmd[0], &rf)) {
1837
+ char cmd;
1838
+ if (hk_read_full(p->hk_cmd[0], &cmd, 1) != 0) break;
1839
+ if (cmd == 'Q') break;
1840
+ if (cmd == 'R') {
1841
+ unsigned int len = 0;
1842
+ char *wire;
1843
+ if (hk_read_full(p->hk_cmd[0], &len, sizeof(len)) != 0) break;
1844
+ if (len > 65536) break; /* 防御:线路文本不该有这么长 */
1845
+ wire = (char *)malloc(len + 1);
1846
+ if (!wire) break;
1847
+ if (hk_read_full(p->hk_cmd[0], wire, len) != 0) {
1848
+ free(wire);
1849
+ break;
1850
+ }
1851
+ wire[len] = '\0';
1852
+ if (st.dpy) {
1853
+ hk_apply_wire(&st, wire);
1854
+ } else {
1855
+ fprintf(stderr, "[cj-bridge] hotkeys set but X11 unavailable - ignored\n");
1856
+ }
1857
+ free(wire);
1858
+ }
1859
+ }
1860
+ if (st.dpy && xfd >= 0 && FD_ISSET(xfd, &rf)) {
1861
+ while (XPending(st.dpy)) {
1862
+ XEvent ev;
1863
+ int i;
1864
+ XNextEvent(st.dpy, &ev);
1865
+ if (ev.type != KeyPress) continue;
1866
+ for (i = 0; i < st.count; i++) {
1867
+ unsigned int mods = ev.xkey.state & ~HK_LOCK_MASKS;
1868
+ if (st.keys[i].used && st.keys[i].keycode == (int)ev.xkey.keycode &&
1869
+ st.keys[i].mods == mods) {
1870
+ char line[HK_ID_MAX + 2];
1871
+ int n = snprintf(line, sizeof(line), "%s\n", st.keys[i].id);
1872
+ /* PIPE_BUF 之上的写才可能交错;单行远小于 4KB,原子 */
1873
+ if (write(p->hk_evt[1], line, (size_t)n) > 0) {
1874
+ fprintf(stderr, "[cj-bridge] hotkey fired: %s\n", st.keys[i].id);
1875
+ }
1876
+ break;
1877
+ }
1878
+ }
1879
+ }
1880
+ }
1881
+ }
1882
+ if (st.dpy) {
1883
+ hk_ungrab_all(&st);
1884
+ XCloseDisplay(st.dpy);
1885
+ }
1886
+ p->hk_done = 1;
1887
+ return NULL;
1888
+ }
1889
+
1890
+ /** GTK 侧:50ms 轮询 hk_evt,把触发的 id 逐行交回 core(仓颉回调只在本线程发生) */
1891
+ static gboolean hk_evt_poll(gpointer data) {
1892
+ cj_host *h = (cj_host *)data;
1893
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1894
+ char buf[1024];
1895
+ ssize_t n;
1896
+ if (!p) return G_SOURCE_REMOVE;
1897
+ do {
1898
+ n = read(p->hk_evt[0], buf, sizeof(buf) - 1);
1899
+ if (n > 0) {
1900
+ ssize_t i = 0;
1901
+ buf[n] = '\0';
1902
+ while (i < n) {
1903
+ ssize_t j = i;
1904
+ while (j < n && buf[j] != '\n') j++;
1905
+ if (j > i) {
1906
+ buf[j] = '\0';
1907
+ cj_core_hotkey_pressed(h, buf + i);
1908
+ }
1909
+ i = j + 1;
1910
+ }
1911
+ }
1912
+ } while (n > 0);
1913
+ return G_SOURCE_CONTINUE;
1914
+ }
1915
+
1916
+ #else /* !CJ_HAVE_X11:能力位不置位,调用打一行如实说明(可选编译约定,RFC-002 §6.3) */
1917
+
1918
+ static gboolean hk_evt_poll(gpointer data) {
1919
+ (void)data;
1920
+ return G_SOURCE_REMOVE;
1921
+ }
1922
+
1923
+ #endif /* CJ_HAVE_X11 */
1924
+
1925
+ void cj_plat_set_global_hotkeys(cj_host *h, const char *wire) {
1926
+ #ifndef CJ_HAVE_X11
1927
+ (void)wire;
1928
+ fprintf(stderr, "[cj-bridge] global hotkeys not compiled in (no X11 dev) - ignored\n");
1929
+ (void)h;
1930
+ #else
1931
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1932
+ unsigned int len;
1933
+ if (!p) return;
1934
+ /* 懒启动:首次调用才建管道与 grab 线程(不用全局快捷键的应用零改动、零线程) */
1935
+ if (!p->hk_boot) {
1936
+ if (pipe(p->hk_cmd) != 0 || pipe(p->hk_evt) != 0) {
1937
+ fprintf(stderr, "[cj-bridge] hotkey: pipe failed (errno=%d)\n", errno);
1938
+ return;
1939
+ }
1940
+ fcntl(p->hk_evt[0], F_SETFL, fcntl(p->hk_evt[0], F_GETFL) | O_NONBLOCK);
1941
+ p->hk_boot = 1;
1942
+ if (pthread_create(&p->hk_thread, NULL, hk_thread_main, h) == 0) {
1943
+ p->hk_thread_started = 1;
1944
+ p->hk_evt_src = g_timeout_add(50, hk_evt_poll, h);
1945
+ fprintf(stderr, "[cj-bridge] hotkey grab thread started (X11)\n");
1946
+ } else {
1947
+ fprintf(stderr, "[cj-bridge] hotkey: pthread_create failed\n");
1948
+ return;
1949
+ }
1950
+ }
1951
+ /* 管道写对 <=PIPE_BUF 的块是原子的:任意线程可直接写,无需投到 GTK 线程 */
1952
+ len = wire ? (unsigned int)strlen(wire) : 0;
1953
+ {
1954
+ char cmd = 'R';
1955
+ ssize_t w1 = write(p->hk_cmd[1], &cmd, 1);
1956
+ ssize_t w2 = write(p->hk_cmd[1], &len, sizeof(len));
1957
+ ssize_t w3 = (len > 0) ? write(p->hk_cmd[1], wire, len) : 0;
1958
+ (void)w1; (void)w2; (void)w3; /* 失败由 grab 线程侧 read_full 兜住并退出循环 */
1959
+ }
1960
+ #endif
1961
+ }
1962
+
1963
+ /* =========================================================================
1964
+ * 串口(plugin_serial 的平台层):POSIX termios
1965
+ *
1966
+ * 句柄表与 fd **只活在本文件**:仓颉侧拿到的是表里分配的序号而不是 fd —— 页面即使拿着句柄乱试,
1967
+ * 也够不到 fd 1/2 这类「另有含义」的值。表锁用 PTHREAD_MUTEX_INITIALIZER 静态初始化,
1968
+ * 所以不需要 core 提供「一次性初始化」钩子,无头进程(没有窗口)里也能直接用。
1969
+ *
1970
+ * 打开一律 O_RDWR | O_NOCTTY | O_NONBLOCK:不阻塞(不为等载波信号钉住 worker 线程),
1971
+ * 也不把串口变成进程的控制终端。读取的超时交给 poll,不用 VTIME/VMIN——两套超时混用会让
1972
+ * 「timeout=0 就是不等待」这件事变得不可预测。
1973
+ * ========================================================================= */
1974
+ #define CJ_SERIAL_MAX_HANDLES 8
1975
+
1976
+ typedef struct serial_slot {
1977
+ int used;
1978
+ long long id;
1979
+ int fd;
1980
+ char path[256];
1981
+ } serial_slot;
1982
+
1983
+ static serial_slot g_serial_slots[CJ_SERIAL_MAX_HANDLES];
1984
+ static long long g_serial_next_id = 1;
1985
+ static pthread_mutex_t g_serial_lock = PTHREAD_MUTEX_INITIALIZER;
1986
+
1987
+ static void serial_err(char *err, int err_len, const char *fmt, ...) {
1988
+ va_list ap;
1989
+ if (!err || err_len <= 0) return;
1990
+ va_start(ap, fmt);
1991
+ vsnprintf(err, (size_t)err_len, fmt, ap);
1992
+ va_end(ap);
1993
+ }
1994
+
1995
+ /* 数值波特率 → speed_t:只收 Linux 明确有宏的档位,不认识就拒绝(不猜、不近似) */
1996
+ static int serial_speed_of(int baud, speed_t *out) {
1997
+ switch (baud) {
1998
+ case 300: *out = B300; return 1;
1999
+ case 600: *out = B600; return 1;
2000
+ case 1200: *out = B1200; return 1;
2001
+ case 2400: *out = B2400; return 1;
2002
+ case 4800: *out = B4800; return 1;
2003
+ case 9600: *out = B9600; return 1;
2004
+ case 19200: *out = B19200; return 1;
2005
+ case 38400: *out = B38400; return 1;
2006
+ case 57600: *out = B57600; return 1;
2007
+ case 115200: *out = B115200; return 1;
2008
+ case 230400: *out = B230400; return 1;
2009
+ case 460800: *out = B460800; return 1;
2010
+ case 500000: *out = B500000; return 1;
2011
+ case 921600: *out = B921600; return 1;
2012
+ case 1000000: *out = B1000000; return 1;
2013
+ default: return 0;
2014
+ }
2015
+ }
2016
+
2017
+ static serial_slot *serial_find_locked(long long id) {
2018
+ int i;
2019
+ for (i = 0; i < CJ_SERIAL_MAX_HANDLES; i++) {
2020
+ if (g_serial_slots[i].used && g_serial_slots[i].id == id) return &g_serial_slots[i];
2021
+ }
2022
+ return NULL;
2023
+ }
2024
+
2025
+ /* 把句柄翻成 fd;句柄无效时返回 -1 并填 err。调用方负责在 poll/read/write 期间不再持锁。 */
2026
+ static int serial_fd_of(long long h, char *err, int err_len) {
2027
+ serial_slot *s;
2028
+ int fd;
2029
+ pthread_mutex_lock(&g_serial_lock);
2030
+ s = serial_find_locked(h);
2031
+ fd = s ? s->fd : -1;
2032
+ pthread_mutex_unlock(&g_serial_lock);
2033
+ if (fd < 0) serial_err(err, err_len, "serial: 句柄 %lld 无效(未打开或已关闭)", h);
2034
+ return fd;
2035
+ }
2036
+
2037
+ static long long serial_now_ms(void) {
2038
+ struct timespec ts;
2039
+ clock_gettime(CLOCK_MONOTONIC, &ts);
2040
+ return (long long)ts.tv_sec * 1000 + (long long)ts.tv_nsec / 1000000;
2041
+ }
2042
+
2043
+ long long cj_plat_serial_open(const char *path, const cj_serial_cfg *cfg, char *err, int err_len) {
2044
+ struct termios tty;
2045
+ speed_t sp;
2046
+ tcflag_t dbits;
2047
+ int fd;
2048
+ int i;
2049
+ long long id;
2050
+
2051
+ if (!path || !cfg) {
2052
+ serial_err(err, err_len, "serial: 空路径 / 空配置");
2053
+ return -1;
2054
+ }
2055
+ if (!serial_speed_of(cfg->baud, &sp)) {
2056
+ serial_err(err, err_len, "serial: 本平台不支持的波特率 %d(只收常见档位)", cfg->baud);
2057
+ return -1;
2058
+ }
2059
+
2060
+ /* open + 配置整段留在锁内:O_NONBLOCK 下 open 不会久等,这样也免了「先占槽再开」的竞态窗口 */
2061
+ pthread_mutex_lock(&g_serial_lock);
2062
+
2063
+ for (i = 0; i < CJ_SERIAL_MAX_HANDLES; i++) {
2064
+ if (!g_serial_slots[i].used) break;
2065
+ }
2066
+ if (i == CJ_SERIAL_MAX_HANDLES) {
2067
+ pthread_mutex_unlock(&g_serial_lock);
2068
+ serial_err(err, err_len, "serial: 句柄表满(最多 %d 个并发串口)", CJ_SERIAL_MAX_HANDLES);
2069
+ return -2;
2070
+ }
2071
+
2072
+ fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK);
2073
+ if (fd < 0) {
2074
+ serial_err(err, err_len, "serial: open(%s) 失败: %s", path, strerror(errno));
2075
+ pthread_mutex_unlock(&g_serial_lock);
2076
+ return -3;
2077
+ }
2078
+ if (tcgetattr(fd, &tty) != 0) {
2079
+ serial_err(err, err_len, "serial: tcgetattr(%s) 失败: %s(不是终端类设备?)",
2080
+ path, strerror(errno));
2081
+ close(fd);
2082
+ pthread_mutex_unlock(&g_serial_lock);
2083
+ return -3;
2084
+ }
2085
+
2086
+ /* 裸模式:串口上多半是二进制帧,回显 / 换行转换 / 信号解释都是破坏性加工。
2087
+ cfmakeraw 顺带设好 CS8 与清 PARENB,所以后面再按调用方参数覆盖一遍。 */
2088
+ cfmakeraw(&tty);
2089
+ tty.c_cflag |= (CLOCAL | CREAD);
2090
+ switch (cfg->data_bits) {
2091
+ case 5: dbits = CS5; break;
2092
+ case 6: dbits = CS6; break;
2093
+ case 7: dbits = CS7; break;
2094
+ default: dbits = CS8; break;
2095
+ }
2096
+ tty.c_cflag &= ~CSIZE;
2097
+ tty.c_cflag |= dbits;
2098
+ switch (cfg->parity) {
2099
+ case 1: tty.c_cflag |= (PARENB | PARODD); break; /* 奇校验 */
2100
+ case 2: tty.c_cflag |= PARENB; tty.c_cflag &= ~PARODD; break; /* 偶校验 */
2101
+ default: tty.c_cflag &= ~PARENB; break; /* 无校验 */
2102
+ }
2103
+ if (cfg->stop_bits == 2) {
2104
+ tty.c_cflag |= CSTOPB;
2105
+ } else {
2106
+ tty.c_cflag &= ~CSTOPB;
2107
+ }
2108
+ /* 硬件流控不替应用开:RTS/CTS 在不少设备上是手工握手的信号,框架自作主张会改语义 */
2109
+ tty.c_cflag &= ~CRTSCTS;
2110
+ tty.c_iflag &= ~(IXON | IXOFF | IXANY);
2111
+ tty.c_cc[VMIN] = 0;
2112
+ tty.c_cc[VTIME] = 0;
2113
+ if (cfsetispeed(&tty, sp) != 0 || cfsetospeed(&tty, sp) != 0) {
2114
+ serial_err(err, err_len, "serial: 设置波特率 %d 失败: %s", cfg->baud, strerror(errno));
2115
+ close(fd);
2116
+ pthread_mutex_unlock(&g_serial_lock);
2117
+ return -3;
2118
+ }
2119
+ if (tcsetattr(fd, TCSANOW, &tty) != 0) {
2120
+ serial_err(err, err_len, "serial: tcsetattr(%s) 失败: %s(设备不接受这组参数?)",
2121
+ path, strerror(errno));
2122
+ close(fd);
2123
+ pthread_mutex_unlock(&g_serial_lock);
2124
+ return -3;
2125
+ }
2126
+ tcflush(fd, TCIOFLUSH);
2127
+
2128
+ id = g_serial_next_id++;
2129
+ if (g_serial_next_id <= 0) g_serial_next_id = 1; /* 溢出兜底:句柄永不为 0 */
2130
+ g_serial_slots[i].used = 1;
2131
+ g_serial_slots[i].id = id;
2132
+ g_serial_slots[i].fd = fd;
2133
+ snprintf(g_serial_slots[i].path, sizeof(g_serial_slots[i].path), "%s", path);
2134
+ pthread_mutex_unlock(&g_serial_lock);
2135
+
2136
+ /* 落地一行:参数与 fd 都打全,串口问题基本都是拿这行对账(AGENTS §1 的取证口径) */
2137
+ fprintf(stderr, "[cj-bridge] serial termios: path=%s baud=%d data=%d parity=%d stop=%d fd=%d\n",
2138
+ path, cfg->baud, cfg->data_bits, cfg->parity, cfg->stop_bits, fd);
2139
+ return id;
2140
+ }
2141
+
2142
+ int cj_plat_serial_read(long long h, unsigned char *buf, int len, int timeout_ms, char *err, int err_len) {
2143
+ struct pollfd pfd;
2144
+ int fd = serial_fd_of(h, err, err_len);
2145
+ int pr;
2146
+ ssize_t n;
2147
+
2148
+ if (fd < 0) return -4;
2149
+
2150
+ /* poll 在锁外等:同一把锁若被「某个句柄的读超时」占住,别的串口就全废了 */
2151
+ pfd.fd = fd;
2152
+ pfd.events = POLLIN;
2153
+ pfd.revents = 0;
2154
+ pr = poll(&pfd, 1, timeout_ms > 0 ? timeout_ms : 0);
2155
+ if (pr < 0) {
2156
+ if (errno == EINTR) return 0; /* 被打断按「本轮无数据」处理,由上层决定要不要再读 */
2157
+ serial_err(err, err_len, "serial: poll 失败: %s", strerror(errno));
2158
+ return -3;
2159
+ }
2160
+ if (pr == 0) return 0; /* 超时:确实没数据,不是错误 */
2161
+
2162
+ n = read(fd, buf, (size_t)len);
2163
+ if (n < 0) {
2164
+ if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) return 0;
2165
+ serial_err(err, err_len, "serial: read 失败: %s", strerror(errno));
2166
+ return -3;
2167
+ }
2168
+ return (int)n; /* n == 0:对端已关闭(pty 侧退出)*/
2169
+ }
2170
+
2171
+ int cj_plat_serial_write(long long h, const unsigned char *buf, int len, int timeout_ms,
2172
+ char *err, int err_len) {
2173
+ struct pollfd pfd;
2174
+ long long deadline = serial_now_ms() + (timeout_ms > 0 ? (long long)timeout_ms : 0);
2175
+ int budget = timeout_ms > 0 ? timeout_ms : 0;
2176
+ int fd = serial_fd_of(h, err, err_len);
2177
+ int written = 0;
2178
+
2179
+ if (fd < 0) return -4;
2180
+
2181
+ /* 有界写完:每轮 poll 只等「剩余预算」,所以总等待不会超出 timeout_ms(不是每轮都等满一遍) */
2182
+ while (written < len) {
2183
+ ssize_t n;
2184
+ int pr;
2185
+ pfd.fd = fd;
2186
+ pfd.events = POLLOUT;
2187
+ pfd.revents = 0;
2188
+ pr = poll(&pfd, 1, budget);
2189
+ if (pr < 0) {
2190
+ if (errno == EINTR) continue;
2191
+ serial_err(err, err_len, "serial: poll(POLLOUT) 失败: %s", strerror(errno));
2192
+ return written > 0 ? written : -3;
2193
+ }
2194
+ if (pr == 0) break; /* 预算用尽:返回已写入量,由上层决定要不要再写 */
2195
+
2196
+ n = write(fd, buf + written, (size_t)(len - written));
2197
+ if (n < 0) {
2198
+ if (errno == EINTR) continue;
2199
+ if (errno == EAGAIN || errno == EWOULDBLOCK) {
2200
+ if (budget <= 0) break; /* 不忙等:预算已尽就交回上层 */
2201
+ budget = (int)(deadline - serial_now_ms());
2202
+ if (budget < 0) budget = 0;
2203
+ continue;
2204
+ }
2205
+ serial_err(err, err_len, "serial: write 失败: %s", strerror(errno));
2206
+ return written > 0 ? written : -3;
2207
+ }
2208
+ written += (int)n;
2209
+ budget = (int)(deadline - serial_now_ms());
2210
+ if (budget < 0) budget = 0;
2211
+ }
2212
+ return written;
2213
+ }
2214
+
2215
+ int cj_plat_serial_close(long long h, char *err, int err_len) {
2216
+ int fd = -1;
2217
+ int i;
2218
+
2219
+ pthread_mutex_lock(&g_serial_lock);
2220
+ for (i = 0; i < CJ_SERIAL_MAX_HANDLES; i++) {
2221
+ if (g_serial_slots[i].used && g_serial_slots[i].id == h) {
2222
+ fd = g_serial_slots[i].fd;
2223
+ g_serial_slots[i].used = 0;
2224
+ g_serial_slots[i].id = 0;
2225
+ g_serial_slots[i].fd = -1;
2226
+ g_serial_slots[i].path[0] = '\0';
2227
+ break;
2228
+ }
2229
+ }
2230
+ pthread_mutex_unlock(&g_serial_lock);
2231
+
2232
+ if (fd < 0) {
2233
+ serial_err(err, err_len, "serial: 句柄 %lld 无效(未打开或已关闭)", h);
2234
+ return -4;
2235
+ }
2236
+ close(fd); /* close 放锁外:它可能触发 pty 侧的清理,不该占着表锁 */
2237
+ return 0;
2238
+ }
2239
+
2240
+ /* Linux 侧不实现 C 桥枚举:serial:list 仍走仓颉侧的 /dev 目录扫描(pty / by-id 都能收),
2241
+ 这里按「未实现」口径返回 -4,core 原样交回。 */
2242
+ int cj_plat_serial_list(char *out, int out_len) {
2243
+ if (out && out_len > 0) {
2244
+ serial_err(out, out_len, "%s", "serial: list 在本平台未实现(走仓颉侧 /dev 扫描)");
2245
+ }
2246
+ return -4;
2247
+ }
2248
+
2249
+ /* ===== 通用 UI 线程投递(剪贴板等宿主能力共用;对话框走的是自己的专用通道)===== */
2250
+
2251
+ /* 通用投递的载荷 + 跳板:core 侧回调是 void 返回的,直接塞给 g_idle_add 会被
2252
+ GLib 按 GSourceFunc 解读返回值——void 函数留在返回寄存器里的**垃圾**若非 0,
2253
+ GLib 就认为「还要再跑」,同一个载荷被反复重入(实测:剪贴板读被跑两遍、
2254
+ 通知载荷 double free)。跳板一律返回 G_SOURCE_REMOVE,只执行一次。 */
2255
+ typedef struct ui_post_msg {
2256
+ cj_ui_fn fn;
2257
+ void *ud;
2258
+ } ui_post_msg;
2259
+
2260
+ static gboolean ui_post_trampoline(gpointer data) {
2261
+ ui_post_msg *m = (ui_post_msg *)data;
2262
+ if (m) {
2263
+ m->fn(m->ud);
2264
+ free(m);
2265
+ }
2266
+ return G_SOURCE_REMOVE;
2267
+ }
2268
+
2269
+ int cj_plat_post_ui(cj_host *h, cj_ui_fn fn, void *ud) {
2270
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
2271
+ ui_post_msg *m;
2272
+ if (!p || !p->view) {
2273
+ /* GTK 主循环还没起来(或已退出):idle 回调永远不会执行 */
2274
+ fprintf(stderr, "[cj-bridge] post ui failed: gtk loop not ready\n");
2275
+ return 0;
2276
+ }
2277
+ m = (ui_post_msg *)malloc(sizeof(ui_post_msg));
2278
+ if (!m) {
2279
+ return 0;
2280
+ }
2281
+ m->fn = fn;
2282
+ m->ud = ud;
2283
+ g_idle_add(ui_post_trampoline, m);
2284
+ return 1;
2285
+ }
2286
+
2287
+ /* ===== 剪贴板(plugin_clipboard 的平台层):GTK clipboard =====
2288
+ 调用方保证已在 GTK 线程上(core 的 UI 线程分流负责),这里不做线程判断。
2289
+ gtk_clipboard_wait_* 是同步 API:在 GTK 线程上阻塞等 X11 往返——对话框轮已证过
2290
+ 「worker 等 UI 线程」这条链可行,这里反过来「UI 线程短暂阻塞等剪贴板」是 GTK 应用的常规做法。 */
2291
+
2292
+ int cj_plat_clipboard_write_text(cj_host *h, const char *text, char *err, int err_len) {
2293
+ GtkClipboard *cb;
2294
+
2295
+ (void)h;
2296
+ cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD);
2297
+ if (!cb) {
2298
+ if (err && err_len > 0) serial_err(err, err_len, "%s", "clipboard: 取不到默认剪贴板");
2299
+ return -3;
2300
+ }
2301
+ gtk_clipboard_set_text(cb, text ? text : "", -1);
2302
+ gtk_clipboard_store(cb); /* 关窗后仍留给系统(X11 上不 store 会随窗口一起消失)*/
2303
+ return 0;
2304
+ }
2305
+
2306
+ char *cj_plat_clipboard_read_text(cj_host *h, char *err, int err_len) {
2307
+ GtkClipboard *cb;
2308
+ gchar *text;
2309
+ char *out = NULL;
2310
+
2311
+ (void)h;
2312
+ cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD);
2313
+ if (!cb) {
2314
+ if (err && err_len > 0) serial_err(err, err_len, "%s", "clipboard: 取不到默认剪贴板");
2315
+ return NULL;
2316
+ }
2317
+ text = gtk_clipboard_wait_for_text(cb);
2318
+ if (text) {
2319
+ out = strdup(text); /* 返回值要经 cj_bridge_clipboard_free 释放(malloc 家族)*/
2320
+ g_free(text);
2321
+ }
2322
+ return out; /* NULL = 空 / 非文本,不是错误 */
2323
+ }
2324
+
2325
+ /* ===== 通知(plugin_notification 的平台层):freedesktop Notifications =====
2326
+ 走 GDBus 同步调用 org.freedesktop.Notifications.Notify(发后即忘:不等动作回执)。
2327
+ 调用方保证已在 GTK 线程上。GIO 与 GTK 同源,无需额外链接。 */
2328
+
2329
+ int cj_plat_notification_notify(cj_host *h, const char *title, const char *body,
2330
+ char *err, int err_len) {
2331
+ GBusType bus_type;
2332
+ GDBusConnection *conn;
2333
+ GVariant *result;
2334
+ GVariantBuilder actions_b;
2335
+ GVariantBuilder hints_b;
2336
+ GError *gerr = NULL;
2337
+ const char *app_name = "cj-tauri";
2338
+ const char *unused = "";
2339
+
2340
+ (void)h;
2341
+ /* 会话总线(桌面通知走 session,不是 system);无桌面环境时失败——如实报,不降级 */
2342
+ bus_type = G_BUS_TYPE_SESSION;
2343
+ conn = g_bus_get_sync(bus_type, NULL, &gerr);
2344
+ if (!conn) {
2345
+ if (err && err_len > 0) serial_err(err, err_len, "notification: 会话总线不可用: %s",
2346
+ gerr ? gerr->message : "?");
2347
+ if (gerr) g_error_free(gerr);
2348
+ return -3;
2349
+ }
2350
+ g_variant_builder_init(&actions_b, G_VARIANT_TYPE("as"));
2351
+ g_variant_builder_init(&hints_b, G_VARIANT_TYPE("a{sv}"));
2352
+ result = g_dbus_connection_call_sync(
2353
+ conn, "org.freedesktop.Notifications", "/org/freedesktop/Notifications",
2354
+ "org.freedesktop.Notifications", "Notify",
2355
+ g_variant_new("(susssasa{sv}i)", app_name, (guint32)0, unused, title ? title : "",
2356
+ body ? body : "", &actions_b, &hints_b, (gint32)-1),
2357
+ NULL, G_DBUS_CALL_FLAGS_NONE, 2000, NULL, &gerr);
2358
+ g_object_unref(conn);
2359
+ if (!result) {
2360
+ if (err && err_len > 0) serial_err(err, err_len, "notification: Notify 调用失败: %s",
2361
+ gerr ? gerr->message : "?");
2362
+ if (gerr) g_error_free(gerr);
2363
+ return -3;
2364
+ }
2365
+ g_variant_unref(result);
2366
+ return 0;
2367
+ }
2368
+
2369
+ /* ===== 静默子进程(cj_plat_spawn_hidden / kill / alive 的平台层)=====
2370
+ Linux 没有「控制台窗口」这回事,但同样得脱开终端:自成会话 + 独立进程组之后,终端 Ctrl-C
2371
+ (SIGINT 发给前台进程组)不会连带打死它;标准 I/O 指到 /dev/null——父进程的终端随时可能消失,
2372
+ 子进程往一个已经关掉的 pty 写就是 SIGPIPE。
2373
+
2374
+ 用 posix_spawn 而不是 fork + exec:它把「exec 失败」**同步**报给父进程(fork 那条只能让子进程
2375
+ 自己 _exit,失败原因父进程永远拿不到),正好喂给 err 缓冲。 */
2376
+
2377
+ long long cj_plat_spawn_hidden(const char *exe, const char *args, char *err, int err_len) {
2378
+ posix_spawn_file_actions_t fa;
2379
+ posix_spawnattr_t attr;
2380
+ char *copy = NULL;
2381
+ char *argv[64];
2382
+ int n = 0;
2383
+ int rc;
2384
+ pid_t pid = 0;
2385
+ short flags = POSIX_SPAWN_SETPGROUP; /* 自成进程组:终端 Ctrl-C 不连带打死 */
2386
+
2387
+ if (!exe || !*exe) {
2388
+ serial_err(err, err_len, "spawn hidden: 程序路径为空");
2389
+ return 0;
2390
+ }
2391
+ argv[n++] = (char *)exe;
2392
+ if (args && *args) {
2393
+ char *p;
2394
+ copy = strdup(args);
2395
+ if (!copy) {
2396
+ serial_err(err, err_len, "spawn hidden: out of memory");
2397
+ return 0;
2398
+ }
2399
+ p = copy;
2400
+ while (*p && n < 63) {
2401
+ char *q = strchr(p, 0x1f);
2402
+ if (q) *q = '\0';
2403
+ argv[n++] = p;
2404
+ if (!q) break;
2405
+ p = q + 1;
2406
+ }
2407
+ }
2408
+ argv[n] = NULL;
2409
+
2410
+ posix_spawn_file_actions_init(&fa);
2411
+ posix_spawn_file_actions_addopen(&fa, 0, "/dev/null", O_RDWR, 0);
2412
+ posix_spawn_file_actions_addopen(&fa, 1, "/dev/null", O_RDWR, 0);
2413
+ posix_spawn_file_actions_addopen(&fa, 2, "/dev/null", O_RDWR, 0);
2414
+ posix_spawnattr_init(&attr);
2415
+ #ifdef POSIX_SPAWN_SETSID
2416
+ flags = (short)(flags | POSIX_SPAWN_SETSID);
2417
+ #endif
2418
+ posix_spawnattr_setflags(&attr, flags);
2419
+ rc = posix_spawn(&pid, exe, &fa, &attr, argv, environ);
2420
+ posix_spawn_file_actions_destroy(&fa);
2421
+ posix_spawnattr_destroy(&attr);
2422
+ free(copy);
2423
+ if (rc != 0) {
2424
+ serial_err(err, err_len, "spawn hidden: posix_spawn 失败(%s)", strerror(rc));
2425
+ return 0;
2426
+ }
2427
+ return (long long)pid;
2428
+ }
2429
+
2430
+ /* 幂等口径:进程已经不在了算成功(调用方只是想让它结束)。 */
2431
+ int cj_plat_kill_pid(long long pid) {
2432
+ int i;
2433
+
2434
+ if (pid <= 0) return 0;
2435
+ if (kill((pid_t)pid, SIGTERM) != 0) {
2436
+ if (errno == ESRCH) return 1; /* 已经不在了 */
2437
+ fprintf(stderr, "[cj-bridge] kill pid %lld: SIGTERM 失败(%s)\n", pid, strerror(errno));
2438
+ return 0;
2439
+ }
2440
+ /* 给 3 秒走完收尾(与 Windows 侧同口径),到点还在就补一刀 SIGKILL */
2441
+ for (i = 0; i < 30; i++) {
2442
+ usleep(100 * 1000);
2443
+ if (kill((pid_t)pid, 0) != 0 && errno == ESRCH) return 1;
2444
+ }
2445
+ fprintf(stderr, "[cj-bridge] kill pid %lld: 3s 内没等到退出,补 SIGKILL\n", pid);
2446
+ kill((pid_t)pid, SIGKILL);
2447
+ return 1;
2448
+ }
2449
+
2450
+ int cj_plat_pid_alive(long long pid) {
2451
+ if (pid <= 0) return 0;
2452
+ return (kill((pid_t)pid, 0) == 0) ? 1 : 0;
2453
+ }
2454
+
2455
+ /* ===== 内嵌资源释放(cj_plat_extract_assets 的平台层)=====
2456
+ Linux 侧的单文件产物是 ELF、随包资源走打包脚本各自的机制,不在 PE 资源这条路上,所以这里
2457
+ 如实返回 0 =「这个产物里没有内嵌资源」。**不要**假装释放成功:调用方据此降级,谎报只会让它
2458
+ 去找一个并不存在的文件。 */
2459
+ int cj_plat_extract_assets(const char *dir, char *err, int err_len) {
2460
+ (void)dir;
2461
+ if (err && err_len > 0) err[0] = '\0';
2462
+ return 0;
2463
+ }
2464
+
2465
+ /* ===== 动态库预加载(cj_plat_preload_library 的平台层)=====
2466
+ Linux 侧通常由系统提供 libssl/libcrypto(发行版包),本来就在 loader 的搜索路径里;
2467
+ 这条原语是给「随包释放出来的库」留的出口,与 Windows 同名同签名(AGENTS §2)。
2468
+ RTLD_NOW:符号缺失当场报,不把错误推迟到第一次调用;RTLD_GLOBAL 让后续按名引用可见。 */
2469
+ int cj_plat_preload_library(const char *path, char *err, int err_len) {
2470
+ void *h;
2471
+
2472
+ if (err && err_len > 0) err[0] = '\0';
2473
+ if (!path || !*path) {
2474
+ serial_err(err, err_len, "preload: 路径为空");
2475
+ return 0;
2476
+ }
2477
+ h = dlopen(path, RTLD_NOW | RTLD_GLOBAL);
2478
+ if (!h) {
2479
+ const char *e = dlerror();
2480
+ serial_err(err, err_len, "dlopen 失败:%s", e ? e : "没有给出原因");
2481
+ return 0;
2482
+ }
2483
+ return 1;
2484
+ }
2485
+