@celestia-island/hikari 0.55.212 → 0.55.213
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/package.json +5 -1
- package/src/components/HkTitleBar.test.tsx +68 -0
- package/src/components/HkTitleBar.tsx +11 -0
- package/src/components/HkWindowControls.test.tsx +234 -1
- package/src/components/HkWindowControls.tsx +68 -26
- package/src/index.ts +26 -0
- package/src/plugins/boot-chrome.d.mts +41 -0
- package/src/plugins/boot-chrome.install.test.ts +272 -0
- package/src/plugins/boot-chrome.mjs +263 -0
- package/src/plugins/boot-chrome.test.ts +427 -0
- package/src/plugins/index.ts +10 -0
- package/src/res/boot-chrome.js +916 -0
- package/src/res/bootChrome.test.ts +648 -0
- package/src/runtime/index.ts +9 -0
- package/src/runtime/windowChrome.test.ts +238 -0
- package/src/runtime/windowChrome.ts +264 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celestia-island/hikari",
|
|
3
|
-
"version": "0.55.
|
|
3
|
+
"version": "0.55.213",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"./plugins": {
|
|
38
38
|
"import": "./src/plugins/index.ts",
|
|
39
39
|
"types": "./src/plugins/index.ts"
|
|
40
|
+
},
|
|
41
|
+
"./plugins/boot-chrome": {
|
|
42
|
+
"import": "./src/plugins/boot-chrome.mjs",
|
|
43
|
+
"types": "./src/plugins/boot-chrome.d.mts"
|
|
40
44
|
}
|
|
41
45
|
},
|
|
42
46
|
"files": [
|
|
@@ -109,6 +109,74 @@ describe("HkTitleBar", () => {
|
|
|
109
109
|
});
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
describe("HkTitleBar window-chrome claim", () => {
|
|
113
|
+
// The bar IS the app's title bar, so mounting it must stand hikari's boot bar
|
|
114
|
+
// (boot-chrome.js) down — otherwise two caption bars overlap for the rest of
|
|
115
|
+
// the session. Tests use a raw window listener: that is the whole contract.
|
|
116
|
+
function collect(): { claims: Array<{ by?: string }>; stop: () => void } {
|
|
117
|
+
const claims: Array<{ by?: string }> = [];
|
|
118
|
+
const listener = (event: Event) => {
|
|
119
|
+
claims.push((event as CustomEvent<{ by?: string }>).detail ?? {});
|
|
120
|
+
};
|
|
121
|
+
window.addEventListener("hk:chrome-claim", listener);
|
|
122
|
+
return {
|
|
123
|
+
claims,
|
|
124
|
+
stop: () => window.removeEventListener("hk:chrome-claim", listener),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function cleanup(): void {
|
|
129
|
+
document.documentElement.removeAttribute("data-hk-chrome");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
it("claims the window on mount and marks the app as the chrome owner", () => {
|
|
133
|
+
const { claims, stop } = collect();
|
|
134
|
+
try {
|
|
135
|
+
mount(h(HkTitleBar, { title: "App" }));
|
|
136
|
+
|
|
137
|
+
expect(claims).toHaveLength(1);
|
|
138
|
+
expect(claims[0]?.by).toBe("HkTitleBar");
|
|
139
|
+
expect(document.documentElement.getAttribute("data-hk-chrome")).toBe("app");
|
|
140
|
+
} finally {
|
|
141
|
+
stop();
|
|
142
|
+
cleanup();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("never claims twice for one mount (a re-render is not a new takeover)", async () => {
|
|
147
|
+
const { claims, stop } = collect();
|
|
148
|
+
try {
|
|
149
|
+
const maximized = ref(false);
|
|
150
|
+
mount(h(() => h(HkTitleBar, { title: "App", maximized: maximized.value })));
|
|
151
|
+
expect(claims).toHaveLength(1);
|
|
152
|
+
|
|
153
|
+
maximized.value = true;
|
|
154
|
+
await nextTick();
|
|
155
|
+
maximized.value = false;
|
|
156
|
+
await nextTick();
|
|
157
|
+
|
|
158
|
+
expect(claims).toHaveLength(1);
|
|
159
|
+
} finally {
|
|
160
|
+
stop();
|
|
161
|
+
cleanup();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("leaves the existing props/emits contract untouched", () => {
|
|
166
|
+
const emitted: string[] = [];
|
|
167
|
+
const host = mount(
|
|
168
|
+
h(HkTitleBar, {
|
|
169
|
+
title: "App",
|
|
170
|
+
onMinimize: () => emitted.push("minimize"),
|
|
171
|
+
onClose: () => emitted.push("close"),
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
host.querySelector<HTMLButtonElement>('[title="Minimize"]')!.click();
|
|
175
|
+
host.querySelector<HTMLButtonElement>('[title="Close"]')!.click();
|
|
176
|
+
expect(emitted).toEqual(["minimize", "close"]);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
112
180
|
describe("HkTitleBar stylesheet contract", () => {
|
|
113
181
|
const css = compile(resolve(componentDir, "HkTitleBar.scss"), {
|
|
114
182
|
style: "expanded",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineComponent, onBeforeUnmount, onMounted, ref } from "vue";
|
|
2
2
|
import { useI18n } from "../i18n/context";
|
|
3
|
+
import { claimWindowChrome } from "../runtime/windowChrome";
|
|
3
4
|
|
|
4
5
|
import "./HkTitleBar.scss";
|
|
5
6
|
|
|
@@ -60,6 +61,16 @@ export default defineComponent({
|
|
|
60
61
|
},
|
|
61
62
|
setup(props, { emit, slots }) {
|
|
62
63
|
const { t } = useI18n();
|
|
64
|
+
// The bar IS the app's window chrome, so mounting it means the boot chrome
|
|
65
|
+
// (boot-chrome.js, drawn before the bundle exists) must hand the window
|
|
66
|
+
// over: dispatch `hk:chrome-claim` once per mount, never revoked. See
|
|
67
|
+
// runtime/windowChrome for the contract; hydration/SSR never reaches here.
|
|
68
|
+
let claimed = false;
|
|
69
|
+
onMounted(() => {
|
|
70
|
+
if (claimed) return;
|
|
71
|
+
claimed = true;
|
|
72
|
+
claimWindowChrome("HkTitleBar");
|
|
73
|
+
});
|
|
63
74
|
return () => (
|
|
64
75
|
<div
|
|
65
76
|
class="hk-titlebar"
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { createApp, defineComponent, h, type App } from "vue";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
|
3
|
+
|
|
4
|
+
import { WINDOW_IPC } from "../runtime/windowChrome";
|
|
3
5
|
|
|
4
6
|
import HkWindowControls, { isApplePlatform } from "./HkWindowControls";
|
|
5
7
|
|
|
@@ -91,3 +93,234 @@ describe("HkWindowControls (two deliberate modes, explicit None)", () => {
|
|
|
91
93
|
t.app.unmount();
|
|
92
94
|
});
|
|
93
95
|
});
|
|
96
|
+
|
|
97
|
+
describe("HkWindowControls window-chrome claim", () => {
|
|
98
|
+
// Only a cluster that ACTUALLY renders may take the window over from
|
|
99
|
+
// hikari's boot bar: claiming while rendering nothing would remove the only
|
|
100
|
+
// usable controls in the window.
|
|
101
|
+
function collect(): { claims: Array<{ by?: string }>; stop: () => void } {
|
|
102
|
+
const claims: Array<{ by?: string }> = [];
|
|
103
|
+
const listener = (event: Event) => {
|
|
104
|
+
claims.push((event as CustomEvent<{ by?: string }>).detail ?? {});
|
|
105
|
+
};
|
|
106
|
+
window.addEventListener("hk:chrome-claim", listener);
|
|
107
|
+
return {
|
|
108
|
+
claims,
|
|
109
|
+
stop: () => window.removeEventListener("hk:chrome-claim", listener),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function cleanup(): void {
|
|
114
|
+
document.documentElement.removeAttribute("data-hk-chrome");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
it("claims the window when the cluster really renders", () => {
|
|
118
|
+
stubTauriWindow();
|
|
119
|
+
const { claims, stop } = collect();
|
|
120
|
+
try {
|
|
121
|
+
const t = mountControls({ variant: "standard" });
|
|
122
|
+
|
|
123
|
+
expect(claims.map((c) => c.by)).toEqual(["HkWindowControls"]);
|
|
124
|
+
expect(document.documentElement.getAttribute("data-hk-chrome")).toBe("app");
|
|
125
|
+
t.app.unmount();
|
|
126
|
+
} finally {
|
|
127
|
+
stop();
|
|
128
|
+
cleanup();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("does not claim when no variant was given (it renders nothing)", () => {
|
|
133
|
+
stubTauriWindow();
|
|
134
|
+
const { claims, stop } = collect();
|
|
135
|
+
try {
|
|
136
|
+
const t = mountControls({});
|
|
137
|
+
expect(claims).toHaveLength(0);
|
|
138
|
+
expect(document.documentElement.hasAttribute("data-hk-chrome")).toBe(false);
|
|
139
|
+
t.app.unmount();
|
|
140
|
+
} finally {
|
|
141
|
+
stop();
|
|
142
|
+
cleanup();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("does not claim off-desktop (no Tauri window API)", () => {
|
|
147
|
+
const { claims, stop } = collect();
|
|
148
|
+
try {
|
|
149
|
+
const t = mountControls({ variant: "flush" });
|
|
150
|
+
expect(claims).toHaveLength(0);
|
|
151
|
+
expect(document.documentElement.hasAttribute("data-hk-chrome")).toBe(false);
|
|
152
|
+
t.app.unmount();
|
|
153
|
+
} finally {
|
|
154
|
+
stop();
|
|
155
|
+
cleanup();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("does not claim on Apple platforms (the OS owns the traffic lights)", () => {
|
|
160
|
+
stubTauriWindow();
|
|
161
|
+
vi.stubGlobal("navigator", {
|
|
162
|
+
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
|
163
|
+
});
|
|
164
|
+
const { claims, stop } = collect();
|
|
165
|
+
try {
|
|
166
|
+
const t = mountControls({ variant: "standard" });
|
|
167
|
+
expect(claims).toHaveLength(0);
|
|
168
|
+
expect(document.documentElement.hasAttribute("data-hk-chrome")).toBe(false);
|
|
169
|
+
t.app.unmount();
|
|
170
|
+
} finally {
|
|
171
|
+
stop();
|
|
172
|
+
cleanup();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("renders and claims when only __TAURI_INTERNALS__ exists (late __TAURI__)", async () => {
|
|
177
|
+
// The 2026-10-28 report: the documented API was late/absent at boot. The
|
|
178
|
+
// cluster must still paint three real controls and hand the window over,
|
|
179
|
+
// otherwise the boot bar never retires and two caption strips overlap.
|
|
180
|
+
const invoke = vi.fn(() => Promise.resolve(false));
|
|
181
|
+
vi.stubGlobal("__TAURI_INTERNALS__", {
|
|
182
|
+
invoke,
|
|
183
|
+
metadata: { currentWindow: { label: "main" } },
|
|
184
|
+
});
|
|
185
|
+
const { claims, stop } = collect();
|
|
186
|
+
try {
|
|
187
|
+
const t = mountControls({ variant: "standard" });
|
|
188
|
+
const buttons = t.container.querySelectorAll<HTMLButtonElement>(
|
|
189
|
+
".hk-window-controls__btn",
|
|
190
|
+
);
|
|
191
|
+
expect(buttons).toHaveLength(3);
|
|
192
|
+
expect(claims.map((c) => c.by)).toEqual(["HkWindowControls"]);
|
|
193
|
+
expect(document.documentElement.getAttribute("data-hk-chrome")).toBe("app");
|
|
194
|
+
|
|
195
|
+
buttons[0]!.click();
|
|
196
|
+
buttons[1]!.click();
|
|
197
|
+
buttons[2]!.click();
|
|
198
|
+
await Promise.resolve();
|
|
199
|
+
|
|
200
|
+
expect(invoke).toHaveBeenCalledWith(WINDOW_IPC.minimize, {});
|
|
201
|
+
expect(invoke).toHaveBeenCalledWith(WINDOW_IPC.toggleMaximize, {});
|
|
202
|
+
expect(invoke).toHaveBeenCalledWith(WINDOW_IPC.close, {});
|
|
203
|
+
t.app.unmount();
|
|
204
|
+
} finally {
|
|
205
|
+
stop();
|
|
206
|
+
cleanup();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("swallows a rejected is_maximized (no unhandled rejection during boot)", async () => {
|
|
211
|
+
// S2-6: the family's res/fatal-fallback.js raises the full-screen
|
|
212
|
+
// "Application failed to load" landing on `unhandledrejection` — during
|
|
213
|
+
// boot that is worse than a missing glyph, and the internals path polls,
|
|
214
|
+
// so an unhandled rejection would repeat every 500ms.
|
|
215
|
+
const invoke = vi.fn((command: string) =>
|
|
216
|
+
command === WINDOW_IPC.isMaximized
|
|
217
|
+
? Promise.reject(new Error("window.is_maximized not allowed"))
|
|
218
|
+
: Promise.resolve(false),
|
|
219
|
+
);
|
|
220
|
+
vi.stubGlobal("__TAURI_INTERNALS__", { invoke });
|
|
221
|
+
|
|
222
|
+
const unhandled: unknown[] = [];
|
|
223
|
+
const onUnhandled = (reason: unknown) => void unhandled.push(reason);
|
|
224
|
+
process.on("unhandledRejection", onUnhandled);
|
|
225
|
+
const t = mountControls({ variant: "standard" });
|
|
226
|
+
try {
|
|
227
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
228
|
+
// The cluster still renders and the rejection never escaped.
|
|
229
|
+
expect(
|
|
230
|
+
t.container.querySelectorAll(".hk-window-controls__btn"),
|
|
231
|
+
).toHaveLength(3);
|
|
232
|
+
expect(unhandled).toHaveLength(0);
|
|
233
|
+
} finally {
|
|
234
|
+
t.app.unmount();
|
|
235
|
+
process.off("unhandledRejection", onUnhandled);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("swallows rejected minimize/close (no unhandled rejection, no fake state)", async () => {
|
|
240
|
+
// S2-1 (R3): `void win.minimize()` without a rejection handler meant an
|
|
241
|
+
// ACL refusal became an unhandledrejection — which the family's
|
|
242
|
+
// res/fatal-fallback.js turns into the full-screen boot-failure landing —
|
|
243
|
+
// while the button kept looking perfectly usable.
|
|
244
|
+
const invoke = vi.fn((command: string) =>
|
|
245
|
+
command === WINDOW_IPC.isMaximized
|
|
246
|
+
? Promise.resolve(false)
|
|
247
|
+
: Promise.reject(new Error(`${command} not allowed`)),
|
|
248
|
+
);
|
|
249
|
+
vi.stubGlobal("__TAURI_INTERNALS__", { invoke });
|
|
250
|
+
|
|
251
|
+
const unhandled: unknown[] = [];
|
|
252
|
+
const onUnhandled = (reason: unknown) => void unhandled.push(reason);
|
|
253
|
+
process.on("unhandledRejection", onUnhandled);
|
|
254
|
+
const t = mountControls({ variant: "standard" });
|
|
255
|
+
try {
|
|
256
|
+
const buttons = t.container.querySelectorAll<HTMLButtonElement>(
|
|
257
|
+
".hk-window-controls__btn",
|
|
258
|
+
);
|
|
259
|
+
buttons[0]!.click(); // minimize
|
|
260
|
+
buttons[2]!.click(); // close
|
|
261
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
262
|
+
|
|
263
|
+
expect(invoke).toHaveBeenCalledWith(WINDOW_IPC.minimize, {});
|
|
264
|
+
expect(invoke).toHaveBeenCalledWith(WINDOW_IPC.close, {});
|
|
265
|
+
expect(unhandled).toHaveLength(0);
|
|
266
|
+
} finally {
|
|
267
|
+
t.app.unmount();
|
|
268
|
+
process.off("unhandledRejection", onUnhandled);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("stops the maximized poll when unmounted before onResized resolves (S2-7)", async () => {
|
|
273
|
+
vi.useFakeTimers();
|
|
274
|
+
try {
|
|
275
|
+
const invoke = vi.fn((_command: string) => Promise.resolve(false));
|
|
276
|
+
vi.stubGlobal("__TAURI_INTERNALS__", { invoke });
|
|
277
|
+
|
|
278
|
+
const t = mountControls({ variant: "standard" });
|
|
279
|
+
// Unmount while the awaited onResized() promise is still unresolved.
|
|
280
|
+
t.app.unmount();
|
|
281
|
+
await Promise.resolve();
|
|
282
|
+
await Promise.resolve();
|
|
283
|
+
await Promise.resolve();
|
|
284
|
+
|
|
285
|
+
const isMaximizedCalls = () =>
|
|
286
|
+
invoke.mock.calls.filter((call) => call[0] === WINDOW_IPC.isMaximized)
|
|
287
|
+
.length;
|
|
288
|
+
const afterUnmount = isMaximizedCalls();
|
|
289
|
+
vi.advanceTimersByTime(5000);
|
|
290
|
+
// An orphaned 500ms interval would keep polling a dead component.
|
|
291
|
+
expect(isMaximizedCalls()).toBe(afterUnmount);
|
|
292
|
+
} finally {
|
|
293
|
+
vi.useRealTimers();
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("claims once, not once per render", async () => {
|
|
298
|
+
// The maximized glyph re-renders the cluster; the takeover already happened.
|
|
299
|
+
vi.stubGlobal("__TAURI__", {
|
|
300
|
+
window: {
|
|
301
|
+
getCurrentWindow: () => ({
|
|
302
|
+
minimize: () => Promise.resolve(),
|
|
303
|
+
toggleMaximize: () => Promise.resolve(),
|
|
304
|
+
close: () => Promise.resolve(),
|
|
305
|
+
isMaximized: () => Promise.resolve(true),
|
|
306
|
+
onResized: () => Promise.resolve(() => {}),
|
|
307
|
+
}),
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
const { claims, stop } = collect();
|
|
311
|
+
try {
|
|
312
|
+
const t = mountControls({ variant: "standard" });
|
|
313
|
+
await nextTick();
|
|
314
|
+
await nextTick();
|
|
315
|
+
await nextTick();
|
|
316
|
+
|
|
317
|
+
// Proof the re-render happened: the maximize button now offers Restore.
|
|
318
|
+
expect(t.container.querySelector('[aria-label="Restore"]')).not.toBeNull();
|
|
319
|
+
expect(claims).toHaveLength(1);
|
|
320
|
+
t.app.unmount();
|
|
321
|
+
} finally {
|
|
322
|
+
stop();
|
|
323
|
+
cleanup();
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
});
|
|
@@ -2,6 +2,8 @@ import { defineComponent, onMounted, onUnmounted, ref, type PropType } from "vue
|
|
|
2
2
|
|
|
3
3
|
import { Minus, Square, X, Copy } from "lucide-vue-next";
|
|
4
4
|
|
|
5
|
+
import { claimWindowChrome, resolveWindowApi } from "../runtime/windowChrome";
|
|
6
|
+
|
|
5
7
|
import "./HkWindowControls.scss";
|
|
6
8
|
|
|
7
9
|
/**
|
|
@@ -41,28 +43,15 @@ import "./HkWindowControls.scss";
|
|
|
41
43
|
* renders nothing on Apple platforms and the header degrades to its
|
|
42
44
|
* browser-face look automatically.
|
|
43
45
|
*
|
|
44
|
-
* The window API
|
|
45
|
-
*
|
|
46
|
-
*
|
|
46
|
+
* The window API comes from `resolveWindowApi()` (runtime/windowChrome):
|
|
47
|
+
* `__TAURI__.window.getCurrentWindow()` when the host exposes it, otherwise the
|
|
48
|
+
* `__TAURI_INTERNALS__` IPC surface. That fallback matters: the field report
|
|
49
|
+
* (2026-10-28) was a boot where `__TAURI__` was missing/late, this component
|
|
50
|
+
* rendered NOTHING, the boot bar never got claimed, and two caption strips
|
|
51
|
+
* fought over the top edge. The three gates below are unchanged; only the
|
|
52
|
+
* "is there a shell?" probe is shared now. No shell at all → nothing renders.
|
|
47
53
|
*/
|
|
48
54
|
|
|
49
|
-
interface TauriWindowLike {
|
|
50
|
-
minimize(): Promise<void>;
|
|
51
|
-
toggleMaximize(): Promise<void>;
|
|
52
|
-
close(): Promise<void>;
|
|
53
|
-
isMaximized(): Promise<boolean>;
|
|
54
|
-
onResized(cb: () => void): Promise<() => void>;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function currentWindow(): TauriWindowLike | null {
|
|
58
|
-
const w = (
|
|
59
|
-
window as unknown as {
|
|
60
|
-
__TAURI__?: { window?: { getCurrentWindow?: () => TauriWindowLike } };
|
|
61
|
-
}
|
|
62
|
-
).__TAURI__?.window;
|
|
63
|
-
return w?.getCurrentWindow?.() ?? null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
55
|
/** Apple platforms own their traffic lights — we never render there. */
|
|
67
56
|
export function isApplePlatform(): boolean {
|
|
68
57
|
return /Mac|iPhone|iPad|iPod/.test(
|
|
@@ -86,14 +75,48 @@ export const HkWindowControls = defineComponent({
|
|
|
86
75
|
setup(props) {
|
|
87
76
|
const maximized = ref(false);
|
|
88
77
|
let unlisten: (() => void) | null = null;
|
|
78
|
+
// Set on unmount: the mount path awaits `onResized()`, and an unmount that
|
|
79
|
+
// lands before that promise resolves would otherwise arm a poll interval
|
|
80
|
+
// (and keep writing `maximized`) for a component that no longer exists.
|
|
81
|
+
let disposed = false;
|
|
82
|
+
// Only a mount that actually paints the cluster takes the window chrome
|
|
83
|
+
// over from the boot bar (boot-chrome.js): the SAME gates the render
|
|
84
|
+
// function applies, replayed once on mount. A component that renders
|
|
85
|
+
// nothing must not claim — the boot bar would vanish and leave the window
|
|
86
|
+
// with no controls at all. On Apple platforms nothing is painted (the OS
|
|
87
|
+
// owns the traffic lights), so a host that ships only this component on
|
|
88
|
+
// macOS must dispatch `claimWindowChrome()` itself.
|
|
89
|
+
let claimed = false;
|
|
89
90
|
|
|
90
91
|
onMounted(async () => {
|
|
91
|
-
|
|
92
|
+
if (isApplePlatform()) return;
|
|
93
|
+
if (props.variant !== "standard" && props.variant !== "flush") return;
|
|
94
|
+
const win = resolveWindowApi();
|
|
92
95
|
if (!win) return;
|
|
96
|
+
if (!claimed) {
|
|
97
|
+
claimed = true;
|
|
98
|
+
claimWindowChrome("HkWindowControls");
|
|
99
|
+
}
|
|
100
|
+
// Every shell read is failure-tolerant AND handled: an unhandled
|
|
101
|
+
// rejection during boot is treated as fatal by the family's
|
|
102
|
+
// `res/fatal-fallback.js` (it raises the full-screen "Application failed
|
|
103
|
+
// to load" landing on `unhandledrejection`), and the internals path would
|
|
104
|
+
// reproduce it every poll interval.
|
|
105
|
+
const refresh = () => {
|
|
106
|
+
void win.isMaximized().then(
|
|
107
|
+
(value) => {
|
|
108
|
+
if (!disposed) maximized.value = value;
|
|
109
|
+
},
|
|
110
|
+
() => {},
|
|
111
|
+
);
|
|
112
|
+
};
|
|
93
113
|
try {
|
|
94
|
-
const refresh = () => void win.isMaximized().then((m) => (maximized.value = m));
|
|
95
114
|
refresh();
|
|
96
115
|
const off = await win.onResized(refresh);
|
|
116
|
+
if (disposed) {
|
|
117
|
+
off();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
97
120
|
unlisten = off;
|
|
98
121
|
} catch {
|
|
99
122
|
/* the window API is optional furniture */
|
|
@@ -101,7 +124,9 @@ export const HkWindowControls = defineComponent({
|
|
|
101
124
|
});
|
|
102
125
|
|
|
103
126
|
onUnmounted(() => {
|
|
127
|
+
disposed = true;
|
|
104
128
|
unlisten?.();
|
|
129
|
+
unlisten = null;
|
|
105
130
|
});
|
|
106
131
|
|
|
107
132
|
return () => {
|
|
@@ -111,9 +136,26 @@ export const HkWindowControls = defineComponent({
|
|
|
111
136
|
// enforce the required prop).
|
|
112
137
|
if (isApplePlatform()) return null;
|
|
113
138
|
if (props.variant !== "standard" && props.variant !== "flush") return null;
|
|
114
|
-
const win =
|
|
139
|
+
const win = resolveWindowApi();
|
|
115
140
|
if (!win) return null;
|
|
116
141
|
|
|
142
|
+
// Toggle, then re-read the shell state: the glyph must follow the real
|
|
143
|
+
// window, not a local guess (the internals path may have no resize event).
|
|
144
|
+
const toggleMaximize = () => {
|
|
145
|
+
void win
|
|
146
|
+
.toggleMaximize()
|
|
147
|
+
.then(() => win.isMaximized())
|
|
148
|
+
.then((value) => (maximized.value = value))
|
|
149
|
+
.catch(() => {});
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// A refused shell call must never surface as an unhandled rejection (the
|
|
153
|
+
// family's res/fatal-fallback.js turns one into a full-screen boot
|
|
154
|
+
// failure) and must never look like a working button. The boot bar shows
|
|
155
|
+
// the degraded state when it is the one drawing the controls; here the
|
|
156
|
+
// host's own footer simply keeps the glyph it had.
|
|
157
|
+
const ignore = (pending: Promise<unknown>) => void pending.catch(() => {});
|
|
158
|
+
|
|
117
159
|
const flush = props.variant === "flush";
|
|
118
160
|
return (
|
|
119
161
|
<div
|
|
@@ -124,7 +166,7 @@ export const HkWindowControls = defineComponent({
|
|
|
124
166
|
type="button"
|
|
125
167
|
class="hk-window-controls__btn"
|
|
126
168
|
aria-label="Minimize"
|
|
127
|
-
onClick={() =>
|
|
169
|
+
onClick={() => ignore(win.minimize())}
|
|
128
170
|
>
|
|
129
171
|
<Minus size={flush ? 14 : 16} />
|
|
130
172
|
</button>
|
|
@@ -132,7 +174,7 @@ export const HkWindowControls = defineComponent({
|
|
|
132
174
|
type="button"
|
|
133
175
|
class="hk-window-controls__btn"
|
|
134
176
|
aria-label={maximized.value ? "Restore" : "Maximize"}
|
|
135
|
-
onClick={
|
|
177
|
+
onClick={toggleMaximize}
|
|
136
178
|
>
|
|
137
179
|
{maximized.value
|
|
138
180
|
? <Copy size={flush ? 12 : 14} style={{ transform: "rotate(180deg)" }} />
|
|
@@ -142,7 +184,7 @@ export const HkWindowControls = defineComponent({
|
|
|
142
184
|
type="button"
|
|
143
185
|
class="hk-window-controls__btn hk-window-controls__btn--close"
|
|
144
186
|
aria-label="Close"
|
|
145
|
-
onClick={() =>
|
|
187
|
+
onClick={() => ignore(win.close())}
|
|
146
188
|
>
|
|
147
189
|
<X size={flush ? 14 : 16} />
|
|
148
190
|
</button>
|
package/src/index.ts
CHANGED
|
@@ -826,3 +826,29 @@ export { type HAboutComponentVersion as HkAboutComponentVersion } from "./compon
|
|
|
826
826
|
export { type HCustomTheme as HkCustomTheme } from "./components/HkColorSchemeDialog";
|
|
827
827
|
export { OVERLAY_ROOT_SELECTOR, OVERLAY_Z, ensureOverlayRoot } from "./overlay/overlayRoot";
|
|
828
828
|
export { HK_PAD_VAR, hkPadStyle, type HkPad } from "./runtime/containerPad";
|
|
829
|
+
|
|
830
|
+
// Window-chrome handoff (2026-10-28): the boot bar hikari's Vite plugin draws
|
|
831
|
+
// before the app bundle exists hands the window over on `hk:chrome-claim`.
|
|
832
|
+
// `claimWindowChrome` is for hosts that paint their OWN title bar instead of
|
|
833
|
+
// HkTitleBar/HkWindowControls (both claim automatically).
|
|
834
|
+
export {
|
|
835
|
+
claimWindowChrome,
|
|
836
|
+
resolveWindowApi,
|
|
837
|
+
CHROME_CLAIM_EVENT,
|
|
838
|
+
CHROME_OWNER_ATTR,
|
|
839
|
+
WINDOW_IPC,
|
|
840
|
+
RESIZE_POLL_MS,
|
|
841
|
+
type WindowApiLike,
|
|
842
|
+
} from "./runtime/windowChrome";
|
|
843
|
+
// Pre-boot caption bar (`bootChromePlugin`) is NOT exported here. It ships from
|
|
844
|
+
// its own published subpath — `@celestia-island/hikari/plugins/boot-chrome` —
|
|
845
|
+
// resolved to a self-contained ESM module, and that is what a host imports in
|
|
846
|
+
// vite.config.ts:
|
|
847
|
+
// 1. this entry is what BROWSER bundles import; re-exporting the Node-side
|
|
848
|
+
// plugin from it makes every consumer's build pull the module, and Vite
|
|
849
|
+
// then emits `boot-chrome.js` as a duplicate 28.8 kB asset AND warns
|
|
850
|
+
// "node:fs has been externalized for browser compatibility" (measured with
|
|
851
|
+
// Vite 8; tree-shaking does not remove a statically-referenced asset), and
|
|
852
|
+
// 2. the `/plugins` barrel is TypeScript, which Node cannot load from inside
|
|
853
|
+
// node_modules (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`) — the
|
|
854
|
+
// dedicated subpath exists precisely to avoid that.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Plugin } from "vite";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Types for `boot-chrome.mjs` — the self-contained, published Vite plugin entry
|
|
5
|
+
* (`@celestia-island/hikari/plugins/boot-chrome`).
|
|
6
|
+
*
|
|
7
|
+
* The implementation is plain ESM with static Node imports on purpose: a
|
|
8
|
+
* consumer loads it from `node_modules` while resolving `vite.config.*`, where
|
|
9
|
+
* TypeScript cannot be stripped and Vite's `configLoader: "runner"` tears the
|
|
10
|
+
* module runner down before plugin hooks run. Keep the runtime file dependency
|
|
11
|
+
* free apart from `node:fs` / `node:path` / `node:url`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** File name the bar is emitted and served under (output-dir root). */
|
|
15
|
+
export declare const BOOT_CHROME_SCRIPT: "hk-boot-chrome.js";
|
|
16
|
+
|
|
17
|
+
/** Root-absolute default URL the injected `<script src>` points at. */
|
|
18
|
+
export declare const BOOT_CHROME_URL: string;
|
|
19
|
+
|
|
20
|
+
export interface BootChromePluginOptions {
|
|
21
|
+
/** Application name on the left of the bar (default: `document.title`). */
|
|
22
|
+
title?: string;
|
|
23
|
+
/** Bar height in px; `boot-chrome.js` clamps it to 20..64 (default 32). */
|
|
24
|
+
height?: number;
|
|
25
|
+
/** Watchdog delay in ms before the reload affordance appears (default 20000). */
|
|
26
|
+
hangMs?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Which HTML entries get the bar. Default: only entries whose basename is
|
|
29
|
+
* `index.html`. Multi-window shells MUST configure this — a transparent
|
|
30
|
+
* overlay window must never paint the caption bar.
|
|
31
|
+
*
|
|
32
|
+
* A `RegExp` is matched against the entry's file path; a `string[]` is
|
|
33
|
+
* matched against the basename or the path suffix (`"pages/index.html"`).
|
|
34
|
+
*/
|
|
35
|
+
htmlFiles?: string[] | RegExp;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Vite plugin: inject `boot-chrome.js` into the app window's HTML entry. */
|
|
39
|
+
export declare function bootChromePlugin(
|
|
40
|
+
options?: BootChromePluginOptions,
|
|
41
|
+
): Plugin;
|