@kabmo-labs/kabmo 0.2.0 → 0.3.1
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-28z316qk.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/panels/git-update.server.test.ts +18 -1
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kabmo-labs/kabmo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Web console and diagnostic workbench for AI coding agents — Elysia + Preact on Bun.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
|
|
@@ -26,10 +26,19 @@ export interface KabmoBootstrap {
|
|
|
26
26
|
* The Android shell's version, when this page was requested by the APK
|
|
27
27
|
* (`KabmoAndroid/<version>` in the User-Agent). Null in every browser.
|
|
28
28
|
*
|
|
29
|
-
* The sidebar uses it to decide whether
|
|
30
|
-
*
|
|
29
|
+
* The sidebar uses it to decide whether an UPDATE applies — this install is
|
|
30
|
+
* behind the published APK.
|
|
31
31
|
*/
|
|
32
32
|
shellVersion?: string | null;
|
|
33
|
+
/**
|
|
34
|
+
* Whether this request came from an Android device at all — the app, or a
|
|
35
|
+
* browser running on it.
|
|
36
|
+
*
|
|
37
|
+
* The wider fact, and it exists for the INSTALL offer: a reader on Chrome or
|
|
38
|
+
* Brave for Android carries no shell token, so `shellVersion` alone cannot
|
|
39
|
+
* tell them from a desktop where an APK is useless.
|
|
40
|
+
*/
|
|
41
|
+
isAndroid?: boolean;
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
declare global {
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { Download, Info, Settings } from 'lucide-preact';
|
|
2
|
+
import {
|
|
3
|
+
androidDownloadLabel,
|
|
4
|
+
type AndroidDownload,
|
|
5
|
+
} from '@/client/hooks/ui/updates/android-download';
|
|
2
6
|
|
|
3
7
|
interface SessionSidebarFooterProps {
|
|
4
8
|
onSettings: () => void;
|
|
5
9
|
onInfo: () => void;
|
|
6
10
|
updateAvailable?: boolean;
|
|
7
11
|
onUpdateClick?: () => void;
|
|
8
|
-
/** The
|
|
9
|
-
androidDownload?:
|
|
12
|
+
/** The APK to offer, or null. Set inside the app (an update) or on an Android browser (an install). */
|
|
13
|
+
androidDownload?: AndroidDownload | null;
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
/**
|
|
@@ -42,16 +46,16 @@ export function SessionSidebarFooter({ onSettings, onInfo, updateAvailable, onUp
|
|
|
42
46
|
<Info size={16} />
|
|
43
47
|
</button>
|
|
44
48
|
{androidDownload && (
|
|
45
|
-
// Next to About
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
// `useAndroidDownload`.
|
|
49
|
+
// Next to About. Two reasons to be here, and the label says which:
|
|
50
|
+
// an installed APK that is behind (an update), or an Android browser
|
|
51
|
+
// whose reader may not know the shell exists (an install). A desktop
|
|
52
|
+
// browser gets neither — see `useAndroidDownload`.
|
|
49
53
|
<a
|
|
50
54
|
href={androidDownload.url}
|
|
51
55
|
target="_blank"
|
|
52
56
|
rel="noopener noreferrer"
|
|
53
|
-
title={
|
|
54
|
-
aria-label={
|
|
57
|
+
title={androidDownloadLabel(androidDownload)}
|
|
58
|
+
aria-label={androidDownloadLabel(androidDownload)}
|
|
55
59
|
className="p-1 rounded hover:text-ink hover:bg-ink/5 cursor-pointer transition-colors"
|
|
56
60
|
>
|
|
57
61
|
<Download size={16} />
|
|
@@ -57,9 +57,12 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
|
|
|
57
57
|
const [schedulerOpen, setSchedulerOpen] = useState(false);
|
|
58
58
|
const scheduledCount = useScheduledTaskCount();
|
|
59
59
|
const updates = useUpdates();
|
|
60
|
-
// The Android
|
|
61
|
-
//
|
|
62
|
-
const androidDownload = useAndroidDownload(
|
|
60
|
+
// The Android offer: an UPDATE inside the APK, an INSTALL on an Android
|
|
61
|
+
// browser, and nothing anywhere else — see `useAndroidDownload`.
|
|
62
|
+
const androidDownload = useAndroidDownload(
|
|
63
|
+
window.__OMP_BOOTSTRAP__?.shellVersion,
|
|
64
|
+
window.__OMP_BOOTSTRAP__?.isAndroid,
|
|
65
|
+
);
|
|
63
66
|
const { toasts, pushToast, dismissToast } = useToasts();
|
|
64
67
|
|
|
65
68
|
// Sidebar inline states
|
|
@@ -73,9 +73,12 @@ export function MobileSessionSidebar({
|
|
|
73
73
|
const scheduledCount = useScheduledTaskCount();
|
|
74
74
|
const [aboutOpen, setAboutOpen] = useState(false);
|
|
75
75
|
const updates = useUpdates();
|
|
76
|
-
//
|
|
77
|
-
// `useAndroidDownload`.
|
|
78
|
-
const androidDownload = useAndroidDownload(
|
|
76
|
+
// An update inside the APK, an install on an Android browser, nothing
|
|
77
|
+
// elsewhere — see `useAndroidDownload`.
|
|
78
|
+
const androidDownload = useAndroidDownload(
|
|
79
|
+
window.__OMP_BOOTSTRAP__?.shellVersion,
|
|
80
|
+
window.__OMP_BOOTSTRAP__?.isAndroid,
|
|
81
|
+
);
|
|
79
82
|
const { toasts, pushToast, dismissToast } = useToasts();
|
|
80
83
|
|
|
81
84
|
// The popup's "Update now" hands the run over rather than starting its own —
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { Download, Info, Monitor, Settings } from 'lucide-preact';
|
|
2
2
|
import packageJson from '@/../package.json';
|
|
3
|
+
import {
|
|
4
|
+
androidDownloadLabel,
|
|
5
|
+
type AndroidDownload,
|
|
6
|
+
} from '@/client/hooks/ui/updates/android-download';
|
|
3
7
|
|
|
4
8
|
interface MobileSessionFooterProps {
|
|
5
9
|
onSettings: () => void;
|
|
6
10
|
onAbout: () => void;
|
|
7
11
|
updateAvailable?: boolean;
|
|
8
12
|
onUpdateClick?: () => void;
|
|
9
|
-
/** The
|
|
10
|
-
androidDownload?:
|
|
13
|
+
/** The APK to offer, or null. Set inside the app (an update) or on an Android browser (an install). */
|
|
14
|
+
androidDownload?: AndroidDownload | null;
|
|
11
15
|
/** Switch the app to the desktop layout for this page session only. The
|
|
12
16
|
* choice is not persisted, so the next load re-detects the device. */
|
|
13
17
|
onDesktopToggle?: () => void;
|
|
@@ -48,16 +52,20 @@ export function MobileSessionFooter({
|
|
|
48
52
|
// button: the download is the browser's (or the WebView's) job, and
|
|
49
53
|
// `target="_blank"` hands it to the system browser so the console is
|
|
50
54
|
// not replaced by a file download.
|
|
55
|
+
//
|
|
56
|
+
// The visible word is the OFFER, not the file type: `APK` told a
|
|
57
|
+
// reader what the bytes are but not what the control does, and on a
|
|
58
|
+
// browser it is the only place the native shell is ever mentioned.
|
|
51
59
|
<a
|
|
52
60
|
href={androidDownload.url}
|
|
53
61
|
target="_blank"
|
|
54
62
|
rel="noopener noreferrer"
|
|
55
|
-
title={
|
|
56
|
-
aria-label={
|
|
63
|
+
title={androidDownloadLabel(androidDownload)}
|
|
64
|
+
aria-label={androidDownloadLabel(androidDownload)}
|
|
57
65
|
className="flex items-center space-x-1 hover:text-ink transition-colors"
|
|
58
66
|
>
|
|
59
67
|
<Download size={14} />
|
|
60
|
-
<span>APK</span>
|
|
68
|
+
<span>{androidDownload.kind === 'update' ? 'Update APK' : 'Get APK'}</span>
|
|
61
69
|
</a>
|
|
62
70
|
)}
|
|
63
71
|
{onDesktopToggle && (
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"source": "@oh-my-pi/pi-coding-agent 10 tabs /
|
|
2
|
+
"source": "@oh-my-pi/pi-coding-agent 10 tabs / 559 settings",
|
|
3
3
|
"tabs": [
|
|
4
4
|
{
|
|
5
5
|
"id": "appearance",
|
|
@@ -507,6 +507,16 @@
|
|
|
507
507
|
]
|
|
508
508
|
}
|
|
509
509
|
},
|
|
510
|
+
"tui.completeScrollback": {
|
|
511
|
+
"type": "boolean",
|
|
512
|
+
"default": false,
|
|
513
|
+
"ui": {
|
|
514
|
+
"tab": "appearance",
|
|
515
|
+
"group": "Display",
|
|
516
|
+
"label": "Complete Scrollback",
|
|
517
|
+
"description": "Send transcript rows pushed off screen by a growing HUD or dialog straight to scrollback; when it shrinks, blank rows sit between the transcript and the input until new output fills them"
|
|
518
|
+
}
|
|
519
|
+
},
|
|
510
520
|
"terminal.showProgress": {
|
|
511
521
|
"type": "boolean",
|
|
512
522
|
"default": false,
|
|
@@ -3865,6 +3875,39 @@
|
|
|
3865
3875
|
"condition": "advisorEnabled"
|
|
3866
3876
|
}
|
|
3867
3877
|
},
|
|
3878
|
+
"advisor.continueSeverity": {
|
|
3879
|
+
"type": "enum",
|
|
3880
|
+
"default": "concern",
|
|
3881
|
+
"values": [
|
|
3882
|
+
"nit",
|
|
3883
|
+
"concern",
|
|
3884
|
+
"blocker"
|
|
3885
|
+
],
|
|
3886
|
+
"ui": {
|
|
3887
|
+
"tab": "model",
|
|
3888
|
+
"group": "Advisor",
|
|
3889
|
+
"label": "Advisor Continue Severity",
|
|
3890
|
+
"description": "Minimum severity of an advisor note that wakes the agent after it finished with a final answer. Lower-severity notes stay as a card at the end of the transcript and reach the agent with your next message.",
|
|
3891
|
+
"options": [
|
|
3892
|
+
{
|
|
3893
|
+
"value": "nit",
|
|
3894
|
+
"label": "Nit",
|
|
3895
|
+
"description": "Every note continues the run."
|
|
3896
|
+
},
|
|
3897
|
+
{
|
|
3898
|
+
"value": "concern",
|
|
3899
|
+
"label": "Concern",
|
|
3900
|
+
"description": "Concerns and blockers continue the run. Default."
|
|
3901
|
+
},
|
|
3902
|
+
{
|
|
3903
|
+
"value": "blocker",
|
|
3904
|
+
"label": "Blocker",
|
|
3905
|
+
"description": "Only blockers continue the run."
|
|
3906
|
+
}
|
|
3907
|
+
],
|
|
3908
|
+
"condition": "advisorEnabled"
|
|
3909
|
+
}
|
|
3910
|
+
},
|
|
3868
3911
|
"advisor.reviewMode": {
|
|
3869
3912
|
"type": "enum",
|
|
3870
3913
|
"default": "turn",
|
|
@@ -4062,7 +4105,7 @@
|
|
|
4062
4105
|
{
|
|
4063
4106
|
"value": "remote",
|
|
4064
4107
|
"label": "Server compaction",
|
|
4065
|
-
"description": "Use provider-native server compaction (OpenAI Responses compact, Anthropic compaction beta) when the active route supports it"
|
|
4108
|
+
"description": "Use provider-native server compaction (OpenAI Responses compact, Anthropic compaction beta, including on Bedrock) when the active route supports it"
|
|
4066
4109
|
},
|
|
4067
4110
|
{
|
|
4068
4111
|
"value": "snapcompact",
|
|
@@ -4312,7 +4355,7 @@
|
|
|
4312
4355
|
"tab": "context",
|
|
4313
4356
|
"group": "Compaction",
|
|
4314
4357
|
"label": "Idle Compaction Delay",
|
|
4315
|
-
"description": "Seconds to wait while idle before compacting",
|
|
4358
|
+
"description": "Seconds to wait while idle before compacting; with a 1-hour prompt cache, compaction instead runs just before the cache expires",
|
|
4316
4359
|
"options": [
|
|
4317
4360
|
{
|
|
4318
4361
|
"value": "60",
|
|
@@ -5811,6 +5854,7 @@
|
|
|
5811
5854
|
"default": "yolo",
|
|
5812
5855
|
"values": [
|
|
5813
5856
|
"always-ask",
|
|
5857
|
+
"always-ask-cached",
|
|
5814
5858
|
"write",
|
|
5815
5859
|
"yolo"
|
|
5816
5860
|
],
|
|
@@ -5818,13 +5862,18 @@
|
|
|
5818
5862
|
"tab": "interaction",
|
|
5819
5863
|
"group": "Approvals",
|
|
5820
5864
|
"label": "Tool Approval",
|
|
5821
|
-
"description": "Default approval behavior for tool calls. 'Always ask' auto-approves read-only tools only. 'Write' auto-approves read and workspace-write tools. 'Yolo' auto-approves all tiers; user policy may still prompt or block.",
|
|
5865
|
+
"description": "Default approval behavior for tool calls. 'Always ask' auto-approves read-only tools only. 'Ask and remember' also asks before file reads, and can remember a file or folder. 'Write' auto-approves read and workspace-write tools. 'Yolo' auto-approves all tiers; user policy may still prompt or block.",
|
|
5822
5866
|
"options": [
|
|
5823
5867
|
{
|
|
5824
5868
|
"value": "always-ask",
|
|
5825
5869
|
"label": "Always ask",
|
|
5826
5870
|
"description": "Auto-approve read-only tools; require confirmation for write and exec tools."
|
|
5827
5871
|
},
|
|
5872
|
+
{
|
|
5873
|
+
"value": "always-ask-cached",
|
|
5874
|
+
"label": "Ask and remember",
|
|
5875
|
+
"description": "Ask before reading or writing a file, with answers that remember the file or its folder for this session or every session; require confirmation for exec tools."
|
|
5876
|
+
},
|
|
5828
5877
|
{
|
|
5829
5878
|
"value": "write",
|
|
5830
5879
|
"label": "Write",
|
|
@@ -6199,6 +6248,32 @@
|
|
|
6199
6248
|
"description": "Maximum screenshot height in pixels"
|
|
6200
6249
|
}
|
|
6201
6250
|
},
|
|
6251
|
+
"computer.backgroundFallback": {
|
|
6252
|
+
"type": "enum",
|
|
6253
|
+
"default": "takeover",
|
|
6254
|
+
"values": [
|
|
6255
|
+
"takeover",
|
|
6256
|
+
"refuse"
|
|
6257
|
+
],
|
|
6258
|
+
"ui": {
|
|
6259
|
+
"tab": "tools",
|
|
6260
|
+
"group": "Computer",
|
|
6261
|
+
"label": "Computer Background Fallback",
|
|
6262
|
+
"description": "What window input does when its background route is unavailable: rerun it in takeover and return focus to your app (macOS; elsewhere plain takeover), or refuse, which also keeps the agent from taking the foreground or opening macOS menus itself",
|
|
6263
|
+
"options": [
|
|
6264
|
+
{
|
|
6265
|
+
"value": "takeover",
|
|
6266
|
+
"label": "Takeover",
|
|
6267
|
+
"description": "Briefly bring the target forward for that one call, then return focus to your app (on macOS even if you kept typing)"
|
|
6268
|
+
},
|
|
6269
|
+
{
|
|
6270
|
+
"value": "refuse",
|
|
6271
|
+
"label": "Refuse",
|
|
6272
|
+
"description": "Fail the call; nothing takes over, and the agent's own takeover, raise, activating open, desktop input and macOS menus (context menus, popup and menu buttons; they take your keyboard while open) are refused too"
|
|
6273
|
+
}
|
|
6274
|
+
]
|
|
6275
|
+
}
|
|
6276
|
+
},
|
|
6202
6277
|
"images.questionTimeoutMs": {
|
|
6203
6278
|
"type": "number",
|
|
6204
6279
|
"default": 300000,
|
|
@@ -6435,6 +6510,16 @@
|
|
|
6435
6510
|
"description": "Enable async bash commands and background task execution"
|
|
6436
6511
|
}
|
|
6437
6512
|
},
|
|
6513
|
+
"async.widgets": {
|
|
6514
|
+
"type": "boolean",
|
|
6515
|
+
"default": true,
|
|
6516
|
+
"ui": {
|
|
6517
|
+
"tab": "tools",
|
|
6518
|
+
"group": "Execution",
|
|
6519
|
+
"label": "Live Job Widgets",
|
|
6520
|
+
"description": "Draw a live widget for each running background job (charts, bars, status grids derived from its output) in the wait card, the jobs pill and the jobs sheet. The `widget` model role (default: `smol`) writes one spec per job; updates need no further model calls"
|
|
6521
|
+
}
|
|
6522
|
+
},
|
|
6438
6523
|
"tools.xdev": {
|
|
6439
6524
|
"type": "boolean",
|
|
6440
6525
|
"default": true,
|
|
@@ -6,21 +6,22 @@
|
|
|
6
6
|
import { describe, expect, test } from 'bun:test';
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
androidDownloadLabel,
|
|
9
10
|
parseAndroidRelease,
|
|
10
11
|
shouldOfferAndroidDownload,
|
|
11
12
|
type AndroidDownload,
|
|
12
13
|
} from '@/client/hooks/ui/updates/android-download';
|
|
13
14
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
* ways that promise can be wrong, and each must render nothing.
|
|
17
|
-
*/
|
|
18
|
-
const RELEASE: AndroidDownload = {
|
|
15
|
+
/** The published release every case compares against. */
|
|
16
|
+
const RELEASE = {
|
|
19
17
|
url: 'https://github.com/kabmo-labs/kabmo/releases/download/v1.2.3/kabmo-android-v1.2.3.apk',
|
|
20
18
|
latest: '1.2.3',
|
|
21
19
|
releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v1.2.3',
|
|
22
20
|
};
|
|
23
21
|
|
|
22
|
+
const UPDATE: AndroidDownload = { ...RELEASE, kind: 'update' };
|
|
23
|
+
const INSTALL: AndroidDownload = { ...RELEASE, kind: 'install' };
|
|
24
|
+
|
|
24
25
|
describe('parseAndroidRelease', () => {
|
|
25
26
|
test('reads a complete payload', () => {
|
|
26
27
|
expect(parseAndroidRelease(RELEASE)).toEqual(RELEASE);
|
|
@@ -44,30 +45,68 @@ describe('parseAndroidRelease', () => {
|
|
|
44
45
|
});
|
|
45
46
|
});
|
|
46
47
|
|
|
47
|
-
describe('
|
|
48
|
-
test('offers a newer
|
|
49
|
-
expect(shouldOfferAndroidDownload('1.2.2', RELEASE)).toEqual(
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test('offers nothing to a browser', () => {
|
|
53
|
-
// No shell version means the page is not the APK; a browser has no app to
|
|
54
|
-
// update, and the app's own update badge already covers the server.
|
|
55
|
-
expect(shouldOfferAndroidDownload(null, RELEASE)).toBeNull();
|
|
56
|
-
expect(shouldOfferAndroidDownload(undefined, RELEASE)).toBeNull();
|
|
57
|
-
expect(shouldOfferAndroidDownload('', RELEASE)).toBeNull();
|
|
48
|
+
describe('the offer inside the installed app', () => {
|
|
49
|
+
test('offers an UPDATE when a newer APK is published', () => {
|
|
50
|
+
expect(shouldOfferAndroidDownload('1.2.2', true, RELEASE)).toEqual(UPDATE);
|
|
58
51
|
});
|
|
59
52
|
|
|
60
53
|
test('offers nothing when the app is already current', () => {
|
|
61
|
-
expect(shouldOfferAndroidDownload('1.2.3', RELEASE)).toBeNull();
|
|
54
|
+
expect(shouldOfferAndroidDownload('1.2.3', true, RELEASE)).toBeNull();
|
|
62
55
|
});
|
|
63
56
|
|
|
64
57
|
test('offers nothing when the app is ahead', () => {
|
|
65
58
|
// A sideloaded build newer than the published asset: downloading it would be
|
|
66
59
|
// a downgrade.
|
|
67
|
-
expect(shouldOfferAndroidDownload('1.3.0', RELEASE)).toBeNull();
|
|
60
|
+
expect(shouldOfferAndroidDownload('1.3.0', true, RELEASE)).toBeNull();
|
|
68
61
|
});
|
|
69
62
|
|
|
70
63
|
test('offers nothing when the probe failed', () => {
|
|
71
|
-
expect(shouldOfferAndroidDownload('1.0.0', null)).toBeNull();
|
|
64
|
+
expect(shouldOfferAndroidDownload('1.0.0', true, null)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('the offer to an Android browser', () => {
|
|
69
|
+
test('offers an INSTALL, because there is no installed version to compare', () => {
|
|
70
|
+
// The reported gap: a reader on Chrome or Brave for Android was told
|
|
71
|
+
// nothing, and the shell is exactly what they do not know exists.
|
|
72
|
+
expect(shouldOfferAndroidDownload(null, true, RELEASE)).toEqual(INSTALL);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('offers an install even when the published APK is older than some build', () => {
|
|
76
|
+
// Nothing is installed here, so "newer than" has no left-hand side — the
|
|
77
|
+
// published asset is simply what the button names.
|
|
78
|
+
expect(shouldOfferAndroidDownload(undefined, true, { ...RELEASE, latest: '0.0.1' })).toEqual({
|
|
79
|
+
...INSTALL,
|
|
80
|
+
latest: '0.0.1',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('offers nothing when no APK is published', () => {
|
|
85
|
+
expect(shouldOfferAndroidDownload(null, true, null)).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('everywhere else', () => {
|
|
90
|
+
test('a desktop browser is offered nothing', () => {
|
|
91
|
+
// An APK cannot be installed there, and the app's own update badge already
|
|
92
|
+
// covers the server.
|
|
93
|
+
expect(shouldOfferAndroidDownload(null, false, RELEASE)).toBeNull();
|
|
94
|
+
expect(shouldOfferAndroidDownload(null, null, RELEASE)).toBeNull();
|
|
95
|
+
expect(shouldOfferAndroidDownload(null, undefined, RELEASE)).toBeNull();
|
|
96
|
+
expect(shouldOfferAndroidDownload('', false, RELEASE)).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('the shell token alone is enough, even if the wider flag is missing', () => {
|
|
100
|
+
// `isAndroid` is derived from the same UA, so a stale bootstrap that predates
|
|
101
|
+
// the field must not silently drop the update offer.
|
|
102
|
+
expect(shouldOfferAndroidDownload('1.2.2', false, RELEASE)).toEqual(UPDATE);
|
|
103
|
+
expect(shouldOfferAndroidDownload('1.2.2', undefined, RELEASE)).toEqual(UPDATE);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('androidDownloadLabel', () => {
|
|
108
|
+
test('names the verb the reader needs', () => {
|
|
109
|
+
expect(androidDownloadLabel(UPDATE)).toBe('Update Kabmo 1.2.3 for Android');
|
|
110
|
+
expect(androidDownloadLabel(INSTALL)).toBe('Install Kabmo 1.2.3 for Android');
|
|
72
111
|
});
|
|
73
112
|
});
|
|
@@ -1,31 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* What the sidebar should offer for the Android shell, and why.
|
|
3
3
|
*
|
|
4
|
-
* Two
|
|
4
|
+
* Two DIFFERENT offers, from two different facts:
|
|
5
5
|
*
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* that downloads the build already installed.
|
|
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.
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import { useEffect, useState } from 'preact/hooks';
|
|
20
22
|
import { isNewer } from '@/shared/lib/updates/semver';
|
|
21
23
|
|
|
24
|
+
/** Which offer this is — the two carry different wording. */
|
|
25
|
+
export type AndroidOfferKind = 'update' | 'install';
|
|
26
|
+
|
|
22
27
|
export interface AndroidDownload {
|
|
23
|
-
/** The asset to download
|
|
28
|
+
/** The asset to download. */
|
|
24
29
|
url: string;
|
|
25
30
|
/** The version that asset ships. */
|
|
26
31
|
latest: string;
|
|
27
32
|
/** The release page, for a "what changed" link. */
|
|
28
33
|
releaseUrl: string | null;
|
|
34
|
+
/** An update to an installed APK, or a first install from a browser. */
|
|
35
|
+
kind: AndroidOfferKind;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
interface ReleasePayload {
|
|
@@ -41,7 +48,7 @@ interface ReleasePayload {
|
|
|
41
48
|
* probe, so a shape check that only looks at the version would offer a button
|
|
42
49
|
* with no link.
|
|
43
50
|
*/
|
|
44
|
-
export function parseAndroidRelease(payload: unknown): AndroidDownload | null {
|
|
51
|
+
export function parseAndroidRelease(payload: unknown): Omit<AndroidDownload, 'kind'> | null {
|
|
45
52
|
if (typeof payload !== 'object' || payload === null) return null;
|
|
46
53
|
const body = payload as ReleasePayload;
|
|
47
54
|
if (typeof body.url !== 'string' || !body.url) return null;
|
|
@@ -54,23 +61,51 @@ export function parseAndroidRelease(payload: unknown): AndroidDownload | null {
|
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
/**
|
|
57
|
-
* The
|
|
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.
|
|
58
70
|
*
|
|
59
|
-
* `
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* page.
|
|
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.
|
|
63
74
|
*/
|
|
64
75
|
export function shouldOfferAndroidDownload(
|
|
65
76
|
shellVersion: string | null | undefined,
|
|
66
|
-
|
|
77
|
+
isAndroid: boolean | null | undefined,
|
|
78
|
+
release: Omit<AndroidDownload, 'kind'> | null,
|
|
67
79
|
): AndroidDownload | null {
|
|
68
|
-
if (!
|
|
69
|
-
|
|
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`;
|
|
70
105
|
}
|
|
71
106
|
|
|
72
107
|
/**
|
|
73
|
-
* Fetch the published APK and
|
|
108
|
+
* Fetch the published APK and decide what to offer.
|
|
74
109
|
*
|
|
75
110
|
* `fetchImpl` is a seam for the tests: the cases that matter (no release, a
|
|
76
111
|
* failed probe, a version equal to the installed one) cannot be produced by a
|
|
@@ -78,12 +113,14 @@ export function shouldOfferAndroidDownload(
|
|
|
78
113
|
*/
|
|
79
114
|
export function useAndroidDownload(
|
|
80
115
|
shellVersion: string | null | undefined,
|
|
116
|
+
isAndroid: boolean | null | undefined,
|
|
81
117
|
fetchImpl: typeof fetch = fetch,
|
|
82
118
|
): AndroidDownload | null {
|
|
83
|
-
const [release, setRelease] = useState<AndroidDownload | null>(null);
|
|
119
|
+
const [release, setRelease] = useState<Omit<AndroidDownload, 'kind'> | null>(null);
|
|
120
|
+
const wanted = Boolean(shellVersion) || Boolean(isAndroid);
|
|
84
121
|
|
|
85
122
|
useEffect(() => {
|
|
86
|
-
if (!
|
|
123
|
+
if (!wanted) return;
|
|
87
124
|
|
|
88
125
|
let cancelled = false;
|
|
89
126
|
void (async () => {
|
|
@@ -100,7 +137,7 @@ export function useAndroidDownload(
|
|
|
100
137
|
return () => {
|
|
101
138
|
cancelled = true;
|
|
102
139
|
};
|
|
103
|
-
}, [
|
|
140
|
+
}, [wanted, fetchImpl]);
|
|
104
141
|
|
|
105
|
-
return shouldOfferAndroidDownload(shellVersion, release);
|
|
142
|
+
return shouldOfferAndroidDownload(shellVersion, isAndroid, release);
|
|
106
143
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* environment condition, not a bug, so the suite is skipped rather than failed.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
|
20
20
|
import fs from 'node:fs';
|
|
21
21
|
import { tmpdir } from 'node:os';
|
|
22
22
|
import { join } from 'node:path';
|
|
@@ -29,6 +29,23 @@ import { removeDir } from '@/test-support/remove-dir';
|
|
|
29
29
|
|
|
30
30
|
const hasGit = Boolean(Bun.which('git'));
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The suite drives REAL git processes — `init`, `add`, `commit`, `ls-remote`,
|
|
34
|
+
* `fetch`, `reset`, and a plugin build — and one test of it pays for most of
|
|
35
|
+
* them. Bun's default per-test budget is 5s, which a Linux runner clears in
|
|
36
|
+
* ~1.2s and a Windows runner does not: measured on `windows-latest`, the update
|
|
37
|
+
* test timed out at 5072ms and failed `expect(result.ok).toBe(true)` because the
|
|
38
|
+
* timeout fired mid-await (the run reported 1 fail + 1 unhandled error, and the
|
|
39
|
+
* suite is green locally). Process spawn on that runner costs several times what
|
|
40
|
+
* it does on POSIX, and this is the only suite here that spawns this many.
|
|
41
|
+
*
|
|
42
|
+
* A budget, not a skip: the behaviour under test — a shallow clone advancing past
|
|
43
|
+
* its boundary — is identical on both platforms, and skipping it there would
|
|
44
|
+
* report the update path as unverified on the platform the CLI ships to.
|
|
45
|
+
*/
|
|
46
|
+
const GIT_TEST_TIMEOUT_MS = 30_000;
|
|
47
|
+
setDefaultTimeout(GIT_TEST_TIMEOUT_MS);
|
|
48
|
+
|
|
32
49
|
let root = '';
|
|
33
50
|
let work = '';
|
|
34
51
|
|