@kabmo-labs/kabmo 0.1.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.
Files changed (80) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +285 -285
  3. package/dist/client/static/js/{index-enkbbffs.js → index-8x5j8zrr.js} +671 -671
  4. package/dist/client/static/js/index-pdp5dwc8.css +1 -0
  5. package/package.json +1 -1
  6. package/src/client/bootstrap.d.ts +17 -0
  7. package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
  8. package/src/client/components/common/header-controls/index.test.tsx +77 -11
  9. package/src/client/components/common/header-controls/index.tsx +53 -4
  10. package/src/client/components/common/session-state-provider/index.test.ts +22 -1
  11. package/src/client/components/layout/session-sidebar/Footer.tsx +24 -2
  12. package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
  13. package/src/client/components/layout/session-sidebar/Toolbar.tsx +2 -33
  14. package/src/client/components/layout/session-sidebar/index.tsx +8 -0
  15. package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
  16. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  17. package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
  18. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +29 -1
  19. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +104 -0
  20. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +166 -106
  21. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
  22. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
  23. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
  24. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
  25. package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
  26. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
  27. package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
  28. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
  29. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
  30. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
  31. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
  32. package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
  33. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
  34. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
  35. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
  36. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
  37. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
  38. package/src/client/components/workspace/git-panel/index.test.ts +15 -0
  39. package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
  40. package/src/client/components/workspace/search-panel/Body.tsx +53 -9
  41. package/src/client/data/settings/omp-schema.json +89 -4
  42. package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
  43. package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
  44. package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
  45. package/src/client/hooks/chat/timeline/index.ts +2 -1
  46. package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
  47. package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
  48. package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
  49. package/src/client/hooks/chat/timeline/scroll.ts +34 -1
  50. package/src/client/hooks/ui/media-query.ts +41 -0
  51. package/src/client/hooks/ui/updates/android-download.test.ts +112 -0
  52. package/src/client/hooks/ui/updates/android-download.ts +143 -0
  53. package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
  54. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  55. package/src/client/hooks/workspace/session-state/index.ts +24 -1
  56. package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
  57. package/src/client/lib/native/back.test.ts +108 -0
  58. package/src/client/lib/native/back.ts +60 -0
  59. package/src/client/main.tsx +6 -0
  60. package/src/server/lib/fs/git-actions-branch.ts +18 -1
  61. package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
  62. package/src/server/lib/fs/git-publish.test.ts +96 -0
  63. package/src/server/lib/fs/git-publish.ts +35 -3
  64. package/src/server/lib/fs/listing.test.ts +86 -0
  65. package/src/server/lib/fs/listing.ts +20 -6
  66. package/src/server/lib/updates/android-release.test.ts +138 -0
  67. package/src/server/lib/updates/android-release.ts +183 -0
  68. package/src/server/plugins/ssr.ts +14 -2
  69. package/src/server/routes/fs/read.ts +24 -9
  70. package/src/server/routes/sessions/batch-delete.test.ts +14 -11
  71. package/src/server/routes/updates/android.ts +22 -0
  72. package/src/server/routes/updates/index.ts +2 -0
  73. package/src/shared/lib/updates/shell-ua.test.ts +121 -0
  74. package/src/shared/lib/updates/shell-ua.ts +56 -0
  75. package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
  76. package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
  77. package/src/shared/lib/workspace/session-state/store.ts +13 -2
  78. package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
  79. package/src/shared/lib/workspace/sidebar-toolbar.ts +78 -0
  80. package/dist/client/static/js/index-yn17t04b.css +0 -1
@@ -0,0 +1,183 @@
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
+ }
@@ -15,6 +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, isAndroidDevice } from '@/shared/lib/updates/shell-ua';
18
19
 
19
20
  /** HTML-escape a string for interpolation into the failure page's `<pre>`. */
