@kabmo-labs/kabmo 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +359 -350
- package/dist/client/static/js/{index-9js9mrmj.js → index-ae9z0yvm.js} +704 -704
- package/dist/client/static/js/index-nzyjf92m.css +1 -0
- package/marketplace/plugins/stats/README.md +26 -4
- package/marketplace/plugins/stats/package.json +2 -2
- package/marketplace/plugins/stats/src/HeaderReadout.test.tsx +157 -0
- package/marketplace/plugins/stats/src/HeaderReadout.tsx +115 -0
- package/marketplace/plugins/stats/src/app.tsx +11 -0
- package/marketplace/plugins/stats/src/format.test.ts +27 -1
- package/marketplace/plugins/stats/src/format.ts +41 -0
- package/package.json +2 -2
- package/src/client/bootstrap.d.ts +11 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +38 -1
- package/src/client/components/common/header-controls/index.tsx +9 -2
- package/src/client/components/common/header-controls/plugin-entry.test.tsx +229 -0
- package/src/client/components/common/header-panel-buttons/entries.test.ts +46 -0
- package/src/client/components/common/header-panel-buttons/entries.ts +109 -0
- package/src/client/components/common/header-panel-buttons/index.tsx +71 -38
- package/src/client/components/common/server-switcher/index.test.tsx +150 -0
- package/src/client/components/common/server-switcher/index.tsx +281 -0
- package/src/client/components/common/window-controls/index.test.tsx +113 -0
- package/src/client/components/common/window-controls/index.tsx +66 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +3 -6
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +59 -3
- package/src/client/components/layout/desktop-layout/index.tsx +34 -26
- package/src/client/components/layout/session-sidebar/Footer.tsx +12 -12
- package/src/client/components/layout/session-sidebar/Header.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +36 -0
- package/src/client/components/mobile/RightSidebar.tsx +2 -2
- package/src/client/components/mobile/SessionSidebar.tsx +5 -8
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +15 -12
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +9 -2
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.test.ts +41 -0
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.ts +39 -0
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +6 -2
- package/src/client/components/settings/categories/OmpSettings.tsx +25 -29
- package/src/client/components/settings/categories/omp-settings/PresetBar.tsx +187 -0
- package/src/client/components/settings/categories/omp-settings/RoleEditor.test.ts +260 -32
- package/src/client/components/settings/categories/omp-settings/RoleEditor.tsx +198 -102
- package/src/client/components/settings/categories/omp-settings/RoleRow.tsx +79 -0
- package/src/client/data/settings/omp-schema.json +2 -2
- package/src/client/hooks/desktop/servers.test.ts +73 -0
- package/src/client/hooks/desktop/servers.ts +126 -0
- package/src/client/hooks/settings/model-roles.ts +156 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
- package/src/client/hooks/ui/updates/shell-download.ts +220 -0
- package/src/client/hooks/workspace/keybindings.ts +44 -0
- package/src/client/hooks/workspace/open-panel.ts +42 -0
- package/src/client/lib/desktop/bridge.ts +158 -0
- package/src/client/lib/desktop/titlebar.test.ts +99 -0
- package/src/client/lib/desktop/titlebar.ts +97 -0
- package/src/client/lib/plugins/slots.test.ts +54 -0
- package/src/client/lib/plugins/slots.ts +37 -1
- package/src/server/db.server.test.ts +44 -3
- package/src/server/db.server.ts +26 -2
- package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
- package/src/server/lib/model-roles/catalog.server.ts +112 -0
- package/src/server/lib/model-roles/mock.server.ts +40 -0
- package/src/server/lib/model-roles/schema.server.ts +36 -0
- package/src/server/lib/model-roles/store.server.test.ts +218 -0
- package/src/server/lib/model-roles/store.server.ts +188 -0
- package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
- package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
- package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
- package/src/server/lib/omp/rpc/manager.ts +18 -13
- package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
- package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
- package/src/server/lib/omp/rpc/session-commands.ts +7 -0
- package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
- package/src/server/lib/omp/session/auto-title.server.ts +150 -120
- package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
- package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
- package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
- package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
- package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
- package/src/server/lib/omp/session/title-request.server.ts +213 -0
- package/src/server/lib/panels/baseline.server.test.ts +4 -4
- package/src/server/lib/panels/compat.server.test.ts +4 -4
- package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
- package/src/server/lib/panels/registry.server.test.ts +4 -4
- package/src/server/lib/panels/state.server.test.ts +4 -4
- package/src/server/lib/panels/sync.server.test.ts +4 -4
- package/src/server/lib/plugins/background.test.ts +2 -2
- package/src/server/lib/plugins/e2e.test.ts +2 -2
- package/src/server/lib/plugins/platform.test.ts +2 -2
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
- package/src/server/lib/queue/delivery.server.ts +11 -2
- package/src/server/lib/updates/shell-release.test.ts +184 -0
- package/src/server/lib/updates/shell-release.ts +215 -0
- package/src/server/lib/waiting/store.server.test.ts +2 -2
- package/src/server/plugins/ssr.ts +13 -10
- package/src/server/routes/chat/stream.test.ts +4 -4
- package/src/server/routes/models/roles.ts +73 -14
- package/src/server/routes/panels/css.test.ts +4 -4
- package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
- package/src/server/routes/plugins/routes.test.ts +4 -4
- package/src/server/routes/sessions/batch-delete.test.ts +4 -4
- package/src/server/routes/updates/android.ts +2 -2
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +26 -0
- package/src/server/routes/waiting/routes.test.ts +2 -2
- package/src/shared/lib/models/server-cache.ts +11 -0
- package/src/shared/lib/omp/config/model-roles.ts +129 -0
- package/src/shared/lib/updates/shell-ua.test.ts +56 -24
- package/src/shared/lib/updates/shell-ua.ts +41 -20
- package/src/shared/lib/workspace/panel-open.test.ts +56 -0
- package/src/shared/lib/workspace/panel-open.ts +76 -0
- package/src/shared/types/model-roles.ts +54 -0
- package/src/test-support/isolated-db.ts +17 -5
- package/dist/client/static/js/index-3qamd33d.css +0 -1
- package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
- package/src/client/hooks/ui/updates/android-download.ts +0 -143
- package/src/server/lib/updates/android-release.test.ts +0 -138
- package/src/server/lib/updates/android-release.ts +0 -183
|
@@ -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
|
-
});
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Which Android APK a browser should download, and whether this one is behind.
|
|
3
|
-
*
|
|
4
|
-
* The APK is NOT an npm package (see `.github/workflows/publish-android.yml`), so
|
|
5
|
-
* the npm probe that answers for the app and for `omp` cannot answer here. The
|
|
6
|
-
* only record of a published APK is a GitHub release asset, which means one
|
|
7
|
-
* unauthenticated REST read of the release LIST — not `/releases/latest`, which
|
|
8
|
-
* answers the newest release of ANY kind (see `fetchAndroidRelease`).
|
|
9
|
-
*
|
|
10
|
-
* Two facts travel back, and they answer different questions:
|
|
11
|
-
*
|
|
12
|
-
* - `url` is the asset to download. It is resolved from the release's OWN asset
|
|
13
|
-
* list rather than built as `releases/latest/download/<name>`, because that
|
|
14
|
-
* path needs the asset name to match exactly and the name carries the tag
|
|
15
|
-
* (`kabmo-android-v1.2.3.apk`) — a name built here would be a second copy of
|
|
16
|
-
* the workflow's naming rule, and the first release that renames it 404s.
|
|
17
|
-
* - `latest` is the version that asset ships, read from the asset name. The
|
|
18
|
-
* release TAG cannot be used: the APK is attached to the app's release, whose
|
|
19
|
-
* tag names the SERVER version, and the two move independently.
|
|
20
|
-
*
|
|
21
|
-
* Failure is a normal answer here (`null`), never a throw: an offline box or a
|
|
22
|
-
* rate-limited GitHub must not take the sidebar down, and a button that appears
|
|
23
|
-
* because a probe failed would offer a download that does not exist.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { REPO } from '@/server/lib/updates/release-url';
|
|
27
|
-
|
|
28
|
-
/** One published APK, as the console needs it. */
|
|
29
|
-
export interface AndroidRelease {
|
|
30
|
-
/** Asset download URL, or null when the release carries no APK. */
|
|
31
|
-
url: string | null;
|
|
32
|
-
/** Version the asset name declares (`v1.2.3` → `1.2.3`), or null. */
|
|
33
|
-
latest: string | null;
|
|
34
|
-
/** The release page, for a "what changed" link. */
|
|
35
|
-
releaseUrl: string | null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** The asset-name prefix the release workflow writes. */
|
|
39
|
-
const ASSET_PREFIX = 'kabmo-android-';
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* `kabmo-android-v1.2.3.apk` → `1.2.3`.
|
|
43
|
-
*
|
|
44
|
-
* The `v` is optional because a hand-uploaded asset may omit it, and a version
|
|
45
|
-
* that cannot be read is not evidence of an update — the caller treats null as
|
|
46
|
-
* "no new version".
|
|
47
|
-
*/
|
|
48
|
-
export function versionFromAssetName(name: string): string | null {
|
|
49
|
-
if (!name.startsWith(ASSET_PREFIX) || !name.endsWith('.apk')) return null;
|
|
50
|
-
const middle = name.slice(ASSET_PREFIX.length, -'.apk'.length);
|
|
51
|
-
const version = middle.startsWith('v') ? middle.slice(1) : middle;
|
|
52
|
-
return /^\d+\.\d+\.\d+/.test(version) ? version : null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* The newest release that actually carries an APK.
|
|
57
|
-
*
|
|
58
|
-
* NOT `/releases/latest`, which answers the newest release of ANY kind. This
|
|
59
|
-
* repository publishes three artifacts from one push, and the packages are
|
|
60
|
-
* released FIRST (`packages → marketplace → android → app` in `release.yml`),
|
|
61
|
-
* so their `kabmo-sdk/v*` / `kabmo-ui/v*` releases are newer than the app
|
|
62
|
-
* release that carries the APK. `@semantic-release/github` runs in both
|
|
63
|
-
* pipelines, so those are real releases — measured against a stub that answered
|
|
64
|
-
* a package release: `/releases/latest` produced `{url: null, latest: null}` and
|
|
65
|
-
* the sidebar's button disappeared even though the APK sat on the release
|
|
66
|
-
* directly below.
|
|
67
|
-
*
|
|
68
|
-
* One request, cached for five minutes on `globalThis` for the same reason the
|
|
69
|
-
* database handle is anchored there: `bun --hot` re-evaluates this module while
|
|
70
|
-
* the cache it would otherwise hold lives on. GitHub allows 60 unauthenticated
|
|
71
|
-
* reads an hour per address, and the sidebar asks on every mount.
|
|
72
|
-
*/
|
|
73
|
-
export async function fetchAndroidRelease(fetchImpl: typeof fetch = fetch): Promise<AndroidRelease | null> {
|
|
74
|
-
const cache = cacheSlot();
|
|
75
|
-
const now = Date.now();
|
|
76
|
-
if (cache.entry && now - cache.at < CACHE_MS) return cache.entry;
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
const response = await fetchImpl(`${githubApiBase()}/repos/${REPO}/releases?per_page=${RELEASE_SCAN}`, {
|
|
80
|
-
headers: {
|
|
81
|
-
accept: 'application/vnd.github+json',
|
|
82
|
-
// GitHub rejects a request with no User-Agent.
|
|
83
|
-
'user-agent': 'kabmo-console',
|
|
84
|
-
},
|
|
85
|
-
});
|
|
86
|
-
if (!response.ok) return cache.entry ?? null;
|
|
87
|
-
|
|
88
|
-
const body = (await response.json()) as unknown;
|
|
89
|
-
// A body that is not the release list is a failed probe, not an empty
|
|
90
|
-
// repository: answering `{url: null, latest: null}` here would be
|
|
91
|
-
// indistinguishable from "no release carries an APK", and the caller would
|
|
92
|
-
// cache that mistake for five minutes.
|
|
93
|
-
if (!Array.isArray(body)) return cache.entry ?? null;
|
|
94
|
-
|
|
95
|
-
// Drafts are not published and prereleases are not what a console offers an
|
|
96
|
-
// installed app — the same two the `latest` endpoint excluded.
|
|
97
|
-
const published = body.filter(
|
|
98
|
-
(entry): entry is Record<string, unknown> =>
|
|
99
|
-
entry !== null && typeof entry === 'object' && entry.draft !== true && entry.prerelease !== true,
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
const release = published
|
|
103
|
-
.map((entry) => ({ entry, asset: apkAsset(entry.assets) }))
|
|
104
|
-
.find((candidate) => candidate.asset !== null);
|
|
105
|
-
|
|
106
|
-
if (!release?.asset) {
|
|
107
|
-
const fallback: AndroidRelease = { url: null, latest: null, releaseUrl: null };
|
|
108
|
-
cache.entry = fallback;
|
|
109
|
-
cache.at = now;
|
|
110
|
-
return fallback;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const resolved: AndroidRelease = {
|
|
114
|
-
url: typeof release.asset.browser_download_url === 'string' ? release.asset.browser_download_url : null,
|
|
115
|
-
latest: versionFromAssetName(String(release.asset.name)),
|
|
116
|
-
releaseUrl: typeof release.entry.html_url === 'string' ? release.entry.html_url : null,
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
cache.entry = resolved;
|
|
120
|
-
cache.at = now;
|
|
121
|
-
return resolved;
|
|
122
|
-
} catch {
|
|
123
|
-
// Keep the last good answer rather than replacing it with a failure: a
|
|
124
|
-
// momentary outage must not remove a download button that was valid.
|
|
125
|
-
return cache.entry ?? null;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** How many releases to look back through for the APK. */
|
|
130
|
-
const RELEASE_SCAN = 20;
|
|
131
|
-
|
|
132
|
-
/** The first asset of a release that names an APK, or null. */
|
|
133
|
-
function apkAsset(assets: unknown): Record<string, unknown> | null {
|
|
134
|
-
if (!Array.isArray(assets)) return null;
|
|
135
|
-
|
|
136
|
-
for (const entry of assets) {
|
|
137
|
-
if (entry === null || typeof entry !== 'object') continue;
|
|
138
|
-
const asset = entry as Record<string, unknown>;
|
|
139
|
-
if (typeof asset.name === 'string' && versionFromAssetName(asset.name) !== null) return asset;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const CACHE_MS = 5 * 60_000;
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Where the release read goes.
|
|
149
|
-
*
|
|
150
|
-
* Overridable for the same reason the npm probe honours `NPM_CONFIG_REGISTRY`: a
|
|
151
|
-
* test and a rehearsal need to exercise the real parsing against a stub instead
|
|
152
|
-
* of a live GitHub call, and hardcoding the host makes that impossible without
|
|
153
|
-
* intercepting `fetch` globally.
|
|
154
|
-
*/
|
|
155
|
-
function githubApiBase(): string {
|
|
156
|
-
const configured = Bun.env.KABMO_GITHUB_API ?? '';
|
|
157
|
-
return configured.trim().replace(/\/+$/, '') || 'https://api.github.com';
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
interface CacheSlot {
|
|
161
|
-
entry: AndroidRelease | null;
|
|
162
|
-
at: number;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function cacheSlot(): CacheSlot {
|
|
166
|
-
const host = globalThis as typeof globalThis & { __kabmoAndroidRelease?: CacheSlot };
|
|
167
|
-
host.__kabmoAndroidRelease ??= { entry: null, at: 0 };
|
|
168
|
-
return host.__kabmoAndroidRelease;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Drop the cached answer.
|
|
173
|
-
*
|
|
174
|
-
* The cache lives on `globalThis` so `bun --hot` cannot orphan it, which also
|
|
175
|
-
* means it outlives a test file: without this seam the second case in a suite
|
|
176
|
-
* reads the first case's answer and passes for the wrong reason (measured — the
|
|
177
|
-
* "malformed body" case returned the previous test's release). Production has no
|
|
178
|
-
* caller; it exists because the cache is process-wide by design.
|
|
179
|
-
*/
|
|
180
|
-
export function resetAndroidReleaseCache(): void {
|
|
181
|
-
cacheSlot().entry = null;
|
|
182
|
-
cacheSlot().at = 0;
|
|
183
|
-
}
|