@kabmo-labs/kabmo 0.1.0 → 0.2.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 +285 -285
- package/dist/client/static/js/index-pdp5dwc8.css +1 -0
- package/dist/client/static/js/{index-enkbbffs.js → index-zsskckw7.js} +671 -671
- package/package.json +1 -1
- package/src/client/bootstrap.d.ts +8 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
- package/src/client/components/common/header-controls/index.test.tsx +77 -11
- package/src/client/components/common/header-controls/index.tsx +53 -4
- package/src/client/components/common/session-state-provider/index.test.ts +22 -1
- package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
- package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +2 -33
- package/src/client/components/layout/session-sidebar/index.tsx +5 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
- package/src/client/components/mobile/SessionSidebar.tsx +5 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +104 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +166 -106
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
- package/src/client/components/workspace/git-panel/index.test.ts +15 -0
- package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
- package/src/client/components/workspace/search-panel/Body.tsx +53 -9
- package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
- package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
- package/src/client/hooks/chat/timeline/index.ts +2 -1
- package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
- package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
- package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
- package/src/client/hooks/chat/timeline/scroll.ts +34 -1
- package/src/client/hooks/ui/media-query.ts +41 -0
- package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
- package/src/client/hooks/ui/updates/android-download.ts +106 -0
- package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/session-state/index.ts +24 -1
- package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
- package/src/client/lib/native/back.test.ts +108 -0
- package/src/client/lib/native/back.ts +60 -0
- package/src/client/main.tsx +6 -0
- package/src/server/lib/fs/git-actions-branch.ts +18 -1
- package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
- package/src/server/lib/fs/git-publish.test.ts +96 -0
- package/src/server/lib/fs/git-publish.ts +35 -3
- package/src/server/lib/fs/listing.test.ts +86 -0
- package/src/server/lib/fs/listing.ts +20 -6
- package/src/server/lib/updates/android-release.test.ts +92 -0
- package/src/server/lib/updates/android-release.ts +148 -0
- package/src/server/plugins/ssr.ts +9 -2
- package/src/server/routes/fs/read.ts +24 -9
- package/src/server/routes/sessions/batch-delete.test.ts +14 -11
- package/src/server/routes/updates/android.ts +22 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/shared/lib/updates/shell-ua.test.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +36 -0
- package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
- package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
- package/src/shared/lib/workspace/session-state/store.ts +13 -2
- package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
- package/src/shared/lib/workspace/sidebar-toolbar.ts +78 -0
- package/dist/client/static/js/index-yn17t04b.css +0 -1
|
@@ -0,0 +1,148 @@
|
|
|
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 the GitHub release asset, which means one
|
|
7
|
+
* unauthenticated REST read of the newest release.
|
|
8
|
+
*
|
|
9
|
+
* Two facts travel back, and they answer different questions:
|
|
10
|
+
*
|
|
11
|
+
* - `url` is the asset to download. It is resolved from the release's OWN asset
|
|
12
|
+
* list rather than built as `releases/latest/download/<name>`, because that
|
|
13
|
+
* path needs the asset name to match exactly and the name carries the tag
|
|
14
|
+
* (`kabmo-android-v1.2.3.apk`) — a name built here would be a second copy of
|
|
15
|
+
* the workflow's naming rule, and the first release that renames it 404s.
|
|
16
|
+
* - `latest` is the version that asset ships, read from the asset name. The
|
|
17
|
+
* release TAG cannot be used: the APK is attached to the app's release, whose
|
|
18
|
+
* tag names the SERVER version, and the two move independently.
|
|
19
|
+
*
|
|
20
|
+
* Failure is a normal answer here (`null`), never a throw: an offline box or a
|
|
21
|
+
* rate-limited GitHub must not take the sidebar down, and a button that appears
|
|
22
|
+
* because a probe failed would offer a download that does not exist.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { REPO } from '@/server/lib/updates/release-url';
|
|
26
|
+
|
|
27
|
+
/** One published APK, as the console needs it. */
|
|
28
|
+
export interface AndroidRelease {
|
|
29
|
+
/** Asset download URL, or null when the release carries no APK. */
|
|
30
|
+
url: string | null;
|
|
31
|
+
/** Version the asset name declares (`v1.2.3` → `1.2.3`), or null. */
|
|
32
|
+
latest: string | null;
|
|
33
|
+
/** The release page, for a "what changed" link. */
|
|
34
|
+
releaseUrl: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The asset-name prefix the release workflow writes. */
|
|
38
|
+
const ASSET_PREFIX = 'kabmo-android-';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `kabmo-android-v1.2.3.apk` → `1.2.3`.
|
|
42
|
+
*
|
|
43
|
+
* The `v` is optional because a hand-uploaded asset may omit it, and a version
|
|
44
|
+
* that cannot be read is not evidence of an update — the caller treats null as
|
|
45
|
+
* "no new version".
|
|
46
|
+
*/
|
|
47
|
+
export function versionFromAssetName(name: string): string | null {
|
|
48
|
+
if (!name.startsWith(ASSET_PREFIX) || !name.endsWith('.apk')) return null;
|
|
49
|
+
const middle = name.slice(ASSET_PREFIX.length, -'.apk'.length);
|
|
50
|
+
const version = middle.startsWith('v') ? middle.slice(1) : middle;
|
|
51
|
+
return /^\d+\.\d+\.\d+/.test(version) ? version : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The newest release's APK.
|
|
56
|
+
*
|
|
57
|
+
* One request, cached for five minutes on `globalThis` for the same reason the
|
|
58
|
+
* database handle is anchored there: `bun --hot` re-evaluates this module while
|
|
59
|
+
* the cache it would otherwise hold lives on. GitHub allows 60 unauthenticated
|
|
60
|
+
* reads an hour per address, and the sidebar asks on every mount.
|
|
61
|
+
*/
|
|
62
|
+
export async function fetchAndroidRelease(fetchImpl: typeof fetch = fetch): Promise<AndroidRelease | null> {
|
|
63
|
+
const cache = cacheSlot();
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
if (cache.entry && now - cache.at < CACHE_MS) return cache.entry;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetchImpl(`${githubApiBase()}/repos/${REPO}/releases/latest`, {
|
|
69
|
+
headers: {
|
|
70
|
+
accept: 'application/vnd.github+json',
|
|
71
|
+
// GitHub rejects a request with no User-Agent.
|
|
72
|
+
'user-agent': 'kabmo-console',
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
if (!response.ok) return cache.entry ?? null;
|
|
76
|
+
|
|
77
|
+
const body = (await response.json()) as {
|
|
78
|
+
html_url?: unknown;
|
|
79
|
+
assets?: unknown;
|
|
80
|
+
};
|
|
81
|
+
// A body that is not the release document is a failed probe, not an empty
|
|
82
|
+
// release: answering `{url: null, latest: null}` here would be
|
|
83
|
+
// indistinguishable from "a release with no APK", and the caller would cache
|
|
84
|
+
// that mistake for five minutes.
|
|
85
|
+
if (!Array.isArray(body.assets)) return cache.entry ?? null;
|
|
86
|
+
|
|
87
|
+
const assets = body.assets;
|
|
88
|
+
const asset = assets
|
|
89
|
+
.map((entry) => (entry && typeof entry === 'object' ? (entry as Record<string, unknown>) : null))
|
|
90
|
+
.find((entry) => entry && typeof entry.name === 'string' && versionFromAssetName(entry.name) !== null);
|
|
91
|
+
|
|
92
|
+
const name = typeof asset?.name === 'string' ? asset.name : '';
|
|
93
|
+
const url = typeof asset?.browser_download_url === 'string' ? asset.browser_download_url : null;
|
|
94
|
+
const release: AndroidRelease = {
|
|
95
|
+
url,
|
|
96
|
+
latest: versionFromAssetName(name),
|
|
97
|
+
releaseUrl: typeof body.html_url === 'string' ? body.html_url : null,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
cache.entry = release;
|
|
101
|
+
cache.at = now;
|
|
102
|
+
return release;
|
|
103
|
+
} catch {
|
|
104
|
+
// Keep the last good answer rather than replacing it with a failure: a
|
|
105
|
+
// momentary outage must not remove a download button that was valid.
|
|
106
|
+
return cache.entry ?? null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const CACHE_MS = 5 * 60_000;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Where the release read goes.
|
|
114
|
+
*
|
|
115
|
+
* Overridable for the same reason the npm probe honours `NPM_CONFIG_REGISTRY`: a
|
|
116
|
+
* test and a rehearsal need to exercise the real parsing against a stub instead
|
|
117
|
+
* of a live GitHub call, and hardcoding the host makes that impossible without
|
|
118
|
+
* intercepting `fetch` globally.
|
|
119
|
+
*/
|
|
120
|
+
function githubApiBase(): string {
|
|
121
|
+
const configured = Bun.env.KABMO_GITHUB_API ?? '';
|
|
122
|
+
return configured.trim().replace(/\/+$/, '') || 'https://api.github.com';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface CacheSlot {
|
|
126
|
+
entry: AndroidRelease | null;
|
|
127
|
+
at: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function cacheSlot(): CacheSlot {
|
|
131
|
+
const host = globalThis as typeof globalThis & { __kabmoAndroidRelease?: CacheSlot };
|
|
132
|
+
host.__kabmoAndroidRelease ??= { entry: null, at: 0 };
|
|
133
|
+
return host.__kabmoAndroidRelease;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Drop the cached answer.
|
|
138
|
+
*
|
|
139
|
+
* The cache lives on `globalThis` so `bun --hot` cannot orphan it, which also
|
|
140
|
+
* means it outlives a test file: without this seam the second case in a suite
|
|
141
|
+
* reads the first case's answer and passes for the wrong reason (measured — the
|
|
142
|
+
* "malformed body" case returned the previous test's release). Production has no
|
|
143
|
+
* caller; it exists because the cache is process-wide by design.
|
|
144
|
+
*/
|
|
145
|
+
export function resetAndroidReleaseCache(): void {
|
|
146
|
+
cacheSlot().entry = null;
|
|
147
|
+
cacheSlot().at = 0;
|
|
148
|
+
}
|
|
@@ -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 } 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,13 @@ 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
|
|
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
|
+
const shellVersion = androidShellVersion(userAgent);
|
|
157
164
|
|
|
158
165
|
const rendered = await renderShell();
|
|
159
166
|
if (!rendered.ok) return shellUnavailableResponse(rendered.reason);
|
|
@@ -165,7 +172,7 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
165
172
|
// questions: "this browser has a session" versus "there is a session to have".
|
|
166
173
|
// A Kabmo running open answers `authenticated: true` for every visitor, so
|
|
167
174
|
// 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');
|
|
175
|
+
const bootstrap = JSON.stringify({ initialIsMobile, shellVersion, authenticated, authEnabled: isAuthEnabled(), production: !DEV_ASSETS_ENABLED, appSettings: settings }).replace(/</g, '\\u003c');
|
|
169
176
|
// The template's own `<meta name="theme-color">` is REWRITTEN, not joined by
|
|
170
177
|
// a second tag: with both present Chrome reads the first one, so an appended
|
|
171
178
|
// 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
|
-
|
|
100
|
-
|
|
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
|
-
/**
|
|
147
|
-
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { androidShellVersion, 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
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
* The APK version that sent this request, or null when it is not the app.
|
|
21
|
+
*
|
|
22
|
+
* The FIRST matching token wins: the suffix is appended once, and a duplicate
|
|
23
|
+
* would mean something else crafted the string.
|
|
24
|
+
*/
|
|
25
|
+
export function androidShellVersion(userAgent: string | null | undefined): string | null {
|
|
26
|
+
if (!userAgent) return null;
|
|
27
|
+
|
|
28
|
+
for (const token of userAgent.split(/\s+/)) {
|
|
29
|
+
if (!token.startsWith(SHELL_UA_PREFIX)) continue;
|
|
30
|
+
const version = token.slice(SHELL_UA_PREFIX.length);
|
|
31
|
+
// A version, not an arbitrary string: `1`, `1.2`, `1.2.3`, optionally with a
|
|
32
|
+
// prerelease suffix. Anything else is a token this parser does not own.
|
|
33
|
+
if (/^\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?$/.test(version)) return version;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
@@ -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);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The store's WHOLESALE-REPLACEMENT notifications.
|
|
8
|
+
*
|
|
9
|
+
* `useSessionState` hands every caller a private copy that deliberately does
|
|
10
|
+
* not follow another component's per-slot write (a panel width is owned by one
|
|
11
|
+
* component). But the store also REPLACES a session's whole state — a blob
|
|
12
|
+
* landing, a hydrate, a spawn-adoption migrate, a delete — and there the copy's
|
|
13
|
+
* value was never written by anyone: it is the fallback the component read on
|
|
14
|
+
* the commit the session id changed. `ready` does not move across an adoption,
|
|
15
|
+
* so without this event every opened right-panel view collapsed at the first
|
|
16
|
+
* send of a new chat (measured: `true:git` → `true:files`).
|
|
17
|
+
*
|
|
18
|
+
* Split from `store.test.ts` for the per-file size ceiling.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
22
|
+
|
|
23
|
+
import { subscribeSessionReplaced } from '@/shared/lib/workspace/session-state/listeners';
|
|
24
|
+
import {
|
|
25
|
+
clearSessionKey,
|
|
26
|
+
forgetSession,
|
|
27
|
+
getSessionValue,
|
|
28
|
+
hydrateSession,
|
|
29
|
+
loadSession,
|
|
30
|
+
migrateSessionState,
|
|
31
|
+
setSessionKey,
|
|
32
|
+
} from '@/shared/lib/workspace/session-state/store';
|
|
33
|
+
|
|
34
|
+
const originalWindow = Reflect.get(globalThis, 'window');
|
|
35
|
+
/** The runner's own fetch, reached through `Bun` so a stub leaked onto the global cannot be mistaken for it. */
|
|
36
|
+
const originalFetch = Bun.fetch;
|
|
37
|
+
const touched: string[] = [];
|
|
38
|
+
const unsubs: Array<() => void> = [];
|
|
39
|
+
|
|
40
|
+
/** Register a session id so `afterEach` forgets it. */
|
|
41
|
+
function use(id: string): string {
|
|
42
|
+
touched.push(id);
|
|
43
|
+
return id;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Follow wholesale blob replacements for a session, torn down with the rest. */
|
|
47
|
+
function followReplaced(sessionId: string, listener: () => void): () => void {
|
|
48
|
+
const unsubscribe = subscribeSessionReplaced(sessionId, listener);
|
|
49
|
+
unsubs.push(unsubscribe);
|
|
50
|
+
return unsubscribe;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Count how many times a notification reaches a listener. */
|
|
54
|
+
function counter(): { calls: number; listener: () => void } {
|
|
55
|
+
const state = { calls: 0, listener: () => { state.calls += 1; } };
|
|
56
|
+
return state;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
for (const unsubscribe of unsubs.splice(0)) unsubscribe();
|
|
61
|
+
for (const id of touched.splice(0)) forgetSession(id);
|
|
62
|
+
Reflect.set(globalThis, 'window', originalWindow);
|
|
63
|
+
Reflect.set(globalThis, 'fetch', originalFetch);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('session-state store: wholesale replacement notifications', () => {
|
|
67
|
+
test('migrateSessionState announces both ids as replaced', () => {
|
|
68
|
+
const from = use('new-replace');
|
|
69
|
+
const to = use('real-replace');
|
|
70
|
+
setSessionKey(from, 'layout.showRightPanel', true);
|
|
71
|
+
const fromProbe = counter();
|
|
72
|
+
const toProbe = counter();
|
|
73
|
+
followReplaced(from, fromProbe.listener);
|
|
74
|
+
followReplaced(to, toProbe.listener);
|
|
75
|
+
|
|
76
|
+
migrateSessionState(from, to);
|
|
77
|
+
|
|
78
|
+
expect(fromProbe.calls).toBe(1);
|
|
79
|
+
expect(toProbe.calls).toBe(1);
|
|
80
|
+
expect<boolean | undefined>(getSessionValue(to, 'layout.showRightPanel')).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a per-slot write is NOT a replacement', () => {
|
|
84
|
+
const id = use('slot-write');
|
|
85
|
+
const replaced = counter();
|
|
86
|
+
followReplaced(id, replaced.listener);
|
|
87
|
+
|
|
88
|
+
setSessionKey(id, 'layout.activeRightPanel', 'git');
|
|
89
|
+
clearSessionKey(id, 'layout.activeRightPanel');
|
|
90
|
+
|
|
91
|
+
// Only the wholesale paths (load/hydrate/migrate/forget) replace state;
|
|
92
|
+
// announcing a write here would make every editor and panel copy re-read
|
|
93
|
+
// on every keystroke elsewhere in the app.
|
|
94
|
+
expect(replaced.calls).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('a blob landing, a hydrate and a delete all announce a replacement', async () => {
|
|
98
|
+
const id = use('replace-paths');
|
|
99
|
+
const probe = counter();
|
|
100
|
+
followReplaced(id, probe.listener);
|
|
101
|
+
Reflect.set(globalThis, 'window', {});
|
|
102
|
+
Reflect.set(globalThis, 'fetch', async () => ({ ok: true, json: async () => ({ state: { a: 1 } }) }));
|
|
103
|
+
|
|
104
|
+
await loadSession(id);
|
|
105
|
+
expect(probe.calls).toBe(1);
|
|
106
|
+
|
|
107
|
+
hydrateSession(id, { b: 2 });
|
|
108
|
+
expect(probe.calls).toBe(2);
|
|
109
|
+
|
|
110
|
+
forgetSession(id);
|
|
111
|
+
expect(probe.calls).toBe(3);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a subscriber on a null session id is never notified of a replacement', () => {
|
|
115
|
+
const probe = counter();
|
|
116
|
+
followReplaced(null as unknown as string, probe.listener);
|
|
117
|
+
const id = use('replace-null');
|
|
118
|
+
hydrateSession(id, { a: 1 });
|
|
119
|
+
expect(probe.calls).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
* - chat.draftAttachments attachment metadata array
|
|
69
69
|
*/
|
|
70
70
|
|
|
71
|
-
import { notifySessionKey } from '@/shared/lib/workspace/session-state/listeners';
|
|
71
|
+
import { notifySessionKey, notifySessionReplaced } from '@/shared/lib/workspace/session-state/listeners';
|
|
72
72
|
|
|
73
73
|
type SessionState = Record<string, unknown>;
|
|
74
74
|
|
|
@@ -263,8 +263,10 @@ export async function loadSession(sessionId: string): Promise<void> {
|
|
|
263
263
|
}
|
|
264
264
|
evictIfNeeded(sessionId);
|
|
265
265
|
// The blob replaces every slot at once, so a follower that read the store
|
|
266
|
-
// before it arrived (no pick yet) has to re-read
|
|
266
|
+
// before it arrived (no pick yet) has to re-read — including a private copy
|
|
267
|
+
// held by `useSessionState`, which does not follow per-slot writes.
|
|
267
268
|
notifySessionKey(sessionId);
|
|
269
|
+
notifySessionReplaced(sessionId);
|
|
268
270
|
}
|
|
269
271
|
|
|
270
272
|
/** Seed the cache synchronously (e.g. from SSR-provided data). */
|
|
@@ -274,6 +276,7 @@ export function hydrateSession(sessionId: string | null, state: SessionState): v
|
|
|
274
276
|
touchCacheEntry(sessionId);
|
|
275
277
|
evictIfNeeded(sessionId);
|
|
276
278
|
notifySessionKey(sessionId);
|
|
279
|
+
notifySessionReplaced(sessionId);
|
|
277
280
|
}
|
|
278
281
|
|
|
279
282
|
/**
|
|
@@ -308,6 +311,7 @@ export function forgetSession(sessionId: string): void {
|
|
|
308
311
|
dirtySessions.delete(sessionId);
|
|
309
312
|
lastTouched.delete(sessionId);
|
|
310
313
|
notifySessionKey(sessionId);
|
|
314
|
+
notifySessionReplaced(sessionId);
|
|
311
315
|
}
|
|
312
316
|
|
|
313
317
|
/**
|
|
@@ -327,4 +331,11 @@ export function migrateSessionState(fromId: string, toId: string): void {
|
|
|
327
331
|
// The adopted slot gained the whole transient state; the transient one lost it.
|
|
328
332
|
notifySessionKey(toId);
|
|
329
333
|
notifySessionKey(fromId);
|
|
334
|
+
// Both entries were REPLACED, not written slot by slot. `ready` does not
|
|
335
|
+
// change across an adoption (it was already true for the pending id), so
|
|
336
|
+
// without this a private copy kept the fallback it read on the commit the
|
|
337
|
+
// URL moved — the reported "opening panels in a new chat and sending the
|
|
338
|
+
// first prompt closes them all".
|
|
339
|
+
notifySessionReplaced(toId);
|
|
340
|
+
notifySessionReplaced(fromId);
|
|
330
341
|
}
|