20
21
  function escapeHtmlText(value: string): string {
@@ -153,7 +154,18 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
153
154
  // downgrade, the retired `noir` alias) renders the default palette instead of
154
155
  // an unstyled page.
155
156
  const theme = resolveTheme(settingsBlob.theme);
156
- const initialIsMobile = MOBILE_UA.test(request.headers.get('user-agent') ?? '');
157
+ const userAgent = request.headers.get('user-agent') ?? '';
158
+ const initialIsMobile = MOBILE_UA.test(userAgent);
159
+ // Which APK is asking, when the request came from the Android shell. Read
160
+ // HERE because the shell is rendered per request and the client's download
161
+ // button needs the answer before its first paint: a fetch after hydration
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.
167
+ const shellVersion = androidShellVersion(userAgent);
168
+ const isAndroid = isAndroidDevice(userAgent);
157
169
 
158
170
  const rendered = await renderShell();
159
171
  if (!rendered.ok) return shellUnavailableResponse(rendered.reason);
@@ -165,7 +177,7 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
165
177
  // questions: "this browser has a session" versus "there is a session to have".
166
178
  // A Kabmo running open answers `authenticated: true` for every visitor, so
167
179
  // the navbar cannot use it to decide whether a sign-out control applies.
168
- const bootstrap = JSON.stringify({ initialIsMobile, 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');
169
181
  // The template's own `<meta name="theme-color">` is REWRITTEN, not joined by
170
182
  // a second tag: with both present Chrome reads the first one, so an appended
171
183
  // tag would leave a dark theme painting light browser chrome.
@@ -95,12 +95,9 @@ export async function browseDirectories({ request }: LoaderFunctionArgs) {
95
95
  });
96
96
  }
97
97
 
98
- /**
99
- * Names never listed in the Files panel: dependency/build output and the git
100
- * object database. Skipped for being noise, not for being hidden — every other
101
- * entry is listed, dot-prefixed files and folders included.
102
- */
103
-
98
+ // No name is filtered: `lib/fs/listing.ts` (shared with the realtime topic)
99
+ // lists every entry, and git-ignored ones are dimmed rather than dropped.
100
+ //
104
101
  // Lazy listing: return only the immediate children of a directory. Folders are
105
102
  // emitted with `children: null` meaning "not loaded yet" so the client can
106
103
  // fetch them on demand — directories are NOT recursed here (keeps the initial
@@ -143,8 +140,26 @@ export async function listDirectory({ request }: LoaderFunctionArgs) {
143
140
  /** Hard cap on emitted files so a huge tree cannot stall the request. */
144
141
  const MAX_FILES = 3000;
145
142
 
146
- /** Directory names that are never recursed into. */
147
- const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', 'coverage']);
143
+ /**
144
+ * Directory names the RECURSIVE walk does not descend into.
145
+ *
146
+ * This is a budget guard, not the Files panel's noise table (there is none any
147
+ * more — `lib/fs/listing.ts` lists every entry). `/api/fs/list` returns a flat
148
+ * file index capped at `MAX_FILES` for the composer's `@` picker, and
149
+ * descending into a dependency tree spends that whole budget inside
150
+ * `node_modules` — leaving the picker with no project file in it. The tree
151
+ * panel is unaffected: it lists lazily, one directory per request, so a folder
152
+ * is only walked when someone expands it.
153
+ */
154
+ const SKIP_DIRS: Record<string, true> = {
155
+ node_modules: true,
156
+ '.git': true,
157
+ dist: true,
158
+ build: true,
159
+ '.next': true,
160
+ '.cache': true,
161
+ coverage: true,
162
+ };
148
163
 
149
164
  interface ListFileEntry {
150
165
  name: string;
@@ -172,7 +187,7 @@ async function walk(dir: string, baseDir: string, out: ListFileEntry[]): Promise
172
187
  if (entry.name.startsWith('.')) continue;
173
188
 
174
189
  if (entry.isDirectory()) {
175
- if (SKIP_DIRS.has(entry.name)) continue;
190
+ if (SKIP_DIRS[entry.name]) continue;
176
191
  await walk(path.join(dir, entry.name), baseDir, out);
177
192
  continue;
178
193
  }
@@ -29,6 +29,13 @@ const AGENT_DIR = path.join(os.tmpdir(), `omc-batch-delete-${process.pid}`, 'age
29
29
  let priorAgentDir: string | undefined;
30
30
 
31
31
  beforeAll(() => {
32
+ // This suite reaches the database: `removeSession` → `purgeSessionData`
33
+ // resolves `getDb()`. Without isolating, it inherits whatever handle an
34
+ // earlier file left on the process-wide slot — and when that handle points at
35
+ // a temp directory its owner already deleted, SQLite answers `disk I/O error`
36
+ // and the verdict loses its code (measured on CI: `not_found` came back
37
+ // `undefined` while the same file passed alone).
38
+ isolateDb();
32
39
  fs.rmSync(AGENT_DIR, { recursive: true, force: true });
33
40
  fs.mkdirSync(path.join(AGENT_DIR, 'sessions'), { recursive: true });
34
41
  priorAgentDir = Bun.env.PI_CODING_AGENT_DIR;
@@ -41,6 +48,7 @@ afterAll(() => {
41
48
  if (priorAgentDir === undefined) delete Bun.env.PI_CODING_AGENT_DIR;
42
49
  else Bun.env.PI_CODING_AGENT_DIR = priorAgentDir;
43
50
  fs.rmSync(path.dirname(AGENT_DIR), { recursive: true, force: true });
51
+ releaseDb();
44
52
  });
45
53
 
46
54
  function post(body: unknown, url: string): Promise<Response> {
@@ -124,16 +132,11 @@ describe('batchDeleteFolders request shape', () => {
124
132
  });
125
133
 
126
134
  test('an unknown workspace is refused per id', async () => {
127
- isolateDb();
128
- try {
129
- const res = await postFolder({ ids: ['424242'] });
130
- expect(res.status).toBe(200);
131
- const body = await res.json() as { success: boolean; removed: string[]; refused: { code?: string }[] };
132
- expect(body.success).toBe(false);
133
- expect(body.removed).toEqual([]);
134
- expect(body.refused[0].code).toBe('not_found');
135
- } finally {
136
- releaseDb();
137
- }
135
+ const res = await postFolder({ ids: ['424242'] });
136
+ expect(res.status).toBe(200);
137
+ const body = await res.json() as { success: boolean; removed: string[]; refused: { code?: string }[] };
138
+ expect(body.success).toBe(false);
139
+ expect(body.removed).toEqual([]);
140
+ expect(body.refused[0].code).toBe('not_found');
138
141
  });
139
142
  });
@@ -0,0 +1,22 @@
1
+ import { json, NO_STORE_HEADERS } from '@/server/lib/remix-compat';
2
+ import { fetchAndroidRelease, type AndroidRelease } from '@/server/lib/updates/android-release';
3
+
4
+ /**
5
+ * GET /api/updates/android — the newest APK a visitor can download.
6
+ *
7
+ * Its own route rather than a field on `/api/updates/check`: that one answers for
8
+ * the INSTALLED server (npm), this one answers for a different product (the
9
+ * Android shell), and folding them would make one probe's failure look like the
10
+ * other's. The client asks for it only when the sidebar renders, so a console
11
+ * that never shows the button never pays for the read.
12
+ *
13
+ * A failed probe is a valid answer (`url: null`), never a 500: the sidebar's job
14
+ * is to omit a button it cannot back with a link.
15
+ */
16
+ export async function loader() {
17
+ const release: AndroidRelease | null = await fetchAndroidRelease();
18
+ return json(
19
+ release ?? { url: null, latest: null, releaseUrl: null },
20
+ { headers: NO_STORE_HEADERS },
21
+ );
22
+ }
@@ -1,9 +1,11 @@
1
1
  import { bindingsFor, type HandlerBinding } from '@/server/lib/route-adapter';
2
+ import * as updatesAndroid from '@/server/routes/updates/android';
2
3
  import * as updatesApply from '@/server/routes/updates/apply';
3
4
  import * as updatesChangelog from '@/server/routes/updates/changelog';
4
5
  import * as updatesCheck from '@/server/routes/updates/check';
5
6
 
6
7
  export const updatesBindings: HandlerBinding[] = [
8
+ ...bindingsFor(updatesAndroid, '/api/updates/android'),
7
9
  ...bindingsFor(updatesApply, '/api/updates/apply'),
8
10
  ...bindingsFor(updatesChangelog, '/api/updates/changelog'),
9
11
  ...bindingsFor(updatesCheck, '/api/updates/check'),
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { androidShellVersion, isAndroidDevice, SHELL_UA_PREFIX } from '@/shared/lib/updates/shell-ua';
9
+
10
+ /**
11
+ * The token is the only signal that a page is running inside the Android app,
12
+ * and it decides whether a download button appears. Two failure modes matter:
13
+ * a browser mistaken for the app (a button in a place it does not belong) and a
14
+ * malformed token read as a version (a comparison against `''`).
15
+ */
16
+ describe('androidShellVersion', () => {
17
+ test('reads the version from a real WebView UA', () => {
18
+ const ua =
19
+ 'Mozilla/5.0 (Linux; Android 15; Pixel 6 Build/AP4A; wv) AppleWebKit/537.36 (KHTML, like Gecko) '
20
+ + `Version/4.0 Chrome/124.0.6367.219 Mobile Safari/537.36 ${SHELL_UA_PREFIX}0.1.0`;
21
+ expect(androidShellVersion(ua)).toBe('0.1.0');
22
+ });
23
+
24
+ test('is null for a plain browser', () => {
25
+ expect(androidShellVersion('Mozilla/5.0 (Macintosh) AppleWebKit/537.36 Chrome/124.0 Safari/537.36')).toBeNull();
26
+ expect(androidShellVersion('')).toBeNull();
27
+ expect(androidShellVersion(null)).toBeNull();
28
+ expect(androidShellVersion(undefined)).toBeNull();
29
+ });
30
+
31
+ test('is null when the token carries no usable version', () => {
32
+ // A forged or truncated token must not read as version '' — that would make
33
+ // `isNewer(latest, '')` true for every release and show a button always.
34
+ expect(androidShellVersion('Mozilla/5.0 KabmoAndroid/')).toBeNull();
35
+ expect(androidShellVersion('Mozilla/5.0 KabmoAndroid/abc')).toBeNull();
36
+ expect(androidShellVersion('Mozilla/5.0 KabmoAndroid/1.x.3')).toBeNull();
37
+ });
38
+
39
+ test('accepts one-, two- and three-part versions and a prerelease', () => {
40
+ expect(androidShellVersion('KabmoAndroid/1')).toBe('1');
41
+ expect(androidShellVersion('KabmoAndroid/1.2')).toBe('1.2');
42
+ expect(androidShellVersion('KabmoAndroid/1.2.3')).toBe('1.2.3');
43
+ expect(androidShellVersion('KabmoAndroid/1.2.3-beta.1')).toBe('1.2.3-beta.1');
44
+ });
45
+
46
+ test('takes the first token when the UA was crafted twice', () => {
47
+ expect(androidShellVersion('KabmoAndroid/1.0.0 KabmoAndroid/9.9.9')).toBe('1.0.0');
48
+ });
49
+
50
+ test('does not match a lookalike token', () => {
51
+ expect(androidShellVersion('KabmoAndroidX/1.0.0')).toBeNull();
52
+ expect(androidShellVersion('Mozilla KabmoAndroid')).toBeNull();
53
+ });
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
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The `KabmoAndroid/<version>` token in a request's User-Agent.
3
+ *
4
+ * The APK appends it (`KabmoWebView.configure`), and it is the console's only
5
+ * signal for two questions at once: "am I running inside the Android app" and
6
+ * "which build of it". The UA is the right channel because it crosses the very
7
+ * request that renders the shell, so the answer needs no bridge, no extra
8
+ * endpoint, and no round trip — the page is served already knowing.
9
+ *
10
+ * Parsed leniently and defensively: a browser can be made to send any UA, so a
11
+ * forged token buys nothing here (the value only decides whether to show a
12
+ * download button), and a malformed one must read as "not the app" rather than
13
+ * as version `''`.
14
+ */
15
+
16
+ /** The token's name, before the slash. */
17
+ export const SHELL_UA_PREFIX = 'KabmoAndroid/';
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
+
39
+ /**
40
+ * The APK version that sent this request, or null when it is not the app.
41
+ *
42
+ * The FIRST matching token wins: the suffix is appended once, and a duplicate
43
+ * would mean something else crafted the string.
44
+ */
45
+ export function androidShellVersion(userAgent: string | null | undefined): string | null {
46
+ if (!userAgent) return null;
47
+
48
+ for (const token of userAgent.split(/\s+/)) {
49
+ if (!token.startsWith(SHELL_UA_PREFIX)) continue;
50
+ const version = token.slice(SHELL_UA_PREFIX.length);
51
+ // A version, not an arbitrary string: `1`, `1.2`, `1.2.3`, optionally with a
52
+ // prerelease suffix. Anything else is a token this parser does not own.
53
+ if (/^\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?$/.test(version)) return version;
54
+ }
55
+ return null;
56
+ }
@@ -21,6 +21,19 @@
21
21
  /** Listeners per session, then per slot (`ALL_SLOTS` = every slot of it). */
22
22
  const listeners = new Map<string, Map<string, Set<() => void>>>();
23
23
 
24
+ /**
25
+ * Listeners for a session's blob being REPLACED wholesale (a fetch landing, a
26
+ * spawn-adoption migrate, a delete) — not for one slot being written.
27
+ *
28
+ * Separate from the per-slot bus because the two mean different things to a
29
+ * private copy. `useSessionState` hands every caller its own `useState`, which
30
+ * deliberately does not follow another component's writes to the same key; but
31
+ * a replacement invalidates the copy outright (the store now holds different
32
+ * state for the whole session), so a reader must re-read or it keeps rendering
33
+ * a stale — often fallback — value forever.
34
+ */
35
+ const replacedListeners = new Map<string, Set<() => void>>();
36
+
24
37
  /** Bucket name for a listener that follows every slot of a session. */
25
38
  const ALL_SLOTS = '*';
26
39
 
@@ -57,6 +70,31 @@ function notifyBucket(bucket: Set<() => void> | undefined): void {
57
70
  for (const listener of [...bucket]) listener();
58
71
  }
59
72
 
73
+ /**
74
+ * Listen for this session's blob being replaced wholesale. Returns the
75
+ * unsubscribe.
76
+ */
77
+ export function subscribeSessionReplaced(sessionId: string | null, listener: () => void): () => void {
78
+ if (!sessionId) return () => {};
79
+ let bucket = replacedListeners.get(sessionId);
80
+ if (!bucket) {
81
+ bucket = new Set();
82
+ replacedListeners.set(sessionId, bucket);
83
+ }
84
+ bucket.add(listener);
85
+ return () => {
86
+ bucket.delete(listener);
87
+ if (bucket.size === 0) replacedListeners.delete(sessionId);
88
+ };
89
+ }
90
+
91
+ /** Announce that this session's whole blob changed under its readers. */
92
+ export function notifySessionReplaced(sessionId: string): void {
93
+ const bucket = replacedListeners.get(sessionId);
94
+ if (!bucket) return;
95
+ for (const listener of [...bucket]) listener();
96
+ }
97
+
60
98
  /** Fire the listeners of one slot, or of every slot when `key` is omitted. */
61
99
  export function notifySessionKey(sessionId: string, key?: string): void {
62
100
  const slots = listeners.get(sessionId);