@kabmo-labs/kabmo 0.2.0 → 0.3.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 +185 -185
- package/dist/client/static/js/{index-zsskckw7.js → index-8x5j8zrr.js} +538 -538
- package/package.json +1 -1
- package/src/client/bootstrap.d.ts +11 -2
- package/src/client/components/layout/session-sidebar/Footer.tsx +12 -8
- package/src/client/components/layout/session-sidebar/index.tsx +6 -3
- package/src/client/components/mobile/SessionSidebar.tsx +6 -3
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +13 -5
- package/src/client/data/settings/omp-schema.json +89 -4
- package/src/client/hooks/ui/updates/android-download.test.ts +58 -19
- package/src/client/hooks/ui/updates/android-download.ts +64 -27
- package/src/server/lib/updates/android-release.test.ts +60 -14
- package/src/server/lib/updates/android-release.ts +62 -27
- package/src/server/plugins/ssr.ts +7 -2
- package/src/shared/lib/updates/shell-ua.test.ts +68 -1
- package/src/shared/lib/updates/shell-ua.ts +20 -0
|
@@ -52,16 +52,18 @@ describe('fetchAndroidRelease', () => {
|
|
|
52
52
|
beforeEach(() => resetAndroidReleaseCache());
|
|
53
53
|
|
|
54
54
|
test('picks the APK asset and its version', async () => {
|
|
55
|
-
const { impl } = stubFetch(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
]);
|
|
65
67
|
|
|
66
68
|
const release = await fetchAndroidRelease(impl);
|
|
67
69
|
expect(release).toEqual({
|
|
@@ -71,10 +73,54 @@ describe('fetchAndroidRelease', () => {
|
|
|
71
73
|
});
|
|
72
74
|
});
|
|
73
75
|
|
|
74
|
-
test('
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 });
|
|
78
124
|
});
|
|
79
125
|
|
|
80
126
|
test('answers null when the probe fails', async () => {
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The APK is NOT an npm package (see `.github/workflows/publish-android.yml`), so
|
|
5
5
|
* the npm probe that answers for the app and for `omp` cannot answer here. The
|
|
6
|
-
* only record of a published APK is
|
|
7
|
-
* unauthenticated REST read of 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`).
|
|
8
9
|
*
|
|
9
10
|
* Two facts travel back, and they answer different questions:
|
|
10
11
|
*
|
|
@@ -52,7 +53,17 @@ export function versionFromAssetName(name: string): string | null {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
/**
|
|
55
|
-
* The newest release
|
|
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.
|
|
56
67
|
*
|
|
57
68
|
* One request, cached for five minutes on `globalThis` for the same reason the
|
|
58
69
|
* database handle is anchored there: `bun --hot` re-evaluates this module while
|
|
@@ -65,7 +76,7 @@ export async function fetchAndroidRelease(fetchImpl: typeof fetch = fetch): Prom
|
|
|
65
76
|
if (cache.entry && now - cache.at < CACHE_MS) return cache.entry;
|
|
66
77
|
|
|
67
78
|
try {
|
|
68
|
-
const response = await fetchImpl(`${githubApiBase()}/repos/${REPO}/releases
|
|
79
|
+
const response = await fetchImpl(`${githubApiBase()}/repos/${REPO}/releases?per_page=${RELEASE_SCAN}`, {
|
|
69
80
|
headers: {
|
|
70
81
|
accept: 'application/vnd.github+json',
|
|
71
82
|
// GitHub rejects a request with no User-Agent.
|
|
@@ -74,32 +85,40 @@ export async function fetchAndroidRelease(fetchImpl: typeof fetch = fetch): Prom
|
|
|
74
85
|
});
|
|
75
86
|
if (!response.ok) return cache.entry ?? null;
|
|
76
87
|
|
|
77
|
-
const body = (await response.json()) as
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
latest:
|
|
97
|
-
|
|
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,
|
|
98
117
|
};
|
|
99
118
|
|
|
100
|
-
cache.entry =
|
|
119
|
+
cache.entry = resolved;
|
|
101
120
|
cache.at = now;
|
|
102
|
-
return
|
|
121
|
+
return resolved;
|
|
103
122
|
} catch {
|
|
104
123
|
// Keep the last good answer rather than replacing it with a failure: a
|
|
105
124
|
// momentary outage must not remove a download button that was valid.
|
|
@@ -107,6 +126,22 @@ export async function fetchAndroidRelease(fetchImpl: typeof fetch = fetch): Prom
|
|
|
107
126
|
}
|
|
108
127
|
}
|
|
109
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
|
+
|
|
110
145
|
const CACHE_MS = 5 * 60_000;
|
|
111
146
|
|
|
112
147
|
/**
|
|
@@ -15,7 +15,7 @@ import { readSettingsBlob } from '@/shared/lib/settings/keys';
|
|
|
15
15
|
import { resolveTheme } from '@/shared/lib/theme/catalog';
|
|
16
16
|
import { windowChromeColor } from '@/shared/lib/theme/chrome';
|
|
17
17
|
import { THEME_STYLE_ELEMENT_ID, themeStyleSheet } from '@/shared/lib/theme/css';
|
|
18
|
-
import { androidShellVersion } from '@/shared/lib/updates/shell-ua';
|
|
18
|
+
import { androidShellVersion, isAndroidDevice } from '@/shared/lib/updates/shell-ua';
|
|
19
19
|
|
|
20
20
|
/** HTML-escape a string for interpolation into the failure page's `<pre>`. */
|
|
21
21
|
function escapeHtmlText(value: string): string {
|
|
@@ -160,7 +160,12 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
160
160
|
// HERE because the shell is rendered per request and the client's download
|
|
161
161
|
// button needs the answer before its first paint: a fetch after hydration
|
|
162
162
|
// would draw the button late, or draw it and then remove it.
|
|
163
|
+
//
|
|
164
|
+
// `isAndroid` is the wider fact and it exists for the INSTALL offer: a reader
|
|
165
|
+
// on Chrome or Brave for Android carries no shell token, and "not the app"
|
|
166
|
+
// alone cannot tell them from a desktop where an APK is useless.
|
|
163
167
|
const shellVersion = androidShellVersion(userAgent);
|
|
168
|
+
const isAndroid = isAndroidDevice(userAgent);
|
|
164
169
|
|
|
165
170
|
const rendered = await renderShell();
|
|
166
171
|
if (!rendered.ok) return shellUnavailableResponse(rendered.reason);
|
|
@@ -172,7 +177,7 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
172
177
|
// questions: "this browser has a session" versus "there is a session to have".
|
|
173
178
|
// A Kabmo running open answers `authenticated: true` for every visitor, so
|
|
174
179
|
// the navbar cannot use it to decide whether a sign-out control applies.
|
|
175
|
-
const bootstrap = JSON.stringify({ initialIsMobile, shellVersion, authenticated, authEnabled: isAuthEnabled(), production: !DEV_ASSETS_ENABLED, appSettings: settings }).replace(/</g, '\\u003c');
|
|
180
|
+
const bootstrap = JSON.stringify({ initialIsMobile, shellVersion, isAndroid, authenticated, authEnabled: isAuthEnabled(), production: !DEV_ASSETS_ENABLED, appSettings: settings }).replace(/</g, '\\u003c');
|
|
176
181
|
// The template's own `<meta name="theme-color">` is REWRITTEN, not joined by
|
|
177
182
|
// a second tag: with both present Chrome reads the first one, so an appended
|
|
178
183
|
// tag would leave a dark theme painting light browser chrome.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { describe, expect, test } from 'bun:test';
|
|
7
7
|
|
|
8
|
-
import { androidShellVersion, SHELL_UA_PREFIX } from '@/shared/lib/updates/shell-ua';
|
|
8
|
+
import { androidShellVersion, isAndroidDevice, SHELL_UA_PREFIX } from '@/shared/lib/updates/shell-ua';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The token is the only signal that a page is running inside the Android app,
|
|
@@ -52,3 +52,70 @@ describe('androidShellVersion', () => {
|
|
|
52
52
|
expect(androidShellVersion('Mozilla KabmoAndroid')).toBeNull();
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The wider fact, and the one the INSTALL offer rests on: a reader on Chrome or
|
|
58
|
+
* Brave for Android carries no shell token, so "not the app" alone cannot tell
|
|
59
|
+
* them from a desktop where an APK is useless.
|
|
60
|
+
*
|
|
61
|
+
* The two failure modes are a device wrongly included (a button offering an
|
|
62
|
+
* install to a machine that cannot install it) and a real Android device wrongly
|
|
63
|
+
* excluded (the offer never appears where it was the point).
|
|
64
|
+
*/
|
|
65
|
+
describe('isAndroidDevice', () => {
|
|
66
|
+
test('recognises the real Android browsers', () => {
|
|
67
|
+
// The three the report names, plus Firefox — the platform token is the same.
|
|
68
|
+
expect(
|
|
69
|
+
isAndroidDevice(
|
|
70
|
+
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
|
|
71
|
+
),
|
|
72
|
+
).toBe(true);
|
|
73
|
+
expect(
|
|
74
|
+
isAndroidDevice(
|
|
75
|
+
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Brave/124',
|
|
76
|
+
),
|
|
77
|
+
).toBe(true);
|
|
78
|
+
expect(isAndroidDevice('Mozilla/5.0 (Android 14; Mobile; rv:126.0) Gecko/126.0 Firefox/126.0')).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('recognises the shell itself, which also sends the platform token', () => {
|
|
82
|
+
expect(
|
|
83
|
+
isAndroidDevice(
|
|
84
|
+
`Mozilla/5.0 (Linux; Android 15; Pixel 6 Build/AP4A; wv) AppleWebKit/537.36 (KHTML, like Gecko) `
|
|
85
|
+
+ `Version/4.0 Chrome/124.0.6367.219 Mobile Safari/537.36 ${SHELL_UA_PREFIX}0.3.0`,
|
|
86
|
+
),
|
|
87
|
+
).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('recognises an Android tablet, which sends no Mobile token', () => {
|
|
91
|
+
// The reason this matches `Android` and not `Mobile`: a tablet is a device
|
|
92
|
+
// the APK installs on, and it is the case a `Mobile` test would drop.
|
|
93
|
+
expect(
|
|
94
|
+
isAndroidDevice(
|
|
95
|
+
'Mozilla/5.0 (Linux; Android 13; SM-X700) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
96
|
+
),
|
|
97
|
+
).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('excludes every platform the APK cannot install on', () => {
|
|
101
|
+
expect(isAndroidDevice('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0 Safari/537.36')).toBe(false);
|
|
102
|
+
expect(isAndroidDevice('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36')).toBe(false);
|
|
103
|
+
expect(isAndroidDevice('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36')).toBe(false);
|
|
104
|
+
// An iPad sends `Mobile` and has no APK to install — the token this
|
|
105
|
+
// deliberately does NOT match.
|
|
106
|
+
expect(isAndroidDevice('Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 Version/17.4 Mobile/15E148 Safari/604.1')).toBe(false);
|
|
107
|
+
expect(isAndroidDevice('Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1')).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('is false for an empty or missing UA', () => {
|
|
111
|
+
expect(isAndroidDevice('')).toBe(false);
|
|
112
|
+
expect(isAndroidDevice(null)).toBe(false);
|
|
113
|
+
expect(isAndroidDevice(undefined)).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('does not match a lookalike word', () => {
|
|
117
|
+
// `` is what stops a crafted or unrelated token from counting.
|
|
118
|
+
expect(isAndroidDevice('Mozilla/5.0 (compatible; NotAndroidBot/1.0)')).toBe(false);
|
|
119
|
+
expect(isAndroidDevice('Mozilla/5.0 AndroidX/1.0')).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -16,6 +16,26 @@
|
|
|
16
16
|
/** The token's name, before the slash. */
|
|
17
17
|
export const SHELL_UA_PREFIX = 'KabmoAndroid/';
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Whether this request came from an Android device at all — the app or a
|
|
21
|
+
* browser on it.
|
|
22
|
+
*
|
|
23
|
+
* The download button has two reasons to appear and they are different offers:
|
|
24
|
+
* an installed APK that is behind (an UPDATE), and a reader using Chrome or
|
|
25
|
+
* Brave on a phone who may not know a native shell exists (an INSTALL). The
|
|
26
|
+
* second one needs this fact, which the shell token cannot supply: a browser
|
|
27
|
+
* carries no `KabmoAndroid/` token, and "no token" alone means "not the app" on
|
|
28
|
+
* every platform, including a desktop where an APK is useless.
|
|
29
|
+
*
|
|
30
|
+
* Matched on `Android`, the platform token every Android browser sends, and NOT
|
|
31
|
+
* on `Mobile`: an iPad sends `Mobile` and cannot install an APK, while an
|
|
32
|
+
* Android tablet sends `Android` without it.
|
|
33
|
+
*/
|
|
34
|
+
export function isAndroidDevice(userAgent: string | null | undefined): boolean {
|
|
35
|
+
if (!userAgent) return false;
|
|
36
|
+
return /\bAndroid\b/.test(userAgent);
|
|
37
|
+
}
|
|
38
|
+
|
|
19
39
|
/**
|
|
20
40
|
* The APK version that sent this request, or null when it is not the app.
|
|
21
41
|
*
|