@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.
Files changed (124) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +359 -350
  3. package/dist/client/static/js/{index-zcvn50cf.js → index-ae9z0yvm.js} +703 -703
  4. package/dist/client/static/js/index-nzyjf92m.css +1 -0
  5. package/marketplace/plugins/stats/README.md +26 -4
  6. package/marketplace/plugins/stats/package.json +2 -2
  7. package/marketplace/plugins/stats/src/HeaderReadout.test.tsx +157 -0
  8. package/marketplace/plugins/stats/src/HeaderReadout.tsx +115 -0
  9. package/marketplace/plugins/stats/src/app.tsx +11 -0
  10. package/marketplace/plugins/stats/src/format.test.ts +27 -1
  11. package/marketplace/plugins/stats/src/format.ts +41 -0
  12. package/package.json +2 -2
  13. package/src/client/bootstrap.d.ts +11 -0
  14. package/src/client/components/common/header-controls/MoreMenu.tsx +38 -1
  15. package/src/client/components/common/header-controls/index.tsx +9 -2
  16. package/src/client/components/common/header-controls/plugin-entry.test.tsx +229 -0
  17. package/src/client/components/common/header-panel-buttons/entries.test.ts +46 -0
  18. package/src/client/components/common/header-panel-buttons/entries.ts +109 -0
  19. package/src/client/components/common/header-panel-buttons/index.tsx +71 -38
  20. package/src/client/components/common/server-switcher/index.test.tsx +150 -0
  21. package/src/client/components/common/server-switcher/index.tsx +281 -0
  22. package/src/client/components/common/window-controls/index.test.tsx +113 -0
  23. package/src/client/components/common/window-controls/index.tsx +66 -0
  24. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +3 -6
  25. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +59 -3
  26. package/src/client/components/layout/desktop-layout/index.tsx +34 -26
  27. package/src/client/components/layout/session-sidebar/Footer.tsx +12 -12
  28. package/src/client/components/layout/session-sidebar/Header.tsx +4 -2
  29. package/src/client/components/mobile/LayoutWrapper.tsx +36 -0
  30. package/src/client/components/mobile/RightSidebar.tsx +2 -2
  31. package/src/client/components/mobile/SessionSidebar.tsx +10 -15
  32. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +15 -12
  33. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +9 -2
  34. package/src/client/components/mobile/mobile-session-sidebar/folder-gap.test.ts +41 -0
  35. package/src/client/components/mobile/mobile-session-sidebar/folder-gap.ts +39 -0
  36. package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +6 -6
  37. package/src/client/components/settings/categories/OmpSettings.tsx +25 -29
  38. package/src/client/components/settings/categories/omp-settings/PresetBar.tsx +187 -0
  39. package/src/client/components/settings/categories/omp-settings/RoleEditor.test.ts +260 -32
  40. package/src/client/components/settings/categories/omp-settings/RoleEditor.tsx +198 -102
  41. package/src/client/components/settings/categories/omp-settings/RoleRow.tsx +79 -0
  42. package/src/client/data/settings/omp-schema.json +2 -2
  43. package/src/client/hooks/chat/omp/session-list.test.ts +0 -24
  44. package/src/client/hooks/desktop/servers.test.ts +73 -0
  45. package/src/client/hooks/desktop/servers.ts +126 -0
  46. package/src/client/hooks/settings/model-roles.ts +156 -0
  47. package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
  48. package/src/client/hooks/ui/updates/shell-download.ts +220 -0
  49. package/src/client/hooks/workspace/keybindings.ts +44 -0
  50. package/src/client/hooks/workspace/open-panel.ts +42 -0
  51. package/src/client/lib/desktop/bridge.ts +158 -0
  52. package/src/client/lib/desktop/titlebar.test.ts +99 -0
  53. package/src/client/lib/desktop/titlebar.ts +97 -0
  54. package/src/client/lib/plugins/slots.test.ts +54 -0
  55. package/src/client/lib/plugins/slots.ts +37 -1
  56. package/src/server/db.server.test.ts +44 -3
  57. package/src/server/db.server.ts +26 -2
  58. package/src/server/lib/bundler/build-errors.test.ts +0 -6
  59. package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
  60. package/src/server/lib/db/schema.ts +3 -0
  61. package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
  62. package/src/server/lib/model-roles/catalog.server.ts +112 -0
  63. package/src/server/lib/model-roles/mock.server.ts +40 -0
  64. package/src/server/lib/model-roles/schema.server.ts +36 -0
  65. package/src/server/lib/model-roles/store.server.test.ts +218 -0
  66. package/src/server/lib/model-roles/store.server.ts +188 -0
  67. package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
  68. package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
  69. package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
  70. package/src/server/lib/omp/rpc/manager.ts +18 -13
  71. package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
  72. package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
  73. package/src/server/lib/omp/rpc/session-commands.ts +7 -0
  74. package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
  75. package/src/server/lib/omp/session/auto-title.server.ts +150 -120
  76. package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
  77. package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
  78. package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
  79. package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
  80. package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
  81. package/src/server/lib/omp/session/title-request.server.ts +213 -0
  82. package/src/server/lib/panels/baseline.server.test.ts +4 -4
  83. package/src/server/lib/panels/compat.server.test.ts +4 -4
  84. package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
  85. package/src/server/lib/panels/registry.server.test.ts +4 -4
  86. package/src/server/lib/panels/state.server.test.ts +4 -4
  87. package/src/server/lib/panels/sync.server.test.ts +4 -4
  88. package/src/server/lib/plugins/background.test.ts +2 -2
  89. package/src/server/lib/plugins/e2e.test.ts +2 -2
  90. package/src/server/lib/plugins/platform.test.ts +2 -2
  91. package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
  92. package/src/server/lib/queue/delivery.server.ts +11 -2
  93. package/src/server/lib/updates/shell-release.test.ts +184 -0
  94. package/src/server/lib/updates/shell-release.ts +215 -0
  95. package/src/server/lib/waiting/store.server.test.ts +2 -2
  96. package/src/server/plugins/ssr.ts +13 -10
  97. package/src/server/routes/chat/stream.test.ts +4 -4
  98. package/src/server/routes/fs/git-merge.test.ts +0 -11
  99. package/src/server/routes/models/roles.ts +73 -14
  100. package/src/server/routes/panels/css.test.ts +4 -4
  101. package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
  102. package/src/server/routes/panels/routes.test.ts +0 -7
  103. package/src/server/routes/plugins/routes.test.ts +4 -4
  104. package/src/server/routes/sessions/batch-delete.test.ts +4 -4
  105. package/src/server/routes/updates/android.ts +2 -2
  106. package/src/server/routes/updates/index.ts +2 -0
  107. package/src/server/routes/updates/ios.ts +26 -0
  108. package/src/server/routes/waiting/routes.test.ts +2 -2
  109. package/src/shared/lib/models/server-cache.ts +11 -0
  110. package/src/shared/lib/omp/config/model-roles.ts +129 -0
  111. package/src/shared/lib/updates/shell-ua.test.ts +56 -24
  112. package/src/shared/lib/updates/shell-ua.ts +41 -20
  113. package/src/shared/lib/workspace/mobile-dock.test.ts +7 -13
  114. package/src/shared/lib/workspace/mobile-dock.ts +15 -6
  115. package/src/shared/lib/workspace/panel-open.test.ts +56 -0
  116. package/src/shared/lib/workspace/panel-open.ts +76 -0
  117. package/src/shared/types/model-roles.ts +54 -0
  118. package/src/test-support/isolated-db.ts +17 -5
  119. package/dist/client/static/js/index-3qamd33d.css +0 -1
  120. package/src/client/components/layout/desktop-layout/left-column.test.tsx +0 -192
  121. package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
  122. package/src/client/hooks/ui/updates/android-download.ts +0 -143
  123. package/src/server/lib/updates/android-release.test.ts +0 -138
  124. 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
- });