@kabmo-labs/kabmo 0.5.2 → 0.6.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/dist/client/index.html +1 -1
- package/dist/client/index.js +359 -350
- package/dist/client/static/js/{index-zcvn50cf.js → index-ae9z0yvm.js} +703 -703
- package/dist/client/static/js/index-nzyjf92m.css +1 -0
- package/marketplace/plugins/stats/README.md +26 -4
- package/marketplace/plugins/stats/package.json +2 -2
- package/marketplace/plugins/stats/src/HeaderReadout.test.tsx +157 -0
- package/marketplace/plugins/stats/src/HeaderReadout.tsx +115 -0
- package/marketplace/plugins/stats/src/app.tsx +11 -0
- package/marketplace/plugins/stats/src/format.test.ts +27 -1
- package/marketplace/plugins/stats/src/format.ts +41 -0
- package/package.json +2 -2
- package/src/client/bootstrap.d.ts +11 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +38 -1
- package/src/client/components/common/header-controls/index.tsx +9 -2
- package/src/client/components/common/header-controls/plugin-entry.test.tsx +229 -0
- package/src/client/components/common/header-panel-buttons/entries.test.ts +46 -0
- package/src/client/components/common/header-panel-buttons/entries.ts +109 -0
- package/src/client/components/common/header-panel-buttons/index.tsx +71 -38
- package/src/client/components/common/server-switcher/index.test.tsx +150 -0
- package/src/client/components/common/server-switcher/index.tsx +281 -0
- package/src/client/components/common/window-controls/index.test.tsx +113 -0
- package/src/client/components/common/window-controls/index.tsx +66 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +3 -6
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +59 -3
- package/src/client/components/layout/desktop-layout/index.tsx +34 -26
- package/src/client/components/layout/session-sidebar/Footer.tsx +12 -12
- package/src/client/components/layout/session-sidebar/Header.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +36 -0
- package/src/client/components/mobile/RightSidebar.tsx +2 -2
- package/src/client/components/mobile/SessionSidebar.tsx +10 -15
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +15 -12
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +9 -2
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.test.ts +41 -0
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.ts +39 -0
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +6 -6
- package/src/client/components/settings/categories/OmpSettings.tsx +25 -29
- package/src/client/components/settings/categories/omp-settings/PresetBar.tsx +187 -0
- package/src/client/components/settings/categories/omp-settings/RoleEditor.test.ts +260 -32
- package/src/client/components/settings/categories/omp-settings/RoleEditor.tsx +198 -102
- package/src/client/components/settings/categories/omp-settings/RoleRow.tsx +79 -0
- package/src/client/data/settings/omp-schema.json +2 -2
- package/src/client/hooks/chat/omp/session-list.test.ts +0 -24
- package/src/client/hooks/desktop/servers.test.ts +73 -0
- package/src/client/hooks/desktop/servers.ts +126 -0
- package/src/client/hooks/settings/model-roles.ts +156 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
- package/src/client/hooks/ui/updates/shell-download.ts +220 -0
- package/src/client/hooks/workspace/keybindings.ts +44 -0
- package/src/client/hooks/workspace/open-panel.ts +42 -0
- package/src/client/lib/desktop/bridge.ts +158 -0
- package/src/client/lib/desktop/titlebar.test.ts +99 -0
- package/src/client/lib/desktop/titlebar.ts +97 -0
- package/src/client/lib/plugins/slots.test.ts +54 -0
- package/src/client/lib/plugins/slots.ts +37 -1
- package/src/server/db.server.test.ts +44 -3
- package/src/server/db.server.ts +26 -2
- package/src/server/lib/bundler/build-errors.test.ts +0 -6
- package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
- package/src/server/lib/model-roles/catalog.server.ts +112 -0
- package/src/server/lib/model-roles/mock.server.ts +40 -0
- package/src/server/lib/model-roles/schema.server.ts +36 -0
- package/src/server/lib/model-roles/store.server.test.ts +218 -0
- package/src/server/lib/model-roles/store.server.ts +188 -0
- package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
- package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
- package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
- package/src/server/lib/omp/rpc/manager.ts +18 -13
- package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
- package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
- package/src/server/lib/omp/rpc/session-commands.ts +7 -0
- package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
- package/src/server/lib/omp/session/auto-title.server.ts +150 -120
- package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
- package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
- package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
- package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
- package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
- package/src/server/lib/omp/session/title-request.server.ts +213 -0
- package/src/server/lib/panels/baseline.server.test.ts +4 -4
- package/src/server/lib/panels/compat.server.test.ts +4 -4
- package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
- package/src/server/lib/panels/registry.server.test.ts +4 -4
- package/src/server/lib/panels/state.server.test.ts +4 -4
- package/src/server/lib/panels/sync.server.test.ts +4 -4
- package/src/server/lib/plugins/background.test.ts +2 -2
- package/src/server/lib/plugins/e2e.test.ts +2 -2
- package/src/server/lib/plugins/platform.test.ts +2 -2
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
- package/src/server/lib/queue/delivery.server.ts +11 -2
- package/src/server/lib/updates/shell-release.test.ts +184 -0
- package/src/server/lib/updates/shell-release.ts +215 -0
- package/src/server/lib/waiting/store.server.test.ts +2 -2
- package/src/server/plugins/ssr.ts +13 -10
- package/src/server/routes/chat/stream.test.ts +4 -4
- package/src/server/routes/fs/git-merge.test.ts +0 -11
- package/src/server/routes/models/roles.ts +73 -14
- package/src/server/routes/panels/css.test.ts +4 -4
- package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
- package/src/server/routes/panels/routes.test.ts +0 -7
- package/src/server/routes/plugins/routes.test.ts +4 -4
- package/src/server/routes/sessions/batch-delete.test.ts +4 -4
- package/src/server/routes/updates/android.ts +2 -2
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +26 -0
- package/src/server/routes/waiting/routes.test.ts +2 -2
- package/src/shared/lib/models/server-cache.ts +11 -0
- package/src/shared/lib/omp/config/model-roles.ts +129 -0
- package/src/shared/lib/updates/shell-ua.test.ts +56 -24
- package/src/shared/lib/updates/shell-ua.ts +41 -20
- package/src/shared/lib/workspace/mobile-dock.test.ts +7 -13
- package/src/shared/lib/workspace/mobile-dock.ts +15 -6
- package/src/shared/lib/workspace/panel-open.test.ts +56 -0
- package/src/shared/lib/workspace/panel-open.ts +76 -0
- package/src/shared/types/model-roles.ts +54 -0
- package/src/test-support/isolated-db.ts +17 -5
- package/dist/client/static/js/index-3qamd33d.css +0 -1
- package/src/client/components/layout/desktop-layout/left-column.test.tsx +0 -192
- package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
- package/src/client/hooks/ui/updates/android-download.ts +0 -143
- package/src/server/lib/updates/android-release.test.ts +0 -138
- package/src/server/lib/updates/android-release.ts +0 -183
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The desktop shell's bridge, and the reason every other module asks this one
|
|
8
|
+
* whether it is running inside the shell.
|
|
9
|
+
*
|
|
10
|
+
* THREE shells install the same transport, and that is the point of this module:
|
|
11
|
+
*
|
|
12
|
+
* - `packages/macos` (macOS, Swift + WKWebView)
|
|
13
|
+
* - `packages/windows` (.NET + WPF + WebView2)
|
|
14
|
+
* - `packages/linux` (Rust + GTK 3 + WebKitGTK 4.1)
|
|
15
|
+
*
|
|
16
|
+
* Each installs ONE transport before the page's own scripts run:
|
|
17
|
+
* `window.__kabmoCall(method, params)` posts `{ id, method, params }` to a host
|
|
18
|
+
* message handler named `kabmo`, and the shell answers by evaluating
|
|
19
|
+
* `window.__kabmoReply(id, ok, payload)`. There is no capability file and no
|
|
20
|
+
* per-origin allowance — the shell owns the webview, so the page cannot be
|
|
21
|
+
* anything but one this window chose to load.
|
|
22
|
+
*
|
|
23
|
+
* This module must be safe in a BROWSER too: every function rejects with "not
|
|
24
|
+
* inside the shell" when there is no bridge, and the navbar draws no server
|
|
25
|
+
* control at all. A browser tab has no shell to control, and a control that
|
|
26
|
+
* opened onto "not available" would be a button that never works.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** What the shell reports about itself. */
|
|
30
|
+
export interface DesktopInfo {
|
|
31
|
+
platform: string;
|
|
32
|
+
shellVersion: string;
|
|
33
|
+
/** True when the shell hides the native title bar and the page draws it. */
|
|
34
|
+
overlayTitlebar: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* macOS: the width of the traffic lights, which float over the page's LEFT
|
|
37
|
+
* edge. Absent on a platform whose window controls are not on the left.
|
|
38
|
+
*/
|
|
39
|
+
trafficLightInset?: string;
|
|
40
|
+
/**
|
|
41
|
+
* True when the shell is borderless and has NO controls of its own, so the
|
|
42
|
+
* page must draw minimise / maximise / close.
|
|
43
|
+
*
|
|
44
|
+
* Windows and Linux. macOS gets its traffic lights from the system over a
|
|
45
|
+
* full-size content view; a borderless Windows window has nothing at all, and
|
|
46
|
+
* the webview there is an HWND — a WPF element cannot be drawn on top of it
|
|
47
|
+
* (the airspace problem) — so the page is the only surface that can. Linux
|
|
48
|
+
* could draw native buttons in a `GtkOverlay` (measured: an overlay child does
|
|
49
|
+
* receive clicks over a WebView), but the page's set already exists, is tested,
|
|
50
|
+
* and is the one every other shell's users see.
|
|
51
|
+
*/
|
|
52
|
+
windowControls?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One server in the shell's list, or one found on this machine. */
|
|
56
|
+
export interface DesktopServer {
|
|
57
|
+
id: string;
|
|
58
|
+
label: string;
|
|
59
|
+
url: string;
|
|
60
|
+
kind: 'remote' | 'managed';
|
|
61
|
+
port?: number;
|
|
62
|
+
running: boolean;
|
|
63
|
+
/** This shell started it (as opposed to a server found on the machine). */
|
|
64
|
+
owned: boolean;
|
|
65
|
+
version?: string;
|
|
66
|
+
mode?: string;
|
|
67
|
+
pid?: number;
|
|
68
|
+
startedAt?: string;
|
|
69
|
+
/** `stored` for the list, `record` for a local instance found on disk. */
|
|
70
|
+
source: 'stored' | 'record';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface DesktopServersState {
|
|
74
|
+
entries: DesktopServer[];
|
|
75
|
+
/** Local instances this shell did NOT start — a `kabmo serve`, a dev server. */
|
|
76
|
+
detected: DesktopServer[];
|
|
77
|
+
activeId?: string;
|
|
78
|
+
activeUrl?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
declare global {
|
|
82
|
+
interface Window {
|
|
83
|
+
__kabmoCall?: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
|
|
84
|
+
__kabmoDesktop?: DesktopInfo;
|
|
85
|
+
__kabmoReply?: (id: number, ok: boolean, payload: unknown) => void;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True when this page is inside the desktop shell. */
|
|
90
|
+
export function isDesktopShell(): boolean {
|
|
91
|
+
return typeof window !== 'undefined' && typeof window.__kabmoCall === 'function';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function call<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
|
95
|
+
const invoke = typeof window === 'undefined' ? undefined : window.__kabmoCall;
|
|
96
|
+
if (!invoke) throw new Error('Not running inside the Kabmo desktop shell.');
|
|
97
|
+
return (await invoke(method, params)) as T;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function desktopInfo(): Promise<DesktopInfo | null> {
|
|
101
|
+
if (!isDesktopShell()) return Promise.resolve(null);
|
|
102
|
+
const injected = typeof window === 'undefined' ? undefined : window.__kabmoDesktop;
|
|
103
|
+
// The shell injects this before the page's first script, so the navbar's first
|
|
104
|
+
// paint is already correct; the command is only a fallback for a shell that
|
|
105
|
+
// did not (an older build).
|
|
106
|
+
if (injected) return Promise.resolve(injected);
|
|
107
|
+
return call<DesktopInfo>('desktop_info');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function readServers(): Promise<DesktopServersState> {
|
|
111
|
+
return call<DesktopServersState>('servers_state');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function addRemoteServer(label: string, url: string): Promise<DesktopServersState> {
|
|
115
|
+
return call<DesktopServersState>('servers_add', { label, url });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function removeServer(id: string): Promise<DesktopServersState> {
|
|
119
|
+
return call<DesktopServersState>('servers_remove', { id });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Point the window at a server. The shell navigates, so this page goes away. */
|
|
123
|
+
export function activateServer(id: string): Promise<DesktopServersState> {
|
|
124
|
+
return call<DesktopServersState>('servers_activate', { id });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Start a local server as the shell's child, then navigate to it. */
|
|
128
|
+
export function startLocalServer(port?: number, label?: string): Promise<DesktopServersState> {
|
|
129
|
+
return call<DesktopServersState>('servers_start', { port: port ?? null, label: label ?? null });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function stopLocalServer(id: string): Promise<DesktopServersState> {
|
|
133
|
+
return call<DesktopServersState>('servers_stop', { id });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A refused command's message.
|
|
138
|
+
*
|
|
139
|
+
* The shell sends its own sentence (a refused URL, a server that exited), which
|
|
140
|
+
* is what the UI shows. Nothing is stripped: a message that genuinely starts with
|
|
141
|
+
* "Error" would lose its first word.
|
|
142
|
+
*/
|
|
143
|
+
export function bridgeError(error: unknown): string {
|
|
144
|
+
return error instanceof Error ? error.message : String(error);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Minimise, maximise/restore and close, for a shell that draws no controls. */
|
|
148
|
+
export function windowMinimize(): Promise<void> {
|
|
149
|
+
return call<void>('window_minimize');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function windowToggleMaximize(): Promise<void> {
|
|
153
|
+
return call<void>('window_maximize');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function windowClose(): Promise<void> {
|
|
157
|
+
return call<void>('window_close');
|
|
158
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The shell's title-bar contract, evaluated against both shells and a browser.
|
|
8
|
+
*
|
|
9
|
+
* Two shells wrap this app, and they put their controls on OPPOSITE edges — or
|
|
10
|
+
* nowhere at all. A single "inset" would have to say which edge it means at
|
|
11
|
+
* every call site, and the cost of getting it wrong is a dead strip of window
|
|
12
|
+
* with no control in it, which is exactly the macOS 76px bug stated in
|
|
13
|
+
* `src/shared/lib/ui/titlebar.ts`.
|
|
14
|
+
*
|
|
15
|
+
* Three properties are load-bearing, and each fails silently:
|
|
16
|
+
*
|
|
17
|
+
* 1. macOS floats traffic lights over the page's LEFT edge, so the left inset is
|
|
18
|
+
* the shell's reported width and nothing is reserved on the right;
|
|
19
|
+
* 2. Windows is borderless with no controls of its own — its webview is an HWND,
|
|
20
|
+
* so a host element cannot be painted over it — which is why the PAGE draws
|
|
21
|
+
* the three buttons there (`drawsWindowControls`), and why it reserves no
|
|
22
|
+
* inset on either edge;
|
|
23
|
+
* 3. a BROWSER, or an older shell that injects no identity, gets the caller's
|
|
24
|
+
* own resolved padding (the PWA's Window Controls Overlay arithmetic).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
28
|
+
|
|
29
|
+
type Shell = {
|
|
30
|
+
__kabmoCall?: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
|
|
31
|
+
__kabmoDesktop?: Record<string, unknown>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const scope = () => globalThis as unknown as { window?: Shell };
|
|
35
|
+
|
|
36
|
+
async function titlebar() {
|
|
37
|
+
return import('@/client/lib/desktop/titlebar');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function shell(identity: Record<string, unknown> | null) {
|
|
41
|
+
scope().window = identity === null
|
|
42
|
+
? { __kabmoCall: async () => null }
|
|
43
|
+
: { __kabmoCall: async () => null, __kabmoDesktop: identity };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const MACOS = { platform: 'macos', shellVersion: '0.1.0', overlayTitlebar: true, trafficLightInset: '5rem' };
|
|
47
|
+
const WINDOWS = { platform: 'windows', shellVersion: '0.1.0', overlayTitlebar: true, windowControls: true };
|
|
48
|
+
|
|
49
|
+
describe('shell title-bar contract', () => {
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
delete scope().window;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a browser reserves nothing for a shell it is not in', async () => {
|
|
55
|
+
scope().window = {};
|
|
56
|
+
const bar = await titlebar();
|
|
57
|
+
expect(bar.overlayTitlebar()).toBe(false);
|
|
58
|
+
expect(bar.dragRegionProps()).toEqual({});
|
|
59
|
+
expect(bar.titlebarInsetLeft('1rem')).toBe('1rem');
|
|
60
|
+
expect(bar.drawsWindowControls()).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('macOS reserves the traffic lights on the left and draws no controls', async () => {
|
|
64
|
+
shell(MACOS);
|
|
65
|
+
const bar = await titlebar();
|
|
66
|
+
expect(bar.dragRegionProps()).toEqual({ 'data-drag-region': '' });
|
|
67
|
+
expect(bar.titlebarInsetLeft('1rem')).toBe('5rem');
|
|
68
|
+
// The system owns the traffic lights: a page-drawn set would be a second one.
|
|
69
|
+
expect(bar.drawsWindowControls()).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('Windows reserves nothing and draws the three buttons itself', async () => {
|
|
73
|
+
shell(WINDOWS);
|
|
74
|
+
const bar = await titlebar();
|
|
75
|
+
expect(bar.dragRegionProps()).toEqual({ 'data-drag-region': '' });
|
|
76
|
+
// Nothing floats over the page on a borderless Windows window.
|
|
77
|
+
expect(bar.titlebarInsetLeft('1rem')).toBe('1rem');
|
|
78
|
+
expect(bar.drawsWindowControls()).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** An older shell injects no identity at all; nothing may be invented. */
|
|
82
|
+
test('a shell that reports no identity keeps the resolved padding', async () => {
|
|
83
|
+
shell(null);
|
|
84
|
+
const bar = await titlebar();
|
|
85
|
+
expect(bar.overlayTitlebar()).toBe(true);
|
|
86
|
+
expect(bar.titlebarInsetLeft('1rem')).toBe('1rem');
|
|
87
|
+
expect(bar.drawsWindowControls()).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/** A shell that draws its own native bar must not mark a drag region. */
|
|
91
|
+
test('overlayTitlebar false marks no drag region and no controls', async () => {
|
|
92
|
+
shell({ platform: 'windows', shellVersion: '0.1.0', overlayTitlebar: false, windowControls: true });
|
|
93
|
+
const bar = await titlebar();
|
|
94
|
+
expect(bar.overlayTitlebar()).toBe(false);
|
|
95
|
+
expect(bar.dragRegionProps()).toEqual({});
|
|
96
|
+
// The native bar owns the buttons when it is visible.
|
|
97
|
+
expect(bar.drawsWindowControls()).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Making the page's own header act as the desktop window's title bar.
|
|
8
|
+
*
|
|
9
|
+
* Three facts, and the third is why this module is shared by three shells.
|
|
10
|
+
*
|
|
11
|
+
* 1. **The window controls float OVER the content**, because the window is
|
|
12
|
+
* built with a transparent title bar over a full-size content view. WHICH
|
|
13
|
+
* EDGE they occupy is the platform's choice, and getting it wrong is a dead
|
|
14
|
+
* strip of window with no control in it: macOS puts its traffic lights on
|
|
15
|
+
* the LEFT, so the leftmost ~76px of the page is behind them and a wordmark
|
|
16
|
+
* drawn at the normal 16px inset sits under the close button.
|
|
17
|
+
*
|
|
18
|
+
* - Windows is borderless too, but draws nothing over the page: its webview
|
|
19
|
+
* is an HWND, so a host element cannot be painted on top of it. The page
|
|
20
|
+
* draws its own minimise / maximise / close instead (`windowControls`), so
|
|
21
|
+
* the only edge it reserves anything for is the one macOS owns. Linux is
|
|
22
|
+
* the same: borderless, page-drawn controls, no reserved edge.
|
|
23
|
+
*
|
|
24
|
+
* The inset is therefore REPORTED by the shell (`trafficLightInset`), and a
|
|
25
|
+
* shell that reports none gets the caller's own resolved padding.
|
|
26
|
+
*
|
|
27
|
+
* 2. **The drag region is decided by the SHELL, from a selector** — not by CSS.
|
|
28
|
+
* macOS asks the page whether the press landed on `[data-drag-region]`
|
|
29
|
+
* (`ShellWindow.sendEvent`); Windows lets WebView2 do the same with
|
|
30
|
+
* `IsNonClientRegionSupportEnabled`, which is what makes `app-region: drag`
|
|
31
|
+
* mean the title bar there. The page's only job is to MARK its header.
|
|
32
|
+
*
|
|
33
|
+
* 3. **The inset must survive a page that has not asked yet.** The shells inject
|
|
34
|
+
* their identity before the first script runs, so the first paint is already
|
|
35
|
+
* correct; a shell that did not (an older build) leaves this module with no
|
|
36
|
+
* identity, and the answer is the caller's own padding rather than a guess.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { isDesktopShell } from '@/client/lib/desktop/bridge';
|
|
40
|
+
|
|
41
|
+
/** Width macOS reserves for its traffic lights, plus the gap after them. */
|
|
42
|
+
export const TRAFFIC_LIGHT_INSET = '5rem';
|
|
43
|
+
|
|
44
|
+
/** The shell's own report, or null in a browser (and in an older shell). */
|
|
45
|
+
function shellInfo(): { overlayTitlebar: boolean; trafficLightInset?: string; windowControls?: boolean } | null {
|
|
46
|
+
if (typeof window === 'undefined') return null;
|
|
47
|
+
const injected = window.__kabmoDesktop;
|
|
48
|
+
return injected ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True when the shell hid the native title bar and the page draws it. */
|
|
52
|
+
export function overlayTitlebar(): boolean {
|
|
53
|
+
if (!isDesktopShell()) return false;
|
|
54
|
+
// The injected identity is authoritative when present: a shell that reports
|
|
55
|
+
// `overlayTitlebar: false` draws its own native bar and the page must not
|
|
56
|
+
// reserve anything for controls it does not have.
|
|
57
|
+
const injected = shellInfo();
|
|
58
|
+
return injected ? injected.overlayTitlebar : true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The attribute that marks an element as a window drag handle.
|
|
63
|
+
*
|
|
64
|
+
* `data-drag-region` is read by the macOS shell's own hit test. It is an
|
|
65
|
+
* ATTRIBUTE rather than a class so it cannot be reordered away by a utility
|
|
66
|
+
* class and does not read as styling.
|
|
67
|
+
*/
|
|
68
|
+
export function dragRegionProps(): Record<string, string> {
|
|
69
|
+
return overlayTitlebar() ? { 'data-drag-region': '' } : {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The left inset the first header on the strip must carry.
|
|
74
|
+
*
|
|
75
|
+
* macOS: the traffic lights' width — the caller's own padding would be *inside*
|
|
76
|
+
* them. Windows and Linux: the caller's own padding, because the caption
|
|
77
|
+
* buttons are on the other edge (or drawn by the page) and reserving here would
|
|
78
|
+
* leave a hole. Everywhere else it is what the caller already resolved, which is
|
|
79
|
+
* the PWA's Window Controls Overlay arithmetic (`titlebar-area-x`).
|
|
80
|
+
*/
|
|
81
|
+
export function titlebarInsetLeft(resolved: string): string {
|
|
82
|
+
if (!overlayTitlebar()) return resolved;
|
|
83
|
+
return shellInfo()?.trafficLightInset ?? resolved;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* True when the page must draw the window's own controls.
|
|
88
|
+
*
|
|
89
|
+
* The Windows and Linux shells answer true: both are borderless, and Windows'
|
|
90
|
+
* webview is an HWND that no host element can be drawn over, so the three
|
|
91
|
+
* buttons the macOS shell gets from the system have to come from the page there.
|
|
92
|
+
* Linux COULD draw them (`GtkOverlay` children do receive clicks over a WebView,
|
|
93
|
+
* measured), but the page's set already exists and is tested.
|
|
94
|
+
*/
|
|
95
|
+
export function drawsWindowControls(): boolean {
|
|
96
|
+
return overlayTitlebar() && shellInfo()?.windowControls === true;
|
|
97
|
+
}
|
|
@@ -19,6 +19,7 @@ import { definePluginApp } from '@kabmo-labs/kabmo-sdk/app';
|
|
|
19
19
|
import {
|
|
20
20
|
failPluginBundle,
|
|
21
21
|
headerDropdownOf,
|
|
22
|
+
headerOpensPanelOf,
|
|
22
23
|
headerTriggerOf,
|
|
23
24
|
panelOf,
|
|
24
25
|
pluginSlotState,
|
|
@@ -80,6 +81,59 @@ describe('registerPluginBundle', () => {
|
|
|
80
81
|
expect(headerDropdownOf(slots)).toBeUndefined();
|
|
81
82
|
});
|
|
82
83
|
|
|
84
|
+
test('an opensPanel header keeps the slot it opens, and no dropdown', () => {
|
|
85
|
+
// The navbar resolves this to `plugin:<id>:<slot>` and the layout decides
|
|
86
|
+
// the COLUMN, so the store has to carry the slot and nothing else.
|
|
87
|
+
registerPluginBundle('demo', {
|
|
88
|
+
default: definePluginApp((app) => {
|
|
89
|
+
app.rightPanel({ id: 'r', component: Noop });
|
|
90
|
+
app.headerPanel({ id: 'h', component: Noop, opensPanel: 'rightPanel' });
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const slots = pluginSlotState().slots.get('demo')!;
|
|
95
|
+
expect(headerOpensPanelOf(slots)).toBe('rightPanel');
|
|
96
|
+
expect(headerDropdownOf(slots)).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a header may not declare both a dropdown and opensPanel', () => {
|
|
100
|
+
// One click cannot open two things. Refused at registration rather than
|
|
101
|
+
// drawing a button that silently does one of them.
|
|
102
|
+
registerPluginBundle('demo', {
|
|
103
|
+
default: definePluginApp((app) =>
|
|
104
|
+
app.headerPanel({ id: 'h', component: Noop, opensPanel: 'panel', dropdown: { component: Noop } }),
|
|
105
|
+
),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const failure = pluginSlotState().failures.find((entry) => entry.pluginId === 'demo');
|
|
109
|
+
expect(failure?.reason).toContain('both a dropdown and opensPanel');
|
|
110
|
+
expect(pluginSlotState().slots.has('demo')).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('an opensPanel naming a panel the plugin never registered is refused', () => {
|
|
114
|
+
// A header that opens a panel this bundle did not contribute is a click
|
|
115
|
+
// with nothing behind it, so the registration is refused by name.
|
|
116
|
+
registerPluginBundle('demo', {
|
|
117
|
+
default: definePluginApp((app) => app.headerPanel({ id: 'h', component: Noop, opensPanel: 'panel' })),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const failure = pluginSlotState().failures.find((entry) => entry.pluginId === 'demo');
|
|
121
|
+
expect(failure?.reason).toContain('names a panel this plugin did not register');
|
|
122
|
+
expect(pluginSlotState().slots.has('demo')).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('the panel may be registered AFTER the header that opens it', () => {
|
|
126
|
+
registerPluginBundle('demo', {
|
|
127
|
+
default: definePluginApp((app) => {
|
|
128
|
+
app.headerPanel({ id: 'h', component: Noop, opensPanel: 'rightPanel' });
|
|
129
|
+
app.rightPanel({ id: 'r', component: Noop });
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(pluginSlotState().failures).toEqual([]);
|
|
134
|
+
expect(headerOpensPanelOf(pluginSlotState().slots.get('demo')!)).toBe('rightPanel');
|
|
135
|
+
});
|
|
136
|
+
|
|
83
137
|
test('refuses a second registration in the same slot, with the slot named', () => {
|
|
84
138
|
registerPluginBundle('demo', {
|
|
85
139
|
default: definePluginApp((app) => {
|
|
@@ -65,6 +65,17 @@ export interface PluginSlots {
|
|
|
65
65
|
* dropdown is a static readout rather than a broken button.
|
|
66
66
|
*/
|
|
67
67
|
headerDropdown?: unknown;
|
|
68
|
+
/**
|
|
69
|
+
* The panel a header trigger OPENS, when it declared `opensPanel`.
|
|
70
|
+
*
|
|
71
|
+
* Held here rather than on `components` for the same reason the dropdown is:
|
|
72
|
+
* it is a property of the header registration, and the surface that draws the
|
|
73
|
+
* button (the navbar) is not the surface that draws the panel. The host
|
|
74
|
+
* resolves it to `plugin:<id>:<slot>` at the moment of the click, so the
|
|
75
|
+
* placement rules (left dock, right column, editor column, phone drawer) all
|
|
76
|
+
* apply exactly as they do for the activity bar's own button.
|
|
77
|
+
*/
|
|
78
|
+
headerOpensPanel?: 'rightPanel' | 'panel';
|
|
68
79
|
}
|
|
69
80
|
|
|
70
81
|
/** One plugin's bundle could not be loaded, or refused to register. */
|
|
@@ -119,6 +130,11 @@ export function headerDropdownOf(slots: PluginSlots): ComponentType<HeaderDropdo
|
|
|
119
130
|
return slots.headerDropdown as ComponentType<HeaderDropdownProps> | undefined;
|
|
120
131
|
}
|
|
121
132
|
|
|
133
|
+
/** The panel a header trigger opens, when it declared `opensPanel`. */
|
|
134
|
+
export function headerOpensPanelOf(slots: PluginSlots): 'rightPanel' | 'panel' | undefined {
|
|
135
|
+
return slots.headerOpensPanel;
|
|
136
|
+
}
|
|
137
|
+
|
|
122
138
|
export function settingsSectionOf(slots: PluginSlots): ComponentType<SettingsSectionProps> | undefined {
|
|
123
139
|
return slots.components.settingsSection as ComponentType<SettingsSectionProps> | undefined;
|
|
124
140
|
}
|
|
@@ -142,6 +158,7 @@ export function registerPluginBundle(pluginId: string, module: unknown): void {
|
|
|
142
158
|
const titles: PluginSlots['titles'] = {};
|
|
143
159
|
const sizing: PluginSlots['sizing'] = {};
|
|
144
160
|
let headerDropdown: unknown;
|
|
161
|
+
let headerOpensPanel: 'rightPanel' | 'panel' | undefined;
|
|
145
162
|
|
|
146
163
|
// A bundle re-imported under a new URL (a rebuild) registers AGAIN, and the
|
|
147
164
|
// list slots refuse a duplicate id — so the previous rows go first. Doing it
|
|
@@ -171,9 +188,15 @@ export function registerPluginBundle(pluginId: string, module: unknown): void {
|
|
|
171
188
|
register('rightPanel', component, id, title, { minWidth, defaultFraction }),
|
|
172
189
|
panel: ({ id, title, component, minWidth, defaultFraction }) =>
|
|
173
190
|
register('panel', component, id, title, { minWidth, defaultFraction }),
|
|
174
|
-
headerPanel: ({ id, title, component, dropdown }) => {
|
|
191
|
+
headerPanel: ({ id, title, component, dropdown, opensPanel }) => {
|
|
175
192
|
register('headerPanel', component, id, title);
|
|
193
|
+
if (dropdown && opensPanel) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`headerPanel "${id}" declares both a dropdown and opensPanel — a trigger has one click, so it can open one of them`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
176
198
|
if (dropdown) headerDropdown = dropdown.component;
|
|
199
|
+
if (opensPanel) headerOpensPanel = opensPanel;
|
|
177
200
|
},
|
|
178
201
|
settingsSection: ({ id, title, component }) => register('settingsSection', component, id, title),
|
|
179
202
|
// The list slots: each is a keyed registration that RETURNS its own
|
|
@@ -205,6 +228,18 @@ export function registerPluginBundle(pluginId: string, module: unknown): void {
|
|
|
205
228
|
return;
|
|
206
229
|
}
|
|
207
230
|
|
|
231
|
+
// Checked AFTER `setup`, because a plugin may register the header before the
|
|
232
|
+
// panel it names. A button that opens a panel this bundle never contributed
|
|
233
|
+
// is a click with nothing behind it, so it is refused by name rather than
|
|
234
|
+
// drawn.
|
|
235
|
+
if (headerOpensPanel && !components[headerOpensPanel]) {
|
|
236
|
+
failPluginBundle(
|
|
237
|
+
pluginId,
|
|
238
|
+
`headerPanel opensPanel "${headerOpensPanel}" names a panel this plugin did not register`,
|
|
239
|
+
);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
208
243
|
const slots = new Map(state.slots);
|
|
209
244
|
slots.set(pluginId, {
|
|
210
245
|
pluginId,
|
|
@@ -212,6 +247,7 @@ export function registerPluginBundle(pluginId: string, module: unknown): void {
|
|
|
212
247
|
titles,
|
|
213
248
|
sizing,
|
|
214
249
|
...(headerDropdown ? { headerDropdown } : {}),
|
|
250
|
+
...(headerOpensPanel ? { headerOpensPanel } : {}),
|
|
215
251
|
});
|
|
216
252
|
publish({ slots, failures: state.failures.filter((entry) => entry.pluginId !== pluginId) });
|
|
217
253
|
}
|
|
@@ -168,7 +168,7 @@ describe('closeDb', () => {
|
|
|
168
168
|
// continuation is still pending when `closeDb` runs.
|
|
169
169
|
const pending = getDb();
|
|
170
170
|
const retired = globalThis.__kabmoDb as { disposed?: boolean } | undefined;
|
|
171
|
-
closeDb();
|
|
171
|
+
await closeDb();
|
|
172
172
|
expect(retired?.disposed).toBe(true);
|
|
173
173
|
|
|
174
174
|
// The open settles as a refusal rather than handing back a handle whose
|
|
@@ -181,7 +181,7 @@ describe('closeDb', () => {
|
|
|
181
181
|
Bun.env.KABMO_DB_PATH = path.join(tempRoot(), 'after', 'db.sqlite');
|
|
182
182
|
const db = await getDb();
|
|
183
183
|
expect(db).toBeDefined();
|
|
184
|
-
closeDb();
|
|
184
|
+
await closeDb();
|
|
185
185
|
} finally {
|
|
186
186
|
globalThis.__kabmoDb = savedSlot;
|
|
187
187
|
}
|
|
@@ -193,7 +193,7 @@ describe('closeDb', () => {
|
|
|
193
193
|
Bun.env.KABMO_DB_PATH = path.join(tempRoot(), 'resolved', 'db.sqlite');
|
|
194
194
|
globalThis.__kabmoDb = undefined;
|
|
195
195
|
const db = await getDb();
|
|
196
|
-
closeDb();
|
|
196
|
+
await closeDb();
|
|
197
197
|
expect(globalThis.__kabmoDb ?? null).toBeNull();
|
|
198
198
|
// A closed handle refuses further work rather than silently succeeding.
|
|
199
199
|
expect(() => db.raw.exec('SELECT 1')).toThrow();
|
|
@@ -201,4 +201,45 @@ describe('closeDb', () => {
|
|
|
201
201
|
globalThis.__kabmoDb = savedSlot;
|
|
202
202
|
}
|
|
203
203
|
});
|
|
204
|
+
|
|
205
|
+
test('the returned promise settles only once the in-flight handle is closed', async () => {
|
|
206
|
+
// The bug this pins is a RACE, and it is the one the Release gate hit:
|
|
207
|
+
// retiring the slot is synchronous, but an open already in flight still
|
|
208
|
+
// reaches `createDb` and OPENS a file. A caller that deleted the temp
|
|
209
|
+
// directory on the strength of a synchronous return raced a handle that did
|
|
210
|
+
// not exist yet — and could never win, because `removeDir` retries with
|
|
211
|
+
// `Bun.sleepSync`, which blocks the very continuation that closes it.
|
|
212
|
+
//
|
|
213
|
+
// Asserted on the observable the caller needs: after `await closeDb()`, no
|
|
214
|
+
// handle is open and none is about to be. A database file that was opened
|
|
215
|
+
// and closed again reports no further work through it, and the slot is
|
|
216
|
+
// clear, so the next open uses whatever path is configured by then.
|
|
217
|
+
const savedSlot = globalThis.__kabmoDb;
|
|
218
|
+
try {
|
|
219
|
+
const dir = tempRoot();
|
|
220
|
+
Bun.env.KABMO_DB_PATH = path.join(dir, 'settle', 'db.sqlite');
|
|
221
|
+
globalThis.__kabmoDb = undefined;
|
|
222
|
+
|
|
223
|
+
const pending = getDb();
|
|
224
|
+
const retiring = closeDb();
|
|
225
|
+
|
|
226
|
+
// A synchronous return is the bug, not a detail: it is what let a caller
|
|
227
|
+
// delete the directory while the open was still about to create a file.
|
|
228
|
+
expect(retiring).toBeInstanceOf(Promise);
|
|
229
|
+
|
|
230
|
+
// Both settle, and the retirement is what decides when the caller may
|
|
231
|
+
// touch the directory — so it must not resolve before the open has been
|
|
232
|
+
// closed. The open's own rejection is expected and is not an error.
|
|
233
|
+
await expect(pending).rejects.toThrow(/retired/);
|
|
234
|
+
await expect(retiring).resolves.toBeUndefined();
|
|
235
|
+
expect(globalThis.__kabmoDb ?? null).toBeNull();
|
|
236
|
+
|
|
237
|
+
// The proof the caller needs: the directory can be removed the moment the
|
|
238
|
+
// promise settles, with no retry window spent.
|
|
239
|
+
removeDir(dir);
|
|
240
|
+
expect(existsSync(dir)).toBe(false);
|
|
241
|
+
} finally {
|
|
242
|
+
globalThis.__kabmoDb = savedSlot;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
204
245
|
});
|
package/src/server/db.server.ts
CHANGED
|
@@ -73,11 +73,25 @@ function slot(): DbSlot {
|
|
|
73
73
|
* a lifecycle one: a handle must never outlive the configuration it was opened
|
|
74
74
|
* under. An in-flight open is marked `disposed` rather than awaited, and the
|
|
75
75
|
* continuation closes its own handle — see {@link DbSlot.disposed}.
|
|
76
|
+
*
|
|
77
|
+
* **The returned promise is the contract, and it is not decoration.** Marking
|
|
78
|
+
* the slot `disposed` does not stop an open that is already running: the
|
|
79
|
+
* continuation still reaches `createDb`, which OPENS a file. A caller that
|
|
80
|
+
* deleted the temp directory on the strength of a synchronous return therefore
|
|
81
|
+
* raced a handle that did not exist yet — measured on the Release gate, where
|
|
82
|
+
* `releaseDb` failed with `EBUSY … a handle outlived its test` after burning
|
|
83
|
+
* the whole retry window, and the continuation's own rejection surfaced as
|
|
84
|
+
* "Unhandled error between tests". The retry could not win, and the reason is
|
|
85
|
+
* worth stating: `removeDir` spins with `Bun.sleepSync`, which BLOCKS the event
|
|
86
|
+
* loop, so the very continuation that would close the handle could never
|
|
87
|
+
* progress while the delete was being retried.
|
|
88
|
+
*
|
|
89
|
+
* Awaiting this promise means "no handle is open, and none is about to be".
|
|
76
90
|
*/
|
|
77
|
-
export function closeDb(): void {
|
|
91
|
+
export function closeDb(): Promise<void> {
|
|
78
92
|
const state = globalThis.__kabmoDb;
|
|
79
93
|
globalThis.__kabmoDb = undefined;
|
|
80
|
-
if (!state) return;
|
|
94
|
+
if (!state) return Promise.resolve();
|
|
81
95
|
state.disposed = true;
|
|
82
96
|
try {
|
|
83
97
|
state.resolved?.raw.close();
|
|
@@ -85,7 +99,17 @@ export function closeDb(): void {
|
|
|
85
99
|
// Already closed by a failed open; the slot is gone either way.
|
|
86
100
|
}
|
|
87
101
|
state.resolved = null;
|
|
102
|
+
const pending = state.promise;
|
|
88
103
|
state.promise = null;
|
|
104
|
+
// Swallowed deliberately: the caller is RETIRING this handle, so the
|
|
105
|
+
// continuation's "retired before it finished" rejection is the expected
|
|
106
|
+
// outcome rather than an error. Letting it escape here would resurrect the
|
|
107
|
+
// unhandled-rejection noise this await exists to remove.
|
|
108
|
+
return pending ? pending.then(noop, noop) : Promise.resolve();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function noop(): void {
|
|
112
|
+
// See `closeDb`: the settlement is the signal, not the value.
|
|
89
113
|
}
|
|
90
114
|
|
|
91
115
|
export async function getDatabasePath(): Promise<string> {
|
|
@@ -66,12 +66,6 @@ describe('describeShellBuildFailure', () => {
|
|
|
66
66
|
expect(await describeShellBuildFailure(root)).toBeNull();
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
test('returns null when there is no shell entrypoint to build', async () => {
|
|
70
|
-
rmSync(join(root, 'index.html'));
|
|
71
|
-
|
|
72
|
-
expect(await describeShellBuildFailure(root)).toBeNull();
|
|
73
|
-
});
|
|
74
|
-
|
|
75
69
|
test('loads the plugins named in bunfig.toml, as the route does', async () => {
|
|
76
70
|
// A plugin that fails a file the route would bundle: if the probe ignored
|
|
77
71
|
// bunfig it would report a clean build and hide the real failure.
|