@kabmo-labs/kabmo 0.5.3 → 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-9js9mrmj.js → index-ae9z0yvm.js} +704 -704
- 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 +5 -8
- 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 -2
- 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/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/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/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/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/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/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
|
@@ -28,14 +28,14 @@ import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
|
|
|
28
28
|
const AGENT_DIR = path.join(os.tmpdir(), `omc-batch-delete-${process.pid}`, 'agent');
|
|
29
29
|
let priorAgentDir: string | undefined;
|
|
30
30
|
|
|
31
|
-
beforeAll(() => {
|
|
31
|
+
beforeAll(async () => {
|
|
32
32
|
// This suite reaches the database: `removeSession` → `purgeSessionData`
|
|
33
33
|
// resolves `getDb()`. Without isolating, it inherits whatever handle an
|
|
34
34
|
// earlier file left on the process-wide slot — and when that handle points at
|
|
35
35
|
// a temp directory its owner already deleted, SQLite answers `disk I/O error`
|
|
36
36
|
// and the verdict loses its code (measured on CI: `not_found` came back
|
|
37
37
|
// `undefined` while the same file passed alone).
|
|
38
|
-
isolateDb();
|
|
38
|
+
await isolateDb();
|
|
39
39
|
fs.rmSync(AGENT_DIR, { recursive: true, force: true });
|
|
40
40
|
fs.mkdirSync(path.join(AGENT_DIR, 'sessions'), { recursive: true });
|
|
41
41
|
priorAgentDir = Bun.env.PI_CODING_AGENT_DIR;
|
|
@@ -43,12 +43,12 @@ beforeAll(() => {
|
|
|
43
43
|
clearSessionFileCaches();
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
afterAll(() => {
|
|
46
|
+
afterAll(async () => {
|
|
47
47
|
clearSessionFileCaches();
|
|
48
48
|
if (priorAgentDir === undefined) delete Bun.env.PI_CODING_AGENT_DIR;
|
|
49
49
|
else Bun.env.PI_CODING_AGENT_DIR = priorAgentDir;
|
|
50
50
|
fs.rmSync(path.dirname(AGENT_DIR), { recursive: true, force: true });
|
|
51
|
-
releaseDb();
|
|
51
|
+
await releaseDb();
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
function post(body: unknown, url: string): Promise<Response> {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { json, NO_STORE_HEADERS } from '@/server/lib/remix-compat';
|
|
2
|
-
import {
|
|
2
|
+
import { fetchShellRelease, type ShellRelease } from '@/server/lib/updates/shell-release';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* GET /api/updates/android — the newest APK a visitor can download.
|
|
@@ -14,7 +14,7 @@ import { fetchAndroidRelease, type AndroidRelease } from '@/server/lib/updates/a
|
|
|
14
14
|
* is to omit a button it cannot back with a link.
|
|
15
15
|
*/
|
|
16
16
|
export async function loader() {
|
|
17
|
-
const release:
|
|
17
|
+
const release: ShellRelease | null = await fetchShellRelease('android');
|
|
18
18
|
return json(
|
|
19
19
|
release ?? { url: null, latest: null, releaseUrl: null },
|
|
20
20
|
{ headers: NO_STORE_HEADERS },
|
|
@@ -3,10 +3,12 @@ import * as updatesAndroid from '@/server/routes/updates/android';
|
|
|
3
3
|
import * as updatesApply from '@/server/routes/updates/apply';
|
|
4
4
|
import * as updatesChangelog from '@/server/routes/updates/changelog';
|
|
5
5
|
import * as updatesCheck from '@/server/routes/updates/check';
|
|
6
|
+
import * as updatesIos from '@/server/routes/updates/ios';
|
|
6
7
|
|
|
7
8
|
export const updatesBindings: HandlerBinding[] = [
|
|
8
9
|
...bindingsFor(updatesAndroid, '/api/updates/android'),
|
|
9
10
|
...bindingsFor(updatesApply, '/api/updates/apply'),
|
|
10
11
|
...bindingsFor(updatesChangelog, '/api/updates/changelog'),
|
|
11
12
|
...bindingsFor(updatesCheck, '/api/updates/check'),
|
|
13
|
+
...bindingsFor(updatesIos, '/api/updates/ios'),
|
|
12
14
|
];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { json, NO_STORE_HEADERS } from '@/server/lib/remix-compat';
|
|
2
|
+
import { fetchShellRelease, type ShellRelease } from '@/server/lib/updates/shell-release';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /api/updates/ios — the newest iOS shell a visitor can install.
|
|
6
|
+
*
|
|
7
|
+
* A separate route from `/api/updates/android` even though both read the same
|
|
8
|
+
* release list: the two shells are different artifacts on different tracks, and
|
|
9
|
+
* one endpoint answering "the newest shell" would force the client to say which
|
|
10
|
+
* one it meant in a query string — a parameter whose absence silently means
|
|
11
|
+
* Android.
|
|
12
|
+
*
|
|
13
|
+
* Only an INSTALLED shell asks for this. There is no browser-side install offer
|
|
14
|
+
* on iOS (an IPA cannot be sideloaded from a page the way an APK can), so the
|
|
15
|
+
* payload is only ever compared against `KabmoIOS/<version>`.
|
|
16
|
+
*
|
|
17
|
+
* A failed probe is a valid answer (`url: null`), never a 500: the sidebar's job
|
|
18
|
+
* is to omit a button it cannot back with a link.
|
|
19
|
+
*/
|
|
20
|
+
export async function loader() {
|
|
21
|
+
const release: ShellRelease | null = await fetchShellRelease('ios');
|
|
22
|
+
return json(
|
|
23
|
+
release ?? { url: null, latest: null, releaseUrl: null },
|
|
24
|
+
{ headers: NO_STORE_HEADERS },
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -44,11 +44,11 @@ async function call(body: unknown): Promise<{ status: number; body: Record<strin
|
|
|
44
44
|
|
|
45
45
|
beforeAll(async () => {
|
|
46
46
|
fs.mkdirSync(ROOT, { recursive: true });
|
|
47
|
-
isolateDb();
|
|
47
|
+
await isolateDb();
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
afterAll(async () => {
|
|
51
|
-
releaseDb();
|
|
51
|
+
await releaseDb();
|
|
52
52
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
53
53
|
});
|
|
54
54
|
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { ModelsData, ProviderItem } from '@/shared/types';
|
|
15
|
+
import type { ModelRoleCandidate } from '@/shared/types/model-roles';
|
|
15
16
|
import { emitRealtimeSignal } from '@/server/lib/realtime/signals.server';
|
|
16
17
|
|
|
17
18
|
declare global {
|
|
@@ -19,6 +20,15 @@ declare global {
|
|
|
19
20
|
var __kabmoModelsCache: { data: ModelsData; expiresAt: number } | undefined;
|
|
20
21
|
// eslint-disable-next-line no-var
|
|
21
22
|
var __kabmoProvidersRpcCache: { data: ProviderItem[]; expiresAt: number } | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* The role dropdowns' candidate list (`lib/model-roles/catalog.server.ts`).
|
|
25
|
+
* Declared HERE because it is dropped by the same signal: a model removed
|
|
26
|
+
* from models.yml, or a provider that lost its credential, must leave the
|
|
27
|
+
* role pickers too — otherwise they keep offering a selector omp can no
|
|
28
|
+
* longer resolve.
|
|
29
|
+
*/
|
|
30
|
+
// eslint-disable-next-line no-var
|
|
31
|
+
var __kabmoRoleCandidatesCache: { data: ModelRoleCandidate[]; expiresAt: number } | undefined;
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
/**
|
|
@@ -33,5 +43,6 @@ declare global {
|
|
|
33
43
|
export function invalidateModelsCaches(): void {
|
|
34
44
|
globalThis.__kabmoModelsCache = undefined;
|
|
35
45
|
globalThis.__kabmoProvidersRpcCache = undefined;
|
|
46
|
+
globalThis.__kabmoRoleCandidatesCache = undefined;
|
|
36
47
|
emitRealtimeSignal('models-changed');
|
|
37
48
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* omp's built-in model roles, and which catalog kinds each one accepts.
|
|
8
|
+
*
|
|
9
|
+
* One table, read by the role editor (labels + sections), by the role dropdowns
|
|
10
|
+
* (which models to offer) and by the server (which role ids a preset may hold).
|
|
11
|
+
* It mirrors `MODEL_ROLES` in `@oh-my-pi/pi-coding-agent/src/config/model-roles.ts`
|
|
12
|
+
* — the `accepts` predicates are flattened into `kinds` so the same rule can run
|
|
13
|
+
* on a catalog row that has no `Model` object.
|
|
14
|
+
*
|
|
15
|
+
* `kinds` is a CATALOG-kind list, and the predicates upstream are not all
|
|
16
|
+
* expressible that way: `web` also accepts chat models that declare a grounding
|
|
17
|
+
* transport (`webSearch`), which the catalog JSON does not carry. Those rows are
|
|
18
|
+
* marked `webCapable` by the server and admitted by
|
|
19
|
+
* {@link roleAcceptsCandidate} — the one predicate both the UI and the tests use.
|
|
20
|
+
*
|
|
21
|
+
* `widget` ("Job widgets") exists at omp 18.12.6 and was missing from this
|
|
22
|
+
* panel, which made it the only role a user could not set from the console.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { ModelRoleCandidate } from '@/shared/types/model-roles';
|
|
26
|
+
|
|
27
|
+
export interface ModelRoleInfo {
|
|
28
|
+
/** omp's display name for the role (`MODEL_ROLES[role].name`). */
|
|
29
|
+
label: string;
|
|
30
|
+
/** omp's section: a chat model, or a model of that kind only. */
|
|
31
|
+
section: 'chat' | 'kind';
|
|
32
|
+
/** Catalog kinds this role accepts. */
|
|
33
|
+
kinds: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** omp's `MODEL_ROLES` table, in its own declaration order. */
|
|
37
|
+
export const MODEL_ROLES: Record<string, ModelRoleInfo> = {
|
|
38
|
+
default: { label: 'Default', section: 'chat', kinds: ['chat'] },
|
|
39
|
+
smol: { label: 'Fast', section: 'chat', kinds: ['chat'] },
|
|
40
|
+
slow: { label: 'Thinking', section: 'chat', kinds: ['chat'] },
|
|
41
|
+
vision: { label: 'Vision', section: 'chat', kinds: ['chat'] },
|
|
42
|
+
plan: { label: 'Architect', section: 'chat', kinds: ['chat'] },
|
|
43
|
+
commit: { label: 'Commit', section: 'chat', kinds: ['chat'] },
|
|
44
|
+
tiny: { label: 'Tiny', section: 'chat', kinds: ['tiny', 'chat'] },
|
|
45
|
+
memory: { label: 'Memory', section: 'chat', kinds: ['tiny', 'chat'] },
|
|
46
|
+
task: { label: 'Subtask', section: 'chat', kinds: ['chat'] },
|
|
47
|
+
advisor: { label: 'Advisor', section: 'chat', kinds: ['chat'] },
|
|
48
|
+
widget: { label: 'Job widgets', section: 'chat', kinds: ['chat'] },
|
|
49
|
+
image: { label: 'Image generation', section: 'kind', kinds: ['image'] },
|
|
50
|
+
web: { label: 'Web search', section: 'kind', kinds: ['search'] },
|
|
51
|
+
speech: { label: 'Speech (TTS)', section: 'kind', kinds: ['tts'] },
|
|
52
|
+
dictation: { label: 'Dictation (STT)', section: 'kind', kinds: ['stt'] },
|
|
53
|
+
judge: { label: 'Judge', section: 'kind', kinds: ['judge', 'tiny', 'chat'] },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Role ids in omp's declaration order. */
|
|
57
|
+
export const ROLE_IDS: string[] = Object.keys(MODEL_ROLES);
|
|
58
|
+
|
|
59
|
+
/** Whether `role` is one of omp's built-in roles. */
|
|
60
|
+
export function isModelRole(role: string): boolean {
|
|
61
|
+
return Object.prototype.hasOwnProperty.call(MODEL_ROLES, role);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether a catalog row can fill `role`.
|
|
66
|
+
*
|
|
67
|
+
* `web` is the only role whose upstream predicate needs a field the catalog
|
|
68
|
+
* JSON omits, so its chat rows are admitted through `webCapable` — a search
|
|
69
|
+
* provider is admitted by kind alone.
|
|
70
|
+
*/
|
|
71
|
+
export function roleAcceptsCandidate(role: string, candidate: ModelRoleCandidate): boolean {
|
|
72
|
+
const info = MODEL_ROLES[role];
|
|
73
|
+
if (!info) return false;
|
|
74
|
+
if (info.kinds.includes(candidate.kind)) return true;
|
|
75
|
+
return role === 'web' && candidate.kind === 'chat' && candidate.webCapable === true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Role ids: a letter, then letters, digits, `-` or `_` (omp's own role-name rule). */
|
|
79
|
+
const ROLE_ID_PATTERN = /^[a-zA-Z][\w-]*$/;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Validate a `modelRoles` map from a request.
|
|
83
|
+
*
|
|
84
|
+
* The keys are NOT narrowed to omp's built-ins: `modelRoles` accepts custom role
|
|
85
|
+
* ids and omp adds them to its own role list, so a filtered copy would silently
|
|
86
|
+
* delete a custom role the moment a preset was applied (the map is written
|
|
87
|
+
* whole). A key that cannot be a role name, or an empty selector, is dropped —
|
|
88
|
+
* those are typos, not assignments.
|
|
89
|
+
*/
|
|
90
|
+
export function normalizeRoleMap(raw: unknown): Record<string, string> {
|
|
91
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
92
|
+
throw new Error('roles must be a role → model selector map');
|
|
93
|
+
}
|
|
94
|
+
const roles: Record<string, string> = {};
|
|
95
|
+
for (const [role, value] of Object.entries(raw)) {
|
|
96
|
+
if (!ROLE_ID_PATTERN.test(role)) continue;
|
|
97
|
+
if (typeof value !== 'string' || !value.trim()) continue;
|
|
98
|
+
roles[role] = value.trim();
|
|
99
|
+
}
|
|
100
|
+
return roles;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Candidates for one role, ordered by provider then display name. */
|
|
104
|
+
export function candidatesForRole(role: string, candidates: readonly ModelRoleCandidate[]): ModelRoleCandidate[] {
|
|
105
|
+
return candidates
|
|
106
|
+
.filter((candidate) => roleAcceptsCandidate(role, candidate))
|
|
107
|
+
.sort((a, b) => a.provider.localeCompare(b.provider) || a.name.localeCompare(b.name));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Two `modelRoles` maps hold the same assignments (order-insensitive). */
|
|
111
|
+
export function roleMapsEqual(a: Record<string, string>, b: Record<string, string>): boolean {
|
|
112
|
+
const keys = Object.keys(a);
|
|
113
|
+
if (keys.length !== Object.keys(b).length) return false;
|
|
114
|
+
return keys.every((key) => b[key] === a[key]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The preset whose assignments ARE the live map, or null.
|
|
119
|
+
*
|
|
120
|
+
* This is how the picker says which preset is in force: not a stored "last
|
|
121
|
+
* applied" flag, which goes stale the moment a role is edited by hand — the map
|
|
122
|
+
* itself is the state, and a preset matches when it equals it exactly.
|
|
123
|
+
*/
|
|
124
|
+
export function matchPreset<T extends { roles: Record<string, string> }>(
|
|
125
|
+
presets: readonly T[],
|
|
126
|
+
roles: Record<string, string>,
|
|
127
|
+
): T | null {
|
|
128
|
+
return presets.find((preset) => roleMapsEqual(preset.roles, roles)) ?? null;
|
|
129
|
+
}
|
|
@@ -5,51 +5,83 @@
|
|
|
5
5
|
|
|
6
6
|
import { describe, expect, test } from 'bun:test';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { isAndroidDevice, shellVersion, SHELL_UA_PREFIXES } from '@/shared/lib/updates/shell-ua';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* The token is the only signal that a page is running inside
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* The token is the only signal that a page is running inside a native shell, and
|
|
12
|
+
* it decides whether a download button appears. Two failure modes matter: a
|
|
13
|
+
* browser mistaken for the shell (a button in a place it does not belong) and a
|
|
14
14
|
* malformed token read as a version (a comparison against `''`).
|
|
15
|
+
*
|
|
16
|
+
* Both shells are exercised against the same parser, because the grammar is one
|
|
17
|
+
* grammar and only the prefix differs — the shape that silently reads the other
|
|
18
|
+
* shell's version.
|
|
15
19
|
*/
|
|
16
|
-
describe('
|
|
17
|
-
test('reads the version from a real WebView UA', () => {
|
|
20
|
+
describe('shellVersion', () => {
|
|
21
|
+
test('reads the version from a real Android WebView UA', () => {
|
|
18
22
|
const ua =
|
|
19
23
|
'Mozilla/5.0 (Linux; Android 15; Pixel 6 Build/AP4A; wv) AppleWebKit/537.36 (KHTML, like Gecko) '
|
|
20
|
-
+ `Version/4.0 Chrome/124.0.6367.219 Mobile Safari/537.36 ${
|
|
21
|
-
expect(
|
|
24
|
+
+ `Version/4.0 Chrome/124.0.6367.219 Mobile Safari/537.36 ${SHELL_UA_PREFIXES.android}0.1.0`;
|
|
25
|
+
expect(shellVersion(ua, 'android')).toBe('0.1.0');
|
|
26
|
+
expect(shellVersion(ua, 'ios')).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('reads the version from a real iOS WKWebView UA', () => {
|
|
30
|
+
const ua =
|
|
31
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) '
|
|
32
|
+
+ `Mobile/15E148 ${SHELL_UA_PREFIXES.ios}0.1.0`;
|
|
33
|
+
expect(shellVersion(ua, 'ios')).toBe('0.1.0');
|
|
34
|
+
// The platform token is `iPhone`, not `Android` — so the Android install
|
|
35
|
+
// offer must not fire for the iOS shell.
|
|
36
|
+
expect(isAndroidDevice(ua)).toBe(false);
|
|
22
37
|
});
|
|
23
38
|
|
|
24
39
|
test('is null for a plain browser', () => {
|
|
25
|
-
|
|
26
|
-
expect(
|
|
27
|
-
expect(
|
|
28
|
-
expect(
|
|
40
|
+
const browser = 'Mozilla/5.0 (Macintosh) AppleWebKit/537.36 Chrome/124.0 Safari/537.36';
|
|
41
|
+
expect(shellVersion(browser, 'android')).toBeNull();
|
|
42
|
+
expect(shellVersion(browser, 'ios')).toBeNull();
|
|
43
|
+
expect(shellVersion('', 'android')).toBeNull();
|
|
44
|
+
expect(shellVersion(null, 'android')).toBeNull();
|
|
45
|
+
expect(shellVersion(undefined, 'ios')).toBeNull();
|
|
29
46
|
});
|
|
30
47
|
|
|
31
48
|
test('is null when the token carries no usable version', () => {
|
|
32
49
|
// A forged or truncated token must not read as version '' — that would make
|
|
33
50
|
// `isNewer(latest, '')` true for every release and show a button always.
|
|
34
|
-
expect(
|
|
35
|
-
expect(
|
|
36
|
-
expect(
|
|
51
|
+
expect(shellVersion('Mozilla/5.0 KabmoAndroid/', 'android')).toBeNull();
|
|
52
|
+
expect(shellVersion('Mozilla/5.0 KabmoAndroid/abc', 'android')).toBeNull();
|
|
53
|
+
expect(shellVersion('Mozilla/5.0 KabmoAndroid/1.x.3', 'android')).toBeNull();
|
|
54
|
+
expect(shellVersion('Mozilla/5.0 KabmoIOS/', 'ios')).toBeNull();
|
|
55
|
+
expect(shellVersion('Mozilla/5.0 KabmoIOS/1.x.3', 'ios')).toBeNull();
|
|
37
56
|
});
|
|
38
57
|
|
|
39
58
|
test('accepts one-, two- and three-part versions and a prerelease', () => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
59
|
+
for (const prefix of Object.values(SHELL_UA_PREFIXES)) {
|
|
60
|
+
const kind = prefix === SHELL_UA_PREFIXES.android ? 'android' : 'ios';
|
|
61
|
+
expect(shellVersion(`${prefix}1`, kind)).toBe('1');
|
|
62
|
+
expect(shellVersion(`${prefix}1.2`, kind)).toBe('1.2');
|
|
63
|
+
expect(shellVersion(`${prefix}1.2.3`, kind)).toBe('1.2.3');
|
|
64
|
+
expect(shellVersion(`${prefix}1.2.3-beta.1`, kind)).toBe('1.2.3-beta.1');
|
|
65
|
+
}
|
|
44
66
|
});
|
|
45
67
|
|
|
46
68
|
test('takes the first token when the UA was crafted twice', () => {
|
|
47
|
-
expect(
|
|
69
|
+
expect(shellVersion('KabmoAndroid/1.0.0 KabmoAndroid/9.9.9', 'android')).toBe('1.0.0');
|
|
70
|
+
expect(shellVersion('KabmoIOS/1.0.0 KabmoIOS/9.9.9', 'ios')).toBe('1.0.0');
|
|
48
71
|
});
|
|
49
72
|
|
|
50
73
|
test('does not match a lookalike token', () => {
|
|
51
|
-
expect(
|
|
52
|
-
expect(
|
|
74
|
+
expect(shellVersion('KabmoAndroidX/1.0.0', 'android')).toBeNull();
|
|
75
|
+
expect(shellVersion('Mozilla KabmoAndroid', 'android')).toBeNull();
|
|
76
|
+
expect(shellVersion('KabmoIOSX/1.0.0', 'ios')).toBeNull();
|
|
77
|
+
expect(shellVersion('Mozilla KabmoIOS', 'ios')).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('the two prefixes are distinct', () => {
|
|
81
|
+
// A shared prefix would make one shell's token readable as the other's, and
|
|
82
|
+
// the two artifacts are on separate release tracks.
|
|
83
|
+
expect(SHELL_UA_PREFIXES.android).not.toBe(SHELL_UA_PREFIXES.ios);
|
|
84
|
+
expect(SHELL_UA_PREFIXES.android.startsWith(SHELL_UA_PREFIXES.ios)).toBe(false);
|
|
53
85
|
});
|
|
54
86
|
});
|
|
55
87
|
|
|
@@ -82,7 +114,7 @@ describe('isAndroidDevice', () => {
|
|
|
82
114
|
expect(
|
|
83
115
|
isAndroidDevice(
|
|
84
116
|
`Mozilla/5.0 (Linux; Android 15; Pixel 6 Build/AP4A; wv) AppleWebKit/537.36 (KHTML, like Gecko) `
|
|
85
|
-
+ `Version/4.0 Chrome/124.0.6367.219 Mobile Safari/537.36 ${
|
|
117
|
+
+ `Version/4.0 Chrome/124.0.6367.219 Mobile Safari/537.36 ${SHELL_UA_PREFIXES.android}0.3.0`,
|
|
86
118
|
),
|
|
87
119
|
).toBe(true);
|
|
88
120
|
});
|
|
@@ -114,7 +146,7 @@ describe('isAndroidDevice', () => {
|
|
|
114
146
|
});
|
|
115
147
|
|
|
116
148
|
test('does not match a lookalike word', () => {
|
|
117
|
-
//
|
|
149
|
+
// `` is what stops a crafted or unrelated token from counting.
|
|
118
150
|
expect(isAndroidDevice('Mozilla/5.0 (compatible; NotAndroidBot/1.0)')).toBe(false);
|
|
119
151
|
expect(isAndroidDevice('Mozilla/5.0 AndroidX/1.0')).toBe(false);
|
|
120
152
|
});
|
|
@@ -1,35 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `KabmoAndroid/<version>` token in a request's
|
|
2
|
+
* The `KabmoAndroid/<version>` / `KabmoIOS/<version>` token in a request's
|
|
3
|
+
* User-Agent.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Each native shell appends its own token, and it is the console's only signal
|
|
6
|
+
* for two questions at once: "am I running inside a shell" and "which build of
|
|
7
|
+
* it". The UA is the right channel because it crosses the very request that
|
|
8
|
+
* renders the shell, so the answer needs no bridge, no extra endpoint, and no
|
|
9
|
+
* round trip — the page is served already knowing.
|
|
10
|
+
*
|
|
11
|
+
* ONE parser for both shells, because the token grammar is one grammar and two
|
|
12
|
+
* copies would be two answers to "is this a version". What differs per shell is
|
|
13
|
+
* only the prefix, which is the table below.
|
|
9
14
|
*
|
|
10
15
|
* Parsed leniently and defensively: a browser can be made to send any UA, so a
|
|
11
16
|
* forged token buys nothing here (the value only decides whether to show a
|
|
12
|
-
* download button), and a malformed one must read as "not the
|
|
13
|
-
* as version `''`.
|
|
17
|
+
* download button), and a malformed one must read as "not the shell" rather
|
|
18
|
+
* than as version `''`.
|
|
14
19
|
*/
|
|
15
20
|
|
|
16
|
-
/** The
|
|
17
|
-
export
|
|
21
|
+
/** The native shells that report themselves on the UA. */
|
|
22
|
+
export type ShellKind = 'android' | 'ios';
|
|
23
|
+
|
|
24
|
+
/** The token's name, before the slash, per shell. */
|
|
25
|
+
export const SHELL_UA_PREFIXES: Record<ShellKind, string> = {
|
|
26
|
+
android: 'KabmoAndroid/',
|
|
27
|
+
ios: 'KabmoIOS/',
|
|
28
|
+
};
|
|
18
29
|
|
|
19
30
|
/**
|
|
20
31
|
* Whether this request came from an Android device at all — the app or a
|
|
21
32
|
* browser on it.
|
|
22
33
|
*
|
|
23
|
-
* The download button has two reasons to appear and they are different
|
|
24
|
-
* an installed APK that is behind (an UPDATE), and a reader using
|
|
25
|
-
* Brave on a phone who may not know a native shell exists (an
|
|
26
|
-
* second one needs this fact, which the shell token cannot
|
|
27
|
-
* carries no `KabmoAndroid/` token, and "no token" alone
|
|
28
|
-
* every platform, including a desktop where an APK is
|
|
34
|
+
* The Android download button has two reasons to appear and they are different
|
|
35
|
+
* offers: an installed APK that is behind (an UPDATE), and a reader using
|
|
36
|
+
* Chrome or Brave on a phone who may not know a native shell exists (an
|
|
37
|
+
* INSTALL). The second one needs this fact, which the shell token cannot
|
|
38
|
+
* supply: a browser carries no `KabmoAndroid/` token, and "no token" alone
|
|
39
|
+
* means "not the app" on every platform, including a desktop where an APK is
|
|
40
|
+
* useless.
|
|
29
41
|
*
|
|
30
42
|
* Matched on `Android`, the platform token every Android browser sends, and NOT
|
|
31
43
|
* on `Mobile`: an iPad sends `Mobile` and cannot install an APK, while an
|
|
32
44
|
* Android tablet sends `Android` without it.
|
|
45
|
+
*
|
|
46
|
+
* There is deliberately no iOS counterpart. An IPA cannot be installed from a
|
|
47
|
+
* browser at all — iOS has no sideload-by-URL path — so an iOS browser is
|
|
48
|
+
* offered nothing, and only the installed shell asks for an update.
|
|
33
49
|
*/
|
|
34
50
|
export function isAndroidDevice(userAgent: string | null | undefined): boolean {
|
|
35
51
|
if (!userAgent) return false;
|
|
@@ -37,17 +53,22 @@ export function isAndroidDevice(userAgent: string | null | undefined): boolean {
|
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
/**
|
|
40
|
-
* The
|
|
56
|
+
* The shell version that sent this request, or null when it is not that shell.
|
|
41
57
|
*
|
|
42
58
|
* The FIRST matching token wins: the suffix is appended once, and a duplicate
|
|
43
59
|
* would mean something else crafted the string.
|
|
44
60
|
*/
|
|
45
|
-
export function
|
|
61
|
+
export function shellVersion(
|
|
62
|
+
userAgent: string | null | undefined,
|
|
63
|
+
kind: ShellKind,
|
|
64
|
+
): string | null {
|
|
46
65
|
if (!userAgent) return null;
|
|
47
66
|
|
|
67
|
+
const prefix = SHELL_UA_PREFIXES[kind];
|
|
68
|
+
|
|
48
69
|
for (const token of userAgent.split(/\s+/)) {
|
|
49
|
-
if (!token.startsWith(
|
|
50
|
-
const version = token.slice(
|
|
70
|
+
if (!token.startsWith(prefix)) continue;
|
|
71
|
+
const version = token.slice(prefix.length);
|
|
51
72
|
// A version, not an arbitrary string: `1`, `1.2`, `1.2.3`, optionally with a
|
|
52
73
|
// prerelease suffix. Anything else is a token this parser does not own.
|
|
53
74
|
if (/^\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?$/.test(version)) return version;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which phone drawer can reach a panel, and when none can.
|
|
8
|
+
*
|
|
9
|
+
* Three answers and each is a silent failure when wrong:
|
|
10
|
+
*
|
|
11
|
+
* - a panel placed on the left with the rail ON belongs to the LEFT drawer —
|
|
12
|
+
* the right drawer's catalog deliberately drops it (`phoneRightPanels`), so
|
|
13
|
+
* sending the user there would open a drawer that does not list it;
|
|
14
|
+
* - the same panel with the rail OFF belongs to the RIGHT drawer, which is the
|
|
15
|
+
* rule that keeps a placement reachable when the left drawer has no strip;
|
|
16
|
+
* - an `editor`-slot takeover belongs to NEITHER, because a phone has no editor
|
|
17
|
+
* column — the caller must do nothing rather than navigate to a blank screen.
|
|
18
|
+
*
|
|
19
|
+
* A built-in view is always `right`, so it never lands on the left drawer even
|
|
20
|
+
* though its id is not a plugin key.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, expect, test } from 'bun:test';
|
|
24
|
+
import { phoneDrawerForPanel } from '@/shared/lib/workspace/panel-open';
|
|
25
|
+
import type { LeftDockState } from '@/shared/lib/workspace/left-dock';
|
|
26
|
+
|
|
27
|
+
const RAIL_ON: LeftDockState = { enabled: true, panels: ['plugin:stats:rightPanel'] };
|
|
28
|
+
const RAIL_OFF: LeftDockState = { enabled: false, panels: ['plugin:stats:rightPanel'] };
|
|
29
|
+
|
|
30
|
+
describe('phoneDrawerForPanel', () => {
|
|
31
|
+
test('a panel placed on the left, with the rail on, is the left drawer', () => {
|
|
32
|
+
expect(phoneDrawerForPanel('plugin:stats:rightPanel', RAIL_ON)).toBe('left');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('the same placement with the rail off is the right drawer', () => {
|
|
36
|
+
// The left drawer draws no chip strip while the rail is off, so the panel
|
|
37
|
+
// stays in the right drawer's `all` catalog — and that is where the click
|
|
38
|
+
// has to land.
|
|
39
|
+
expect(phoneDrawerForPanel('plugin:stats:rightPanel', RAIL_OFF)).toBe('right');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('an unplaced panel is the right drawer', () => {
|
|
43
|
+
expect(phoneDrawerForPanel('plugin:stats:rightPanel', { enabled: true, panels: [] })).toBe('right');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a built-in view is the right drawer, never the left one', () => {
|
|
47
|
+
expect(phoneDrawerForPanel('git', RAIL_ON)).toBe('right');
|
|
48
|
+
expect(phoneDrawerForPanel('files', RAIL_OFF)).toBe('right');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('an editor-slot takeover has no drawer at all', () => {
|
|
52
|
+
// A phone has no editor column. Reporting a drawer would navigate to a
|
|
53
|
+
// screen whose catalog cannot show the panel.
|
|
54
|
+
expect(phoneDrawerForPanel('plugin:session-info:panel', RAIL_ON)).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Opening a panel from somewhere that is not its own rail.
|
|
8
|
+
*
|
|
9
|
+
* A plugin's navbar entry can be an ACTION — `app.headerPanel({ opensPanel })`
|
|
10
|
+
* — and so can anything else that names a panel it does not draw. The click
|
|
11
|
+
* cannot resolve the destination itself, because the destination is the USER's
|
|
12
|
+
* choice: the same panel may sit on the left dock, in the right column, in the
|
|
13
|
+
* editor column, or in one of a phone's two drawers. So the event carries the
|
|
14
|
+
* panel INSTANCE key and the layout decides where that key lives right now.
|
|
15
|
+
*
|
|
16
|
+
* The placement rules live in `left-dock.ts` (one source, read by the catalogs,
|
|
17
|
+
* the rails and this module), and this file adds the one thing they cannot
|
|
18
|
+
* answer: which of a PHONE's two drawers can reach a panel. A phone has no
|
|
19
|
+
* rail, so "left" and "right" are screens rather than columns — and a panel
|
|
20
|
+
* placed on the left is drawn by the RIGHT drawer while the rail is off, which
|
|
21
|
+
* is the case a naive `side === 'left' ? leftDrawer : rightDrawer` gets wrong.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { panelSideOf, type LeftDockState, type PanelSide } from '@/shared/lib/workspace/left-dock';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The window event that asks a layout to show a panel.
|
|
28
|
+
*
|
|
29
|
+
* `detail.panelKey` is a panel INSTANCE key (`plugin:<pluginId>:<slot>`) or a
|
|
30
|
+
* built-in view id. A key no layout can resolve is reported by the panel
|
|
31
|
+
* position's own notice, never silently ignored — the same answer the activity
|
|
32
|
+
* bar gives for a panel whose plugin was uninstalled.
|
|
33
|
+
*/
|
|
34
|
+
export const OPEN_PANEL_EVENT = 'omp:open-panel';
|
|
35
|
+
|
|
36
|
+
export interface OpenPanelDetail {
|
|
37
|
+
/** The panel instance key to show. */
|
|
38
|
+
panelKey: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Ask the layout to show a panel.
|
|
43
|
+
*
|
|
44
|
+
* The one place the event is dispatched: a navbar readout, a folded menu row and
|
|
45
|
+
* anything else that names a panel must send the SAME shape, or one of them
|
|
46
|
+
* silently stops opening anything.
|
|
47
|
+
*/
|
|
48
|
+
export function requestOpenPanel(panelKey: string): void {
|
|
49
|
+
if (typeof window === 'undefined' || !panelKey) return;
|
|
50
|
+
window.dispatchEvent(new CustomEvent(OPEN_PANEL_EVENT, { detail: { panelKey } }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The two screens a phone can put a panel on. */
|
|
54
|
+
export type PanelDrawer = 'left' | 'right';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The phone drawer that can reach a panel, or null when none can.
|
|
58
|
+
*
|
|
59
|
+
* Three answers, each a different fact:
|
|
60
|
+
*
|
|
61
|
+
* - `left` — the panel is placed on the dock AND the rail is on, so the left
|
|
62
|
+
* drawer is where its chip is drawn;
|
|
63
|
+
* - `right` — everything else that the right drawer lists. That includes a
|
|
64
|
+
* panel placed on the left while the rail is OFF: `phoneRightPanels` keeps it
|
|
65
|
+
* there on purpose, because the left drawer then has no strip and the
|
|
66
|
+
* placement would otherwise be unreachable;
|
|
67
|
+
* - `null` — an `editor`-slot takeover. The phone has no editor column, so
|
|
68
|
+
* there is no screen to navigate to and the caller must do nothing rather
|
|
69
|
+
* than send the user to a drawer that will not show it.
|
|
70
|
+
*/
|
|
71
|
+
export function phoneDrawerForPanel(panelKey: string, dock: LeftDockState): PanelDrawer | null {
|
|
72
|
+
const side: PanelSide = panelSideOf(panelKey, dock);
|
|
73
|
+
if (side === 'editor') return null;
|
|
74
|
+
if (side === 'left' && dock.enabled) return 'left';
|
|
75
|
+
return 'right';
|
|
76
|
+
}
|