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,3248 @@
1
+ /*
2
+ * cj-tauri C 桥(Windows 平台层):Win32 窗口 + WebView2 宿主原语
3
+ *
4
+ * 本文件只实现 bridge_core.h 的「平台原语清单」+ 宿主线程装配;平台无关逻辑
5
+ * (IPC 回投队列与批处理、对话框单槽状态机、窗口配置存储、生命周期标志、全部导出)
6
+ * 都在 native/bridge_core.c,两平台共用。分层原因见 docs/架构演进-多平台与多窗口.md §2.2。
7
+ *
8
+ * 分层对标 Tauri 2:本文件 = tao(窗口/事件循环) + wry(WebView 抽象) 的 Windows 后端,
9
+ * 上层 IPC 桥 / capability 安全模型(src 目录下的 .cj)跨平台复用。
10
+ *
11
+ * 与 Linux 桥(bridge_linux.c)的差异:
12
+ * - Windows 的 JS 引擎(V8)运行在 msedgewebview2.exe 独立进程,宿主进程内不跑 JS 引擎,
13
+ * 因此 Linux 上 JSC 栈边界校验导致的「WebView 调用必须在原生 pthread」约束在此不适用;
14
+ * 这里仍保持「独立宿主线程 + JS FIFO 投递」的同构结构,两端 API 与行为一致。
15
+ * - JS → 宿主:window.chrome.webview.postMessage(JSON.stringify(obj))
16
+ * - 宿主 → JS:ExecuteScript("window.postMessage(json,'*')"),与 Linux 端完全一致
17
+ *
18
+ * 编译(mingw-w64 / llvm-mingw gcc,见 native/build_win.bat):
19
+ * gcc -shared -O2 bridge_core.c bridge_win.c -o libcjtbridge.dll \
20
+ * -Wl,--out-implib,libcjtbridge.dll.a \
21
+ * -I<WebView2 SDK>/build/native/include \
22
+ * -lole32 -loleaut32 -luuid -luser32 -lgdi32
23
+ * 运行期要求:libcjtbridge.dll 与 WebView2Loader.dll 位于 exe 目录或 PATH 中。
24
+ */
25
+ #define UNICODE
26
+ #define _UNICODE
27
+ /* 条件变量(CONDITION_VARIABLE)要 Vista+ 的头;WebView2 本身就要求 Win10,写 0x0601 足够 */
28
+ #define _WIN32_WINNT 0x0601
29
+ #include <windows.h>
30
+ #include <commdlg.h>
31
+ #include <objbase.h>
32
+ #include <shellapi.h> /* DragQueryFileW / DragFinish(CF_HDROP → 文件列表,shell32) */
33
+ #include <shobjidl.h> /* IFileOpenDialog + FOS_PICKFOLDERS(选目录,kind 6;ole32 已在链接项里)*/
34
+ #include <stdio.h>
35
+ #include <stdarg.h>
36
+ #include <stdlib.h>
37
+ #include <string.h>
38
+ #include <wchar.h> /* wmemcpy / wcslen(EOF 白名单与对话框预置文件名都要用)*/
39
+ #include "WebView2.h"
40
+ #include "bridge_core.h"
41
+
42
+ /* ===== COM 回调对象(每个宿主一套)=====
43
+ 原先 4 个 handler 是静态单例(AddRef/Release 硬编码 return 1),多窗口一落地就分不清是谁的回调;
44
+ 现在按宿主内嵌,This 直接转回本结构就能拿到 cj_host。引用计数仍恒 1:对象随宿主句柄存续
45
+ (句柄壳活到 cj_bridge_destroy),这是 WebView2 手工 COM 的惯用写法,也是 AGENTS.md §4 里
46
+ hr=0x8007139F 那个坑的由来——AddRef 若返回 0 / 不自持有,注册给 WebView2 的回调会被提前回收。 */
47
+ typedef struct env_handler {
48
+ ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler iface; /* 首成员:This == &iface */
49
+ cj_host *h;
50
+ } env_handler;
51
+
52
+ typedef struct ctrl_handler {
53
+ ICoreWebView2CreateCoreWebView2ControllerCompletedHandler iface;
54
+ cj_host *h;
55
+ } ctrl_handler;
56
+
57
+ typedef struct msg_handler {
58
+ ICoreWebView2WebMessageReceivedEventHandler iface;
59
+ cj_host *h;
60
+ } msg_handler;
61
+
62
+ typedef struct nav_handler {
63
+ ICoreWebView2NavigationCompletedEventHandler iface;
64
+ cj_host *h;
65
+ } nav_handler;
66
+
67
+ /* ===== 菜单栏(RFC-002 §5.4 / §5.5)=====
68
+ core 把线路文本交下来,本文件翻译成 Win32 菜单栏。菜单栏是**每窗口**资源(HMENU 挂在窗口上),
69
+ 建 / 换 / 改都必须在 UI 线程上做——跨线程调用一律 PostMessage 到宿主线程。 */
70
+ #define CJ_MENU_MAX_ITEMS 64
71
+ #define CJ_MENU_MAX_DEPTH 8
72
+
73
+ typedef struct menu_slot {
74
+ UINT cmd_id; /* 建菜单时分配的命令 id(WM_COMMAND 带回来的那个),从 1 开始 */
75
+ char *app_id; /* 应用给的稳定 id:回投仓颉侧,也是 set_menu_item_state 的查找键 */
76
+ char kind; /* 'n' 普通 / 'c' 勾选(点击时由我们自己翻转勾选状态)*/
77
+ int enabled;
78
+ int checked;
79
+ } menu_slot;
80
+
81
+ /* ===== 全局快捷键(RFC-003 第 5 期)=====
82
+ 线路文本一行一条 `<accel>:<id>`(见 bridge_core.h 的 pending_hotkeys 注释)。
83
+ RegisterHotKey 只认「修饰键位掩码 + 虚拟键码」,本层负责把 accel 词法翻译成这两样;
84
+ 解析不了 / 被系统占用的那条整行丢弃并出声(不静默吞,与 Linux 的 X11 grab 口径一致)。 */
85
+ #define CJ_HOTKEY_MAX 16
86
+
87
+ typedef struct hotkey_slot {
88
+ int id; /* RegisterHotKey 的 id(1..CJ_HOTKEY_MAX);WM_HOTKEY 的 wParam 就是它 */
89
+ char *app_id; /* 应用给的稳定标识:回投 core 的 cj_core_hotkey_pressed 用它 */
90
+ UINT mods; /* MOD_CONTROL / MOD_ALT / MOD_SHIFT / MOD_WIN 的按位或(0 = 无修饰)*/
91
+ UINT vk; /* 虚拟键码(VK_*)*/
92
+ int registered; /* RegisterHotKey 成功;失败的那条不参与回投 */
93
+ } hotkey_slot;
94
+
95
+ /* ===== 每个宿主的平台私有状态 =====
96
+ 原先是一堆进程级单例(g_hwnd / g_controller / g_environment / g_webview / g_host_thread_id …)。
97
+ 现在挂在自己的 cj_host->plat 上,平台函数一律从入参句柄取状态。 */
98
+ typedef struct plat_host {
99
+ HWND hwnd; /* 主窗口;WM_DESTROY 后置空 */
100
+ ICoreWebView2Controller *controller;
101
+ ICoreWebView2Environment *environment; /* 必须自己持有一份引用(见 env_Invoke)*/
102
+ ICoreWebView2 *webview;
103
+ HANDLE thread; /* 宿主线程句柄(cj_plat_fini 里 join 用)*/
104
+ DWORD thread_id; /* 宿主线程 id:判断「调用方是否已在宿主线程」*/
105
+ int thread_started;
106
+ int thread_set;
107
+ volatile int thread_done; /* 宿主线程跑完(收尾最后一步):见 cj_plat_fini */
108
+ HMODULE loader; /* WebView2Loader.dll */
109
+ env_handler env;
110
+ ctrl_handler ctrl;
111
+ msg_handler msg;
112
+ nav_handler nav;
113
+ /* --- 菜单栏(只在 UI 线程上建 / 换 / 改)--- */
114
+ HMENU menu; /* 当前菜单栏;NULL = 没有 */
115
+ menu_slot menu_items[CJ_MENU_MAX_ITEMS]; /* 命令 id ↔ 应用 id 映射:WM_COMMAND 靠它回投 */
116
+ int menu_item_count;
117
+ /* --- 拖放(7.B Windows 真实现,只在 UI 线程挂 / 摘)--- */
118
+ IDropTarget *drop_target; /* 我们的拖放目标;NULL = 尚未创建 */
119
+ /* --- 托盘(Shell_NotifyIconW,只在 UI 线程上建 / 换 / 拆)--- */
120
+ NOTIFYICONDATAW nid; /* 图标注册块:hWnd/uID/uCallbackMessage 一次填好 */
121
+ int tray_added; /* 图标真挂上去了(最小化拦截拿它做降级判据)*/
122
+ HMENU tray_menu; /* 托盘菜单(左键 / 右键都弹它);NULL = 没有 */
123
+ menu_slot tray_items[CJ_MENU_MAX_ITEMS]; /* 命令 id ↔ 应用 id:WM_COMMAND 靠它回投 */
124
+ int tray_item_count;
125
+ /* --- 全局快捷键(RegisterHotKey,只在 UI 线程上注册 / 注销)--- */
126
+ hotkey_slot hotkeys[CJ_HOTKEY_MAX];
127
+ int hotkey_count;
128
+ /* --- 最小化到托盘:托盘没装上时「降级为普通最小化」只出声一次 --- */
129
+ int min_to_tray_warned;
130
+ } plat_host;
131
+
132
+ /* 拖放目标的挂/摘(定义在文件后部):controller 完成回调与 wnd_proc 都要调 */
133
+ static void drag_dest_apply(cj_host *h, plat_host *p, int enabled);
134
+
135
+ /* ===== 同步原语(core 只用这些,绝不直接碰 Win32)=====
136
+ 与 Linux 桥的 pthread_mutex + pthread_cond 一一对应;用条件变量而不是「事件」的好处是
137
+ 谓词在锁内检查,不存在「先 SetEvent 后 CreateEvent 丢唤醒」的时序坑。 */
138
+
139
+ struct cj_sync {
140
+ CRITICAL_SECTION cs;
141
+ CONDITION_VARIABLE cv;
142
+ };
143
+
144
+ cj_sync *cj_plat_sync_create(void) {
145
+ cj_sync *s = (cj_sync *)calloc(1, sizeof(cj_sync));
146
+ if (!s) return NULL;
147
+ InitializeCriticalSection(&s->cs);
148
+ InitializeConditionVariable(&s->cv);
149
+ return s;
150
+ }
151
+
152
+ void cj_plat_sync_destroy(cj_sync *s) {
153
+ if (!s) return;
154
+ DeleteCriticalSection(&s->cs); /* CONDITION_VARIABLE 无需销毁 */
155
+ free(s);
156
+ }
157
+
158
+ void cj_plat_sync_lock(cj_sync *s) {
159
+ if (s) EnterCriticalSection(&s->cs);
160
+ }
161
+
162
+ void cj_plat_sync_unlock(cj_sync *s) {
163
+ if (s) LeaveCriticalSection(&s->cs);
164
+ }
165
+
166
+ void cj_plat_sync_wait(cj_sync *s, int *ready) {
167
+ if (!s || !ready) return;
168
+ while (!*ready) {
169
+ SleepConditionVariableCS(&s->cv, &s->cs, INFINITE);
170
+ }
171
+ }
172
+
173
+ void cj_plat_sync_signal(cj_sync *s) {
174
+ if (s) WakeConditionVariable(&s->cv);
175
+ }
176
+
177
+ /* ===== UTF-8 <-> UTF-16 ===== */
178
+
179
+ static wchar_t *utf8_to_wide(const char *s) {
180
+ if (!s) return NULL;
181
+ int n = MultiByteToWideChar(CP_UTF8, 0, s, -1, NULL, 0);
182
+ if (n <= 0) return NULL;
183
+ wchar_t *w = (wchar_t *)malloc((size_t)n * sizeof(wchar_t));
184
+ if (!w) return NULL;
185
+ MultiByteToWideChar(CP_UTF8, 0, s, -1, w, n);
186
+ return w;
187
+ }
188
+
189
+ static char *wide_to_utf8(const wchar_t *w) {
190
+ if (!w) return NULL;
191
+ int n = WideCharToMultiByte(CP_UTF8, 0, w, -1, NULL, 0, NULL, NULL);
192
+ if (n <= 0) return NULL;
193
+ char *s = (char *)malloc((size_t)n);
194
+ if (!s) return NULL;
195
+ WideCharToMultiByte(CP_UTF8, 0, w, -1, s, n, NULL, NULL);
196
+ return s;
197
+ }
198
+
199
+ /* 关键 WebView2 调用的 HRESULT 上报 */
200
+ static void log_hr(const char *what, HRESULT hr) {
201
+ fprintf(stderr, "[cj-bridge] %s -> hr=0x%08lx\n", what, (unsigned long)hr);
202
+ }
203
+
204
+ /* ===== 注入前端桥接脚本(暴露 window.__CJ_TAURI__,对标 @tauri-apps/api)===== */
205
+
206
+ /* 平台唯一差异行(其余 JS 见 native/bridge_js.h,两平台共用一份,防漂移):
207
+ post 通道 = chrome.webview;reload 控制消息同通道发字面量。宽字符:本文件要 wchar_t。 */
208
+ #define CJ_LIT(s) L##s
209
+ #define CJ_POST_STMT L" window.chrome.webview.postMessage(JSON.stringify(obj));"
210
+ #define CJ_RELOAD_STMT L" window.chrome.webview.postMessage('__cj_tauri_reload__');"
211
+ #include "bridge_js.h"
212
+
213
+ static const wchar_t *BRIDGE_JS = CJ_BRIDGE_JS(CJ_POST_STMT, CJ_RELOAD_STMT);
214
+
215
+ /* ===== 投递通道用到的窗口消息(PostMessage 按窗口寻址,句柄从窗口 USERDATA 取)===== */
216
+
217
+ #define WM_CJT_FLUSH (WM_APP + 1)
218
+ #define WM_CJT_QUIT (WM_APP + 2)
219
+ #define WM_CJT_DEVTOOLS (WM_APP + 3)
220
+ #define WM_CJT_LOAD_URL (WM_APP + 4)
221
+ #define WM_CJT_RELOAD (WM_APP + 5)
222
+ #define WM_CJT_DIALOG (WM_APP + 6)
223
+ #define WM_CJT_MENU (WM_APP + 7) /* 运行期换菜单:lParam = strdup 的线路文本(UI 线程释放)*/
224
+ #define WM_CJT_MENU_ITEM (WM_APP + 8) /* 运行期改单项状态:lParam = menu_state_msg*(UI 线程释放)*/
225
+ #define WM_CJT_UI (WM_APP + 9) /* 通用 UI 投递(剪贴板等):lParam = cj_ui_msg*(UI 线程释放)*/
226
+ #define WM_CJT_DRAG_DROP (WM_APP + 10) /* 运行期挂/摘拖放:lParam = drag_drop_msg*(UI 线程释放)*/
227
+ #define WM_CJT_TRAY (WM_APP + 11) /* 运行期换托盘:lParam = strdup 的线路文本(UI 线程释放)*/
228
+ #define WM_CJT_HOTKEYS (WM_APP + 12) /* 运行期换快捷键表:lParam = strdup 的线路文本(UI 线程释放)*/
229
+ #define WM_CJT_VISIBLE (WM_APP + 13) /* 运行期改可见性:wParam = 0 隐藏 / 1 显示 */
230
+ #define WM_CJT_TRAY_NOTIFY (WM_APP + 14) /* 托盘图标的回调消息(Shell_NotifyIconW 的 uCallbackMessage)*/
231
+
232
+ /* 托盘菜单项的命令 id 从 1000 起:TrackPopupMenu 的 WM_COMMAND 也落到本窗口,
233
+ 与菜单栏的命令 id(从 1 起)分开放,WM_COMMAND 才能判别是哪一个表。 */
234
+ #define CJ_TRAY_CMD_BASE 1000
235
+ #define CJ_TRAY_UID 1 /* NOTIFYICONDATAW.uID:一个进程一个托盘图标 */
236
+
237
+ /* 运行期改单项状态:跨线程传递的小包裹(谁 post 谁分配,UI 线程处理完释放)*/
238
+ typedef struct menu_state_msg {
239
+ char *id;
240
+ int enabled;
241
+ int checked;
242
+ } menu_state_msg;
243
+
244
+ /* 运行期挂/摘拖放:跨线程传递的小包裹(谁 post 谁分配,UI 线程处理完释放)*/
245
+ typedef struct drag_drop_msg {
246
+ int enabled;
247
+ } drag_drop_msg;
248
+
249
+ /* 通用 UI 投递的包裹:PostMessage 一次带一个 fn + ud(UI 线程处理完释放)*/
250
+ typedef struct cj_ui_msg {
251
+ cj_ui_fn fn;
252
+ void *ud;
253
+ } cj_ui_msg;
254
+
255
+ static LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp);
256
+ /* 错误格式化助手(定义在串口区,剪贴板也用它) */
257
+ static void serial_err(char *err, int err_len, const char *fmt, ...);
258
+ /* 菜单实现集中在文件末尾;宿主线程与窗口过程要用,先声明 */
259
+ static void menu_apply_wire(cj_host *h, plat_host *p, const char *wire);
260
+ static void menu_apply_item_state(plat_host *p, const char *id, int enabled, int checked);
261
+ static void menu_clear_slots(plat_host *p);
262
+ /* 托盘 / 全局快捷键 / 窗口可见性实现同样在文件末尾;宿主线程与窗口过程要用,先声明 */
263
+ static void tray_apply_wire(cj_host *h, plat_host *p, const char *wire);
264
+ static void tray_remove(plat_host *p);
265
+ static void hotkey_apply_wire(plat_host *p, const char *wire);
266
+ static void hotkey_unregister_all(plat_host *p);
267
+ static void window_apply_visible(plat_host *p, int visible);
268
+ static int accel_parse(const char *accel, UINT *mods, UINT *vk);
269
+
270
+ /* ===== COM 回调实现(完成/事件 handler)===== */
271
+
272
+ /* --- 环境创建完成 --- */
273
+
274
+ static HRESULT STDMETHODCALLTYPE env_QueryInterface(
275
+ ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler *This, REFIID riid, void **ppvObject) {
276
+ if (ppvObject) *ppvObject = This;
277
+ return S_OK;
278
+ }
279
+
280
+ static ULONG STDMETHODCALLTYPE env_AddRef(
281
+ ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler *This) {
282
+ return 1;
283
+ }
284
+
285
+ static ULONG STDMETHODCALLTYPE env_Release(
286
+ ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler *This) {
287
+ return 1;
288
+ }
289
+
290
+ static HRESULT STDMETHODCALLTYPE env_Invoke(
291
+ ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler *This,
292
+ HRESULT errorCode, ICoreWebView2Environment *result) {
293
+ env_handler *self = (env_handler *)This;
294
+ cj_host *h = self ? self->h : NULL;
295
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
296
+
297
+ if (!h || !p) return S_OK;
298
+ if (FAILED(errorCode) || !result) {
299
+ fprintf(stderr, "[cj-bridge] create WebView2 environment failed: 0x%08lx\n",
300
+ (unsigned long)errorCode);
301
+ h->should_quit = 1;
302
+ cj_core_window_destroyed(h); /* 通知仓颉侧(onDestroy 恰好一次)+ 作废对话框 */
303
+ if (p->hwnd) PostMessageW(p->hwnd, WM_CJT_QUIT, 0, 0);
304
+ return S_OK;
305
+ }
306
+ /* 必须自己持有一份环境引用:否则本回调返回后环境对象被释放,
307
+ WebView2 会随即关闭浏览器进程,表现为导航不完成、ExecuteScript 返回 0x8007139F */
308
+ p->environment = result;
309
+ p->environment->lpVtbl->AddRef(p->environment);
310
+ result->lpVtbl->CreateCoreWebView2Controller(result, p->hwnd, &p->ctrl.iface);
311
+ fprintf(stderr, "[cj-bridge] WebView2 environment ready\n");
312
+ return S_OK;
313
+ }
314
+
315
+ static ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandlerVtbl g_env_vtbl = {
316
+ env_QueryInterface, env_AddRef, env_Release, env_Invoke
317
+ };
318
+
319
+ /* --- 控制器(WebView)创建完成:装配 WebView、注入桥、加载 HTML --- */
320
+
321
+ static HRESULT STDMETHODCALLTYPE ctrl_QueryInterface(
322
+ ICoreWebView2CreateCoreWebView2ControllerCompletedHandler *This, REFIID riid, void **ppvObject) {
323
+ if (ppvObject) *ppvObject = This;
324
+ return S_OK;
325
+ }
326
+
327
+ static ULONG STDMETHODCALLTYPE ctrl_AddRef(
328
+ ICoreWebView2CreateCoreWebView2ControllerCompletedHandler *This) {
329
+ return 1;
330
+ }
331
+
332
+ static ULONG STDMETHODCALLTYPE ctrl_Release(
333
+ ICoreWebView2CreateCoreWebView2ControllerCompletedHandler *This) {
334
+ return 1;
335
+ }
336
+
337
+ static HRESULT STDMETHODCALLTYPE ctrl_Invoke(
338
+ ICoreWebView2CreateCoreWebView2ControllerCompletedHandler *This,
339
+ HRESULT errorCode, ICoreWebView2Controller *result) {
340
+ ctrl_handler *self = (ctrl_handler *)This;
341
+ cj_host *h = self ? self->h : NULL;
342
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
343
+ RECT rc;
344
+ int i;
345
+
346
+ if (!h || !p) return S_OK;
347
+ if (FAILED(errorCode) || !result) {
348
+ fprintf(stderr, "[cj-bridge] create WebView2 controller failed: 0x%08lx\n",
349
+ (unsigned long)errorCode);
350
+ h->should_quit = 1;
351
+ cj_core_window_destroyed(h);
352
+ if (p->hwnd) PostMessageW(p->hwnd, WM_CJT_QUIT, 0, 0);
353
+ return S_OK;
354
+ }
355
+ p->controller = result;
356
+ p->controller->lpVtbl->AddRef(p->controller);
357
+ result->lpVtbl->get_CoreWebView2(result, &p->webview);
358
+ if (!p->webview) {
359
+ fprintf(stderr, "[cj-bridge] get_CoreWebView2 failed\n");
360
+ return S_OK;
361
+ }
362
+
363
+ /* 先让父窗口可见,再摆放 WebView:不可见的父窗口会挂起渲染 */
364
+ ShowWindow(p->hwnd, SW_SHOW);
365
+ UpdateWindow(p->hwnd);
366
+
367
+ GetClientRect(p->hwnd, &rc);
368
+ result->lpVtbl->put_Bounds(result, rc);
369
+ result->lpVtbl->put_IsVisible(result, TRUE);
370
+
371
+ /* WebView 行为设置 */
372
+ {
373
+ ICoreWebView2Settings *settings = NULL;
374
+ HRESULT hr = p->webview->lpVtbl->get_Settings(p->webview, &settings);
375
+ if (SUCCEEDED(hr) && settings) {
376
+ settings->lpVtbl->put_IsStatusBarEnabled(settings, FALSE);
377
+ settings->lpVtbl->put_IsZoomControlEnabled(settings, FALSE);
378
+ settings->lpVtbl->put_AreDefaultContextMenusEnabled(settings, TRUE);
379
+ settings->lpVtbl->put_AreDevToolsEnabled(settings, h->devtools ? TRUE : FALSE);
380
+ settings->lpVtbl->Release(settings);
381
+ }
382
+ }
383
+
384
+ /* 拖放:先关掉 WebView2 自己的外部拖放(不关的话它先吃掉 drop,默认行为是导航到文件);
385
+ 装配期已 set_drag_drop 过的(pending 非 0)这时挂我们的 IDropTarget */
386
+ {
387
+ ICoreWebView2Controller4 *c4 = NULL;
388
+ if (SUCCEEDED(p->controller->lpVtbl->QueryInterface(p->controller,
389
+ &IID_ICoreWebView2Controller4, (void **)&c4)) && c4) {
390
+ c4->lpVtbl->put_AllowExternalDrop(c4, FALSE);
391
+ c4->lpVtbl->Release(c4);
392
+ }
393
+ }
394
+ if (h->pending_drop_enabled) {
395
+ drag_dest_apply(h, p, h->pending_drop_enabled);
396
+ }
397
+
398
+ /* 注入桥接脚本(页面脚本执行前)+ 注册 JS 消息通道 */
399
+ p->webview->lpVtbl->AddScriptToExecuteOnDocumentCreated(p->webview, BRIDGE_JS, NULL);
400
+ /* 预执行脚本按注册顺序追加在桥之后(WebView2 按注册顺序执行),脚本里可直接用 window.__CJ_TAURI__ */
401
+ for (i = 0; i < h->init_script_count; i++) {
402
+ wchar_t *w = utf8_to_wide(h->init_scripts[i]);
403
+ if (w) {
404
+ p->webview->lpVtbl->AddScriptToExecuteOnDocumentCreated(p->webview, w, NULL);
405
+ free(w);
406
+ }
407
+ }
408
+ fprintf(stderr, "[cj-bridge] init scripts injected: %d\n", h->init_script_count);
409
+ p->webview->lpVtbl->add_WebMessageReceived(p->webview, &p->msg.iface, NULL);
410
+ p->webview->lpVtbl->add_NavigationCompleted(p->webview, &p->nav.iface, NULL);
411
+
412
+ cj_core_set_ready(h);
413
+ fprintf(stderr, "[cj-bridge] controller ready, loading page\n");
414
+
415
+ /* 页面来源:URL 优先(cj_bridge_load_url),否则内联 HTML(cj_bridge_start) */
416
+ if (h->pending_url) {
417
+ wchar_t *w = utf8_to_wide(h->pending_url);
418
+ if (w) {
419
+ log_hr("Navigate", p->webview->lpVtbl->Navigate(p->webview, w));
420
+ free(w);
421
+ }
422
+ fprintf(stderr, "[cj-bridge] url navigation requested: %s\n", h->pending_url);
423
+ } else if (h->pending_html) {
424
+ wchar_t *w = utf8_to_wide(h->pending_html);
425
+ if (w) {
426
+ log_hr("NavigateToString", p->webview->lpVtbl->NavigateToString(p->webview, w));
427
+ free(w);
428
+ }
429
+ fprintf(stderr, "[cj-bridge] html navigation requested\n");
430
+ }
431
+ /* 宿主就绪前可能已有排队脚本(如启动即推送的事件) */
432
+ cj_core_flush_js(h);
433
+ return S_OK;
434
+ }
435
+
436
+ static ICoreWebView2CreateCoreWebView2ControllerCompletedHandlerVtbl g_ctrl_vtbl = {
437
+ ctrl_QueryInterface, ctrl_AddRef, ctrl_Release, ctrl_Invoke
438
+ };
439
+
440
+ /* --- JS → 宿主的消息(chrome.webview.postMessage 的字符串载荷)--- */
441
+
442
+ static HRESULT STDMETHODCALLTYPE msg_QueryInterface(
443
+ ICoreWebView2WebMessageReceivedEventHandler *This, REFIID riid, void **ppvObject) {
444
+ if (ppvObject) *ppvObject = This;
445
+ return S_OK;
446
+ }
447
+
448
+ static ULONG STDMETHODCALLTYPE msg_AddRef(ICoreWebView2WebMessageReceivedEventHandler *This) {
449
+ return 1;
450
+ }
451
+
452
+ static ULONG STDMETHODCALLTYPE msg_Release(ICoreWebView2WebMessageReceivedEventHandler *This) {
453
+ return 1;
454
+ }
455
+
456
+ static HRESULT STDMETHODCALLTYPE msg_Invoke(
457
+ ICoreWebView2WebMessageReceivedEventHandler *This,
458
+ ICoreWebView2 *sender, ICoreWebView2WebMessageReceivedEventArgs *args) {
459
+ msg_handler *self = (msg_handler *)This;
460
+ cj_host *h = self ? self->h : NULL;
461
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
462
+ LPWSTR payload = NULL;
463
+
464
+ if (SUCCEEDED(args->lpVtbl->TryGetWebMessageAsString(args, &payload)) && payload) {
465
+ char *utf8 = wide_to_utf8(payload);
466
+ CoTaskMemFree(payload);
467
+ if (utf8) {
468
+ /* 前端 __CJ_TAURI__.reload():宿主控制消息,不进 IPC hub */
469
+ if (strcmp(utf8, CJT_RELOAD_MSG) == 0) {
470
+ fprintf(stderr, "[cj-bridge] frontend requested reload\n");
471
+ if (p && p->hwnd) PostMessageW(p->hwnd, WM_CJT_RELOAD, 0, 0);
472
+ free(utf8);
473
+ return S_OK;
474
+ }
475
+ fprintf(stderr, "[cj-bridge] js -> native (%d bytes)\n", (int)strlen(utf8));
476
+ if (h && h->on_message) h->on_message(utf8); /* 回调仓颉(IPC hub) */
477
+ free(utf8);
478
+ }
479
+ }
480
+ return S_OK;
481
+ }
482
+
483
+ static ICoreWebView2WebMessageReceivedEventHandlerVtbl g_msg_vtbl = {
484
+ msg_QueryInterface, msg_AddRef, msg_Release, msg_Invoke
485
+ };
486
+
487
+ /* --- 导航完成:仅在失败时上报(成功无需打扰用户)--- */
488
+
489
+ static HRESULT STDMETHODCALLTYPE nav_QueryInterface(
490
+ ICoreWebView2NavigationCompletedEventHandler *This, REFIID riid, void **ppvObject) {
491
+ if (ppvObject) *ppvObject = This;
492
+ return S_OK;
493
+ }
494
+
495
+ static ULONG STDMETHODCALLTYPE nav_AddRef(ICoreWebView2NavigationCompletedEventHandler *This) {
496
+ return 1;
497
+ }
498
+
499
+ static ULONG STDMETHODCALLTYPE nav_Release(ICoreWebView2NavigationCompletedEventHandler *This) {
500
+ return 1;
501
+ }
502
+
503
+ static HRESULT STDMETHODCALLTYPE nav_Invoke(
504
+ ICoreWebView2NavigationCompletedEventHandler *This,
505
+ ICoreWebView2 *sender, ICoreWebView2NavigationCompletedEventArgs *args) {
506
+ BOOL ok = FALSE;
507
+ COREWEBVIEW2_WEB_ERROR_STATUS status = COREWEBVIEW2_WEB_ERROR_STATUS_UNKNOWN;
508
+ args->lpVtbl->get_IsSuccess(args, &ok);
509
+ args->lpVtbl->get_WebErrorStatus(args, &status);
510
+ if (!ok) {
511
+ fprintf(stderr, "[cj-bridge] navigation failed: web error status=%d\n", (int)status);
512
+ }
513
+ return S_OK;
514
+ }
515
+
516
+ static ICoreWebView2NavigationCompletedEventHandlerVtbl g_nav_vtbl = {
517
+ nav_QueryInterface, nav_AddRef, nav_Release, nav_Invoke
518
+ };
519
+
520
+ /* ===== 原生对话框(原生弹窗部分;状态机在 core)===== */
521
+
522
+ /* "描述|模式|描述|模式" → Win32 双 NUL 过滤器串(末尾落单的描述段按 *.* 处理) */
523
+ static wchar_t *build_win_filter(const char *filter) {
524
+ const char *p;
525
+ wchar_t *out;
526
+ size_t cap, len = 0;
527
+
528
+ if (!filter || !filter[0]) {
529
+ filter = "所有文件 (*.*)|*.*";
530
+ }
531
+ cap = strlen(filter) * 4 + 8; /* UTF-8 → UTF-16 上界,末尾留双 NUL 位 */
532
+ out = (wchar_t *)calloc(cap, sizeof(wchar_t));
533
+ if (!out) return NULL;
534
+
535
+ p = filter;
536
+ while (p && p[0]) {
537
+ const char *bar = strchr(p, '|');
538
+ size_t seg = bar ? (size_t)(bar - p) : strlen(p);
539
+ int n = MultiByteToWideChar(CP_UTF8, 0, p, (int)seg, out + len, (int)(cap - len - 2));
540
+ if (n > 0) {
541
+ len += (size_t)n;
542
+ out[len++] = 0; /* 段结束符 */
543
+ }
544
+ if (!bar) {
545
+ break;
546
+ }
547
+ if (!bar[1]) { /* 末尾落单的描述段:补一个 *.* 当模式 */
548
+ int m = MultiByteToWideChar(CP_UTF8, 0, "*.*", 3, out + len, (int)(cap - len - 2));
549
+ if (m > 0) {
550
+ len += (size_t)m;
551
+ out[len++] = 0;
552
+ }
553
+ break;
554
+ }
555
+ p = bar + 1;
556
+ }
557
+ out[len] = 0; /* 双 NUL 收尾(上一格已经是 0) */
558
+ return out;
559
+ }
560
+
561
+ /* 已经站在宿主线程上:直接弹一次,返回 1 = 确认;选中路径写入 *out_path(由调用方 free) */
562
+ static int show_dialog_here(cj_host *h, int kind, const char *title, const char *message,
563
+ const char *filter, char **out_path) {
564
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
565
+ HWND owner = p ? p->hwnd : NULL;
566
+ int ok = 0;
567
+
568
+ *out_path = NULL;
569
+ if (kind == 6) {
570
+ /* 选目录:GetOpenFileName 没有选目录能力,走 shell 的 IFileOpenDialog + FOS_PICKFOLDERS。
571
+ COM 套间在 WebView2 宿主线程上已初始化(WebView2 自身的前提),这里不再 CoInitialize——
572
+ 重复初始化会多一次配平负担(且 STA 下 CoUninitialize 归谁都是麻烦)。
573
+ 失败一律留一行 stderr 凭证:这个 API 家族「静默失败」的形状是弹了框但拿不到路径。 */
574
+ IFileOpenDialog *fd = NULL;
575
+ HRESULT hr = CoCreateInstance(&CLSID_FileOpenDialog, NULL, CLSCTX_INPROC_SERVER,
576
+ &IID_IFileOpenDialog, (void **)&fd);
577
+ if (SUCCEEDED(hr) && fd) {
578
+ DWORD opts = 0;
579
+ if (SUCCEEDED(fd->lpVtbl->GetOptions(fd, &opts))) {
580
+ fd->lpVtbl->SetOptions(fd, opts | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM);
581
+ }
582
+ if (title[0]) {
583
+ wchar_t *title_w = utf8_to_wide(title);
584
+ if (title_w) {
585
+ fd->lpVtbl->SetTitle(fd, title_w);
586
+ free(title_w);
587
+ }
588
+ }
589
+ hr = fd->lpVtbl->Show(fd, owner);
590
+ if (SUCCEEDED(hr)) {
591
+ IShellItem *item = NULL;
592
+ if (SUCCEEDED(fd->lpVtbl->GetResult(fd, &item)) && item) {
593
+ PWSTR psz = NULL;
594
+ if (SUCCEEDED(item->lpVtbl->GetDisplayName(item, SIGDN_FILESYSPATH, &psz)) && psz) {
595
+ char *u8 = wide_to_utf8(psz);
596
+ if (u8) {
597
+ *out_path = u8;
598
+ ok = 1;
599
+ }
600
+ CoTaskMemFree(psz);
601
+ }
602
+ item->lpVtbl->Release(item);
603
+ }
604
+ } else if (hr != HRESULT_FROM_WIN32(ERROR_CANCELLED)) {
605
+ /* 取消不是失败(用户按了取消 / 关了框):只有真错才落凭证 */
606
+ fprintf(stderr, "[cj-bridge] folder dialog failed: hr=0x%08lx\n", (unsigned long)hr);
607
+ }
608
+ fd->lpVtbl->Release(fd);
609
+ } else {
610
+ fprintf(stderr, "[cj-bridge] folder dialog failed: CoCreateInstance hr=0x%08lx\n",
611
+ (unsigned long)hr);
612
+ }
613
+ } else if (kind <= 1) {
614
+ wchar_t path[32768];
615
+ wchar_t *title_w = utf8_to_wide(title[0] ? title : (kind == 1 ? "保存文件" : "打开文件"));
616
+ wchar_t *filter_w = build_win_filter(filter);
617
+ OPENFILENAMEW ofn;
618
+
619
+ /* message 参数在文件类对话框里当**默认文件名**用(showFileDialogPreset 的通道):
620
+ 探针的自动确认(对 #32770 发 IDOK)需要框里已有名字才能关——多窗并发取证靠它 */
621
+ path[0] = 0;
622
+ if (message && message[0]) {
623
+ wchar_t *preset = utf8_to_wide(message);
624
+ if (preset) {
625
+ size_t plen = wcslen(preset);
626
+ if (plen >= 32768) plen = 32767;
627
+ wmemcpy(path, preset, plen);
628
+ path[plen] = 0;
629
+ free(preset);
630
+ }
631
+ }
632
+ ZeroMemory(&ofn, sizeof(ofn));
633
+ ofn.lStructSize = sizeof(ofn);
634
+ ofn.hwndOwner = owner;
635
+ ofn.lpstrFile = path;
636
+ ofn.nMaxFile = (DWORD)(sizeof(path) / sizeof(path[0]));
637
+ ofn.lpstrFilter = filter_w;
638
+ ofn.nFilterIndex = 1;
639
+ ofn.lpstrTitle = title_w;
640
+ ofn.Flags = OFN_EXPLORER | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR |
641
+ ((kind == 1) ? OFN_OVERWRITEPROMPT : OFN_FILEMUSTEXIST);
642
+ {
643
+ BOOL got = (kind == 1) ? GetSaveFileNameW(&ofn) : GetOpenFileNameW(&ofn);
644
+ if (got) {
645
+ *out_path = wide_to_utf8(path);
646
+ ok = 1;
647
+ } else {
648
+ DWORD err = CommDlgExtendedError();
649
+ if (err) {
650
+ fprintf(stderr, "[cj-bridge] dialog failed: CommDlgExtendedError=0x%lx\n",
651
+ (unsigned long)err);
652
+ }
653
+ }
654
+ }
655
+ free(title_w);
656
+ free(filter_w);
657
+ } else {
658
+ UINT flags = MB_OK | MB_ICONINFORMATION | MB_SETFOREGROUND;
659
+ wchar_t *title_w, *msg_w;
660
+
661
+ if (kind == 3) {
662
+ flags = MB_OK | MB_ICONWARNING | MB_SETFOREGROUND;
663
+ } else if (kind == 4) {
664
+ flags = MB_OK | MB_ICONERROR | MB_SETFOREGROUND;
665
+ } else if (kind == 5) {
666
+ flags = MB_OKCANCEL | MB_ICONQUESTION | MB_SETFOREGROUND;
667
+ }
668
+ title_w = utf8_to_wide(title[0] ? title : "cj-tauri");
669
+ msg_w = utf8_to_wide(message[0] ? message : " ");
670
+ {
671
+ int res = MessageBoxW(owner, msg_w, title_w, flags);
672
+ ok = (kind == 5) ? (res == IDOK) : 1;
673
+ }
674
+ free(title_w);
675
+ free(msg_w);
676
+ }
677
+ return ok;
678
+ }
679
+
680
+ int cj_plat_run_dialog(cj_host *h, dlg_req *req) {
681
+ if (!req) return 0;
682
+ req->ok = show_dialog_here(h, req->kind, req->title, req->message, req->filter, &req->path);
683
+ return req->ok;
684
+ }
685
+
686
+ void cj_plat_post_dialog(cj_host *h, dlg_req *req) {
687
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
688
+ if (!p || !p->hwnd) {
689
+ fprintf(stderr, "[cj-bridge] dialog post failed: window not ready\n");
690
+ cj_core_dialog_abort(h); /* 投不出去就别让调用方干等 */
691
+ return;
692
+ }
693
+ if (!PostMessageW(p->hwnd, WM_CJT_DIALOG, 0, (LPARAM)req)) {
694
+ fprintf(stderr, "[cj-bridge] dialog post failed: %lu\n", (unsigned long)GetLastError());
695
+ cj_core_dialog_abort(h);
696
+ }
697
+ }
698
+
699
+ int cj_plat_post_ui(cj_host *h, cj_ui_fn fn, void *ud) {
700
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
701
+ cj_ui_msg *m;
702
+
703
+ if (!p || !p->hwnd) {
704
+ fprintf(stderr, "[cj-bridge] post ui failed: window not ready\n");
705
+ return 0;
706
+ }
707
+ m = (cj_ui_msg *)malloc(sizeof(cj_ui_msg));
708
+ if (!m) {
709
+ return 0;
710
+ }
711
+ m->fn = fn;
712
+ m->ud = ud;
713
+ if (!PostMessageW(p->hwnd, WM_CJT_UI, 0, (LPARAM)m)) {
714
+ fprintf(stderr, "[cj-bridge] post ui failed: %lu\n", (unsigned long)GetLastError());
715
+ free(m);
716
+ return 0;
717
+ }
718
+ return 1;
719
+ }
720
+
721
+ /* ===== 剪贴板(plugin_clipboard 的平台层):Win32 clipboard =====
722
+ 调用方保证已在宿主 UI 线程上(core 的 UI 线程分流负责)。OpenClipboard 带重试:
723
+ 剪贴板是系统单例,别的进程可能正占着(MSDN 建议的做法)。 */
724
+
725
+ static int clip_open_retry(HWND hwnd) {
726
+ int i;
727
+ for (i = 0; i < 10; i++) {
728
+ if (OpenClipboard(hwnd)) return 1;
729
+ Sleep(2);
730
+ }
731
+ return 0;
732
+ }
733
+
734
+ int cj_plat_clipboard_write_text(cj_host *h, const char *text, char *err, int err_len) {
735
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
736
+ int wlen;
737
+ wchar_t *wtext;
738
+ HGLOBAL glob;
739
+ wchar_t *dst;
740
+ size_t bytes;
741
+
742
+ if (!clip_open_retry(p ? p->hwnd : NULL)) {
743
+ if (err && err_len > 0) serial_err(err, err_len, "clipboard: OpenClipboard 失败(被别的进程占用?)");
744
+ return -3;
745
+ }
746
+ EmptyClipboard();
747
+ wlen = MultiByteToWideChar(CP_UTF8, 0, text ? text : "", -1, NULL, 0);
748
+ if (wlen <= 0) {
749
+ CloseClipboard();
750
+ if (err && err_len > 0) serial_err(err, err_len, "clipboard: 文本不是合法 UTF-8");
751
+ return -1;
752
+ }
753
+ bytes = (size_t)wlen * sizeof(wchar_t);
754
+ glob = GlobalAlloc(GMEM_MOVEABLE, bytes);
755
+ if (!glob) {
756
+ CloseClipboard();
757
+ if (err && err_len > 0) serial_err(err, err_len, "clipboard: GlobalAlloc 失败");
758
+ return -3;
759
+ }
760
+ dst = (wchar_t *)GlobalLock(glob);
761
+ if (!dst) {
762
+ GlobalFree(glob);
763
+ CloseClipboard();
764
+ if (err && err_len > 0) serial_err(err, err_len, "clipboard: GlobalLock 失败");
765
+ return -3;
766
+ }
767
+ MultiByteToWideChar(CP_UTF8, 0, text ? text : "", -1, dst, wlen);
768
+ GlobalUnlock(glob);
769
+ /* SetClipboardData 交出所有权:成功后 glob 归系统,不再 free */
770
+ if (!SetClipboardData(CF_UNICODETEXT, glob)) {
771
+ GlobalFree(glob);
772
+ CloseClipboard();
773
+ if (err && err_len > 0) serial_err(err, err_len, "clipboard: SetClipboardData 失败");
774
+ return -3;
775
+ }
776
+ CloseClipboard();
777
+ return 0;
778
+ }
779
+
780
+ char *cj_plat_clipboard_read_text(cj_host *h, char *err, int err_len) {
781
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
782
+ HANDLE hd;
783
+ const wchar_t *wtext;
784
+ int len;
785
+ char *out;
786
+
787
+ if (!clip_open_retry(p ? p->hwnd : NULL)) {
788
+ if (err && err_len > 0) serial_err(err, err_len, "clipboard: OpenClipboard 失败(被别的进程占用?)");
789
+ return NULL;
790
+ }
791
+ hd = GetClipboardData(CF_UNICODETEXT);
792
+ if (!hd) {
793
+ CloseClipboard();
794
+ return NULL; /* 空 / 非文本,不是错误 */
795
+ }
796
+ wtext = (const wchar_t *)GlobalLock(hd);
797
+ if (!wtext) {
798
+ CloseClipboard();
799
+ return NULL;
800
+ }
801
+ len = WideCharToMultiByte(CP_UTF8, 0, wtext, -1, NULL, 0, NULL, NULL);
802
+ out = NULL;
803
+ if (len > 0) {
804
+ out = (char *)malloc((size_t)len);
805
+ if (out) {
806
+ WideCharToMultiByte(CP_UTF8, 0, wtext, -1, out, len, NULL, NULL);
807
+ }
808
+ }
809
+ GlobalUnlock(hd);
810
+ CloseClipboard();
811
+ return out; /* NULL = 空 / 非文本 / 转换失败 */
812
+ }
813
+
814
+ /* ===== 通知(plugin_notification 的平台层):Windows Toast =====
815
+ v1 实现走 PowerShell 的 WinRT 投影(系统自带,零新增依赖、MIT 口径不变):
816
+ 脚本整体经 -EncodedCommand(UTF-16LE 的 base64)传入——不碰 cmd 的引号转义,
817
+ 也避开 AGENTS §4 的「PowerShell 按 OEM 码页读错误」一整类坑;标题/正文先做 XML 转义。
818
+ AUMID 借用 PowerShell 自己的(未注册 AUMID 的 toast 在 Win10+ 不显示,这是通行做法)。
819
+ 发送语义:CreateProcess 成功即返回 0(fire-and-forget,toast 的可见与否由系统托盘设置决定);
820
+ spawn 失败(无 powershell 等)返回 -3,stderr 带原因。 */
821
+
822
+ /* 小工具:标准 base64(RFC 4648,带填充)。输入 UTF-16LE 字节流。 */
823
+ static BOOL toast_b64(const unsigned char *in, size_t n, char **out) {
824
+ static const char tab[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
825
+ size_t olen = ((n + 2) / 3) * 4;
826
+ size_t i, o = 0;
827
+ char *buf = (char *)malloc(olen + 1);
828
+ if (!buf) return FALSE;
829
+ for (i = 0; i + 2 < n; i += 3) {
830
+ unsigned v = (in[i] << 16) | (in[i + 1] << 8) | in[i + 2];
831
+ buf[o++] = tab[(v >> 18) & 63]; buf[o++] = tab[(v >> 12) & 63];
832
+ buf[o++] = tab[(v >> 6) & 63]; buf[o++] = tab[v & 63];
833
+ }
834
+ if (i < n) {
835
+ unsigned v = in[i] << 16;
836
+ int pad = (i + 1 < n) ? 1 : 2;
837
+ if (i + 1 < n) v |= in[i + 1] << 8;
838
+ buf[o++] = tab[(v >> 18) & 63]; buf[o++] = tab[(v >> 12) & 63];
839
+ buf[o++] = pad == 1 ? tab[(v >> 6) & 63] : '=';
840
+ buf[o++] = '=';
841
+ }
842
+ buf[o] = '\0';
843
+ *out = buf;
844
+ return TRUE;
845
+ }
846
+
847
+ /* XML 文本转义(& < > ' " 追加进 buf,容量不足截断) */
848
+ static void toast_xml_escape(const char *s, char *buf, size_t cap) {
849
+ size_t o = 0, i;
850
+ for (i = 0; s[i] && o + 8 < cap; i++) {
851
+ const char *rep = NULL;
852
+ if (s[i] == '&') rep = "&amp;";
853
+ else if (s[i] == '<') rep = "&lt;";
854
+ else if (s[i] == '>') rep = "&gt;";
855
+ else if (s[i] == '\'') rep = "&apos;";
856
+ else if (s[i] == '"') rep = "&quot;";
857
+ if (rep) {
858
+ size_t l = strlen(rep);
859
+ if (o + l + 1 >= cap) break;
860
+ memcpy(buf + o, rep, l);
861
+ o += l;
862
+ } else {
863
+ buf[o++] = s[i];
864
+ }
865
+ }
866
+ buf[o] = '\0';
867
+ }
868
+
869
+ int cj_plat_notification_notify(cj_host *h, const char *title, const char *body,
870
+ char *err, int err_len) {
871
+ char t_esc[1024];
872
+ char b_esc[16384];
873
+ char *script; /* UTF-8 PowerShell 脚本 */
874
+ char *b64; /* -EncodedCommand 载荷 */
875
+ unsigned char *u16;
876
+ size_t n16 = 0, slen, i;
877
+ wchar_t *cmdline;
878
+ STARTUPINFOW si;
879
+ PROCESS_INFORMATION pi;
880
+ BOOL ok;
881
+
882
+ (void)h;
883
+ toast_xml_escape(title ? title : "", t_esc, sizeof(t_esc));
884
+ toast_xml_escape(body ? body : "", b_esc, sizeof(b_esc));
885
+
886
+ slen = strlen(t_esc) + strlen(b_esc) + 512;
887
+ script = (char *)malloc(slen);
888
+ if (!script) {
889
+ if (err && err_len > 0) serial_err(err, err_len, "%s", "notification: out of memory");
890
+ return -3;
891
+ }
892
+ snprintf(script, slen,
893
+ "$a='{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe';"
894
+ "[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,"
895
+ "ContentType=WindowsRuntime]>$null;"
896
+ "$t=[Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent("
897
+ "[Windows.UI.Notifications.ToastTemplateType]::ToastText02);"
898
+ "$x=$t.GetElementsByTagName('text');"
899
+ "$x.Item(0).AppendChild($t.CreateTextNode('%s'))|Out-Null;"
900
+ "$x.Item(1).AppendChild($t.CreateTextNode('%s'))|Out-Null;"
901
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($a)"
902
+ ".Show([Windows.UI.Notifications.ToastNotification]::new($t))",
903
+ t_esc, b_esc);
904
+
905
+ /* UTF-8 → UTF-16LE → base64(-EncodedCommand 的口径) */
906
+ u16 = (unsigned char *)malloc((slen + 1) * 2);
907
+ if (!u16) {
908
+ free(script);
909
+ if (err && err_len > 0) serial_err(err, err_len, "%s", "notification: out of memory");
910
+ return -3;
911
+ }
912
+ for (i = 0; i < slen; i++) {
913
+ unsigned char c = (unsigned char)script[i];
914
+ if (c < 0x80) {
915
+ u16[n16++] = c; u16[n16++] = 0;
916
+ } else {
917
+ /* 非 ASCII(如中文标题):走 MultiByteToWideChar,别手工拆 UTF-8 */
918
+ wchar_t *w = utf8_to_wide(script);
919
+ if (!w) { free(u16); free(script); return -3; }
920
+ n16 = 0;
921
+ for (i = 0; w[i]; i++) {
922
+ u16[n16++] = (unsigned char)(w[i] & 0xFF);
923
+ u16[n16++] = (unsigned char)((w[i] >> 8) & 0xFF);
924
+ }
925
+ free(w);
926
+ break;
927
+ }
928
+ }
929
+ free(script);
930
+ if (!toast_b64(u16, n16, &b64)) {
931
+ free(u16);
932
+ if (err && err_len > 0) serial_err(err, err_len, "%s", "notification: out of memory");
933
+ return -3;
934
+ }
935
+ free(u16);
936
+
937
+ cmdline = (wchar_t *)malloc((strlen(b64) + 128) * sizeof(wchar_t));
938
+ if (!cmdline) {
939
+ free(b64);
940
+ if (err && err_len > 0) serial_err(err, err_len, "%s", "notification: out of memory");
941
+ return -3;
942
+ }
943
+ /* 手拼宽串:mingw 的 swprintf %s 在默认方言里收 char*(AGENTS §4),base64 是纯 ASCII
944
+ 逐字节提升即可,不经过任何 printf 家族,避免方言歧义。 */
945
+ {
946
+ wchar_t prefix[] = L"powershell.exe -NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ";
947
+ size_t plen = wcslen(prefix);
948
+ wcscpy(cmdline, prefix);
949
+ for (i = 0; b64[i]; i++) {
950
+ cmdline[plen + i] = (wchar_t)(unsigned char)b64[i];
951
+ }
952
+ cmdline[plen + i] = 0;
953
+ }
954
+ free(b64);
955
+
956
+ ZeroMemory(&si, sizeof(si));
957
+ si.cb = sizeof(si);
958
+ si.dwFlags = STARTF_USESHOWWINDOW;
959
+ si.wShowWindow = SW_HIDE;
960
+ ZeroMemory(&pi, sizeof(pi));
961
+ ok = CreateProcessW(NULL, cmdline, NULL, NULL, FALSE,
962
+ CREATE_NO_WINDOW, NULL, NULL, &si, &pi);
963
+ free(cmdline);
964
+ if (!ok) {
965
+ if (err && err_len > 0) {
966
+ serial_err(err, err_len, "notification: CreateProcessW failed (%lu)",
967
+ (unsigned long)GetLastError());
968
+ }
969
+ return -3;
970
+ }
971
+ CloseHandle(pi.hThread);
972
+ CloseHandle(pi.hProcess);
973
+ return 0;
974
+ }
975
+
976
+ static LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
977
+ /* 句柄从窗口自己的 USERDATA 取:PostMessage 按窗口寻址,谁收到就是谁的宿主 */
978
+ cj_host *h = (cj_host *)GetWindowLongPtrW(hwnd, GWLP_USERDATA);
979
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
980
+
981
+ switch (msg) {
982
+ case WM_NCCREATE: {
983
+ /* 唯一安全时机:CreateWindowExW 的 lpParam 在这里交回来,之后这个窗口就知道自己属于谁 */
984
+ CREATESTRUCTW *cs = (CREATESTRUCTW *)lp;
985
+ if (cs) {
986
+ SetWindowLongPtrW(hwnd, GWLP_USERDATA, (LONG_PTR)cs->lpCreateParams);
987
+ }
988
+ break; /* 交给 DefWindowProcW 继续(它对 WM_NCCREATE 返回 TRUE)*/
989
+ }
990
+ case WM_SIZE:
991
+ if (p && p->controller) {
992
+ RECT rc;
993
+ GetClientRect(hwnd, &rc);
994
+ p->controller->lpVtbl->put_Bounds(p->controller, rc);
995
+ }
996
+ return 0;
997
+ case WM_CJT_FLUSH:
998
+ cj_core_flush_js(h);
999
+ return 0;
1000
+ case WM_CJT_QUIT:
1001
+ DestroyWindow(hwnd);
1002
+ return 0;
1003
+ case WM_CJT_DEVTOOLS:
1004
+ if (p && p->webview) {
1005
+ p->webview->lpVtbl->OpenDevToolsWindow(p->webview);
1006
+ }
1007
+ return 0;
1008
+ case WM_CJT_RELOAD:
1009
+ if (p && p->webview) {
1010
+ log_hr("Reload", p->webview->lpVtbl->Reload(p->webview));
1011
+ }
1012
+ return 0;
1013
+ case WM_CJT_LOAD_URL: {
1014
+ /* lParam 是跨线程传过来的宽字符串,由宿主线程负责释放 */
1015
+ wchar_t *w = (wchar_t *)lp;
1016
+ if (p && p->webview && w) {
1017
+ log_hr("Navigate", p->webview->lpVtbl->Navigate(p->webview, w));
1018
+ }
1019
+ free(w);
1020
+ return 0;
1021
+ }
1022
+ case WM_CJT_MENU: {
1023
+ /* lParam 是调用方 strdup 的线路文本:宿主线程用完即释放(与 WM_CJT_LOAD_URL 同规)*/
1024
+ char *wire = (char *)lp;
1025
+ if (wire && h && p) {
1026
+ menu_apply_wire(h, p, wire);
1027
+ }
1028
+ free(wire);
1029
+ return 0;
1030
+ }
1031
+ case WM_CJT_MENU_ITEM: {
1032
+ menu_state_msg *m = (menu_state_msg *)lp;
1033
+ if (m) {
1034
+ if (p) {
1035
+ menu_apply_item_state(p, m->id, m->enabled, m->checked);
1036
+ }
1037
+ free(m->id);
1038
+ free(m);
1039
+ }
1040
+ return 0;
1041
+ }
1042
+ case WM_COMMAND: {
1043
+ /* 菜单项:LOWORD(wp) 是建菜单时分配的命令 id;0 = 没有命令(分隔线 / 子菜单标题)*/
1044
+ UINT cmd = (UINT)LOWORD(wp);
1045
+ if (h && p && cmd >= CJ_TRAY_CMD_BASE) {
1046
+ /* 托盘项:命令 id 从 CJ_TRAY_CMD_BASE 起,与菜单栏的命令 id 空间分开放,
1047
+ 否则两个表(menu_items / tray_items)会互相抢同一个 id。 */
1048
+ int ti = (int)(cmd - CJ_TRAY_CMD_BASE);
1049
+ if (ti >= 0 && ti < p->tray_item_count) {
1050
+ menu_slot *s = &p->tray_items[ti];
1051
+ if (s->kind == 'c') {
1052
+ s->checked = s->checked ? 0 : 1;
1053
+ CheckMenuItem(p->tray_menu, cmd,
1054
+ MF_BYCOMMAND | (s->checked ? MF_CHECKED : MF_UNCHECKED));
1055
+ }
1056
+ cj_core_tray_clicked(h, s->app_id);
1057
+ }
1058
+ return 0;
1059
+ }
1060
+ if (h && p && cmd) {
1061
+ int i;
1062
+ for (i = 0; i < p->menu_item_count; i++) {
1063
+ menu_slot *s = &p->menu_items[i];
1064
+ if (s->cmd_id != cmd) continue;
1065
+ if (s->kind == 'c') {
1066
+ /* Win32 不会替我们翻转勾选:自己翻,并同步菜单上的勾 */
1067
+ s->checked = s->checked ? 0 : 1;
1068
+ CheckMenuItem(p->menu, cmd,
1069
+ MF_BYCOMMAND | (s->checked ? MF_CHECKED : MF_UNCHECKED));
1070
+ }
1071
+ cj_core_menu_clicked(h, s->app_id, s->enabled, s->checked);
1072
+ break;
1073
+ }
1074
+ }
1075
+ return 0;
1076
+ }
1077
+ case WM_HOTKEY: {
1078
+ /* wParam = RegisterHotKey 时给的 id;只回投真的注册成功的那条 */
1079
+ int i;
1080
+ if (p) {
1081
+ for (i = 0; i < p->hotkey_count; i++) {
1082
+ if (p->hotkeys[i].id == (int)wp && p->hotkeys[i].registered) {
1083
+ cj_core_hotkey_pressed(h, p->hotkeys[i].app_id);
1084
+ break;
1085
+ }
1086
+ }
1087
+ }
1088
+ return 0;
1089
+ }
1090
+ case WM_SYSCOMMAND:
1091
+ /* 最小化到托盘:把「最小化」改写成「隐藏」。**只在托盘真挂上时才拦**——
1092
+ 没有托盘还把窗口藏起来,用户就再也点不回来了;宁可如实降级为普通最小化并出声一次。 */
1093
+ if (p && h && (wp & 0xFFF0) == SC_MINIMIZE && h->minimize_to_tray) {
1094
+ if (p->tray_added) {
1095
+ ShowWindow(hwnd, SW_HIDE);
1096
+ fprintf(stderr, "[cj-bridge] minimize -> hidden to tray\n");
1097
+ return 0;
1098
+ }
1099
+ if (!p->min_to_tray_warned) {
1100
+ p->min_to_tray_warned = 1;
1101
+ fprintf(stderr, "[cj-bridge] minimize_to_tray ignored: no tray icon yet "
1102
+ "(set_tray / CJ_CAP_TRAY) - normal minimize\n");
1103
+ }
1104
+ }
1105
+ return DefWindowProcW(hwnd, msg, wp, lp);
1106
+ case WM_CJT_TRAY: {
1107
+ /* lParam 是调用方 strdup 的线路文本:宿主线程用完即释放(与 WM_CJT_MENU 同规)*/
1108
+ char *wire = (char *)lp;
1109
+ if (wire && h && p) {
1110
+ tray_apply_wire(h, p, wire);
1111
+ }
1112
+ free(wire);
1113
+ return 0;
1114
+ }
1115
+ case WM_CJT_HOTKEYS: {
1116
+ char *wire = (char *)lp;
1117
+ if (p) {
1118
+ hotkey_apply_wire(p, wire ? wire : "");
1119
+ }
1120
+ free(wire);
1121
+ return 0;
1122
+ }
1123
+ case WM_CJT_VISIBLE:
1124
+ if (p && p->hwnd) {
1125
+ window_apply_visible(p, (int)wp);
1126
+ }
1127
+ return 0;
1128
+ case WM_CJT_TRAY_NOTIFY: {
1129
+ /* 托盘图标回调:NOTIFYICON_VERSION_4 口径下 wParam 是图标坐标、lParam 低位是事件。
1130
+ 左键与右键都弹菜单——Linux 的 appindicator 本来就只有菜单(没有「图标激活」语义),
1131
+ 两边行为一致,应用侧就不必为平台分叉写代码。 */
1132
+ UINT ev = (UINT)LOWORD(lp);
1133
+ if (p && p->tray_menu &&
1134
+ (ev == WM_LBUTTONUP || ev == NIN_SELECT ||
1135
+ ev == WM_RBUTTONUP || ev == WM_CONTEXTMENU)) {
1136
+ POINT pt;
1137
+ pt.x = (LONG)(short)LOWORD(wp);
1138
+ pt.y = (LONG)(short)HIWORD(wp);
1139
+ /* SetForegroundWindow + 事后 WM_NULL 是 TrackPopupMenu 的老规矩:
1140
+ 少了前者菜单不会消失,少了后者菜单会残留到下次点击 */
1141
+ SetForegroundWindow(hwnd);
1142
+ TrackPopupMenu(p->tray_menu, TPM_RIGHTBUTTON | TPM_BOTTOMALIGN,
1143
+ pt.x, pt.y, 0, hwnd, NULL);
1144
+ PostMessageW(hwnd, WM_NULL, 0, 0);
1145
+ }
1146
+ return 0;
1147
+ }
1148
+ case WM_CJT_DIALOG:
1149
+ /* lParam 是调用方(仓颉线程)堆上的请求:宿主线程只填结果,释放仍归调用方 */
1150
+ if (lp) {
1151
+ cj_core_dialog_tick(h, (dlg_req *)lp);
1152
+ }
1153
+ return 0;
1154
+ case WM_CJT_UI:
1155
+ /* 通用 UI 投递:lParam 是调用方栈上的包裹(fn + ud),处理完就地释放 */
1156
+ if (lp) {
1157
+ cj_ui_msg *m = (cj_ui_msg *)lp;
1158
+ if (m->fn) {
1159
+ m->fn(m->ud);
1160
+ }
1161
+ free(m);
1162
+ }
1163
+ return 0;
1164
+ case WM_CJT_DRAG_DROP: {
1165
+ /* 运行期挂/摘拖放:OLE 注册只能在建它的 STA(宿主 UI 线程)上做 */
1166
+ drag_drop_msg *m = (drag_drop_msg *)lp;
1167
+ if (m) {
1168
+ if (p) {
1169
+ drag_dest_apply(h, p, m->enabled);
1170
+ }
1171
+ free(m);
1172
+ }
1173
+ return 0;
1174
+ }
1175
+ case WM_DESTROY:
1176
+ if (p) {
1177
+ /* 托盘图标先摘:不摘的话进程退出后系统托盘里会留一个点不动的幽灵图标 */
1178
+ tray_remove(p);
1179
+ hotkey_unregister_all(p);
1180
+ /* 拖放目标先摘:RevokeDragDrop 释放系统对我们 COM 对象的引用 */
1181
+ drag_dest_apply(h, p, 0);
1182
+ if (p->drop_target) {
1183
+ p->drop_target->lpVtbl->Release(p->drop_target);
1184
+ p->drop_target = NULL;
1185
+ }
1186
+ p->hwnd = NULL; /* 窗口没了:别再往它 PostMessage */
1187
+ }
1188
+ /* 置 should_quit + 触发 onDestroy(每个宿主恰好一次)+ 作废进行中的对话框 */
1189
+ cj_core_window_destroyed(h);
1190
+ PostQuitMessage(0);
1191
+ return 0;
1192
+ default:
1193
+ return DefWindowProcW(hwnd, msg, wp, lp);
1194
+ }
1195
+ /* Cases above that break instead of returning (currently WM_NCCREATE) land
1196
+ here. Returning DefWindowProcW is mandatory: it is the one that answers
1197
+ TRUE to WM_NCCREATE. Falling off the end leaves the return value
1198
+ indeterminate, CreateWindowExW reads it as FALSE and the window creation
1199
+ fails with GetLastError() == 0 -- which is exactly what it looks like. */
1200
+ return DefWindowProcW(hwnd, msg, wp, lp);
1201
+ }
1202
+
1203
+ /* ===== 宿主线程(对标 tao 的事件循环线程)===== */
1204
+
1205
+ /* WebView2Loader 的两种加载方式:
1206
+ · 默认(动态桥 / 便携包):运行期按名字找 WebView2Loader.dll(exe 目录或 PATH);
1207
+ · 单文件产物(-DCJ_EMBED_WEBVIEW2_LOADER):把 loader 的字节直接编进 exe,首次运行时
1208
+ 释放到 %TEMP%\cj-tauri-loader\ 再按绝对路径加载(幂等:大小一致就不重写)。
1209
+ 为什么不是「静态链 WebView2LoaderStatic.lib」:那份 .lib 是 MSVC 编的,要 /GS 的
1210
+ __security_cookie 与 C++ 运行时符号(?nothrow@std@@、_Init_thread_epoch …),
1211
+ 2026-10-07 mingw 实测链接失败(ld.lld: undefined symbol: __security_cookie)。 */
1212
+ #ifdef CJ_EMBED_WEBVIEW2_LOADER
1213
+ #include "webview2_loader_embed.h" /* 打包脚本生成的 C 头:字节数组 + 长度 */
1214
+
1215
+ static int write_bytes(HANDLE f, const unsigned char *p, DWORD total) {
1216
+ DWORD off = 0;
1217
+ while (off < total) {
1218
+ DWORD wrote = 0;
1219
+ if (!WriteFile(f, p + off, total - off, &wrote, NULL) || wrote == 0) {
1220
+ return 0;
1221
+ }
1222
+ off += wrote;
1223
+ }
1224
+ return 1;
1225
+ }
1226
+
1227
+ /* 把内嵌的 loader 释放成文件,成功时把绝对路径写进 out。
1228
+ 路径拼接一律用 lstrcpyW/lstrcatW:mingw 的 swprintf 在默认方言下走 MSVCRT 语义,
1229
+ `%s` 收的是 char* 而不是 wchar_t* —— 拼出来是垃圾路径,而且失败时一声不响
1230
+ (2026-10-07 实测:日志只有一句 "failed to unpack embedded WebView2Loader.dll")。 */
1231
+ static int unpack_embedded_loader(wchar_t *out, DWORD cap) {
1232
+ wchar_t tmp[MAX_PATH];
1233
+ wchar_t dir[MAX_PATH];
1234
+ DWORD n = GetTempPathW(MAX_PATH, tmp);
1235
+ if (n == 0 || n + 32 >= MAX_PATH) {
1236
+ fprintf(stderr, "[cj-bridge] embedded loader: GetTempPathW failed: err=%lu\n",
1237
+ GetLastError());
1238
+ return 0;
1239
+ }
1240
+ lstrcpyW(dir, tmp);
1241
+ lstrcatW(dir, L"cj-tauri-loader");
1242
+ if (!CreateDirectoryW(dir, NULL) && GetLastError() != ERROR_ALREADY_EXISTS) {
1243
+ fprintf(stderr, "[cj-bridge] embedded loader: CreateDirectoryW(%ls) err=%lu\n",
1244
+ dir, GetLastError());
1245
+ /* 建不出来也不立刻放弃:目录可能本来就存在且可写 */
1246
+ }
1247
+ lstrcpyW(out, dir);
1248
+ lstrcatW(out, L"\\WebView2Loader.dll");
1249
+ {
1250
+ WIN32_FILE_ATTRIBUTE_DATA attr;
1251
+ if (GetFileAttributesExW(out, GetFileExInfoStandard, &attr)) {
1252
+ LARGE_INTEGER sz;
1253
+ sz.HighPart = (LONG)attr.nFileSizeHigh;
1254
+ sz.LowPart = attr.nFileSizeLow;
1255
+ if (sz.QuadPart == (LONGLONG)CJ_WV2_LOADER_SIZE) {
1256
+ return 1; /* 已经释放过同一份 */
1257
+ }
1258
+ }
1259
+ }
1260
+ {
1261
+ HANDLE f = CreateFileW(out, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS,
1262
+ FILE_ATTRIBUTE_NORMAL, NULL);
1263
+ int ok;
1264
+ if (f == INVALID_HANDLE_VALUE) {
1265
+ fprintf(stderr, "[cj-bridge] embedded loader: CreateFileW(%ls) err=%lu\n",
1266
+ out, GetLastError());
1267
+ return 0;
1268
+ }
1269
+ ok = write_bytes(f, CJ_WV2_LOADER_BYTES, (DWORD)CJ_WV2_LOADER_SIZE);
1270
+ CloseHandle(f);
1271
+ if (!ok) {
1272
+ fprintf(stderr, "[cj-bridge] embedded loader: WriteFile failed: err=%lu\n",
1273
+ GetLastError());
1274
+ }
1275
+ return ok;
1276
+ }
1277
+ }
1278
+
1279
+ static HMODULE load_webview2_loader(void) {
1280
+ wchar_t path[MAX_PATH];
1281
+ if (unpack_embedded_loader(path, MAX_PATH)) {
1282
+ HMODULE m = LoadLibraryW(path);
1283
+ if (m) {
1284
+ fprintf(stderr, "[cj-bridge] WebView2Loader unpacked to %%TEMP%% and loaded\n");
1285
+ return m;
1286
+ }
1287
+ fprintf(stderr, "[cj-bridge] unpacked WebView2Loader refused to load: err=%lu\n",
1288
+ GetLastError());
1289
+ } else {
1290
+ fprintf(stderr, "[cj-bridge] failed to unpack embedded WebView2Loader.dll\n");
1291
+ }
1292
+ /* 退回按名字找:失败文案与默认模式一致,便于对照 */
1293
+ return LoadLibraryW(L"WebView2Loader.dll");
1294
+ }
1295
+ #else
1296
+ static HMODULE load_webview2_loader(void) {
1297
+ return LoadLibraryW(L"WebView2Loader.dll");
1298
+ }
1299
+ #endif
1300
+
1301
+ typedef HRESULT(STDMETHODCALLTYPE *pfn_create_env)(
1302
+ PCWSTR, PCWSTR, ICoreWebView2EnvironmentOptions *,
1303
+ ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler *);
1304
+
1305
+ static const wchar_t *WINDOW_CLASS = L"CjTauriWindow";
1306
+
1307
+ /*
1308
+ * 用户数据档案(UserDataFolder)的选择:
1309
+ * 1) 环境变量 WEBVIEW2_USER_DATA_FOLDER 优先——E2E 每轮给独立目录防实例互锁,就靠它;
1310
+ * 2) 默认 %LOCALAPPDATA%\cj-tauri\<exe名>.WebView2——不再落在 exe 旁边(旧行为)。
1311
+ * exe 旁那份对单文件包是噪音(分发目录里凭空多 10M+ 的缓存堆),挪进
1312
+ * LOCALAPPDATA 也是 Windows 应用的惯例位置;localStorage(主题/上次打开)照常持久。
1313
+ * 返回 1 = buf 里是可用目录(会就地建目录);0 = 没定下来(调用方传 NULL 回默认行为)。
1314
+ * 宽字符串拼接一律 lstrcpyW/lstrcatW——本机 mingw 的 swprintf %s 按 MSVCRT 语义收 char*,
1315
+ * 拼出来的宽字符串是垃圾且不报错(AGENTS §4 实测坑)。
1316
+ */
1317
+ static int compute_user_data_folder(wchar_t *buf, DWORD cap) {
1318
+ wchar_t exe[MAX_PATH];
1319
+ wchar_t *name;
1320
+ /* 1) 显式环境变量优先(不校验存在性——WebView2 自己会对坏目录报错) */
1321
+ if (GetEnvironmentVariableW(L"WEBVIEW2_USER_DATA_FOLDER", buf, cap) > 0) {
1322
+ return 1;
1323
+ }
1324
+ if (GetEnvironmentVariableW(L"LOCALAPPDATA", buf, cap) == 0 || buf[0] == 0) {
1325
+ return 0;
1326
+ }
1327
+ if (!GetModuleFileNameW(NULL, exe, MAX_PATH)) {
1328
+ return 0;
1329
+ }
1330
+ name = wcsrchr(exe, L'\\');
1331
+ name = name ? name + 1 : exe; /* exe 基名(不含目录) */
1332
+ if (wcslen(buf) + wcslen(name) + wcslen(L"\\cj-tauri\\.WebView2") + 1 >= cap) {
1333
+ return 0; /* 拼不下就回默认行为,不截断 */
1334
+ }
1335
+ lstrcatW(buf, L"\\cj-tauri");
1336
+ CreateDirectoryW(buf, NULL); /* 已存在静默跳过 */
1337
+ lstrcatW(buf, L"\\");
1338
+ lstrcatW(buf, name);
1339
+ lstrcatW(buf, L".WebView2");
1340
+ CreateDirectoryW(buf, NULL);
1341
+ return 1;
1342
+ }
1343
+
1344
+ static DWORD WINAPI host_thread_main(LPVOID param) {
1345
+ cj_host *h = (cj_host *)param;
1346
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1347
+ pfn_create_env create_env = NULL;
1348
+ WNDCLASSEXW wc;
1349
+ MSG msg;
1350
+ wchar_t *title_w = NULL; /* 宽字符标题(用完即 free)*/
1351
+ wchar_t *icon_w = NULL;
1352
+ HICON icon_big = NULL; /* 窗口图标(h->win_icon 非空时才加载)*/
1353
+ HICON icon_small = NULL;
1354
+
1355
+ if (!p) return 0;
1356
+ CoInitializeEx(NULL, COINIT_APARTMENTTHREADED);
1357
+ fprintf(stderr, "[cj-bridge] host thread started\n");
1358
+
1359
+ /* WebView2 的入口在 WebView2Loader.dll 中(默认按名字找;单文件产物从内嵌字节释放) */
1360
+ p->loader = load_webview2_loader();
1361
+ if (!p->loader) {
1362
+ fprintf(stderr, "[cj-bridge] WebView2Loader.dll not found: "
1363
+ "put it next to the exe or on PATH\n");
1364
+ goto done;
1365
+ }
1366
+ create_env = (pfn_create_env)GetProcAddress(p->loader, "CreateCoreWebView2EnvironmentWithOptions");
1367
+ if (!create_env) {
1368
+ fprintf(stderr, "[cj-bridge] CreateCoreWebView2EnvironmentWithOptions not found\n");
1369
+ goto done;
1370
+ }
1371
+ {
1372
+ /* 诊断:x64 宿主是否能看到可用运行时(排障用,可删) */
1373
+ typedef HRESULT(STDAPICALLTYPE *pfn_getver)(PCWSTR, LPWSTR *);
1374
+ pfn_getver getver =
1375
+ (pfn_getver)GetProcAddress(p->loader, "GetAvailableCoreWebView2BrowserVersionString");
1376
+ if (getver) {
1377
+ LPWSTR ver = NULL;
1378
+ HRESULT hr = getver(NULL, &ver);
1379
+ char *v8 = ver ? wide_to_utf8(ver) : NULL;
1380
+ fprintf(stderr, "[cj-bridge] WebView2 runtime: hr=0x%08lx ver=%s\n",
1381
+ (unsigned long)hr, v8 ? v8 : "(none)");
1382
+ free(v8);
1383
+ if (ver) CoTaskMemFree(ver);
1384
+ }
1385
+ }
1386
+ fprintf(stderr, "[cj-bridge] WebView2Loader loaded\n");
1387
+
1388
+ /* 标题/图标在 core 里是 UTF-8,窗口类与窗口要宽字符,在这里转换 */
1389
+ title_w = h->win_title ? utf8_to_wide(h->win_title) : NULL;
1390
+ icon_w = h->win_icon ? utf8_to_wide(h->win_icon) : NULL;
1391
+
1392
+ /* 窗口图标:在 RegisterClassExW 之前加载并挂到窗口类上,
1393
+ 这样任务栏与 Alt-Tab 也用同一个图标;失败只是回到系统默认图标,不阻断启动。 */
1394
+ if (icon_w) {
1395
+ icon_big = (HICON)LoadImageW(NULL, icon_w, IMAGE_ICON,
1396
+ GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON),
1397
+ LR_LOADFROMFILE);
1398
+ icon_small = (HICON)LoadImageW(NULL, icon_w, IMAGE_ICON,
1399
+ GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON),
1400
+ LR_LOADFROMFILE);
1401
+ /* GetLastError 只在失败时才有意义:成功时它是上一次调用的残留值(常见 6 = 无效句柄) */
1402
+ if (icon_big && icon_small) {
1403
+ fprintf(stderr, "[cj-bridge] window icon: path=%s loaded (big=%p small=%p)\n",
1404
+ h->win_icon, (void *)icon_big, (void *)icon_small);
1405
+ } else {
1406
+ fprintf(stderr, "[cj-bridge] window icon FAILED: path=%s hIcon=%p/%p err=%lu\n",
1407
+ h->win_icon, (void *)icon_big, (void *)icon_small,
1408
+ (unsigned long)GetLastError());
1409
+ }
1410
+ }
1411
+
1412
+ ZeroMemory(&wc, sizeof(wc));
1413
+ wc.cbSize = sizeof(wc);
1414
+ wc.lpfnWndProc = wnd_proc;
1415
+ wc.hInstance = GetModuleHandleW(NULL);
1416
+ wc.hCursor = LoadCursorW(NULL, IDC_ARROW);
1417
+ wc.lpszClassName = WINDOW_CLASS;
1418
+ wc.hIcon = icon_big; /* 可空:空则用系统默认 */
1419
+ wc.hIconSm = icon_small;
1420
+ RegisterClassExW(&wc);
1421
+
1422
+ /* 先记下宿主线程身份:对话框要靠它判断「调用方是不是已经在本线程上」 */
1423
+ p->thread_id = GetCurrentThreadId();
1424
+ p->thread_set = 1;
1425
+
1426
+ /* lpParam = 宿主句柄:WM_NCCREATE 里存进窗口 USERDATA,之后 PostMessage 按窗口寻址即找到宿主 */
1427
+ p->hwnd = CreateWindowExW(0, WINDOW_CLASS, title_w ? title_w : L"cj-tauri", WS_OVERLAPPEDWINDOW,
1428
+ CW_USEDEFAULT, CW_USEDEFAULT, h->win_w, h->win_h,
1429
+ NULL, NULL, wc.hInstance, h);
1430
+ if (!p->hwnd) {
1431
+ fprintf(stderr, "[cj-bridge] CreateWindowExW failed: %lu\n", GetLastError());
1432
+ goto done;
1433
+ }
1434
+ /* 类图标只管新窗口的默认值;对已存在的窗口再显式设一次,标题栏立刻生效 */
1435
+ if (icon_big) {
1436
+ SendMessageW(p->hwnd, WM_SETICON, ICON_BIG, (LPARAM)icon_big);
1437
+ }
1438
+ if (icon_small) {
1439
+ SendMessageW(p->hwnd, WM_SETICON, ICON_SMALL, (LPARAM)icon_small);
1440
+ }
1441
+ /* 菜单栏:装配期就先建好(仓颉侧可能在 start() 之前就 set_menu 了);
1442
+ 运行期再改的走 WM_CJT_MENU 到本线程重建。 */
1443
+ if (h->pending_menu_text && h->pending_menu_text[0]) {
1444
+ menu_apply_wire(h, p, h->pending_menu_text);
1445
+ }
1446
+ /* 托盘 / 快捷键同规:装配期把「start() 之前设好的」线路落下去(运行期改动走 WM_CJT_*)。
1447
+ RegisterHotKey 要求窗口已存在,所以不能更早做。 */
1448
+ if (h->pending_tray && h->pending_tray[0]) {
1449
+ tray_apply_wire(h, p, h->pending_tray);
1450
+ }
1451
+ if (h->pending_hotkeys && h->pending_hotkeys[0]) {
1452
+ hotkey_apply_wire(p, h->pending_hotkeys);
1453
+ }
1454
+ /* 窗口初始可见性:**建窗后立刻定稿**(比「先显示再藏起来」少一次闪烁)。
1455
+ WebView2 还没就绪也无妨——SW_HIDE 是窗口层状态,页面照常加载。 */
1456
+ if (!h->win_visible) {
1457
+ window_apply_visible(p, 0);
1458
+ }
1459
+ /* 创建 WebView2 环境(完成/失败都在回调 env_Invoke 中处理)。
1460
+ 用户数据档案:显式选目录(env 覆盖优先,默认 %LOCALAPPDATA%\cj-tauri\<exe名>.WebView2),
1461
+ 不再让它默认堆在 exe 旁边;选不下来才传 NULL 回 WebView2 默认行为。
1462
+ 磁盘缓存经 WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS 限到 32MB——loader 原生认这个环境
1463
+ 变量(options 传空时生效),免去手写 COM options 对象(WebView2.h 的 C 模式连
1464
+ coclass 的 CLSID 都不声明,CoCreateInstance 路线走不通)。 */
1465
+ {
1466
+ wchar_t udf[1024];
1467
+ PCWSTR udf_arg = NULL;
1468
+ if (compute_user_data_folder(udf, 1024)) {
1469
+ udf_arg = udf;
1470
+ char *udf8 = wide_to_utf8(udf);
1471
+ fprintf(stderr, "[cj-bridge] user data folder: %s\n", udf8 ? udf8 : "(?)");
1472
+ if (udf8) free(udf8);
1473
+ }
1474
+ SetEnvironmentVariableW(L"WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS",
1475
+ L"--disk-cache-size=33554432");
1476
+ fprintf(stderr, "[cj-bridge] window created, requesting WebView2 environment\n");
1477
+ log_hr("CreateCoreWebView2EnvironmentWithOptions",
1478
+ create_env(NULL, udf_arg, NULL, &p->env.iface));
1479
+ }
1480
+
1481
+ while (GetMessageW(&msg, NULL, 0, 0)) {
1482
+ TranslateMessage(&msg);
1483
+ DispatchMessageW(&msg);
1484
+ }
1485
+
1486
+ done:
1487
+ if (title_w) free(title_w);
1488
+ if (icon_w) free(icon_w);
1489
+ if (p->menu) { /* 窗口没了,菜单栏也要回收(HMENU 归本线程建、也归本线程销毁)*/
1490
+ DestroyMenu(p->menu);
1491
+ p->menu = NULL;
1492
+ }
1493
+ menu_clear_slots(p);
1494
+ /* 托盘图标与全局快捷键也归本线程回收:图标不摘会让系统托盘留幽灵图标,
1495
+ 快捷键不注销会跟着窗口句柄一起失效——WM_DESTROY 里已摘过一次,两个函数都幂等 */
1496
+ tray_remove(p);
1497
+ hotkey_unregister_all(p);
1498
+ if (p->controller) {
1499
+ p->controller->lpVtbl->Close(p->controller);
1500
+ p->controller = NULL;
1501
+ }
1502
+ /* COM 引用(controller / environment / webview)不在这里 Release:
1503
+ 它们都在本线程的 STA 上创建,且进程随之结束——沿用原实现的「活到进程退出」口径,
1504
+ 只把指针置空,避免收尾后再被别的路径碰到。 */
1505
+ p->webview = NULL;
1506
+ p->hwnd = NULL;
1507
+ if (p->loader) {
1508
+ FreeLibrary(p->loader);
1509
+ p->loader = NULL;
1510
+ }
1511
+ CoUninitialize();
1512
+ /* 兜底:非关窗路径退出消息循环(Loader 缺失 / 环境创建失败)也要通知一次(幂等)*/
1513
+ cj_core_window_destroyed(h);
1514
+ /* 最后一步才立这个标记:cj_plat_fini 见到它才 free 平台状态(此刻本线程已不再碰 p)*/
1515
+ p->thread_done = 1;
1516
+ return 0;
1517
+ }
1518
+
1519
+ /* ===== 平台原语(bridge_core.h 的清单;导出不在这里,全在 core)===== */
1520
+
1521
+ void cj_plat_init(cj_host *h) {
1522
+ plat_host *p;
1523
+ if (!h) return;
1524
+ p = (plat_host *)calloc(1, sizeof(plat_host));
1525
+ if (!p) {
1526
+ fprintf(stderr, "[cj-bridge] init failed: out of memory\n");
1527
+ return;
1528
+ }
1529
+ p->env.h = h;
1530
+ p->ctrl.h = h;
1531
+ p->msg.h = h;
1532
+ p->nav.h = h;
1533
+ p->env.iface.lpVtbl = &g_env_vtbl;
1534
+ p->ctrl.iface.lpVtbl = &g_ctrl_vtbl;
1535
+ p->msg.iface.lpVtbl = &g_msg_vtbl;
1536
+ p->nav.iface.lpVtbl = &g_nav_vtbl;
1537
+ h->plat = p;
1538
+ fprintf(stderr, "[cj-bridge] init (on_message=%p on_destroy=%p)\n",
1539
+ (void *)h->on_message, (void *)h->on_destroy);
1540
+ }
1541
+
1542
+ int cj_plat_fini(cj_host *h) {
1543
+ plat_host *p;
1544
+ if (!h) return 1;
1545
+ p = (plat_host *)h->plat;
1546
+ if (!p) return 1;
1547
+ /* 宿主线程还活着(事件循环没退出):不能在这里 free 平台状态——它正被那条线程用着。
1548
+ 等待必须有界:宿主线程收尾时要进入仓颉运行时(onDestroy),无限等会与运行时的线程
1549
+ 退出握手互等(Linux 侧实测过,见 bridge_linux.c 同名函数);超时就状态与句柄一起留着。 */
1550
+ if (p->thread_started && p->thread) {
1551
+ WaitForSingleObject(p->thread, 5000);
1552
+ CloseHandle(p->thread);
1553
+ p->thread = NULL;
1554
+ p->thread_started = 0;
1555
+ }
1556
+ if (!p->thread_done) {
1557
+ fprintf(stderr, "[cj-bridge] fini: host thread still running, platform state kept\n");
1558
+ return 0;
1559
+ }
1560
+ h->plat = NULL;
1561
+ free(p);
1562
+ return 1;
1563
+ }
1564
+
1565
+ void cj_plat_start(cj_host *h) {
1566
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1567
+ DWORD tid = 0;
1568
+ if (!p) {
1569
+ fprintf(stderr, "[cj-bridge] start failed: platform not initialized\n");
1570
+ if (h) h->should_quit = 1; /* 起不来就别让 run() 干等 */
1571
+ return;
1572
+ }
1573
+ p->thread = CreateThread(NULL, 0, host_thread_main, h, 0, &tid);
1574
+ if (!p->thread) {
1575
+ fprintf(stderr, "[cj-bridge] start failed: cannot create host thread\n");
1576
+ h->should_quit = 1;
1577
+ return;
1578
+ }
1579
+ p->thread_id = tid;
1580
+ p->thread_started = 1;
1581
+ }
1582
+
1583
+ void cj_plat_quit(cj_host *h) {
1584
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1585
+ if (p && p->hwnd) {
1586
+ PostMessageW(p->hwnd, WM_CJT_QUIT, 0, 0);
1587
+ } else if (h) {
1588
+ h->should_quit = 1;
1589
+ }
1590
+ }
1591
+
1592
+ void cj_plat_wake_ui(cj_host *h) {
1593
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1594
+ if (p && p->hwnd) {
1595
+ PostMessageW(p->hwnd, WM_CJT_FLUSH, 0, 0);
1596
+ }
1597
+ }
1598
+
1599
+ void cj_plat_post_js(cj_host *h, const char *script_utf8) {
1600
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1601
+ wchar_t *w;
1602
+ if (!p || !p->webview || !script_utf8) return;
1603
+ w = utf8_to_wide(script_utf8);
1604
+ if (!w) return;
1605
+ log_hr("ExecuteScript", p->webview->lpVtbl->ExecuteScript(p->webview, w, NULL));
1606
+ free(w);
1607
+ }
1608
+
1609
+ int cj_plat_is_ui_thread(cj_host *h) {
1610
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1611
+ return (p && p->thread_set && GetCurrentThreadId() == p->thread_id) ? 1 : 0;
1612
+ }
1613
+
1614
+ void cj_plat_open_devtools(cj_host *h) {
1615
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1616
+ if (!p || !p->hwnd) {
1617
+ fprintf(stderr, "[cj-bridge] open devtools ignored: window not ready\n");
1618
+ return;
1619
+ }
1620
+ PostMessageW(p->hwnd, WM_CJT_DEVTOOLS, 0, 0);
1621
+ }
1622
+
1623
+ void cj_plat_load_url(cj_host *h, const char *url) {
1624
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1625
+ if (!h || !url || !url[0]) {
1626
+ return;
1627
+ }
1628
+ if (p && p->hwnd) {
1629
+ wchar_t *w = utf8_to_wide(url);
1630
+ if (!w) return;
1631
+ /* pending_url 由 core 拥有(cj_bridge_load_url 已经记过一份),这里只投当次导航 */
1632
+ fprintf(stderr, "[cj-bridge] load url (runtime): %s\n", url);
1633
+ PostMessageW(p->hwnd, WM_CJT_LOAD_URL, 0, (LPARAM)w);
1634
+ return;
1635
+ }
1636
+ fprintf(stderr, "[cj-bridge] load url: %s\n", url);
1637
+ }
1638
+
1639
+ void cj_plat_reload(cj_host *h) {
1640
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1641
+ if (!p || !p->hwnd) {
1642
+ fprintf(stderr, "[cj-bridge] reload ignored: window not ready\n");
1643
+ return;
1644
+ }
1645
+ PostMessageW(p->hwnd, WM_CJT_RELOAD, 0, 0);
1646
+ }
1647
+
1648
+ /* ===== 菜单栏翻译层(RFC-002 §5.4):线路文本 → HMENU =====
1649
+ 格式解析不在这里重复实现:走 core 的 cj_core_menu_walk(两平台共用一份,桩平台已自检)。
1650
+ 本层只做三件事:把行翻译成 Win32 项、维护「命令 id ↔ 应用 id」映射、把点击经 core 回投。 */
1651
+
1652
+ static void menu_clear_slots(plat_host *p) {
1653
+ int i;
1654
+ if (!p) return;
1655
+ for (i = 0; i < p->menu_item_count; i++) {
1656
+ free(p->menu_items[i].app_id);
1657
+ p->menu_items[i].app_id = NULL;
1658
+ }
1659
+ p->menu_item_count = 0;
1660
+ }
1661
+
1662
+ static void menu_apply_item_state(plat_host *p, const char *id, int enabled, int checked) {
1663
+ int i;
1664
+ if (!p || !id || !id[0] || !p->menu) return;
1665
+ for (i = 0; i < p->menu_item_count; i++) {
1666
+ menu_slot *s = &p->menu_items[i];
1667
+ if (!s->app_id || strcmp(s->app_id, id) != 0) continue;
1668
+ s->enabled = enabled ? 1 : 0;
1669
+ s->checked = checked ? 1 : 0;
1670
+ EnableMenuItem(p->menu, s->cmd_id, MF_BYCOMMAND | (s->enabled ? MF_ENABLED : MF_GRAYED));
1671
+ CheckMenuItem(p->menu, s->cmd_id,
1672
+ MF_BYCOMMAND | (s->checked ? MF_CHECKED : MF_UNCHECKED));
1673
+ DrawMenuBar(p->hwnd);
1674
+ return;
1675
+ }
1676
+ fprintf(stderr, "[cj-bridge] menu item not found: %s\n", id);
1677
+ }
1678
+
1679
+ /* 建菜单时的上下文:层级用一张 HMENU 栈表示(行按文档顺序来,深度只会 +1 或回退)*/
1680
+ typedef struct menu_build_ctx {
1681
+ plat_host *p;
1682
+ HMENU bar;
1683
+ HMENU stack[CJ_MENU_MAX_DEPTH]; /* stack[d]:深度 d 的行往哪个菜单里追加 */
1684
+ int ok[CJ_MENU_MAX_DEPTH]; /* 该层是否已经有子菜单可挂(没有就退回上一层)*/
1685
+ } menu_build_ctx;
1686
+
1687
+ static void menu_add_row(void *ctx, int depth, char kind, const char *id,
1688
+ const char *label, int flags, const char *accel) {
1689
+ menu_build_ctx *c = (menu_build_ctx *)ctx;
1690
+ plat_host *p;
1691
+ HMENU target;
1692
+ wchar_t *wlabel = NULL;
1693
+ int enabled = (flags & 1) ? 1 : 0;
1694
+ int checked = (flags & 2) ? 1 : 0;
1695
+
1696
+ if (!c || !c->p) return;
1697
+ p = c->p;
1698
+ if (depth < 0) depth = 0;
1699
+ if (depth >= CJ_MENU_MAX_DEPTH) depth = CJ_MENU_MAX_DEPTH - 1;
1700
+ target = (depth > 0 && c->ok[depth]) ? c->stack[depth] : c->bar;
1701
+
1702
+ if (kind == 's') { /* 分隔线:既没有命令 id,也没有文字 */
1703
+ AppendMenuW(target, MF_SEPARATOR, 0, NULL);
1704
+ return;
1705
+ }
1706
+
1707
+ /* 标签:accel 非空时用制表符并进去——Win32 会把 \t 之后的部分右对齐,正是快捷键栏的位置 */
1708
+ {
1709
+ size_t n = strlen(label) + (accel[0] ? strlen(accel) + 1 : 0) + 1;
1710
+ char *tmp = (char *)malloc(n);
1711
+ if (!tmp) return;
1712
+ snprintf(tmp, n, "%s%s%s", label, accel[0] ? "\t" : "", accel);
1713
+ wlabel = utf8_to_wide(tmp);
1714
+ free(tmp);
1715
+ }
1716
+ if (!wlabel) return;
1717
+
1718
+ if (kind == 'm') { /* 子菜单:建出 HMENU 挂进父项,并让深一层的行知道往哪挂 */
1719
+ HMENU sub = CreatePopupMenu();
1720
+ if (!sub) {
1721
+ free(wlabel);
1722
+ return;
1723
+ }
1724
+ if (!AppendMenuW(target, MF_POPUP | (enabled ? 0 : MF_GRAYED), (UINT_PTR)sub, wlabel)) {
1725
+ fprintf(stderr, "[cj-bridge] menu: append submenu failed: %lu\n",
1726
+ (unsigned long)GetLastError());
1727
+ DestroyMenu(sub);
1728
+ free(wlabel);
1729
+ return;
1730
+ }
1731
+ free(wlabel);
1732
+ if (depth + 1 < CJ_MENU_MAX_DEPTH) {
1733
+ c->stack[depth + 1] = sub;
1734
+ c->ok[depth + 1] = 1;
1735
+ }
1736
+ return;
1737
+ }
1738
+
1739
+ if (p->menu_item_count >= CJ_MENU_MAX_ITEMS) {
1740
+ fprintf(stderr, "[cj-bridge] menu: item limit reached, dropped: %s\n", id);
1741
+ free(wlabel);
1742
+ return;
1743
+ }
1744
+ {
1745
+ UINT item_flags = MF_STRING;
1746
+ UINT cmd_id = (UINT)(p->menu_item_count + 1); /* 0 保留给「没有命令」*/
1747
+ menu_slot *s = &p->menu_items[p->menu_item_count];
1748
+ if (!enabled) item_flags |= MF_GRAYED;
1749
+ if (kind == 'c' && checked) item_flags |= MF_CHECKED;
1750
+ if (!AppendMenuW(target, item_flags, cmd_id, wlabel)) {
1751
+ fprintf(stderr, "[cj-bridge] menu: append item failed: %lu (id=%s)\n",
1752
+ (unsigned long)GetLastError(), id);
1753
+ free(wlabel);
1754
+ return;
1755
+ }
1756
+ free(wlabel);
1757
+ ZeroMemory(s, sizeof(*s));
1758
+ s->cmd_id = cmd_id;
1759
+ s->app_id = strdup(id);
1760
+ s->kind = kind;
1761
+ s->enabled = enabled;
1762
+ s->checked = checked;
1763
+ p->menu_item_count++;
1764
+ }
1765
+ }
1766
+
1767
+ static void menu_apply_wire(cj_host *h, plat_host *p, const char *wire) {
1768
+ menu_build_ctx c;
1769
+ HMENU old;
1770
+
1771
+ if (!p) return;
1772
+ if (!wire || !wire[0]) {
1773
+ /* 空串 = 清空菜单栏:SetMenu(hwnd, NULL) 才是真的没有菜单栏(空 popup 会留一条空条)*/
1774
+ menu_clear_slots(p);
1775
+ if (p->menu) {
1776
+ SetMenu(p->hwnd, NULL);
1777
+ DestroyMenu(p->menu);
1778
+ p->menu = NULL;
1779
+ }
1780
+ DrawMenuBar(p->hwnd);
1781
+ fprintf(stderr, "[cj-bridge] menu cleared\n");
1782
+ return;
1783
+ }
1784
+ ZeroMemory(&c, sizeof(c));
1785
+ c.p = p;
1786
+ /* 顶层必须是「菜单栏」:SetMenu 只接受 CreateMenu 出来的句柄,塞一个 CreatePopupMenu
1787
+ 的 popup 句柄进去会以 ERROR_INVALID_PARAMETER(87) **静默失败**——实测就是这样:
1788
+ menu applied 照打、窗口上却查不到菜单(GetMenu 一直是 NULL)。子菜单才用 popup。 */
1789
+ c.bar = CreateMenu();
1790
+ if (!c.bar) {
1791
+ fprintf(stderr, "[cj-bridge] menu: CreateMenu failed: %lu\n",
1792
+ (unsigned long)GetLastError());
1793
+ return;
1794
+ }
1795
+ c.stack[0] = c.bar;
1796
+ c.ok[0] = 1;
1797
+ menu_clear_slots(p); /* 旧映射整体作废:新菜单的命令 id 从 1 重新分配 */
1798
+ cj_core_menu_walk(wire, menu_add_row, &c);
1799
+ old = p->menu;
1800
+ p->menu = c.bar;
1801
+ /* SetMenu 会静默失败(返回 FALSE),此时日志照样说「已应用」而窗口上什么都没有——
1802
+ Windows 上「真挂上了」的唯一凭证就是这两行:返回值 + 事后 GetMenu 复核。 */
1803
+ if (!SetMenu(p->hwnd, p->menu)) {
1804
+ fprintf(stderr, "[cj-bridge] menu: SetMenu failed: %lu\n", (unsigned long)GetLastError());
1805
+ }
1806
+ DrawMenuBar(p->hwnd);
1807
+ if (old) DestroyMenu(old);
1808
+ fprintf(stderr, "[cj-bridge] menu applied: items=%d hwnd=%p bar=%p GetMenu=%p\n",
1809
+ p->menu_item_count, (void *)p->hwnd, (void *)p->menu, (void *)GetMenu(p->hwnd));
1810
+ }
1811
+
1812
+ /* 窗口顶部副标题:Windows 侧菜单栏是 SetMenu 挂上的系统菜单,那一行没有自绘区,
1813
+ 如实降级(CJ_CAP_SUBTITLE 也不置位)。应用据能力位决定是否自己画页面顶栏。
1814
+ 提示只打一次:页面可能每次编辑都调(字数变化),刷屏不如一行够用。 */
1815
+ int cj_plat_set_titlebar_subtitle(cj_host *h, const char *text) {
1816
+ static int warned = 0;
1817
+ (void)text;
1818
+ if (!h) return -1;
1819
+ if (!warned) {
1820
+ warned = 1;
1821
+ fprintf(stderr, "[cj-bridge] titlebar subtitle not implemented on windows (capability CJ_CAP_SUBTITLE off)\n");
1822
+ }
1823
+ return -4;
1824
+ }
1825
+
1826
+ void cj_plat_set_menu(cj_host *h, const char *wire) {
1827
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1828
+ if (!p || !wire) return;
1829
+ if (!p->hwnd) {
1830
+ /* 未就绪:core 已把线路文本记在 h->pending_menu_text,宿主线程建窗时取用 */
1831
+ fprintf(stderr, "[cj-bridge] menu deferred: window not ready\n");
1832
+ return;
1833
+ }
1834
+ if (cj_plat_is_ui_thread(h)) {
1835
+ menu_apply_wire(h, p, wire);
1836
+ return;
1837
+ }
1838
+ {
1839
+ char *copy = strdup(wire); /* 跨线程:谁 post 谁分配,UI 线程在 WM_CJT_MENU 分支里释放 */
1840
+ if (!copy) return;
1841
+ if (!PostMessageW(p->hwnd, WM_CJT_MENU, 0, (LPARAM)copy)) {
1842
+ fprintf(stderr, "[cj-bridge] menu post failed: %lu\n", (unsigned long)GetLastError());
1843
+ free(copy);
1844
+ return;
1845
+ }
1846
+ fprintf(stderr, "[cj-bridge] menu posted to ui thread (%d bytes)\n", (int)strlen(wire));
1847
+ }
1848
+ }
1849
+
1850
+ void cj_plat_menu_item_state(cj_host *h, const char *id, int enabled, int checked) {
1851
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
1852
+ if (!p || !id || !id[0]) return;
1853
+ if (!p->hwnd || !p->menu) {
1854
+ fprintf(stderr, "[cj-bridge] menu item state ignored: menu not ready (%s)\n", id);
1855
+ return;
1856
+ }
1857
+ if (cj_plat_is_ui_thread(h)) {
1858
+ menu_apply_item_state(p, id, enabled, checked);
1859
+ return;
1860
+ }
1861
+ {
1862
+ menu_state_msg *m = (menu_state_msg *)malloc(sizeof(*m));
1863
+ if (!m) return;
1864
+ m->id = strdup(id);
1865
+ m->enabled = enabled ? 1 : 0;
1866
+ m->checked = checked ? 1 : 0;
1867
+ if (!m->id || !PostMessageW(p->hwnd, WM_CJT_MENU_ITEM, 0, (LPARAM)m)) {
1868
+ fprintf(stderr, "[cj-bridge] menu item state post failed: %lu\n",
1869
+ (unsigned long)GetLastError());
1870
+ free(m->id);
1871
+ free(m);
1872
+ }
1873
+ }
1874
+ }
1875
+
1876
+ int cj_plat_host_capabilities(cj_host *h) {
1877
+ (void)h;
1878
+ /* 本平台的静态能力:菜单栏、拖放(OLE IDropTarget)、通知(PowerShell toast)已落地;
1879
+ 托盘(Shell_NotifyIconW)与全局快捷键(RegisterHotKey)2026-10-15 随剪贴板历史示例落地——
1880
+ 托盘菜单与窗口菜单共用线路格式与回投通道,点击都经 on_shell({"kind":"tray","id":"…"})。 */
1881
+ return CJ_CAP_MENU | CJ_CAP_DRAG_DROP | CJ_CAP_NOTIFICATION | CJ_CAP_TRAY | CJ_CAP_HOTKEY;
1882
+ }
1883
+
1884
+ /* ===== 全局快捷键(RFC-003 第 5 期,Windows 真实现)=====
1885
+ 分工与 Linux 侧逐条对齐:wire 解析在平台层(core 只把线路文本交下来),词法也是同一套
1886
+ (修饰键名 Ctrl/Control、Alt、Shift、Super/Win;键名 Enter/Esc 两个别名 + 常用键表)——
1887
+ 同一份应用配置两平台都认,认不出的那条整行丢弃并出声。
1888
+
1889
+ RegisterHotKey 要求窗口已存在,所以整表在**宿主线程建窗之后**应用一次(host_thread_main,
1890
+ 见 core 的装配点);运行期改动一律 PostMessage(WM_CJT_HOTKEYS) 回宿主线程做,
1891
+ 与菜单 / 拖放同一纪律:绝不在别的线程碰窗口。 */
1892
+
1893
+ /** 修饰键名 → MOD_*;认不出返回 -1(整行丢弃并打日志) */
1894
+ static int hk_mod_bit(const char *name, UINT *out) {
1895
+ if (strcmp(name, "Ctrl") == 0 || strcmp(name, "Control") == 0) { *out = MOD_CONTROL; return 0; }
1896
+ if (strcmp(name, "Alt") == 0) { *out = MOD_ALT; return 0; }
1897
+ if (strcmp(name, "Shift") == 0) { *out = MOD_SHIFT; return 0; }
1898
+ if (strcmp(name, "Super") == 0 || strcmp(name, "Win") == 0) { *out = MOD_WIN; return 0; }
1899
+ return -1;
1900
+ }
1901
+
1902
+ /** 键名 → 虚拟键码;认不出返回 0(整行丢弃并打日志)。
1903
+ XStringToKeysym 的镜像:单字符 / F1..F24 / 常用编辑与导航键,外加 Enter、Esc 两个别名。 */
1904
+ static UINT hk_vk_of(const char *name) {
1905
+ size_t n = strlen(name);
1906
+ if (n == 1) {
1907
+ char c = name[0];
1908
+ if (c >= 'a' && c <= 'z') return (UINT)(c - 'a' + 'A'); /* VK_A..VK_Z 与 ASCII 大写同值 */
1909
+ if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) return (UINT)c;
1910
+ }
1911
+ if ((name[0] == 'F' || name[0] == 'f') && n >= 2 && n <= 3) {
1912
+ int num = atoi(name + 1);
1913
+ if (num >= 1 && num <= 24) return (UINT)(VK_F1 + num - 1);
1914
+ }
1915
+ if (strcmp(name, "Enter") == 0 || strcmp(name, "Return") == 0) return VK_RETURN;
1916
+ if (strcmp(name, "Esc") == 0 || strcmp(name, "Escape") == 0) return VK_ESCAPE;
1917
+ if (strcmp(name, "Space") == 0) return VK_SPACE;
1918
+ if (strcmp(name, "Tab") == 0) return VK_TAB;
1919
+ if (strcmp(name, "Backspace") == 0) return VK_BACK;
1920
+ if (strcmp(name, "Delete") == 0) return VK_DELETE;
1921
+ if (strcmp(name, "Insert") == 0) return VK_INSERT;
1922
+ if (strcmp(name, "Home") == 0) return VK_HOME;
1923
+ if (strcmp(name, "End") == 0) return VK_END;
1924
+ if (strcmp(name, "PgUp") == 0 || strcmp(name, "PageUp") == 0) return VK_PRIOR;
1925
+ if (strcmp(name, "PgDn") == 0 || strcmp(name, "PageDown") == 0) return VK_NEXT;
1926
+ if (strcmp(name, "Up") == 0) return VK_UP;
1927
+ if (strcmp(name, "Down") == 0) return VK_DOWN;
1928
+ if (strcmp(name, "Left") == 0) return VK_LEFT;
1929
+ if (strcmp(name, "Right") == 0) return VK_RIGHT;
1930
+ return 0;
1931
+ }
1932
+
1933
+ /** 注销整表并清空槽位(幂等:WM_DESTROY 与宿主线程收尾都会调) */
1934
+ static void hotkey_unregister_all(plat_host *p) {
1935
+ int i;
1936
+ if (!p) return;
1937
+ for (i = 0; i < p->hotkey_count; i++) {
1938
+ if (p->hotkeys[i].registered && p->hwnd) {
1939
+ UnregisterHotKey(p->hwnd, p->hotkeys[i].id);
1940
+ }
1941
+ p->hotkeys[i].registered = 0;
1942
+ free(p->hotkeys[i].app_id);
1943
+ p->hotkeys[i].app_id = NULL;
1944
+ }
1945
+ p->hotkey_count = 0;
1946
+ }
1947
+
1948
+ /** 整表替换:注销旧表 → 逐行解析 wire → 注册新表(解析不了 / 被占用的那条出局,其余照常) */
1949
+ static void hotkey_apply_wire(plat_host *p, const char *wire) {
1950
+ char *copy, *line, *nl;
1951
+ if (!p || !p->hwnd) return;
1952
+ hotkey_unregister_all(p);
1953
+ if (!wire || !wire[0]) {
1954
+ fprintf(stderr, "[cj-bridge] hotkeys cleared\n");
1955
+ return;
1956
+ }
1957
+ copy = strdup(wire);
1958
+ if (!copy) return;
1959
+ line = copy;
1960
+ while (line && line[0]) {
1961
+ char *colon, *accel, *id, *tok, *save;
1962
+ char accel_orig[128];
1963
+ UINT mods = 0, vk = 0;
1964
+ int ok = 1;
1965
+ nl = strchr(line, '\n');
1966
+ if (nl) *nl = '\0';
1967
+ colon = strchr(line, ':');
1968
+ if (!colon || colon == line) {
1969
+ fprintf(stderr, "[cj-bridge] hotkey line skipped (no 'accel:id'): %s\n", line);
1970
+ goto next_line;
1971
+ }
1972
+ *colon = '\0';
1973
+ accel = line;
1974
+ id = colon + 1;
1975
+ snprintf(accel_orig, sizeof(accel_orig), "%s", accel); /* strtok 会原地改坏 accel,日志留原文 */
1976
+ /* 修饰键:'+' 分隔逐个解析,最后一个 token 是键名(与 Linux 同一个循环)*/
1977
+ save = NULL;
1978
+ tok = strtok_r(accel, "+", &save);
1979
+ while (tok) {
1980
+ UINT bit;
1981
+ char *next = strtok_r(NULL, "+", &save);
1982
+ if (!next) break; /* tok 是键名 */
1983
+ if (hk_mod_bit(tok, &bit) == 0) {
1984
+ mods |= bit;
1985
+ } else {
1986
+ fprintf(stderr, "[cj-bridge] hotkey: unknown modifier \"%s\" in: %s:%s\n",
1987
+ tok, accel_orig, id);
1988
+ ok = 0;
1989
+ break;
1990
+ }
1991
+ tok = next;
1992
+ }
1993
+ if (ok && tok) {
1994
+ vk = hk_vk_of(tok);
1995
+ if (vk == 0) {
1996
+ fprintf(stderr, "[cj-bridge] hotkey: unknown key \"%s\" in: %s:%s\n",
1997
+ tok, accel_orig, id);
1998
+ ok = 0;
1999
+ }
2000
+ }
2001
+ if (ok && tok) {
2002
+ if (p->hotkey_count >= CJ_HOTKEY_MAX) {
2003
+ fprintf(stderr, "[cj-bridge] hotkey: table full (%d), dropped: %s\n", CJ_HOTKEY_MAX, id);
2004
+ } else {
2005
+ hotkey_slot *s = &p->hotkeys[p->hotkey_count];
2006
+ s->id = p->hotkey_count + 1; /* WM_HOTKEY 的 wParam:从 1 起,0 不用 */
2007
+ s->mods = mods;
2008
+ s->vk = vk;
2009
+ s->app_id = strdup(id);
2010
+ s->registered = RegisterHotKey(p->hwnd, s->id, mods, vk) ? 1 : 0;
2011
+ if (s->registered) {
2012
+ fprintf(stderr, "[cj-bridge] hotkey registered: %s:%s (vk=0x%02x mods=0x%04x)\n",
2013
+ accel_orig, id, (unsigned)vk, (unsigned)mods);
2014
+ p->hotkey_count++;
2015
+ } else {
2016
+ /* ERROR_HOTKEY_ALREADY_REGISTERED(1409) = 已被别的程序占用:如实报,不致命 */
2017
+ fprintf(stderr, "[cj-bridge] hotkey register failed (in use?): %s:%s (err=%lu)\n",
2018
+ accel_orig, id, (unsigned long)GetLastError());
2019
+ free(s->app_id);
2020
+ s->app_id = NULL;
2021
+ }
2022
+ }
2023
+ }
2024
+ next_line:
2025
+ line = nl ? nl + 1 : NULL;
2026
+ }
2027
+ free(copy);
2028
+ }
2029
+
2030
+ /* 全局快捷键:装配期(窗口尚未建)静默退出——host_thread_main 建窗后会拿 pending 再应用一次;
2031
+ 运行期改动投到宿主线程做(RegisterHotKey / UnregisterHotKey 都必须与窗口同线程)。 */
2032
+ void cj_plat_set_global_hotkeys(cj_host *h, const char *wire) {
2033
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
2034
+ if (!p) return;
2035
+ if (!p->hwnd) return; /* 见上:pending 由 host_thread_main 应用,这里重复应用只会重复注册 */
2036
+ if (cj_plat_is_ui_thread(h)) {
2037
+ hotkey_apply_wire(p, wire ? wire : "");
2038
+ return;
2039
+ }
2040
+ {
2041
+ char *dup = strdup(wire ? wire : "");
2042
+ if (!dup) return;
2043
+ if (!PostMessageW(p->hwnd, WM_CJT_HOTKEYS, 0, (LPARAM)dup)) {
2044
+ fprintf(stderr, "[cj-bridge] hotkeys: post failed: %lu\n", (unsigned long)GetLastError());
2045
+ free(dup);
2046
+ }
2047
+ }
2048
+ }
2049
+
2050
+ /* ===== 托盘(RFC-002 §7.C,Windows 真实现)=====
2051
+ 线路与菜单共用一套格式(前两行 icon / tooltip,其后是平铺一层的菜单行,见 bridge_core.h 的
2052
+ pending_tray 注释):行解析照样走 core 的 cj_core_menu_walk,本层只把行翻成 HMENU + 图标。
2053
+
2054
+ 托盘菜单用 **CreatePopupMenu**(只弹不挂窗口):AGENTS §4 那条坑(SetMenu 只收 CreateMenu
2055
+ 的菜单栏句柄,塞 popup 进去以 ERROR_INVALID_PARAMETER(87) 静默失败)在托盘侧不存在——
2056
+ 托盘菜单从不交给 SetMenu,只交给 TrackPopupMenu。
2057
+
2058
+ 图标:应用给的是 png / ico 路径。LoadImageW(IMAGE_ICON) 只认 .ico(png 失败**不报错**,
2059
+ 只是拿到 NULL),而 IMAGE_BITMAP 拿到的句柄不是 HICON、塞进 NOTIFYICONDATAW.hIcon 画不出来——
2060
+ 所以口径是:.ico 按 SM_CXSMICON 载入;其它扩展名出声跳过,用系统缺省图标(比给一个画不出来的
2061
+ 句柄诚实);空路径直接用缺省图标。**图标必须在窗口销毁前 NIM_DELETE**(WM_DESTROY 里做),
2062
+ 否则进程退出后系统托盘会留一个点不动的幽灵图标。 */
2063
+
2064
+ typedef struct tray_build_ctx {
2065
+ plat_host *p;
2066
+ HMENU menu;
2067
+ } tray_build_ctx;
2068
+
2069
+ static void tray_clear_slots(plat_host *p) {
2070
+ int i;
2071
+ if (!p) return;
2072
+ for (i = 0; i < p->tray_item_count; i++) {
2073
+ free(p->tray_items[i].app_id);
2074
+ p->tray_items[i].app_id = NULL;
2075
+ }
2076
+ p->tray_item_count = 0;
2077
+ }
2078
+
2079
+ /** 摘掉托盘图标与菜单(幂等:WM_DESTROY 与宿主线程收尾都会调) */
2080
+ static void tray_remove(plat_host *p) {
2081
+ if (!p) return;
2082
+ if (p->tray_added) {
2083
+ Shell_NotifyIconW(NIM_DELETE, &p->nid);
2084
+ p->tray_added = 0;
2085
+ /* 句柄归谁:LoadImageW 出来的是我们自己的一份,得 DestroyIcon;
2086
+ LoadIconW(NULL, IDI_APPLICATION) 是共享资源,绝不能销毁 */
2087
+ if (p->nid.hIcon && p->nid.hIcon != LoadIconW(NULL, IDI_APPLICATION)) {
2088
+ DestroyIcon(p->nid.hIcon);
2089
+ }
2090
+ p->nid.hIcon = NULL;
2091
+ fprintf(stderr, "[cj-bridge] tray removed\n");
2092
+ }
2093
+ if (p->tray_menu) {
2094
+ DestroyMenu(p->tray_menu);
2095
+ p->tray_menu = NULL;
2096
+ }
2097
+ tray_clear_slots(p);
2098
+ }
2099
+
2100
+ /** 载入托盘图标:拿不到就回落到系统缺省图标(永远返回一个可用句柄) */
2101
+ static HICON tray_load_icon(const char *path) {
2102
+ HICON ic = NULL;
2103
+ if (path && path[0]) {
2104
+ wchar_t *w = utf8_to_wide(path);
2105
+ if (w) {
2106
+ ic = (HICON)LoadImageW(NULL, w, IMAGE_ICON, GetSystemMetrics(SM_CXSMICON),
2107
+ GetSystemMetrics(SM_CYSMICON), LR_LOADFROMFILE);
2108
+ if (!ic) {
2109
+ fprintf(stderr, "[cj-bridge] tray: icon load failed (%s) - system default used "
2110
+ "(Windows sidebar icons must be .ico, png is not loadable)\n", path);
2111
+ }
2112
+ free(w);
2113
+ }
2114
+ }
2115
+ if (!ic) ic = LoadIconW(NULL, IDI_APPLICATION);
2116
+ return ic;
2117
+ }
2118
+
2119
+ /** 平铺一层的托盘菜单:kind 与菜单线路同码(n 普通 / c 勾选 / s 分隔线;m 丢弃——v1 托盘菜单平铺)*/
2120
+ static void tray_add_row(void *ctx, int depth, char kind, const char *id,
2121
+ const char *label, int flags, const char *accel) {
2122
+ tray_build_ctx *c = (tray_build_ctx *)ctx;
2123
+ plat_host *p;
2124
+ UINT cmd, mflags;
2125
+ int enabled = (flags & 1) ? 1 : 0;
2126
+ int checked = (flags & 2) ? 1 : 0;
2127
+ (void)depth; /* 托盘菜单平铺:层级位忽略 */
2128
+ if (!c || !c->p || !c->menu) return;
2129
+ p = c->p;
2130
+ if (kind == 's') {
2131
+ AppendMenuW(c->menu, MF_SEPARATOR, 0, NULL);
2132
+ return;
2133
+ }
2134
+ if (kind == 'm') {
2135
+ fprintf(stderr, "[cj-bridge] tray: submenu dropped (v1 tray menu is flat): %s\n", label);
2136
+ return;
2137
+ }
2138
+ if (p->tray_item_count >= CJ_MENU_MAX_ITEMS) {
2139
+ fprintf(stderr, "[cj-bridge] tray: item limit reached, dropped: %s\n", id);
2140
+ return;
2141
+ }
2142
+ cmd = (UINT)(CJ_TRAY_CMD_BASE + p->tray_item_count); /* 与菜单栏命令 id 空间分开,见 WM_COMMAND */
2143
+ mflags = enabled ? 0 : MF_GRAYED;
2144
+ if (kind == 'c' && checked) mflags |= MF_CHECKED;
2145
+ {
2146
+ /* 标签:accel 非空时用制表符并进去(Win32 会把 \t 之后右对齐——正是快捷键栏位置)*/
2147
+ size_t n = strlen(label) + (accel && accel[0] ? strlen(accel) + 1 : 0) + 1;
2148
+ char *tmp = (char *)malloc(n);
2149
+ wchar_t *wlabel;
2150
+ if (!tmp) return;
2151
+ snprintf(tmp, n, "%s%s%s", label, (accel && accel[0]) ? "\t" : "", accel ? accel : "");
2152
+ wlabel = utf8_to_wide(tmp);
2153
+ free(tmp);
2154
+ if (!wlabel) return;
2155
+ if (!AppendMenuW(c->menu, mflags, cmd, wlabel)) {
2156
+ fprintf(stderr, "[cj-bridge] tray: append item failed: %lu\n",
2157
+ (unsigned long)GetLastError());
2158
+ free(wlabel);
2159
+ return;
2160
+ }
2161
+ free(wlabel);
2162
+ }
2163
+ {
2164
+ menu_slot *s = &p->tray_items[p->tray_item_count];
2165
+ s->cmd_id = cmd;
2166
+ s->app_id = strdup(id);
2167
+ s->kind = kind;
2168
+ s->enabled = enabled;
2169
+ s->checked = checked;
2170
+ p->tray_item_count++;
2171
+ }
2172
+ }
2173
+
2174
+ /** 跳下一行行首(与 Linux 侧同名同义:最后一行没有 \n 就到串尾)*/
2175
+ static const char *tray_next_line(const char *p) {
2176
+ const char *nl = strchr(p, '\n');
2177
+ return nl ? nl + 1 : p + strlen(p);
2178
+ }
2179
+
2180
+ /** 整表替换:空线路 = 移除托盘;否则重建图标 + 菜单(重复 setTray = 整体换)*/
2181
+ static void tray_apply_wire(cj_host *h, plat_host *p, const char *wire) {
2182
+ char icon[512];
2183
+ char tooltip[512];
2184
+ const char *menu_part;
2185
+ tray_build_ctx ctx;
2186
+ wchar_t *wtip;
2187
+ HICON new_icon;
2188
+ DWORD op;
2189
+
2190
+ if (!p || !p->hwnd) return;
2191
+ if (!wire || !wire[0]) {
2192
+ tray_remove(p);
2193
+ return;
2194
+ }
2195
+ /* 逐行推进:第 1 行 icon、第 2 行 tooltip、剩下全是菜单行。
2196
+ (第一版「先跳两行再找 tooltip」永远找不着——tooltip 在第 2 行,跳两行已经越过它了) */
2197
+ icon[0] = '\0';
2198
+ tooltip[0] = '\0';
2199
+ menu_part = wire;
2200
+ if (strncmp(menu_part, "icon\t", 5) == 0) {
2201
+ const char *v = menu_part + 5;
2202
+ const char *nl = strchr(v, '\n');
2203
+ size_t n = nl ? (size_t)(nl - v) : strlen(v);
2204
+ if (n >= sizeof(icon)) n = sizeof(icon) - 1;
2205
+ memcpy(icon, v, n);
2206
+ icon[n] = '\0';
2207
+ menu_part = tray_next_line(menu_part);
2208
+ }
2209
+ if (strncmp(menu_part, "tooltip\t", 8) == 0) {
2210
+ const char *v = menu_part + 8;
2211
+ const char *nl = strchr(v, '\n');
2212
+ size_t n = nl ? (size_t)(nl - v) : strlen(v);
2213
+ if (n >= sizeof(tooltip)) n = sizeof(tooltip) - 1;
2214
+ memcpy(tooltip, v, n);
2215
+ tooltip[n] = '\0';
2216
+ menu_part = tray_next_line(menu_part);
2217
+ }
2218
+
2219
+ /* 菜单整表重建(与菜单栏同规:旧 HMENU 与旧映射一起作废)*/
2220
+ if (p->tray_menu) {
2221
+ DestroyMenu(p->tray_menu);
2222
+ p->tray_menu = NULL;
2223
+ }
2224
+ tray_clear_slots(p);
2225
+ {
2226
+ HMENU menu = CreatePopupMenu();
2227
+ if (menu) {
2228
+ ctx.p = p;
2229
+ ctx.menu = menu;
2230
+ cj_core_menu_walk(menu_part, tray_add_row, &ctx);
2231
+ p->tray_menu = menu;
2232
+ }
2233
+ }
2234
+
2235
+ /* 图标 + 提示 + 回调消息:三者每次整体重填(NOTIFYICONDATAW 由平台层持有)*/
2236
+ if (p->nid.hIcon && p->nid.hIcon != LoadIconW(NULL, IDI_APPLICATION)) {
2237
+ DestroyIcon(p->nid.hIcon); /* 换图标前先放掉上一份(LoadImageW 的所有权在我们手上)*/
2238
+ }
2239
+ memset(&p->nid, 0, sizeof(p->nid));
2240
+ p->nid.cbSize = sizeof(p->nid);
2241
+ p->nid.hWnd = p->hwnd;
2242
+ p->nid.uID = CJ_TRAY_UID;
2243
+ p->nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
2244
+ p->nid.uCallbackMessage = WM_CJT_TRAY_NOTIFY;
2245
+ p->nid.hIcon = tray_load_icon(icon);
2246
+ wtip = utf8_to_wide(tooltip);
2247
+ if (wtip) {
2248
+ /* szTip 是定长宽字符数组:lstrcpynW 会保证 NUL 收尾(宽字符拷贝一律走 Win32,
2249
+ mingw 的 swprintf %s 收的是 char*,拿它拼宽串会静默出垃圾——见 AGENTS §4)*/
2250
+ lstrcpynW(p->nid.szTip, wtip, (int)(sizeof(p->nid.szTip) / sizeof(wchar_t)));
2251
+ free(wtip);
2252
+ }
2253
+
2254
+ op = p->tray_added ? NIM_MODIFY : NIM_ADD;
2255
+ if (Shell_NotifyIconW(op, &p->nid)) {
2256
+ p->tray_added = 1;
2257
+ p->nid.uVersion = NOTIFYICON_VERSION_4; /* 回调口径:wParam 坐标 / lParam 事件 */
2258
+ Shell_NotifyIconW(NIM_SETVERSION, &p->nid);
2259
+ fprintf(stderr, "[cj-bridge] tray %s: items=%d icon=%s tooltip=%s\n",
2260
+ op == NIM_ADD ? "added" : "updated", p->tray_item_count,
2261
+ icon[0] ? icon : "(system default)", tooltip[0] ? tooltip : "(none)");
2262
+ } else {
2263
+ fprintf(stderr, "[cj-bridge] tray add failed: %lu\n", (unsigned long)GetLastError());
2264
+ }
2265
+ }
2266
+
2267
+ /* 托盘:装配期(窗口尚未建)静默退出——host_thread_main 建窗后会拿 pending 再应用一次;
2268
+ 运行期改动投到宿主线程做(Shell_NotifyIconW 的回调消息落在窗口上,必须与窗口同线程)。 */
2269
+ void cj_plat_set_tray(cj_host *h, const char *wire) {
2270
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
2271
+ if (!p) return;
2272
+ if (!p->hwnd) return; /* 见上:pending 由 host_thread_main 应用 */
2273
+ if (cj_plat_is_ui_thread(h)) {
2274
+ tray_apply_wire(h, p, wire ? wire : "");
2275
+ return;
2276
+ }
2277
+ {
2278
+ char *dup = strdup(wire ? wire : "");
2279
+ if (!dup) return;
2280
+ if (!PostMessageW(p->hwnd, WM_CJT_TRAY, 0, (LPARAM)dup)) {
2281
+ fprintf(stderr, "[cj-bridge] tray: post failed: %lu\n", (unsigned long)GetLastError());
2282
+ free(dup);
2283
+ }
2284
+ }
2285
+ }
2286
+
2287
+ /* ===== 窗口可见性 / 最小化到托盘(RFC-003 第 5 期余项)=====
2288
+ 两条都只动**窗口层**:ShowWindow(SW_HIDE / SW_SHOW) 不销毁 WebView2,页面照旧跑、轮询照旧,
2289
+ 只是看不见——所以「最小化到托盘」不需要任何页面侧配合。还原入口是应用自己的事
2290
+ (托盘菜单 / 全局快捷键里调 setWindowVisible(true)),框架不替它决定。
2291
+
2292
+ WM_SYSCOMMAND 的拦截在 wnd_proc:那条路径要求托盘**真的挂上了**(p->tray_added),
2293
+ 没挂就如实降级为普通最小化并打一行 stderr——理由见那里的注释。 */
2294
+
2295
+ static void window_apply_visible(plat_host *p, int visible) {
2296
+ if (!p || !p->hwnd) return;
2297
+ ShowWindow(p->hwnd, visible ? SW_SHOW : SW_HIDE);
2298
+ if (visible) {
2299
+ SetForegroundWindow(p->hwnd); /* 藏过的窗口 SW_SHOW 不会自己激活:顺手提到前面 */
2300
+ }
2301
+ fprintf(stderr, "[cj-bridge] window %s\n", visible ? "shown" : "hidden");
2302
+ }
2303
+
2304
+ void cj_plat_set_window_visible(cj_host *h, int visible) {
2305
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
2306
+ if (!p) return;
2307
+ if (!p->hwnd) return; /* 建窗前设的可见性由 host_thread_main 按 h->win_visible 定稿 */
2308
+ if (cj_plat_is_ui_thread(h)) {
2309
+ window_apply_visible(p, visible);
2310
+ return;
2311
+ }
2312
+ PostMessageW(p->hwnd, WM_CJT_VISIBLE, visible ? 1 : 0, 0);
2313
+ }
2314
+
2315
+ /* 最小化到托盘这里只**记一条策略**(并重置提醒位):真正的拦截发生在用户按下最小化的那一刻
2316
+ (WM_SYSCOMMAND),那时才知道托盘到底挂上了没有;此刻窗口层没有可做的事。 */
2317
+ void cj_plat_set_minimize_to_tray(cj_host *h, int enabled) {
2318
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
2319
+ if (!p) return;
2320
+ p->min_to_tray_warned = 0;
2321
+ fprintf(stderr, "[cj-bridge] minimize_to_tray: %s\n", enabled ? "on" : "off");
2322
+ }
2323
+
2324
+ /* IDropTarget COM 对象(mingw C 模式,与文件里其余 COM 对象同风格)。
2325
+ iface 必须在首位:RegisterDragDrop 收的就是 IDropTarget*。 */
2326
+ typedef struct cjt_drop_target {
2327
+ IDropTarget iface;
2328
+ LONG refcount;
2329
+ cj_host *h; /* 回投宿主:Drop 落地时交 core(宿主销毁前必然先 Revoke,见 WM_DESTROY) */
2330
+ } cjt_drop_target;
2331
+
2332
+ static HRESULT STDMETHODCALLTYPE drop_QueryInterface(IDropTarget *This, REFIID riid, void **ppv) {
2333
+ if (!ppv) return E_POINTER;
2334
+ if (IsEqualIID(riid, &IID_IUnknown) || IsEqualIID(riid, &IID_IDropTarget)) {
2335
+ *ppv = This;
2336
+ This->lpVtbl->AddRef(This);
2337
+ return S_OK;
2338
+ }
2339
+ *ppv = NULL;
2340
+ return E_NOINTERFACE;
2341
+ }
2342
+
2343
+ static ULONG STDMETHODCALLTYPE drop_AddRef(IDropTarget *This) {
2344
+ cjt_drop_target *t = (cjt_drop_target *)This;
2345
+ return (ULONG)InterlockedIncrement(&t->refcount);
2346
+ }
2347
+
2348
+ static ULONG STDMETHODCALLTYPE drop_Release(IDropTarget *This) {
2349
+ cjt_drop_target *t = (cjt_drop_target *)This;
2350
+ ULONG rc = (ULONG)InterlockedDecrement(&t->refcount);
2351
+ if (rc == 0) {
2352
+ free(t);
2353
+ }
2354
+ return rc;
2355
+ }
2356
+
2357
+ /* Drop 的载荷只认 CF_HDROP:拖进来的如果是别的东西(文本等),v1 如实不收 */
2358
+ static const FORMATETC g_drop_fmt = { CF_HDROP, NULL, DVASPECT_CONTENT, -1, TYMED_HGLOBAL };
2359
+
2360
+ static HRESULT STDMETHODCALLTYPE drop_DragEnter(IDropTarget *This, IDataObject *dobj,
2361
+ DWORD key_state, POINTL pt, DWORD *effect) {
2362
+ (void)This; (void)key_state; (void)pt;
2363
+ *effect = (dobj && dobj->lpVtbl->QueryGetData(dobj, (FORMATETC *)&g_drop_fmt) == S_OK)
2364
+ ? DROPEFFECT_COPY : DROPEFFECT_NONE;
2365
+ return S_OK;
2366
+ }
2367
+
2368
+ static HRESULT STDMETHODCALLTYPE drop_DragOver(IDropTarget *This, DWORD key_state, POINTL pt,
2369
+ DWORD *effect) {
2370
+ (void)This; (void)key_state; (void)pt;
2371
+ *effect = DROPEFFECT_COPY; /* 光标策略沿用 DragEnter 的判定;这里保持 COPY */
2372
+ return S_OK;
2373
+ }
2374
+
2375
+ static HRESULT STDMETHODCALLTYPE drop_DragLeave(IDropTarget *This) {
2376
+ (void)This;
2377
+ return S_OK;
2378
+ }
2379
+
2380
+ /* 把一个宽字符路径转成 file:/// URI(百分号编码,'\' → '/')。
2381
+ core 的 drop_uri_to_path 会剥 file:// 前缀并解码 %XX——两平台同一出口。 */
2382
+ static void drop_append_uri(char **buf, size_t *cap, size_t *len, const wchar_t *path) {
2383
+ static const char hex[] = "0123456789ABCDEF";
2384
+ char *utf8 = wide_to_utf8(path);
2385
+ size_t i;
2386
+ if (!utf8) return;
2387
+ if (*len + strlen(utf8) * 3 + 12 > *cap) { /* 超出总量上限:丢弃这一条 */
2388
+ free(utf8);
2389
+ return;
2390
+ }
2391
+ *len += (size_t)snprintf(*buf + *len, 9, "file:///");
2392
+ for (i = 0; utf8[i]; i++) {
2393
+ unsigned char c = (unsigned char)utf8[i];
2394
+ if (c == '\\') {
2395
+ (*buf)[(*len)++] = '/';
2396
+ } else if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
2397
+ strchr("-._~/", c)) {
2398
+ (*buf)[(*len)++] = (char)c;
2399
+ } else {
2400
+ (*buf)[(*len)++] = '%';
2401
+ (*buf)[(*len)++] = hex[c >> 4];
2402
+ (*buf)[(*len)++] = hex[c & 0x0F];
2403
+ }
2404
+ }
2405
+ (*buf)[(*len)++] = '\n';
2406
+ free(utf8);
2407
+ }
2408
+
2409
+ static HRESULT STDMETHODCALLTYPE drop_Drop(IDropTarget *This, IDataObject *dobj,
2410
+ DWORD key_state, POINTL pt, DWORD *effect) {
2411
+ cjt_drop_target *t = (cjt_drop_target *)This;
2412
+ STGMEDIUM stg;
2413
+ (void)key_state; (void)pt;
2414
+ *effect = DROPEFFECT_COPY;
2415
+ if (!dobj || FAILED(dobj->lpVtbl->GetData(dobj, (FORMATETC *)&g_drop_fmt, &stg))) {
2416
+ return S_OK;
2417
+ }
2418
+ if (stg.hGlobal) {
2419
+ HDROP hd = (HDROP)stg.hGlobal;
2420
+ UINT i, count = DragQueryFileW(hd, 0xFFFFFFFF, NULL, 0);
2421
+ size_t cap = 32 * (12 + 3 * 4096) + 1; /* 与 core 的上限同量级(32 个 × 4096 字节) */
2422
+ char *list = (char *)malloc(cap);
2423
+ size_t len = 0;
2424
+ if (list) {
2425
+ for (i = 0; i < count; i++) {
2426
+ UINT wlen = DragQueryFileW(hd, i, NULL, 0);
2427
+ wchar_t *wpath;
2428
+ if (wlen == 0 || wlen > 32768) continue;
2429
+ wpath = (wchar_t *)malloc((wlen + 1) * sizeof(wchar_t));
2430
+ if (!wpath) continue;
2431
+ if (DragQueryFileW(hd, i, wpath, wlen + 1) > 0) {
2432
+ drop_append_uri(&list, &cap, &len, wpath);
2433
+ }
2434
+ free(wpath);
2435
+ }
2436
+ if (len > 0) {
2437
+ list[len] = '\0';
2438
+ cj_core_drop_files(t->h, list); /* 解码 / 上限 / 报文都在 core */
2439
+ }
2440
+ free(list);
2441
+ }
2442
+ DragFinish(hd);
2443
+ }
2444
+ if (stg.pUnkForRelease) {
2445
+ stg.pUnkForRelease->lpVtbl->Release(stg.pUnkForRelease);
2446
+ }
2447
+ return S_OK;
2448
+ }
2449
+
2450
+ static IDropTargetVtbl g_drop_vtbl = {
2451
+ drop_QueryInterface, drop_AddRef, drop_Release,
2452
+ drop_DragEnter, drop_DragOver, drop_DragLeave, drop_Drop
2453
+ };
2454
+
2455
+ /* 对一个子窗口(含间接后代)撤销 WebView2 自己的目标并注册我们的 */
2456
+ static BOOL CALLBACK drop_inject_child(HWND child, LPARAM lp) {
2457
+ IDropTarget *t = (IDropTarget *)lp;
2458
+ /* WebView2 已注册了自己的 drop target:先撤销再覆盖(没有注册时撤销失败,忽略) */
2459
+ RevokeDragDrop(child);
2460
+ return SUCCEEDED(RegisterDragDrop(child, t)) ? TRUE : TRUE;
2461
+ }
2462
+
2463
+ static BOOL CALLBACK drop_revoke_child(HWND child, LPARAM lp) {
2464
+ (void)lp;
2465
+ RevokeDragDrop(child);
2466
+ return TRUE;
2467
+ }
2468
+
2469
+ /* 挂/摘拖放目标(只在宿主 UI 线程调):enabled=0 摘除。挂时枚举全部子窗口逐个覆盖。 */
2470
+ static void drag_dest_apply(cj_host *h, plat_host *p, int enabled) {
2471
+ if (!p || !p->hwnd) return;
2472
+ if (enabled) {
2473
+ if (!p->drop_target) {
2474
+ cjt_drop_target *t = (cjt_drop_target *)calloc(1, sizeof(cjt_drop_target));
2475
+ if (!t) return;
2476
+ t->iface.lpVtbl = &g_drop_vtbl;
2477
+ t->refcount = 1;
2478
+ t->h = h;
2479
+ p->drop_target = &t->iface;
2480
+ }
2481
+ EnumChildWindows(p->hwnd, drop_inject_child, (LPARAM)p->drop_target);
2482
+ fprintf(stderr, "[cj-bridge] drag dest attached (CF_HDROP)\n");
2483
+ } else {
2484
+ EnumChildWindows(p->hwnd, drop_revoke_child, 0);
2485
+ fprintf(stderr, "[cj-bridge] drag dest detached\n");
2486
+ }
2487
+ }
2488
+
2489
+ /* 拖放策略(7.B Windows 真实现):与 Linux 侧同签名同语义——
2490
+ 未就绪(窗口还没建)时 core 已把策略写进 h->pending_drop_*,controller 建好时读;
2491
+ 就绪后投 UI 线程重挂(Windows 的 OLE 注册表项只能在建它的 STA 线程碰)。 */
2492
+ void cj_plat_set_drag_drop(cj_host *h, int enabled, int files) {
2493
+ plat_host *p = h ? (plat_host *)h->plat : NULL;
2494
+ (void)files; /* v1 载荷只有「文件列表」一种形态,files 开关不改变 Windows 侧的注册方式 */
2495
+ if (!p || !p->hwnd) {
2496
+ return;
2497
+ }
2498
+ {
2499
+ drag_drop_msg *m = (drag_drop_msg *)calloc(1, sizeof(drag_drop_msg));
2500
+ if (!m) return;
2501
+ m->enabled = enabled ? 1 : 0;
2502
+ if (!PostMessageW(p->hwnd, WM_CJT_DRAG_DROP, 0, (LPARAM)m)) {
2503
+ fprintf(stderr, "[cj-bridge] drag drop post failed: %lu\n",
2504
+ (unsigned long)GetLastError());
2505
+ free(m);
2506
+ }
2507
+ }
2508
+ }
2509
+
2510
+ /* =========================================================================
2511
+ * 串口(plugin_serial 的平台层):Windows 侧
2512
+ *
2513
+ * 四个原语的**名字与返回码口径**与 Linux 侧逐字一致(见 bridge_core.h),所以上层不必写平台分支。
2514
+ * 实现:CreateFileW("\\\\.\\COMx", …, FILE_FLAG_OVERLAPPED) + DCB 配参;
2515
+ * 读写都走 OVERLAPPED——超时由 WaitForSingleObject 的预算控制,不会把调用线程钉死在
2516
+ * 驱动内部的阻塞读上(SetCommTimeouts 的总超时只对「等到第一个字节」可控,
2517
+ * OVERLAPPED 才能像 Linux 的 poll 那样精确按剩余预算等)。
2518
+ * ========================================================================= */
2519
+
2520
+ #define CJ_SERIAL_MAX_HANDLES 8
2521
+
2522
+ typedef struct serial_slot {
2523
+ int used;
2524
+ long long id;
2525
+ HANDLE h;
2526
+ char path[256];
2527
+ } serial_slot;
2528
+
2529
+ static serial_slot g_serial_slots[CJ_SERIAL_MAX_HANDLES];
2530
+ static long long g_serial_next_id = 1;
2531
+ static CRITICAL_SECTION g_serial_lock;
2532
+ static INIT_ONCE g_serial_lock_once = INIT_ONCE_STATIC_INIT;
2533
+
2534
+ static BOOL CALLBACK serial_lock_init_once(PINIT_ONCE once, PVOID param, PVOID *ctx) {
2535
+ (void)once; (void)param; (void)ctx;
2536
+ InitializeCriticalSection(&g_serial_lock);
2537
+ return TRUE;
2538
+ }
2539
+
2540
+ static void serial_lock(void) {
2541
+ InitOnceExecuteOnce(&g_serial_lock_once, serial_lock_init_once, NULL, NULL);
2542
+ EnterCriticalSection(&g_serial_lock);
2543
+ }
2544
+
2545
+ static void serial_unlock(void) {
2546
+ LeaveCriticalSection(&g_serial_lock);
2547
+ }
2548
+
2549
+ static void serial_err(char *err, int err_len, const char *fmt, ...) {
2550
+ va_list ap;
2551
+ if (!err || err_len <= 0) return;
2552
+ va_start(ap, fmt);
2553
+ vsnprintf(err, (size_t)err_len, fmt, ap);
2554
+ va_end(ap);
2555
+ }
2556
+
2557
+ static serial_slot *serial_find_locked(long long id) {
2558
+ int i;
2559
+ for (i = 0; i < CJ_SERIAL_MAX_HANDLES; i++) {
2560
+ if (g_serial_slots[i].used && g_serial_slots[i].id == id) return &g_serial_slots[i];
2561
+ }
2562
+ return NULL;
2563
+ }
2564
+
2565
+ /* 句柄 → HANDLE;无效时填 err 返回 NULL。HANDLE 值本身取出后在锁外用:
2566
+ close 会先置 used=0 再 CloseHandle,所以锁外使用期间句柄不会被本表回收。 */
2567
+ static HANDLE serial_handle_of(long long h, char *err, int err_len) {
2568
+ serial_slot *s;
2569
+ HANDLE hd = NULL;
2570
+ serial_lock();
2571
+ s = serial_find_locked(h);
2572
+ if (s) hd = s->h;
2573
+ serial_unlock();
2574
+ if (!hd) serial_err(err, err_len, "serial: 句柄 %lld 无效(未打开或已关闭)", h);
2575
+ return hd;
2576
+ }
2577
+
2578
+ /* "COM3"(白名单放行的形状)→ "\\.\COM3":Win32 打开串口必须走设备命名空间,
2579
+ 普通文件语义的 "COM3" 会被当成当前目录下的同名文件。 */
2580
+ static int serial_win_path(const char *path, wchar_t *out, int out_chars) {
2581
+ wchar_t w[256];
2582
+ int n;
2583
+ if (MultiByteToWideChar(CP_UTF8, 0, path, -1, w, 256) <= 0) return 0;
2584
+ if (wcsncmp(w, L"\\\\.\\", 4) == 0) {
2585
+ n = _snwprintf(out, (size_t)out_chars, L"%s", w);
2586
+ } else {
2587
+ n = _snwprintf(out, (size_t)out_chars, L"\\\\.\\%s", w);
2588
+ }
2589
+ return (n > 0 && n < out_chars) ? 1 : 0;
2590
+ }
2591
+
2592
+ /* 数值波特率 → DCB:DCB.BaudRate 本身收数值,但沿用 Linux「只收常见档位」的口径——
2593
+ 不认识的值拒绝而不是透传给驱动(两端行为一致,上层不用猜哪边支持哪档) */
2594
+ static int serial_baud_known(int baud) {
2595
+ switch (baud) {
2596
+ case 300: case 600: case 1200: case 2400: case 4800: case 9600:
2597
+ case 19200: case 38400: case 57600: case 115200: case 230400:
2598
+ case 460800: case 500000: case 921600: case 1000000:
2599
+ return 1;
2600
+ default:
2601
+ return 0;
2602
+ }
2603
+ }
2604
+
2605
+ static long long serial_now_ms(void) {
2606
+ return (long long)GetTickCount64();
2607
+ }
2608
+
2609
+ long long cj_plat_serial_open(const char *path, const cj_serial_cfg *cfg, char *err, int err_len) {
2610
+ wchar_t wpath[280];
2611
+ HANDLE hd;
2612
+ DCB dcb;
2613
+ COMMTIMEOUTS to;
2614
+ int i;
2615
+ long long id;
2616
+
2617
+ if (!path || !cfg) {
2618
+ serial_err(err, err_len, "serial: 空路径 / 空配置");
2619
+ return -1;
2620
+ }
2621
+ if (!serial_baud_known(cfg->baud)) {
2622
+ serial_err(err, err_len, "serial: 本平台不支持的波特率 %d(只收常见档位)", cfg->baud);
2623
+ return -1;
2624
+ }
2625
+ if (!serial_win_path(path, wpath, (int)(sizeof(wpath) / sizeof(wpath[0])))) {
2626
+ serial_err(err, err_len, "serial: 设备路径转宽字符失败(过长或非 UTF-8?): %s", path);
2627
+ return -1;
2628
+ }
2629
+
2630
+ serial_lock();
2631
+ for (i = 0; i < CJ_SERIAL_MAX_HANDLES; i++) {
2632
+ if (!g_serial_slots[i].used) break;
2633
+ }
2634
+ if (i == CJ_SERIAL_MAX_HANDLES) {
2635
+ serial_unlock();
2636
+ serial_err(err, err_len, "serial: 句柄表满(最多 %d 个并发串口)", CJ_SERIAL_MAX_HANDLES);
2637
+ return -2;
2638
+ }
2639
+
2640
+ /* 独占打开(共享模式 0):串口是物理独占资源,两个进程同开一个口只会互咬数据 */
2641
+ hd = CreateFileW(wpath, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING,
2642
+ FILE_FLAG_OVERLAPPED, NULL);
2643
+ if (hd == INVALID_HANDLE_VALUE) {
2644
+ serial_err(err, err_len, "serial: CreateFileW(%s) 失败: lu=%lu(口不存在 / 被占用?)",
2645
+ path, (unsigned long)GetLastError());
2646
+ serial_unlock();
2647
+ return -3;
2648
+ }
2649
+
2650
+ /* GetCommState 拿驱动现状打底再覆盖:DCB 结构里有几十个流控/二进制位,
2651
+ 全零起步会把 fBinary=0 这类非法组合交给驱动,行为随驱动版本漂移 */
2652
+ memset(&dcb, 0, sizeof(dcb));
2653
+ dcb.DCBlength = sizeof(dcb);
2654
+ if (!GetCommState(hd, &dcb)) {
2655
+ serial_err(err, err_len, "serial: GetCommState(%s) 失败: lu=%lu",
2656
+ path, (unsigned long)GetLastError());
2657
+ CloseHandle(hd);
2658
+ serial_unlock();
2659
+ return -3;
2660
+ }
2661
+ dcb.BaudRate = (DWORD)cfg->baud;
2662
+ dcb.ByteSize = (BYTE)cfg->data_bits;
2663
+ dcb.Parity = (cfg->parity == 1) ? ODDPARITY : (cfg->parity == 2) ? EVENPARITY : NOPARITY;
2664
+ dcb.StopBits = (cfg->stop_bits == 2) ? TWOSTOPBITS : ONESTOPBIT;
2665
+ dcb.fBinary = TRUE;
2666
+ dcb.fParity = (cfg->parity != 0) ? TRUE : FALSE;
2667
+ /* 硬件/软件流控不替应用开(与 Linux 侧同一条铁律:框架自作主张会改握手语义) */
2668
+ dcb.fOutxCtsFlow = FALSE;
2669
+ dcb.fOutxDsrFlow = FALSE;
2670
+ dcb.fDtrControl = DTR_CONTROL_DISABLE;
2671
+ dcb.fRtsControl = RTS_CONTROL_DISABLE;
2672
+ dcb.fOutX = FALSE;
2673
+ dcb.fInX = FALSE;
2674
+ if (!SetCommState(hd, &dcb)) {
2675
+ serial_err(err, err_len, "serial: SetCommState(%s) 失败: lu=%lu(设备不接受这组参数?)",
2676
+ path, (unsigned long)GetLastError());
2677
+ CloseHandle(hd);
2678
+ serial_unlock();
2679
+ return -3;
2680
+ }
2681
+
2682
+ /* 超时全部归零:等待交给 OVERLAPPED 的 WaitForSingleObject(我们控制预算),
2683
+ 驱动层不再叠加自己的阻塞语义。PURGE 清掉打开瞬间的残留字节 */
2684
+ memset(&to, 0, sizeof(to));
2685
+ to.ReadIntervalTimeout = MAXDWORD;
2686
+ to.ReadTotalTimeoutConstant = 0;
2687
+ to.ReadTotalTimeoutMultiplier = 0;
2688
+ to.WriteTotalTimeoutConstant = 0;
2689
+ to.WriteTotalTimeoutMultiplier = 0;
2690
+ if (!SetCommTimeouts(hd, &to)) {
2691
+ serial_err(err, err_len, "serial: SetCommTimeouts(%s) 失败: lu=%lu",
2692
+ path, (unsigned long)GetLastError());
2693
+ CloseHandle(hd);
2694
+ serial_unlock();
2695
+ return -3;
2696
+ }
2697
+ PurgeComm(hd, PURGE_TXABORT | PURGE_RXABORT | PURGE_TXCLEAR | PURGE_RXCLEAR);
2698
+
2699
+ id = g_serial_next_id++;
2700
+ if (g_serial_next_id <= 0) g_serial_next_id = 1; /* 溢出兜底:句柄永不为 0 */
2701
+ g_serial_slots[i].used = 1;
2702
+ g_serial_slots[i].id = id;
2703
+ g_serial_slots[i].h = hd;
2704
+ snprintf(g_serial_slots[i].path, sizeof(g_serial_slots[i].path), "%s", path);
2705
+ serial_unlock();
2706
+
2707
+ /* 落地一行:参数与句柄都打全,串口问题基本都是拿这行对账(AGENTS §1 的取证口径) */
2708
+ fprintf(stderr, "[cj-bridge] serial win32: path=%s baud=%d data=%d parity=%d stop=%d handle=%p\n",
2709
+ path, cfg->baud, cfg->data_bits, cfg->parity, cfg->stop_bits, (void *)hd);
2710
+ return id;
2711
+ }
2712
+
2713
+ /* 读:OVERLAPPED 发起,等事件最多 timeout_ms;0 = 超时无数据(与 Linux poll 口径一致)
2714
+ *
2715
+ * 预算必须**按每次调用逐次下发给驱动**:打开时那套 `ReadIntervalTimeout=MAXDWORD` + 两个
2716
+ * total 全 0 是「立即交回已有字节」语义——没数据时驱动当场把请求完成成 0 字节、ReadFile
2717
+ * 返回 TRUE,下面按预算等事件的分支根本进不去(实测 2026-10-15:预算 1000ms 的读 0ms 就
2718
+ * 交回 0 字节,`serial:read` 的 timeoutMs 形同虚设,前端只能靠高频轮询碰运气)。
2719
+ * 这里改成 `ReadIntervalTimeout=0` + `ReadTotalTimeoutConstant=预算`:有第一个字节就立刻
2720
+ * 完成、没有就等满预算,与 Linux 的 poll 同一口径;只有预算为 0 才用「立即返回」那套。
2721
+ * WaitForSingleObject 继续留着,作为驱动不按套路完成时的兜底(比预算多给一点余量)。 */
2722
+ int cj_plat_serial_read(long long h, unsigned char *buf, int len, int timeout_ms, char *err, int err_len) {
2723
+ HANDLE hd = serial_handle_of(h, err, err_len);
2724
+ COMMTIMEOUTS to;
2725
+ OVERLAPPED ov;
2726
+ DWORD got = 0;
2727
+ BOOL ok;
2728
+
2729
+ if (!hd) return -4;
2730
+
2731
+ memset(&to, 0, sizeof(to));
2732
+ if (timeout_ms > 0) {
2733
+ to.ReadIntervalTimeout = 0;
2734
+ to.ReadTotalTimeoutMultiplier = 0;
2735
+ to.ReadTotalTimeoutConstant = (DWORD)timeout_ms;
2736
+ } else {
2737
+ to.ReadIntervalTimeout = MAXDWORD;
2738
+ to.ReadTotalTimeoutMultiplier = 0;
2739
+ to.ReadTotalTimeoutConstant = 0;
2740
+ }
2741
+ if (!SetCommTimeouts(hd, &to)) {
2742
+ serial_err(err, err_len, "serial: SetCommTimeouts(预算 %d ms) 失败: lu=%lu",
2743
+ timeout_ms, (unsigned long)GetLastError());
2744
+ return -3;
2745
+ }
2746
+
2747
+ memset(&ov, 0, sizeof(ov));
2748
+ ov.hEvent = CreateEventW(NULL, TRUE, FALSE, NULL);
2749
+ if (!ov.hEvent) {
2750
+ serial_err(err, err_len, "serial: CreateEvent 失败: lu=%lu", (unsigned long)GetLastError());
2751
+ return -3;
2752
+ }
2753
+ ok = ReadFile(hd, buf, (DWORD)len, &got, &ov);
2754
+ if (!ok && GetLastError() == ERROR_IO_PENDING) {
2755
+ DWORD w = WaitForSingleObject(ov.hEvent, (DWORD)(timeout_ms > 0 ? timeout_ms + 250 : 0));
2756
+ if (w == WAIT_TIMEOUT) {
2757
+ /* 预算用尽:撤销这次读再走,驱动里不挂未完成请求 */
2758
+ CancelIo(hd);
2759
+ GetOverlappedResult(hd, &ov, &got, TRUE); /* 等 CancelIo 本身落定 */
2760
+ CloseHandle(ov.hEvent);
2761
+ return 0;
2762
+ }
2763
+ ok = (w == WAIT_OBJECT_0) ? GetOverlappedResult(hd, &ov, &got, FALSE) : FALSE;
2764
+ if (!ok && GetLastError() == ERROR_OPERATION_ABORTED) {
2765
+ CloseHandle(ov.hEvent);
2766
+ return 0;
2767
+ }
2768
+ } else if (!ok && GetLastError() == ERROR_IO_INCOMPLETE) {
2769
+ /* 理论上只在无事件句柄时出现;按「本轮无数据」降级,别当错误放大 */
2770
+ CloseHandle(ov.hEvent);
2771
+ return 0;
2772
+ }
2773
+ CloseHandle(ov.hEvent);
2774
+ if (!ok) {
2775
+ serial_err(err, err_len, "serial: ReadFile 失败: lu=%lu", (unsigned long)GetLastError());
2776
+ return -3;
2777
+ }
2778
+ return (int)got;
2779
+ }
2780
+
2781
+ /* 写:在 timeout_ms 总预算内有界写完(与 Linux 侧同一口径:返回已写入量,预算用尽即交回) */
2782
+ int cj_plat_serial_write(long long h, const unsigned char *buf, int len, int timeout_ms,
2783
+ char *err, int err_len) {
2784
+ HANDLE hd = serial_handle_of(h, err, err_len);
2785
+ long long deadline = serial_now_ms() + (timeout_ms > 0 ? (long long)timeout_ms : 0);
2786
+ int written = 0;
2787
+
2788
+ if (!hd) return -4;
2789
+
2790
+ while (written < len) {
2791
+ OVERLAPPED ov;
2792
+ DWORD put = 0, w;
2793
+ long long budget;
2794
+ BOOL ok;
2795
+
2796
+ memset(&ov, 0, sizeof(ov));
2797
+ ov.hEvent = CreateEventW(NULL, TRUE, FALSE, NULL);
2798
+ if (!ov.hEvent) {
2799
+ serial_err(err, err_len, "serial: CreateEvent 失败: lu=%lu", (unsigned long)GetLastError());
2800
+ return written > 0 ? written : -3;
2801
+ }
2802
+ ok = WriteFile(hd, buf + written, (DWORD)(len - written), &put, &ov);
2803
+ if (!ok && GetLastError() == ERROR_IO_PENDING) {
2804
+ budget = deadline - serial_now_ms();
2805
+ w = WaitForSingleObject(ov.hEvent, budget > 0 ? (DWORD)budget : 0);
2806
+ if (w == WAIT_TIMEOUT) {
2807
+ CancelIo(hd);
2808
+ GetOverlappedResult(hd, &ov, &put, TRUE);
2809
+ CloseHandle(ov.hEvent);
2810
+ break; /* 预算用尽:返回已写入量 */
2811
+ }
2812
+ ok = (w == WAIT_OBJECT_0) ? GetOverlappedResult(hd, &ov, &put, FALSE) : FALSE;
2813
+ if (!ok && GetLastError() == ERROR_OPERATION_ABORTED) {
2814
+ CloseHandle(ov.hEvent);
2815
+ break;
2816
+ }
2817
+ }
2818
+ CloseHandle(ov.hEvent);
2819
+ if (!ok) {
2820
+ serial_err(err, err_len, "serial: WriteFile 失败: lu=%lu", (unsigned long)GetLastError());
2821
+ return written > 0 ? written : -3;
2822
+ }
2823
+ if (put == 0) break; /* 驱动收了请求却 0 字节:别空转,交回上层 */
2824
+ written += (int)put;
2825
+ }
2826
+ return written;
2827
+ }
2828
+
2829
+ int cj_plat_serial_close(long long h, char *err, int err_len) {
2830
+ HANDLE hd = NULL;
2831
+ int i;
2832
+
2833
+ serial_lock();
2834
+ for (i = 0; i < CJ_SERIAL_MAX_HANDLES; i++) {
2835
+ if (g_serial_slots[i].used && g_serial_slots[i].id == h) {
2836
+ hd = g_serial_slots[i].h;
2837
+ g_serial_slots[i].used = 0;
2838
+ g_serial_slots[i].id = 0;
2839
+ g_serial_slots[i].h = NULL;
2840
+ g_serial_slots[i].path[0] = '\0';
2841
+ break;
2842
+ }
2843
+ }
2844
+ serial_unlock();
2845
+ if (!hd) {
2846
+ serial_err(err, err_len, "serial: 句柄 %lld 无效(未打开或已关闭)", h);
2847
+ return -4;
2848
+ }
2849
+ if (!CloseHandle(hd)) {
2850
+ serial_err(err, err_len, "serial: CloseHandle 失败: lu=%lu", (unsigned long)GetLastError());
2851
+ return -3;
2852
+ }
2853
+ return 0;
2854
+ }
2855
+
2856
+ /* 串口枚举(cj_bridge_serial_list 的平台层):QueryDosDeviceW 扫全部 MS-DOS 设备名,
2857
+ 收「COM + 全数字」的形状。相比翻注册表(枚举值随驱动形态漂移:有 00xx 子键的、
2858
+ 有 Device Parameters 的),QueryDosDevice 一次调用给全系统当前真实存在的设备名,
2859
+ 虚拟口(com0com、驱动模拟)也在其中。 */
2860
+ int cj_plat_serial_list(char *out, int out_len) {
2861
+ wchar_t *buf;
2862
+ DWORD need = 65536, got, i;
2863
+ int count = 0, pos = 0;
2864
+
2865
+ if (!out || out_len <= 0) return -1;
2866
+ out[0] = '\0';
2867
+ for (;;) {
2868
+ buf = (wchar_t *)malloc((size_t)need * sizeof(wchar_t));
2869
+ if (!buf) return -1;
2870
+ got = QueryDosDeviceW(NULL, buf, need);
2871
+ if (got > 0) break;
2872
+ free(buf);
2873
+ if (GetLastError() != ERROR_INSUFFICIENT_BUFFER) return -1;
2874
+ need *= 2; /* 缓冲不够就翻倍重试;QueryDosDevice 无返回所需大小的口径 */
2875
+ }
2876
+ for (i = 0; buf[i] != L'\0'; i += wcslen(buf + i) + 1) {
2877
+ wchar_t *name = buf + i;
2878
+ const wchar_t *p = name;
2879
+ int digits = 0;
2880
+ if (wcsncmp(p, L"COM", 3) != 0) continue;
2881
+ p += 3;
2882
+ while (*p >= L'0' && *p <= L'9') { p++; digits++; }
2883
+ if (digits == 0 || digits > 3 || *p != L'\0') continue; /* COMfoo / 超长号一律不要 */
2884
+ {
2885
+ char ascii[16];
2886
+ int n = WideCharToMultiByte(CP_UTF8, 0, name, -1, ascii, (int)sizeof(ascii), NULL, NULL);
2887
+ if (n <= 0) continue;
2888
+ if (pos + n >= out_len) break; /* 放不下就到此为止:串口数量不会真到溢出 */
2889
+ if (count > 0) out[pos++] = '\n';
2890
+ memcpy(out + pos, ascii, (size_t)n);
2891
+ pos += n - 1; /* 不含收尾 '\0' */
2892
+ count++;
2893
+ }
2894
+ }
2895
+ free(buf);
2896
+ out[pos] = '\0';
2897
+ return count;
2898
+ }
2899
+
2900
+ /* ===== 静默子进程(cj_plat_spawn_hidden / kill / alive 的平台层)=====
2901
+ 为什么是 DETACHED_PROCESS 而不是 SW_HIDE / CREATE_NO_WINDOW:
2902
+ - SW_HIDE 只是「先建出来再藏起来」,控制台窗口会先闪一下;
2903
+ - CREATE_NO_WINDOW 不显示窗口,但子进程**仍然拥有一个控制台对象**,conhost.exe 照样为它挂一个
2904
+ 隐藏的 console 宿主(`GetConsoleWindow` 非空、进程树里多一个 conhost);
2905
+ - DETACHED_PROCESS 让子进程**根本没有控制台**,连隐藏的都不创建。
2906
+ 后台服务进程(cjh 这类)要的就是最后这一种:它不需要 console,也不该让用户看见任何窗口。
2907
+ 代价是它的标准 I/O 没有去处——没有控制台时那三个句柄是无效值,而控制台程序(cjh 启动第一件事
2908
+ 就是打 banner)一 println 就可能死在自己的控制台上,所以三个句柄显式指到 NUL。诊断仍在父进程
2909
+ 的 stderr 上,「诊断输出走 stderr」的约定不受影响(AGENTS §4)。 */
2910
+
2911
+ /* 按 CommandLineToArgvW 的规则给一个参数加引号:只在含空格 / 制表 / 引号时才加;加引号时,
2912
+ 引号前 n 个反斜杠要变 2n+1 个、结尾 n 个要变 2n 个——否则 `C:\dir\` 这种尾部反斜杠会把收尾
2913
+ 引号转义掉、参数被吃掉。UTF-8 逐字节处理是安全的:要判的字符都 < 0x80,多字节序列的每个字节
2914
+ 都 ≥ 0x80,不会误判。缓冲容量最坏情况是「每个字节都翻倍 + 两个引号」。 */
2915
+ static int append_quoted_arg(char *buf, size_t cap, size_t *len, const char *s, size_t n) {
2916
+ size_t i, k, bs = 0;
2917
+ int need = 0;
2918
+
2919
+ for (i = 0; i < n; i++) {
2920
+ char c = s[i];
2921
+ if (c == ' ' || c == '\t' || c == '"') {
2922
+ need = 1;
2923
+ break;
2924
+ }
2925
+ }
2926
+ if (*len + n * 2 + 4 > cap) return 0;
2927
+ if (!need) {
2928
+ if (n) memcpy(buf + *len, s, n);
2929
+ *len += n;
2930
+ return 1;
2931
+ }
2932
+ buf[(*len)++] = '"';
2933
+ for (i = 0; i < n; i++) {
2934
+ char c = s[i];
2935
+ if (c == '\\') {
2936
+ bs++;
2937
+ continue;
2938
+ }
2939
+ if (c == '"') {
2940
+ for (k = 0; k < bs * 2 + 1; k++) buf[(*len)++] = '\\';
2941
+ buf[(*len)++] = '"';
2942
+ } else {
2943
+ for (k = 0; k < bs; k++) buf[(*len)++] = '\\';
2944
+ buf[(*len)++] = c;
2945
+ }
2946
+ bs = 0;
2947
+ }
2948
+ for (k = 0; k < bs * 2; k++) buf[(*len)++] = '\\'; /* 结尾反斜杠翻倍,别把收尾引号吃了 */
2949
+ buf[(*len)++] = '"';
2950
+ return 1;
2951
+ }
2952
+
2953
+ /* 拼命令行:argv[0] = 程序路径(带引号),后面是把 \x1f 切开的参数。 */
2954
+ static wchar_t *build_cmdline_wide(const char *exe, const char *args) {
2955
+ size_t elen = strlen(exe);
2956
+ size_t alen = args ? strlen(args) : 0;
2957
+ size_t cap = (elen + alen) * 2 + 64;
2958
+ size_t len = 0;
2959
+ char *buf = (char *)malloc(cap);
2960
+ wchar_t *w;
2961
+
2962
+ if (!buf) return NULL;
2963
+ if (!append_quoted_arg(buf, cap, &len, exe, elen)) {
2964
+ free(buf);
2965
+ return NULL;
2966
+ }
2967
+ if (alen > 0) {
2968
+ const char *p = args;
2969
+ while (*p) {
2970
+ const char *q = strchr(p, 0x1f);
2971
+ size_t seg = q ? (size_t)(q - p) : strlen(p);
2972
+ if (len + 1 >= cap) {
2973
+ free(buf);
2974
+ return NULL;
2975
+ }
2976
+ buf[len++] = ' ';
2977
+ if (!append_quoted_arg(buf, cap, &len, p, seg)) {
2978
+ free(buf);
2979
+ return NULL;
2980
+ }
2981
+ if (!q) break;
2982
+ p = q + 1;
2983
+ }
2984
+ }
2985
+ buf[len] = '\0';
2986
+ w = utf8_to_wide(buf);
2987
+ free(buf);
2988
+ return w;
2989
+ }
2990
+
2991
+ long long cj_plat_spawn_hidden(const char *exe, const char *args, char *err, int err_len) {
2992
+ wchar_t *wexe, *wcmd;
2993
+ STARTUPINFOW si;
2994
+ PROCESS_INFORMATION pi;
2995
+ SECURITY_ATTRIBUTES sa;
2996
+ HANDLE hNul = INVALID_HANDLE_VALUE;
2997
+ BOOL ok;
2998
+
2999
+ if (!exe || !*exe) {
3000
+ serial_err(err, err_len, "spawn hidden: 程序路径为空");
3001
+ return 0;
3002
+ }
3003
+ wexe = utf8_to_wide(exe);
3004
+ wcmd = build_cmdline_wide(exe, args);
3005
+ if (!wexe || !wcmd) {
3006
+ free(wexe);
3007
+ free(wcmd);
3008
+ serial_err(err, err_len, "spawn hidden: UTF-8 转 UTF-16 / 拼命令行失败");
3009
+ return 0;
3010
+ }
3011
+ ZeroMemory(&sa, sizeof(sa));
3012
+ sa.nLength = (DWORD)sizeof(sa);
3013
+ sa.bInheritHandle = TRUE;
3014
+ hNul = CreateFileW(L"NUL", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE,
3015
+ &sa, OPEN_EXISTING, 0, NULL);
3016
+ ZeroMemory(&si, sizeof(si));
3017
+ si.cb = sizeof(si);
3018
+ si.dwFlags = STARTF_USESHOWWINDOW;
3019
+ si.wShowWindow = SW_HIDE;
3020
+ if (hNul != INVALID_HANDLE_VALUE) {
3021
+ si.dwFlags |= STARTF_USESTDHANDLES;
3022
+ si.hStdInput = hNul;
3023
+ si.hStdOutput = hNul;
3024
+ si.hStdError = hNul;
3025
+ }
3026
+ ZeroMemory(&pi, sizeof(pi));
3027
+ /* DETACHED_PROCESS:完全不给子进程 console(见本段开头,连隐藏的都不建);
3028
+ CREATE_NEW_PROCESS_GROUP:让它不受父进程 Ctrl-C 的影响。 */
3029
+ ok = CreateProcessW(wexe, wcmd, NULL, NULL, TRUE,
3030
+ DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, NULL, NULL, &si, &pi);
3031
+ if (hNul != INVALID_HANDLE_VALUE) CloseHandle(hNul);
3032
+ free(wexe);
3033
+ free(wcmd);
3034
+ if (!ok) {
3035
+ serial_err(err, err_len, "spawn hidden: CreateProcessW 失败(GetLastError=%lu)",
3036
+ (unsigned long)GetLastError());
3037
+ return 0;
3038
+ }
3039
+ CloseHandle(pi.hThread);
3040
+ CloseHandle(pi.hProcess);
3041
+ return (long long)pi.dwProcessId;
3042
+ }
3043
+
3044
+ /* 幂等口径:进程已经不在了算成功(调用方只是想让它结束)。 */
3045
+ int cj_plat_kill_pid(long long pid) {
3046
+ HANDLE h;
3047
+ DWORD wr;
3048
+
3049
+ if (pid <= 0) return 0;
3050
+ h = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, (DWORD)pid);
3051
+ if (!h) {
3052
+ /* 打不开基本就是已经不在了(或不是我们有权碰的进程):按「已结束」上报,幂等 */
3053
+ fprintf(stderr, "[cj-bridge] kill pid %lld: OpenProcess 失败(%lu),按已退出处理\n", pid,
3054
+ (unsigned long)GetLastError());
3055
+ return 1;
3056
+ }
3057
+ if (!TerminateProcess(h, 0)) {
3058
+ fprintf(stderr, "[cj-bridge] kill pid %lld: TerminateProcess 失败(%lu)\n", pid,
3059
+ (unsigned long)GetLastError());
3060
+ CloseHandle(h);
3061
+ return 0;
3062
+ }
3063
+ wr = WaitForSingleObject(h, 3000);
3064
+ CloseHandle(h);
3065
+ if (wr == WAIT_TIMEOUT) {
3066
+ fprintf(stderr, "[cj-bridge] kill pid %lld: 3s 内没等到退出\n", pid);
3067
+ return 0;
3068
+ }
3069
+ return 1;
3070
+ }
3071
+
3072
+ int cj_plat_pid_alive(long long pid) {
3073
+ HANDLE h;
3074
+ DWORD code = 0;
3075
+
3076
+ if (pid <= 0) return 0;
3077
+ /* 打不开=已经不在了(或不是我们有权碰的进程):按「不在」上报,调用方据此重新拉起 */
3078
+ h = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, (DWORD)pid);
3079
+ if (!h) return 0;
3080
+ if (!GetExitCodeProcess(h, &code)) {
3081
+ CloseHandle(h);
3082
+ return 0;
3083
+ }
3084
+ CloseHandle(h);
3085
+ return (code == STILL_ACTIVE) ? 1 : 0;
3086
+ }
3087
+
3088
+ /* ===== 内嵌资源释放(cj_plat_extract_assets 的平台层)=====
3089
+ 单文件包(scripts/pack-win-single.sh)把随包的外部程序与它的依赖 DLL 作为 RT_RCDATA 资源嵌进
3090
+ exe,这里运行期取出来写盘——cjh 这种独立程序没法静态链接进来。
3091
+
3092
+ 表从哪来:打包器同时生成 `pack_assets_map.h`(资源 ID -> 释放出来的文件名),编桥时带
3093
+ `-DCJ_PACK_ASSETS_EMBEDDED` 才会包含它。普通构建(包括 native/build_win.bat 编的动态桥)
3094
+ 没有这个宏、也就没有表,函数如实返回 0 =「这个产物里没有内嵌资源」——不假装释放成功。
3095
+
3096
+ 为什么不是「按资源名枚举」:资源名只能用数字 ID,因为 llvm-rc 不认字符串资源名(实测
3097
+ `expected int or identifier, got "cjh.exe"`),而数字 ID 本身说不出文件名。
3098
+
3099
+ 幂等:目标已存在且大小与资源一致就跳过。几十 MB 的资源每次启动重写一遍是不可接受的。
3100
+ 写文件用宽字符 API(CreateFileW / GetFileAttributesExW),不碰 CRT 的新旧方言。 */
3101
+
3102
+ #ifdef CJ_PACK_ASSETS_EMBEDDED
3103
+ #include "pack_assets_map.h"
3104
+
3105
+ static int cj_asset_size_w(const wchar_t *path, long long *out) {
3106
+ WIN32_FILE_ATTRIBUTE_DATA fa;
3107
+
3108
+ if (!GetFileAttributesExW(path, GetFileExInfoStandard, &fa)) return 0;
3109
+ if (fa.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) return 0;
3110
+ *out = ((long long)fa.nFileSizeHigh << 32) | (long long)fa.nFileSizeLow;
3111
+ return 1;
3112
+ }
3113
+
3114
+ static int cj_asset_write_w(const wchar_t *path, const void *data, DWORD size) {
3115
+ HANDLE h;
3116
+ const BYTE *p = (const BYTE *)data;
3117
+ DWORD left = size;
3118
+ DWORD done = 0;
3119
+
3120
+ h = CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
3121
+ if (h == INVALID_HANDLE_VALUE) return 0;
3122
+ while (left > 0) {
3123
+ if (!WriteFile(h, p, left, &done, NULL) || done == 0) {
3124
+ CloseHandle(h);
3125
+ return 0;
3126
+ }
3127
+ p += done;
3128
+ left -= done;
3129
+ }
3130
+ CloseHandle(h);
3131
+ return 1;
3132
+ }
3133
+ #endif
3134
+
3135
+ int cj_plat_extract_assets(const char *dir, char *err, int err_len) {
3136
+ #ifdef CJ_PACK_ASSETS_EMBEDDED
3137
+ wchar_t *wdir;
3138
+ wchar_t dst[1024];
3139
+ size_t i;
3140
+ size_t count = sizeof(CJ_PACK_ASSETS) / sizeof(CJ_PACK_ASSETS[0]);
3141
+ int ready = 0;
3142
+
3143
+ if (err && err_len > 0) err[0] = '\0';
3144
+ if (!dir || !*dir) {
3145
+ serial_err(err, err_len, "extract assets: 目录为空");
3146
+ return -1;
3147
+ }
3148
+ wdir = utf8_to_wide(dir);
3149
+ if (!wdir) {
3150
+ serial_err(err, err_len, "extract assets: UTF-8 转 UTF-16 失败");
3151
+ return -1;
3152
+ }
3153
+ /* 已存在算成功;多级目录由调用方(仓颉侧)先建好——桥不该替应用决定目录层级 */
3154
+ CreateDirectoryW(wdir, NULL);
3155
+ for (i = 0; i < count; i++) {
3156
+ const char *name = CJ_PACK_ASSETS[i].name;
3157
+ HRSRC hres;
3158
+ HGLOBAL hg;
3159
+ DWORD size;
3160
+ const void *data;
3161
+ wchar_t *wname;
3162
+ long long have = 0;
3163
+
3164
+ if (!name || !*name) continue;
3165
+ /* 名字来自编译期生成的表、不是外部输入,这条只是兜底:不含分隔符就不会写到目录外 */
3166
+ if (strchr(name, '\\') || strchr(name, '/') || strchr(name, ':')) {
3167
+ serial_err(err, err_len, "资源名不合法(%s),已跳过", name);
3168
+ continue;
3169
+ }
3170
+ hres = FindResourceW(NULL, MAKEINTRESOURCEW(CJ_PACK_ASSETS[i].id), RT_RCDATA);
3171
+ if (!hres) {
3172
+ serial_err(err, err_len, "找不到内嵌资源(%s)", name);
3173
+ continue;
3174
+ }
3175
+ size = SizeofResource(NULL, hres);
3176
+ hg = LoadResource(NULL, hres);
3177
+ if (!hg) {
3178
+ serial_err(err, err_len, "LoadResource 失败(%s)", name);
3179
+ continue;
3180
+ }
3181
+ data = LockResource(hg);
3182
+ if (!data) {
3183
+ serial_err(err, err_len, "LockResource 失败(%s)", name);
3184
+ continue;
3185
+ }
3186
+ wname = utf8_to_wide(name);
3187
+ if (!wname) {
3188
+ serial_err(err, err_len, "文件名 UTF-8 转 UTF-16 失败(%s)", name);
3189
+ continue;
3190
+ }
3191
+ if (wcslen(wdir) + 1 + wcslen(wname) + 1 > (sizeof(dst) / sizeof(dst[0]))) {
3192
+ serial_err(err, err_len, "目标路径过长(%s)", name);
3193
+ free(wname);
3194
+ continue;
3195
+ }
3196
+ lstrcpyW(dst, wdir);
3197
+ lstrcatW(dst, L"\\");
3198
+ lstrcatW(dst, wname);
3199
+ free(wname);
3200
+ if (cj_asset_size_w(dst, &have) && have == (long long)size) {
3201
+ ready++; /* 已经在盘上且大小一致:不重写 */
3202
+ continue;
3203
+ }
3204
+ if (!cj_asset_write_w(dst, data, size)) {
3205
+ serial_err(err, err_len, "写盘失败(%s,GetLastError=%lu)", name,
3206
+ (unsigned long)GetLastError());
3207
+ continue;
3208
+ }
3209
+ ready++;
3210
+ }
3211
+ free(wdir);
3212
+ return ready;
3213
+ #else
3214
+ (void)dir;
3215
+ (void)err;
3216
+ (void)err_len;
3217
+ return 0; /* 普通构建没有内嵌资源——如实回报,调用方据此走 vendor 目录 */
3218
+ #endif
3219
+ }
3220
+
3221
+ /* ===== 动态库预加载(cj_plat_preload_library 的平台层)=====
3222
+ 一句话:按**绝对路径**先 LoadLibraryW 一次,之后运行时按裸名去找同名 DLL 时就会命中
3223
+ 已加载的那个模块。为什么要这么绕,见 bridge_core.h 同名段落(2026-10-21:单文件包在
3224
+ 别人的机器上连明文 ws:// 都挂在 `Can not load openssl library or function RAND_bytes.`)。 */
3225
+ int cj_plat_preload_library(const char *path, char *err, int err_len) {
3226
+ wchar_t *wpath;
3227
+ HMODULE h;
3228
+
3229
+ if (err && err_len > 0) err[0] = '\0';
3230
+ if (!path || !*path) {
3231
+ serial_err(err, err_len, "preload: 路径为空");
3232
+ return 0;
3233
+ }
3234
+ wpath = utf8_to_wide(path);
3235
+ if (!wpath) {
3236
+ serial_err(err, err_len, "preload: UTF-8 转 UTF-16 失败");
3237
+ return 0;
3238
+ }
3239
+ h = LoadLibraryW(wpath);
3240
+ free(wpath);
3241
+ if (!h) {
3242
+ serial_err(err, err_len, "LoadLibraryW 失败(GetLastError=%lu)",
3243
+ (unsigned long)GetLastError());
3244
+ return 0;
3245
+ }
3246
+ return 1;
3247
+ }
3248
+