@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,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Model-role state for the OMP Engine panel: the live `modelRoles` map, the
|
|
8
|
+
* stored presets, and the catalog the role dropdowns offer.
|
|
9
|
+
*
|
|
10
|
+
* Extracted from `OmpSettings` because it is a self-contained store with its own
|
|
11
|
+
* three writes, and because the panel file was crossing the per-file ceiling.
|
|
12
|
+
* The load is still driven by the panel (it fetches everything in one
|
|
13
|
+
* `Promise.all` so a single spinner covers the whole screen), so this hook
|
|
14
|
+
* exposes `applyPayload` for that fetch and `reload` for a standalone refresh.
|
|
15
|
+
*
|
|
16
|
+
* Every write answers with the WHOLE list/map from the server and is adopted
|
|
17
|
+
* verbatim: the route normalizes (blank selectors dropped, the map written
|
|
18
|
+
* whole), so the panel shows what config.yml and the table actually hold rather
|
|
19
|
+
* than an optimistic guess that can drift.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useCallback, useState } from 'preact/hooks';
|
|
23
|
+
import type { ModelRoleCandidate, ModelRolePreset } from '@/shared/types/model-roles';
|
|
24
|
+
|
|
25
|
+
export interface ModelRoleState {
|
|
26
|
+
roles: Record<string, string>;
|
|
27
|
+
presets: ModelRolePreset[];
|
|
28
|
+
candidates: ModelRoleCandidate[];
|
|
29
|
+
candidatesError: string | null;
|
|
30
|
+
/** The last write's refusal (omp's or the table's own sentence), cleared on the next write. */
|
|
31
|
+
writeError: string | null;
|
|
32
|
+
clearWriteError: () => void;
|
|
33
|
+
/** Adopt the `GET /api/model-roles` payload (the panel's combined load). */
|
|
34
|
+
applyPayload: (data: unknown) => void;
|
|
35
|
+
/** Replace the whole map — a single role edit and applying a preset both. */
|
|
36
|
+
writeRoles: (roles: Record<string, string>) => Promise<boolean>;
|
|
37
|
+
savePreset: (name: string, roles: Record<string, string>) => Promise<boolean>;
|
|
38
|
+
updatePreset: (preset: ModelRolePreset) => Promise<boolean>;
|
|
39
|
+
deletePreset: (preset: ModelRolePreset) => Promise<boolean>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readRoles(data: unknown): Record<string, string> | null {
|
|
43
|
+
if (typeof data !== 'object' || data === null || !('roles' in data)) return null;
|
|
44
|
+
const roles = data.roles;
|
|
45
|
+
if (typeof roles !== 'object' || roles === null || Array.isArray(roles)) return null;
|
|
46
|
+
return Object.fromEntries(
|
|
47
|
+
Object.entries(roles).filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The server's own refusal sentence, when the payload carries one. */
|
|
52
|
+
function errorText(data: unknown): string | null {
|
|
53
|
+
if (typeof data !== 'object' || data === null || !('error' in data)) return null;
|
|
54
|
+
return typeof data.error === 'string' ? data.error : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function useModelRoles(onSaved: (key: string) => void): ModelRoleState {
|
|
58
|
+
const [roles, setRoles] = useState<Record<string, string>>({});
|
|
59
|
+
const [presets, setPresets] = useState<ModelRolePreset[]>([]);
|
|
60
|
+
const [candidates, setCandidates] = useState<ModelRoleCandidate[]>([]);
|
|
61
|
+
const [candidatesError, setCandidatesError] = useState<string | null>(null);
|
|
62
|
+
const [writeError, setWriteError] = useState<string | null>(null);
|
|
63
|
+
|
|
64
|
+
const clearWriteError = useCallback(() => setWriteError(null), []);
|
|
65
|
+
|
|
66
|
+
const applyPayload = useCallback((data: unknown) => {
|
|
67
|
+
const nextRoles = readRoles(data);
|
|
68
|
+
if (nextRoles) setRoles(nextRoles);
|
|
69
|
+
if (typeof data !== 'object' || data === null) return;
|
|
70
|
+
if ('presets' in data && Array.isArray(data.presets)) setPresets(data.presets as ModelRolePreset[]);
|
|
71
|
+
if ('candidates' in data && Array.isArray(data.candidates)) setCandidates(data.candidates as ModelRoleCandidate[]);
|
|
72
|
+
if ('candidatesError' in data) {
|
|
73
|
+
setCandidatesError(typeof data.candidatesError === 'string' ? data.candidatesError : null);
|
|
74
|
+
}
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
const writeRoles = useCallback(async (next: Record<string, string>): Promise<boolean> => {
|
|
78
|
+
// The previous map, so a refusal puts the rows back: leaving the optimistic
|
|
79
|
+
// draft on screen after config.yml rejected it would show a role assignment
|
|
80
|
+
// that does not exist, and the next edit would build on it.
|
|
81
|
+
const previous = roles;
|
|
82
|
+
setRoles(next);
|
|
83
|
+
setWriteError(null);
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch('/api/model-roles', {
|
|
86
|
+
method: 'PUT',
|
|
87
|
+
headers: { 'Content-Type': 'application/json' },
|
|
88
|
+
body: JSON.stringify({ roles: next }),
|
|
89
|
+
});
|
|
90
|
+
const data: unknown = await res.json();
|
|
91
|
+
const stored = readRoles(data);
|
|
92
|
+
if (stored) setRoles(stored);
|
|
93
|
+
const ok = typeof data === 'object' && data !== null && 'success' in data && data.success === true;
|
|
94
|
+
if (ok) onSaved('role-map');
|
|
95
|
+
else {
|
|
96
|
+
setRoles(previous);
|
|
97
|
+
setWriteError(errorText(data) ?? 'The role write was refused');
|
|
98
|
+
}
|
|
99
|
+
return ok;
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.error('Failed to save model roles:', err);
|
|
102
|
+
setRoles(previous);
|
|
103
|
+
setWriteError(err instanceof Error ? err.message : String(err));
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}, [onSaved, roles]);
|
|
107
|
+
|
|
108
|
+
/** Save/update/delete a preset; every answer carries the whole preset list. */
|
|
109
|
+
const writePreset = useCallback(async (init: RequestInit, query = ''): Promise<boolean> => {
|
|
110
|
+
setWriteError(null);
|
|
111
|
+
try {
|
|
112
|
+
const res = await fetch(`/api/model-roles${query}`, init);
|
|
113
|
+
const data: unknown = await res.json();
|
|
114
|
+
if (typeof data === 'object' && data !== null && 'presets' in data && Array.isArray(data.presets)) {
|
|
115
|
+
setPresets(data.presets as ModelRolePreset[]);
|
|
116
|
+
}
|
|
117
|
+
const ok = typeof data === 'object' && data !== null && 'success' in data && data.success === true;
|
|
118
|
+
if (!ok) setWriteError(errorText(data) ?? 'The preset write was refused');
|
|
119
|
+
return ok;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error('Failed to save model role preset:', err);
|
|
122
|
+
setWriteError(err instanceof Error ? err.message : String(err));
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
const savePreset = useCallback((name: string, map: Record<string, string>) => writePreset({
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: { 'Content-Type': 'application/json' },
|
|
130
|
+
body: JSON.stringify({ preset: { name, roles: map } }),
|
|
131
|
+
}), [writePreset]);
|
|
132
|
+
|
|
133
|
+
const updatePreset = useCallback((preset: ModelRolePreset) => writePreset({
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { 'Content-Type': 'application/json' },
|
|
136
|
+
body: JSON.stringify({ preset: { id: preset.id, name: preset.name, roles: preset.roles } }),
|
|
137
|
+
}), [writePreset]);
|
|
138
|
+
|
|
139
|
+
const deletePreset = useCallback((preset: ModelRolePreset) => writePreset({
|
|
140
|
+
method: 'DELETE',
|
|
141
|
+
}, `?id=${encodeURIComponent(preset.id)}`), [writePreset]);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
roles,
|
|
145
|
+
presets,
|
|
146
|
+
candidates,
|
|
147
|
+
candidatesError,
|
|
148
|
+
writeError,
|
|
149
|
+
clearWriteError,
|
|
150
|
+
applyPayload,
|
|
151
|
+
writeRoles,
|
|
152
|
+
savePreset,
|
|
153
|
+
updatePreset,
|
|
154
|
+
deletePreset,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
parseShellRelease,
|
|
10
|
+
shellDownloadLabel,
|
|
11
|
+
shellFromBootstrap,
|
|
12
|
+
shellReleasePath,
|
|
13
|
+
shouldOfferShellDownload,
|
|
14
|
+
type ShellDownload,
|
|
15
|
+
} from '@/client/hooks/ui/updates/shell-download';
|
|
16
|
+
|
|
17
|
+
/** The published release every case compares against. */
|
|
18
|
+
const RELEASE = {
|
|
19
|
+
url: 'https://github.com/kabmo-labs/kabmo/releases/download/v1.2.3/kabmo-android-v1.2.3.apk',
|
|
20
|
+
latest: '1.2.3',
|
|
21
|
+
releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v1.2.3',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const UPDATE: ShellDownload = { ...RELEASE, kind: 'update', platform: 'android' };
|
|
25
|
+
const INSTALL: ShellDownload = { ...RELEASE, kind: 'install', platform: 'android' };
|
|
26
|
+
|
|
27
|
+
describe('parseShellRelease', () => {
|
|
28
|
+
test('reads a complete payload', () => {
|
|
29
|
+
expect(parseShellRelease(RELEASE)).toEqual(RELEASE);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('refuses a payload with no artifact', () => {
|
|
33
|
+
// The route answers this on a failed probe. A version without a URL would be
|
|
34
|
+
// a button that downloads nothing.
|
|
35
|
+
expect(parseShellRelease({ url: null, latest: '1.2.3', releaseUrl: null })).toBeNull();
|
|
36
|
+
expect(parseShellRelease({ url: 'https://x/a.apk', latest: null, releaseUrl: null })).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('refuses a non-object', () => {
|
|
40
|
+
expect(parseShellRelease(null)).toBeNull();
|
|
41
|
+
expect(parseShellRelease('nope')).toBeNull();
|
|
42
|
+
expect(parseShellRelease({})).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('keeps a missing releaseUrl as null', () => {
|
|
46
|
+
expect(parseShellRelease({ url: 'https://x/a.apk', latest: '1.0.0' })?.releaseUrl).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('the offer inside the installed shell', () => {
|
|
51
|
+
test('offers an UPDATE when a newer build is published', () => {
|
|
52
|
+
expect(shouldOfferShellDownload('android', '1.2.2', true, RELEASE)).toEqual(UPDATE);
|
|
53
|
+
expect(shouldOfferShellDownload('ios', '1.2.2', null, RELEASE)).toEqual({
|
|
54
|
+
...RELEASE,
|
|
55
|
+
kind: 'update',
|
|
56
|
+
platform: 'ios',
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('offers nothing when the shell is already current', () => {
|
|
61
|
+
expect(shouldOfferShellDownload('android', '1.2.3', true, RELEASE)).toBeNull();
|
|
62
|
+
expect(shouldOfferShellDownload('ios', '1.2.3', null, RELEASE)).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('offers nothing when the shell is ahead', () => {
|
|
66
|
+
// A sideloaded build newer than the published asset: downloading it would be
|
|
67
|
+
// a downgrade.
|
|
68
|
+
expect(shouldOfferShellDownload('android', '1.3.0', true, RELEASE)).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('offers nothing when the probe failed', () => {
|
|
72
|
+
expect(shouldOfferShellDownload('android', '1.0.0', true, null)).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('the offer to an Android browser', () => {
|
|
77
|
+
test('offers an INSTALL, because there is no installed version to compare', () => {
|
|
78
|
+
// The reported gap: a reader on Chrome or Brave for Android was told
|
|
79
|
+
// nothing, and the shell is exactly what they do not know exists.
|
|
80
|
+
expect(shouldOfferShellDownload('android', null, true, RELEASE)).toEqual(INSTALL);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('offers an install even when the published APK is older than some build', () => {
|
|
84
|
+
// Nothing is installed here, so "newer than" has no left-hand side — the
|
|
85
|
+
// published asset is simply what the button names.
|
|
86
|
+
expect(shouldOfferShellDownload('android', undefined, true, { ...RELEASE, latest: '0.0.1' })).toEqual({
|
|
87
|
+
...INSTALL,
|
|
88
|
+
latest: '0.0.1',
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('offers nothing when no APK is published', () => {
|
|
93
|
+
expect(shouldOfferShellDownload('android', null, true, null)).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('an iOS browser is offered nothing, because an IPA cannot be installed there', () => {
|
|
97
|
+
// iOS has no sideload-by-URL path. Offering a download to a Safari tab on an
|
|
98
|
+
// iPhone would be a button that cannot do what it says.
|
|
99
|
+
expect(shouldOfferShellDownload('ios', null, true, RELEASE)).toBeNull();
|
|
100
|
+
expect(shouldOfferShellDownload('ios', undefined, undefined, RELEASE)).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('everywhere else', () => {
|
|
105
|
+
test('a desktop browser is offered nothing', () => {
|
|
106
|
+
// An APK cannot be installed there, and the app's own update badge already
|
|
107
|
+
// covers the server.
|
|
108
|
+
expect(shouldOfferShellDownload('android', null, false, RELEASE)).toBeNull();
|
|
109
|
+
expect(shouldOfferShellDownload('android', null, null, RELEASE)).toBeNull();
|
|
110
|
+
expect(shouldOfferShellDownload('android', null, undefined, RELEASE)).toBeNull();
|
|
111
|
+
expect(shouldOfferShellDownload('android', '', false, RELEASE)).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('the shell token alone is enough, even if the wider flag is missing', () => {
|
|
115
|
+
// `isAndroid` is derived from the same UA, so a stale bootstrap that predates
|
|
116
|
+
// the field must not silently drop the update offer.
|
|
117
|
+
expect(shouldOfferShellDownload('android', '1.2.2', false, RELEASE)).toEqual(UPDATE);
|
|
118
|
+
expect(shouldOfferShellDownload('android', '1.2.2', undefined, RELEASE)).toEqual(UPDATE);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('shellDownloadLabel', () => {
|
|
123
|
+
test('names the verb the reader needs, and the platform', () => {
|
|
124
|
+
expect(shellDownloadLabel(UPDATE)).toBe('Update Kabmo 1.2.3 for Android');
|
|
125
|
+
expect(shellDownloadLabel(INSTALL)).toBe('Install Kabmo 1.2.3 for Android');
|
|
126
|
+
expect(shellDownloadLabel({ ...UPDATE, platform: 'ios' })).toBe('Update Kabmo 1.2.3 for iOS');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('shellFromBootstrap', () => {
|
|
131
|
+
test('reads whichever token the request carried', () => {
|
|
132
|
+
expect(shellFromBootstrap({ shellVersion: '0.5.0' })).toEqual({ platform: 'android', version: '0.5.0' });
|
|
133
|
+
expect(shellFromBootstrap({ iosShellVersion: '0.1.0' })).toEqual({ platform: 'ios', version: '0.1.0' });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('is null in a browser, and for an absent bootstrap', () => {
|
|
137
|
+
expect(shellFromBootstrap({ shellVersion: null, iosShellVersion: null })).toBeNull();
|
|
138
|
+
expect(shellFromBootstrap({})).toBeNull();
|
|
139
|
+
expect(shellFromBootstrap(undefined)).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('prefers the Android token when both are present', () => {
|
|
143
|
+
// A UA carrying both is crafted rather than observed; one shell has to win,
|
|
144
|
+
// and the one whose platform also has a browser offer is the safer answer.
|
|
145
|
+
expect(shellFromBootstrap({ shellVersion: '0.5.0', iosShellVersion: '0.1.0' })).toEqual({
|
|
146
|
+
platform: 'android',
|
|
147
|
+
version: '0.5.0',
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('shellReleasePath', () => {
|
|
153
|
+
test('is one endpoint per shell', () => {
|
|
154
|
+
expect(shellReleasePath('android')).toBe('/api/updates/android');
|
|
155
|
+
expect(shellReleasePath('ios')).toBe('/api/updates/ios');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the sidebar should offer for a native shell, and why.
|
|
3
|
+
*
|
|
4
|
+
* TWO DIFFERENT OFFERS, from two different facts:
|
|
5
|
+
*
|
|
6
|
+
* - **`update`** — this page is running inside the shell (`shellVersion` from
|
|
7
|
+
* the bootstrap) and a newer build is published. An up-to-date shell offers
|
|
8
|
+
* nothing: a button that downloads the build already installed is worse than
|
|
9
|
+
* no button.
|
|
10
|
+
* - **`install`** — this page is an Android BROWSER (Chrome, Brave, Firefox) and
|
|
11
|
+
* an APK is published. The reader may not know a native shell exists, and
|
|
12
|
+
* telling them is the whole point of the offer; there is no installed version
|
|
13
|
+
* to compare against, so the newest published one is what it names.
|
|
14
|
+
*
|
|
15
|
+
* There is NO iOS install offer. An IPA cannot be installed from a page the way
|
|
16
|
+
* an APK can — iOS has no sideload-by-URL path — so a browser on an iPhone is
|
|
17
|
+
* offered nothing, and only an installed iOS shell ever sees this control.
|
|
18
|
+
*
|
|
19
|
+
* A desktop browser gets neither: an APK cannot be installed there, and the
|
|
20
|
+
* app's own update badge already covers the server.
|
|
21
|
+
*
|
|
22
|
+
* The probe is lazy: it runs only when a shell token or an Android device is
|
|
23
|
+
* present, so a console that can never show the button makes no request for it.
|
|
24
|
+
* ONE hook for both shells, because the decision table is identical and only the
|
|
25
|
+
* artifact, the endpoint and the platform word differ.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
29
|
+
import { isNewer } from '@/shared/lib/updates/semver';
|
|
30
|
+
import type { ShellKind } from '@/shared/lib/updates/shell-ua';
|
|
31
|
+
|
|
32
|
+
/** Which shell the offer is for. */
|
|
33
|
+
export type ShellPlatform = ShellKind;
|
|
34
|
+
|
|
35
|
+
/** Which offer this is — the two carry different wording. */
|
|
36
|
+
export type ShellOfferKind = 'update' | 'install';
|
|
37
|
+
|
|
38
|
+
export interface ShellDownload {
|
|
39
|
+
/** The artifact to download. */
|
|
40
|
+
url: string;
|
|
41
|
+
/** The version that artifact ships. */
|
|
42
|
+
latest: string;
|
|
43
|
+
/** The release page, for a "what changed" link. */
|
|
44
|
+
releaseUrl: string | null;
|
|
45
|
+
/** An update to an installed shell, or a first install from a browser. */
|
|
46
|
+
kind: ShellOfferKind;
|
|
47
|
+
/** Which shell this offer is for. */
|
|
48
|
+
platform: ShellPlatform;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ReleasePayload {
|
|
52
|
+
url?: unknown;
|
|
53
|
+
latest?: unknown;
|
|
54
|
+
releaseUrl?: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The platform's own name, as the label says it. */
|
|
58
|
+
const PLATFORM_NAMES: Record<ShellPlatform, string> = {
|
|
59
|
+
android: 'Android',
|
|
60
|
+
ios: 'iOS',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read the release payload defensively.
|
|
65
|
+
*
|
|
66
|
+
* The route answers `{url: null, latest: null, releaseUrl: null}` on a failed
|
|
67
|
+
* probe, so a shape check that only looks at the version would offer a button
|
|
68
|
+
* with no link.
|
|
69
|
+
*/
|
|
70
|
+
export function parseShellRelease(payload: unknown): Omit<ShellDownload, 'kind' | 'platform'> | null {
|
|
71
|
+
if (typeof payload !== 'object' || payload === null) return null;
|
|
72
|
+
const body = payload as ReleasePayload;
|
|
73
|
+
if (typeof body.url !== 'string' || !body.url) return null;
|
|
74
|
+
if (typeof body.latest !== 'string' || !body.latest) return null;
|
|
75
|
+
return {
|
|
76
|
+
url: body.url,
|
|
77
|
+
latest: body.latest,
|
|
78
|
+
releaseUrl: typeof body.releaseUrl === 'string' ? body.releaseUrl : null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The offer, or null.
|
|
84
|
+
*
|
|
85
|
+
* `version` is null in every browser — that is what makes the two branches
|
|
86
|
+
* mutually exclusive — and `onAndroid` is the wider fact that admits a browser
|
|
87
|
+
* ON ANDROID. Both come from the bootstrap rather than a client-side UA sniff:
|
|
88
|
+
* the server already parsed them for the very request that served the page.
|
|
89
|
+
*
|
|
90
|
+
* An `install` needs no version comparison, because nothing is installed. It
|
|
91
|
+
* does require a published artifact: `release` being null is a failed probe, and
|
|
92
|
+
* a button the console cannot back with a link must not appear.
|
|
93
|
+
*/
|
|
94
|
+
export function shouldOfferShellDownload(
|
|
95
|
+
platform: ShellPlatform,
|
|
96
|
+
version: string | null | undefined,
|
|
97
|
+
onAndroid: boolean | null | undefined,
|
|
98
|
+
release: Omit<ShellDownload, 'kind' | 'platform'> | null,
|
|
99
|
+
): ShellDownload | null {
|
|
100
|
+
if (!release) return null;
|
|
101
|
+
|
|
102
|
+
// Inside the shell: an update, and only when one exists.
|
|
103
|
+
if (version) {
|
|
104
|
+
return isNewer(release.latest, version) ? { ...release, kind: 'update', platform } : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A browser on an Android device: the app is something to install. iOS has no
|
|
108
|
+
// equivalent — an IPA cannot be installed from a page — so the platform is
|
|
109
|
+
// part of this test, not only the label.
|
|
110
|
+
if (platform === 'android' && onAndroid) return { ...release, kind: 'install', platform };
|
|
111
|
+
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The wording of the offer, in one place.
|
|
117
|
+
*
|
|
118
|
+
* Both layouts draw this control and they must say the same thing: an installed
|
|
119
|
+
* shell that is behind is an UPDATE, and a browser on Android is an INSTALL —
|
|
120
|
+
* "Download" for both would leave a first-time reader unsure whether they
|
|
121
|
+
* already have the app. The version is in the label because the artifact is a
|
|
122
|
+
* specific build, not "the app".
|
|
123
|
+
*/
|
|
124
|
+
export function shellDownloadLabel(download: ShellDownload): string {
|
|
125
|
+
const verb = download.kind === 'update' ? 'Update' : 'Install';
|
|
126
|
+
return `${verb} Kabmo ${download.latest} for ${PLATFORM_NAMES[download.platform]}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The endpoint that answers for one shell. */
|
|
130
|
+
export function shellReleasePath(platform: ShellPlatform): string {
|
|
131
|
+
return `/api/updates/${platform}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Which shell this page is running inside, from the bootstrap.
|
|
136
|
+
*
|
|
137
|
+
* The Android token wins when both are somehow present: the two shells are
|
|
138
|
+
* different devices, and a UA carrying both is crafted rather than observed.
|
|
139
|
+
*/
|
|
140
|
+
export function shellFromBootstrap(bootstrap: {
|
|
141
|
+
shellVersion?: string | null;
|
|
142
|
+
iosShellVersion?: string | null;
|
|
143
|
+
} | undefined): { platform: ShellPlatform; version: string } | null {
|
|
144
|
+
if (bootstrap?.shellVersion) return { platform: 'android', version: bootstrap.shellVersion };
|
|
145
|
+
if (bootstrap?.iosShellVersion) return { platform: 'ios', version: bootstrap.iosShellVersion };
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Fetch the published artifact and decide what to offer.
|
|
151
|
+
*
|
|
152
|
+
* `fetchImpl` is a seam for the tests: the cases that matter (no release, a
|
|
153
|
+
* failed probe, a version equal to the installed one) cannot be produced by a
|
|
154
|
+
* live GitHub call.
|
|
155
|
+
*/
|
|
156
|
+
export function useShellDownload(
|
|
157
|
+
platform: ShellPlatform,
|
|
158
|
+
version: string | null | undefined,
|
|
159
|
+
onAndroid: boolean | null | undefined,
|
|
160
|
+
fetchImpl: typeof fetch = fetch,
|
|
161
|
+
): ShellDownload | null {
|
|
162
|
+
const [release, setRelease] = useState<Omit<ShellDownload, 'kind' | 'platform'> | null>(null);
|
|
163
|
+
const wanted = Boolean(version) || (platform === 'android' && Boolean(onAndroid));
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!wanted) return;
|
|
167
|
+
|
|
168
|
+
let cancelled = false;
|
|
169
|
+
void (async () => {
|
|
170
|
+
try {
|
|
171
|
+
const response = await fetchImpl(shellReleasePath(platform));
|
|
172
|
+
if (!response.ok) return;
|
|
173
|
+
const payload: unknown = await response.json();
|
|
174
|
+
if (!cancelled) setRelease(parseShellRelease(payload));
|
|
175
|
+
} catch {
|
|
176
|
+
// A probe that cannot answer is a button that must not appear.
|
|
177
|
+
}
|
|
178
|
+
})();
|
|
179
|
+
|
|
180
|
+
return () => {
|
|
181
|
+
cancelled = true;
|
|
182
|
+
};
|
|
183
|
+
}, [wanted, platform, fetchImpl]);
|
|
184
|
+
|
|
185
|
+
return shouldOfferShellDownload(platform, version, onAndroid, release);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The offer for whichever shell this page is running inside — or the Android
|
|
190
|
+
* install offer for a browser on that platform.
|
|
191
|
+
*
|
|
192
|
+
* The single entry point both sidebar layouts call, so the two cannot disagree
|
|
193
|
+
* about which shell they are in or which endpoint they read. It reads the
|
|
194
|
+
* bootstrap the SERVER wrote for this request rather than sniffing the UA
|
|
195
|
+
* client-side, because the server already parsed it for the very response that
|
|
196
|
+
* carried the page.
|
|
197
|
+
*/
|
|
198
|
+
export function useShellOffer(fetchImpl: typeof fetch = fetch): ShellDownload | null {
|
|
199
|
+
const bootstrap = typeof window === 'undefined' ? undefined : window.__OMP_BOOTSTRAP__;
|
|
200
|
+
const shell = shellFromBootstrap(bootstrap);
|
|
201
|
+
return useShellDownload(
|
|
202
|
+
shell?.platform ?? 'android',
|
|
203
|
+
shell?.version ?? null,
|
|
204
|
+
bootstrap?.isAndroid,
|
|
205
|
+
fetchImpl,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The short form the phone's footer prints, where the label has no room.
|
|
211
|
+
*
|
|
212
|
+
* It names the OFFER, and for Android it names the file type because that is
|
|
213
|
+
* what a reader recognises (`APK` is the thing they will see in Downloads). An
|
|
214
|
+
* iOS update is an IPA, which nobody installs by hand — the shell finishes the
|
|
215
|
+
* install itself — so the platform word is the honest one there.
|
|
216
|
+
*/
|
|
217
|
+
export function shellDownloadShortLabel(download: ShellDownload): string {
|
|
218
|
+
if (download.platform === 'ios') return 'Update app';
|
|
219
|
+
return download.kind === 'update' ? 'Update APK' : 'Get APK';
|
|
220
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The workspace shortcuts, bound once for the desktop layout.
|
|
8
|
+
*
|
|
9
|
+
* Extracted from the layout because it is a self-contained decision — a chord
|
|
10
|
+
* mapped to one of four column/settings toggles — that has nothing to do with
|
|
11
|
+
* what the layout renders. `⌘S` is deliberately absent: the editor owns it, and
|
|
12
|
+
* binding it twice would save the file twice.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useWindowEvent } from '@/client/hooks/ui/window-event';
|
|
16
|
+
import { resolveBinding } from '@/shared/lib/ui/key-binding';
|
|
17
|
+
import { WORKSPACE_KEY_BINDINGS } from '@/shared/lib/workspace/keymap';
|
|
18
|
+
|
|
19
|
+
export interface WorkspaceKeybindingTargets {
|
|
20
|
+
showEditor: boolean;
|
|
21
|
+
/** Whether the editor column has anything to open on to. */
|
|
22
|
+
editorHasContent: boolean;
|
|
23
|
+
toggleSidebar: () => void;
|
|
24
|
+
toggleEditor: () => void;
|
|
25
|
+
toggleRightPanel: () => void;
|
|
26
|
+
openSettings: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useWorkspaceKeybindings(targets: WorkspaceKeybindingTargets): void {
|
|
30
|
+
useWindowEvent('keydown', (event) => {
|
|
31
|
+
const command = resolveBinding(WORKSPACE_KEY_BINDINGS, event);
|
|
32
|
+
if (!command) return;
|
|
33
|
+
if (command === 'saveFile') return;
|
|
34
|
+
// The toggle is the only way to REACH a takeover while the column is
|
|
35
|
+
// closed, since the rail lives inside the column: with nothing to open on
|
|
36
|
+
// to there would be no column at all.
|
|
37
|
+
if (command === 'toggleEditorPanel' && !targets.showEditor && !targets.editorHasContent) return;
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
if (command === 'toggleSidebar') targets.toggleSidebar();
|
|
40
|
+
else if (command === 'toggleEditorPanel') targets.toggleEditor();
|
|
41
|
+
else if (command === 'toggleRightPanel') targets.toggleRightPanel();
|
|
42
|
+
else if (command === 'openSettings') targets.openSettings();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The desktop half of `omp:open-panel`: turn a panel key into a visible column.
|
|
8
|
+
*
|
|
9
|
+
* The COLUMN is the user's placement, read from the dock state, never a choice
|
|
10
|
+
* the caller makes: the same panel may sit on the left dock, in the right
|
|
11
|
+
* column or in the editor column, and a plugin that named one would break the
|
|
12
|
+
* moment the user moved it. Each handler therefore does what the rail's own
|
|
13
|
+
* button does — select the view AND open its column — because a selection made
|
|
14
|
+
* while the column is collapsed is a click that changed nothing on screen.
|
|
15
|
+
*
|
|
16
|
+
* The phone does not use this: its drawers are SCREENS, and which one can reach
|
|
17
|
+
* a panel is a different question (`phoneDrawerForPanel`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useKabmoEvent } from '@/client/hooks/ui/window-event';
|
|
21
|
+
import type { LeftDockState } from '@/shared/lib/workspace/left-dock';
|
|
22
|
+
import { panelSideOf } from '@/shared/lib/workspace/left-dock';
|
|
23
|
+
import { OPEN_PANEL_EVENT, type OpenPanelDetail } from '@/shared/lib/workspace/panel-open';
|
|
24
|
+
|
|
25
|
+
export interface OpenPanelTargets {
|
|
26
|
+
/** The dock state the placement is read from. */
|
|
27
|
+
dock: LeftDockState;
|
|
28
|
+
onLeft: (panelKey: string) => void;
|
|
29
|
+
onEditor: (panelKey: string) => void;
|
|
30
|
+
onRight: (panelKey: string) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useOpenPanelRequest(targets: OpenPanelTargets): void {
|
|
34
|
+
useKabmoEvent(OPEN_PANEL_EVENT, (event) => {
|
|
35
|
+
const panelKey = (event as CustomEvent<OpenPanelDetail>).detail?.panelKey;
|
|
36
|
+
if (!panelKey) return;
|
|
37
|
+
const side = panelSideOf(panelKey, targets.dock);
|
|
38
|
+
if (side === 'left') targets.onLeft(panelKey);
|
|
39
|
+
else if (side === 'editor') targets.onEditor(panelKey);
|
|
40
|
+
else targets.onRight(panelKey);
|
|
41
|
+
});
|
|
42
|
+
}
|