@forgeax/interface 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/dist/package/browser/ApplicationShell.js +3 -2
- package/dist/package/browser/appHostBootstrap.js +10 -2
- package/dist/package/browser/application.d.ts +0 -8
- package/dist/package/browser/application.js +0 -9
- package/dist/package/browser/components/MenuBar/MenuBar.d.ts +3 -2
- package/dist/package/browser/components/MenuBar/MenuBar.js +9 -33
- package/dist/package/browser/components/MenuBar/menubar-surface.d.ts +3 -2
- package/dist/package/browser/components/MenuBar/menubar-surface.js +16 -13
- package/dist/package/browser/core/app-shell/host.js +7 -2
- package/dist/package/browser/core/app-shell/types.d.ts +5 -1
- package/dist/package/browser/core/extensions/builtin-commands.js +1 -0
- package/dist/package/browser/core/extensions/builtin-menus.js +23 -3
- package/dist/package/browser/i18n/locales/en.json +0 -10
- package/dist/package/browser/i18n/locales/zh.json +0 -10
- package/dist/package/browser/lib/application-menu-projection.d.ts +5 -0
- package/dist/package/browser/lib/application-menu-projection.js +53 -0
- package/dist/package/browser/lib/global-shortcuts.d.ts +5 -74
- package/dist/package/browser/lib/global-shortcuts.js +29 -228
- package/dist/package/browser/lib/menu-registry.d.ts +0 -49
- package/dist/package/browser/lib/menu-registry.js +3 -197
- package/dist/package/browser/lib/menu-registry.test-utils.d.ts +11 -0
- package/dist/package/browser/lib/menu-registry.test-utils.js +28 -0
- package/dist/package/browser/lib/native-menu-bridge.d.ts +17 -3
- package/dist/package/browser/lib/native-menu-bridge.js +121 -81
- package/dist/package/node/ApplicationShell.js +3 -2
- package/dist/package/node/appHostBootstrap.js +10 -2
- package/dist/package/node/application.js +0 -9
- package/dist/package/node/components/MenuBar/MenuBar.js +9 -33
- package/dist/package/node/components/MenuBar/menubar-surface.js +16 -13
- package/dist/package/node/core/app-shell/host.js +7 -2
- package/dist/package/node/core/extensions/builtin-commands.js +1 -0
- package/dist/package/node/core/extensions/builtin-menus.js +23 -3
- package/dist/package/node/i18n/locales/en.json +0 -10
- package/dist/package/node/i18n/locales/zh.json +0 -10
- package/dist/package/node/lib/application-menu-projection.js +53 -0
- package/dist/package/node/lib/global-shortcuts.js +29 -228
- package/dist/package/node/lib/menu-registry.js +3 -197
- package/dist/package/node/lib/menu-registry.test-utils.js +28 -0
- package/dist/package/node/lib/native-menu-bridge.js +121 -81
- package/package.json +2 -2
- package/dist/package/browser/core/extensions/editor-commands.d.ts +0 -2
- package/dist/package/browser/core/extensions/editor-commands.js +0 -182
- package/dist/package/browser/lib/repair-builtin-menus.d.ts +0 -6
- package/dist/package/browser/lib/repair-builtin-menus.js +0 -18
- package/dist/package/node/core/extensions/editor-commands.js +0 -182
- package/dist/package/node/lib/repair-builtin-menus.js +0 -18
|
@@ -1,197 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* 1. Web 端渲染器 (React) —— 通过 `snapshotMenu(...)` / `snapshotAllMenus()` + `onMenuChange`
|
|
5
|
-
* 走 useSyncExternalStore 拿到当前菜单结构;
|
|
6
|
-
* 2. Tauri 原生菜单桥接 —— 通过 `serializeMenusForNative(t)` 拿到已翻译、去函数、
|
|
7
|
-
* JSON 可序列化的原生菜单契约,一次性 build native menu / 每次注册表变化时 rebuild.
|
|
8
|
-
*
|
|
9
|
-
* 设计与 action-registry.ts 对齐 (模块级 Map 页级单例 + change listener):
|
|
10
|
-
* - 菜单项按 `menu`(顶层大类) + `group`(段落分组) + `order`(段内序) 三级组织;
|
|
11
|
-
* - 同 id 重复注册 = **替换** (幂等,HMR / StrictMode 双挂载安全,cleanup
|
|
12
|
-
* 走身份检查避免误删);
|
|
13
|
-
* - 键位串遵循全仓 canonical combo 格式 (见 lib/global-shortcuts.ts `prettyCombo`),
|
|
14
|
-
* 例:'Ctrl+S' / 'Ctrl+Shift+Z' / 'Ctrl+,'。Web 显示走 prettyCombo,原生菜单走
|
|
15
|
-
* `serializeMenusForNative` 内部的 accelerator 转换 (Ctrl→CmdOrCtrl,保留 Shift/Alt)。
|
|
16
|
-
*
|
|
17
|
-
* 分隔符策略 (评审):**注册者不显式发 separator sentinel** —— 只填 `group`,
|
|
18
|
-
* 渲染器按 `snapshotMenu` 返回结果中相邻两项的 `group` 差异自行绘制分隔线;
|
|
19
|
-
* 原生序列化则在 `serializeMenusForNative` 中把段边界翻成 `separatorBefore=true`。
|
|
20
|
-
* 这样避免维护「三种分隔来源」(注册者手插 + 渲染器 + 原生桥)—— 一处派生 (§2 Derive)。
|
|
21
|
-
*/
|
|
22
|
-
// ─── 注册表本体 (模块级 Map,页级单例) ────────────────────────────────────────
|
|
23
|
-
const items = new Map();
|
|
24
|
-
const changeListeners = new Set();
|
|
25
|
-
function notifyChange() {
|
|
26
|
-
for (const cb of changeListeners) {
|
|
27
|
-
try {
|
|
28
|
-
cb();
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
/* listener 异常不传染 */
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
/** 注册一项菜单 —— 同 id 重复注册 = 替换 (幂等,HMR / StrictMode 双挂载安全)。
|
|
36
|
-
* 返回 cleanup 函数;cleanup 幂等 (身份检查:仅当当前项仍是本次注册的 def
|
|
37
|
-
* 才 delete,避免 A 注册→A 注销 后误删掉 B 的 same-id 覆盖)。
|
|
38
|
-
*
|
|
39
|
-
* 设计变更 (T7 集成):初版为 throw-on-duplicate,与 action-registry 的
|
|
40
|
-
* 「替换即幂等」策略不一致 —— React StrictMode 会双执行 App.tsx 的
|
|
41
|
-
* bootstrapAppHost useEffect,两次 setup 并发跑到同一个模块级 Map 时必抛
|
|
42
|
-
* 「duplicate menu item id」把整个菜单栏打空。改为 last-writer-wins 后
|
|
43
|
-
* 两次 setup 都能顺利完成,先释放的 cleanup 通过身份检查不会误删,
|
|
44
|
-
* 与 action-registry 的语义对齐 (§SSOT / 兄弟注册表一致性)。 */
|
|
45
|
-
export function registerMenuItem(def) {
|
|
46
|
-
items.set(def.id, def);
|
|
47
|
-
notifyChange();
|
|
48
|
-
return () => {
|
|
49
|
-
if (items.get(def.id) === def) {
|
|
50
|
-
items.delete(def.id);
|
|
51
|
-
notifyChange();
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
/** 注册表变更订阅 —— 供 useSyncExternalStore / native bridge rebuild 消费。 */
|
|
56
|
-
export function onMenuChange(listener) {
|
|
57
|
-
changeListeners.add(listener);
|
|
58
|
-
return () => {
|
|
59
|
-
changeListeners.delete(listener);
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
// ─── 派生视图:排序与快照 (SSOT 是 items Map;所有视图都是纯 derive) ─────────
|
|
63
|
-
/** 排序规则:先按 group 的**首次出现顺序**分段,段内按 order 升序 (相同 order 用
|
|
64
|
-
* 插入顺序破平局,稳定排序)。JS `Array.prototype.sort` 从 ES2019 起要求稳定,
|
|
65
|
-
* Bun/V8/JSC 均已合规,直接用即可。 */
|
|
66
|
-
function sortMenuItems(list) {
|
|
67
|
-
const groupFirstIndex = new Map();
|
|
68
|
-
list.forEach((item, idx) => {
|
|
69
|
-
if (!groupFirstIndex.has(item.group))
|
|
70
|
-
groupFirstIndex.set(item.group, idx);
|
|
71
|
-
});
|
|
72
|
-
const decorated = list.map((item, idx) => ({ item, idx }));
|
|
73
|
-
decorated.sort((a, b) => {
|
|
74
|
-
const ga = groupFirstIndex.get(a.item.group);
|
|
75
|
-
const gb = groupFirstIndex.get(b.item.group);
|
|
76
|
-
if (ga !== gb)
|
|
77
|
-
return ga - gb;
|
|
78
|
-
if (a.item.order !== b.item.order)
|
|
79
|
-
return a.item.order - b.item.order;
|
|
80
|
-
return a.idx - b.idx; // 稳定破平局:插入顺序
|
|
81
|
-
});
|
|
82
|
-
return decorated.map((d) => d.item);
|
|
83
|
-
}
|
|
84
|
-
/** 取单个菜单的排序后条目 —— 渲染器直接消费。 */
|
|
85
|
-
export function snapshotMenu(menu) {
|
|
86
|
-
const list = [];
|
|
87
|
-
for (const def of items.values()) {
|
|
88
|
-
if (def.menu === menu)
|
|
89
|
-
list.push(def);
|
|
90
|
-
}
|
|
91
|
-
return sortMenuItems(list);
|
|
92
|
-
}
|
|
93
|
-
const ALL_MENUS = ['brand', 'file', 'edit', 'window', 'build', 'select', 'help', 'publish'];
|
|
94
|
-
/** 取所有菜单的排序后条目,按 MenuId 组织。 */
|
|
95
|
-
export function snapshotAllMenus() {
|
|
96
|
-
const out = {};
|
|
97
|
-
for (const m of ALL_MENUS)
|
|
98
|
-
out[m] = snapshotMenu(m);
|
|
99
|
-
return out;
|
|
100
|
-
}
|
|
101
|
-
/** 键位串 → 原生 accelerator 转换。canonical 形如 'Ctrl+Shift+S';原生形 'CmdOrCtrl+Shift+S'
|
|
102
|
-
* (Tauri/Electron 通用惯例:Ctrl 走 CmdOrCtrl,让 macOS 走 ⌘、其它平台走 Ctrl)。 */
|
|
103
|
-
function toNativeAccelerator(combo) {
|
|
104
|
-
return combo
|
|
105
|
-
.split('+')
|
|
106
|
-
.map((seg) => (seg === 'Ctrl' ? 'CmdOrCtrl' : seg))
|
|
107
|
-
.join('+');
|
|
108
|
-
}
|
|
109
|
-
/** 递归序列化一项 (含 children) —— 返回值必须完全无函数,可直接 JSON.stringify。 */
|
|
110
|
-
function serializeItem(def, translate) {
|
|
111
|
-
if (def.when && !def.when())
|
|
112
|
-
return null; // 隐藏项直接 drop
|
|
113
|
-
// Static `children` OR a `dynamicChildren` derivation — both serialize the
|
|
114
|
-
// same way. dynamicChildren is evaluated at serialize time (native menu is
|
|
115
|
-
// rebuilt on every registry change, so it re-derives with the cache current
|
|
116
|
-
// at that moment). Web + native thus share one derivation (§Derive / §SSOT).
|
|
117
|
-
const childDefs = def.children && def.children.length > 0
|
|
118
|
-
? def.children
|
|
119
|
-
: def.dynamicChildren
|
|
120
|
-
? def.dynamicChildren()
|
|
121
|
-
: null;
|
|
122
|
-
// enabled: explicit predicate wins; a submenu opener (has children) defaults
|
|
123
|
-
// enabled even without a command; else `!!commandId`. Mirrors the web
|
|
124
|
-
// renderer's resolveEnabled so both bars agree on which items are clickable.
|
|
125
|
-
const enabledResolved = def.enabled
|
|
126
|
-
? def.enabled()
|
|
127
|
-
: (childDefs && childDefs.length > 0)
|
|
128
|
-
? true
|
|
129
|
-
: !!def.commandId;
|
|
130
|
-
const out = {
|
|
131
|
-
id: def.id,
|
|
132
|
-
label: translate(def.labelKey),
|
|
133
|
-
enabled: enabledResolved,
|
|
134
|
-
};
|
|
135
|
-
if (def.keybinding)
|
|
136
|
-
out.accelerator = toNativeAccelerator(def.keybinding);
|
|
137
|
-
if (def.danger)
|
|
138
|
-
out.danger = true;
|
|
139
|
-
if (childDefs && childDefs.length > 0) {
|
|
140
|
-
const kids = [];
|
|
141
|
-
for (const child of childDefs) {
|
|
142
|
-
const ser = serializeItem(child, translate);
|
|
143
|
-
if (ser)
|
|
144
|
-
kids.push(ser);
|
|
145
|
-
}
|
|
146
|
-
if (kids.length > 0)
|
|
147
|
-
out.children = kids;
|
|
148
|
-
}
|
|
149
|
-
return out;
|
|
150
|
-
}
|
|
151
|
-
/** 序列化全部菜单为原生契约 —— T5 (Tauri 桥) 的唯一入口。契约要点:
|
|
152
|
-
* - `when === false` 的项被 drop;
|
|
153
|
-
* - `enabled` 缺省时默认 = `!!commandId` (无命令 = 禁用);
|
|
154
|
-
* - `labelKey` 走 `translate` 翻译,函数不出墙;
|
|
155
|
-
* - `keybinding` 转 native accelerator (Ctrl→CmdOrCtrl,Shift/Alt 保留);
|
|
156
|
-
* - 段边界翻成 `separatorBefore=true` (每个菜单首项除外);
|
|
157
|
-
* - 输出**完全 JSON 可序列化** (JSON.stringify 无损 roundtrip,无函数残留)。 */
|
|
158
|
-
export function serializeMenusForNative(translate) {
|
|
159
|
-
const out = [];
|
|
160
|
-
for (const menu of ALL_MENUS) {
|
|
161
|
-
const sorted = snapshotMenu(menu);
|
|
162
|
-
const serialized = [];
|
|
163
|
-
let prevGroup = null;
|
|
164
|
-
for (const def of sorted) {
|
|
165
|
-
const ser = serializeItem(def, translate);
|
|
166
|
-
if (!ser) {
|
|
167
|
-
// when=false 的项被 drop;下一项若换 group 依然按已见 group 判段边界。
|
|
168
|
-
prevGroup = def.group;
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
if (prevGroup !== null && def.group !== prevGroup)
|
|
172
|
-
ser.separatorBefore = true;
|
|
173
|
-
serialized.push(ser);
|
|
174
|
-
prevGroup = def.group;
|
|
175
|
-
}
|
|
176
|
-
out.push({ menu, items: serialized });
|
|
177
|
-
}
|
|
178
|
-
return out;
|
|
179
|
-
}
|
|
180
|
-
// ─── Vite HMR: preserve registrations across hot module swap ───────────────
|
|
181
|
-
if (import.meta.hot) {
|
|
182
|
-
import.meta.hot.dispose((data) => {
|
|
183
|
-
data.menuItems = [...items.entries()];
|
|
184
|
-
});
|
|
185
|
-
const saved = import.meta.hot.data.menuItems;
|
|
186
|
-
if (saved) {
|
|
187
|
-
for (const [id, def] of saved)
|
|
188
|
-
items.set(id, def);
|
|
189
|
-
notifyChange();
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
// ─── 测试辅助 (生产代码不要调) ─────────────────────────────────────────────
|
|
193
|
-
/** 清空注册表 —— 仅供单测 beforeEach 使用。 */
|
|
194
|
-
export function __resetMenuRegistryForTest() {
|
|
195
|
-
items.clear();
|
|
196
|
-
changeListeners.clear();
|
|
197
|
-
}
|
|
1
|
+
// Interface-owned built-in menu descriptors and native wire types.
|
|
2
|
+
// Runtime registration, ordering, subscriptions and disposal belong to host.menus.
|
|
3
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ApplicationMenuItem } from '@forgeax/app-shell/application';
|
|
2
|
+
import type { MenuId, MenuItemDef } from './menu-registry';
|
|
3
|
+
export type { MenuItemDef } from './menu-registry';
|
|
4
|
+
export declare const getTestMenus: () => import("@forgeax/app-shell/application").ApplicationMenuRegistry;
|
|
5
|
+
export declare const setTestMenuTranslator: (next: (key: string) => string) => void;
|
|
6
|
+
export declare const registerMenuItem: (def: MenuItemDef) => () => void;
|
|
7
|
+
export declare const onMenuChange: (listener: () => void) => () => void;
|
|
8
|
+
export declare const snapshotMenu: (menu: MenuId) => MenuItemDef[];
|
|
9
|
+
export declare const snapshotAllMenus: () => Record<MenuId, readonly ApplicationMenuItem[]>;
|
|
10
|
+
export declare function serializeMenusForNative(t: (key: string) => string): import("./menu-registry").NativeMenu[];
|
|
11
|
+
export declare function __resetMenuRegistryForTest(): void;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Test data adapter only. Production readers receive their host registry.
|
|
2
|
+
import { createApplicationMenuRegistry } from '@forgeax/app-shell/application';
|
|
3
|
+
import { projectApplicationMenus, serializeApplicationMenusForNative } from './application-menu-projection';
|
|
4
|
+
let menus = createApplicationMenuRegistry();
|
|
5
|
+
let translate = (key) => key;
|
|
6
|
+
export const getTestMenus = () => menus;
|
|
7
|
+
export const setTestMenuTranslator = (next) => { translate = next; };
|
|
8
|
+
function contribution(def) {
|
|
9
|
+
return {
|
|
10
|
+
...def, groupOrder: 0,
|
|
11
|
+
get label() { return translate(def.labelKey); },
|
|
12
|
+
children: def.children?.map(contribution),
|
|
13
|
+
dynamicChildren: def.dynamicChildren ? () => def.dynamicChildren().map(contribution) : undefined,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export const registerMenuItem = (def) => menus.register(contribution(def));
|
|
17
|
+
export const onMenuChange = (listener) => menus.subscribe(listener);
|
|
18
|
+
export const snapshotMenu = (menu) => menus.snapshot(menu);
|
|
19
|
+
export const snapshotAllMenus = () => projectApplicationMenus(menus.snapshot());
|
|
20
|
+
export function serializeMenusForNative(t) {
|
|
21
|
+
translate = t;
|
|
22
|
+
return serializeApplicationMenusForNative(menus.snapshot());
|
|
23
|
+
}
|
|
24
|
+
export function __resetMenuRegistryForTest() {
|
|
25
|
+
menus.dispose();
|
|
26
|
+
menus = createApplicationMenuRegistry();
|
|
27
|
+
translate = key => key;
|
|
28
|
+
}
|
|
@@ -17,9 +17,11 @@
|
|
|
17
17
|
* ⚠️ Rust 侧收到的 payload 结构与 menu-registry.ts 的 `NativeMenu` 一致,
|
|
18
18
|
* 但额外带一个可选 `title` —— 我们在这里补上,契约扩展在桥内闭环,不动 T1。
|
|
19
19
|
*/
|
|
20
|
+
import type { ApplicationMenuRegistry } from '@forgeax/app-shell/application';
|
|
20
21
|
/** 派发接口:与 host.commands.execute 兼容,fire-and-forget。 */
|
|
21
22
|
export type MenuExecute = (id: string, args?: unknown) => void | Promise<unknown>;
|
|
22
23
|
export interface InitNativeMenuBridgeOptions {
|
|
24
|
+
menus: ApplicationMenuRegistry;
|
|
23
25
|
/** 命令派发器 —— 通常是 (id, args) => host.commands.execute(id, args)。 */
|
|
24
26
|
execute: MenuExecute;
|
|
25
27
|
/** i18n 翻译器 —— 通常是 useTranslation().t。 */
|
|
@@ -32,6 +34,18 @@ export interface InitNativeMenuBridgeOptions {
|
|
|
32
34
|
* 导出给链路下游(命令落地后的 UI 环节)复用,让"事件没到"与"命令跑了但界面
|
|
33
35
|
* 没出来"在同一个流里可区分。sink 未装上前只进 console。 */
|
|
34
36
|
export declare function fxTrace(line: string): void;
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
/** The existing Tauri menu endpoint, injectable for lifecycle contract tests. */
|
|
38
|
+
export interface NativeMenuTransport {
|
|
39
|
+
invoke(command: string, args: Record<string, unknown>): Promise<unknown>;
|
|
40
|
+
listen(listener: (id: string) => void): Promise<() => void>;
|
|
41
|
+
}
|
|
42
|
+
export interface NativeMenuBridgeDependencies {
|
|
43
|
+
loadTransport(): Promise<NativeMenuTransport>;
|
|
44
|
+
warmRecentGames(): Promise<void>;
|
|
45
|
+
subscribeLocale(listener: () => void): () => void;
|
|
46
|
+
reportError(error: unknown): void;
|
|
47
|
+
}
|
|
48
|
+
/** One mounted shell owns all native subscriptions, including asynchronous setup. */
|
|
49
|
+
export declare function installNativeMenuBridge(opts: InitNativeMenuBridgeOptions, deps: NativeMenuBridgeDependencies): () => void;
|
|
50
|
+
/** Browser shells install nothing. React effect cleanup also fences late imports. */
|
|
51
|
+
export declare function initNativeMenuBridge(opts: InitNativeMenuBridgeOptions): () => void;
|
|
@@ -19,13 +19,14 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { isTauri } from './platform/runtime';
|
|
21
21
|
import { warmRecentGames } from './recent-games';
|
|
22
|
-
import {
|
|
22
|
+
import { subscribeLocale } from '../i18n';
|
|
23
|
+
import { projectApplicationMenus, serializeApplicationMenusForNative } from './application-menu-projection';
|
|
23
24
|
// ─── Menu id → command lookup (原生点击回吐用) ─────────────────────────────
|
|
24
25
|
/** 展平所有菜单项 (含 children),建 id → def 的索引,供 menu:invoke 回吐时 O(1)
|
|
25
26
|
* 查表。不缓存:每次点击都 fresh 一遍,让 when/enabled 变化立刻生效;菜单量级
|
|
26
27
|
* 几十项,遍历开销可忽略。 */
|
|
27
|
-
function findMenuItemById(id) {
|
|
28
|
-
const all =
|
|
28
|
+
function findMenuItemById(id, menus) {
|
|
29
|
+
const all = projectApplicationMenus(menus.snapshot());
|
|
29
30
|
for (const menuId of Object.keys(all)) {
|
|
30
31
|
const found = findInList(all[menuId], id);
|
|
31
32
|
if (found)
|
|
@@ -35,6 +36,8 @@ function findMenuItemById(id) {
|
|
|
35
36
|
}
|
|
36
37
|
function findInList(list, id) {
|
|
37
38
|
for (const item of list) {
|
|
39
|
+
if ((item.when && !item.when()) || (item.enabled && !item.enabled()))
|
|
40
|
+
continue;
|
|
38
41
|
if (item.id === id)
|
|
39
42
|
return item;
|
|
40
43
|
// Recurse into static children AND dynamic ones — a native click on a
|
|
@@ -67,85 +70,122 @@ export function fxTrace(line) {
|
|
|
67
70
|
console.debug('[fx-trace]', line);
|
|
68
71
|
traceSink?.(line);
|
|
69
72
|
}
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
// current list. Web warms on File-dropdown open; native has no such hook, so
|
|
78
|
-
// we warm here before every rebuild. Failures leave the last cache intact.
|
|
79
|
-
trace('push: warmRecentGames…');
|
|
80
|
-
await warmRecentGames();
|
|
81
|
-
trace('push: warmRecentGames done, serializing…');
|
|
82
|
-
const raw = serializeMenusForNative(translate);
|
|
83
|
-
// 补顶层 title —— 与 MenuBar.tsx 的 `t('menubar.${menu}')` 保持一致。
|
|
84
|
-
const payload = raw.map((m) => ({
|
|
85
|
-
...m,
|
|
86
|
-
title: translate(`menubar.${m.menu}`),
|
|
87
|
-
}));
|
|
88
|
-
const fileIds = raw.find((m) => m.menu === 'file')?.items.map((i) => i.id) ?? [];
|
|
89
|
-
trace(`push: serialized menus=${payload.length} file.items=[${fileIds.join(',')}]`);
|
|
90
|
-
try {
|
|
91
|
-
await invoke('set_app_menu', { payload });
|
|
92
|
-
trace('push: set_app_menu resolved');
|
|
93
|
-
}
|
|
94
|
-
catch (err) {
|
|
95
|
-
trace(`push: set_app_menu REJECTED ${err?.message ?? String(err)}`);
|
|
96
|
-
console.warn('[native-menu-bridge] set_app_menu failed:', err?.message ?? err);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
// ─── Init (唯一对外入口) ────────────────────────────────────────────────
|
|
100
|
-
/** 幂等标记:StrictMode 双 invoke / boot 双路径都可能重入 init,原生只该被安装一次。
|
|
101
|
-
* 一旦装上:后续调用直接返回。 */
|
|
102
|
-
let installed = false;
|
|
103
|
-
/** Public entry —— App.tsx 在 boot 完成后调用一次。返回 Promise 让调用方能
|
|
104
|
-
* await (不必须);浏览器形态立刻 resolve()。 */
|
|
105
|
-
export async function initNativeMenuBridge(opts) {
|
|
106
|
-
if (!isTauri())
|
|
107
|
-
return; // web 形态 no-op
|
|
108
|
-
if (installed)
|
|
109
|
-
return;
|
|
110
|
-
installed = true;
|
|
111
|
-
const { translate } = opts;
|
|
112
|
-
// 懒加载 Tauri API —— 与 runtime.ts 的其它加载器同风格,避免把 chunk 塞进 web bundle。
|
|
113
|
-
const [{ invoke }, eventMod] = await Promise.all([
|
|
114
|
-
import('@tauri-apps/api/core'),
|
|
115
|
-
import('@tauri-apps/api/event'),
|
|
116
|
-
]);
|
|
117
|
-
traceSink = (line) => { void invoke('fx_trace', { line }).catch(() => { }); };
|
|
118
|
-
trace('init: tauri api loaded, starting first push…');
|
|
119
|
-
// 1. 原生点击回吐 —— Rust 侧 on_menu_event emit 'menu:invoke' { id },
|
|
120
|
-
// 我们查注册表拿到 commandId + args,派发到 command bus。
|
|
121
|
-
// 先注册监听器再做首次 push:首次 push 会预热 listGames,若接口卡住,
|
|
122
|
-
// 旧菜单仍能响应点击,不会出现"菜单显示但点击无反应"的窗口。
|
|
123
|
-
await eventMod.listen('menu:invoke', (ev) => {
|
|
124
|
-
const id = ev.payload?.id;
|
|
125
|
-
trace(`recv: menu:invoke id=${String(id)}`);
|
|
126
|
-
if (!id)
|
|
127
|
-
return;
|
|
128
|
-
const def = findMenuItemById(id);
|
|
129
|
-
if (!def) {
|
|
130
|
-
// 原生菜单栏比 web 快照晚一拍时可能出现;下次 push 会对齐。
|
|
131
|
-
trace(`recv: id=${id} NOT FOUND in registry — dropped`);
|
|
132
|
-
console.warn('[native-menu-bridge] menu:invoke for unknown id:', id);
|
|
133
|
-
return;
|
|
73
|
+
/** One mounted shell owns all native subscriptions, including asynchronous setup. */
|
|
74
|
+
export function installNativeMenuBridge(opts, deps) {
|
|
75
|
+
let disposed = false;
|
|
76
|
+
const cleanups = [];
|
|
77
|
+
const report = (error) => {
|
|
78
|
+
try {
|
|
79
|
+
deps.reportError(error);
|
|
134
80
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
81
|
+
catch { /* Reporting must not prevent cleanup. */ }
|
|
82
|
+
};
|
|
83
|
+
const safely = (cleanup) => {
|
|
84
|
+
try {
|
|
85
|
+
cleanup();
|
|
138
86
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
87
|
+
catch (error) {
|
|
88
|
+
report(error);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const retain = (cleanup) => {
|
|
92
|
+
if (disposed)
|
|
93
|
+
safely(cleanup);
|
|
94
|
+
else
|
|
95
|
+
cleanups.push(cleanup);
|
|
96
|
+
};
|
|
97
|
+
const dispose = () => {
|
|
98
|
+
if (disposed)
|
|
99
|
+
return;
|
|
100
|
+
disposed = true;
|
|
101
|
+
for (const cleanup of cleanups.splice(0).reverse())
|
|
102
|
+
safely(cleanup);
|
|
103
|
+
};
|
|
104
|
+
void (async () => {
|
|
105
|
+
const transport = await deps.loadTransport();
|
|
106
|
+
if (disposed)
|
|
107
|
+
return;
|
|
108
|
+
const sink = (line) => {
|
|
109
|
+
if (!disposed)
|
|
110
|
+
void transport.invoke('fx_trace', { line }).catch(() => { });
|
|
111
|
+
};
|
|
112
|
+
traceSink = sink;
|
|
113
|
+
retain(() => { if (traceSink === sink)
|
|
114
|
+
traceSink = null; });
|
|
115
|
+
// Subscribe before warming so existing native items remain responsive.
|
|
116
|
+
const off = await transport.listen(id => {
|
|
117
|
+
if (disposed || !id)
|
|
118
|
+
return;
|
|
119
|
+
try {
|
|
120
|
+
const item = findMenuItemById(id, opts.menus);
|
|
121
|
+
if (!item?.commandId)
|
|
122
|
+
return;
|
|
123
|
+
void Promise.resolve(opts.execute(item.commandId, item.args)).catch(report);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
report(error);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
retain(off);
|
|
130
|
+
if (disposed)
|
|
131
|
+
return;
|
|
132
|
+
// Serialize pushes: a slower old update must not overwrite a newer locale
|
|
133
|
+
// or host snapshot. Events arriving during warming are read in that push.
|
|
134
|
+
let dirty = false;
|
|
135
|
+
let running = false;
|
|
136
|
+
const refresh = () => {
|
|
137
|
+
if (disposed)
|
|
138
|
+
return;
|
|
139
|
+
dirty = true;
|
|
140
|
+
if (running)
|
|
141
|
+
return;
|
|
142
|
+
running = true;
|
|
143
|
+
void (async () => {
|
|
144
|
+
try {
|
|
145
|
+
while (!disposed && dirty) {
|
|
146
|
+
dirty = false;
|
|
147
|
+
try {
|
|
148
|
+
await deps.warmRecentGames();
|
|
149
|
+
if (disposed)
|
|
150
|
+
return;
|
|
151
|
+
dirty = false;
|
|
152
|
+
const payload = serializeApplicationMenusForNative(opts.menus.snapshot())
|
|
153
|
+
.map(menu => ({ ...menu, title: opts.translate(`menubar.${menu.menu}`) }));
|
|
154
|
+
await transport.invoke('set_app_menu', { payload });
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
report(error);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
running = false;
|
|
163
|
+
}
|
|
164
|
+
})();
|
|
165
|
+
};
|
|
166
|
+
retain(opts.menus.subscribe(refresh));
|
|
167
|
+
retain(deps.subscribeLocale(refresh));
|
|
168
|
+
refresh();
|
|
169
|
+
})().catch(error => { report(error); dispose(); });
|
|
170
|
+
return dispose;
|
|
171
|
+
}
|
|
172
|
+
/** Browser shells install nothing. React effect cleanup also fences late imports. */
|
|
173
|
+
export function initNativeMenuBridge(opts) {
|
|
174
|
+
if (!isTauri())
|
|
175
|
+
return () => { };
|
|
176
|
+
return installNativeMenuBridge(opts, {
|
|
177
|
+
async loadTransport() {
|
|
178
|
+
const [core, events] = await Promise.all([
|
|
179
|
+
import('@tauri-apps/api/core'),
|
|
180
|
+
import('@tauri-apps/api/event'),
|
|
181
|
+
]);
|
|
182
|
+
return {
|
|
183
|
+
invoke: (command, args) => core.invoke(command, args),
|
|
184
|
+
listen: listener => events.listen('menu:invoke', event => listener(event.payload?.id)),
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
warmRecentGames,
|
|
188
|
+
subscribeLocale: listener => subscribeLocale(listener),
|
|
189
|
+
reportError: error => console.warn('[native-menu-bridge]', error),
|
|
147
190
|
});
|
|
148
|
-
// 3. 首次推送 —— 让原生菜单栏与当前注册表对齐。
|
|
149
|
-
await pushMenusToNative(invoke, translate);
|
|
150
|
-
trace('init: first push returned');
|
|
151
191
|
}
|
|
@@ -25,7 +25,7 @@ import { isTauri } from './lib/platform/runtime.js';
|
|
|
25
25
|
|
|
26
26
|
export { ApplicationDetachedShell } from './ApplicationDetachedShell.js';
|
|
27
27
|
function KeyboardRouter({ runtime }) {
|
|
28
|
-
useGlobalShortcuts(runtime.host.keybindings);
|
|
28
|
+
useGlobalShortcuts(runtime.host.keybindings, runtime.host.shortcuts);
|
|
29
29
|
return null;
|
|
30
30
|
}
|
|
31
31
|
export function isApplicationOnboardingEnabled(onboarding) {
|
|
@@ -46,7 +46,8 @@ export function ApplicationShell({ runtime, onboarding }) {
|
|
|
46
46
|
useEffect(() => {
|
|
47
47
|
if (!isTauri())
|
|
48
48
|
return;
|
|
49
|
-
|
|
49
|
+
return initNativeMenuBridge({
|
|
50
|
+
menus: runtime.host.menus,
|
|
50
51
|
execute: (id, args) => runtime.host.commands.execute(id, args),
|
|
51
52
|
translate: t,
|
|
52
53
|
});
|
|
@@ -21,7 +21,6 @@ import { foundationStorageExtension } from './core/extensions/foundation-storage
|
|
|
21
21
|
import { builtinCommandsExtension } from './core/extensions/builtin-commands.js';
|
|
22
22
|
import { hostCommandsExtension } from './core/extensions/host-commands.js';
|
|
23
23
|
import { builtinMenusExtension } from './core/extensions/builtin-menus.js';
|
|
24
|
-
import { editorCommandsExtension } from './core/extensions/editor-commands.js';
|
|
25
24
|
import { panelsViewportExtension } from './core/extensions/panels-viewport.js';
|
|
26
25
|
import { panelsChatExtension } from './core/extensions/panels-chat.js';
|
|
27
26
|
import { chromeStatusBarExtension } from './core/extensions/chrome-statusbar.js';
|
|
@@ -62,9 +61,14 @@ export async function bootstrapAppHost(overrides = {}) {
|
|
|
62
61
|
...m,
|
|
63
62
|
async setup(ctx) {
|
|
64
63
|
control.beginSetup(m);
|
|
64
|
+
const menuCleanups = [];
|
|
65
65
|
try {
|
|
66
66
|
const cleanups = [];
|
|
67
67
|
let pageCleanup;
|
|
68
|
+
for (const menu of m.contributes?.menus ?? []) {
|
|
69
|
+
menuCleanups.push(host.menus.register(menu));
|
|
70
|
+
}
|
|
71
|
+
cleanups.push(...menuCleanups);
|
|
68
72
|
if (m.contributes?.panels) {
|
|
69
73
|
cleanups.push(control.contributePanels(m.id, m.contributes.panels));
|
|
70
74
|
}
|
|
@@ -95,6 +99,11 @@ export async function bootstrapAppHost(overrides = {}) {
|
|
|
95
99
|
await c();
|
|
96
100
|
};
|
|
97
101
|
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
for (const cleanup of menuCleanups.slice().reverse())
|
|
104
|
+
await cleanup();
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
98
107
|
finally {
|
|
99
108
|
control.endSetup();
|
|
100
109
|
}
|
|
@@ -108,7 +117,6 @@ export async function bootstrapAppHost(overrides = {}) {
|
|
|
108
117
|
builtinCommandsExtension,
|
|
109
118
|
hostCommandsExtension,
|
|
110
119
|
builtinMenusExtension,
|
|
111
|
-
editorCommandsExtension,
|
|
112
120
|
panelsViewportExtension,
|
|
113
121
|
panelsChatExtension,
|
|
114
122
|
chromeStatusBarExtension,
|
|
@@ -2,7 +2,6 @@ import { bootstrapAppHost, } from './appHostBootstrap.js';
|
|
|
2
2
|
import { bootStageAppMounted } from './boot/driver.js';
|
|
3
3
|
import { changeLanguage, initI18n } from './i18n/index.js';
|
|
4
4
|
import { useShellStore } from './store.js';
|
|
5
|
-
import { registerKeyboardRouterDeps, } from './lib/global-shortcuts.js';
|
|
6
5
|
/**
|
|
7
6
|
* Lets a product replace one shell overlay with its own destination. Interface
|
|
8
7
|
* owns observation, clearing and synchronous reentry; the product owns routing.
|
|
@@ -24,14 +23,6 @@ export function installApplicationOverlayRedirect(overlayId, onRedirect) {
|
|
|
24
23
|
}
|
|
25
24
|
});
|
|
26
25
|
}
|
|
27
|
-
/**
|
|
28
|
-
* Configures product-owned editor callbacks consumed by Interface's single
|
|
29
|
-
* keyboard router. Product assemblies use this public application boundary;
|
|
30
|
-
* the router implementation remains private to Interface.
|
|
31
|
-
*/
|
|
32
|
-
export function configureInterfaceKeyboardRouter(deps) {
|
|
33
|
-
registerKeyboardRouterDeps(deps);
|
|
34
|
-
}
|
|
35
26
|
/**
|
|
36
27
|
* Starts the shared Interface application runtime without selecting a product
|
|
37
28
|
* entry or product extensions. IDE and compatibility callers supply their own
|