@kabmo-labs/kabmo 0.7.0 → 0.9.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/README.md +7 -0
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +403 -384
- package/dist/client/static/js/{index-0wvbca0b.js → index-bpptfkz2.js} +905 -875
- package/dist/client/static/js/index-k315p1e8.css +1 -0
- package/package.json +10 -1
- package/src/client/components/common/code-surface/documents.test.ts +120 -0
- package/src/client/components/common/code-surface/documents.ts +201 -0
- package/src/client/components/common/code-surface/extensions.test.ts +110 -0
- package/src/client/components/common/code-surface/extensions.ts +162 -0
- package/src/client/components/common/code-surface/handle.ts +102 -0
- package/src/client/components/common/code-surface/index.tsx +253 -314
- package/src/client/components/common/code-surface/keymap.test.ts +133 -0
- package/src/client/components/common/code-surface/keymap.ts +267 -0
- package/src/client/components/common/code-surface/marks.ts +74 -0
- package/src/client/components/common/code-surface/shiki.ts +234 -0
- package/src/client/components/common/code-surface/sync.test.ts +185 -0
- package/src/client/components/common/code-surface/sync.ts +58 -0
- package/src/client/components/common/code-surface/theme.test.ts +202 -0
- package/src/client/components/common/code-surface/theme.ts +111 -0
- package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
- package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
- package/src/client/components/common/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
- package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
- package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
- package/src/client/components/common/shell-update-button/index.tsx +125 -0
- package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
- package/src/client/components/common/toolbar-row/index.tsx +189 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
- package/src/client/components/layout/desktop-layout/index.tsx +55 -69
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +23 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- package/src/client/components/settings/Modal.tsx +8 -0
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +146 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +36 -55
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
- package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +32 -17
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
- package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
- package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
- package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
- package/src/client/components/workspace/diff-panel/index.tsx +76 -145
- package/src/client/components/workspace/diff-panel/merge.ts +315 -0
- package/src/client/components/workspace/editor/Body.tsx +4 -7
- package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
- package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
- package/src/client/components/workspace/editor/FilePane.tsx +4 -7
- package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
- package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
- package/src/client/components/workspace/editor/index.tsx +7 -9
- package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
- package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
- package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- package/src/client/components/workspace/search-panel/Body.tsx +1 -1
- package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
- package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
- package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
- package/src/client/hooks/chat/omp/reconnect.ts +187 -0
- package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
- package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
- package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
- package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
- package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
- package/src/client/hooks/chat/omp/session-list.tsx +69 -9
- package/src/client/hooks/chat/timeline/generating.ts +19 -2
- package/src/client/hooks/chat/timeline/index.ts +27 -28
- package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
- package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
- package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
- package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
- package/src/client/hooks/chat/timeline/session-load.ts +69 -37
- package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
- package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
- package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
- package/src/client/hooks/code/deferred-highlight.ts +129 -0
- package/src/client/hooks/editor/find-state.ts +1 -1
- package/src/client/hooks/editor/theme.test.ts +206 -0
- package/src/client/hooks/editor/theme.ts +82 -0
- package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
- package/src/client/hooks/editor/use-editor-drop.ts +67 -14
- package/src/client/hooks/editor/use-editor-find.ts +7 -7
- package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
- package/src/client/hooks/editor/use-outline-view.ts +94 -7
- package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
- package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
- package/src/client/hooks/editor/use-search-reveal.ts +1 -1
- package/src/client/hooks/ui/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/settings-modal.ts +56 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +75 -0
- package/src/client/hooks/ui/updates/shell-download.ts +153 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +213 -0
- package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
- package/src/client/hooks/ui/updates/shell-update.ts +119 -0
- package/src/client/hooks/workspace/diff-file.ts +29 -10
- package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
- package/src/client/hooks/workspace/editor-view.ts +61 -0
- package/src/client/hooks/workspace/file-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
- package/src/client/hooks/workspace/worktree/index.ts +165 -0
- package/src/client/lib/desktop/bridge.ts +45 -0
- package/src/client/lib/search-reveal.ts +1 -1
- package/src/client/main.tsx +5 -1
- package/src/client/tailwind.css +56 -8
- package/src/server/index.ts +7 -2
- package/src/server/lib/auth/guard.test.ts +28 -0
- package/src/server/lib/auth/guard.ts +10 -0
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +262 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +142 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
- package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
- package/src/server/lib/lifecycle/port-guard.ts +39 -7
- package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
- package/src/server/lib/lifecycle/probe.test.ts +77 -164
- package/src/server/lib/lifecycle/probe.ts +121 -13
- package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
- package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
- package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
- package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
- package/src/server/lib/omp/session/telemetry.test.ts +33 -0
- package/src/server/lib/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- package/src/server/lib/realtime/signals.server.ts +10 -0
- package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
- package/src/server/lib/realtime/topics.server.ts +16 -0
- package/src/server/lib/updates/shell-assets.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +200 -0
- package/src/server/lib/updates/shell-cache.ts +105 -0
- package/src/server/lib/updates/shell-pages.test.ts +187 -0
- package/src/server/lib/updates/shell-pages.ts +157 -0
- package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
- package/src/server/lib/updates/shell-release.test.ts +59 -71
- package/src/server/lib/updates/shell-release.ts +178 -198
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- package/src/server/routes/fs/git-merge.test.ts +11 -1
- package/src/server/routes/fs/read.test.ts +24 -7
- package/src/server/routes/fs/scope.test.ts +15 -10
- package/src/server/routes/fs/scope.ts +3 -14
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/updates/android.ts +6 -1
- package/src/server/routes/updates/desktop.ts +59 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +5 -1
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +134 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +326 -0
- package/src/server/routes/worktrees/shared.ts +90 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
- package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
- package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
- package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
- package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
- package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
- package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
- package/src/shared/lib/code/editor/find-scan.ts +177 -0
- package/src/shared/lib/code/editor/keymap.ts +3 -3
- package/src/shared/lib/code/editor/lines.test.ts +7 -55
- package/src/shared/lib/code/editor/lines.ts +8 -48
- package/src/shared/lib/code/editor/marks.test.ts +4 -5
- package/src/shared/lib/code/editor/marks.ts +13 -25
- package/src/shared/lib/code/editor/theme-loader.ts +97 -0
- package/src/shared/lib/code/editor/themes.generated.ts +156 -0
- package/src/shared/lib/code/editor/themes.test.ts +165 -0
- package/src/shared/lib/code/editor/themes.ts +165 -0
- package/src/shared/lib/code/highlight-memo.test.ts +103 -0
- package/src/shared/lib/code/highlight-memo.ts +149 -0
- package/src/shared/lib/code/highlighter-warmup.ts +112 -0
- package/src/shared/lib/code/highlighter.ts +3 -1
- package/src/shared/lib/code/idle.ts +37 -0
- package/src/shared/lib/code/outline/rows.test.ts +96 -0
- package/src/shared/lib/code/outline/rows.ts +94 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
- package/src/shared/lib/code/syntax-highlight.ts +2 -3
- package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
- package/src/shared/lib/fs/file-kind.ts +47 -13
- package/src/shared/lib/fs/file-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- package/src/shared/lib/fs/file-url.test.ts +52 -0
- package/src/shared/lib/fs/file-url.ts +37 -0
- package/src/shared/lib/fs/patch-sides.test.ts +114 -0
- package/src/shared/lib/fs/patch-sides.ts +139 -0
- package/src/shared/lib/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -0
- package/src/shared/lib/realtime/protocol.ts +16 -0
- package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
- package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
- package/src/shared/lib/updates/shell-arch.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
- package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
- package/src/shared/lib/workspace/panel-widths.ts +25 -5
- package/src/shared/lib/workspace/session-state/cache.ts +159 -0
- package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
- package/src/shared/lib/workspace/session-state/merge.ts +100 -0
- package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
- package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
- package/src/shared/lib/workspace/session-state/store.ts +92 -130
- package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
- package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/realtime.ts +29 -0
- package/src/shared/types/settings/state.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- package/src/test-support/loopback.ts +183 -0
- package/dist/client/static/js/index-nzyjf92m.css +0 -1
- package/src/client/components/common/code-editor/handler.test.ts +0 -274
- package/src/client/components/common/code-editor/handler.ts +0 -175
- package/src/client/components/common/code-editor/index.tsx +0 -314
- package/src/client/components/common/code-surface/measure.ts +0 -148
- package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
- package/src/client/hooks/editor/scroll-container.test.ts +0 -102
- package/src/client/hooks/editor/scroll-container.ts +0 -26
- package/src/client/hooks/editor/use-code-window.ts +0 -100
- package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
- package/src/client/hooks/editor/use-editor-history.ts +0 -134
- package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
- package/src/client/hooks/editor/use-occurrences.ts +0 -98
- package/src/shared/lib/code/editor/commands.test.ts +0 -155
- package/src/shared/lib/code/editor/commands.ts +0 -260
- package/src/shared/lib/code/editor/edits.test.ts +0 -100
- package/src/shared/lib/code/editor/edits.ts +0 -122
- package/src/shared/lib/code/editor/history.test.ts +0 -118
- package/src/shared/lib/code/editor/history.ts +0 -101
- package/src/shared/lib/code/editor/layers.test.ts +0 -77
- package/src/shared/lib/code/editor/layers.ts +0 -67
- package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
- package/src/shared/lib/code/editor/line-edits.ts +0 -159
- package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
- package/src/shared/lib/code/editor/occurrences.ts +0 -111
- package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
- package/src/shared/lib/code/editor/reset-css.ts +0 -32
- package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
- package/src/shared/lib/code/editor/text-commands.ts +0 -154
- package/src/shared/lib/code/lazy-window.test.ts +0 -161
- package/src/shared/lib/code/lazy-window.ts +0 -156
- package/src/shared/lib/code/windowed-highlight.ts +0 -114
- package/src/shared/lib/fs/diff-parser.test.ts +0 -116
- package/src/shared/lib/fs/diff-parser.ts +0 -213
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What the
|
|
2
|
+
* What the console should offer for a native shell, and why.
|
|
3
3
|
*
|
|
4
4
|
* TWO DIFFERENT OFFERS, from two different facts:
|
|
5
5
|
*
|
|
6
|
-
* - **`update`** — this page is running inside the shell
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* no button.
|
|
6
|
+
* - **`update`** — this page is running inside the shell and a newer build is
|
|
7
|
+
* published. An up-to-date shell offers nothing: a button that downloads the
|
|
8
|
+
* build already installed is worse than no button.
|
|
10
9
|
* - **`install`** — this page is an Android BROWSER (Chrome, Brave, Firefox) and
|
|
11
10
|
* an APK is published. The reader may not know a native shell exists, and
|
|
12
11
|
* telling them is the whole point of the offer; there is no installed version
|
|
@@ -16,18 +15,26 @@
|
|
|
16
15
|
* an APK can — iOS has no sideload-by-URL path — so a browser on an iPhone is
|
|
17
16
|
* offered nothing, and only an installed iOS shell ever sees this control.
|
|
18
17
|
*
|
|
19
|
-
*
|
|
20
|
-
* app
|
|
18
|
+
* There is NO desktop BROWSER offer either, but there IS a desktop SHELL one: a
|
|
19
|
+
* desktop app is installed by hand from the release page, so nothing in a tab
|
|
20
|
+
* should advertise it — while an installed app that is behind has exactly the
|
|
21
|
+
* same problem the mobile pair had, and used to have no way to learn it.
|
|
21
22
|
*
|
|
22
|
-
* The probe is lazy: it runs only when
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* The probe is lazy: it runs only when an installed shell (a bootstrap token, or
|
|
24
|
+
* a bridge identity) or an Android device is present, so a console that can
|
|
25
|
+
* never show the button makes no request for it. ONE hook for every shell,
|
|
26
|
+
* because the decision table is identical and only the artifact, the endpoint
|
|
27
|
+
* and the platform word differ.
|
|
26
28
|
*/
|
|
27
29
|
|
|
28
30
|
import { useEffect, useState } from 'preact/hooks';
|
|
29
31
|
import { isNewer } from '@/shared/lib/updates/semver';
|
|
30
|
-
import type
|
|
32
|
+
import { parseShellArch, type ShellArch } from '@/shared/lib/updates/shell-arch';
|
|
33
|
+
import {
|
|
34
|
+
isDesktopShellKind,
|
|
35
|
+
type DesktopShellKind,
|
|
36
|
+
type ShellKind,
|
|
37
|
+
} from '@/shared/lib/updates/shell-ua';
|
|
31
38
|
|
|
32
39
|
/** Which shell the offer is for. */
|
|
33
40
|
export type ShellPlatform = ShellKind;
|
|
@@ -36,7 +43,15 @@ export type ShellPlatform = ShellKind;
|
|
|
36
43
|
export type ShellOfferKind = 'update' | 'install';
|
|
37
44
|
|
|
38
45
|
export interface ShellDownload {
|
|
39
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* What the control points at.
|
|
48
|
+
*
|
|
49
|
+
* The artifact itself for the mobile pair (an APK the shell installs, an IPA
|
|
50
|
+
* the release page explains), and for a desktop shell EITHER its own
|
|
51
|
+
* per-architecture artifact (when it reported `arch`) or the RELEASE PAGE (when
|
|
52
|
+
* it did not). `direct` says which, and that is what decides between a button
|
|
53
|
+
* and a link.
|
|
54
|
+
*/
|
|
40
55
|
url: string;
|
|
41
56
|
/** The version that artifact ships. */
|
|
42
57
|
latest: string;
|
|
@@ -46,18 +61,31 @@ export interface ShellDownload {
|
|
|
46
61
|
kind: ShellOfferKind;
|
|
47
62
|
/** Which shell this offer is for. */
|
|
48
63
|
platform: ShellPlatform;
|
|
64
|
+
/**
|
|
65
|
+
* True when `url` is the artifact itself, so the SHELL can install it.
|
|
66
|
+
*
|
|
67
|
+
* False means `url` is the release page: the console cannot name one file
|
|
68
|
+
* (a desktop shell that did not report its architecture), so the offer is a
|
|
69
|
+
* link a human follows. That distinction is what decides between an `<a>` and
|
|
70
|
+
* a button that drives `update_apply`.
|
|
71
|
+
*/
|
|
72
|
+
direct: boolean;
|
|
49
73
|
}
|
|
50
74
|
|
|
51
75
|
interface ReleasePayload {
|
|
52
76
|
url?: unknown;
|
|
53
77
|
latest?: unknown;
|
|
54
78
|
releaseUrl?: unknown;
|
|
79
|
+
direct?: unknown;
|
|
55
80
|
}
|
|
56
81
|
|
|
57
82
|
/** The platform's own name, as the label says it. */
|
|
58
83
|
const PLATFORM_NAMES: Record<ShellPlatform, string> = {
|
|
59
84
|
android: 'Android',
|
|
60
85
|
ios: 'iOS',
|
|
86
|
+
macos: 'macOS',
|
|
87
|
+
windows: 'Windows',
|
|
88
|
+
linux: 'Linux',
|
|
61
89
|
};
|
|
62
90
|
|
|
63
91
|
/**
|
|
@@ -76,6 +104,10 @@ export function parseShellRelease(payload: unknown): Omit<ShellDownload, 'kind'
|
|
|
76
104
|
url: body.url,
|
|
77
105
|
latest: body.latest,
|
|
78
106
|
releaseUrl: typeof body.releaseUrl === 'string' ? body.releaseUrl : null,
|
|
107
|
+
// Absent means "not installable": a shell that cannot install the bytes must
|
|
108
|
+
// fall back to the link rather than to a button that would download into
|
|
109
|
+
// nowhere.
|
|
110
|
+
direct: body.direct === true,
|
|
79
111
|
};
|
|
80
112
|
}
|
|
81
113
|
|
|
@@ -126,8 +158,20 @@ export function shellDownloadLabel(download: ShellDownload): string {
|
|
|
126
158
|
return `${verb} Kabmo ${download.latest} for ${PLATFORM_NAMES[download.platform]}`;
|
|
127
159
|
}
|
|
128
160
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
161
|
+
/**
|
|
162
|
+
* The endpoint that answers for one shell.
|
|
163
|
+
*
|
|
164
|
+
* The desktop trio shares ONE route with an explicit `kind`, so no absence can
|
|
165
|
+
* silently mean a platform — the reason the mobile pair has two.
|
|
166
|
+
*/
|
|
167
|
+
export function shellReleasePath(platform: ShellPlatform, arch: ShellArch | null = null): string {
|
|
168
|
+
if (isDesktopShellKind(platform)) {
|
|
169
|
+
// The arch is what makes the answer a FILE rather than a page. A shell that
|
|
170
|
+
// did not report one still asks, and still gets the page that lists every
|
|
171
|
+
// architecture — the offer is then a link, which is the honest fallback.
|
|
172
|
+
const base = `/api/updates/desktop?kind=${platform}`;
|
|
173
|
+
return arch ? `${base}&arch=${arch}` : base;
|
|
174
|
+
}
|
|
131
175
|
return `/api/updates/${platform}`;
|
|
132
176
|
}
|
|
133
177
|
|
|
@@ -146,6 +190,34 @@ export function shellFromBootstrap(bootstrap: {
|
|
|
146
190
|
return null;
|
|
147
191
|
}
|
|
148
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Which DESKTOP shell this page is running inside, from the bridge identity.
|
|
195
|
+
*
|
|
196
|
+
* The desktop trio reports itself over `window.__kabmoDesktop` rather than the
|
|
197
|
+
* User-Agent, because a desktop app is not downloaded from the console and the
|
|
198
|
+
* server has nothing to parse per request. The identity is injected at document
|
|
199
|
+
* start, before the page's own scripts, so this answers on the FIRST paint.
|
|
200
|
+
*
|
|
201
|
+
* An identity whose `platform` is not one of the three is not a desktop shell
|
|
202
|
+
* this build knows how to update — a newer shell reporting a fourth platform
|
|
203
|
+
* must not be compared against an artifact nobody publishes.
|
|
204
|
+
*/
|
|
205
|
+
export function desktopShellFromIdentity(identity: {
|
|
206
|
+
platform?: string;
|
|
207
|
+
shellVersion?: string;
|
|
208
|
+
arch?: string;
|
|
209
|
+
} | null | undefined): { platform: DesktopShellKind; version: string; arch: ShellArch | null } | null {
|
|
210
|
+
if (!identity?.platform || !identity.shellVersion) return null;
|
|
211
|
+
if (!isDesktopShellKind(identity.platform)) return null;
|
|
212
|
+
return {
|
|
213
|
+
platform: identity.platform,
|
|
214
|
+
version: identity.shellVersion,
|
|
215
|
+
// A shell too old to report one keeps working: the offer degrades to the
|
|
216
|
+
// release page instead of disappearing.
|
|
217
|
+
arch: parseShellArch(identity.arch),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
149
221
|
/**
|
|
150
222
|
* Fetch the published artifact and decide what to offer.
|
|
151
223
|
*
|
|
@@ -158,9 +230,25 @@ export function useShellDownload(
|
|
|
158
230
|
version: string | null | undefined,
|
|
159
231
|
onAndroid: boolean | null | undefined,
|
|
160
232
|
fetchImpl: typeof fetch = fetch,
|
|
233
|
+
arch: ShellArch | null = null,
|
|
161
234
|
): ShellDownload | null {
|
|
162
|
-
|
|
235
|
+
/**
|
|
236
|
+
* The answer, TAGGED with the platform it was read for.
|
|
237
|
+
*
|
|
238
|
+
* A bare payload is not enough: this hook is called with a different platform
|
|
239
|
+
* when a page's shell changes (a desktop identity arriving, a spawn adoption),
|
|
240
|
+
* and an untagged answer would be compared against the new platform's version
|
|
241
|
+
* — offering an Android APK to a Mac for one render.
|
|
242
|
+
*/
|
|
243
|
+
const [answer, setAnswer] = useState<{
|
|
244
|
+
key: string;
|
|
245
|
+
release: Omit<ShellDownload, 'kind' | 'platform'> | null;
|
|
246
|
+
} | null>(null);
|
|
163
247
|
const wanted = Boolean(version) || (platform === 'android' && Boolean(onAndroid));
|
|
248
|
+
// The question this hook is asking, arch included: two windows on one console
|
|
249
|
+
// differ only by architecture, and an answer read for the other one is not an
|
|
250
|
+
// answer.
|
|
251
|
+
const key = `${platform}:${arch ?? ''}`;
|
|
164
252
|
|
|
165
253
|
useEffect(() => {
|
|
166
254
|
if (!wanted) return;
|
|
@@ -168,10 +256,10 @@ export function useShellDownload(
|
|
|
168
256
|
let cancelled = false;
|
|
169
257
|
void (async () => {
|
|
170
258
|
try {
|
|
171
|
-
const response = await fetchImpl(shellReleasePath(platform));
|
|
259
|
+
const response = await fetchImpl(shellReleasePath(platform, arch));
|
|
172
260
|
if (!response.ok) return;
|
|
173
261
|
const payload: unknown = await response.json();
|
|
174
|
-
if (!cancelled)
|
|
262
|
+
if (!cancelled) setAnswer({ key, release: parseShellRelease(payload) });
|
|
175
263
|
} catch {
|
|
176
264
|
// A probe that cannot answer is a button that must not appear.
|
|
177
265
|
}
|
|
@@ -180,29 +268,64 @@ export function useShellDownload(
|
|
|
180
268
|
return () => {
|
|
181
269
|
cancelled = true;
|
|
182
270
|
};
|
|
183
|
-
}, [wanted, platform, fetchImpl]);
|
|
271
|
+
}, [wanted, key, platform, arch, fetchImpl]);
|
|
184
272
|
|
|
273
|
+
// A stale answer belongs to the question it was read for, not this one.
|
|
274
|
+
const release = answer?.key === key ? answer.release : null;
|
|
185
275
|
return shouldOfferShellDownload(platform, version, onAndroid, release);
|
|
186
276
|
}
|
|
187
277
|
|
|
278
|
+
/** The bridge identity a desktop shell injects before the page's first script. */
|
|
279
|
+
export interface DesktopIdentity {
|
|
280
|
+
platform?: string;
|
|
281
|
+
shellVersion?: string;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function readDesktopIdentity(): DesktopIdentity | null {
|
|
285
|
+
if (typeof window === 'undefined') return null;
|
|
286
|
+
// `window.__kabmoDesktop` is declared by `lib/desktop/bridge` — the same
|
|
287
|
+
// object the shell injects, so this reads the shell's own contract rather
|
|
288
|
+
// than re-declaring its shape here.
|
|
289
|
+
return window.__kabmoDesktop ?? null;
|
|
290
|
+
}
|
|
291
|
+
|
|
188
292
|
/**
|
|
189
293
|
* The offer for whichever shell this page is running inside — or the Android
|
|
190
294
|
* install offer for a browser on that platform.
|
|
191
295
|
*
|
|
192
296
|
* The single entry point both sidebar layouts call, so the two cannot disagree
|
|
193
297
|
* about which shell they are in or which endpoint they read. It reads the
|
|
194
|
-
* bootstrap the SERVER wrote for this request
|
|
195
|
-
*
|
|
196
|
-
* carried the page.
|
|
298
|
+
* bootstrap the SERVER wrote for this request (and, for a desktop shell, the
|
|
299
|
+
* identity the shell injected) rather than sniffing the UA client-side, because
|
|
300
|
+
* the server already parsed it for the very response that carried the page.
|
|
301
|
+
*
|
|
302
|
+
* `desktop` is a parameter so the tests can drive the bridge identity without a
|
|
303
|
+
* real shell; production reads it from `window.__kabmoDesktop`.
|
|
197
304
|
*/
|
|
198
|
-
export function useShellOffer(
|
|
305
|
+
export function useShellOffer(
|
|
306
|
+
fetchImpl: typeof fetch = fetch,
|
|
307
|
+
desktop: DesktopIdentity | null = readDesktopIdentity(),
|
|
308
|
+
): ShellDownload | null {
|
|
199
309
|
const bootstrap = typeof window === 'undefined' ? undefined : window.__OMP_BOOTSTRAP__;
|
|
200
|
-
const
|
|
310
|
+
const mobile = shellFromBootstrap(bootstrap);
|
|
311
|
+
const fromBridge = desktopShellFromIdentity(desktop);
|
|
312
|
+
|
|
313
|
+
// The desktop identity is checked FIRST and is authoritative when present: a
|
|
314
|
+
// desktop shell never carries a mobile UA token, and a page that somehow had
|
|
315
|
+
// both is in the desktop app, whose version is the one being compared.
|
|
316
|
+
//
|
|
317
|
+
// ONE call, with the resolved platform — never two conditional calls. A hook
|
|
318
|
+
// behind an `if` changes the hook order between renders, which React/Preact
|
|
319
|
+
// treats as a different component.
|
|
320
|
+
const platform = fromBridge?.platform ?? mobile?.platform ?? 'android';
|
|
321
|
+
const version = fromBridge?.version ?? mobile?.version ?? null;
|
|
322
|
+
|
|
201
323
|
return useShellDownload(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
bootstrap?.isAndroid,
|
|
324
|
+
platform,
|
|
325
|
+
version,
|
|
326
|
+
fromBridge ? null : bootstrap?.isAndroid,
|
|
205
327
|
fetchImpl,
|
|
328
|
+
fromBridge?.arch ?? null,
|
|
206
329
|
);
|
|
207
330
|
}
|
|
208
331
|
|
|
@@ -212,9 +335,11 @@ export function useShellOffer(fetchImpl: typeof fetch = fetch): ShellDownload |
|
|
|
212
335
|
* It names the OFFER, and for Android it names the file type because that is
|
|
213
336
|
* what a reader recognises (`APK` is the thing they will see in Downloads). An
|
|
214
337
|
* iOS update is an IPA, which nobody installs by hand — the shell finishes the
|
|
215
|
-
* install itself — so the platform word is the honest one there.
|
|
338
|
+
* install itself — so the platform word is the honest one there. A desktop
|
|
339
|
+
* update opens the release page, so it names the platform too.
|
|
216
340
|
*/
|
|
217
341
|
export function shellDownloadShortLabel(download: ShellDownload): string {
|
|
218
342
|
if (download.platform === 'ios') return 'Update app';
|
|
343
|
+
if (isDesktopShellKind(download.platform)) return `Update ${PLATFORM_NAMES[download.platform]}`;
|
|
219
344
|
return download.kind === 'update' ? 'Update APK' : 'Get APK';
|
|
220
345
|
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `useShellOffer` — the one hook both sidebars call.
|
|
8
|
+
*
|
|
9
|
+
* The pure decision table is pinned next door; what this file covers is the
|
|
10
|
+
* WIRING, which is where the reported bug lived: an APK two versions behind a
|
|
11
|
+
* running server drew no button, and no unit test of `shouldOfferShellDownload`
|
|
12
|
+
* could have seen it, because the hook never asked the server at all.
|
|
13
|
+
*
|
|
14
|
+
* Three facts it reads, and each has been wrong at some point:
|
|
15
|
+
*
|
|
16
|
+
* 1. the MOBILE shell's version, from the bootstrap the server wrote for this
|
|
17
|
+
* request (the `KabmoAndroid/<v>` token);
|
|
18
|
+
* 2. the DESKTOP shell's version, from the bridge identity injected before the
|
|
19
|
+
* first script — a desktop app carries no UA token;
|
|
20
|
+
* 3. the release itself, fetched from the shell's own route, with the desktop
|
|
21
|
+
* trio sharing one endpoint and an explicit `kind`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
25
|
+
import { Window } from 'happy-dom';
|
|
26
|
+
import { h, render } from 'preact';
|
|
27
|
+
import { act } from 'preact/test-utils';
|
|
28
|
+
import { useShellOffer } from '@/client/hooks/ui/updates/shell-download';
|
|
29
|
+
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
30
|
+
|
|
31
|
+
let container: HTMLElement;
|
|
32
|
+
|
|
33
|
+
/** The payload every route answers in these cases. */
|
|
34
|
+
const RELEASE = {
|
|
35
|
+
url: 'https://github.com/kabmo-labs/kabmo/releases/download/v0.7.0/Kabmo-0.6.0-android.apk',
|
|
36
|
+
latest: '0.6.0',
|
|
37
|
+
releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
|
|
38
|
+
direct: true,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function Harness({ fetchImpl }: { fetchImpl: typeof fetch }) {
|
|
42
|
+
const offer = useShellOffer(fetchImpl);
|
|
43
|
+
return h('span', { id: 'offer' }, offer ? `${offer.kind}:${offer.platform}:${offer.latest}` : 'none');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A fetch double that answers the release payload and records the URLs asked. */
|
|
47
|
+
function stubFetch(asked: string[]) {
|
|
48
|
+
return (async (input: string | URL | Request) => {
|
|
49
|
+
asked.push(String(input));
|
|
50
|
+
return { ok: true, json: async () => RELEASE } as Response;
|
|
51
|
+
}) as unknown as typeof fetch;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function mount(fetchImpl: typeof fetch): Promise<string> {
|
|
55
|
+
await act(async () => {
|
|
56
|
+
render(h(Harness, { fetchImpl }), container);
|
|
57
|
+
});
|
|
58
|
+
// The probe is an effect: let its promise settle and re-render.
|
|
59
|
+
for (let i = 0; i < 20; i += 1) await act(async () => {});
|
|
60
|
+
return container.querySelector('#offer')?.textContent ?? '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Install a bootstrap and (optionally) a bridge identity on the page. */
|
|
64
|
+
function shell(options: { bootstrap?: Record<string, unknown>; desktop?: Record<string, unknown> }): void {
|
|
65
|
+
const page = globalThis as unknown as {
|
|
66
|
+
window: {
|
|
67
|
+
__OMP_BOOTSTRAP__?: Record<string, unknown>;
|
|
68
|
+
__kabmoDesktop?: Record<string, unknown>;
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
delete page.window.__OMP_BOOTSTRAP__;
|
|
72
|
+
delete page.window.__kabmoDesktop;
|
|
73
|
+
if (options.bootstrap) page.window.__OMP_BOOTSTRAP__ = options.bootstrap;
|
|
74
|
+
if (options.desktop) page.window.__kabmoDesktop = options.desktop;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
beforeAll(() => {
|
|
78
|
+
installDomGlobals(new Window({ url: 'http://localhost' }));
|
|
79
|
+
container = document.createElement('div');
|
|
80
|
+
document.body.appendChild(container);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
act(() => render(null, container));
|
|
85
|
+
shell({});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterAll(() => {
|
|
89
|
+
container.remove();
|
|
90
|
+
restoreDomGlobals();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('the Android shell', () => {
|
|
94
|
+
test('offers an update when the APK is behind the published asset', async () => {
|
|
95
|
+
// The reported incident: server v0.7.0, APK v0.5.0, published 0.6.0, and
|
|
96
|
+
// the sidebar drew nothing because the probe answered `null`.
|
|
97
|
+
shell({ bootstrap: { shellVersion: '0.5.0', isAndroid: true } });
|
|
98
|
+
const asked: string[] = [];
|
|
99
|
+
|
|
100
|
+
expect(await mount(stubFetch(asked))).toBe('update:android:0.6.0');
|
|
101
|
+
expect(asked, 'the Android route').toEqual(['/api/updates/android']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('offers nothing when the APK is the published build', async () => {
|
|
105
|
+
shell({ bootstrap: { shellVersion: '0.6.0', isAndroid: true } });
|
|
106
|
+
expect(await mount(stubFetch([]))).toBe('none');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('offers an INSTALL to a browser on Android, and nothing on a desktop browser', async () => {
|
|
110
|
+
shell({ bootstrap: { isAndroid: true } });
|
|
111
|
+
expect(await mount(stubFetch([]))).toBe('install:android:0.6.0');
|
|
112
|
+
|
|
113
|
+
shell({ bootstrap: { isAndroid: false } });
|
|
114
|
+
expect(await mount(stubFetch([]))).toBe('none');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('the iOS shell', () => {
|
|
119
|
+
test('reads its own token and its own route', async () => {
|
|
120
|
+
shell({ bootstrap: { iosShellVersion: '0.1.0' } });
|
|
121
|
+
const asked: string[] = [];
|
|
122
|
+
|
|
123
|
+
expect(await mount(stubFetch(asked))).toBe('update:ios:0.6.0');
|
|
124
|
+
expect(asked).toEqual(['/api/updates/ios']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('an iOS browser is offered nothing, because an IPA cannot be installed from a page', async () => {
|
|
128
|
+
shell({ bootstrap: {} });
|
|
129
|
+
expect(await mount(stubFetch([]))).toBe('none');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('the desktop shells', () => {
|
|
134
|
+
test('reads the version from the bridge and the route from the kind', async () => {
|
|
135
|
+
// No bootstrap token at all: a desktop app reports itself over the bridge,
|
|
136
|
+
// and the console had no way to read it before this.
|
|
137
|
+
shell({ desktop: { platform: 'macos', shellVersion: '0.1.0', overlayTitlebar: true } });
|
|
138
|
+
const asked: string[] = [];
|
|
139
|
+
|
|
140
|
+
expect(await mount(stubFetch(asked))).toBe('update:macos:0.6.0');
|
|
141
|
+
expect(asked).toEqual(['/api/updates/desktop?kind=macos']);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('each desktop shell asks for its own kind', async () => {
|
|
145
|
+
for (const platform of ['windows', 'linux'] as const) {
|
|
146
|
+
shell({ desktop: { platform, shellVersion: '0.1.0' } });
|
|
147
|
+
const asked: string[] = [];
|
|
148
|
+
await mount(stubFetch(asked));
|
|
149
|
+
expect(asked, platform).toEqual([`/api/updates/desktop?kind=${platform}`]);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('a current desktop build offers nothing', async () => {
|
|
154
|
+
shell({ desktop: { platform: 'windows', shellVersion: '0.6.0' } });
|
|
155
|
+
expect(await mount(stubFetch([]))).toBe('none');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('a browser on a desktop is offered nothing', async () => {
|
|
159
|
+
shell({ bootstrap: { isAndroid: false } });
|
|
160
|
+
expect(await mount(stubFetch([]))).toBe('none');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('the desktop identity wins over a mobile token, because that is the shell asking', async () => {
|
|
164
|
+
// A page cannot carry both in practice, and if it somehow did the desktop
|
|
165
|
+
// app is the process whose version is being compared.
|
|
166
|
+
shell({
|
|
167
|
+
bootstrap: { shellVersion: '0.1.0', isAndroid: true },
|
|
168
|
+
desktop: { platform: 'linux', shellVersion: '0.1.0' },
|
|
169
|
+
});
|
|
170
|
+
expect(await mount(stubFetch([]))).toBe('update:linux:0.6.0');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A shell that reports its architecture asks for THAT machine's artifact.
|
|
175
|
+
*
|
|
176
|
+
* Without it the console can only answer the release page — every desktop
|
|
177
|
+
* artifact is per-architecture — so the offer is a link a human follows instead
|
|
178
|
+
* of an update the shell installs.
|
|
179
|
+
*/
|
|
180
|
+
test('reports the arch, so the answer is a file rather than a page', async () => {
|
|
181
|
+
shell({ desktop: { platform: 'macos', shellVersion: '0.1.0', arch: 'arm64' } });
|
|
182
|
+
const asked: string[] = [];
|
|
183
|
+
|
|
184
|
+
await mount(stubFetch(asked));
|
|
185
|
+
expect(asked).toEqual(['/api/updates/desktop?kind=macos&arch=arm64']);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('a shell with no arch still asks, and gets the page', async () => {
|
|
189
|
+
// An older shell must keep working: the offer degrades to the release page
|
|
190
|
+
// instead of disappearing.
|
|
191
|
+
shell({ desktop: { platform: 'macos', shellVersion: '0.1.0' } });
|
|
192
|
+
const asked: string[] = [];
|
|
193
|
+
|
|
194
|
+
await mount(stubFetch(asked));
|
|
195
|
+
expect(asked).toEqual(['/api/updates/desktop?kind=macos']);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('a refused probe draws no button', async () => {
|
|
199
|
+
shell({ desktop: { platform: 'macos', shellVersion: '0.1.0' } });
|
|
200
|
+
const refusing = (async () => ({ ok: false, json: async () => ({}) }) as Response) as unknown as typeof fetch;
|
|
201
|
+
expect(await mount(refusing)).toBe('none');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('a payload with no artifact draws no button', async () => {
|
|
205
|
+
shell({ desktop: { platform: 'macos', shellVersion: '0.1.0' } });
|
|
206
|
+
const empty = (async () =>
|
|
207
|
+
({
|
|
208
|
+
ok: true,
|
|
209
|
+
json: async () => ({ url: null, latest: null, releaseUrl: null }),
|
|
210
|
+
}) as Response) as unknown as typeof fetch;
|
|
211
|
+
expect(await mount(empty)).toBe('none');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The desktop self-update's client half.
|
|
8
|
+
*
|
|
9
|
+
* Two facts are pinned here and both were decisions rather than accidents:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Which offers the shell can install itself** — a FILE (`direct`) in a
|
|
12
|
+
* DESKTOP shell. An Android offer installs through `PackageInstaller` (its own
|
|
13
|
+
* path), an iOS one cannot install at all, and a browser has no shell to ask.
|
|
14
|
+
* 2. **A failure is reported, never swallowed.** The shell's own sentence is what
|
|
15
|
+
* a user can act on, and a run that reported success before the shell said so
|
|
16
|
+
* would be a lie about an app that is still on the old build.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
20
|
+
import { Window } from 'happy-dom';
|
|
21
|
+
import { h, render } from 'preact';
|
|
22
|
+
import { act } from 'preact/test-utils';
|
|
23
|
+
import { canSelfUpdate, useShellUpdate } from '@/client/hooks/ui/updates/shell-update';
|
|
24
|
+
import type { ShellDownload } from '@/client/hooks/ui/updates/shell-download';
|
|
25
|
+
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
26
|
+
|
|
27
|
+
const DESKTOP: ShellDownload = {
|
|
28
|
+
url: 'https://x/Kabmo-1.2.3-mac-arm64.zip',
|
|
29
|
+
latest: '1.2.3',
|
|
30
|
+
releaseUrl: 'https://x/app',
|
|
31
|
+
direct: true,
|
|
32
|
+
kind: 'update',
|
|
33
|
+
platform: 'macos',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const PAGE: ShellDownload = { ...DESKTOP, url: 'https://x/app', direct: false };
|
|
37
|
+
|
|
38
|
+
let container: HTMLElement;
|
|
39
|
+
|
|
40
|
+
/** Install (or remove) a bridge on the page. */
|
|
41
|
+
function bridge(call: ((method: string, params?: Record<string, unknown>) => Promise<unknown>) | null): void {
|
|
42
|
+
const page = globalThis as unknown as { window: { __kabmoCall?: unknown } };
|
|
43
|
+
if (call) page.window.__kabmoCall = call;
|
|
44
|
+
else delete page.window.__kabmoCall;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeAll(() => {
|
|
48
|
+
installDomGlobals(new Window({ url: 'http://localhost' }));
|
|
49
|
+
container = document.createElement('div');
|
|
50
|
+
document.body.appendChild(container);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
act(() => render(null, container));
|
|
55
|
+
bridge(null);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterAll(() => {
|
|
59
|
+
container.remove();
|
|
60
|
+
restoreDomGlobals();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('canSelfUpdate', () => {
|
|
64
|
+
test('a direct desktop offer inside the shell is installable', () => {
|
|
65
|
+
bridge(async () => null);
|
|
66
|
+
expect(canSelfUpdate(DESKTOP)).toBe(true);
|
|
67
|
+
expect(canSelfUpdate({ ...DESKTOP, platform: 'windows' })).toBe(true);
|
|
68
|
+
expect(canSelfUpdate({ ...DESKTOP, platform: 'linux' })).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a release PAGE is not installable, whatever the platform', () => {
|
|
72
|
+
bridge(async () => null);
|
|
73
|
+
// The console could not name a file, so there is nothing to hand the shell.
|
|
74
|
+
expect(canSelfUpdate(PAGE)).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('the mobile shells keep their own paths', () => {
|
|
78
|
+
bridge(async () => null);
|
|
79
|
+
// Android installs through PackageInstaller, iOS cannot install at all.
|
|
80
|
+
expect(canSelfUpdate({ ...DESKTOP, platform: 'android' })).toBe(false);
|
|
81
|
+
expect(canSelfUpdate({ ...DESKTOP, platform: 'ios' })).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a browser has no shell to ask', () => {
|
|
85
|
+
bridge(null);
|
|
86
|
+
expect(canSelfUpdate(DESKTOP)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('an absent offer is not installable', () => {
|
|
90
|
+
bridge(async () => null);
|
|
91
|
+
expect(canSelfUpdate(null)).toBe(false);
|
|
92
|
+
expect(canSelfUpdate(undefined)).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('useShellUpdate', () => {
|
|
97
|
+
/** A harness that exposes the hook's state and its one action. */
|
|
98
|
+
function Harness({ run }: { run: boolean }) {
|
|
99
|
+
const state = useShellUpdate();
|
|
100
|
+
if (run && state.phase === 'idle') void state.apply(DESKTOP);
|
|
101
|
+
return h('span', { id: 'state' }, `${state.phase}:${state.message ?? ''}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function mount(run: boolean): Promise<string> {
|
|
105
|
+
await act(async () => {
|
|
106
|
+
render(h(Harness, { run }), container);
|
|
107
|
+
});
|
|
108
|
+
for (let i = 0; i < 20; i += 1) await act(async () => {});
|
|
109
|
+
return container.querySelector('#state')?.textContent ?? '';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
test('passes the offer to the shell, and reports its own sentence', async () => {
|
|
113
|
+
const calls: { method: string; params?: Record<string, unknown> }[] = [];
|
|
114
|
+
bridge(async (method, params) => {
|
|
115
|
+
calls.push({ method, params });
|
|
116
|
+
return { success: true, version: '1.2.3', message: 'Kabmo 1.2.3 installed — restarting.', relaunching: true };
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(await mount(true)).toBe('done:Kabmo 1.2.3 installed — restarting.');
|
|
120
|
+
expect(calls).toEqual([
|
|
121
|
+
{ method: 'update_apply', params: { url: DESKTOP.url, version: DESKTOP.latest } },
|
|
122
|
+
]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('a refused update keeps the shell’s reason', async () => {
|
|
126
|
+
// The shell verified the bytes and declined: "the downloaded file is not a
|
|
127
|
+
// signed Kabmo build" is the only thing that tells a user what to do next.
|
|
128
|
+
bridge(async () => ({ success: false, message: 'The downloaded file is not a signed Kabmo build.' }));
|
|
129
|
+
|
|
130
|
+
expect(await mount(true)).toBe('error:The downloaded file is not a signed Kabmo build.');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a shell that cannot answer is reported rather than assumed to have worked', async () => {
|
|
134
|
+
bridge(async () => {
|
|
135
|
+
throw new Error('Unknown shell command: update_apply');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(await mount(true)).toBe('error:Unknown shell command: update_apply');
|
|
139
|
+
});
|
|
140
|
+
});
|