@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
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @license
|
|
3
|
-
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The left column draws ONE separator, and its chrome spans the WHOLE column.
|
|
8
|
-
*
|
|
9
|
-
* Both `LeftColumn` and `DesktopLayout` rendered a `ResizeHandle` after the
|
|
10
|
-
* dock, and both were mounted — so the column boundary drew two hairlines 3px
|
|
11
|
-
* apart and a reader saw a double rule between the session list and the chat.
|
|
12
|
-
* It was reproduced only when the rail was ON, because that is the layout a
|
|
13
|
-
* screenshotted report came from, though the extra handle was there in both
|
|
14
|
-
* states.
|
|
15
|
-
*
|
|
16
|
-
* A geometry test cannot catch the separator (happy-dom computes no layout), so
|
|
17
|
-
* that assertion is on the STRUCTURE: the group's direct children, in order. The
|
|
18
|
-
* second handle was a sibling — `LeftColumn`'s own sits between the dock and
|
|
19
|
-
* whatever follows — which is exactly what an order comparison shows.
|
|
20
|
-
*
|
|
21
|
-
* The chrome half is structural too, and both cases were reported. The strip
|
|
22
|
-
* lived inside the dock BODY while the rail was a sibling panel, so with the
|
|
23
|
-
* rail on it stopped at the body's left edge and left an empty strip under the
|
|
24
|
-
* rail. And the rail started at the column's top, running up past the title row
|
|
25
|
-
* and under the app's own titlebar. The column is three ROWS — title, then
|
|
26
|
-
* (rail | view), then strip — so both are asserted by the row each one lives in.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
30
|
-
import { Window } from 'happy-dom';
|
|
31
|
-
import { h, render } from 'preact';
|
|
32
|
-
import { act } from 'preact/test-utils';
|
|
33
|
-
import { Group } from '@/client/components/layout/desktop-layout/resizer';
|
|
34
|
-
import { LeftColumn } from '@/client/components/layout/desktop-layout/LeftColumn';
|
|
35
|
-
import { SessionStateProvider } from '@/client/components/common/session-state-provider/index';
|
|
36
|
-
import { primeKabmoSettings } from '@/shared/lib/settings/client';
|
|
37
|
-
import { realtimeClient, resetRealtimeClient } from '@/shared/lib/realtime/client';
|
|
38
|
-
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
39
|
-
|
|
40
|
-
let container: HTMLElement | null = null;
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* A socket that never dials.
|
|
44
|
-
*
|
|
45
|
-
* The rail subscribes to the Source Control topic on mount. Left to a REAL
|
|
46
|
-
* socket, its close lands after this file has restored the globals and surfaces
|
|
47
|
-
* as `Unhandled error. (ErrorEvent …)` attributed to whichever suite ran next —
|
|
48
|
-
* measured. This column's subject is its own structure, so the channel is
|
|
49
|
-
* silenced rather than served.
|
|
50
|
-
*/
|
|
51
|
-
class SilentSocket {
|
|
52
|
-
static readonly CLOSING = 2;
|
|
53
|
-
static readonly CLOSED = 3;
|
|
54
|
-
readyState = SilentSocket.CLOSED;
|
|
55
|
-
onopen: (() => void) | null = null;
|
|
56
|
-
onclose: (() => void) | null = null;
|
|
57
|
-
onmessage: ((event: { data: string }) => void) | null = null;
|
|
58
|
-
onerror: (() => void) | null = null;
|
|
59
|
-
send(): void {}
|
|
60
|
-
close(): void {}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
beforeEach(() => {
|
|
64
|
-
installDomGlobals(new Window({ url: 'http://127.0.0.1:1' }));
|
|
65
|
-
realtimeClient.setSocketConstructor(SilentSocket as unknown as typeof WebSocket);
|
|
66
|
-
primeKabmoSettings({});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
afterAll(() => {
|
|
70
|
-
realtimeClient.setSocketConstructor(null);
|
|
71
|
-
restoreDomGlobals();
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
afterEach(() => {
|
|
75
|
-
// The rail's Source Control dot subscribes to a realtime topic, so the render
|
|
76
|
-
// opens a socket. Left open, its close lands in the NEXT file's globals once
|
|
77
|
-
// this one has restored them — measured as `Unhandled error. (ErrorEvent …)`
|
|
78
|
-
// between tests, attributed to whichever suite happened to run next.
|
|
79
|
-
resetRealtimeClient();
|
|
80
|
-
if (container) {
|
|
81
|
-
render(null, container);
|
|
82
|
-
container.remove();
|
|
83
|
-
container = null;
|
|
84
|
-
}
|
|
85
|
-
primeKabmoSettings({});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
async function mountColumn(railEnabled: boolean) {
|
|
89
|
-
const host = document.createElement('div');
|
|
90
|
-
document.body.appendChild(host);
|
|
91
|
-
container = host;
|
|
92
|
-
|
|
93
|
-
await act(async () => {
|
|
94
|
-
render(
|
|
95
|
-
h(SessionStateProvider, {
|
|
96
|
-
sessionId: 'new-separator',
|
|
97
|
-
children: h(Group, {
|
|
98
|
-
orientation: 'horizontal',
|
|
99
|
-
id: 'kabmo-main',
|
|
100
|
-
children: [
|
|
101
|
-
h(LeftColumn, {
|
|
102
|
-
panelRef: { current: null },
|
|
103
|
-
open: true,
|
|
104
|
-
railEnabled,
|
|
105
|
-
view: 'sessions',
|
|
106
|
-
onSelectView: () => {},
|
|
107
|
-
onCollapse: () => {},
|
|
108
|
-
width: 280,
|
|
109
|
-
minWidth: 264,
|
|
110
|
-
hasActiveContext: true,
|
|
111
|
-
refreshKey: 0,
|
|
112
|
-
onRefresh: () => {},
|
|
113
|
-
onOpenFile: () => {},
|
|
114
|
-
}),
|
|
115
|
-
h('div', { 'data-panel-id': 'main-right-stack' }),
|
|
116
|
-
],
|
|
117
|
-
}),
|
|
118
|
-
}),
|
|
119
|
-
host,
|
|
120
|
-
);
|
|
121
|
-
});
|
|
122
|
-
// The dock's view is `lazy()`, so the boundary matters before it resolves:
|
|
123
|
-
// the separator is a SIBLING of the panel, not part of its content.
|
|
124
|
-
for (let i = 0; i < 5; i += 1) await act(async () => {});
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** The group's DIRECT children, in order — a separator appears once per entry. */
|
|
128
|
-
const children = (): string[] =>
|
|
129
|
-
[...(container!.querySelector('[data-panel-group-id="kabmo-main"]')?.children ?? [])].map((child) =>
|
|
130
|
-
child.hasAttribute('data-separator') ? 'separator' : (child.getAttribute('data-panel-id') ?? child.tagName),
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
describe('left column separators', () => {
|
|
134
|
-
test('one separator with the rail on', async () => {
|
|
135
|
-
await mountColumn(true);
|
|
136
|
-
expect(children().filter((entry) => entry === 'separator')).toHaveLength(1);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
test('one separator with the rail off', async () => {
|
|
140
|
-
await mountColumn(false);
|
|
141
|
-
expect(children().filter((entry) => entry === 'separator')).toHaveLength(1);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test('the separator is the column boundary — after the dock, not before it', async () => {
|
|
145
|
-
await mountColumn(true);
|
|
146
|
-
const kids = children();
|
|
147
|
-
expect(kids.at(-1)).toBe('main-right-stack');
|
|
148
|
-
expect(kids.at(-2)).toBe('separator');
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
describe('left column chrome', () => {
|
|
153
|
-
const panel = () => container!.querySelector('[data-panel-id="left-panel"]') as HTMLElement;
|
|
154
|
-
const rail = () => panel().querySelector('nav[aria-label="Left panel views"]');
|
|
155
|
-
const title = () => panel().querySelector('.h-10.flex-shrink-0.flex.items-center.justify-between');
|
|
156
|
-
const footer = () => panel().querySelector('button[aria-label="Settings"]');
|
|
157
|
-
|
|
158
|
-
test('the title row, the rail and the strip are three rows, in that order', async () => {
|
|
159
|
-
await mountColumn(true);
|
|
160
|
-
const column = panel().firstElementChild as HTMLElement;
|
|
161
|
-
const rows = [...column.children];
|
|
162
|
-
expect(rows).toHaveLength(3);
|
|
163
|
-
// Row 0 is the title, row 1 holds the rail beside the view, row 2 the strip.
|
|
164
|
-
expect(rows[0].contains(title())).toBe(true);
|
|
165
|
-
expect(rows[1].contains(rail())).toBe(true);
|
|
166
|
-
expect(rows[2].contains(footer())).toBe(true);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test('the rail starts BELOW the title, never above it', async () => {
|
|
170
|
-
await mountColumn(true);
|
|
171
|
-
const column = panel().firstElementChild as HTMLElement;
|
|
172
|
-
expect(column.children[0].contains(rail())).toBe(false);
|
|
173
|
-
expect(column.children[1].contains(rail())).toBe(true);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test('the strip spans the column, so the rail does not outlive it', async () => {
|
|
177
|
-
await mountColumn(true);
|
|
178
|
-
const column = panel().firstElementChild as HTMLElement;
|
|
179
|
-
expect(column.children[2].contains(rail())).toBe(false);
|
|
180
|
-
expect(column.children[2].contains(footer())).toBe(true);
|
|
181
|
-
// Same row container for title and strip: both are full-width rows of the
|
|
182
|
-
// column, which is what makes the strip run under the rail too.
|
|
183
|
-
expect(column.children[0].parentElement).toBe(column.children[2].parentElement);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
test('the strip survives a view that is not the session list', async () => {
|
|
187
|
-
await mountColumn(true);
|
|
188
|
-
// The column's furniture is rendered outside `LeftDockView` entirely, so it
|
|
189
|
-
// is present even while the lazy view is still resolving.
|
|
190
|
-
expect(footer()).not.toBeNull();
|
|
191
|
-
});
|
|
192
|
-
});
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @license
|
|
3
|
-
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { describe, expect, test } from 'bun:test';
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
androidDownloadLabel,
|
|
10
|
-
parseAndroidRelease,
|
|
11
|
-
shouldOfferAndroidDownload,
|
|
12
|
-
type AndroidDownload,
|
|
13
|
-
} from '@/client/hooks/ui/updates/android-download';
|
|
14
|
-
|
|
15
|
-
/** The published release every case compares against. */
|
|
16
|
-
const RELEASE = {
|
|
17
|
-
url: 'https://github.com/kabmo-labs/kabmo/releases/download/v1.2.3/kabmo-android-v1.2.3.apk',
|
|
18
|
-
latest: '1.2.3',
|
|
19
|
-
releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v1.2.3',
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const UPDATE: AndroidDownload = { ...RELEASE, kind: 'update' };
|
|
23
|
-
const INSTALL: AndroidDownload = { ...RELEASE, kind: 'install' };
|
|
24
|
-
|
|
25
|
-
describe('parseAndroidRelease', () => {
|
|
26
|
-
test('reads a complete payload', () => {
|
|
27
|
-
expect(parseAndroidRelease(RELEASE)).toEqual(RELEASE);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test('refuses a payload with no asset', () => {
|
|
31
|
-
// The route answers this on a failed probe. A version without a URL would be
|
|
32
|
-
// a button that downloads nothing.
|
|
33
|
-
expect(parseAndroidRelease({ url: null, latest: '1.2.3', releaseUrl: null })).toBeNull();
|
|
34
|
-
expect(parseAndroidRelease({ url: 'https://x/a.apk', latest: null, releaseUrl: null })).toBeNull();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test('refuses a non-object', () => {
|
|
38
|
-
expect(parseAndroidRelease(null)).toBeNull();
|
|
39
|
-
expect(parseAndroidRelease('nope')).toBeNull();
|
|
40
|
-
expect(parseAndroidRelease({})).toBeNull();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test('keeps a missing releaseUrl as null', () => {
|
|
44
|
-
expect(parseAndroidRelease({ url: 'https://x/a.apk', latest: '1.0.0' })?.releaseUrl).toBeNull();
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe('the offer inside the installed app', () => {
|
|
49
|
-
test('offers an UPDATE when a newer APK is published', () => {
|
|
50
|
-
expect(shouldOfferAndroidDownload('1.2.2', true, RELEASE)).toEqual(UPDATE);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test('offers nothing when the app is already current', () => {
|
|
54
|
-
expect(shouldOfferAndroidDownload('1.2.3', true, RELEASE)).toBeNull();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test('offers nothing when the app is ahead', () => {
|
|
58
|
-
// A sideloaded build newer than the published asset: downloading it would be
|
|
59
|
-
// a downgrade.
|
|
60
|
-
expect(shouldOfferAndroidDownload('1.3.0', true, RELEASE)).toBeNull();
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test('offers nothing when the probe failed', () => {
|
|
64
|
-
expect(shouldOfferAndroidDownload('1.0.0', true, null)).toBeNull();
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
describe('the offer to an Android browser', () => {
|
|
69
|
-
test('offers an INSTALL, because there is no installed version to compare', () => {
|
|
70
|
-
// The reported gap: a reader on Chrome or Brave for Android was told
|
|
71
|
-
// nothing, and the shell is exactly what they do not know exists.
|
|
72
|
-
expect(shouldOfferAndroidDownload(null, true, RELEASE)).toEqual(INSTALL);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test('offers an install even when the published APK is older than some build', () => {
|
|
76
|
-
// Nothing is installed here, so "newer than" has no left-hand side — the
|
|
77
|
-
// published asset is simply what the button names.
|
|
78
|
-
expect(shouldOfferAndroidDownload(undefined, true, { ...RELEASE, latest: '0.0.1' })).toEqual({
|
|
79
|
-
...INSTALL,
|
|
80
|
-
latest: '0.0.1',
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test('offers nothing when no APK is published', () => {
|
|
85
|
-
expect(shouldOfferAndroidDownload(null, true, null)).toBeNull();
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
describe('everywhere else', () => {
|
|
90
|
-
test('a desktop browser is offered nothing', () => {
|
|
91
|
-
// An APK cannot be installed there, and the app's own update badge already
|
|
92
|
-
// covers the server.
|
|
93
|
-
expect(shouldOfferAndroidDownload(null, false, RELEASE)).toBeNull();
|
|
94
|
-
expect(shouldOfferAndroidDownload(null, null, RELEASE)).toBeNull();
|
|
95
|
-
expect(shouldOfferAndroidDownload(null, undefined, RELEASE)).toBeNull();
|
|
96
|
-
expect(shouldOfferAndroidDownload('', false, RELEASE)).toBeNull();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test('the shell token alone is enough, even if the wider flag is missing', () => {
|
|
100
|
-
// `isAndroid` is derived from the same UA, so a stale bootstrap that predates
|
|
101
|
-
// the field must not silently drop the update offer.
|
|
102
|
-
expect(shouldOfferAndroidDownload('1.2.2', false, RELEASE)).toEqual(UPDATE);
|
|
103
|
-
expect(shouldOfferAndroidDownload('1.2.2', undefined, RELEASE)).toEqual(UPDATE);
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
describe('androidDownloadLabel', () => {
|
|
108
|
-
test('names the verb the reader needs', () => {
|
|
109
|
-
expect(androidDownloadLabel(UPDATE)).toBe('Update Kabmo 1.2.3 for Android');
|
|
110
|
-
expect(androidDownloadLabel(INSTALL)).toBe('Install Kabmo 1.2.3 for Android');
|
|
111
|
-
});
|
|
112
|
-
});
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What the sidebar should offer for the Android shell, and why.
|
|
3
|
-
*
|
|
4
|
-
* Two DIFFERENT offers, from two different facts:
|
|
5
|
-
*
|
|
6
|
-
* - **`update`** — this page is running inside the APK (`shellVersion` from the
|
|
7
|
-
* bootstrap) and a newer one is published. An up-to-date app offers nothing:
|
|
8
|
-
* a button that downloads the build already installed is worse than no button.
|
|
9
|
-
* - **`install`** — this page is an Android BROWSER (Chrome, Brave, Firefox) and
|
|
10
|
-
* an APK is published. The reader may not know a native shell exists, and
|
|
11
|
-
* telling them is the whole point of the offer; there is no installed version
|
|
12
|
-
* to compare against, so the newest published one is what it names.
|
|
13
|
-
*
|
|
14
|
-
* A desktop browser gets neither: `isAndroid` is false, an APK cannot be
|
|
15
|
-
* installed there, and the app's own update badge already covers the server.
|
|
16
|
-
*
|
|
17
|
-
* The probe is lazy: it runs only on an Android device, never on a desktop, so a
|
|
18
|
-
* console that can never show the button makes no request for it.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { useEffect, useState } from 'preact/hooks';
|
|
22
|
-
import { isNewer } from '@/shared/lib/updates/semver';
|
|
23
|
-
|
|
24
|
-
/** Which offer this is — the two carry different wording. */
|
|
25
|
-
export type AndroidOfferKind = 'update' | 'install';
|
|
26
|
-
|
|
27
|
-
export interface AndroidDownload {
|
|
28
|
-
/** The asset to download. */
|
|
29
|
-
url: string;
|
|
30
|
-
/** The version that asset ships. */
|
|
31
|
-
latest: string;
|
|
32
|
-
/** The release page, for a "what changed" link. */
|
|
33
|
-
releaseUrl: string | null;
|
|
34
|
-
/** An update to an installed APK, or a first install from a browser. */
|
|
35
|
-
kind: AndroidOfferKind;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
interface ReleasePayload {
|
|
39
|
-
url?: unknown;
|
|
40
|
-
latest?: unknown;
|
|
41
|
-
releaseUrl?: unknown;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Read the release payload defensively.
|
|
46
|
-
*
|
|
47
|
-
* The route answers `{url: null, latest: null, releaseUrl: null}` on a failed
|
|
48
|
-
* probe, so a shape check that only looks at the version would offer a button
|
|
49
|
-
* with no link.
|
|
50
|
-
*/
|
|
51
|
-
export function parseAndroidRelease(payload: unknown): Omit<AndroidDownload, 'kind'> | null {
|
|
52
|
-
if (typeof payload !== 'object' || payload === null) return null;
|
|
53
|
-
const body = payload as ReleasePayload;
|
|
54
|
-
if (typeof body.url !== 'string' || !body.url) return null;
|
|
55
|
-
if (typeof body.latest !== 'string' || !body.latest) return null;
|
|
56
|
-
return {
|
|
57
|
-
url: body.url,
|
|
58
|
-
latest: body.latest,
|
|
59
|
-
releaseUrl: typeof body.releaseUrl === 'string' ? body.releaseUrl : null,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* The offer, or null.
|
|
65
|
-
*
|
|
66
|
-
* `shellVersion` is null in every browser — that is what makes the two branches
|
|
67
|
-
* mutually exclusive — and `isAndroid` is the wider fact that admits a browser
|
|
68
|
-
* ON ANDROID. Both come from the bootstrap rather than a client-side UA sniff:
|
|
69
|
-
* the server already parsed them for the very request that served the page.
|
|
70
|
-
*
|
|
71
|
-
* An `install` needs no version comparison, because nothing is installed. It
|
|
72
|
-
* does require a published APK: `release` being null is a failed probe, and a
|
|
73
|
-
* button the console cannot back with a link must not appear.
|
|
74
|
-
*/
|
|
75
|
-
export function shouldOfferAndroidDownload(
|
|
76
|
-
shellVersion: string | null | undefined,
|
|
77
|
-
isAndroid: boolean | null | undefined,
|
|
78
|
-
release: Omit<AndroidDownload, 'kind'> | null,
|
|
79
|
-
): AndroidDownload | null {
|
|
80
|
-
if (!release) return null;
|
|
81
|
-
|
|
82
|
-
// Inside the APK: an update, and only when one exists.
|
|
83
|
-
if (shellVersion) {
|
|
84
|
-
return isNewer(release.latest, shellVersion) ? { ...release, kind: 'update' } : null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// A browser on an Android device: the app is something to install.
|
|
88
|
-
if (isAndroid) return { ...release, kind: 'install' };
|
|
89
|
-
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* The wording of the offer, in one place.
|
|
95
|
-
*
|
|
96
|
-
* Both layouts draw this control and they must say the same thing: an installed
|
|
97
|
-
* app that is behind is an UPDATE, and a browser on Android is an INSTALL —
|
|
98
|
-
* "Download" for both would leave a first-time reader unsure whether they
|
|
99
|
-
* already have the app. The version is in the label because the asset is a
|
|
100
|
-
* specific build, not "the app".
|
|
101
|
-
*/
|
|
102
|
-
export function androidDownloadLabel(download: AndroidDownload): string {
|
|
103
|
-
const verb = download.kind === 'update' ? 'Update' : 'Install';
|
|
104
|
-
return `${verb} Kabmo ${download.latest} for Android`;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Fetch the published APK and decide what to offer.
|
|
109
|
-
*
|
|
110
|
-
* `fetchImpl` is a seam for the tests: the cases that matter (no release, a
|
|
111
|
-
* failed probe, a version equal to the installed one) cannot be produced by a
|
|
112
|
-
* live GitHub call.
|
|
113
|
-
*/
|
|
114
|
-
export function useAndroidDownload(
|
|
115
|
-
shellVersion: string | null | undefined,
|
|
116
|
-
isAndroid: boolean | null | undefined,
|
|
117
|
-
fetchImpl: typeof fetch = fetch,
|
|
118
|
-
): AndroidDownload | null {
|
|
119
|
-
const [release, setRelease] = useState<Omit<AndroidDownload, 'kind'> | null>(null);
|
|
120
|
-
const wanted = Boolean(shellVersion) || Boolean(isAndroid);
|
|
121
|
-
|
|
122
|
-
useEffect(() => {
|
|
123
|
-
if (!wanted) return;
|
|
124
|
-
|
|
125
|
-
let cancelled = false;
|
|
126
|
-
void (async () => {
|
|
127
|
-
try {
|
|
128
|
-
const response = await fetchImpl('/api/updates/android');
|
|
129
|
-
if (!response.ok) return;
|
|
130
|
-
const payload: unknown = await response.json();
|
|
131
|
-
if (!cancelled) setRelease(parseAndroidRelease(payload));
|
|
132
|
-
} catch {
|
|
133
|
-
// A probe that cannot answer is a button that must not appear.
|
|
134
|
-
}
|
|
135
|
-
})();
|
|
136
|
-
|
|
137
|
-
return () => {
|
|
138
|
-
cancelled = true;
|
|
139
|
-
};
|
|
140
|
-
}, [wanted, fetchImpl]);
|
|
141
|
-
|
|
142
|
-
return shouldOfferAndroidDownload(shellVersion, isAndroid, release);
|
|
143
|
-
}
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @license
|
|
3
|
-
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
fetchAndroidRelease,
|
|
10
|
-
resetAndroidReleaseCache,
|
|
11
|
-
versionFromAssetName,
|
|
12
|
-
} from '@/server/lib/updates/android-release';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* The asset name is the only place the APK's version exists: the release TAG
|
|
16
|
-
* names the SERVER version, and the two move independently. A parser that read
|
|
17
|
-
* the tag would compare a server version against an APK version.
|
|
18
|
-
*/
|
|
19
|
-
describe('versionFromAssetName', () => {
|
|
20
|
-
test('reads the version the release workflow writes', () => {
|
|
21
|
-
expect(versionFromAssetName('kabmo-android-v1.2.3.apk')).toBe('1.2.3');
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test('accepts a hand-uploaded name without the v', () => {
|
|
25
|
-
expect(versionFromAssetName('kabmo-android-1.2.3.apk')).toBe('1.2.3');
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test('ignores anything that is not the APK', () => {
|
|
29
|
-
expect(versionFromAssetName('kabmo-android-v1.2.3.apk.zip')).toBeNull();
|
|
30
|
-
expect(versionFromAssetName('source.tar.gz')).toBeNull();
|
|
31
|
-
expect(versionFromAssetName('kabmo-android-latest.apk')).toBeNull();
|
|
32
|
-
expect(versionFromAssetName('')).toBeNull();
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
/** A fetch double that answers one body, and counts its calls. */
|
|
37
|
-
function stubFetch(body: unknown, ok = true) {
|
|
38
|
-
const calls: string[] = [];
|
|
39
|
-
const impl = (async (input: string | URL | Request) => {
|
|
40
|
-
calls.push(String(input));
|
|
41
|
-
return {
|
|
42
|
-
ok,
|
|
43
|
-
json: async () => body,
|
|
44
|
-
} as Response;
|
|
45
|
-
}) as unknown as typeof fetch;
|
|
46
|
-
return { impl, calls };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
describe('fetchAndroidRelease', () => {
|
|
50
|
-
// The cache is process-wide (see `resetAndroidReleaseCache`), so every case
|
|
51
|
-
// starts from a cold one or it reads the previous case's answer.
|
|
52
|
-
beforeEach(() => resetAndroidReleaseCache());
|
|
53
|
-
|
|
54
|
-
test('picks the APK asset and its version', async () => {
|
|
55
|
-
const { impl } = stubFetch([
|
|
56
|
-
{
|
|
57
|
-
html_url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v9.9.9',
|
|
58
|
-
assets: [
|
|
59
|
-
{ name: 'source.tar.gz', browser_download_url: 'https://x/src.tgz' },
|
|
60
|
-
{
|
|
61
|
-
name: 'kabmo-android-v1.2.3.apk',
|
|
62
|
-
browser_download_url: 'https://x/kabmo-android-v1.2.3.apk',
|
|
63
|
-
},
|
|
64
|
-
],
|
|
65
|
-
},
|
|
66
|
-
]);
|
|
67
|
-
|
|
68
|
-
const release = await fetchAndroidRelease(impl);
|
|
69
|
-
expect(release).toEqual({
|
|
70
|
-
url: 'https://x/kabmo-android-v1.2.3.apk',
|
|
71
|
-
latest: '1.2.3',
|
|
72
|
-
releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v9.9.9',
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test('skips a newer release that carries no APK', async () => {
|
|
77
|
-
// This is the shape that broke the button: `release.yml` releases the npm
|
|
78
|
-
// packages FIRST, so `kabmo-sdk/v0.0.2` is newer than the app release that
|
|
79
|
-
// holds the APK. Asking `/releases/latest` answered the package release and
|
|
80
|
-
// the button vanished while the APK sat one entry below.
|
|
81
|
-
const { impl } = stubFetch([
|
|
82
|
-
{ html_url: 'https://x/kabmo-sdk', assets: [{ name: 'source.tar.gz' }] },
|
|
83
|
-
{ html_url: 'https://x/kabmo-ui', assets: [] },
|
|
84
|
-
{
|
|
85
|
-
html_url: 'https://x/app',
|
|
86
|
-
assets: [{ name: 'kabmo-android-v0.3.0.apk', browser_download_url: 'https://x/a.apk' }],
|
|
87
|
-
},
|
|
88
|
-
]);
|
|
89
|
-
|
|
90
|
-
expect(await fetchAndroidRelease(impl)).toEqual({
|
|
91
|
-
url: 'https://x/a.apk',
|
|
92
|
-
latest: '0.3.0',
|
|
93
|
-
releaseUrl: 'https://x/app',
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test('skips drafts and prereleases', async () => {
|
|
98
|
-
const { impl } = stubFetch([
|
|
99
|
-
{
|
|
100
|
-
draft: true,
|
|
101
|
-
html_url: 'https://x/draft',
|
|
102
|
-
assets: [{ name: 'kabmo-android-v9.9.9.apk', browser_download_url: 'https://x/draft.apk' }],
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
prerelease: true,
|
|
106
|
-
html_url: 'https://x/pre',
|
|
107
|
-
assets: [{ name: 'kabmo-android-v8.0.0.apk', browser_download_url: 'https://x/pre.apk' }],
|
|
108
|
-
},
|
|
109
|
-
{
|
|
110
|
-
html_url: 'https://x/app',
|
|
111
|
-
assets: [{ name: 'kabmo-android-v0.3.0.apk', browser_download_url: 'https://x/a.apk' }],
|
|
112
|
-
},
|
|
113
|
-
]);
|
|
114
|
-
|
|
115
|
-
expect((await fetchAndroidRelease(impl))?.latest).toBe('0.3.0');
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test('answers null for a list with no APK anywhere', async () => {
|
|
119
|
-
const { impl } = stubFetch([
|
|
120
|
-
{ html_url: 'https://x', assets: [{ name: 'notes.md' }] },
|
|
121
|
-
{ html_url: 'https://y', assets: [] },
|
|
122
|
-
]);
|
|
123
|
-
expect(await fetchAndroidRelease(impl)).toEqual({ url: null, latest: null, releaseUrl: null });
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test('answers null when the probe fails', async () => {
|
|
127
|
-
// An offline box or a rate-limited GitHub must not take the sidebar down,
|
|
128
|
-
// and a failure must not be read as "no newer version" either — it is
|
|
129
|
-
// simply no offer.
|
|
130
|
-
const { impl } = stubFetch({}, false);
|
|
131
|
-
expect(await fetchAndroidRelease(impl)).toBeNull();
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test('answers null when the body is malformed', async () => {
|
|
135
|
-
const { impl } = stubFetch({ assets: 'nope' });
|
|
136
|
-
expect(await fetchAndroidRelease(impl)).toBeNull();
|
|
137
|
-
});
|
|
138
|
-
});
|