From fcb5c5daa11eee3cd01a5c367ab6941f2512215c Mon Sep 17 00:00:00 2001
From: gkeyes <25716974+gkeyes@users.noreply.github.com>
Date: Mon, 21 Sep 2026 00:39:19 +0800
Subject: [PATCH 01/19] feat(nextflux): stabilize image layout and reading
gestures in 1.0.12
---
.github/workflows/tbx.yml | 21 ++
examples/nextflux/THIRD_PARTY_NOTICES.txt | 31 ++-
examples/nextflux/manifest.json | 4 +-
examples/nextflux/package-lock.json | 4 +-
examples/nextflux/package.json | 5 +-
.../ArticleView/components/ArticleImage.jsx | 14 +-
examples/nextflux/src/hooks/swipeGesture.js | 96 +++++++
.../nextflux/src/hooks/useSwipeGesture.js | 119 ++------
examples/nextflux/src/toolbox/content.js | 3 +
.../nextflux/src/toolbox/imageDimensions.js | 35 +++
examples/nextflux/src/toolbox/media.js | 2 +
.../nextflux/test/browser/fixtures/index.html | 12 +
.../nextflux/test/browser/fixtures/main.jsx | 65 +++++
.../test/browser/fixtures/media-transport.js | 39 +++
.../nextflux/test/browser/fixtures/network.js | 1 +
.../nextflux/test/browser/fixtures/stores.js | 5 +
.../nextflux/test/browser/fixtures/style.css | 12 +
.../nextflux/test/browser/package-lock.json | 76 +++++
examples/nextflux/test/browser/package.json | 12 +
.../test/browser/playwright.config.js | 31 +++
.../nextflux/test/browser/reading.spec.js | 263 ++++++++++++++++++
examples/nextflux/test/browser/vite.config.js | 45 +++
.../nextflux/test/image-dimensions.test.mjs | 66 +++++
examples/nextflux/test/swipe-gesture.test.mjs | 209 ++++++++++++++
24 files changed, 1056 insertions(+), 114 deletions(-)
create mode 100644 examples/nextflux/src/hooks/swipeGesture.js
create mode 100644 examples/nextflux/src/toolbox/imageDimensions.js
create mode 100644 examples/nextflux/test/browser/fixtures/index.html
create mode 100644 examples/nextflux/test/browser/fixtures/main.jsx
create mode 100644 examples/nextflux/test/browser/fixtures/media-transport.js
create mode 100644 examples/nextflux/test/browser/fixtures/network.js
create mode 100644 examples/nextflux/test/browser/fixtures/stores.js
create mode 100644 examples/nextflux/test/browser/fixtures/style.css
create mode 100644 examples/nextflux/test/browser/package-lock.json
create mode 100644 examples/nextflux/test/browser/package.json
create mode 100644 examples/nextflux/test/browser/playwright.config.js
create mode 100644 examples/nextflux/test/browser/reading.spec.js
create mode 100644 examples/nextflux/test/browser/vite.config.js
create mode 100644 examples/nextflux/test/image-dimensions.test.mjs
create mode 100644 examples/nextflux/test/swipe-gesture.test.mjs
diff --git a/.github/workflows/tbx.yml b/.github/workflows/tbx.yml
index f27ac78b..3d30fc14 100644
--- a/.github/workflows/tbx.yml
+++ b/.github/workflows/tbx.yml
@@ -92,6 +92,27 @@ jobs:
- if: ${{ matrix.npm }}
working-directory: examples/${{ matrix.tool }}
run: npm ci --ignore-scripts
+ - name: NextFlux reading unit tests
+ if: ${{ matrix.tool == 'nextflux' }}
+ working-directory: examples/nextflux
+ run: npm test
+ - name: Prepare NextFlux browser checks
+ if: ${{ matrix.tool == 'nextflux' }}
+ working-directory: examples/nextflux/test/browser
+ run: |
+ npm ci --ignore-scripts
+ npx playwright install --with-deps chromium
+ - name: NextFlux reading browser checks
+ if: ${{ matrix.tool == 'nextflux' }}
+ working-directory: examples/nextflux/test/browser
+ run: npm test
+ - name: Save NextFlux reading test results
+ if: ${{ always() && matrix.tool == 'nextflux' }}
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+ with:
+ name: nextflux-reading-checks-${{ github.sha }}
+ path: examples/nextflux/test/browser/test-results/reading-results.json
+ if-no-files-found: warn
- env:
TBX_TOOL: ${{ matrix.tool }}
run: python3 scripts/ci/tbx.py build --tool "$TBX_TOOL"
diff --git a/examples/nextflux/THIRD_PARTY_NOTICES.txt b/examples/nextflux/THIRD_PARTY_NOTICES.txt
index 580b705c..8b427161 100644
--- a/examples/nextflux/THIRD_PARTY_NOTICES.txt
+++ b/examples/nextflux/THIRD_PARTY_NOTICES.txt
@@ -28,11 +28,40 @@ the same hash-checked Tailwind Oxide archive, not independently upgraded.
Original location fields describe the source inventory.
Source inventory lockfile SHA-256: d9be22538f9a6060638f48035f90456611f71df2364ed106f86984fec2559de2
-Lockfile SHA-256: dbf51b812a62f53c9e96c4108535eb42a164b9517bd43abcda77dec05ca96b0b
+Lockfile SHA-256: 0ba1aae271f943aeb6b87de11fafb4531b09cb83720aed266a6856fed18a2840
Build-only cleanup removed unused direct dependencies and unreachable lock entries; the license inventory remains a conservative superset.
Inventory entries: 236
Distinct license/notice texts: 118
+FRIRSS ADAPTED READING UTILITIES
+
+Source: https://github.com/Fripix/Frirss
+Commit: 9fb386594a64eb78154865a11f94172d27d40b86
+Reference files: src/lib/imageAspect.ts; src/components/ArticleList/SwipeableArticleRow.tsx
+Adapted image dimension memory and touch direction locking for the ToolBox runtime.
+
+MIT License
+
+Copyright (c) 2026 Fripix
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
DEPENDENCY INVENTORY
@adobe/react-spectrum@3.47.0
diff --git a/examples/nextflux/manifest.json b/examples/nextflux/manifest.json
index 7279c828..847e1b0c 100644
--- a/examples/nextflux/manifest.json
+++ b/examples/nextflux/manifest.json
@@ -4,8 +4,8 @@
"name": "NextFlux",
"shortName": "NextFlux",
"description": "连接指定 Miniflux 服务的 RSS 阅读器,支持订阅管理、阅读缓存、AI 摘要及后台同步。",
- "version": "1.0.11",
- "versionCode": 12,
+ "version": "1.0.12",
+ "versionCode": 13,
"entry": "index.html",
"icon": "web-app-manifest-512x512.png",
"apiVersion": "1.0",
diff --git a/examples/nextflux/package-lock.json b/examples/nextflux/package-lock.json
index 2b3bd5b2..26120f8c 100644
--- a/examples/nextflux/package-lock.json
+++ b/examples/nextflux/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nextflux-toolbox",
- "version": "1.0.11",
+ "version": "1.0.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nextflux-toolbox",
- "version": "1.0.11",
+ "version": "1.0.12",
"dependencies": {
"@heroui/react": "^3.0.4",
"@heroui/styles": "3.0.4",
diff --git a/examples/nextflux/package.json b/examples/nextflux/package.json
index 515eb906..2b4b484e 100644
--- a/examples/nextflux/package.json
+++ b/examples/nextflux/package.json
@@ -1,11 +1,12 @@
{
"name": "nextflux-toolbox",
"private": true,
- "version": "1.0.11",
+ "version": "1.0.12",
"type": "module",
"scripts": {
"build": "vite build",
- "package": "npm run build && python3 package.py"
+ "package": "npm run build && python3 package.py",
+ "test": "node --test test/*.test.mjs"
},
"dependencies": {
"@heroui/react": "^3.0.4",
diff --git a/examples/nextflux/src/components/ArticleView/components/ArticleImage.jsx b/examples/nextflux/src/components/ArticleView/components/ArticleImage.jsx
index 87132b6d..4e51dbb2 100644
--- a/examples/nextflux/src/components/ArticleView/components/ArticleImage.jsx
+++ b/examples/nextflux/src/components/ArticleView/components/ArticleImage.jsx
@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils.js";
import { approvedImageSource, useSafeImage } from "@/toolbox/media.js";
import { useStore } from "@nanostores/react";
import { imageGalleryActive } from "@/stores/articlesStore.js";
+import { imageDimensions, imageSize } from "@/toolbox/imageDimensions.js";
function ArticleImage({ imgNode, type = "article" }) {
const { src, "data-image-source": sanitizedSource, alt = "" } = imgNode.attribs;
@@ -13,8 +14,11 @@ function ArticleImage({ imgNode, type = "article" }) {
const { containerRef, url, error, retry } = useSafeImage(source, false, galleryOpen);
const [dimensions, setDimensions] = useState(null);
const [failedUrl, setFailedUrl] = useState(null);
+ const epoch = imageDimensions.epoch;
const failure = error || (failedUrl && failedUrl === url ? "图片解码失败,暂时无法显示。" : null);
- const measured = dimensions?.source === source ? dimensions : null;
+ const measured = imageSize(imgNode.attribs.width, imgNode.attribs.height)
+ || (dimensions?.source === source && dimensions.epoch === epoch ? dimensions : null)
+ || imageDimensions.get(source);
const imageStyle = measured ? { width: measured.width, aspectRatio: `${measured.width} / ${measured.height}` } : undefined;
return (
@@ -34,11 +38,13 @@ function ArticleImage({ imgNode, type = "article" }) {
alt={alt}
decoding="async"
className="h-auto object-contain my-0 mx-auto max-w-full"
+ style={measured ? { width: "100%", aspectRatio: `${measured.width} / ${measured.height}` } : undefined}
onLoad={(event) => {
const { naturalWidth: width, naturalHeight: height } = event.currentTarget;
- if (width > 0 && height > 0) {
- setDimensions((previous) => previous?.source === source && previous.width === width && previous.height === height
- ? previous : { source, width, height });
+ if (epoch === imageDimensions.epoch && imageSize(width, height)) {
+ imageDimensions.remember(source, width, height, epoch);
+ setDimensions((previous) => previous?.source === source && previous.epoch === epoch && previous.width === width && previous.height === height
+ ? previous : { source, width, height, epoch });
}
}}
onError={() => setFailedUrl(url)}
diff --git a/examples/nextflux/src/hooks/swipeGesture.js b/examples/nextflux/src/hooks/swipeGesture.js
new file mode 100644
index 00000000..926356c5
--- /dev/null
+++ b/examples/nextflux/src/hooks/swipeGesture.js
@@ -0,0 +1,96 @@
+const EXCLUDED_SELECTOR = [
+ ".audio-player-slider", ".video-player", ".code-block", "iframe",
+ "audio", "video", "input", "textarea", "select", '[role="slider"]',
+ '[contenteditable]:not([contenteditable="false"])',
+ "dialog", '[role="dialog"]', '[aria-modal="true"]', ".PhotoView-Portal",
+].join(",");
+
+export function resolveSwipeDirection(deltaX, deltaY) {
+ if (Math.abs(deltaX) < 8 && Math.abs(deltaY) < 8) return null;
+ return Math.abs(deltaX) > Math.abs(deltaY) * 1.2 ? "horizontal" : "vertical";
+}
+
+export function shouldExcludeSwipeTarget(target) {
+ let element = target?.nodeType === 3 ? target.parentElement : target;
+ while (element) {
+ if (element.matches?.(EXCLUDED_SELECTOR)) return true;
+ // Preserve nested horizontal scrolling (tables, carousels and custom controls).
+ // Read layout only at touchstart, never on every touchmove.
+ if (element.scrollWidth > element.clientWidth) {
+ const style = element.ownerDocument?.defaultView?.getComputedStyle(element);
+ if (style?.overflowX === "auto" || style?.overflowX === "scroll") return true;
+ }
+ element = element.parentElement;
+ }
+ return false;
+}
+
+// The supplied refs and current callbacks let moves avoid React renders and
+// document listener replacement. This module needs no React or app-store runtime.
+export function attachSwipeGesture(document, { gestureRef, getOptions, isBlocked = () => false }) {
+ const reset = () => { gestureRef.current = null; };
+ const blocked = (event) => {
+ const selection = document.getSelection?.();
+ return event.defaultPrevented || isBlocked() ||
+ Boolean(selection?.rangeCount && !selection.isCollapsed);
+ };
+
+ const handleTouchStart = (event) => {
+ // A second finger or an excluded new start invalidates the entire old gesture.
+ reset();
+ if (event.touches.length !== 1 || blocked(event) || shouldExcludeSwipeTarget(event.target)) return;
+ const touch = event.touches[0];
+ gestureRef.current = {
+ identifier: touch.identifier,
+ startX: touch.clientX,
+ startY: touch.clientY,
+ direction: null,
+ };
+ };
+
+ const handleTouchMove = (event) => {
+ const gesture = gestureRef.current;
+ if (!gesture) return;
+ const touch = event.touches[0];
+ if (event.touches.length !== 1 || touch.identifier !== gesture.identifier || blocked(event)) {
+ reset();
+ return;
+ }
+ if (gesture.direction === null) {
+ gesture.direction = resolveSwipeDirection(
+ touch.clientX - gesture.startX,
+ touch.clientY - gesture.startY,
+ );
+ }
+ if (gesture.direction !== "horizontal") return;
+ // The browser may already own a scroll. Do not turn its final touchend into back.
+ if (!event.cancelable) {
+ reset();
+ return;
+ }
+ event.preventDefault();
+ };
+
+ const handleTouchEnd = (event) => {
+ const gesture = gestureRef.current;
+ reset();
+ if (!gesture || event.touches.length || blocked(event)) return;
+ const touch = Array.from(event.changedTouches).find((item) => item.identifier === gesture.identifier);
+ if (!touch || gesture.direction !== "horizontal") return;
+ const { onSwipeRight, threshold = 50 } = getOptions();
+ if (touch.clientX - gesture.startX > threshold) onSwipeRight?.();
+ };
+
+ document.addEventListener("touchstart", handleTouchStart, { passive: true });
+ document.addEventListener("touchmove", handleTouchMove, { passive: false });
+ document.addEventListener("touchend", handleTouchEnd);
+ document.addEventListener("touchcancel", reset);
+
+ return () => {
+ reset();
+ document.removeEventListener("touchstart", handleTouchStart);
+ document.removeEventListener("touchmove", handleTouchMove);
+ document.removeEventListener("touchend", handleTouchEnd);
+ document.removeEventListener("touchcancel", reset);
+ };
+}
diff --git a/examples/nextflux/src/hooks/useSwipeGesture.js b/examples/nextflux/src/hooks/useSwipeGesture.js
index 5e5c8ae4..169dadd4 100644
--- a/examples/nextflux/src/hooks/useSwipeGesture.js
+++ b/examples/nextflux/src/hooks/useSwipeGesture.js
@@ -1,106 +1,19 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useLayoutEffect, useRef } from "react";
+import { imageGalleryActive } from "@/stores/articlesStore.js";
+import { isModalOpen } from "@/stores/modalStore.js";
+import { attachSwipeGesture } from "./swipeGesture.js";
export function useSwipeGesture({ onSwipeRight, threshold = 50 }) {
- const touchStartX = useRef(0);
- const touchStartY = useRef(0);
- const [isScrolling, setIsScrolling] = useState(false);
- const [isHorizontalSwipe, setIsHorizontalSwipe] = useState(false);
-
- useEffect(() => {
- // 默认排除的选择器,包括音频和视频播放器的进度条
- const excludeSelectors = [
- ".audio-player-slider", // 音频播放器进度条
- ".video-player", // 视频播放器
- ".code-block", // code
- "iframe", // 所有iframe元素
- ];
-
- // 检查元素是否应该被排除
- const shouldExcludeElement = (element) => {
- if (!element) return false;
-
- // 检查元素本身或其父元素是否匹配排除选择器
- return excludeSelectors.some((selector) => {
- // 检查元素本身
- if (element.matches?.(selector)) return true;
-
- // 检查父元素
- let parent = element.parentElement;
- while (parent) {
- if (parent.matches?.(selector)) return true;
- parent = parent.parentElement;
- }
-
- return false;
- });
- };
-
- const handleTouchStart = (e) => {
- // 如果触摸开始于排除的元素,则不处理
- if (shouldExcludeElement(e.target)) return;
-
- touchStartX.current = e.touches[0].clientX;
- touchStartY.current = e.touches[0].clientY;
- setIsScrolling(false);
- setIsHorizontalSwipe(false);
- };
-
- const handleTouchMove = (e) => {
- // 如果没有记录起始位置或触摸在排除的元素上,则不处理
- if (
- !touchStartX.current ||
- !touchStartY.current ||
- shouldExcludeElement(e.target)
- )
- return;
-
- const deltaX = e.touches[0].clientX - touchStartX.current;
- const deltaY = e.touches[0].clientY - touchStartY.current;
-
- // 如果还没有确定滑动方向
- if (!isScrolling && !isHorizontalSwipe) {
- // 判断滑动方向
- if (Math.abs(deltaX) > Math.abs(deltaY)) {
- setIsHorizontalSwipe(true);
- e.preventDefault(); // 阻止垂直滚动
- } else {
- setIsScrolling(true);
- }
- }
-
- // 如果是水平滑动,阻止默认行为
- if (isHorizontalSwipe) {
- e.preventDefault();
- }
- };
-
- const handleTouchEnd = (e) => {
- // 如果没有记录起始位置或触摸在排除的元素上,则不处理
- if (!touchStartX.current || shouldExcludeElement(e.target)) return;
-
- const deltaX = e.changedTouches[0].clientX - touchStartX.current;
-
- if (isHorizontalSwipe && deltaX > threshold) {
- onSwipeRight?.();
- }
-
- // 重置状态
- touchStartX.current = 0;
- touchStartY.current = 0;
- setIsScrolling(false);
- setIsHorizontalSwipe(false);
- };
-
- document.addEventListener("touchstart", handleTouchStart, {
- passive: false,
- });
- document.addEventListener("touchmove", handleTouchMove, { passive: false });
- document.addEventListener("touchend", handleTouchEnd);
-
- return () => {
- document.removeEventListener("touchstart", handleTouchStart);
- document.removeEventListener("touchmove", handleTouchMove);
- document.removeEventListener("touchend", handleTouchEnd);
- };
- }, [onSwipeRight, threshold, isScrolling, isHorizontalSwipe]);
+ const gestureRef = useRef(null);
+ const optionsRef = useRef({ onSwipeRight, threshold });
+
+ useLayoutEffect(() => {
+ optionsRef.current = { onSwipeRight, threshold };
+ }, [onSwipeRight, threshold]);
+
+ useEffect(() => attachSwipeGesture(document, {
+ gestureRef,
+ getOptions: () => optionsRef.current,
+ isBlocked: () => isModalOpen.get() || imageGalleryActive.get(),
+ }), []);
}
diff --git a/examples/nextflux/src/toolbox/content.js b/examples/nextflux/src/toolbox/content.js
index 94456ebc..5cb2a3ef 100644
--- a/examples/nextflux/src/toolbox/content.js
+++ b/examples/nextflux/src/toolbox/content.js
@@ -1,3 +1,5 @@
+import { imageDimension } from "./imageDimensions.js";
+
export const ALLOWED_TAGS = new Set("p div span br hr h1 h2 h3 h4 h5 h6 blockquote pre code strong b em i u s del ins small sub sup mark abbr q cite kbd samp var ul ol li dl dt dd table thead tbody tfoot tr th td caption colgroup col figure figcaption details summary a img".split(" "));
export const DROP_CONTENT = new Set(["script", "style", "object", "embed", "svg", "math", "template", "form", "input", "button", "textarea", "select", "option", "meta", "link", "base"]);
@@ -13,6 +15,7 @@ export function cleanAttributes(tag, attributes, baseUrl) {
const clean = {};
for (const [name, value] of Object.entries(attributes)) {
if (["alt", "title"].includes(name)) clean[name] = String(value);
+ if (tag === "img" && ["width", "height"].includes(name) && imageDimension(value)) clean[name] = String(imageDimension(value));
if (["colspan", "rowspan", "start"].includes(name) && /^\d+$/.test(value)) clean[name] = value;
// Keep article-local anchors as inert metadata, never document-global IDs.
if ((name === "id" && tag !== "img") || (name === "name" && tag === "a")) {
diff --git a/examples/nextflux/src/toolbox/imageDimensions.js b/examples/nextflux/src/toolbox/imageDimensions.js
new file mode 100644
index 00000000..fd1bde88
--- /dev/null
+++ b/examples/nextflux/src/toolbox/imageDimensions.js
@@ -0,0 +1,35 @@
+// Adapted from FriRSS imageAspect.ts (MIT), commit 9fb386594a64eb78154865a11f94172d27d40b86.
+// Keep source URLs exact: signed query strings and proxy paths identify different images.
+export function imageDimension(value) {
+ if (typeof value !== "number" && (typeof value !== "string" || !/^\d{1,6}$/.test(value))) return null;
+ const number = Number(value);
+ return Number.isInteger(number) && number > 0 && number <= 100_000 ? number : null;
+}
+
+export function imageSize(width, height) {
+ const w = imageDimension(width), h = imageDimension(height);
+ return w && h ? Object.freeze({ width: w, height: h }) : null;
+}
+
+export function createImageDimensions(limit = 300) {
+ if (!Number.isInteger(limit) || limit < 1) throw new RangeError("Invalid image dimension capacity");
+ const entries = new Map();
+ let epoch = 0;
+ return {
+ get epoch() { return epoch; },
+ get(source) { return entries.get(source) || null; },
+ remember(source, width, height, expectedEpoch = epoch) {
+ const size = imageSize(width, height);
+ // Do not retain large embedded data URLs or short-lived Blob addresses.
+ if (expectedEpoch !== epoch || !size || typeof source !== "string" || source.length > 8192 || !source.startsWith("https://")) return null;
+ entries.delete(source);
+ entries.set(source, size);
+ while (entries.size > limit) entries.delete(entries.keys().next().value);
+ return size;
+ },
+ clear() { epoch += 1; entries.clear(); },
+ };
+}
+
+// Session-only measurements; cleared alongside media on logout.
+export const imageDimensions = createImageDimensions();
diff --git a/examples/nextflux/src/toolbox/media.js b/examples/nextflux/src/toolbox/media.js
index 278985f0..d600ad40 100644
--- a/examples/nextflux/src/toolbox/media.js
+++ b/examples/nextflux/src/toolbox/media.js
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { SERVER_URL } from "./network.js";
import { createImageCache } from "./imageCache.js";
import { createMediaTransport } from "./mediaTransport.js";
+import { imageDimensions } from "./imageDimensions.js";
const RASTER_MIME = /^image\//i;
const transport = createMediaTransport();
@@ -80,6 +81,7 @@ const activeMedia = new Set();
let mediaEpoch = 0;
export function clearMediaCache() {
+ imageDimensions.clear();
images.clear();
mediaEpoch += 1;
for (const media of activeMedia) media.release();
diff --git a/examples/nextflux/test/browser/fixtures/index.html b/examples/nextflux/test/browser/fixtures/index.html
new file mode 100644
index 00000000..5c259a7a
--- /dev/null
+++ b/examples/nextflux/test/browser/fixtures/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
NextFlux reading regression fixture
+
+
+
+
+
+
diff --git a/examples/nextflux/test/browser/fixtures/main.jsx b/examples/nextflux/test/browser/fixtures/main.jsx
new file mode 100644
index 00000000..517de8c4
--- /dev/null
+++ b/examples/nextflux/test/browser/fixtures/main.jsx
@@ -0,0 +1,65 @@
+import { useEffect, useState } from "react";
+import { createRoot } from "react-dom/client";
+import { PhotoProvider } from "react-photo-view";
+import "react-photo-view/dist/react-photo-view.css";
+import "./style.css";
+import "../../../src/components/ArticleView/ArticleView.css";
+import ArticleImage from "@/components/ArticleView/components/ArticleImage.jsx";
+import { useSwipeGesture } from "@/hooks/useSwipeGesture.js";
+import { clearMediaCache } from "@/toolbox/media.js";
+import { imageDimensions } from "@/toolbox/imageDimensions.js";
+import { imageGalleryActive, isModalOpen } from "./stores.js";
+import { pendingRequests, requestCount, settleImage } from "./media-transport.js";
+
+const initialSource = "https://reader.example.invalid/proxy/image?signature=a";
+
+function ReadingFixture() {
+ const [attribs, setAttribs] = useState({ src: initialSource, alt: "Fixture article image" });
+ const [mounted, setMounted] = useState(true);
+ const [generation, setGeneration] = useState(0);
+ const [returns, setReturns] = useState(0);
+ const [accountEpoch, setAccountEpoch] = useState(0);
+
+ useSwipeGesture({ onSwipeRight: () => setReturns((value) => value + 1) });
+
+ useEffect(() => {
+ window.readingFixture = {
+ setImage: (next) => setAttribs({ alt: "Fixture article image", ...next }),
+ unmountImage: () => setMounted(false),
+ mountImage: () => { setGeneration((value) => value + 1); setMounted(true); },
+ clearSessionMedia: () => {
+ // This is the production media cleanup invoked by authStore.logout().
+ // Account persistence and login are intentionally outside this fixture.
+ clearMediaCache();
+ setAccountEpoch(imageDimensions.epoch);
+ },
+ pendingRequests,
+ requestCount,
+ settleImage,
+ dimensions: (source) => imageDimensions.get(source),
+ setGalleryActive: (value) => imageGalleryActive.set(value),
+ setModalOpen: (value) => isModalOpen.set(value),
+ };
+ return () => { delete window.readingFixture; };
+ }, []);
+
+ return (
+ <>
+
+
+ Select part of this article without navigating away.
+ imageGalleryActive.set(visible)}>
+
+ {mounted && }
+ The article continues here.
+
+
+ Horizontal code
+
+
+
+ >
+ );
+}
+
+createRoot(document.getElementById("root")).render(
);
diff --git a/examples/nextflux/test/browser/fixtures/media-transport.js b/examples/nextflux/test/browser/fixtures/media-transport.js
new file mode 100644
index 00000000..459dc35d
--- /dev/null
+++ b/examples/nextflux/test/browser/fixtures/media-transport.js
@@ -0,0 +1,39 @@
+const requests = [];
+
+export function createMediaTransport() {
+ return {
+ load(source, { signal }) {
+ return new Promise((resolve, reject) => {
+ const request = { source, signal, resolve, reject, settled: false };
+ requests.push(request);
+ const abort = () => {
+ request.settled = true;
+ reject(Object.assign(new Error("Fixture media cancelled"), { code: "CANCELLED" }));
+ };
+ if (signal.aborted) abort();
+ else signal.addEventListener("abort", abort, { once: true });
+ });
+ },
+ };
+}
+
+export function pendingRequests(source) {
+ return requests.filter((request) => !request.settled && (!source || request.source === source)).length;
+}
+
+export function requestCount(source) {
+ return requests.filter((request) => request.source === source).length;
+}
+
+export function settleImage(source, { width = 640, height = 320, failure = null } = {}) {
+ const request = requests.find((item) => item.source === source && !item.settled);
+ if (!request) throw new Error(`No pending fixture image request: ${source}`);
+ request.settled = true;
+ if (failure === "network") request.reject(new Error("图片下载失败,请重试。"));
+ else if (failure === "decode") request.resolve(new Blob(["invalid image bytes"], { type: "image/png" }));
+ else {
+ // A real browser decoder sets naturalWidth/naturalHeight and fires onLoad.
+ const svg = `
`;
+ request.resolve(new Blob([svg], { type: "image/svg+xml" }));
+ }
+}
diff --git a/examples/nextflux/test/browser/fixtures/network.js b/examples/nextflux/test/browser/fixtures/network.js
new file mode 100644
index 00000000..2a73c178
--- /dev/null
+++ b/examples/nextflux/test/browser/fixtures/network.js
@@ -0,0 +1 @@
+export const SERVER_URL = "https://reader.example.invalid";
diff --git a/examples/nextflux/test/browser/fixtures/stores.js b/examples/nextflux/test/browser/fixtures/stores.js
new file mode 100644
index 00000000..65593386
--- /dev/null
+++ b/examples/nextflux/test/browser/fixtures/stores.js
@@ -0,0 +1,5 @@
+import { atom } from "nanostores";
+
+// No account, database, article query or network-login graph is mounted here.
+export const imageGalleryActive = atom(false);
+export const isModalOpen = atom(false);
diff --git a/examples/nextflux/test/browser/fixtures/style.css b/examples/nextflux/test/browser/fixtures/style.css
new file mode 100644
index 00000000..c93cab57
--- /dev/null
+++ b/examples/nextflux/test/browser/fixtures/style.css
@@ -0,0 +1,12 @@
+@import "../../../src/index.css";
+@source "../../../src";
+
+/* Only article context and test controls are laid out here. ArticleImage sizing,
+ aspect ratios, margins, image constraints and placeholders use production CSS. */
+html { font-size: 16px; }
+body { margin: 0; }
+#reading-surface { box-sizing: border-box; width: 100%; padding: 20px; }
+[data-testid="returns"] { display: block; height: 24px; }
+#selectable-text { margin: 0 0 16px; }
+#horizontal-container { width: 180px; overflow-x: auto; }
+#horizontal-target { width: 600px; }
diff --git a/examples/nextflux/test/browser/package-lock.json b/examples/nextflux/test/browser/package-lock.json
new file mode 100644
index 00000000..c7b204c3
--- /dev/null
+++ b/examples/nextflux/test/browser/package-lock.json
@@ -0,0 +1,76 @@
+{
+ "name": "nextflux-reading-browser-tests",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "nextflux-reading-browser-tests",
+ "devDependencies": {
+ "@playwright/test": "1.58.2"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
+ "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
+ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
+ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/examples/nextflux/test/browser/package.json b/examples/nextflux/test/browser/package.json
new file mode 100644
index 00000000..cecf86e0
--- /dev/null
+++ b/examples/nextflux/test/browser/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "nextflux-reading-browser-tests",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "playwright test",
+ "serve": "../../node_modules/.bin/vite --config vite.config.js"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.58.2"
+ }
+}
diff --git a/examples/nextflux/test/browser/playwright.config.js b/examples/nextflux/test/browser/playwright.config.js
new file mode 100644
index 00000000..9110ca54
--- /dev/null
+++ b/examples/nextflux/test/browser/playwright.config.js
@@ -0,0 +1,31 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ testDir: ".",
+ testMatch: "reading.spec.js",
+ fullyParallel: false,
+ workers: 1,
+ forbidOnly: Boolean(process.env.CI),
+ retries: 0,
+ timeout: 30_000,
+ expect: { timeout: 10_000 },
+ reporter: [["list"], ["json", { outputFile: "test-results/reading-results.json" }]],
+ outputDir: "test-results/artifacts",
+ use: {
+ baseURL: "http://127.0.0.1:4175",
+ browserName: "chromium",
+ viewport: { width: 400, height: 900 },
+ hasTouch: true,
+ deviceScaleFactor: 1,
+ serviceWorkers: "block",
+ screenshot: "off",
+ video: "off",
+ trace: "off",
+ },
+ webServer: {
+ command: "npm run serve",
+ url: "http://127.0.0.1:4175",
+ reuseExistingServer: false,
+ timeout: 60_000,
+ },
+});
diff --git a/examples/nextflux/test/browser/reading.spec.js b/examples/nextflux/test/browser/reading.spec.js
new file mode 100644
index 00000000..c3a4ac79
--- /dev/null
+++ b/examples/nextflux/test/browser/reading.spec.js
@@ -0,0 +1,263 @@
+import { test as base, expect } from "@playwright/test";
+
+const SOURCE = "https://reader.example.invalid/proxy/image?signature=a";
+const OTHER_SOURCE = "https://reader.example.invalid/proxy/image?signature=b";
+
+const test = base.extend({
+ page: async ({ page }, use, testInfo) => {
+ const pageErrors = [];
+ const externalRequests = [];
+ page.on("pageerror", (error) => pageErrors.push(error.message));
+ await page.route("**/*", async (route) => {
+ const url = new URL(route.request().url());
+ if (url.origin === "http://127.0.0.1:4175") await route.continue();
+ else {
+ externalRequests.push(url.href);
+ await route.abort("blockedbyclient");
+ }
+ });
+ await use(page);
+ await testInfo.attach("browser-diagnostics", {
+ body: JSON.stringify({ pageErrors, externalRequests }),
+ contentType: "application/json",
+ });
+ expect(pageErrors, "uncaught errors in the mounted production components").toEqual([]);
+ expect(externalRequests, "fixture must never contact an external server").toEqual([]);
+ },
+});
+
+async function openFixture(page) {
+ await page.goto("/");
+ await page.waitForFunction(() => Boolean(window.readingFixture));
+ await expect(page.getByTestId("returns")).toHaveText("0");
+}
+
+async function pending(page, source = SOURCE) {
+ await expect.poll(() => page.evaluate((value) => window.readingFixture.pendingRequests(value), source)).toBe(1);
+}
+
+async function resolveImage(page, source = SOURCE, options = {}) {
+ await pending(page, source);
+ await page.evaluate(({ source, options }) => window.readingFixture.settleImage(source, options), { source, options });
+}
+
+async function decodedImage(page, width, height) {
+ const image = page.getByTestId("image-fixture").locator("img");
+ await expect(image).toBeVisible();
+ await expect.poll(() => image.evaluate((element) => [element.complete, element.naturalWidth, element.naturalHeight])).toEqual([true, width, height]);
+}
+
+async function geometry(page) {
+ const box = await page.getByTestId("image-fixture").locator(":scope > div").boundingBox();
+ const paragraph = await page.getByTestId("after-image").boundingBox();
+ expect(box).not.toBeNull();
+ expect(paragraph).not.toBeNull();
+ return { width: box.width, height: box.height, followingTop: paragraph.y };
+}
+
+async function loadingGeometry(page) {
+ const fixture = page.getByTestId("image-fixture");
+ await expect(fixture.locator("img")).toHaveCount(0);
+ await expect(fixture.getByRole("status")).toHaveText("图片加载中…");
+ return geometry(page);
+}
+
+for (const [orientation, width, height] of [["landscape", 1200, 800], ["portrait", 800, 1200]]) {
+ test(`authored ${orientation} dimensions reserve real layout before decoding`, async ({ page }) => {
+ await openFixture(page);
+ await page.evaluate(({ source, width, height }) => window.readingFixture.setImage({ src: source, width: String(width), height: String(height) }), { source: SOURCE, width, height });
+ await pending(page);
+ const before = await loadingGeometry(page);
+ expect(before.width).toBeCloseTo(400, 0);
+ expect(before.height).toBeCloseTo(400 * height / width, 0);
+
+ await resolveImage(page, SOURCE, { width, height });
+ await decodedImage(page, width, height);
+ const after = await geometry(page);
+ expect(Math.abs(after.height - before.height)).toBeLessThanOrEqual(1);
+ expect(Math.abs(after.followingTop - before.followingTop)).toBeLessThanOrEqual(1);
+ });
+}
+
+test("an unknown image learns its decoded dimensions and reserves them after Blob eviction and remount", async ({ page }) => {
+ await page.clock.install();
+ await openFixture(page);
+ const unknown = await loadingGeometry(page);
+ expect(unknown.height).toBeCloseTo(48, 0);
+ await resolveImage(page);
+ await decodedImage(page, 640, 320);
+ const learned = await geometry(page);
+ expect(learned.height).toBeCloseTo(200, 0);
+ expect(learned.followingTop - unknown.followingTop).toBeGreaterThan(100);
+
+ await page.evaluate(() => window.readingFixture.unmountImage());
+ await expect(page.getByTestId("image-fixture")).toHaveAttribute("data-mounted", "false");
+ // Exercise the actual 30 s idle Blob expiration, preserving dimension memory.
+ await page.clock.fastForward(31_000);
+ await page.evaluate(() => window.readingFixture.mountImage());
+ await pending(page);
+ await expect.poll(() => page.evaluate((source) => window.readingFixture.requestCount(source), SOURCE)).toBe(2);
+ const remembered = await loadingGeometry(page);
+ expect(remembered.height).toBeCloseTo(learned.height, 0);
+ expect(Math.abs(remembered.followingTop - learned.followingTop)).toBeLessThanOrEqual(1);
+ await resolveImage(page);
+ await decodedImage(page, 640, 320);
+ expect(Math.abs((await geometry(page)).followingTop - remembered.followingTop)).toBeLessThanOrEqual(1);
+});
+
+test("changing only the signed source query does not reuse the previous image or its aspect ratio", async ({ page }) => {
+ await openFixture(page);
+ await resolveImage(page);
+ await decodedImage(page, 640, 320);
+ const originalBlob = await page.getByTestId("image-fixture").locator("img").getAttribute("src");
+ await page.evaluate((source) => window.readingFixture.setImage({ src: source }), OTHER_SOURCE);
+ await pending(page, OTHER_SOURCE);
+ expect((await loadingGeometry(page)).height).toBeCloseTo(48, 0);
+ await resolveImage(page, OTHER_SOURCE, { width: 320, height: 640 });
+ await decodedImage(page, 320, 640);
+ expect((await geometry(page)).height).toBeCloseTo(640, 0);
+ expect(await page.getByTestId("image-fixture").locator("img").getAttribute("src")).not.toBe(originalBlob);
+});
+
+test("the logout media cleanup invalidates learned geometry in retained and newly mounted components", async ({ page }) => {
+ await openFixture(page);
+ await resolveImage(page);
+ await decodedImage(page, 640, 320);
+ expect((await geometry(page)).height).toBeCloseTo(200, 0);
+ await page.evaluate(() => window.readingFixture.clearSessionMedia());
+ await expect(page.locator("#reading-surface")).toHaveAttribute("data-account-epoch", "1");
+ expect((await loadingGeometry(page)).height).toBeCloseTo(48, 0);
+ expect(await page.evaluate((source) => window.readingFixture.dimensions(source), SOURCE)).toBeNull();
+
+ await page.evaluate(() => window.readingFixture.mountImage());
+ await pending(page);
+ expect((await loadingGeometry(page)).height).toBeCloseTo(48, 0);
+ await resolveImage(page, SOURCE, { width: 320, height: 640 });
+ await decodedImage(page, 320, 640);
+ expect((await geometry(page)).height).toBeCloseTo(640, 0);
+});
+
+for (const failure of ["network", "decode"]) {
+ test(`${failure} failure can retry through the real image button and media lease`, async ({ page }) => {
+ await openFixture(page);
+ await resolveImage(page, SOURCE, { failure });
+ const retry = page.getByRole("button", { name: "重试图片" });
+ await expect(retry).toBeVisible();
+ await retry.click();
+ await pending(page);
+ await expect(retry).toHaveCount(0);
+ await resolveImage(page);
+ await decodedImage(page, 640, 320);
+ expect(await page.evaluate((source) => window.readingFixture.requestCount(source), SOURCE)).toBe(2);
+ });
+}
+
+async function touch(page, selector, type, points, changed = points, cancelable = true) {
+ return page.locator(selector).evaluate((target, { type, points, changed, cancelable }) => {
+ const touches = (values) => values.map(([clientX, clientY, identifier = 1]) => new Touch({
+ identifier, target, clientX, clientY, pageX: clientX, pageY: clientY, screenX: clientX, screenY: clientY,
+ }));
+ const event = new TouchEvent(type, { touches: touches(points), targetTouches: touches(points), changedTouches: touches(changed), bubbles: true, cancelable });
+ target.dispatchEvent(event);
+ return event.defaultPrevented;
+ }, { type, points, changed, cancelable });
+}
+
+async function swipe(page, selector = "#reading-surface", points = [[60, 100], [80, 102], [140, 103]]) {
+ await touch(page, selector, "touchstart", [points[0]]);
+ const prevented = [];
+ for (const point of points.slice(1)) prevented.push(await touch(page, selector, "touchmove", [point]));
+ await touch(page, selector, "touchend", [], [points.at(-1)]);
+ return prevented;
+}
+
+test("the dead zone leaves small motion alone and a vertical decision stays native", async ({ page }) => {
+ await openFixture(page);
+ expect(await swipe(page, "#reading-surface", [[60, 100], [63, 103], [66, 106]])).toEqual([false, false]);
+ await expect(page.getByTestId("returns")).toHaveText("0");
+ expect(await swipe(page, "#reading-surface", [[60, 100], [64, 114], [180, 160]])).toEqual([false, false]);
+ await expect(page.getByTestId("returns")).toHaveText("0");
+});
+
+for (const start of [[60, 100], [0, 0]]) {
+ test(`right swipe from ${start.join(",")} cancels the first accepted move and returns exactly once`, async ({ page }) => {
+ await openFixture(page);
+ const [x, y] = start;
+ expect(await swipe(page, "#reading-surface", [[x, y], [x + 3, y + 2], [x + 20, y + 3], [x + 80, y + 4]])).toEqual([false, true, true]);
+ await expect(page.getByTestId("returns")).toHaveText("1");
+ await touch(page, "#reading-surface", "touchend", [], [[x + 90, y + 4]]);
+ await expect(page.getByTestId("returns")).toHaveText("1");
+ });
+}
+
+test("adding a second finger cancels navigation until all fingers end", async ({ page }) => {
+ await openFixture(page);
+ await touch(page, "#reading-surface", "touchstart", [[60, 100]]);
+ await touch(page, "#reading-surface", "touchmove", [[80, 102]]);
+ await touch(page, "#reading-surface", "touchstart", [[80, 102], [120, 102, 2]], [[120, 102, 2]]);
+ await touch(page, "#reading-surface", "touchend", [[80, 102]], [[120, 102, 2]]);
+ expect(await touch(page, "#reading-surface", "touchmove", [[160, 103]])).toBe(false);
+ await touch(page, "#reading-surface", "touchend", [], [[160, 103]]);
+ await expect(page.getByTestId("returns")).toHaveText("0");
+ await swipe(page);
+ await expect(page.getByTestId("returns")).toHaveText("1");
+});
+
+test("touchcancel clears an accepted swipe without poisoning the next gesture", async ({ page }) => {
+ await openFixture(page);
+ await touch(page, "#reading-surface", "touchstart", [[60, 100]]);
+ expect(await touch(page, "#reading-surface", "touchmove", [[80, 102]])).toBe(true);
+ await touch(page, "#reading-surface", "touchcancel", [], [[80, 102]]);
+ await touch(page, "#reading-surface", "touchend", [], [[160, 103]]);
+ await expect(page.getByTestId("returns")).toHaveText("0");
+ await swipe(page);
+ await expect(page.getByTestId("returns")).toHaveText("1");
+});
+
+test("code, range controls and actual horizontal overflow retain their gestures", async ({ page }) => {
+ await openFixture(page);
+ expect(await page.locator("#horizontal-container").evaluate((element) => element.scrollWidth > element.clientWidth && getComputedStyle(element).overflowX === "auto")).toBe(true);
+ for (const selector of ["#code-target", "#range-target", "#horizontal-target"]) {
+ expect(await swipe(page, selector)).toEqual([false, false]);
+ await expect(page.getByTestId("returns")).toHaveText("0");
+ }
+});
+
+test("selected article text blocks navigation and clearing the selection restores it", async ({ page }) => {
+ await openFixture(page);
+ await page.locator("#selectable-text").evaluate((element) => {
+ const range = document.createRange();
+ range.selectNodeContents(element);
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+ });
+ expect(await swipe(page)).toEqual([false, false]);
+ await expect(page.getByTestId("returns")).toHaveText("0");
+ await page.evaluate(() => window.getSelection().removeAllRanges());
+ await swipe(page);
+ await expect(page.getByTestId("returns")).toHaveText("1");
+});
+
+test("opening the real photo gallery blocks the article return gesture", async ({ page }) => {
+ await openFixture(page);
+ await resolveImage(page);
+ await decodedImage(page, 640, 320);
+ await page.getByTestId("image-fixture").locator("img").click();
+ await expect(page.locator(".PhotoView-Portal")).toBeVisible();
+ // Dispatch behind the portal as well, proving the live gallery-state guard.
+ expect(await swipe(page)).toEqual([false, false]);
+ await expect(page.getByTestId("returns")).toHaveText("0");
+});
+
+test("a modal appearing during a swipe cancels its pending navigation", async ({ page }) => {
+ await openFixture(page);
+ await touch(page, "#reading-surface", "touchstart", [[60, 100]]);
+ expect(await touch(page, "#reading-surface", "touchmove", [[80, 102]])).toBe(true);
+ await page.evaluate(() => window.readingFixture.setModalOpen(true));
+ await touch(page, "#reading-surface", "touchend", [], [[160, 103]]);
+ await expect(page.getByTestId("returns")).toHaveText("0");
+ await page.evaluate(() => window.readingFixture.setModalOpen(false));
+ await swipe(page);
+ await expect(page.getByTestId("returns")).toHaveText("1");
+});
diff --git a/examples/nextflux/test/browser/vite.config.js b/examples/nextflux/test/browser/vite.config.js
new file mode 100644
index 00000000..56b9917f
--- /dev/null
+++ b/examples/nextflux/test/browser/vite.config.js
@@ -0,0 +1,45 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import tailwindcss from "@tailwindcss/vite";
+
+const directory = path.dirname(fileURLToPath(import.meta.url));
+const app = path.resolve(directory, "../..");
+const fixture = path.join(directory, "fixtures");
+
+export default defineConfig({
+ root: fixture,
+ plugins: [
+ {
+ name: "reading-test-boundaries",
+ enforce: "pre",
+ resolveId(source, importer) {
+ // Keep real media leases, source validation, cancellation and cleanup.
+ // Only the native transport and server/account boundary are substituted.
+ if (importer?.split("?")[0] === path.join(app, "src/toolbox/media.js")) {
+ if (source === "./mediaTransport.js") return path.join(fixture, "media-transport.js");
+ if (source === "./network.js") return path.join(fixture, "network.js");
+ }
+ },
+ },
+ react(),
+ tailwindcss(),
+ ],
+ resolve: {
+ alias: [
+ { find: "@/stores/articlesStore.js", replacement: path.join(fixture, "stores.js") },
+ { find: "@/stores/modalStore.js", replacement: path.join(fixture, "stores.js") },
+ { find: "@", replacement: path.join(app, "src") },
+ ],
+ },
+ // Tailwind's Vite plugin processes the production CSS; no second PostCSS pass.
+ css: { postcss: { plugins: [] } },
+ server: {
+ host: "127.0.0.1",
+ port: 4175,
+ strictPort: true,
+ hmr: false,
+ fs: { allow: [app] },
+ },
+});
diff --git a/examples/nextflux/test/image-dimensions.test.mjs b/examples/nextflux/test/image-dimensions.test.mjs
new file mode 100644
index 00000000..2bf8ae22
--- /dev/null
+++ b/examples/nextflux/test/image-dimensions.test.mjs
@@ -0,0 +1,66 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { createImageDimensions, imageDimension, imageSize } from "../src/toolbox/imageDimensions.js";
+import { cleanAttributes } from "../src/toolbox/content.js";
+
+test("dimensions accept only bounded positive integers, never CSS or partial numbers", () => {
+ assert.deepEqual(imageSize("640", "480"), { width: 640, height: 480 });
+ for (const value of [0, -1, 100001, Infinity, NaN, 1.5, "10px", "100%", "1e3", "10;position:fixed", true, null, {}, "", " 2"]) {
+ assert.equal(imageDimension(value), null, String(value));
+ assert.equal(imageSize(value, 10), null);
+ }
+});
+
+test("sanitizer preserves dimensions without admitting styles, handlers or active src", () => {
+ assert.deepEqual(cleanAttributes("img", {
+ src: "https://images.example/a.png?a=1&b=2", width: "640", height: "480",
+ style: "position:fixed", onload: "alert(1)", srcset: "https://elsewhere.example/evil.png 2x",
+ }), { "data-image-source": "https://images.example/a.png?a=1&b=2", width: "640", height: "480" });
+ assert.deepEqual(cleanAttributes("img", { width: "20px", height: "-1", src: "javascript:alert(1)" }), {});
+ assert.deepEqual(cleanAttributes("div", { width: "10", height: "20" }), {});
+});
+
+test("measurements follow exact source identity, not query reordering or decoding", () => {
+ const cache = createImageDimensions();
+ const source = "https://images.example/media/v1/key?sig=a%2Fb&v=2";
+ cache.remember(source, 640, 480);
+ assert.deepEqual(cache.get(source), { width: 640, height: 480 });
+ assert.equal(cache.get(source.replace("a%2Fb", "a/b")), null);
+ assert.equal(cache.get("https://images.example/media/v1/key?v=2&sig=a%2Fb"), null);
+ assert.equal(cache.get("https://other.example/media/v1/key?sig=a%2Fb&v=2"), null);
+});
+
+test("capacity evicts oldest measurement and refreshing a measurement keeps it", () => {
+ const cache = createImageDimensions(2);
+ const [a, b, c] = ["a", "b", "c"].map((id) => `https://images.example/${id}`);
+ cache.remember(a, 10, 20); cache.remember(b, 20, 30);
+ cache.remember(a, 40, 50); cache.remember(c, 60, 70);
+ assert.equal(cache.get(b), null);
+ assert.deepEqual(cache.get(a), { width: 40, height: 50 });
+ assert.deepEqual(cache.get(c), { width: 60, height: 70 });
+});
+
+test("logout invalidates earlier measurements and late image callbacks", () => {
+ const cache = createImageDimensions(), epoch = cache.epoch;
+ const source = "https://images.example/a";
+ cache.remember(source, 10, 20, epoch);
+ cache.clear();
+ assert.equal(cache.get(source), null);
+ assert.equal(cache.remember(source, 30, 40, epoch), null);
+ assert.equal(cache.get(source), null);
+ cache.remember(source, 50, 60, cache.epoch);
+ assert.deepEqual(cache.get(source), { width: 50, height: 60 });
+});
+
+test("no large embedded URLs, temporary Blob identities, or invalid measurements retained", () => {
+ const cache = createImageDimensions();
+ for (const source of ["blob:https://tool.example/id", "data:image/png;base64,AAAA", "https://images.example/" + "a".repeat(8192)]) {
+ assert.equal(cache.remember(source, 10, 20), null);
+ assert.equal(cache.get(source), null);
+ }
+ const source = "https://images.example/a";
+ cache.remember(source, 10, 20);
+ cache.remember(source, 0, 20);
+ assert.deepEqual(cache.get(source), { width: 10, height: 20 });
+ assert.throws(() => createImageDimensions(0), RangeError);
+});
diff --git a/examples/nextflux/test/swipe-gesture.test.mjs b/examples/nextflux/test/swipe-gesture.test.mjs
new file mode 100644
index 00000000..f7871383
--- /dev/null
+++ b/examples/nextflux/test/swipe-gesture.test.mjs
@@ -0,0 +1,209 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { attachSwipeGesture, resolveSwipeDirection, shouldExcludeSwipeTarget } from "../src/hooks/swipeGesture.js";
+
+const point = (clientX, clientY, identifier = 0) => ({ clientX, clientY, identifier });
+
+function harness() {
+ const document = new EventTarget();
+ const gestureRef = { current: null };
+ let selected = false;
+ let blocked = false;
+ let calls = 0;
+ let options = { onSwipeRight: () => { calls += 1; } };
+ document.getSelection = () => ({ rangeCount: selected ? 1 : 0, isCollapsed: !selected });
+ const detach = attachSwipeGesture(document, {
+ gestureRef,
+ getOptions: () => options,
+ isBlocked: () => blocked,
+ });
+ const dispatch = (type, touches, changedTouches = [], extra = {}) => {
+ const event = new Event(type, { cancelable: extra.cancelable ?? true });
+ Object.assign(event, { touches, changedTouches });
+ if (extra.target) Object.defineProperty(event, "target", { value: extra.target });
+ if (extra.prevented) event.preventDefault();
+ document.dispatchEvent(event);
+ return event;
+ };
+ return {
+ gestureRef, detach, dispatch,
+ start: (x = 0, y = 0, extra) => dispatch("touchstart", [point(x, y)], [], extra),
+ move: (x, y, extra) => dispatch("touchmove", [point(x, y)], [], extra),
+ end: (x, y, extra) => dispatch("touchend", [], [point(x, y)], extra),
+ select: (value) => { selected = value; },
+ block: (value) => { blocked = value; },
+ setOptions: (value) => { options = value; },
+ calls: () => calls,
+ };
+}
+
+test("jitter stays undecided until 8 px; diagonal movement favors scrolling", () => {
+ assert.equal(resolveSwipeDirection(7, -7), null);
+ assert.equal(resolveSwipeDirection(8, 0), "horizontal");
+ assert.equal(resolveSwipeDirection(9, 8), "vertical");
+ assert.equal(resolveSwipeDirection(12, 10), "vertical");
+ assert.equal(resolveSwipeDirection(13, 10), "horizontal");
+});
+
+test("zero coordinates work and the direction-lock event itself stops scrolling", () => {
+ const h = harness();
+ h.start(0, 0);
+ assert.equal(h.move(3, 2).defaultPrevented, false);
+ assert.equal(h.move(8, 1).defaultPrevented, true);
+ assert.equal(h.move(65, 3).defaultPrevented, true);
+ h.end(65, 3);
+ h.end(65, 3);
+ assert.equal(h.calls(), 1);
+ assert.equal(h.gestureRef.current, null);
+ h.detach();
+});
+
+test("once vertical, even a large later right drift cannot become back", () => {
+ const h = harness();
+ h.start(20, 20);
+ assert.equal(h.move(21, 28).defaultPrevented, false);
+ assert.equal(h.move(150, 30).defaultPrevented, false);
+ h.end(150, 30);
+ assert.equal(h.calls(), 0);
+ h.detach();
+});
+
+test("horizontal lock survives later vertical drift without a second direction decision", () => {
+ const h = harness();
+ h.start();
+ h.move(9, 0);
+ assert.equal(h.move(70, 90).defaultPrevented, true);
+ h.end(70, 90);
+ assert.equal(h.calls(), 1);
+ h.detach();
+});
+
+test("taps, release-only movement, left swipes and a drag ending at threshold do not go back", () => {
+ for (const scenario of [[], [[60, 0, "end"]], [[-70, 0]], [[50, 0]], [[70, 0], [40, 0]]]) {
+ const h = harness();
+ h.start();
+ for (const [x, y, kind] of scenario) {
+ if (kind === "end") h.end(x, y);
+ else h.move(x, y);
+ }
+ const [x = 0, y = 0] = scenario.at(-1) ?? [];
+ h.end(x, y);
+ assert.equal(h.calls(), 0);
+ h.detach();
+ }
+});
+
+test("touchcancel drops an active swipe and the next gesture starts cleanly", () => {
+ const h = harness();
+ h.start();
+ h.move(80, 0);
+ h.dispatch("touchcancel", [], [point(80, 0)]);
+ h.end(80, 0);
+ assert.equal(h.calls(), 0);
+ h.start();
+ h.move(80, 0);
+ h.end(80, 0);
+ assert.equal(h.calls(), 1);
+ h.detach();
+});
+
+test("a second finger cancels until all fingers lift, even when the first finger remains", () => {
+ for (const type of ["touchstart", "touchmove"]) {
+ const h = harness();
+ h.start();
+ h.move(60, 0);
+ h.dispatch(type, [point(60, 0), point(80, 20, 1)]);
+ h.dispatch("touchend", [point(60, 0)], [point(80, 20, 1)]);
+ assert.equal(h.move(100, 0).defaultPrevented, false);
+ h.end(100, 0);
+ assert.equal(h.calls(), 0);
+ h.detach();
+ }
+});
+
+test("touch identity changes and a different finger's release cannot complete a swipe", () => {
+ for (const type of ["touchmove", "touchend"]) {
+ const h = harness();
+ h.start();
+ h.move(60, 0);
+ h.dispatch(type, type === "touchmove" ? [point(100, 0, 1)] : [], [point(100, 0, 1)]);
+ h.end(100, 0);
+ assert.equal(h.calls(), 0);
+ h.detach();
+ }
+});
+
+test("an excluded start clears any old active gesture", () => {
+ const h = harness();
+ h.start();
+ h.move(70, 0);
+ const target = { parentElement: { matches: () => true } };
+ h.start(10, 0, { target });
+ assert.equal(h.move(90, 0).defaultPrevented, false);
+ h.end(90, 0);
+ assert.equal(h.calls(), 0);
+ h.detach();
+});
+
+test("nested horizontal scrollers are excluded; clipped or vertical content is not", () => {
+ const scroller = {
+ scrollWidth: 600, clientWidth: 300,
+ ownerDocument: { defaultView: { getComputedStyle: () => ({ overflowX: "auto" }) } },
+ };
+ assert.equal(shouldExcludeSwipeTarget({ nodeType: 3, parentElement: scroller }), true);
+ scroller.ownerDocument.defaultView.getComputedStyle = () => ({ overflowX: "hidden" });
+ assert.equal(shouldExcludeSwipeTarget({ parentElement: scroller }), false);
+ scroller.scrollWidth = 300;
+ assert.equal(shouldExcludeSwipeTarget({ parentElement: scroller }), false);
+});
+
+test("selection and modal/gallery blockers cancel at start, move and release", () => {
+ for (const blocker of ["select", "block"]) {
+ for (const phase of ["start", "move", "end"]) {
+ const h = harness();
+ if (phase === "start") h[blocker](true);
+ h.start();
+ if (phase === "move") h[blocker](true);
+ const move = h.move(80, 0);
+ if (phase !== "end") assert.equal(move.defaultPrevented, false);
+ if (phase === "end") h[blocker](true);
+ h.end(80, 0);
+ assert.equal(h.calls(), 0);
+ h[blocker](false);
+ h.end(90, 0);
+ assert.equal(h.calls(), 0);
+ h.detach();
+ }
+ }
+});
+
+test("browser-owned scroll and events already handled by a control cancel navigation", () => {
+ for (const extra of [{ cancelable: false }, { prevented: true }]) {
+ const h = harness();
+ h.start();
+ h.move(80, 0, extra);
+ h.end(80, 0);
+ assert.equal(h.calls(), 0);
+ h.detach();
+ }
+});
+
+test("current callback and threshold apply without listener rebinding; teardown removes handlers", () => {
+ const h = harness();
+ let currentCalls = 0;
+ h.start();
+ h.move(60, 0);
+ h.setOptions({ threshold: 75, onSwipeRight: () => { currentCalls += 1; } });
+ h.end(60, 0);
+ h.start();
+ h.move(80, 0);
+ h.end(80, 0);
+ assert.equal(h.calls(), 0);
+ assert.equal(currentCalls, 1);
+ h.detach();
+ h.start();
+ assert.equal(h.move(90, 0).defaultPrevented, false);
+ h.end(90, 0);
+ assert.equal(currentCalls, 1);
+ assert.equal(h.gestureRef.current, null);
+});
From b02196c4fe14840ce99e26dac1a0a2ae31a6cf8c Mon Sep 17 00:00:00 2001
From: gkeyes <25716974+gkeyes@users.noreply.github.com>
Date: Mon, 21 Sep 2026 00:44:12 +0800
Subject: [PATCH 02/19] test(nextflux): distinguish gallery and navigation
touch handlers
---
.../nextflux/test/browser/reading.spec.js | 41 +++++++++++++++++--
1 file changed, 38 insertions(+), 3 deletions(-)
diff --git a/examples/nextflux/test/browser/reading.spec.js b/examples/nextflux/test/browser/reading.spec.js
index c3a4ac79..8d5cabb0 100644
--- a/examples/nextflux/test/browser/reading.spec.js
+++ b/examples/nextflux/test/browser/reading.spec.js
@@ -245,9 +245,44 @@ test("opening the real photo gallery blocks the article return gesture", async (
await decodedImage(page, 640, 320);
await page.getByTestId("image-fixture").locator("img").click();
await expect(page.locator(".PhotoView-Portal")).toBeVisible();
- // Dispatch behind the portal as well, proving the live gallery-state guard.
- expect(await swipe(page)).toEqual([false, false]);
- await expect(page.getByTestId("returns")).toHaveText("0");
+ await expect(page.locator("body")).toHaveCSS("overflow", "hidden");
+ expect(await page.evaluate(async () => (await import("/stores.js")).imageGalleryActive.get())).toBe(true);
+ await page.evaluate(async () => {
+ const { imageGalleryActive } = await import("/stores.js");
+ window.galleryMovePhases = [];
+ const observe = (event) => {
+ if (event.target.id !== "reading-surface") return;
+ window.galleryMovePhases.push({
+ at: event.currentTarget === document ? "document" : "window",
+ cancelled: event.defaultPrevented,
+ target: event.target.id,
+ x: event.touches[0].clientX,
+ galleryActive: imageGalleryActive.get(),
+ });
+ };
+ // Registered after the Hook and gallery effects, both in the bubble phase.
+ document.addEventListener("touchmove", observe);
+ window.addEventListener("touchmove", observe);
+ window.removeGalleryMoveObservers = () => {
+ document.removeEventListener("touchmove", observe);
+ window.removeEventListener("touchmove", observe);
+ };
+ });
+ try {
+ // PhotoBox 1.2.7 prevents touchmove on window. The document Hook must leave
+ // these behind-portal events alone before the gallery receives them.
+ expect(await swipe(page)).toEqual([true, true]);
+ const phases = await page.evaluate(() => window.galleryMovePhases);
+ expect(phases.map(({ at, cancelled, x }) => [at, cancelled, x])).toEqual([
+ ["document", false, 80], ["window", true, 80],
+ ["document", false, 140], ["window", true, 140],
+ ]);
+ expect(phases.every(({ target, galleryActive }) => target === "reading-surface" && galleryActive)).toBe(true);
+ await expect(page.getByTestId("returns")).toHaveText("0");
+ await expect(page.locator(".PhotoView-Portal")).toBeVisible();
+ } finally {
+ await page.evaluate(() => window.removeGalleryMoveObservers());
+ }
});
test("a modal appearing during a swipe cancels its pending navigation", async ({ page }) => {
From 67ee41da9f9fc61904f25c430bc7dd7603b0f8f8 Mon Sep 17 00:00:00 2001
From: DoraC <25716974+gkeyes@users.noreply.github.com>
Date: Tue, 22 Sep 2026 09:41:55 +0800
Subject: [PATCH 03/19] ui: refine shared layouts and interaction feedback
Apply Emil-inspired design polish to the existing Compose components:
- Measure toolbar controls before laying out titles, including RTL and large text.
- Keep search geometry stable and expose focused state without hidden controls.
- Defer navigation animation reads to placement and drawing, preserving hit targets.
- Clarify settings rows and make disclosure feedback interruptible.
- Add parameterized Android behavior regressions for six appearance variants.
Preserve public component APIs, existing materials, runtime behavior and dependencies.
Android compilation and behavior verification are delegated to the existing PR CI.
---
.../toolbox/host/DesignPolishBehaviorTest.kt | 195 +++++++++++++++++
.../core/ui/component/ToolBoxDisclosureRow.kt | 12 +-
.../core/ui/component/ToolBoxLayout.kt | 200 ++++++++++++------
.../toolbox/core/ui/component/ToolBoxRows.kt | 83 +++++---
4 files changed, 400 insertions(+), 90 deletions(-)
create mode 100644 app/src/androidTest/kotlin/io/toolbox/host/DesignPolishBehaviorTest.kt
diff --git a/app/src/androidTest/kotlin/io/toolbox/host/DesignPolishBehaviorTest.kt b/app/src/androidTest/kotlin/io/toolbox/host/DesignPolishBehaviorTest.kt
new file mode 100644
index 00000000..59477d74
--- /dev/null
+++ b/app/src/androidTest/kotlin/io/toolbox/host/DesignPolishBehaviorTest.kt
@@ -0,0 +1,195 @@
+package io.toolbox.host
+
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.*
+import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import io.toolbox.core.ui.component.*
+import io.toolbox.core.ui.theme.*
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@RunWith(Parameterized::class)
+class DesignPolishBehaviorTest(
+ private val style: ToolBoxThemeStyle,
+ private val mode: ToolBoxThemeMode,
+ private val reduceTransparency: Boolean,
+) {
+ @get:Rule val compose = createAndroidComposeRule
()
+ private val viewportWidth = mutableFloatStateOf(360f)
+ private val fontScale = mutableFloatStateOf(1f)
+ private val direction = mutableStateOf(LayoutDirection.Ltr)
+
+ private fun render(content: @Composable () -> Unit) {
+ compose.activity.setContent {
+ BoxWithConstraints(Modifier.fillMaxSize()) {
+ CompositionLocalProvider(
+ LocalDensity provides Density(constraints.maxWidth / viewportWidth.floatValue, fontScale.floatValue),
+ LocalLayoutDirection provides direction.value,
+ ) {
+ ToolBoxTheme(style = style, mode = mode, reduceTransparency = reduceTransparency) {
+ Column(Modifier.fillMaxSize(), content = { content() })
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ fun titleAvoidsAllControlsAtNarrowWidthsAndLargeFontsInBothDirections() {
+ val title = "工具详情与运行设置"
+ var clicks = 0
+ render {
+ ToolBoxTopBar(
+ title = title,
+ subtitle = "已安装工具",
+ modifier = Modifier.testTag("topbar"),
+ navigationIcon = ToolBoxIconKey.Back,
+ onNavigationClick = { clicks++ },
+ actions = {
+ repeat(3) { index ->
+ ToolBoxIconButton(ToolBoxIconKey.More, "操作$index", { clicks++ })
+ }
+ },
+ )
+ }
+ val controls = listOf("返回", "操作0", "操作1", "操作2")
+ for (layoutDirection in listOf(LayoutDirection.Ltr, LayoutDirection.Rtl)) {
+ for ((width, scale) in listOf(320f to 1f, 320f to 1.6f, 360f to 1f, 600f to 1f)) {
+ compose.runOnIdle {
+ viewportWidth.floatValue = width
+ fontScale.floatValue = scale
+ direction.value = layoutDirection
+ }
+ val bar = bounds("topbar")
+ val titleBounds = compose.onNodeWithText(title).assertIsDisplayed().fetchSemanticsNode().boundsInRoot
+ assertTrue("The title must retain visible space", titleBounds.width > 0f)
+ assertContained(titleBounds, bar)
+ val controlBounds = controls.map { description ->
+ compose.onNodeWithContentDescription(description)
+ .assertHasClickAction().assertHeightIsAtLeast(48.dp).assertWidthIsAtLeast(48.dp)
+ .fetchSemanticsNode().boundsInRoot.also { assertContained(it, bar) }
+ }
+ controlBounds.forEach { control ->
+ assertTrue("Title overlaps a control", titleBounds.right <= control.left + 1f || titleBounds.left >= control.right - 1f)
+ }
+ for (i in controlBounds.indices) for (j in i + 1 until controlBounds.size) {
+ val a = controlBounds[i]
+ val b = controlBounds[j]
+ assertTrue("Controls overlap", a.right <= b.left + 1f || b.right <= a.left + 1f)
+ }
+ if (width == 600f) assertEquals("Wide titles stay screen-centered", bar.center.x, titleBounds.center.x, 1.5f)
+ }
+ }
+ controls.forEach { compose.onNodeWithContentDescription(it).performClick() }
+ compose.runOnIdle { assertEquals(4, clicks) }
+ }
+
+ @Test
+ fun searchTextWidthDoesNotJumpWhenTheClearControlAppears() {
+ val query = mutableStateOf("")
+ render { ToolBoxSearchField(query.value, { query.value = it }, "搜索工具", Modifier.testTag("search")) }
+ for (layoutDirection in listOf(LayoutDirection.Ltr, LayoutDirection.Rtl)) {
+ compose.runOnIdle { direction.value = layoutDirection; query.value = "" }
+ val empty = compose.onNodeWithContentDescription("搜索工具").fetchSemanticsNode().boundsInRoot
+ val beforeHeight = bounds("search").height
+ compose.onNodeWithContentDescription("清空搜索工具").assertDoesNotExist()
+ compose.runOnIdle { query.value = "RSS" }
+ val filled = compose.onNodeWithContentDescription("搜索工具").fetchSemanticsNode().boundsInRoot
+ assertEquals(empty.left, filled.left, 1f)
+ assertEquals(empty.right, filled.right, 1f)
+ assertEquals(beforeHeight, bounds("search").height, 1f)
+ compose.onNodeWithContentDescription("清空搜索工具")
+ .assertHeightIsAtLeast(48.dp).assertWidthIsAtLeast(48.dp).performClick()
+ compose.onNodeWithContentDescription("搜索工具").assertIsFocused()
+ compose.onNodeWithContentDescription("清空搜索工具").assertDoesNotExist()
+ }
+ }
+
+ @Test
+ fun blankSummaryDoesNotCreateAnEmptyLineOrClickableInformationRow() {
+ val summary = mutableStateOf(null)
+ render { ToolBoxSettingRow("存储信息", summary = summary.value, modifier = Modifier.testTag("information")) }
+ val before = bounds("information").height
+ compose.onNodeWithTag("information").assertHasNoClickAction()
+ compose.runOnIdle { summary.value = " \n " }
+ assertEquals(before, bounds("information").height, 1f)
+ compose.onNodeWithTag("information").assertHasNoClickAction()
+ }
+
+ @Test
+ fun disclosureCanBeRetargetedBeforeItsAnimationFinishes() {
+ val expanded = mutableStateOf(false)
+ render {
+ ToolBoxDisclosureRow("高级设置", expanded.value, { expanded.value = !expanded.value }, Modifier.testTag("disclosure"))
+ }
+ compose.mainClock.autoAdvance = false
+ val row = compose.onNodeWithTag("disclosure")
+ row.performClick().assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "已展开"))
+ compose.mainClock.advanceTimeBy(32)
+ row.performClick().assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "已折叠"))
+ compose.mainClock.advanceTimeBy(32)
+ row.performClick().assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "已展开"))
+ compose.mainClock.advanceTimeBy(200)
+ compose.mainClock.autoAdvance = true
+ }
+
+ @Test
+ fun navigationRemainsSelectableWhenItemsReorderAndFontSizeChanges() {
+ val items = mutableStateOf(listOf(
+ ToolBoxNavigationItem("tools", "工具", ToolBoxIconKey.Tools, "tab-tools"),
+ ToolBoxNavigationItem("recent", "最近", ToolBoxIconKey.Clock, "tab-recent"),
+ ToolBoxNavigationItem("settings", "设置", ToolBoxIconKey.Settings, "tab-settings"),
+ ))
+ val selectedId = mutableStateOf("tools")
+ render { ToolBoxNavigationBar(items.value, selectedId.value, { selectedId.value = it.id }) }
+ compose.onNodeWithTag("tab-settings").performClick().assertIsSelected()
+ compose.runOnIdle {
+ items.value = items.value.reversed()
+ fontScale.floatValue = 1.6f
+ direction.value = LayoutDirection.Rtl
+ }
+ compose.onNodeWithTag("tab-settings").assertIsSelected()
+ compose.onNodeWithTag("tab-tools").assertHeightIsAtLeast(48.dp).assertWidthIsAtLeast(48.dp)
+ .performClick().assertIsSelected()
+ compose.onNodeWithTag("tab-settings").assertIsNotSelected()
+ }
+
+ private fun bounds(tag: String): Rect = compose.onNodeWithTag(tag).assertIsDisplayed().fetchSemanticsNode().boundsInRoot
+
+ private fun assertContained(inner: Rect, outer: Rect) {
+ assertTrue("Content escaped the left edge", inner.left >= outer.left - 1f)
+ assertTrue("Content escaped the right edge", inner.right <= outer.right + 1f)
+ assertTrue("Content escaped the top edge", inner.top >= outer.top - 1f)
+ assertTrue("Content escaped the bottom edge", inner.bottom <= outer.bottom + 1f)
+ }
+
+ companion object {
+ @JvmStatic @Parameterized.Parameters(name = "{0}-{1}-reduce={2}")
+ fun cases(): List> = buildList {
+ for (style in ToolBoxThemeStyle.entries) for (mode in listOf(ToolBoxThemeMode.Light, ToolBoxThemeMode.Dark)) {
+ add(arrayOf(style, mode, false))
+ if (style == ToolBoxThemeStyle.LiquidGlass) add(arrayOf(style, mode, true))
+ }
+ }
+ }
+}
diff --git a/core-ui/src/main/java/io/toolbox/core/ui/component/ToolBoxDisclosureRow.kt b/core-ui/src/main/java/io/toolbox/core/ui/component/ToolBoxDisclosureRow.kt
index 91bb9e40..0fe7b44f 100644
--- a/core-ui/src/main/java/io/toolbox/core/ui/component/ToolBoxDisclosureRow.kt
+++ b/core-ui/src/main/java/io/toolbox/core/ui/component/ToolBoxDisclosureRow.kt
@@ -1,5 +1,8 @@
package io.toolbox.core.ui.component
+import androidx.compose.animation.core.LinearOutSlowInEasing
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -10,7 +13,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
@@ -32,6 +35,11 @@ fun ToolBoxDisclosureRow(
val spacing = ToolBoxThemeTokens.spacing
val colors = ToolBoxThemeTokens.colors
val rotation = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -90f else 90f
+ val angle = animateFloatAsState(
+ targetValue = if (expanded) rotation else 0f,
+ animationSpec = tween(140, easing = LinearOutSlowInEasing),
+ label = "disclosure direction",
+ )
Row(
modifier = modifier
.fillMaxWidth()
@@ -69,7 +77,7 @@ fun ToolBoxDisclosureRow(
ToolBoxIcon(
icon = ToolBoxIconKey.ChevronRight,
contentDescription = null,
- modifier = Modifier.rotate(if (expanded) rotation else 0f),
+ modifier = Modifier.graphicsLayer { rotationZ = angle.value },
)
}
}
diff --git a/core-ui/src/main/java/io/toolbox/core/ui/component/ToolBoxLayout.kt b/core-ui/src/main/java/io/toolbox/core/ui/component/ToolBoxLayout.kt
index 28d162d1..67688d10 100644
--- a/core-ui/src/main/java/io/toolbox/core/ui/component/ToolBoxLayout.kt
+++ b/core-ui/src/main/java/io/toolbox/core/ui/component/ToolBoxLayout.kt
@@ -1,5 +1,8 @@
package io.toolbox.core.ui.component
+import androidx.compose.animation.core.LinearOutSlowInEasing
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
@@ -8,6 +11,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@@ -19,6 +23,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
@@ -38,12 +44,14 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
@@ -53,8 +61,10 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import io.toolbox.core.ui.theme.ToolBoxThemeTokens
import io.toolbox.core.ui.theme.ToolBoxThemeStyle
@@ -152,25 +162,77 @@ fun ToolBoxTopBar(
glassState: ToolBoxGlassState? = null,
actions: @Composable RowScope.() -> Unit = {},
) {
- Box(
- modifier = modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.statusBars)
- .heightIn(min = 60.dp).padding(horizontal = 16.dp, vertical = 6.dp),
- contentAlignment = Alignment.Center,
- ) {
- Column(Modifier.fillMaxWidth().padding(horizontal = 56.dp), horizontalAlignment = Alignment.CenterHorizontally) {
- ToolBoxText(title, style = ToolBoxThemeTokens.textStyles.title.copy(
- fontSize = 18.sp, lineHeight = 24.sp, textAlign = TextAlign.Center,
- color = ToolBoxThemeTokens.colors.textPrimary))
- if (subtitle.isNotBlank()) ToolBoxText(subtitle, style = ToolBoxThemeTokens.textStyles.metadata.copy(
- textAlign = TextAlign.Center, color = ToolBoxThemeTokens.colors.textSecondary))
- }
- Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
- if (navigationIcon != null && onNavigationClick != null) {
- ToolBoxIconButton(navigationIcon, navigationContentDescription, onNavigationClick,
- modifier = Modifier.toolBoxOpenDesignSurface(CircleShape), tint = ToolBoxThemeTokens.colors.primary)
+ val colors = ToolBoxThemeTokens.colors
+ val titleLines = if (LocalDensity.current.fontScale >= 1.3f) 2 else 1
+ Layout(
+ modifier = modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(
+ WindowInsets.statusBars.union(
+ WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal),
+ ),
+ )
+ .heightIn(min = 60.dp)
+ .padding(horizontal = 16.dp, vertical = 6.dp),
+ content = {
+ Box {
+ if (navigationIcon != null && onNavigationClick != null) {
+ ToolBoxIconButton(
+ navigationIcon, navigationContentDescription, onNavigationClick,
+ modifier = Modifier.toolBoxOpenDesignSurface(CircleShape),
+ tint = colors.primary,
+ )
+ }
}
- Spacer(Modifier.weight(1f))
- actions()
+ Row(verticalAlignment = Alignment.CenterVertically, content = actions)
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ ToolBoxText(
+ title,
+ style = ToolBoxThemeTokens.textStyles.title.copy(
+ fontSize = 18.sp, lineHeight = 24.sp,
+ textAlign = TextAlign.Center, color = colors.textPrimary,
+ ),
+ maxLines = titleLines,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (subtitle.isNotBlank()) {
+ ToolBoxText(
+ subtitle,
+ style = ToolBoxThemeTokens.textStyles.metadata.copy(
+ textAlign = TextAlign.Center, color = colors.textSecondary,
+ ),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ },
+ ) { measurables, constraints ->
+ val loose = constraints.copy(minWidth = 0, minHeight = 0)
+ val navigation = measurables[0].measure(loose)
+ val actionsPlaceable = measurables[1].measure(
+ loose.copy(maxWidth = (constraints.maxWidth - navigation.width).coerceAtLeast(0)),
+ )
+ val gap = 8.dp.roundToPx()
+ val sideWidth = maxOf(navigation.width, actionsPlaceable.width)
+ val centeredWidth = (constraints.maxWidth - 2 * (sideWidth + gap)).coerceAtLeast(0)
+ // Stay screen-centered when possible; on narrow screens use the space
+ // between the actual controls instead of letting the title draw under them.
+ val centered = centeredWidth >= 80.dp.roundToPx()
+ val titleWidth = if (centered) centeredWidth else {
+ (constraints.maxWidth - navigation.width - actionsPlaceable.width - 2 * gap).coerceAtLeast(0)
+ }
+ val titlePlaceable = measurables[2].measure(loose.copy(minWidth = titleWidth, maxWidth = titleWidth))
+ val height = maxOf(navigation.height, actionsPlaceable.height, titlePlaceable.height)
+ .coerceIn(constraints.minHeight, constraints.maxHeight)
+ val titleStart = if (centered) sideWidth + gap else navigation.width + gap
+ layout(constraints.maxWidth, height) {
+ navigation.placeRelative(0, (height - navigation.height) / 2)
+ actionsPlaceable.placeRelative(
+ constraints.maxWidth - actionsPlaceable.width,
+ (height - actionsPlaceable.height) / 2,
+ )
+ titlePlaceable.placeRelative(titleStart, (height - titlePlaceable.height) / 2)
}
}
}
@@ -276,14 +338,15 @@ fun ToolBoxNavigationBar(
},
) {
items.forEach { item ->
- val isSelected = item.id == selectedId
- NavigationBarItem(
- selected = isSelected,
- onClick = { onItemSelected(item) },
- icon = item.icon.asImageVector(),
- label = item.label,
- modifier = item.testTag?.let(Modifier::testTag) ?: Modifier,
- )
+ key(item.id) {
+ NavigationBarItem(
+ selected = item.id == selectedId,
+ onClick = { onItemSelected(item) },
+ icon = item.icon.asImageVector(),
+ label = item.label,
+ modifier = item.testTag?.let(Modifier::testTag) ?: Modifier,
+ )
+ }
}
}
}
@@ -318,18 +381,19 @@ private fun LiquidGlassNavigationBar(
.height(58.dp),
) {
val itemWidth = maxWidth / items.size
- val selectedIndex = items.indexOfFirst { it.id == selectedId }.coerceAtLeast(0)
- val selectionOffset by animateDpAsState(
- targetValue = itemWidth * selectedIndex,
- animationSpec = tween(170),
+ val selectedIndex = items.indexOfFirst { it.id == selectedId }
+ val selectionOffset = animateDpAsState(
+ targetValue = itemWidth * selectedIndex.coerceAtLeast(0),
+ animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium),
label = "navigation selection lens",
)
val materials = ToolBoxThemeTokens.materials
val selectionShape = RoundedCornerShape(20.dp)
- if (materials.navigationLensEnabled) {
+ if (materials.navigationLensEnabled && selectedIndex >= 0) {
Box(
Modifier
- .offset(x = selectionOffset)
+ // Read animation state in placement, not composition. offset also mirrors in RTL.
+ .offset { IntOffset(selectionOffset.value.roundToPx(), 0) }
.width(itemWidth)
.fillMaxHeight()
.padding(horizontal = 5.dp, vertical = 5.dp)
@@ -347,15 +411,17 @@ private fun LiquidGlassNavigationBar(
)
}
Row(
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier.fillMaxSize().selectableGroup(),
verticalAlignment = Alignment.CenterVertically,
) {
items.forEach { item ->
- LiquidGlassNavigationItem(
- item = item,
- selected = item.id == selectedId,
- onClick = { onItemSelected(item) },
- )
+ key(item.id) {
+ LiquidGlassNavigationItem(
+ item = item,
+ selected = item.id == selectedId,
+ onClick = { onItemSelected(item) },
+ )
+ }
}
}
}
@@ -370,9 +436,9 @@ private fun RowScope.LiquidGlassNavigationItem(
val interactionSource = remember { MutableInteractionSource() }
val pressed by interactionSource.collectIsPressedAsState()
val pressFeedbackEnabled = ToolBoxThemeTokens.materials.pressFeedbackEnabled
- val contentScale by animateFloatAsState(
- targetValue = if (pressed && pressFeedbackEnabled) 0.92f else 1f,
- animationSpec = tween(160),
+ val contentScale = animateFloatAsState(
+ targetValue = if (pressed && pressFeedbackEnabled) 0.97f else 1f,
+ animationSpec = tween(if (pressed) 100 else 140, easing = LinearOutSlowInEasing),
label = "navigation item press",
)
val colors = ToolBoxThemeTokens.colors
@@ -382,7 +448,7 @@ private fun RowScope.LiquidGlassNavigationItem(
listOf(ToolBoxThemeTokens.materials.navigationSelectionTint, colors.surface),
)
} else colors.textSecondary
- Row(
+ Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
@@ -394,30 +460,36 @@ private fun RowScope.LiquidGlassNavigationItem(
onClick = onClick,
)
.then(item.testTag?.let(Modifier::testTag) ?: Modifier)
- .semantics { contentDescription = item.label }
- .graphicsLayer {
- scaleX = contentScale
- scaleY = contentScale
- },
- horizontalArrangement = Arrangement.Center,
- verticalAlignment = Alignment.CenterVertically,
+ .semantics { contentDescription = item.label },
+ contentAlignment = Alignment.Center,
) {
- ToolBoxIcon(
- icon = item.icon,
- contentDescription = null,
- tint = color,
- modifier = Modifier.size(18.dp),
- )
- if (LocalDensity.current.fontScale < 1.5f) {
- Spacer(Modifier.width(7.dp))
- ToolBoxText(
- text = item.label,
- style = ToolBoxThemeTokens.textStyles.label.copy(
- color = color,
- fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
- ),
- maxLines = 1,
+ // Only the visual content scales; the tab's hit target stays full-sized.
+ Row(
+ modifier = Modifier.graphicsLayer {
+ scaleX = contentScale.value
+ scaleY = contentScale.value
+ },
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ToolBoxIcon(
+ icon = item.icon,
+ contentDescription = null,
+ tint = color,
+ modifier = Modifier.size(18.dp),
)
+ if (LocalDensity.current.fontScale < 1.5f) {
+ Spacer(Modifier.width(7.dp))
+ ToolBoxText(
+ text = item.label,
+ style = ToolBoxThemeTokens.textStyles.label.copy(
+ color = color,
+ fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
+ ),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
}
}
}
diff --git a/core-ui/src/main/java/io/toolbox/core/ui/component/ToolBoxRows.kt b/core-ui/src/main/java/io/toolbox/core/ui/component/ToolBoxRows.kt
index 28221e67..4210c16a 100644
--- a/core-ui/src/main/java/io/toolbox/core/ui/component/ToolBoxRows.kt
+++ b/core-ui/src/main/java/io/toolbox/core/ui/component/ToolBoxRows.kt
@@ -1,12 +1,14 @@
package io.toolbox.core.ui.component
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Arrangement
@@ -31,6 +33,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.semantics.Role
@@ -43,6 +46,7 @@ import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.toolbox.core.ui.theme.ToolBoxThemeTokens
import top.yukonga.miuix.kmp.preference.ArrowPreference
@@ -60,17 +64,26 @@ fun ToolBoxSearchField(
) {
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
+ val colors = ToolBoxThemeTokens.colors
+ val spacing = ToolBoxThemeTokens.spacing
+ val shape = RoundedCornerShape(ToolBoxThemeTokens.radii.control)
+ var focused by remember { mutableStateOf(false) }
Row(
modifier = modifier.fillMaxWidth().heightIn(min = toolBoxSearchFieldMinHeight())
- .toolBoxOpenDesignSurface(RoundedCornerShape(ToolBoxThemeTokens.radii.control))
- .padding(start = 13.dp, end = 5.dp),
+ .toolBoxOpenDesignSurface(shape)
+ .border(1.dp, if (focused) colors.primary.copy(alpha = 0.55f) else Color.Transparent, shape)
+ .padding(start = spacing.oneHalf, end = spacing.half),
verticalAlignment = Alignment.CenterVertically,
) {
- ToolBoxIcon(ToolBoxIconKey.Search, null, Modifier.size(18.dp))
- Spacer(Modifier.width(8.dp))
+ ToolBoxIcon(
+ ToolBoxIconKey.Search, null, Modifier.size(18.dp),
+ tint = if (focused) colors.primary else colors.textSecondary,
+ )
+ Spacer(Modifier.width(spacing.one))
BasicTextField(
value = value, onValueChange = onValueChange, singleLine = true,
- modifier = Modifier.weight(1f).padding(vertical = 10.dp).focusRequester(focusRequester)
+ modifier = Modifier.weight(1f).padding(vertical = spacing.row).focusRequester(focusRequester)
+ .onFocusChanged { focused = it.isFocused }
.semantics { this.contentDescription = contentDescription },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }),
@@ -78,18 +91,28 @@ fun ToolBoxSearchField(
cursorBrush = SolidColor(ToolBoxThemeTokens.colors.primary),
decorationBox = { field ->
Box {
- if (value.isEmpty()) ToolBoxText(placeholder, style = ToolBoxThemeTokens.textStyles.body.copy(
- color = ToolBoxThemeTokens.colors.textSecondary))
+ if (value.isEmpty()) {
+ ToolBoxText(
+ placeholder,
+ style = ToolBoxThemeTokens.textStyles.body.copy(color = colors.textSecondary),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
field()
}
},
)
- if (value.isNotEmpty()) {
- ToolBoxIconButton(ToolBoxIconKey.Close, "清空${contentDescription}", onClick = {
- onValueChange("")
- focusRequester.requestFocus()
- })
- } else Spacer(Modifier.width(8.dp))
+ // Reserve the same space in both states so the first keystroke does not
+ // move the text or cursor. The empty slot has no hidden click semantics.
+ Box(Modifier.size(ToolBoxThemeTokens.sizes.touchTarget), contentAlignment = Alignment.Center) {
+ if (value.isNotEmpty()) {
+ ToolBoxIconButton(ToolBoxIconKey.Close, "清空${contentDescription}", onClick = {
+ onValueChange("")
+ focusRequester.requestFocus()
+ })
+ }
+ }
}
}
@@ -104,22 +127,34 @@ fun ToolBoxSettingRow(
onClick: (() -> Unit)? = null,
enabled: Boolean = true,
) {
+ val spacing = ToolBoxThemeTokens.spacing
+ val colors = ToolBoxThemeTokens.colors
Row(
modifier = modifier.fillMaxWidth().heightIn(min = if (summary.isNullOrBlank()) 56.dp else 64.dp)
.then(if (onClick != null) Modifier.clickable(enabled = enabled, role = Role.Button, onClick = onClick) else Modifier)
- .padding(horizontal = 13.dp, vertical = 10.dp),
+ .padding(horizontal = spacing.oneHalf, vertical = spacing.row),
verticalAlignment = Alignment.CenterVertically,
) {
- icon?.let { ToolBoxPreferenceIcon(it, enabled); Spacer(Modifier.width(12.dp)) }
- Column(Modifier.weight(1f)) {
+ icon?.let {
+ ToolBoxPreferenceIcon(it, enabled)
+ Spacer(Modifier.width(spacing.oneHalf))
+ }
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(spacing.half)) {
ToolBoxText(title, style = ToolBoxThemeTokens.textStyles.title.copy(
- color = if (enabled) ToolBoxThemeTokens.colors.textPrimary else ToolBoxThemeTokens.disabledContent))
- summary?.let { ToolBoxText(it, style = ToolBoxThemeTokens.textStyles.metadata.copy(
- color = if (enabled) ToolBoxThemeTokens.colors.textSecondary else ToolBoxThemeTokens.disabledContent)) }
+ color = if (enabled) colors.textPrimary else ToolBoxThemeTokens.disabledContent))
+ summary?.takeIf(String::isNotBlank)?.let {
+ ToolBoxText(it, style = ToolBoxThemeTokens.textStyles.metadata.copy(
+ color = if (enabled) colors.textSecondary else ToolBoxThemeTokens.disabledContent))
+ }
+ }
+ // A disclosure indicator promises navigation: informational rows must not show one.
+ if (onClick != null) {
+ Spacer(Modifier.width(spacing.one))
+ ToolBoxIcon(
+ ToolBoxIconKey.ChevronRight, null, Modifier.size(18.dp),
+ tint = if (enabled) colors.textSecondary else ToolBoxThemeTokens.disabledContent,
+ )
}
- Spacer(Modifier.width(8.dp))
- ToolBoxIcon(ToolBoxIconKey.ChevronRight, null, Modifier.size(18.dp),
- tint = if (enabled) ToolBoxThemeTokens.colors.textSecondary else ToolBoxThemeTokens.disabledContent)
}
}
@@ -204,11 +239,11 @@ fun ToolBoxValueRow(
@Composable
private fun ToolBoxValueLabel(title: String, summary: String?, modifier: Modifier = Modifier) {
- Column(modifier) {
+ Column(modifier, verticalArrangement = Arrangement.spacedBy(ToolBoxThemeTokens.spacing.half)) {
ToolBoxText(title, style = ToolBoxThemeTokens.textStyles.title.copy(
color = ToolBoxThemeTokens.colors.textPrimary, fontWeight = FontWeight.Medium,
))
- summary?.let {
+ summary?.takeIf(String::isNotBlank)?.let {
ToolBoxText(it, style = ToolBoxThemeTokens.textStyles.metadata.copy(color = ToolBoxThemeTokens.colors.textSecondary))
}
}
From 91d01905ed6bb9f1fac305985f71c0477768c8bb Mon Sep 17 00:00:00 2001
From: DoraC <25716974+gkeyes@users.noreply.github.com>
Date: Tue, 22 Sep 2026 10:11:11 +0800
Subject: [PATCH 04/19] test(ui): synchronize disclosure behavior with the
Compose clock
Settle initial content before freezing the clock, advance a frame after each
click before checking semantics, and restore clock state in finally.
Preserve all theme cases and rapid retargeting assertions; additionally
verify that all three click events are delivered and the final state holds.
Fixes the six disclosure test failures in Android CI run 35676762827.
---
.../toolbox/host/DesignPolishBehaviorTest.kt | 50 +++++++++++++++----
1 file changed, 41 insertions(+), 9 deletions(-)
diff --git a/app/src/androidTest/kotlin/io/toolbox/host/DesignPolishBehaviorTest.kt b/app/src/androidTest/kotlin/io/toolbox/host/DesignPolishBehaviorTest.kt
index 59477d74..9914068e 100644
--- a/app/src/androidTest/kotlin/io/toolbox/host/DesignPolishBehaviorTest.kt
+++ b/app/src/androidTest/kotlin/io/toolbox/host/DesignPolishBehaviorTest.kt
@@ -139,18 +139,50 @@ class DesignPolishBehaviorTest(
@Test
fun disclosureCanBeRetargetedBeforeItsAnimationFinishes() {
val expanded = mutableStateOf(false)
+ var clicks = 0
render {
- ToolBoxDisclosureRow("高级设置", expanded.value, { expanded.value = !expanded.value }, Modifier.testTag("disclosure"))
+ ToolBoxDisclosureRow(
+ title = "高级设置",
+ expanded = expanded.value,
+ onClick = {
+ clicks++
+ expanded.value = !expanded.value
+ },
+ modifier = Modifier.testTag("disclosure"),
+ )
}
- compose.mainClock.autoAdvance = false
+ // activity.setContent schedules composition; settle it before freezing the clock.
+ compose.waitForIdle()
val row = compose.onNodeWithTag("disclosure")
- row.performClick().assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "已展开"))
- compose.mainClock.advanceTimeBy(32)
- row.performClick().assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "已折叠"))
- compose.mainClock.advanceTimeBy(32)
- row.performClick().assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "已展开"))
- compose.mainClock.advanceTimeBy(200)
- compose.mainClock.autoAdvance = true
+ row.assertIsDisplayed().assertHasClickAction()
+ .assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "已折叠"))
+
+ val previousAutoAdvance = compose.mainClock.autoAdvance
+ compose.mainClock.autoAdvance = false
+ try {
+ row.performClick()
+ // A paused clock also pauses recomposition, not just the arrow animation.
+ compose.mainClock.advanceTimeByFrame()
+ row.assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "已展开"))
+ compose.mainClock.advanceTimeBy(32)
+
+ row.performClick()
+ compose.mainClock.advanceTimeByFrame()
+ row.assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "已折叠"))
+ compose.mainClock.advanceTimeBy(32)
+
+ row.performClick()
+ compose.mainClock.advanceTimeByFrame()
+ row.assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "已展开"))
+ compose.mainClock.advanceTimeBy(200)
+ row.assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "已展开"))
+ } finally {
+ compose.mainClock.autoAdvance = previousAutoAdvance
+ }
+ compose.runOnIdle {
+ assertEquals(3, clicks)
+ assertTrue(expanded.value)
+ }
}
@Test
From 09dd867350d022a1e96c99d6c12a74051ec7635d Mon Sep 17 00:00:00 2001
From: DoraC <25716974+gkeyes@users.noreply.github.com>
Date: Tue, 22 Sep 2026 10:40:36 +0800
Subject: [PATCH 05/19] ci: stop emulator UI tests on UI-polish branch at user
request
---
.github/workflows/android.yml | 33 ++++++++++++++++++++++++++-------
1 file changed, 26 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 41d51b21..dd7b20fc 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -61,17 +61,33 @@ jobs:
verification_scope: ${{ steps.baseline.outputs.scope }}
verification_run: ${{ steps.baseline.outputs.source_run }}
verification_commit: ${{ steps.baseline.outputs.base_commit }}
- verification_checks: ${{ steps.baseline.outputs.checks }}
+ verification_checks: ${{ steps.android_ui.outputs.enabled == 'false' && 'build_and_unit_only' || steps.baseline.outputs.checks }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
with:
fetch-depth: 0
persist-credentials: false
+ # User-requested waiver for this UI-polish branch only. A launcher ANR in
+ # run 35678543276 stole focus; do not represent skipped UI tests as passed.
+ - name: Select branch Android UI test policy
+ id: android_ui
+ env:
+ SKIP_ANDROID_UI: ${{ (github.event_name == 'pull_request' && github.event.number == 22 && github.head_ref == 'codex/ui-polish-emil-20260922' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/codex/ui-polish-emil-20260922') }}
+ run: |
+ if [ "$SKIP_ANDROID_UI" = true ]; then
+ echo 'enabled=false' >> "$GITHUB_OUTPUT"
+ printf '%s\n' '## Android UI tests intentionally disabled' \
+ 'User-requested waiver for the UI-polish branch after the emulator launcher ANR in run 35678543276.' \
+ 'APKs and host unit checks still run. Android UI tests are NOT RUN, not passed; this run cannot be reused as full host-verification evidence.' \
+ 'Default-branch checks and release signing are unchanged.' >> "$GITHUB_STEP_SUMMARY"
+ else
+ echo 'enabled=true' >> "$GITHUB_OUTPUT"
+ fi
- name: Select current or unchanged host verification evidence
id: baseline
env:
GH_TOKEN: ${{ github.token }}
- REUSE_VERIFIED_RUN: ${{ inputs.reuse_verified_run }}
+ REUSE_VERIFIED_RUN: ${{ steps.android_ui.outputs.enabled == 'true' && inputs.reuse_verified_run || '' }}
VALIDATION_SCOPE: ${{ inputs.validation_scope || 'full' }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: python3 scripts/ci/reuse-host-verification.py
@@ -97,12 +113,15 @@ jobs:
- name: Verify Wasm binary packaging
if: ${{ steps.baseline.outputs.scope == 'current_run' && steps.baseline.outputs.checks == 'full' }}
run: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/tests -p 'test_package_tool.py' -v
- - name: ${{ steps.baseline.outputs.checks == 'performance' && 'Compile performance test APK' || 'Build APKs and behavioral test APKs' }}
+ - name: ${{ steps.android_ui.outputs.enabled == 'false' && 'Build APKs (Android UI tests disabled)' || (steps.baseline.outputs.checks == 'performance' && 'Compile performance test APK' || 'Build APKs and behavioral test APKs') }}
if: ${{ steps.baseline.outputs.scope == 'current_run' }}
env:
VALIDATION_SCOPE: ${{ steps.baseline.outputs.checks }}
+ SKIP_ANDROID_UI: ${{ steps.android_ui.outputs.enabled == 'false' }}
run: |
- if [ "$VALIDATION_SCOPE" = performance ]; then
+ if [ "$SKIP_ANDROID_UI" = true ]; then
+ ./gradlew --no-daemon :app:assembleDebug :app:assembleRelease
+ elif [ "$VALIDATION_SCOPE" = performance ]; then
./gradlew --no-daemon :app:assembleDebug :app:compileDebugAndroidTestKotlin
else
./gradlew --no-daemon :app:assembleDebug :app:assembleRelease :app:compileDebugAndroidTestKotlin :tool-runtime:compileDebugAndroidTestKotlin
@@ -121,13 +140,13 @@ jobs:
./gradlew --no-daemon :core-data:testDebugUnitTest :tool-package:testDebugUnitTest :tool-runtime:testDebugUnitTest :app:testDebugUnitTest
fi
- name: Enable accelerated behavior test emulator
- if: ${{ steps.baseline.outputs.scope == 'current_run' }}
+ if: ${{ steps.baseline.outputs.scope == 'current_run' && steps.android_ui.outputs.enabled == 'true' }}
run: |
printf '%s\n' 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: ${{ steps.baseline.outputs.checks == 'performance' && 'Verify performance behavior on Android' || 'Verify catalog, backup, execution identity, dialogs and Wasm on Android' }}
- if: ${{ steps.baseline.outputs.scope == 'current_run' }}
+ if: ${{ steps.baseline.outputs.scope == 'current_run' && steps.android_ui.outputs.enabled == 'true' }}
id: runtime_emulator
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
env:
@@ -161,7 +180,7 @@ jobs:
if-no-files-found: ignore
retention-days: 14
- name: Retain Android behavior and WebView evidence
- if: ${{ always() && steps.baseline.outputs.scope == 'current_run' && steps.runtime_emulator.outcome != 'skipped' }}
+ if: ${{ always() && steps.baseline.outputs.scope == 'current_run' && steps.android_ui.outputs.enabled == 'true' && steps.runtime_emulator.outcome != 'skipped' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: wasm-webview-${{ github.sha }}
From 6567e953a726ae7bcde93bef998a2ac029cd6e5f Mon Sep 17 00:00:00 2001
From: DoraC <25716974+gkeyes@users.noreply.github.com>
Date: Tue, 22 Sep 2026 11:13:13 +0800
Subject: [PATCH 06/19] ci: deliver approved UI-polish APK with original
release signature
---
.github/workflows/ui-polish-signed.yml | 184 +++++++++++++++++++++++++
1 file changed, 184 insertions(+)
create mode 100644 .github/workflows/ui-polish-signed.yml
diff --git a/.github/workflows/ui-polish-signed.yml b/.github/workflows/ui-polish-signed.yml
new file mode 100644
index 00000000..52e22a55
--- /dev/null
+++ b/.github/workflows/ui-polish-signed.yml
@@ -0,0 +1,184 @@
+name: UI polish APK - original release signature
+
+on:
+ push:
+ branches: ["codex/ui-polish-emil-20260922"]
+ paths: [".github/workflows/ui-polish-signed.yml"]
+
+permissions:
+ contents: read
+ actions: read
+
+concurrency:
+ group: ui-polish-original-signature
+ cancel-in-progress: false
+
+jobs:
+ sign:
+ name: Sign the successful UI-polish Release APK
+ if: ${{ github.repository == 'gkeyes/ToolBox-Android' && github.event_name == 'push' && github.ref == 'refs/heads/codex/ui-polish-emil-20260922' }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ env:
+ SOURCE_RUN: "35680323971"
+ SOURCE_ARTIFACT: "10674187065"
+ SOURCE_HEAD: "09dd867350d022a1e96c99d6c12a74051ec7635d"
+ SOURCE_BUILD: "8b807ef395057954b73b6e6509eab8ff80786991"
+ SOURCE_ZIP_SHA256: "0be51ecefed54f1a83d501a5cdd73ed4541140f0931f7ed0a7266597929821a6"
+ SOURCE_APK_SHA256: "5bad9a1b881eb46881069567280419d4165ff0ca8a38bdae145db6b4590634bf"
+ REFERENCE_APK_SHA256: "193bb200934c5e04138166e071b1161f1500a530236d053e08bce22373092e3a"
+ RELEASE_CERT_SHA256: "849be1fd8066964d6194faa538fc0b17b4b17e5c116304e1d3be8ff725e4c71d"
+ steps:
+ # This is a one-source signing job, not a PR job or a rebuild. No repository
+ # scripts execute with the signing key. UI tests remain explicitly NOT RUN.
+ - name: Download and pin the approved APK and original release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p input delivery
+ gh api "repos/$GITHUB_REPOSITORY/actions/runs/$SOURCE_RUN" > input/run.json
+ gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$SOURCE_ARTIFACT" > input/artifact.json
+ gh api "repos/$GITHUB_REPOSITORY/compare/$SOURCE_HEAD...$GITHUB_SHA" > input/compare.json
+ gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$SOURCE_ARTIFACT/zip" > input/source.zip
+ curl --fail --location --proto '=https' --retry 3 \
+ 'https://github.com/gkeyes/ToolBox-Android/releases/download/v0.8.5/toolbox-v0.8.5-release.apk' \
+ --output input/reference.apk
+ python3 - <<'PY'
+ import hashlib, json, os, pathlib, zipfile
+ p = pathlib.Path('input')
+ run = json.loads((p / 'run.json').read_text())
+ artifact = json.loads((p / 'artifact.json').read_text())
+ diff = json.loads((p / 'compare.json').read_text())
+ assert run['id'] == int(os.environ['SOURCE_RUN'])
+ assert run['status'] == 'completed' and run['conclusion'] == 'success'
+ assert run['event'] == 'pull_request' and run['workflow_id'] == 343747488
+ assert run['head_sha'] == os.environ['SOURCE_HEAD']
+ assert run['head_branch'] == 'codex/ui-polish-emil-20260922'
+ assert run['head_repository']['full_name'] == os.environ['GITHUB_REPOSITORY']
+ assert artifact['id'] == int(os.environ['SOURCE_ARTIFACT']) and not artifact['expired']
+ assert artifact['workflow_run']['id'] == run['id']
+ assert artifact['workflow_run']['head_sha'] == os.environ['SOURCE_HEAD']
+ assert artifact['name'] == 'apk-' + os.environ['SOURCE_BUILD']
+ assert artifact['digest'] == 'sha256:' + os.environ['SOURCE_ZIP_SHA256']
+ assert diff['status'] == 'ahead' and diff['merge_base_commit']['sha'] == os.environ['SOURCE_HEAD']
+ assert [f['filename'] for f in diff['files']] == ['.github/workflows/ui-polish-signed.yml'], 'App source changed; approve a new build before signing'
+ for name, key in [('source.zip', 'SOURCE_ZIP_SHA256'), ('reference.apk', 'REFERENCE_APK_SHA256')]:
+ assert hashlib.sha256((p / name).read_bytes()).hexdigest() == os.environ[key], name
+ with zipfile.ZipFile(p / 'source.zip') as z:
+ name = 'release/app-release-unsigned.apk'
+ assert z.namelist().count(name) == 1 and z.getinfo(name).file_size < 50_000_000
+ apk = z.read(name)
+ metadata = json.loads(z.read('release/output-metadata.json'))
+ assert hashlib.sha256(apk).hexdigest() == os.environ['SOURCE_APK_SHA256']
+ assert metadata['applicationId'] == 'io.toolbox.host' and metadata['variantName'] == 'release'
+ assert len(metadata['elements']) == 1
+ assert metadata['elements'][0]['versionCode'] == 38
+ assert metadata['elements'][0]['versionName'] == '0.8.5'
+ (p / 'unsigned.apk').write_bytes(apk)
+ print('Pinned successful Release APK: io.toolbox.host 0.8.5 (38). No rebuild or UI tests.')
+ PY
+ - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961
+ with:
+ distribution: temurin
+ java-version: "21"
+ - uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699
+ with:
+ log-accepted-android-sdk-licenses: false
+ packages: build-tools;37.0.0
+ - name: Align and sign with the existing release key only
+ env:
+ SIGNING_KEYSTORE_BASE64: ${{ secrets.TOOLBOX_ANDROID_KEYSTORE_BASE64 }}
+ TOOLBOX_SIGNING_STORE_PASSWORD: ${{ secrets.TOOLBOX_ANDROID_KEYSTORE_PASSWORD }}
+ TOOLBOX_SIGNING_KEY_ALIAS: ${{ secrets.TOOLBOX_ANDROID_KEY_ALIAS }}
+ TOOLBOX_SIGNING_KEY_PASSWORD: ${{ secrets.TOOLBOX_ANDROID_KEY_PASSWORD }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ : "${SIGNING_KEYSTORE_BASE64:?Original signing key required; no replacement key is allowed}"
+ : "${TOOLBOX_SIGNING_STORE_PASSWORD:?Missing store password}"
+ : "${TOOLBOX_SIGNING_KEY_ALIAS:?Missing key alias}"
+ : "${TOOLBOX_SIGNING_KEY_PASSWORD:?Missing key password}"
+ signing_dir="$RUNNER_TEMP/toolbox-ui-signing"
+ mkdir -p "$signing_dir"
+ chmod 700 "$signing_dir"
+ trap 'rm -rf "$signing_dir"' EXIT
+ printf '%s' "$SIGNING_KEYSTORE_BASE64" | base64 --decode > "$signing_dir/toolbox.p12"
+ chmod 600 "$signing_dir/toolbox.p12"
+ unset SIGNING_KEYSTORE_BASE64
+ keytool -exportcert -keystore "$signing_dir/toolbox.p12" \
+ -storepass:env TOOLBOX_SIGNING_STORE_PASSWORD -alias "$TOOLBOX_SIGNING_KEY_ALIAS" \
+ > "$signing_dir/certificate.der" 2>/dev/null
+ test "$(sha256sum "$signing_dir/certificate.der" | cut -d' ' -f1)" = "$RELEASE_CERT_SHA256"
+ tools="$ANDROID_HOME/build-tools/37.0.0"
+ "$tools/zipalign" -P 16 -f 4 input/unsigned.apk input/aligned.apk
+ "$tools/apksigner" sign --ks "$signing_dir/toolbox.p12" \
+ --ks-key-alias "$TOOLBOX_SIGNING_KEY_ALIAS" \
+ --ks-pass env:TOOLBOX_SIGNING_STORE_PASSWORD --key-pass env:TOOLBOX_SIGNING_KEY_PASSWORD \
+ --v1-signing-enabled false --v2-signing-enabled true --v3-signing-enabled true \
+ --v4-signing-enabled false --out delivery/toolbox-v0.8.5-ui-polish-signed.apk input/aligned.apk
+ - name: Verify actual APK certificates, package, version and unchanged payload
+ shell: bash
+ run: |
+ set -euo pipefail
+ tools="$ANDROID_HOME/build-tools/37.0.0"
+ apk=delivery/toolbox-v0.8.5-ui-polish-signed.apk
+ "$tools/apksigner" verify --verbose --print-certs "$apk" > delivery/SIGNATURE.txt
+ "$tools/apksigner" verify --verbose --print-certs input/reference.apk > delivery/REFERENCE_SIGNATURE.txt
+ "$tools/zipalign" -c -P 16 4 "$apk"
+ "$tools/aapt" dump badging "$apk" > delivery/BADGING.txt
+ "$tools/aapt" dump badging input/reference.apk > input/reference-badging.txt
+ python3 - <<'PY'
+ import hashlib, json, os, pathlib, re, zipfile
+ d = pathlib.Path('delivery')
+ def cert(path):
+ values = re.findall(r'^Signer #\d+ certificate SHA-256 digest: ([0-9a-fA-F]+)$', path.read_text(), re.M)
+ assert len(values) == 1, 'Expected exactly one APK signer'
+ return values[0].lower()
+ actual = cert(d / 'SIGNATURE.txt')
+ assert actual == cert(d / 'REFERENCE_SIGNATURE.txt') == os.environ['RELEASE_CERT_SHA256']
+ def identity(path):
+ text = path.read_text()
+ assert 'application-debuggable' not in text
+ match = re.search(r"package: name='([^']+)' versionCode='(\d+)' versionName='([^']+)'", text)
+ assert match, 'Missing package identity'
+ return match.groups()
+ assert identity(d / 'BADGING.txt') == identity(pathlib.Path('input/reference-badging.txt')) == ('io.toolbox.host', '38', '0.8.5')
+ apk = d / 'toolbox-v0.8.5-ui-polish-signed.apk'
+ with zipfile.ZipFile('input/unsigned.apk') as before, zipfile.ZipFile(apk) as after:
+ assert before.namelist() == after.namelist(), 'Signing must not add or remove app entries'
+ for name in before.namelist():
+ assert before.read(name) == after.read(name), 'App payload changed: ' + name
+ receipt = {
+ 'package': 'io.toolbox.host', 'version_name': '0.8.5', 'version_code': 38,
+ 'source_head': os.environ['SOURCE_HEAD'], 'source_build_commit': os.environ['SOURCE_BUILD'],
+ 'source_run': os.environ['SOURCE_RUN'], 'source_artifact': os.environ['SOURCE_ARTIFACT'],
+ 'source_zip_sha256': os.environ['SOURCE_ZIP_SHA256'], 'unsigned_apk_sha256': os.environ['SOURCE_APK_SHA256'],
+ 'signing_commit': os.environ['GITHUB_SHA'], 'signing_run': os.environ['GITHUB_RUN_ID'],
+ 'reference_release': 'v0.8.5', 'reference_apk_sha256': os.environ['REFERENCE_APK_SHA256'],
+ 'certificate_sha256': actual, 'signed_apk_sha256': hashlib.sha256(apk.read_bytes()).hexdigest(),
+ 'app_payload_unchanged': True, 'certificate_matches_original_release': True,
+ 'android_ui_tests': 'NOT RUN - user-requested waiver', 'device_installation': 'NOT RUN',
+ 'delivery_kind': 'signed UI-polish branch build; not a new official Release',
+ }
+ (d / 'BUILD_RECEIPT.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + '\n')
+ print(json.dumps(receipt, indent=2))
+ PY
+ (cd delivery && sha256sum toolbox-v0.8.5-ui-polish-signed.apk SIGNATURE.txt REFERENCE_SIGNATURE.txt BADGING.txt BUILD_RECEIPT.json > SHA256SUMS.txt)
+ printf '%s\n' '## Original-signature UI-polish APK' \
+ 'Signed the exact Release APK from successful run 35680323971; no rebuild.' \
+ 'Actual APK certificate equals the published v0.8.5 certificate; package io.toolbox.host, version 0.8.5 (38).' \
+ 'All APK entry contents are unchanged. UI tests and device installation: NOT RUN.' \
+ 'No default-branch change, PR merge or public Release publication.' >> "$GITHUB_STEP_SUMMARY"
+ - name: Upload APK and signature evidence
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+ with:
+ name: toolbox-v0.8.5-ui-polish-original-signature
+ path: delivery/
+ if-no-files-found: error
+ retention-days: 30
+ - name: Remove private signing material on all exits
+ if: ${{ always() }}
+ shell: bash
+ run: rm -rf "$RUNNER_TEMP/toolbox-ui-signing"
From 1ad8b4fed5fa72a88649adfca2bc02cfe64a467c Mon Sep 17 00:00:00 2001
From: DoraC <25716974+gkeyes@users.noreply.github.com>
Date: Tue, 22 Sep 2026 11:16:00 +0800
Subject: [PATCH 07/19] ci: parse Build Tools 37 certificate reports and match
release signing scheme
---
.github/workflows/ui-polish-signed.yml | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/ui-polish-signed.yml b/.github/workflows/ui-polish-signed.yml
index 52e22a55..8b60c02f 100644
--- a/.github/workflows/ui-polish-signed.yml
+++ b/.github/workflows/ui-polish-signed.yml
@@ -116,7 +116,7 @@ jobs:
"$tools/apksigner" sign --ks "$signing_dir/toolbox.p12" \
--ks-key-alias "$TOOLBOX_SIGNING_KEY_ALIAS" \
--ks-pass env:TOOLBOX_SIGNING_STORE_PASSWORD --key-pass env:TOOLBOX_SIGNING_KEY_PASSWORD \
- --v1-signing-enabled false --v2-signing-enabled true --v3-signing-enabled true \
+ --v1-signing-enabled false --v2-signing-enabled true --v3-signing-enabled false \
--v4-signing-enabled false --out delivery/toolbox-v0.8.5-ui-polish-signed.apk input/aligned.apk
- name: Verify actual APK certificates, package, version and unchanged payload
shell: bash
@@ -126,6 +126,7 @@ jobs:
apk=delivery/toolbox-v0.8.5-ui-polish-signed.apk
"$tools/apksigner" verify --verbose --print-certs "$apk" > delivery/SIGNATURE.txt
"$tools/apksigner" verify --verbose --print-certs input/reference.apk > delivery/REFERENCE_SIGNATURE.txt
+ cat delivery/SIGNATURE.txt delivery/REFERENCE_SIGNATURE.txt
"$tools/zipalign" -c -P 16 4 "$apk"
"$tools/aapt" dump badging "$apk" > delivery/BADGING.txt
"$tools/aapt" dump badging input/reference.apk > input/reference-badging.txt
@@ -133,9 +134,12 @@ jobs:
import hashlib, json, os, pathlib, re, zipfile
d = pathlib.Path('delivery')
def cert(path):
- values = re.findall(r'^Signer #\d+ certificate SHA-256 digest: ([0-9a-fA-F]+)$', path.read_text(), re.M)
- assert len(values) == 1, 'Expected exactly one APK signer'
- return values[0].lower()
+ text = path.read_text()
+ assert re.search(r'^Number of signers:\s*1\s*$', text, re.M), 'Expected exactly one APK signer'
+ # Build Tools 37 uses V2 Signer instead of the older Signer #1 label.
+ values = {v.lower() for v in re.findall(r'^.*certificate SHA-256 digest:\s*([0-9a-fA-F]{64})\s*$', text, re.M)}
+ assert len(values) == 1, 'Expected one consistent certificate across signing schemes'
+ return values.pop()
actual = cert(d / 'SIGNATURE.txt')
assert actual == cert(d / 'REFERENCE_SIGNATURE.txt') == os.environ['RELEASE_CERT_SHA256']
def identity(path):
From 2f6aae3bd6e9c4e1b88cf37ef7b74f190d1306e2 Mon Sep 17 00:00:00 2001
From: DoraC <25716974+gkeyes@users.noreply.github.com>
Date: Tue, 22 Sep 2026 15:56:09 +0800
Subject: [PATCH 08/19] feat(ui): polish high-frequency catalog and import
flows with lazy management lists
Prioritize favorites, compact catalog sorting and empty states, display verified manifest descriptions through the existing icon cache, open the actual imported tool, share confirmation content without replacing dialog hosts, and emit management rows as keyed lazy items.
Preserve the branch-only Android UI test waiver, original signing workflows, app identity, permissions, package validation, and runtime behavior. Add JVM import-target state regressions; no emulator tests added.
---
.../host/background/BackgroundTasksScreen.kt | 45 +-
.../host/icons/InstalledToolIconReader.kt | 20 +-
.../io/toolbox/host/icons/ToolIconLoader.kt | 21 +-
.../toolbox/host/importflow/ImportScreen.kt | 38 +-
.../host/importflow/ImportViewModel.kt | 2 +
.../host/navigation/ToolBoxNavigation.kt | 24 +-
.../host/permissions/ToolPermissionsScreen.kt | 29 +-
.../io/toolbox/host/ui/CatalogLazyGroup.kt | 36 ++
.../io/toolbox/host/ui/CatalogListHeader.kt | 82 ++++
.../io/toolbox/host/ui/CatalogOrganization.kt | 30 +-
.../io/toolbox/host/ui/CatalogToolGlyph.kt | 29 +-
.../toolbox/host/ui/HostCapabilityScreens.kt | 51 +--
.../io/toolbox/host/ui/HostCatalogScreens.kt | 403 ++++++------------
.../host/ui/HostConfirmationContent.kt | 59 +++
.../importflow/ImportLaunchIdentityTest.kt | 110 +++++
15 files changed, 606 insertions(+), 373 deletions(-)
create mode 100644 app/src/main/kotlin/io/toolbox/host/ui/CatalogLazyGroup.kt
create mode 100644 app/src/main/kotlin/io/toolbox/host/ui/CatalogListHeader.kt
create mode 100644 app/src/main/kotlin/io/toolbox/host/ui/HostConfirmationContent.kt
create mode 100644 app/src/test/kotlin/io/toolbox/host/importflow/ImportLaunchIdentityTest.kt
diff --git a/app/src/main/kotlin/io/toolbox/host/background/BackgroundTasksScreen.kt b/app/src/main/kotlin/io/toolbox/host/background/BackgroundTasksScreen.kt
index 4a84498a..c391e9be 100644
--- a/app/src/main/kotlin/io/toolbox/host/background/BackgroundTasksScreen.kt
+++ b/app/src/main/kotlin/io/toolbox/host/background/BackgroundTasksScreen.kt
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -34,8 +35,6 @@ import io.toolbox.core.data.BackgroundTask
import io.toolbox.core.data.RunOutcome
import io.toolbox.core.data.TaskRunResult
import io.toolbox.core.data.TaskState
-import io.toolbox.core.ui.component.ToolBoxGroupDivider
-import io.toolbox.core.ui.component.ToolBoxGroupedSurface
import io.toolbox.core.ui.component.ToolBoxDestructiveButton
import io.toolbox.core.ui.component.ToolBoxTextButton
import io.toolbox.core.ui.theme.ToolBoxThemeTokens
@@ -43,6 +42,7 @@ import io.toolbox.host.HostBackgroundOperations
import io.toolbox.host.runtime.RuntimeBackgroundSessionUi
import io.toolbox.host.runtime.RuntimeSessionManager
import io.toolbox.host.ui.AppText
+import io.toolbox.host.ui.CatalogLazyGroupItem
import io.toolbox.host.ui.DetailScreen
import io.toolbox.host.ui.SectionHeader
import io.toolbox.host.ui.SurfaceCard
@@ -83,7 +83,8 @@ internal fun BackgroundTasksScreen(
stoppingSessionId = actionState.stoppingSessionId,
onBack = onBack,
resultFor = { task ->
- val result by operations.observeResult(task.taskId).collectAsStateWithLifecycle(null)
+ val resultFlow = remember(operations, task.taskId) { operations.observeResult(task.taskId) }
+ val result by resultFlow.collectAsStateWithLifecycle(null)
result
},
onStopSession = { actions.stop(it.sessionId) },
@@ -115,11 +116,10 @@ internal fun BackgroundTasksContent(
chromePadding,
PaddingValues(ToolBoxThemeTokens.spacing.two),
),
- verticalArrangement = Arrangement.spacedBy(ToolBoxThemeTokens.spacing.one),
) {
message?.let { text ->
item("message") {
- SurfaceCard {
+ SurfaceCard(Modifier.padding(bottom = ToolBoxThemeTokens.spacing.one)) {
AppText(text, color = ToolBoxThemeTokens.colors.textSecondary,
modifier = Modifier.semantics {
if (cancellingTaskId == null && stoppingSessionId == null) liveRegion = LiveRegionMode.Polite
@@ -144,33 +144,41 @@ internal fun BackgroundTasksContent(
}
} else {
if (page.runtimeSessions.isNotEmpty()) {
- item("runtime-title") { SectionHeader("持续运行 · ${page.runtimeSessions.size}") }
- }
- if (page.runtimeSessions.isNotEmpty()) item("runtime-sessions") {
- ToolBoxGroupedSurface {
- page.runtimeSessions.forEachIndexed { index, session ->
+ item("runtime-title") {
+ Column(Modifier.padding(bottom = ToolBoxThemeTokens.spacing.one)) {
+ SectionHeader("持续运行 · ${page.runtimeSessions.size}")
+ }
+ }
+ itemsIndexed(
+ items = page.runtimeSessions,
+ key = { _, session -> "session:${session.sessionId}" },
+ contentType = { _, _ -> "runtime-session" },
+ ) { index, session ->
+ CatalogLazyGroupItem(index = index, count = page.runtimeSessions.size) {
RuntimeSessionCard(
session = session,
stopping = stoppingSessionId == session.sessionId,
canStop = stoppingSessionId == null,
onStop = { onStopSession(session) },
)
- if (index != page.runtimeSessions.lastIndex) {
- ToolBoxGroupDivider(startPadding = ToolBoxThemeTokens.spacing.oneHalf)
- }
}
}
}
if (page.tasks.isNotEmpty()) {
item("tasks-title") {
- Column(Modifier.padding(top = if (page.runtimeSessions.isNotEmpty()) ToolBoxThemeTokens.spacing.one else 0.dp)) {
+ Column(Modifier.padding(
+ top = if (page.runtimeSessions.isNotEmpty()) ToolBoxThemeTokens.spacing.two else 0.dp,
+ bottom = ToolBoxThemeTokens.spacing.one,
+ )) {
SectionHeader("已登记任务 · ${page.tasks.size}")
}
}
- }
- if (page.tasks.isNotEmpty()) item("tasks") {
- ToolBoxGroupedSurface {
- page.tasks.forEachIndexed { index, task ->
+ itemsIndexed(
+ items = page.tasks,
+ key = { _, task -> "task:${task.taskId}" },
+ contentType = { _, _ -> "background-task" },
+ ) { index, task ->
+ CatalogLazyGroupItem(index = index, count = page.tasks.size) {
BackgroundTaskCard(
task = task,
result = resultFor(task),
@@ -178,7 +186,6 @@ internal fun BackgroundTasksContent(
canCancel = cancellingTaskId == null,
onCancel = { onCancelTask(task) },
)
- if (index != page.tasks.lastIndex) ToolBoxGroupDivider(startPadding = ToolBoxThemeTokens.spacing.oneHalf)
}
}
}
diff --git a/app/src/main/kotlin/io/toolbox/host/icons/InstalledToolIconReader.kt b/app/src/main/kotlin/io/toolbox/host/icons/InstalledToolIconReader.kt
index 95c7ee49..da09d604 100644
--- a/app/src/main/kotlin/io/toolbox/host/icons/InstalledToolIconReader.kt
+++ b/app/src/main/kotlin/io/toolbox/host/icons/InstalledToolIconReader.kt
@@ -10,6 +10,7 @@ import java.nio.file.Path
import java.nio.file.attribute.BasicFileAttributes
internal data class ToolIconSource(val bytes: ByteArray, val isSvg: Boolean)
+internal data class InstalledToolPresentation(val icon: ToolIconSource?, val description: String?)
internal class IconResourcesUnavailable : Exception("Icon exceeds currently available memory")
@@ -17,9 +18,10 @@ internal class InstalledToolIconReader(
private val privateFilesRoot: () -> Path,
private val availableHeapBytes: () -> Long = io.toolbox.core.data.ResourceCapacity::availableHeapBytes,
) {
- fun read(tool: InstalledTool): ToolIconSource? = readInstalled(tool)
+ fun read(tool: InstalledTool): ToolIconSource? = readPresentation(tool)?.icon
- private fun readInstalled(tool: InstalledTool): ToolIconSource? {
+ /** Description and artwork share one verified manifest read; no database migration is needed. */
+ fun readPresentation(tool: InstalledTool): InstalledToolPresentation? {
val version = tool.currentVersion
if (version.toolId != tool.metadata.id) return null
val locator = RuntimeIdentity.expectedBundleLocator(version.toolId, version.versionCode)
@@ -31,11 +33,13 @@ internal class InstalledToolIconReader(
val verified = InstalledManifestVerifier.verify(
manifestBytes, tool.metadata.id, version.versionCode, tool.metadata.securityProfile,
) as? InstalledManifestVerification.Verified ?: return null
- val icon = verified.manifest.icon ?: return null
- val file = safePath(bundle, icon) ?: return null
+ val description = verified.manifest.description?.replace(WHITESPACE, " ")?.trim()
+ ?.take(160)?.takeIf(String::isNotBlank)
+ val icon = verified.manifest.icon ?: return InstalledToolPresentation(null, description)
+ val file = safePath(bundle, icon) ?: return InstalledToolPresentation(null, description)
val isSvg = icon.substringAfterLast('.', "").equals("svg", ignoreCase = true)
- val bytes = readBounded(file) ?: return null
- return ToolIconSource(bytes, isSvg)
+ val bytes = readBounded(file) ?: return InstalledToolPresentation(null, description)
+ return InstalledToolPresentation(ToolIconSource(bytes, isSvg), description)
}
private fun safePath(root: Path, relative: String, directory: Boolean = false): Path? {
@@ -69,4 +73,8 @@ internal class InstalledToolIconReader(
bytes
}
}
+
+ private companion object {
+ val WHITESPACE = Regex("\\s+")
+ }
}
diff --git a/app/src/main/kotlin/io/toolbox/host/icons/ToolIconLoader.kt b/app/src/main/kotlin/io/toolbox/host/icons/ToolIconLoader.kt
index 7357c1c0..b5fff538 100644
--- a/app/src/main/kotlin/io/toolbox/host/icons/ToolIconLoader.kt
+++ b/app/src/main/kotlin/io/toolbox/host/icons/ToolIconLoader.kt
@@ -39,9 +39,10 @@ internal class ToolIconLoader(
// This is an index into the LRU, not another bitmap cache or a reduced identity key.
private val cachedVersions = mutableMapOf()
private val observers = mutableMapOf>>()
- private data class Cached(val bitmap: Bitmap?)
+ private data class Cached(val bitmap: Bitmap?, val description: String?)
private val cache = object : LruCache(4 * 1024 * 1024) {
- override fun sizeOf(key: ToolVersion, value: Cached): Int = value.bitmap?.allocationByteCount ?: 64 * 1024
+ override fun sizeOf(key: ToolVersion, value: Cached): Int =
+ (value.bitmap?.allocationByteCount ?: 64 * 1024) + (value.description?.length ?: 0) * 2
override fun entryRemoved(evicted: Boolean, key: ToolVersion, oldValue: Cached, newValue: Cached?) {
synchronized(cacheLock) {
@@ -58,6 +59,13 @@ internal class ToolIconLoader(
cache.get(version)?.bitmap
}
+ /** Shares the icon's full installed identity, bounded cache and invalidation lifecycle. */
+ fun cachedDescription(toolId: String, expectedVersionCode: Int? = null): String? = synchronized(cacheLock) {
+ val version = cachedVersions[toolId] ?: return@synchronized null
+ if (expectedVersionCode != null && version.versionCode != expectedVersionCode) return@synchronized null
+ cache.get(version)?.description
+ }
+
/** The initial signal also covers invalidation between the first cache read and subscription. */
fun invalidations(toolId: String): Flow = callbackFlow {
synchronized(cacheLock) {
@@ -94,8 +102,13 @@ internal class ToolIconLoader(
return@withTool it.bitmap
}
HostTrace.bestEffortSection("icon.cache.miss") { }
+ var description: String? = null
val bitmap = loads.decode {
- HostTrace.bestEffortSection("icon.decode") { reader.read(tool)?.let(decode) }
+ HostTrace.bestEffortSection("icon.decode") {
+ val presentation = reader.readPresentation(tool)
+ description = presentation?.description
+ presentation?.icon?.let(decode)
+ }
}
currentCoroutineContext().ensureActive()
val currentVersion = HostTrace.bestEffortAsyncSection("icon.catalog.recheck") {
@@ -104,7 +117,7 @@ internal class ToolIconLoader(
if (currentVersion != version) return@withTool null
synchronized(cacheLock) {
cachedVersions[toolId] = version
- cache.put(version, Cached(bitmap))
+ cache.put(version, Cached(bitmap, description))
}
bitmap
} catch (cancelled: CancellationException) {
diff --git a/app/src/main/kotlin/io/toolbox/host/importflow/ImportScreen.kt b/app/src/main/kotlin/io/toolbox/host/importflow/ImportScreen.kt
index 1e363d00..1e1d3835 100644
--- a/app/src/main/kotlin/io/toolbox/host/importflow/ImportScreen.kt
+++ b/app/src/main/kotlin/io/toolbox/host/importflow/ImportScreen.kt
@@ -5,6 +5,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.toolbox.core.ui.component.*
@@ -18,16 +19,19 @@ internal fun ImportScreen(
onPickPackage: () -> Unit,
onBack: () -> Unit,
onReady: () -> Unit,
+ onOpenTool: ((String) -> Unit)? = null,
+ installedToolReady: Boolean = true,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { onReady() }
DetailScreen(title = "导入小工具", onBack = onBack) { chromePadding ->
LazyColumn(
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier.widthIn(max = ToolBoxThemeTokens.sizes.detailContentMaxWidth)
+ .fillMaxSize().align(Alignment.TopCenter),
contentPadding = mergePadding(chromePadding, PaddingValues(16.dp)),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
- item {
+ item("select-package") {
ToolBoxCard(modifier = Modifier.fillMaxWidth(), onClick = if (state.working || state.confirmation != null) null else onPickPackage) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -43,9 +47,14 @@ internal fun ImportScreen(
}
}
if (state.working || state.message != null) {
- item {
+ item("import-result") {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
- SectionHeader("导入结果")
+ SectionHeader(when {
+ state.working -> "正在导入"
+ state.succeeded -> "导入完成"
+ state.outcome == ImportOutcome.Cancelled -> "已取消导入"
+ else -> "导入失败"
+ })
FeedbackSurface(
message = if (state.working) state.progressMessage else state.message.orEmpty(),
tone = state.feedbackTone,
@@ -56,8 +65,25 @@ internal fun ImportScreen(
)
}
}
- if (!state.working) item {
- ToolBoxTextButton(if (state.succeeded) "查看工具" else "重新选择文件", if (state.succeeded) onBack else onPickPackage)
+ if (!state.working && state.confirmation == null) item("result-actions") {
+ val installedToolId = state.installedToolId
+ Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ if (state.succeeded && installedToolId != null && onOpenTool != null) {
+ ToolBoxPrimaryButton(
+ label = if (installedToolReady) "打开工具" else "正在更新目录…",
+ onClick = { onOpenTool(installedToolId) },
+ modifier = Modifier.fillMaxWidth().testTag("import_open_tool"),
+ enabled = installedToolReady,
+ )
+ ToolBoxTextButton("继续导入", onPickPackage, Modifier.fillMaxWidth(), outlined = false)
+ } else {
+ ToolBoxTextButton(
+ if (state.succeeded) "完成" else "重新选择文件",
+ if (state.succeeded) onBack else onPickPackage,
+ Modifier.fillMaxWidth(),
+ )
+ }
+ }
}
}
}
diff --git a/app/src/main/kotlin/io/toolbox/host/importflow/ImportViewModel.kt b/app/src/main/kotlin/io/toolbox/host/importflow/ImportViewModel.kt
index e5c99828..a4bf8321 100644
--- a/app/src/main/kotlin/io/toolbox/host/importflow/ImportViewModel.kt
+++ b/app/src/main/kotlin/io/toolbox/host/importflow/ImportViewModel.kt
@@ -29,6 +29,7 @@ internal data class ImportUiState(
val confirmation: HostImportConfirmation? = null,
val importPhase: PackageImportPhase? = null,
val feedbackId: Long = 0,
+ val installedToolId: String? = null,
) {
val succeeded: Boolean get() = outcome == ImportOutcome.Success
val progressMessage: String
@@ -149,6 +150,7 @@ internal class ImportViewModel(
is HostImportResult.Installed -> ImportUiState(
message = "$toolName 已安装",
outcome = ImportOutcome.Success,
+ installedToolId = toolId,
)
is HostImportResult.ConfirmationRequired -> ImportUiState(confirmation = confirmation)
is HostImportResult.Failed -> ImportUiState(message = message)
diff --git a/app/src/main/kotlin/io/toolbox/host/navigation/ToolBoxNavigation.kt b/app/src/main/kotlin/io/toolbox/host/navigation/ToolBoxNavigation.kt
index 29a41feb..5b4b40f9 100644
--- a/app/src/main/kotlin/io/toolbox/host/navigation/ToolBoxNavigation.kt
+++ b/app/src/main/kotlin/io/toolbox/host/navigation/ToolBoxNavigation.kt
@@ -49,6 +49,7 @@ import io.toolbox.host.catalog.RunningToolsViewModel
import io.toolbox.host.help.DeveloperHelpScreen
import io.toolbox.host.importflow.ContentResolverPackageInputFactory
import io.toolbox.host.importflow.ImportViewModel
+import io.toolbox.host.importflow.ImportUiState
import io.toolbox.host.importflow.SelectedPackageSource
import io.toolbox.host.importflow.ToolBoxOpenDocument
import io.toolbox.host.permissions.PermissionCenterScreen
@@ -248,7 +249,7 @@ internal fun ToolBoxNavigation(
},
organizeEditing = selectedDestination == MainDestination.Home && homeEditing,
onOrganize = if (selectedDestination == MainDestination.Home) ({ homeEditing = !homeEditing }) else null,
- onCreateGroup = if (selectedDestination == MainDestination.Home) ({ homeCreatingGroup = true }) else null,
+ onCreateGroup = if (selectedDestination == MainDestination.Home && homeEditing) ({ homeCreatingGroup = true }) else null,
) { padding, layout ->
when (currentPrimaryRoute) {
HomeRoute, ToolManagerRoute, null -> ToolManagerRouteContent(
@@ -435,7 +436,20 @@ private fun SecondaryRouteContent(
onPickPackage: () -> Unit,
) {
when (route) {
- ImportRoute -> ImportScreen(importViewModel, onPickPackage, onBack, onReady)
+ ImportRoute -> {
+ val catalogState by catalogViewModel.state.collectAsStateWithLifecycle()
+ val importState by importViewModel.state.collectAsStateWithLifecycle()
+ ImportScreen(
+ viewModel = importViewModel,
+ onPickPackage = onPickPackage,
+ onBack = onBack,
+ onReady = onReady,
+ onOpenTool = { toolId -> catalogViewModel.dispatch(CatalogAction.RequestRuntimeLaunch(toolId)) },
+ installedToolReady = importState.installedToolId?.let { id ->
+ catalogState.tools.any { it.toolId == id }
+ } == true,
+ )
+ }
AboutRoute -> AboutScreen(onBack, onReady)
BackupRestoreRoute -> {
val owner = rememberViewModelStoreOwner(parent = viewModelStoreOwner)
@@ -547,14 +561,16 @@ private fun ToolManagerRouteContent(
creatingGroup = creatingGroup,
onDismissCreateGroup = onDismissCreateGroup,
state = catalogState,
- importState = if (importPageVisible) importState.copy(confirmation = null) else importState,
+ // The dedicated import page owns its result and actions. The covered home
+ // must not duplicate the feedback or expire that result after three seconds.
+ importState = if (importPageVisible) ImportUiState() else importState,
listState = listState,
contentPadding = contentPadding,
onAction = catalogViewModel::dispatch,
onImport = onImport,
onInstallExamples = importViewModel::installBundledExamples,
onDismissImport = importViewModel::dismissMessage,
- onExpireImportSuccess = importViewModel::expireSuccess,
+ onExpireImportSuccess = { if (!importPageVisible) importViewModel.expireSuccess(it) },
onConfirmImport = importViewModel::confirmVersionReplacement,
onCancelImport = importViewModel::cancelVersionReplacement,
onCancelActiveImport = importViewModel::cancelActiveImport,
diff --git a/app/src/main/kotlin/io/toolbox/host/permissions/ToolPermissionsScreen.kt b/app/src/main/kotlin/io/toolbox/host/permissions/ToolPermissionsScreen.kt
index e6e2d097..ad02879e 100644
--- a/app/src/main/kotlin/io/toolbox/host/permissions/ToolPermissionsScreen.kt
+++ b/app/src/main/kotlin/io/toolbox/host/permissions/ToolPermissionsScreen.kt
@@ -1,11 +1,13 @@
package io.toolbox.host.permissions
-import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -18,12 +20,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.toolbox.core.data.CatalogRepository
import io.toolbox.core.data.InstalledTool
import io.toolbox.core.ui.component.ToolBoxCard
-import io.toolbox.core.ui.component.ToolBoxGroupDivider
-import io.toolbox.core.ui.component.ToolBoxGroupedSurface
import io.toolbox.core.ui.component.ToolBoxIconKey
import io.toolbox.core.ui.component.ToolBoxSettingRow
import io.toolbox.core.ui.component.ToolBoxText
import io.toolbox.core.ui.theme.ToolBoxThemeTokens
+import io.toolbox.host.ui.CatalogLazyGroupItem
import io.toolbox.host.ui.DetailScreen
import io.toolbox.host.ui.SectionHeader
import io.toolbox.host.ui.mergePadding
@@ -56,13 +57,12 @@ internal fun ToolPermissionsScreen(
chromePadding,
PaddingValues(ToolBoxThemeTokens.spacing.two),
),
- verticalArrangement = Arrangement.spacedBy(ToolBoxThemeTokens.spacing.one),
) {
if (tools.isEmpty()) {
item("empty") {
ToolBoxCard {
ToolBoxText(
- text = "尚未安装工具",
+ text = if (toolsLoaded) "尚未安装工具" else "正在读取工具",
style = ToolBoxThemeTokens.textStyles.body.copy(
color = ToolBoxThemeTokens.colors.textSecondary,
),
@@ -70,13 +70,18 @@ internal fun ToolPermissionsScreen(
}
}
} else {
- item("installed-title") { SectionHeader("已安装工具 · ${tools.size}") }
- item("installed-tools") {
- ToolBoxGroupedSurface {
- tools.forEachIndexed { index, tool ->
- ToolPermissionSelectionRow(tool, onSelectTool)
- if (index != tools.lastIndex) ToolBoxGroupDivider()
- }
+ item("installed-title") {
+ Column(Modifier.padding(bottom = ToolBoxThemeTokens.spacing.one)) {
+ SectionHeader("已安装工具 · ${tools.size}")
+ }
+ }
+ itemsIndexed(
+ items = tools,
+ key = { _, tool -> "permission:${tool.metadata.id}" },
+ contentType = { _, _ -> "permission-tool" },
+ ) { index, tool ->
+ CatalogLazyGroupItem(index = index, count = tools.size) {
+ ToolPermissionSelectionRow(tool, onSelectTool)
}
}
}
diff --git a/app/src/main/kotlin/io/toolbox/host/ui/CatalogLazyGroup.kt b/app/src/main/kotlin/io/toolbox/host/ui/CatalogLazyGroup.kt
new file mode 100644
index 00000000..270e13b2
--- /dev/null
+++ b/app/src/main/kotlin/io/toolbox/host/ui/CatalogLazyGroup.kt
@@ -0,0 +1,36 @@
+package io.toolbox.host.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import io.toolbox.core.ui.component.ToolBoxGroupDivider
+import io.toolbox.core.ui.theme.ToolBoxThemeTokens
+
+/** One lazy item owns one row; adjacent items still form a single rounded group. */
+@Composable
+internal fun CatalogLazyGroupItem(
+ index: Int,
+ count: Int,
+ modifier: Modifier = Modifier,
+ dividerStart: Dp = ToolBoxThemeTokens.spacing.oneHalf,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val radius = ToolBoxThemeTokens.radii.denseSurface
+ val shape = RoundedCornerShape(
+ topStart = if (index == 0) radius else 0.dp,
+ topEnd = if (index == 0) radius else 0.dp,
+ bottomStart = if (index == count - 1) radius else 0.dp,
+ bottomEnd = if (index == count - 1) radius else 0.dp,
+ )
+ Column(modifier.fillMaxWidth().clip(shape).background(ToolBoxThemeTokens.colors.surface)) {
+ content()
+ if (index < count - 1) ToolBoxGroupDivider(startPadding = dividerStart)
+ }
+}
diff --git a/app/src/main/kotlin/io/toolbox/host/ui/CatalogListHeader.kt b/app/src/main/kotlin/io/toolbox/host/ui/CatalogListHeader.kt
new file mode 100644
index 00000000..fabfb0e3
--- /dev/null
+++ b/app/src/main/kotlin/io/toolbox/host/ui/CatalogListHeader.kt
@@ -0,0 +1,82 @@
+package io.toolbox.host.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import io.toolbox.core.data.CatalogSort
+import io.toolbox.core.ui.component.ToolBoxActionSheet
+import io.toolbox.core.ui.component.ToolBoxIcon
+import io.toolbox.core.ui.component.ToolBoxIconButton
+import io.toolbox.core.ui.component.ToolBoxIconKey
+import io.toolbox.core.ui.component.ToolBoxTextButton
+import io.toolbox.core.ui.theme.ToolBoxThemeTokens
+import io.toolbox.host.catalog.CatalogAction
+import io.toolbox.host.catalog.CatalogUiState
+
+@Composable
+internal fun CatalogListHeader(state: CatalogUiState, onAction: (CatalogAction) -> Unit) {
+ var choosingSort by rememberSaveable { mutableStateOf(false) }
+ val focus = LocalFocusManager.current
+ val title = if (!state.isLoaded) "全部工具" else if (state.isSearching) {
+ "搜索结果 · ${state.visibleTools.size}"
+ } else "全部工具 · ${state.tools.size}"
+ val openSort = { focus.clearFocus(); choosingSort = true }
+ val largeText = LocalDensity.current.fontScale >= 1.3f
+ BoxWithConstraints(Modifier.fillMaxWidth().padding(top = 4.dp).testTag("catalog_list_header")) {
+ if (maxWidth < 300.dp || largeText) {
+ Column(Modifier.fillMaxWidth()) {
+ AppText(title, Modifier.semantics { heading() }, textStyle = ToolBoxThemeTokens.textStyles.title)
+ ToolBoxTextButton("排序 · ${state.layout.sort.label}", openSort, outlined = false)
+ }
+ } else {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ AppText(title, Modifier.weight(1f).semantics { heading() }, textStyle = ToolBoxThemeTokens.textStyles.title)
+ ToolBoxTextButton("排序 · ${state.layout.sort.label}", openSort, outlined = false)
+ }
+ }
+ }
+ if (choosingSort) ToolBoxActionSheet(title = "排序", onDismissRequest = { choosingSort = false }) {
+ Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ AppText("排序", Modifier.weight(1f).semantics { heading() }, textStyle = ToolBoxThemeTokens.textStyles.sectionTitle)
+ ToolBoxIconButton(ToolBoxIconKey.Close, "关闭排序", { choosingSort = false })
+ }
+ CatalogSort.entries.forEach { sort ->
+ val selected = state.layout.sort == sort
+ Row(
+ Modifier.fillMaxWidth().heightIn(min = ToolBoxThemeTokens.sizes.touchTarget)
+ .selectable(selected = selected, role = Role.RadioButton, onClick = {
+ choosingSort = false
+ if (!selected) onAction(CatalogAction.SetSort(sort))
+ }).padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AppText(sort.label, Modifier.weight(1f))
+ Spacer(Modifier.width(12.dp))
+ if (selected) ToolBoxIcon(ToolBoxIconKey.Check, null, tint = ToolBoxThemeTokens.colors.primary)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/io/toolbox/host/ui/CatalogOrganization.kt b/app/src/main/kotlin/io/toolbox/host/ui/CatalogOrganization.kt
index 104d17d4..9476d949 100644
--- a/app/src/main/kotlin/io/toolbox/host/ui/CatalogOrganization.kt
+++ b/app/src/main/kotlin/io/toolbox/host/ui/CatalogOrganization.kt
@@ -60,21 +60,31 @@ internal fun LazyListScope.catalogHomeSections(
onEditGroup: (String) -> Unit,
onAddFavorites: () -> Unit,
onOptions: (String) -> Unit,
+ hasRecentTools: Boolean = false,
+ recentContent: @Composable () -> Unit = {},
) {
- item("favorites-heading") { HomeSectionHeader("收藏", if (editing) "添加" else null, onAddFavorites) }
val favorites = state.layout.favorites.mapNotNull(toolsById::get)
+ if (favorites.isNotEmpty() || editing) item("favorites-heading") {
+ HomeSectionHeader("收藏", if (editing && favorites.isNotEmpty()) "添加" else null, onAddFavorites)
+ }
if (favorites.isEmpty()) item("favorites-empty") {
- HomeEmptySection("收藏常用工具,放在这里快速打开", "添加收藏", onAddFavorites)
+ HomeEmptySection("把常用工具放在首页", "添加收藏", onAddFavorites)
}
homeToolGrid(favorites, "favorite", "favorites", columns, editing, drag, onAction, onOptions,
onMove = { tool, offset -> onAction(CatalogAction.MoveFavorite(tool.toolId, offset)) })
- item("groups-heading") {
+ if (hasRecentTools) {
+ item("recent-title") {
+ Spacer(Modifier.height(20.dp))
+ HomeSectionHeader("最近使用")
+ }
+ item("recent-tools") { recentContent() }
+ }
+ if (state.layout.groups.isNotEmpty() || editing) item("groups-heading") {
Spacer(Modifier.height(20.dp))
HomeSectionHeader("分组")
}
- if (state.layout.groups.isEmpty()) item("groups-empty") {
- AppText("按用途整理工具,同一个工具可以加入多个分组。", color = ToolBoxThemeTokens.colors.textSecondary,
- textStyle = ToolBoxThemeTokens.textStyles.metadata, modifier = Modifier.padding(vertical = 12.dp))
+ if (editing && state.layout.groups.isEmpty()) item("groups-empty") {
+ HomeEmptySection("按用途整理工具", "新建分组", { onEditGroup("") })
}
state.layout.groups.forEachIndexed { index, group ->
val members = group.members.mapNotNull(toolsById::get)
@@ -246,9 +256,11 @@ internal fun CatalogHomeTile(
@Composable
private fun HomeEmptySection(message: String, action: String, onAction: () -> Unit) {
- Column(Modifier.fillMaxWidth().background(ToolBoxThemeTokens.colors.surface, RoundedCornerShape(ToolBoxThemeTokens.radii.card))
- .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
- AppText(message, color = ToolBoxThemeTokens.colors.textSecondary, textStyle = ToolBoxThemeTokens.textStyles.metadata)
+ Row(Modifier.fillMaxWidth().background(ToolBoxThemeTokens.colors.surface, RoundedCornerShape(ToolBoxThemeTokens.radii.card))
+ .padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp),
+ verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ AppText(message, Modifier.weight(1f), color = ToolBoxThemeTokens.colors.textSecondary,
+ textStyle = ToolBoxThemeTokens.textStyles.metadata)
ToolBoxTextButton(action, onAction, outlined = false)
}
}
diff --git a/app/src/main/kotlin/io/toolbox/host/ui/CatalogToolGlyph.kt b/app/src/main/kotlin/io/toolbox/host/ui/CatalogToolGlyph.kt
index caa6d764..6a223360 100644
--- a/app/src/main/kotlin/io/toolbox/host/ui/CatalogToolGlyph.kt
+++ b/app/src/main/kotlin/io/toolbox/host/ui/CatalogToolGlyph.kt
@@ -15,6 +15,8 @@ import kotlinx.coroutines.flow.collectLatest
internal val LocalToolIconLoader = staticCompositionLocalOf { null }
+internal data class CatalogToolPresentation(val bitmap: Bitmap?, val description: String?)
+
@Composable
internal fun CatalogToolGlyph(
toolId: String,
@@ -22,7 +24,15 @@ internal fun CatalogToolGlyph(
visual: ToolVisual,
size: Dp = ToolBoxThemeTokens.sizes.toolGlyph,
) {
- val bitmap = rememberCatalogToolBitmap(toolId, versionCode)
+ CatalogToolArtwork(rememberCatalogToolBitmap(toolId, versionCode), visual, size)
+}
+
+@Composable
+internal fun CatalogToolArtwork(
+ bitmap: Bitmap?,
+ visual: ToolVisual,
+ size: Dp = ToolBoxThemeTokens.sizes.toolGlyph,
+) {
val image = remember(bitmap) { bitmap?.asImageBitmap() }
ToolGlyph(
icon = visual.icon,
@@ -34,15 +44,22 @@ internal fun CatalogToolGlyph(
}
@Composable
-internal fun rememberCatalogToolBitmap(toolId: String, versionCode: Int?): Bitmap? {
+internal fun rememberCatalogToolBitmap(toolId: String, versionCode: Int?): Bitmap? =
+ rememberCatalogToolPresentation(toolId, versionCode).bitmap
+
+@Composable
+internal fun rememberCatalogToolPresentation(toolId: String, versionCode: Int?): CatalogToolPresentation {
val loader = LocalToolIconLoader.current
return key(toolId, versionCode, loader) {
- val bitmap by produceState(loader?.cached(toolId, versionCode)) {
+ val presentation by produceState(
+ CatalogToolPresentation(loader?.cached(toolId, versionCode), loader?.cachedDescription(toolId, versionCode)),
+ ) {
loader?.invalidations(toolId)?.collectLatest {
- value = loader.cached(toolId, versionCode)
- value = loader.load(toolId, versionCode)
+ value = CatalogToolPresentation(loader.cached(toolId, versionCode), loader.cachedDescription(toolId, versionCode))
+ val bitmap = loader.load(toolId, versionCode)
+ value = CatalogToolPresentation(bitmap, loader.cachedDescription(toolId, versionCode))
}
}
- bitmap
+ presentation
}
}
diff --git a/app/src/main/kotlin/io/toolbox/host/ui/HostCapabilityScreens.kt b/app/src/main/kotlin/io/toolbox/host/ui/HostCapabilityScreens.kt
index 405221af..12632b5e 100644
--- a/app/src/main/kotlin/io/toolbox/host/ui/HostCapabilityScreens.kt
+++ b/app/src/main/kotlin/io/toolbox/host/ui/HostCapabilityScreens.kt
@@ -2,8 +2,6 @@ package io.toolbox.host.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -30,15 +28,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import io.toolbox.core.ui.component.ToolBoxModalDialog
-import io.toolbox.core.ui.component.ToolBoxSecondaryButton
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
-import androidx.compose.ui.semantics.heading
-import androidx.compose.ui.semantics.semantics
-import io.toolbox.core.ui.component.ToolBoxTextButton
import io.toolbox.core.ui.component.ToolBoxPrimaryButton
import io.toolbox.core.ui.component.ToolBoxRuntimeScaffold
import io.toolbox.core.ui.theme.ToolBoxThemeTokens
@@ -109,38 +102,20 @@ internal fun RuntimeExitConfirmation(onConfirm: () -> Unit) {
if (visible) {
// Runtime has no Miuix Scaffold popup host. Use a window above the retained WebView.
ToolBoxModalDialog(onDismissRequest = { visible = false }) {
- AppText(
- "返回 ToolBox?",
- modifier = Modifier.semantics { heading() },
- textStyle = ToolBoxThemeTokens.textStyles.title.copy(fontSize = 20.sp, lineHeight = 28.sp, fontWeight = FontWeight.SemiBold),
- )
- Spacer(Modifier.height(ToolBoxThemeTokens.spacing.one))
- AppText(
- "即将离开当前小工具,返回 ToolBox。请确认需要保留的内容已保存。",
- color = ToolBoxThemeTokens.colors.textSecondary,
+ HostConfirmationContent(
+ title = "返回 ToolBox?",
+ summary = "即将离开当前小工具,返回 ToolBox。请确认需要保留的内容已保存。",
+ confirmLabel = "返回 ToolBox",
+ cancelLabel = "继续使用",
+ onCancel = { visible = false },
+ onConfirm = {
+ if (visible && !leaving) {
+ visible = false
+ leaving = true
+ onConfirm()
+ }
+ },
)
- Spacer(Modifier.height(ToolBoxThemeTokens.spacing.two))
- Row(
- Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(ToolBoxThemeTokens.spacing.one),
- ) {
- ToolBoxSecondaryButton(
- label = "继续使用",
- onClick = { visible = false },
- modifier = Modifier.weight(1f),
- )
- ToolBoxPrimaryButton(
- label = "返回 ToolBox",
- onClick = {
- if (visible && !leaving) {
- visible = false
- leaving = true
- onConfirm()
- }
- },
- modifier = Modifier.weight(1f),
- )
- }
}
}
}
diff --git a/app/src/main/kotlin/io/toolbox/host/ui/HostCatalogScreens.kt b/app/src/main/kotlin/io/toolbox/host/ui/HostCatalogScreens.kt
index 0a2f4cec..c447f314 100644
--- a/app/src/main/kotlin/io/toolbox/host/ui/HostCatalogScreens.kt
+++ b/app/src/main/kotlin/io/toolbox/host/ui/HostCatalogScreens.kt
@@ -86,7 +86,6 @@ import java.text.DateFormat
import java.util.Date
@Composable
-
internal fun ToolManagerContent(
state: CatalogUiState,
importState: ImportUiState,
@@ -142,99 +141,83 @@ internal fun ToolManagerContent(
}
}
BoxWithConstraints(Modifier.fillMaxSize()) {
- val direction = LocalLayoutDirection.current
- val contentWidth = maxWidth - contentPadding.calculateStartPadding(direction) - contentPadding.calculateEndPadding(direction)
- val columns = homeGridColumnCount((contentWidth - 32.dp).value, LocalDensity.current.fontScale)
- val tileWidth = ((contentWidth - 32.dp - 12.dp * (columns - 1)) / columns).coerceAtLeast(0.dp)
- LazyColumn(
- userScrollEnabled = drag.active == null,
- state = listState,
- modifier = Modifier.fillMaxSize().testTag(if (home) "catalog_home_list" else "catalog_tools_list")
- .catalogDragSurface(drag, home && editing),
- contentPadding = contentPadding,
- ) {
- if (home && editing) item("organize-help") {
- AppText("长按拖动排序,点击工具或分组旁的 ··· 编辑", color = ToolBoxThemeTokens.colors.textSecondary,
- textStyle = ToolBoxThemeTokens.textStyles.metadata, modifier = Modifier.padding(bottom = 16.dp))
- }
- if (home && recentTools.isNotEmpty()) {
- item("recent-title") { HomeSectionHeader("最近使用") }
- item("recent-tools") { CatalogRecentTools(recentTools, tileWidth, onAction, editing) { selectedToolId = it } }
- item("after-recent") { Spacer(Modifier.height(20.dp)) }
- }
- if (!home) {
- item("search") {
- ToolBoxSearchField(
- value = state.query,
- onValueChange = { onAction(CatalogAction.SetQuery(it)) },
- placeholder = "搜索工具",
- )
- }
- item("sort") {
- io.toolbox.core.ui.component.ToolBoxChoiceSettingRow(
- title = "排序", selectedValue = state.layout.sort.name,
- choices = io.toolbox.core.data.CatalogSort.entries.map {
- io.toolbox.core.ui.component.ToolBoxSettingChoice(it.name, it.label)
- },
- onSelected = { onAction(CatalogAction.SetSort(io.toolbox.core.data.CatalogSort.valueOf(it))) },
- )
- }
- item("after-search") { Spacer(Modifier.height(ToolBoxThemeTokens.spacing.oneHalf)) }
- }
-
- if (importState.working || importState.message != null) {
- item("import-feedback") {
- FeedbackSurface(
- message = if (importState.working) importState.progressMessage else requireNotNull(importState.message),
- tone = importState.feedbackTone,
- dismissible = !importState.working && !importState.succeeded,
- onDismiss = onDismissImport,
- onCancel = onCancelActiveImport.takeIf {
- importState.working && importState.importPhase == PackageImportPhase.IMPORTING
- },
- modifier = Modifier.padding(bottom = ToolBoxThemeTokens.spacing.oneHalf),
- )
+ val direction = LocalLayoutDirection.current
+ val contentWidth = maxWidth - contentPadding.calculateStartPadding(direction) - contentPadding.calculateEndPadding(direction)
+ val columns = homeGridColumnCount((contentWidth - 32.dp).value, LocalDensity.current.fontScale)
+ val tileWidth = ((contentWidth - 32.dp - 12.dp * (columns - 1)) / columns).coerceAtLeast(0.dp)
+ LazyColumn(
+ userScrollEnabled = drag.active == null,
+ state = listState,
+ modifier = Modifier.fillMaxSize().testTag(if (home) "catalog_home_list" else "catalog_tools_list")
+ .catalogDragSurface(drag, home && editing),
+ contentPadding = contentPadding,
+ ) {
+ if (home && editing) item("organize-help") {
+ AppText("长按拖动排序,点击工具或分组旁的 ··· 编辑", color = ToolBoxThemeTokens.colors.textSecondary,
+ textStyle = ToolBoxThemeTokens.textStyles.metadata, modifier = Modifier.padding(bottom = 16.dp))
}
- }
- state.feedback?.let { feedback ->
- item("catalog-feedback") {
- FeedbackSurface(
- message = feedback.message,
- tone = if (feedback is CatalogFeedback.Completed) FeedbackTone.Success else FeedbackTone.Error,
- dismissible = true,
- onDismiss = { onAction(CatalogAction.DismissFeedback) },
- modifier = Modifier.padding(bottom = ToolBoxThemeTokens.spacing.oneHalf),
- )
+ if (!home) {
+ item("search") {
+ ToolBoxSearchField(
+ value = state.query,
+ onValueChange = { onAction(CatalogAction.SetQuery(it)) },
+ placeholder = "搜索工具",
+ )
+ }
+ item("installed-title") { CatalogListHeader(state, onAction) }
+ item("after-search") { Spacer(Modifier.height(ToolBoxThemeTokens.spacing.one)) }
}
- }
-
- if (home && state.isLoaded && state.tools.isNotEmpty()) {
- catalogHomeSections(
- state, toolsById, editing, columns, drag, onAction,
- onEditGroup = { editingGroupId = it }, onAddFavorites = { addingFavorites = true },
- onOptions = { selectedToolId = it },
- )
- item("running-tools", contentType = "running-tools") { runningTools() }
- }
- when {
- !state.isLoaded -> item("loading") { CatalogStatusState("正在读取工具") }
- state.tools.isEmpty() -> item("empty") { EmptyCatalogState(onImport, onInstallExamples) }
- home -> Unit
- state.visibleTools.isEmpty() -> {
- item("installed-title") { SectionHeader("搜索结果 · 0") }
- item("no-match") { CatalogStatusState("没有匹配的工具") }
+ if (importState.working || importState.message != null) {
+ item("import-feedback") {
+ FeedbackSurface(
+ message = if (importState.working) importState.progressMessage else requireNotNull(importState.message),
+ tone = importState.feedbackTone,
+ dismissible = !importState.working && !importState.succeeded,
+ onDismiss = onDismissImport,
+ onCancel = onCancelActiveImport.takeIf {
+ importState.working && importState.importPhase == PackageImportPhase.IMPORTING
+ },
+ modifier = Modifier.padding(bottom = ToolBoxThemeTokens.spacing.oneHalf),
+ )
+ }
}
- else -> {
- item("installed-title") {
- SectionHeader(
- if (state.isSearching) "搜索结果 · ${state.visibleTools.size}"
- else "全部工具",
- action = if (state.isSearching) "" else state.tools.size.toString(),
+ state.feedback?.let { feedback ->
+ item("catalog-feedback") {
+ FeedbackSurface(
+ message = feedback.message,
+ tone = if (feedback is CatalogFeedback.Completed) FeedbackTone.Success else FeedbackTone.Error,
+ dismissible = true,
+ onDismiss = { onAction(CatalogAction.DismissFeedback) },
+ modifier = Modifier.padding(bottom = ToolBoxThemeTokens.spacing.oneHalf),
)
}
- item("before-tools") { Spacer(Modifier.height(ToolBoxThemeTokens.spacing.one)) }
- itemsIndexed(
+ }
+
+ if (home && state.isLoaded && state.tools.isNotEmpty()) {
+ catalogHomeSections(
+ state, toolsById, editing, columns, drag, onAction,
+ onEditGroup = { editingGroupId = it }, onAddFavorites = { addingFavorites = true },
+ onOptions = { selectedToolId = it },
+ hasRecentTools = recentTools.isNotEmpty(),
+ recentContent = {
+ CatalogRecentTools(recentTools, tileWidth, onAction, editing) { selectedToolId = it }
+ },
+ )
+ item("running-tools", contentType = "running-tools") { runningTools() }
+ }
+
+ when {
+ !state.isLoaded -> item("loading") { CatalogStatusState("正在读取工具") }
+ state.tools.isEmpty() -> item("empty") { EmptyCatalogState(onImport, onInstallExamples) }
+ home -> Unit
+ state.visibleTools.isEmpty() -> item("no-match") {
+ Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
+ CatalogStatusState("没有匹配的工具")
+ ToolBoxTextButton("清除搜索", { onAction(CatalogAction.SetQuery("")) }, outlined = false)
+ }
+ }
+ else -> itemsIndexed(
items = state.visibleTools,
key = { _, tool -> tool.toolId },
contentType = { _, _ -> "tool" },
@@ -247,9 +230,7 @@ internal fun ToolManagerContent(
}
}
}
- }
-
- CatalogDragPreview(drag)
+ CatalogDragPreview(drag)
}
ImportReplacementDialog(importState, onConfirmImport, onCancelImport)
@@ -292,31 +273,13 @@ internal fun ImportReplacementDialog(
"${it.toolName} 当前为 $installed,待安装为 $incoming。$replacement 继续会停止旧运行和后台任务,并允许这份更新使用已保存的登录信息、设置与仍有效的权限。原来关闭的权限不会开启。仅在信任此包来源时继续。"
}
ToolBoxModalDialog(onDismissRequest = onCancelImport) {
- AppText(
- title,
- modifier = Modifier.semantics { heading() },
- textStyle = ToolBoxThemeTokens.textStyles.title.copy(
- fontSize = 20.sp, lineHeight = 28.sp, fontWeight = FontWeight.SemiBold,
- ),
+ HostConfirmationContent(
+ title = title,
+ summary = summary,
+ confirmLabel = if (confirmation.kind == HostImportConfirmationKind.SAME_VERSION) "仍要覆盖" else "仍要安装",
+ onConfirm = onConfirmImport,
+ onCancel = onCancelImport,
)
- Spacer(Modifier.height(ToolBoxThemeTokens.spacing.one))
- AppText(summary, color = ToolBoxThemeTokens.colors.textSecondary)
- Spacer(Modifier.height(ToolBoxThemeTokens.spacing.two))
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(ToolBoxThemeTokens.spacing.one),
- ) {
- ToolBoxSecondaryButton(
- label = "取消",
- onClick = onCancelImport,
- modifier = Modifier.weight(1f),
- )
- ToolBoxPrimaryButton(
- label = if (confirmation.kind == HostImportConfirmationKind.SAME_VERSION) "仍要覆盖" else "仍要安装",
- onClick = onConfirmImport,
- modifier = Modifier.weight(1f),
- )
- }
}
}
@@ -332,10 +295,7 @@ internal fun ToolDetailScreen(
val tool = state.tools.firstOrNull { it.toolId == toolId }
val confirmation = state.uninstallConfirmation?.takeIf { it.toolId == toolId }
- DetailScreen(
- title = "工具详情",
- onBack = onBack,
- ) { chromePadding ->
+ DetailScreen(title = "工具详情", onBack = onBack) { chromePadding ->
LazyColumn(
modifier = Modifier
.widthIn(max = ToolBoxThemeTokens.sizes.detailContentMaxWidth)
@@ -371,17 +331,9 @@ internal fun ToolDetailScreen(
item("management-gap") { Spacer(Modifier.height(ToolBoxThemeTokens.spacing.one)) }
item("management") {
ToolBoxGroupedSurface {
- ToolBoxSettingRow(
- title = "权限",
- icon = ToolBoxIconKey.Shield,
- onClick = { onPermissions(tool.toolId) },
- )
+ ToolBoxSettingRow(title = "权限", icon = ToolBoxIconKey.Shield, onClick = { onPermissions(tool.toolId) })
ToolBoxGroupDivider()
- ToolBoxSettingRow(
- title = "后台任务",
- icon = ToolBoxIconKey.Clock,
- onClick = { onBackground(tool.toolId) },
- )
+ ToolBoxSettingRow(title = "后台任务", icon = ToolBoxIconKey.Clock, onClick = { onBackground(tool.toolId) })
}
}
item("before-information") { Spacer(Modifier.height(ToolBoxThemeTokens.spacing.two)) }
@@ -409,24 +361,15 @@ internal fun ToolDetailScreen(
OverlayDialog(
show = confirmation != null,
- title = confirmation?.let { "删除 ${it.toolName}?" },
- summary = "工具文件、权限、存储和后台任务都会一并删除。",
onDismissRequest = { onAction(CatalogAction.CancelUninstall) },
) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(ToolBoxThemeTokens.spacing.one),
- ) {
- ToolBoxTextButton(
- label = "取消",
- onClick = { onAction(CatalogAction.CancelUninstall) },
- modifier = Modifier.weight(1f),
- contentColor = ToolBoxThemeTokens.colors.textPrimary,
- )
- ToolBoxPrimaryButton(
- label = "确认删除",
- onClick = { onAction(CatalogAction.ConfirmUninstall) },
- modifier = Modifier.weight(1f),
+ confirmation?.let {
+ HostConfirmationContent(
+ title = "删除 ${it.toolName}?",
+ summary = "工具文件、权限、存储和后台任务都会一并删除。",
+ confirmLabel = "确认删除",
+ onConfirm = { onAction(CatalogAction.ConfirmUninstall) },
+ onCancel = { onAction(CatalogAction.CancelUninstall) },
destructive = true,
)
}
@@ -435,60 +378,37 @@ internal fun ToolDetailScreen(
}
@Composable
-internal fun CatalogToolRow(
- tool: CatalogTool,
- onOpen: () -> Unit,
- onDetails: () -> Unit,
-) {
+internal fun CatalogToolRow(tool: CatalogTool, onOpen: () -> Unit, onDetails: () -> Unit) {
val corner = ToolBoxThemeTokens.radii.denseSurface
val visual = tool.visual(ToolBoxThemeTokens.colors.primary)
+ val presentation = rememberCatalogToolPresentation(tool.toolId, tool.versionCode)
val interactionSource = remember { MutableInteractionSource() }
Column(
- Modifier
- .fillMaxWidth()
- .padding(bottom = 8.dp)
+ Modifier.fillMaxWidth().padding(bottom = 8.dp)
.toolBoxOpenDesignSurface(RoundedCornerShape(corner))
.indication(interactionSource, LocalIndication.current)
.testTag("tool_card:" + tool.toolId),
) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Row(
- modifier = Modifier
- .weight(1f)
+ modifier = Modifier.weight(1f)
.heightIn(min = ToolBoxThemeTokens.sizes.catalogRow)
.testTag("tool_main:" + tool.toolId)
.combinedClickable(interactionSource = interactionSource, indication = null,
role = Role.Button, onClickLabel = "打开${tool.name}", onClick = onOpen,
onLongClickLabel = "收藏、分组与管理", onLongClick = onDetails)
- .padding(
- start = ToolBoxThemeTokens.spacing.oneHalf,
- end = ToolBoxThemeTokens.spacing.half,
- top = ToolBoxThemeTokens.spacing.one,
- bottom = ToolBoxThemeTokens.spacing.one,
- ),
+ .padding(start = ToolBoxThemeTokens.spacing.oneHalf, end = ToolBoxThemeTokens.spacing.half,
+ top = ToolBoxThemeTokens.spacing.one, bottom = ToolBoxThemeTokens.spacing.one),
verticalAlignment = Alignment.CenterVertically,
) {
- CatalogToolGlyph(
- toolId = tool.toolId,
- versionCode = tool.versionCode,
- visual = visual,
- size = ToolBoxThemeTokens.sizes.compactToolGlyph,
- )
+ CatalogToolArtwork(presentation.bitmap, visual, ToolBoxThemeTokens.sizes.compactToolGlyph)
Spacer(Modifier.width(ToolBoxThemeTokens.spacing.oneHalf))
- Column(Modifier.weight(1f)) {
- AppText(text = tool.name, textStyle = ToolBoxThemeTokens.textStyles.title, weight = FontWeight.SemiBold)
- val summary = when (tool.toolId) {
- "io.toolbox.positioncalculator" -> "计算 · 本地运行"
- "io.toolbox.quicknotes" -> "记录 · 本地存储"
- "io.toolbox.backgroundtaskdemo" -> "后台 · 通知"
- "io.toolbox.notificationlab" -> "测试 · 系统通知"
- else -> null
- }
- if (summary != null) AppText(
- text = summary,
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ AppText(tool.name, maxLines = 2, textStyle = ToolBoxThemeTokens.textStyles.title, weight = FontWeight.SemiBold)
+ // Reserve the same one-line summary slot while metadata loads or is absent.
+ AppText(
+ text = presentation.description ?: "版本 ${tool.versionName}",
+ maxLines = 1,
textStyle = ToolBoxThemeTokens.textStyles.metadata,
color = ToolBoxThemeTokens.colors.textSecondary,
)
@@ -526,27 +446,15 @@ internal fun CatalogRecentTools(
@Composable
private fun ToolIdentity(tool: CatalogTool) {
val visual = tool.visual(ToolBoxThemeTokens.colors.primary)
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- CatalogToolGlyph(
- toolId = tool.toolId,
- versionCode = tool.versionCode,
- visual = visual,
- )
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ CatalogToolGlyph(toolId = tool.toolId, versionCode = tool.versionCode, visual = visual)
Spacer(Modifier.width(ToolBoxThemeTokens.spacing.oneHalf))
- AppText(
- text = tool.name,
- modifier = Modifier.weight(1f),
- textStyle = ToolBoxThemeTokens.textStyles.detailTitle,
- weight = FontWeight.Bold,
- )
+ AppText(text = tool.name, modifier = Modifier.weight(1f),
+ textStyle = ToolBoxThemeTokens.textStyles.detailTitle, weight = FontWeight.Bold)
}
}
private const val INSTALL_SUCCESS_FEEDBACK_DURATION_MS = 3_000L
-
internal enum class FeedbackTone { Progress, Success, Neutral, Error }
internal val ImportUiState.feedbackTone: FeedbackTone get() = when {
@@ -585,33 +493,20 @@ internal fun FeedbackSurface(
FeedbackTone.Error -> ToolBoxIconKey.Close
}
Row(
- modifier = modifier
- .fillMaxWidth()
- .semantics(mergeDescendants = true) {
- if (tone != FeedbackTone.Progress) liveRegion = LiveRegionMode.Polite
- }
- .clip(RoundedCornerShape(ToolBoxThemeTokens.radii.badge))
- .background(container)
- .heightIn(min = ToolBoxThemeTokens.sizes.touchTarget)
- .padding(start = ToolBoxThemeTokens.spacing.oneHalf),
+ modifier = modifier.fillMaxWidth().semantics(mergeDescendants = true) {
+ if (tone != FeedbackTone.Progress) liveRegion = LiveRegionMode.Polite
+ }.clip(RoundedCornerShape(ToolBoxThemeTokens.radii.badge)).background(container)
+ .heightIn(min = ToolBoxThemeTokens.sizes.touchTarget).padding(start = ToolBoxThemeTokens.spacing.oneHalf),
verticalAlignment = Alignment.CenterVertically,
) {
ToolBoxIcon(icon = icon, contentDescription = null, tint = content)
Spacer(Modifier.width(ToolBoxThemeTokens.spacing.one))
- AppText(
- text = message,
- modifier = Modifier.weight(1f),
- color = colors.textPrimary,
- textStyle = ToolBoxThemeTokens.textStyles.metadata,
- )
+ AppText(text = message, modifier = Modifier.weight(1f), color = colors.textPrimary,
+ textStyle = ToolBoxThemeTokens.textStyles.metadata)
if (onCancel != null) {
ToolBoxTextButton(label = "取消", onClick = onCancel)
} else if (dismissible) {
- ToolBoxIconButton(
- icon = ToolBoxIconKey.Close,
- contentDescription = "关闭提示",
- onClick = onDismiss,
- )
+ ToolBoxIconButton(icon = ToolBoxIconKey.Close, contentDescription = "关闭提示", onClick = onDismiss)
} else {
Spacer(Modifier.width(ToolBoxThemeTokens.spacing.oneHalf))
}
@@ -622,26 +517,15 @@ internal fun FeedbackSurface(
internal fun EmptyCatalogState(onImport: () -> Unit, onInstallExamples: () -> Unit) {
SurfaceCard(Modifier.testTag(HostTestTags.CatalogEmptyState)) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
- ToolGlyph(
- icon = ToolBoxIconKey.Tools,
- accent = ToolBoxThemeTokens.colors.primary,
- )
+ ToolGlyph(icon = ToolBoxIconKey.Tools, accent = ToolBoxThemeTokens.colors.primary)
}
Spacer(Modifier.height(ToolBoxThemeTokens.spacing.oneHalf))
- AppText(
- text = "还没有工具",
- modifier = Modifier.fillMaxWidth(),
- textStyle = ToolBoxThemeTokens.textStyles.sectionTitle,
- weight = FontWeight.SemiBold,
- align = androidx.compose.ui.text.style.TextAlign.Center,
- )
- AppText(
- text = "先安装四个可直接使用的范例,或导入自己的 .tbx。",
- modifier = Modifier.fillMaxWidth(),
- textStyle = ToolBoxThemeTokens.textStyles.metadata,
- color = ToolBoxThemeTokens.colors.textSecondary,
- align = androidx.compose.ui.text.style.TextAlign.Center,
- )
+ AppText(text = "还没有工具", modifier = Modifier.fillMaxWidth(),
+ textStyle = ToolBoxThemeTokens.textStyles.sectionTitle, weight = FontWeight.SemiBold,
+ align = androidx.compose.ui.text.style.TextAlign.Center)
+ AppText(text = "先安装四个可直接使用的范例,或导入自己的 .tbx。", modifier = Modifier.fillMaxWidth(),
+ textStyle = ToolBoxThemeTokens.textStyles.metadata, color = ToolBoxThemeTokens.colors.textSecondary,
+ align = androidx.compose.ui.text.style.TextAlign.Center)
Spacer(Modifier.height(ToolBoxThemeTokens.spacing.oneHalf))
ToolBoxPrimaryButton("安装四个范例", onInstallExamples, Modifier.fillMaxWidth())
ToolBoxTextButton("导入 .tbx", onImport, Modifier.fillMaxWidth())
@@ -650,51 +534,32 @@ internal fun EmptyCatalogState(onImport: () -> Unit, onInstallExamples: () -> Un
@Composable
internal fun CatalogStatusState(message: String) {
- Box(
- modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp),
- contentAlignment = Alignment.Center,
- ) {
- AppText(
- text = message,
- color = ToolBoxThemeTokens.colors.textSecondary,
- textStyle = ToolBoxThemeTokens.textStyles.metadata,
- )
+ Box(Modifier.fillMaxWidth().heightIn(min = 96.dp), contentAlignment = Alignment.Center) {
+ AppText(text = message, color = ToolBoxThemeTokens.colors.textSecondary, textStyle = ToolBoxThemeTokens.textStyles.metadata)
}
}
-internal data class ToolVisual(
- val icon: ToolBoxIconKey,
- val accent: Color,
- val imageResource: Int? = null,
-)
+internal data class ToolVisual(val icon: ToolBoxIconKey, val accent: Color, val imageResource: Int? = null)
internal fun CatalogTool.visual(primary: Color): ToolVisual = when (toolId) {
- "io.toolbox.positioncalculator" ->
- ToolVisual(ToolBoxIconKey.Calculator, primary, R.drawable.example_position_calculator)
- "io.toolbox.quicknotes" ->
- ToolVisual(ToolBoxIconKey.Note, Color(0xFF6A78B7), R.drawable.example_quick_notes)
- "io.toolbox.backgroundtaskdemo" ->
- ToolVisual(ToolBoxIconKey.Code, Color(0xFF317F87), R.drawable.example_background_tasks)
- "io.toolbox.notificationlab" ->
- ToolVisual(ToolBoxIconKey.Notifications, Color(0xFF526A9C), R.drawable.example_notification_lab)
+ "io.toolbox.positioncalculator" -> ToolVisual(ToolBoxIconKey.Calculator, primary, R.drawable.example_position_calculator)
+ "io.toolbox.quicknotes" -> ToolVisual(ToolBoxIconKey.Note, Color(0xFF6A78B7), R.drawable.example_quick_notes)
+ "io.toolbox.backgroundtaskdemo" -> ToolVisual(ToolBoxIconKey.Code, Color(0xFF317F87), R.drawable.example_background_tasks)
+ "io.toolbox.notificationlab" -> ToolVisual(ToolBoxIconKey.Notifications, Color(0xFF526A9C), R.drawable.example_notification_lab)
else -> fallbackVisual(primary)
}
private fun CatalogTool.fallbackVisual(primary: Color): ToolVisual = when {
- toolId.contains("position", ignoreCase = true) || name.contains("计算") ->
- ToolVisual(ToolBoxIconKey.Calculator, primary)
- toolId.contains("note", ignoreCase = true) || name.contains("笔记") ->
- ToolVisual(ToolBoxIconKey.Note, Color(0xFFFFB000))
- toolId.contains("background", ignoreCase = true) || name.contains("后台") ->
- ToolVisual(ToolBoxIconKey.Code, Color(0xFF0A8F6A))
+ toolId.contains("position", ignoreCase = true) || name.contains("计算") -> ToolVisual(ToolBoxIconKey.Calculator, primary)
+ toolId.contains("note", ignoreCase = true) || name.contains("笔记") -> ToolVisual(ToolBoxIconKey.Note, Color(0xFFFFB000))
+ toolId.contains("background", ignoreCase = true) || name.contains("后台") -> ToolVisual(ToolBoxIconKey.Code, Color(0xFF0A8F6A))
else -> ToolVisual(ToolBoxIconKey.Tools, Color(0xFF7C4DFF))
}
-private val CatalogFeedback.message: String
- get() = when (this) {
- is CatalogFeedback.Completed -> message
- is CatalogFeedback.Failure -> message
- }
+private val CatalogFeedback.message: String get() = when (this) {
+ is CatalogFeedback.Completed -> message
+ is CatalogFeedback.Failure -> message
+}
private fun Long.fileSizeLabel(): String = when {
this < 1024L -> "$this B"
diff --git a/app/src/main/kotlin/io/toolbox/host/ui/HostConfirmationContent.kt b/app/src/main/kotlin/io/toolbox/host/ui/HostConfirmationContent.kt
new file mode 100644
index 00000000..04e26dae
--- /dev/null
+++ b/app/src/main/kotlin/io/toolbox/host/ui/HostConfirmationContent.kt
@@ -0,0 +1,59 @@
+package io.toolbox.host.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import io.toolbox.core.ui.component.ToolBoxPrimaryButton
+import io.toolbox.core.ui.component.ToolBoxSecondaryButton
+import io.toolbox.core.ui.theme.ToolBoxThemeTokens
+
+/** Shared content only: callers retain the appropriate overlay or Android window host. */
+@Composable
+internal fun HostConfirmationContent(
+ title: String,
+ summary: String,
+ confirmLabel: String,
+ onConfirm: () -> Unit,
+ onCancel: () -> Unit,
+ cancelLabel: String = "取消",
+ destructive: Boolean = false,
+) {
+ Column(Modifier.fillMaxWidth()) {
+ AppText(
+ title,
+ modifier = Modifier.semantics { heading() },
+ textStyle = ToolBoxThemeTokens.textStyles.title.copy(
+ fontSize = 20.sp, lineHeight = 28.sp, fontWeight = FontWeight.SemiBold,
+ ),
+ )
+ Spacer(Modifier.height(ToolBoxThemeTokens.spacing.one))
+ AppText(summary, color = ToolBoxThemeTokens.colors.textSecondary)
+ Spacer(Modifier.height(ToolBoxThemeTokens.spacing.two))
+ val largeText = LocalDensity.current.fontScale >= 1.3f
+ BoxWithConstraints(Modifier.fillMaxWidth()) {
+ if (maxWidth < 300.dp || largeText) {
+ Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(ToolBoxThemeTokens.spacing.one)) {
+ ToolBoxSecondaryButton(cancelLabel, onCancel, Modifier.fillMaxWidth())
+ ToolBoxPrimaryButton(confirmLabel, onConfirm, Modifier.fillMaxWidth(), destructive = destructive)
+ }
+ } else {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(ToolBoxThemeTokens.spacing.one)) {
+ ToolBoxSecondaryButton(cancelLabel, onCancel, Modifier.weight(1f))
+ ToolBoxPrimaryButton(confirmLabel, onConfirm, Modifier.weight(1f), destructive = destructive)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/test/kotlin/io/toolbox/host/importflow/ImportLaunchIdentityTest.kt b/app/src/test/kotlin/io/toolbox/host/importflow/ImportLaunchIdentityTest.kt
new file mode 100644
index 00000000..781c01d6
--- /dev/null
+++ b/app/src/test/kotlin/io/toolbox/host/importflow/ImportLaunchIdentityTest.kt
@@ -0,0 +1,110 @@
+package io.toolbox.host.importflow
+
+import androidx.lifecycle.viewModelScope
+import io.toolbox.host.HostDeleteResult
+import io.toolbox.host.HostExampleInstallResult
+import io.toolbox.host.HostImportCancellationResult
+import io.toolbox.host.HostImportConfirmation
+import io.toolbox.host.HostImportConfirmationKind
+import io.toolbox.host.HostImportResult
+import io.toolbox.host.HostInstalledManifestResult
+import io.toolbox.host.HostPackageOperations
+import io.toolbox.tool.packagekit.PackageInput
+import io.toolbox.tool.packagekit.lifecycle.PackageImportControl
+import java.io.ByteArrayInputStream
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ImportLaunchIdentityTest {
+ private val dispatcher = StandardTestDispatcher()
+ private val operations = PackageOperations()
+ private lateinit var viewModel: ImportViewModel
+ private val input = object : PackageInput {
+ override val displayName = "fixture.tbx"
+ override fun openStream() = ByteArrayInputStream(byteArrayOf())
+ }
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(dispatcher)
+ viewModel = ImportViewModel(operations, dispatcher)
+ }
+
+ @After
+ fun tearDown() {
+ viewModel.viewModelScope.cancel()
+ dispatcher.scheduler.runCurrent()
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun successfulImportKeepsTheActualInstalledIdentityUntilDismissed() = runTest(dispatcher) {
+ viewModel.importPackage(input)
+ advanceUntilIdle()
+ assertTrue(viewModel.state.value.succeeded)
+ assertEquals("io.toolbox.imported", viewModel.state.value.installedToolId)
+ viewModel.dismissMessage()
+ assertNull(viewModel.state.value.installedToolId)
+ }
+
+ @Test
+ fun pendingReplacementCannotOpenUntilApprovalSucceeds() = runTest(dispatcher) {
+ operations.next = HostImportResult.ConfirmationRequired(HostImportConfirmation(
+ id = "replace", toolId = "io.toolbox.imported", toolName = "导入工具",
+ installedVersionName = "1", installedVersionCode = 1,
+ incomingVersionName = "1", incomingVersionCode = 1,
+ kind = HostImportConfirmationKind.SAME_VERSION,
+ ))
+ viewModel.importPackage(input)
+ advanceUntilIdle()
+ assertNull(viewModel.state.value.installedToolId)
+ viewModel.confirmVersionReplacement()
+ advanceUntilIdle()
+ assertEquals("io.toolbox.imported", viewModel.state.value.installedToolId)
+ assertNull(viewModel.state.value.confirmation)
+ }
+
+ @Test
+ fun newImportFailureCancellationAndBatchNeverReuseAnOldOpenTarget() = runTest(dispatcher) {
+ listOf(HostImportResult.Failed("IO", "读取失败"), HostImportResult.Cancelled).forEach { result ->
+ operations.next = HostImportResult.Installed("io.toolbox.imported", "导入工具")
+ viewModel.importPackage(input)
+ advanceUntilIdle()
+ assertEquals("io.toolbox.imported", viewModel.state.value.installedToolId)
+ operations.next = result
+ viewModel.importPackage(input)
+ assertTrue(viewModel.state.value.working)
+ assertNull(viewModel.state.value.installedToolId)
+ advanceUntilIdle()
+ assertNull(viewModel.state.value.installedToolId)
+ }
+ viewModel.installBundledExamples()
+ advanceUntilIdle()
+ assertTrue(viewModel.state.value.succeeded)
+ assertNull(viewModel.state.value.installedToolId)
+ }
+
+ private class PackageOperations : HostPackageOperations {
+ var next: HostImportResult = HostImportResult.Installed("io.toolbox.imported", "导入工具")
+ override suspend fun importPackage(input: PackageInput, control: PackageImportControl) = next
+ override suspend fun confirmImport(confirmationId: String, control: PackageImportControl) =
+ HostImportResult.Installed("io.toolbox.imported", "导入工具")
+ override suspend fun cancelImport(confirmationId: String) = HostImportCancellationResult.Cancelled
+ override suspend fun installedManifest(toolId: String): HostInstalledManifestResult = error("not used")
+ override suspend fun deleteTool(toolId: String): HostDeleteResult = error("not used")
+ override suspend fun installBundledExamples() = HostExampleInstallResult.Installed(4)
+ }
+}
From a4b65a3ccfc58dbe50b6f9363d969ad33bd9dcfa Mon Sep 17 00:00:00 2001
From: DoraC <25716974+gkeyes@users.noreply.github.com>
Date: Tue, 22 Sep 2026 16:01:04 +0800
Subject: [PATCH 09/19] fix(ui): carry optional description through verified
installed manifest
---
.../io/toolbox/tool/packagekit/InstalledManifestVerification.kt | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tool-package/src/main/kotlin/io/toolbox/tool/packagekit/InstalledManifestVerification.kt b/tool-package/src/main/kotlin/io/toolbox/tool/packagekit/InstalledManifestVerification.kt
index 5905087d..3a4758cd 100644
--- a/tool-package/src/main/kotlin/io/toolbox/tool/packagekit/InstalledManifestVerification.kt
+++ b/tool-package/src/main/kotlin/io/toolbox/tool/packagekit/InstalledManifestVerification.kt
@@ -13,6 +13,7 @@ data class InstalledManifest(
val permissionDeclarations: List,
val network: InstalledManifestNetwork?,
val icon: String? = null,
+ val description: String? = null,
)
data class InstalledManifestPermission(
@@ -80,6 +81,7 @@ object InstalledManifestVerifier {
)
},
icon = parsed.icon,
+ description = parsed.description,
),
)
}
From 1be52264e3ff05f88ded5c7a2c16cbd478853489 Mon Sep 17 00:00:00 2001
From: DoraC <25716974+gkeyes@users.noreply.github.com>
Date: Tue, 22 Sep 2026 16:41:01 +0800
Subject: [PATCH 10/19] feat(ui): polish secondary pages and expose read-only
permission summaries
Clarify group selection and unsaved edits, streamline appearance settings with a live token preview, show per-tool host grant summaries, prioritize backup actions and restore counts, and make browser address actions directly accessible.
Keep original permission defaults and mutation paths, backup consent and rollback, browser security controls, app identity, dependency graph and branch-only emulator waiver. Add JVM regressions for draft changes, restore summaries and permission counts.
---
.../io/toolbox/host/backup/BackupScreen.kt | 91 +++++--
.../toolbox/host/browser/BrowserActivity.kt | 62 +++--
.../host/navigation/ToolBoxNavigation.kt | 4 +-
.../permissions/PermissionCenterViewModel.kt | 6 +-
.../host/permissions/ToolPermissionSummary.kt | 79 ++++++
.../host/permissions/ToolPermissionsScreen.kt | 93 ++++---
.../toolbox/host/settings/AppearanceScreen.kt | 97 ++++---
.../toolbox/host/settings/SettingsScreen.kt | 25 +-
.../io/toolbox/host/ui/CatalogActionPanels.kt | 242 +++++++++++++-----
.../host/backup/BackupPresentationTest.kt | 29 +++
.../catalog/GroupDraftPresentationTest.kt | 26 ++
.../permissions/ToolPermissionSummaryTest.kt | 32 +++
12 files changed, 583 insertions(+), 203 deletions(-)
create mode 100644 app/src/main/kotlin/io/toolbox/host/permissions/ToolPermissionSummary.kt
create mode 100644 app/src/test/kotlin/io/toolbox/host/backup/BackupPresentationTest.kt
create mode 100644 app/src/test/kotlin/io/toolbox/host/catalog/GroupDraftPresentationTest.kt
create mode 100644 app/src/test/kotlin/io/toolbox/host/permissions/ToolPermissionSummaryTest.kt
diff --git a/app/src/main/kotlin/io/toolbox/host/backup/BackupScreen.kt b/app/src/main/kotlin/io/toolbox/host/backup/BackupScreen.kt
index 41f8e8b2..f5345bf3 100644
--- a/app/src/main/kotlin/io/toolbox/host/backup/BackupScreen.kt
+++ b/app/src/main/kotlin/io/toolbox/host/backup/BackupScreen.kt
@@ -11,12 +11,14 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.progressSemantics
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.toolbox.core.ui.component.*
@@ -58,16 +60,20 @@ internal fun BackupContent(state: BackupUiState, onBack: () -> Unit, onExport: (
is BackupUiState.ConfirmRestore -> "confirm-restore"
is BackupUiState.Result -> "result"
}
- // A new stage starts at its own first item instead of reusing the old list's key/offset.
- // The saveable state still survives recreation and updates within the same stage.
+ // Each stage keeps its own scroll and disclosure state; safety notices never live in the disclosure.
val listState = key(stage) { rememberLazyListState() }
+ var detailsExpanded by rememberSaveable(stage) { mutableStateOf(false) }
DetailScreen("备份与恢复", onBack) { padding ->
LazyColumn(Modifier.fillMaxSize().testTag("backup_page"), state = listState, contentPadding = mergePadding(padding, PaddingValues(16.dp))) {
- if (state == BackupUiState.Idle) backupSection("overview") { ToolBoxCard {
- AppText("备份内容", textStyle = ToolBoxThemeTokens.textStyles.title)
- AppText("外观、明暗、系统取色、透明度与后台设置;完整工具包、安装信息、ToolBox storage 配置及数据库数据、可解密的安全存储和任务历史。")
- AppText("不迁移硬件密钥、Android 系统授权、WebView 私有浏览器数据库或活动会话。外部文件访问授权与临时相机缓存需重新获取。", color = ToolBoxThemeTokens.colors.textSecondary)
- } }
+ if (state == BackupUiState.Idle) {
+ backupActions(onExport, onRestore)
+ backupSection("overview") { ToolBoxCard {
+ AppText("工具、设置与数据", textStyle = ToolBoxThemeTokens.textStyles.title)
+ AppText("备份保存在你选择的位置;恢复前会先列出将覆盖和跳过的工具。", color = ToolBoxThemeTokens.colors.textSecondary)
+ Spacer(Modifier.height(8.dp))
+ AppText("可能包含账号和令牌,ZIP 未加密,请勿公开分享。", color = ToolBoxThemeTokens.colors.danger)
+ } }
+ }
when (state) {
BackupUiState.ExportConsent -> backupSection("consent") { ToolBoxCard {
AppText("备份可能包含账号和令牌", textStyle = ToolBoxThemeTokens.textStyles.title)
@@ -89,16 +95,24 @@ internal fun BackupContent(state: BackupUiState, onBack: () -> Unit, onExport: (
ToolBoxTextButton("取消", onCancel)
} }
is BackupUiState.ConfirmRestore -> {
+ val counts = restorePlanCounts(state.preview.tools)
backupSection("confirm-restore") { ToolBoxCard {
AppText("恢复前确认", textStyle = ToolBoxThemeTokens.textStyles.title)
- AppText("将覆盖宿主设置;恢复 ${state.preview.recoverable.size} 个工具,其中 ${state.preview.conflicts} 个与本机冲突。其他工具保留。", modifier = Modifier.testTag("backup_conflicts"))
+ AppText("可恢复 ${counts.recoverable} 个 · 覆盖 ${counts.overwritten} 个 · 跳过 ${counts.skipped} 个",
+ textStyle = ToolBoxThemeTokens.textStyles.title, modifier = Modifier.testTag("backup_conflicts"))
+ Spacer(Modifier.height(8.dp))
+ AppText("将覆盖宿主设置及下方标记的工具;其他工具保留。", color = ToolBoxThemeTokens.colors.danger)
AppText("当前有 ${state.preview.runtimeIds.size} 个运行环境和 ${state.preview.activeTaskIds.size} 个待执行或运行任务。恢复将停止全部本机工具会话与任务,完成或取消后均需手动重新打开。")
- AppText("下方版本覆盖只确认一次;安装时仍执行原有校验。备份中的授权不会绕过当前权限模型。")
+ AppText("下方版本覆盖只确认一次;安装时仍执行原有校验。备份中的授权不会绕过当前权限模型。",
+ color = ToolBoxThemeTokens.colors.textSecondary)
} }
+ backupSection("tools-heading") { SectionHeader("工具变更清单") }
itemsIndexed(state.preview.tools, key = { _, tool -> "tool-${tool.id}" }) { index, tool ->
RestorePreviewRow(tool, first = index == 0, last = index == state.preview.tools.lastIndex)
}
- state.preview.warnings.forEachIndexed { i, warning -> backupSection("warning-$i") { AppText(warning, color = ToolBoxThemeTokens.colors.textSecondary) } }
+ state.preview.warnings.forEachIndexed { i, warning ->
+ backupSection("warning-$i") { AppText(warning, color = ToolBoxThemeTokens.colors.textSecondary) }
+ }
backupSection("confirm-actions") { ToolBoxCard {
ToolBoxDestructiveButton("确认覆盖并恢复", onConfirmRestore, Modifier.fillMaxWidth().testTag("backup_confirm_restore"))
Spacer(Modifier.height(ToolBoxThemeTokens.spacing.one))
@@ -114,16 +128,38 @@ internal fun BackupContent(state: BackupUiState, onBack: () -> Unit, onExport: (
} }
else -> Unit
}
- if (state is BackupUiState.Idle || state is BackupUiState.Result) backupSection("actions") { ToolBoxGroupedSurface {
- ToolBoxSettingRow("导出备份", summary = "生成、复核,再选择本地位置", icon = ToolBoxIconKey.Share, onClick = onExport, modifier = Modifier.testTag("backup_export"))
- ToolBoxGroupDivider()
- ToolBoxSettingRow("从文件恢复", summary = "先检查,再确认覆盖内容", icon = ToolBoxIconKey.Folder, onClick = onRestore, modifier = Modifier.testTag("backup_restore"))
- } }
- item { AppText("先在私有临时文件中完整生成并原子发布,再写入所选位置。系统文件提供器不统一保证原子替换;写入失败会尝试删除不完整文件,强制结束应用仍可能留下待删除文件。", textStyle = ToolBoxThemeTokens.textStyles.metadata, color = ToolBoxThemeTokens.colors.textSecondary) }
+ if (state is BackupUiState.Result) backupActions(onExport, onRestore)
+ backupSection("technical-details") {
+ Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ ToolBoxTextButton(
+ if (detailsExpanded) "收起备份范围与技术说明" else "备份范围与技术说明",
+ { detailsExpanded = !detailsExpanded },
+ Modifier.fillMaxWidth().testTag("backup_details_toggle").semantics {
+ stateDescription = if (detailsExpanded) "已展开" else "已收起"
+ }, outlined = false,
+ )
+ if (detailsExpanded) {
+ AppText("包含外观、明暗、系统取色、透明度与后台设置;完整工具包、安装信息、ToolBox storage 配置及数据库数据、可解密的安全存储和任务历史。",
+ textStyle = ToolBoxThemeTokens.textStyles.metadata, color = ToolBoxThemeTokens.colors.textSecondary)
+ AppText("不迁移硬件密钥、Android 系统授权、WebView 私有浏览器数据库或活动会话。外部文件访问授权与临时相机缓存需重新获取。",
+ textStyle = ToolBoxThemeTokens.textStyles.metadata, color = ToolBoxThemeTokens.colors.textSecondary)
+ AppText("先在私有临时文件中完整生成并原子发布,再写入所选位置。系统文件提供器不统一保证原子替换;写入失败会尝试删除不完整文件,强制结束应用仍可能留下待删除文件。",
+ textStyle = ToolBoxThemeTokens.textStyles.metadata, color = ToolBoxThemeTokens.colors.textSecondary)
+ }
+ }
+ }
}
}
}
+private fun LazyListScope.backupActions(onExport: () -> Unit, onRestore: () -> Unit) {
+ backupSection("actions") { ToolBoxGroupedSurface {
+ ToolBoxSettingRow("导出备份", summary = "保存为本地 ZIP 文件", icon = ToolBoxIconKey.Share, onClick = onExport, modifier = Modifier.testTag("backup_export"))
+ ToolBoxGroupDivider()
+ ToolBoxSettingRow("从文件恢复", summary = "先检查文件,再确认覆盖", icon = ToolBoxIconKey.Folder, onClick = onRestore, modifier = Modifier.testTag("backup_restore"))
+ } }
+}
+
private fun LazyListScope.backupSection(key: String, content: @Composable () -> Unit) {
item(key) {
Box(Modifier.fillMaxWidth().padding(bottom = 16.dp)) { content() }
@@ -139,11 +175,28 @@ private fun RestorePreviewRow(tool: RestoreToolPlan, first: Boolean, last: Boole
)
Column(Modifier.fillMaxWidth().padding(bottom = if (last) 16.dp else 0.dp)
.clip(shape).background(ToolBoxThemeTokens.colors.surface)) {
- Column(Modifier.fillMaxWidth().heightIn(min = ToolBoxThemeTokens.sizes.denseRow).padding(horizontal = 12.dp, vertical = 8.dp)) {
+ Column(Modifier.fillMaxWidth().heightIn(min = ToolBoxThemeTokens.sizes.denseRow)
+ .padding(horizontal = 12.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
AppText(tool.name, textStyle = ToolBoxThemeTokens.textStyles.title)
- AppText("${tool.current ?: "未安装"} → ${tool.incoming}", textStyle = ToolBoxThemeTokens.textStyles.metadata)
- tool.skipped?.let { AppText("跳过:$it", color = ToolBoxThemeTokens.colors.danger) }
+ if (tool.skipped != null) {
+ AppText("跳过 · ${tool.skipped}", color = ToolBoxThemeTokens.colors.danger)
+ } else {
+ AppText(if (tool.current == null) "新增安装" else "将覆盖本机版本",
+ color = if (tool.current == null) ToolBoxThemeTokens.colors.primary else ToolBoxThemeTokens.colors.danger,
+ textStyle = ToolBoxThemeTokens.textStyles.metadata)
+ }
+ AppText("${tool.current ?: "未安装"} → ${tool.incoming}", textStyle = ToolBoxThemeTokens.textStyles.metadata,
+ color = ToolBoxThemeTokens.colors.textSecondary)
}
if (!last) ToolBoxGroupDivider(startPadding = 12.dp)
}
}
+
+internal data class RestorePlanCounts(val recoverable: Int, val overwritten: Int, val skipped: Int)
+
+/** Skipped conflicts must never be counted as planned overwrites. */
+internal fun restorePlanCounts(tools: List): RestorePlanCounts = RestorePlanCounts(
+ recoverable = tools.count { it.skipped == null },
+ overwritten = tools.count { it.skipped == null && it.current != null },
+ skipped = tools.count { it.skipped != null },
+)
diff --git a/app/src/main/kotlin/io/toolbox/host/browser/BrowserActivity.kt b/app/src/main/kotlin/io/toolbox/host/browser/BrowserActivity.kt
index a55d967e..28200782 100644
--- a/app/src/main/kotlin/io/toolbox/host/browser/BrowserActivity.kt
+++ b/app/src/main/kotlin/io/toolbox/host/browser/BrowserActivity.kt
@@ -50,6 +50,7 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.platform.testTag
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
@@ -146,6 +147,7 @@ class BrowserActivity : ComponentActivity() {
validUrl(url)?.let { address = it }
title = ""
error = null
+ loadProgress = 0
updateNavigation(view)
}
@@ -231,6 +233,7 @@ class BrowserActivity : ComponentActivity() {
private fun reload() {
error = null
+ loadProgress = 0
if (webView == null) createPage() else webView?.reload()
}
@@ -254,6 +257,18 @@ class BrowserActivity : ComponentActivity() {
private fun unsupported(message: String) { Toast.makeText(this, message, Toast.LENGTH_LONG).show() }
+ private fun copyAddress() {
+ getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("网址", address))
+ // Android 13+ supplies its own clipboard confirmation.
+ if (android.os.Build.VERSION.SDK_INT < 33) Toast.makeText(this, "链接已复制", Toast.LENGTH_SHORT).show()
+ }
+
+ private fun openSystemBrowser() {
+ try { startActivity(browserViewIntent(address)) }
+ catch (_: android.content.ActivityNotFoundException) { unsupported("没有可用的系统浏览器。") }
+ catch (_: SecurityException) { unsupported("系统阻止了外部浏览器启动。") }
+ }
+
private fun flushCookies() {
// Closing the Activity must not cancel its final cookie write.
cookieWrites.launch { CookieManager.getInstance().flush() }
@@ -330,15 +345,24 @@ class BrowserActivity : ComponentActivity() {
Column(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
ToolBoxIconButton(ToolBoxIconKey.Close, "关闭浏览器", ::finish)
- Column(Modifier.weight(1f)) {
- ToolBoxText(title.ifBlank { "内置浏览器" }, maxLines = 1, overflow = TextOverflow.Ellipsis)
- val uri = Uri.parse(address)
+ Column(
+ Modifier.weight(1f).heightIn(min = ToolBoxThemeTokens.sizes.touchTarget)
+ .clip(RoundedCornerShape(ToolBoxThemeTokens.radii.control))
+ .clickable(role = Role.Button, onClickLabel = "查看完整地址", onClick = { fullAddress = true })
+ .padding(horizontal = 8.dp, vertical = 6.dp).testTag("browser_address"),
+ verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
+ ) {
+ ToolBoxText(title.ifBlank { "内置浏览器" }, maxLines = 1, overflow = TextOverflow.Ellipsis,
+ style = ToolBoxThemeTokens.textStyles.title.copy(color = colors.textPrimary))
+ val uri = remember(address) { Uri.parse(address) }
+ val unencrypted = uri.scheme == "http"
ToolBoxText(
- (if (uri.scheme == "http") "未加密 · " else "") + uri.host.orEmpty(),
+ (if (unencrypted) "未加密 · " else "") + uri.host.orEmpty(),
maxLines = 1, overflow = TextOverflow.Ellipsis,
+ style = ToolBoxThemeTokens.textStyles.metadata.copy(color = if (unencrypted) colors.danger else colors.textSecondary),
)
}
- ToolBoxTextButton("菜单", { menu = true }, outlined = false)
+ ToolBoxIconButton(ToolBoxIconKey.More, "浏览器菜单", { menu = true })
}
Box(Modifier.fillMaxWidth().height(2.dp)) {
if (loadProgress in 0..99) Box(Modifier.fillMaxWidth(loadProgress / 100f).fillMaxHeight().background(colors.primary))
@@ -352,28 +376,33 @@ class BrowserActivity : ComponentActivity() {
horizontalAlignment = Alignment.CenterHorizontally,
) {
ToolBoxText(message)
+ Spacer(Modifier.height(12.dp))
ToolBoxTextButton("重新加载", ::reload, enabled = !clearing)
+ ToolBoxTextButton("使用系统浏览器", ::openSystemBrowser, enabled = !clearing, outlined = false)
}
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
- ToolBoxIconButton(ToolBoxIconKey.Back, "网页后退", { webView?.goBack() }, enabled = canBack)
- ToolBoxIconButton(ToolBoxIconKey.ChevronRight, "网页前进", { webView?.goForward() }, enabled = canForward)
+ ToolBoxIconButton(ToolBoxIconKey.Back, "网页后退", { webView?.goBack() }, enabled = canBack && !clearing)
+ ToolBoxIconButton(ToolBoxIconKey.ChevronRight, "网页前进", { webView?.goForward() }, enabled = canForward && !clearing)
ToolBoxIconButton(ToolBoxIconKey.Refresh, "重新加载", ::reload, enabled = !clearing)
}
}
fullScreenView?.let { view -> AndroidView(factory = { view }, modifier = Modifier.fillMaxSize().background(androidx.compose.ui.graphics.Color.Black)) }
}
if (menu) ToolBoxModalDialog(onDismissRequest = { menu = false }) {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ ToolBoxText("浏览器菜单", Modifier.weight(1f).semantics { heading() },
+ style = ToolBoxThemeTokens.textStyles.title.copy(color = colors.textPrimary))
+ ToolBoxIconButton(ToolBoxIconKey.Close, "关闭菜单", { menu = false })
+ }
+ Spacer(Modifier.height(8.dp))
Column(Modifier.fillMaxWidth()
.semantics { paneTitle = "浏览器菜单" }
.clip(RoundedCornerShape(ToolBoxThemeTokens.radii.denseSurface))) {
BrowserMenuAction("查看完整地址", ToolBoxIconKey.Note) { menu = false; fullAddress = true }
ToolBoxGroupDivider(startPadding = 52.dp, endPadding = 14.dp)
- BrowserMenuAction("复制链接", ToolBoxIconKey.Clipboard) {
- getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("网址", address))
- menu = false
- }
+ BrowserMenuAction("复制链接", ToolBoxIconKey.Clipboard) { copyAddress(); menu = false }
ToolBoxGroupDivider(startPadding = 52.dp, endPadding = 14.dp)
BrowserMenuAction("分享链接", ToolBoxIconKey.Share) {
menu = false
@@ -382,12 +411,7 @@ class BrowserActivity : ComponentActivity() {
} catch (_: android.content.ActivityNotFoundException) { unsupported("没有可用的分享应用。") }
}
ToolBoxGroupDivider(startPadding = 52.dp, endPadding = 14.dp)
- BrowserMenuAction("使用系统浏览器", ToolBoxIconKey.Globe) {
- menu = false
- try { startActivity(browserViewIntent(address)) }
- catch (_: android.content.ActivityNotFoundException) { unsupported("没有可用的系统浏览器。") }
- catch (_: SecurityException) { unsupported("系统阻止了外部浏览器启动。") }
- }
+ BrowserMenuAction("使用系统浏览器", ToolBoxIconKey.Globe) { menu = false; openSystemBrowser() }
}
Spacer(Modifier.height(12.dp))
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(ToolBoxThemeTokens.radii.denseSurface))
@@ -413,7 +437,9 @@ class BrowserActivity : ComponentActivity() {
ToolBoxText(address, modifier = Modifier.fillMaxWidth(),
style = ToolBoxThemeTokens.textStyles.body.copy(color = colors.textSecondary))
}
- Spacer(Modifier.height(24.dp))
+ Spacer(Modifier.height(16.dp))
+ ToolBoxTextButton("复制链接", ::copyAddress, Modifier.fillMaxWidth().testTag("browser_copy_address"), outlined = false)
+ Spacer(Modifier.height(8.dp))
ToolBoxSecondaryButton("关闭", { fullAddress = false }, modifier = Modifier.fillMaxWidth())
}
if (clearConfirmation) ToolBoxModalDialog(onDismissRequest = { if (!clearing) clearConfirmation = false }) {
diff --git a/app/src/main/kotlin/io/toolbox/host/navigation/ToolBoxNavigation.kt b/app/src/main/kotlin/io/toolbox/host/navigation/ToolBoxNavigation.kt
index 5b4b40f9..93711fa1 100644
--- a/app/src/main/kotlin/io/toolbox/host/navigation/ToolBoxNavigation.kt
+++ b/app/src/main/kotlin/io/toolbox/host/navigation/ToolBoxNavigation.kt
@@ -493,6 +493,8 @@ private fun SecondaryRouteContent(
onBack = onBack,
onSelectTool = { onNavigate(PermissionCenterRoute(it)) },
onReady = onReady,
+ packages = dependencies.packageOperations,
+ grants = dependencies.repositories.grants,
)
AppearanceRoute -> AppearanceScreen(
@@ -653,4 +655,4 @@ private fun NavigationInputBlocker(modifier: Modifier = Modifier) {
private class RuntimeEntryViewModelStoreOwner : ViewModelStoreOwner {
override val viewModelStore = ViewModelStore()
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/io/toolbox/host/permissions/PermissionCenterViewModel.kt b/app/src/main/kotlin/io/toolbox/host/permissions/PermissionCenterViewModel.kt
index 7468081f..4e8f3f9f 100644
--- a/app/src/main/kotlin/io/toolbox/host/permissions/PermissionCenterViewModel.kt
+++ b/app/src/main/kotlin/io/toolbox/host/permissions/PermissionCenterViewModel.kt
@@ -242,8 +242,8 @@ internal class PermissionCenterViewModel(
result: Deferred,
) {
val operationId = ++nextOperationId
- feedbackOperationId = operationId
activeOperations[capability] = operationId
+ feedbackOperationId = operationId
mutableState.value = mutableState.value.copy(
busyCapabilities = busyCapabilities(), message = null, showSystemSettings = false,
)
@@ -326,7 +326,7 @@ internal class PermissionCenterViewModel(
}
}
-private fun String.defaultEnabled() = this in setOf(
+internal fun String.defaultEnabled() = this in setOf(
"storage",
"storage.secure",
"device.basic",
@@ -365,4 +365,4 @@ private fun String.capabilityTitle(): String = when (this) {
else -> this
}
-internal const val EXACT_ALARM_PERMISSION = "android.permission.SCHEDULE_EXACT_ALARM"
+internal const val EXACT_ALARM_PERMISSION = "android.permission.SCHEDULE_EXACT_ALARM"
\ No newline at end of file
diff --git a/app/src/main/kotlin/io/toolbox/host/permissions/ToolPermissionSummary.kt b/app/src/main/kotlin/io/toolbox/host/permissions/ToolPermissionSummary.kt
new file mode 100644
index 00000000..78368a53
--- /dev/null
+++ b/app/src/main/kotlin/io/toolbox/host/permissions/ToolPermissionSummary.kt
@@ -0,0 +1,79 @@
+package io.toolbox.host.permissions
+
+import io.toolbox.core.data.CatalogRepository
+import io.toolbox.core.data.InstalledTool
+import io.toolbox.core.data.PermissionGrant
+import io.toolbox.core.data.PermissionGrantRepository
+import io.toolbox.host.HostInstalledManifestResult
+import io.toolbox.host.HostPackageOperations
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.emitAll
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
+
+internal sealed interface ToolPermissionSummary {
+ data object Loading : ToolPermissionSummary
+ data object Unavailable : ToolPermissionSummary
+ data class Ready(val enabled: Int, val total: Int) : ToolPermissionSummary
+}
+
+internal val ToolPermissionSummary.label: String
+ get() = when (this) {
+ ToolPermissionSummary.Loading -> "正在读取权限…"
+ ToolPermissionSummary.Unavailable -> "状态暂不可用 · 点此查看"
+ is ToolPermissionSummary.Ready -> when {
+ total == 0 -> "未声明权限"
+ enabled == total -> "已允许全部 $total 项"
+ else -> "已允许 $enabled / $total 项 · ${total - enabled} 项关闭"
+ }
+ }
+
+/** Read-only, per-visible-row flow. It never requests system permissions or writes grants. */
+internal class ToolPermissionSummaryReader(
+ private val catalog: CatalogRepository,
+ private val packages: HostPackageOperations,
+ private val grants: PermissionGrantRepository,
+) {
+ private val manifestReads = Semaphore(2)
+
+ fun observe(tool: InstalledTool): Flow = flow {
+ emit(ToolPermissionSummary.Loading)
+ val toolId = tool.metadata.id
+ val expected = tool.currentVersion
+ val found = manifestReads.withPermit { packages.installedManifest(toolId) } as? HostInstalledManifestResult.Found
+ val manifest = found?.manifest
+ if (manifest == null || manifest.toolId != toolId || manifest.versionCode != expected.versionCode || manifest.versionName != expected.version) {
+ emit(ToolPermissionSummary.Unavailable)
+ return@flow
+ }
+ val declared = manifest.permissions.map { it.capability }.toSet()
+ // Compare the full installed version, including integrity and bundle identity.
+ // A same-version replacement must not keep displaying the previous grants summary.
+ emitAll(combine(
+ grants.observeGrants(toolId),
+ catalog.observeTool(toolId).map { it?.currentVersion }.distinctUntilChanged(),
+ ) { values, current ->
+ if (current != expected) ToolPermissionSummary.Unavailable
+ else summarizeToolPermissions(toolId, declared, values)
+ }.distinctUntilChanged())
+ }.catch { failure ->
+ if (failure is CancellationException) throw failure
+ emit(ToolPermissionSummary.Unavailable)
+ }.flowOn(Dispatchers.IO)
+}
+
+internal fun summarizeToolPermissions(toolId: String, declared: Set, grants: List): ToolPermissionSummary.Ready {
+ val values = grants.filter { it.toolId == toolId }.associateBy(PermissionGrant::capability)
+ return ToolPermissionSummary.Ready(
+ enabled = declared.count { capability -> values[capability]?.granted ?: capability.defaultEnabled() },
+ total = declared.size,
+ )
+}
diff --git a/app/src/main/kotlin/io/toolbox/host/permissions/ToolPermissionsScreen.kt b/app/src/main/kotlin/io/toolbox/host/permissions/ToolPermissionsScreen.kt
index ad02879e..2cec4980 100644
--- a/app/src/main/kotlin/io/toolbox/host/permissions/ToolPermissionsScreen.kt
+++ b/app/src/main/kotlin/io/toolbox/host/permissions/ToolPermissionsScreen.kt
@@ -1,32 +1,30 @@
package io.toolbox.host.permissions
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
+import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.toolbox.core.data.CatalogRepository
import io.toolbox.core.data.InstalledTool
+import io.toolbox.core.data.PermissionGrantRepository
import io.toolbox.core.ui.component.ToolBoxCard
+import io.toolbox.core.ui.component.ToolBoxIcon
import io.toolbox.core.ui.component.ToolBoxIconKey
-import io.toolbox.core.ui.component.ToolBoxSettingRow
import io.toolbox.core.ui.component.ToolBoxText
import io.toolbox.core.ui.theme.ToolBoxThemeTokens
+import io.toolbox.host.HostPackageOperations
import io.toolbox.host.ui.CatalogLazyGroupItem
+import io.toolbox.host.ui.CatalogToolGlyph
import io.toolbox.host.ui.DetailScreen
import io.toolbox.host.ui.SectionHeader
+import io.toolbox.host.ui.ToolVisual
import io.toolbox.host.ui.mergePadding
import kotlinx.coroutines.flow.onEach
@@ -36,43 +34,38 @@ internal fun ToolPermissionsScreen(
onBack: () -> Unit,
onSelectTool: (String) -> Unit,
onReady: () -> Unit = {},
+ packages: HostPackageOperations? = null,
+ grants: PermissionGrantRepository? = null,
) {
var toolsLoaded by remember(catalog) { mutableStateOf(false) }
- val toolsFlow = remember(catalog) {
- catalog.observeTools().onEach { toolsLoaded = true }
- }
+ val toolsFlow = remember(catalog) { catalog.observeTools().onEach { toolsLoaded = true } }
val tools by toolsFlow.collectAsStateWithLifecycle(initialValue = emptyList())
- LaunchedEffect(toolsLoaded) {
- if (toolsLoaded) onReady()
+ val summaryReader = remember(catalog, packages, grants) {
+ if (packages == null || grants == null) null else ToolPermissionSummaryReader(catalog, packages, grants)
}
+ LaunchedEffect(toolsLoaded) { if (toolsLoaded) onReady() }
DetailScreen(title = "工具权限", onBack = onBack) { chromePadding ->
LazyColumn(
- modifier = Modifier
- .widthIn(max = ToolBoxThemeTokens.sizes.detailContentMaxWidth)
- .fillMaxWidth()
- .fillMaxSize()
- .align(Alignment.TopCenter),
- contentPadding = mergePadding(
- chromePadding,
- PaddingValues(ToolBoxThemeTokens.spacing.two),
- ),
+ modifier = Modifier.widthIn(max = ToolBoxThemeTokens.sizes.detailContentMaxWidth)
+ .fillMaxWidth().fillMaxSize().align(Alignment.TopCenter),
+ contentPadding = mergePadding(chromePadding, PaddingValues(ToolBoxThemeTokens.spacing.two)),
) {
if (tools.isEmpty()) {
item("empty") {
ToolBoxCard {
ToolBoxText(
text = if (toolsLoaded) "尚未安装工具" else "正在读取工具",
- style = ToolBoxThemeTokens.textStyles.body.copy(
- color = ToolBoxThemeTokens.colors.textSecondary,
- ),
+ style = ToolBoxThemeTokens.textStyles.body.copy(color = ToolBoxThemeTokens.colors.textSecondary),
)
}
}
} else {
item("installed-title") {
- Column(Modifier.padding(bottom = ToolBoxThemeTokens.spacing.one)) {
+ Column(Modifier.padding(bottom = ToolBoxThemeTokens.spacing.one), verticalArrangement = Arrangement.spacedBy(6.dp)) {
SectionHeader("已安装工具 · ${tools.size}")
+ ToolBoxText("摘要为 ToolBox 内部授权;系统权限请进入详情核对。",
+ style = ToolBoxThemeTokens.textStyles.metadata.copy(color = ToolBoxThemeTokens.colors.textSecondary))
}
}
itemsIndexed(
@@ -81,7 +74,17 @@ internal fun ToolPermissionsScreen(
contentType = { _, _ -> "permission-tool" },
) { index, tool ->
CatalogLazyGroupItem(index = index, count = tools.size) {
- ToolPermissionSelectionRow(tool, onSelectTool)
+ // Reset collected state, not just the flow, when an installed identity changes.
+ key(tool.currentVersion, summaryReader) {
+ val summary = if (summaryReader == null) {
+ ToolPermissionSummary.Unavailable
+ } else {
+ val summaryFlow = remember(tool.currentVersion, summaryReader) { summaryReader.observe(tool) }
+ val value by summaryFlow.collectAsStateWithLifecycle(initialValue = ToolPermissionSummary.Loading)
+ value
+ }
+ ToolPermissionSelectionRow(tool, summary, onSelectTool)
+ }
}
}
}
@@ -90,14 +93,20 @@ internal fun ToolPermissionsScreen(
}
@Composable
-private fun ToolPermissionSelectionRow(
- tool: InstalledTool,
- onSelectTool: (String) -> Unit,
-) {
- ToolBoxSettingRow(
- title = tool.metadata.name,
- summary = "版本 ${tool.currentVersion.version}",
- icon = ToolBoxIconKey.Tools,
- onClick = { onSelectTool(tool.metadata.id) },
- )
+private fun ToolPermissionSelectionRow(tool: InstalledTool, summary: ToolPermissionSummary, onSelectTool: (String) -> Unit) {
+ val colors = ToolBoxThemeTokens.colors
+ Row(
+ Modifier.fillMaxWidth().heightIn(min = 72.dp)
+ .clickable(role = Role.Button, onClickLabel = "管理${tool.metadata.name}的权限", onClick = { onSelectTool(tool.metadata.id) })
+ .padding(horizontal = ToolBoxThemeTokens.spacing.oneHalf, vertical = ToolBoxThemeTokens.spacing.one)
+ .testTag("permission_summary:${tool.metadata.id}"),
+ verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ CatalogToolGlyph(tool.metadata.id, tool.currentVersion.versionCode, ToolVisual(ToolBoxIconKey.Tools, colors.primary), size = 40.dp)
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ ToolBoxText(tool.metadata.name, style = ToolBoxThemeTokens.textStyles.title.copy(color = colors.textPrimary))
+ ToolBoxText(summary.label, style = ToolBoxThemeTokens.textStyles.metadata.copy(color = colors.textSecondary))
+ }
+ ToolBoxIcon(ToolBoxIconKey.ChevronRight, null, Modifier.size(18.dp), tint = colors.textSecondary)
+ }
}
diff --git a/app/src/main/kotlin/io/toolbox/host/settings/AppearanceScreen.kt b/app/src/main/kotlin/io/toolbox/host/settings/AppearanceScreen.kt
index 40c9b699..d1a77f05 100644
--- a/app/src/main/kotlin/io/toolbox/host/settings/AppearanceScreen.kt
+++ b/app/src/main/kotlin/io/toolbox/host/settings/AppearanceScreen.kt
@@ -1,21 +1,22 @@
package io.toolbox.host.settings
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.toolbox.core.data.ThemeMode
-import io.toolbox.core.ui.component.ToolBoxGroupDivider
-import io.toolbox.core.ui.component.ToolBoxGroupedSurface
-import io.toolbox.core.ui.component.ToolBoxSwitchSettingRow
-import io.toolbox.core.ui.component.ToolBoxTextButton
+import io.toolbox.core.data.ThemeStyle
+import io.toolbox.core.ui.component.*
import io.toolbox.core.ui.theme.ToolBoxThemeTokens
import io.toolbox.host.ui.AppText
import io.toolbox.host.ui.DetailScreen
@@ -58,7 +59,7 @@ internal fun AppearanceContent(
onThemeModeSelected: (ThemeMode) -> Unit,
onReduceTransparencyChanged: (Boolean) -> Unit,
onRetry: () -> Unit,
- onThemeStyleSelected: (io.toolbox.core.data.ThemeStyle) -> Unit = {},
+ onThemeStyleSelected: (ThemeStyle) -> Unit = {},
contentPadding: PaddingValues = PaddingValues(
horizontal = ToolBoxThemeTokens.spacing.two,
vertical = ToolBoxThemeTokens.spacing.oneHalf,
@@ -73,38 +74,35 @@ internal fun AppearanceContent(
item("error") {
SurfaceCard {
AppText(error, color = ToolBoxThemeTokens.colors.danger)
- if (state.canRetry) {
- ToolBoxTextButton(label = "重试", onClick = onRetry)
- }
+ if (state.canRetry) ToolBoxTextButton(label = "重试", onClick = onRetry)
}
}
item("error-gap") { Spacer(Modifier.height(ToolBoxThemeTokens.spacing.oneHalf)) }
}
-
- item("style-title") { SectionHeader("当前样式") }
+ item("preview") { AppearancePreview(settings.themeStyle, settings.theme) }
+ item("preview-gap") { Spacer(Modifier.height(ToolBoxThemeTokens.spacing.two)) }
+ item("style-title") { SectionHeader("界面样式") }
item("style-gap") { Spacer(Modifier.height(ToolBoxThemeTokens.spacing.one)) }
item("current-style") {
- ToolBoxGroupedSurface {
- io.toolbox.core.ui.component.ToolBoxChoiceSettingRow(
- title = "界面样式", selectedValue = settings.themeStyle.name,
- choices = listOf(
- io.toolbox.core.ui.component.ToolBoxSettingChoice("MIUIX", "Miuix"),
- io.toolbox.core.ui.component.ToolBoxSettingChoice("LIQUID_GLASS", "Liquid Glass"),
- ),
- onSelected = { onThemeStyleSelected(io.toolbox.core.data.ThemeStyle.valueOf(it)) },
- enabled = state.loaded,
- modifier = Modifier.testTag(HostTestTags.AppearanceLiquidGlass),
- )
+ ToolBoxGroupedSurface(Modifier.testTag(HostTestTags.AppearanceLiquidGlass)) {
+ ThemeStyle.entries.forEachIndexed { index, style ->
+ ToolBoxRadioSettingRow(
+ title = style.displayLabel,
+ selected = settings.themeStyle == style,
+ onClick = { if (settings.themeStyle != style) onThemeStyleSelected(style) },
+ enabled = state.loaded,
+ )
+ if (index < ThemeStyle.entries.lastIndex) ToolBoxGroupDivider()
+ }
}
}
-
item("color-gap") { Spacer(Modifier.height(ToolBoxThemeTokens.spacing.two)) }
item("color-title") { SectionHeader("明暗模式") }
item("color-title-gap") { Spacer(Modifier.height(ToolBoxThemeTokens.spacing.one)) }
item("color-settings") {
ToolBoxGroupedSurface {
listOf(ThemeMode.SYSTEM to "跟随系统", ThemeMode.LIGHT to "浅色", ThemeMode.DARK to "深色").forEachIndexed { index, (mode, label) ->
- io.toolbox.core.ui.component.ToolBoxRadioSettingRow(
+ ToolBoxRadioSettingRow(
title = label, selected = settings.theme.baseMode == mode,
onClick = { onThemeModeSelected(mode.withSystemColor(settings.theme.usesSystemColor)) },
enabled = state.loaded,
@@ -114,10 +112,9 @@ internal fun AppearanceContent(
}
}
}
-
item("system-color-gap") { Spacer(Modifier.height(ToolBoxThemeTokens.spacing.two)) }
item("system-color-title") {
- SectionHeader(if (settings.themeStyle == io.toolbox.core.data.ThemeStyle.LIQUID_GLASS) "颜色与材质" else "颜色")
+ SectionHeader(if (settings.themeStyle == ThemeStyle.LIQUID_GLASS) "颜色与材质" else "颜色")
}
item("system-color-title-gap") { Spacer(Modifier.height(ToolBoxThemeTokens.spacing.one)) }
item("system-color-settings") {
@@ -125,19 +122,17 @@ internal fun AppearanceContent(
ToolBoxSwitchSettingRow(
title = "系统取色",
modifier = Modifier.testTag(HostTestTags.AppearanceSystemColor),
- summary = "仅用于强调色,内容底板保持中性。",
+ summary = "强调色跟随系统,底板保持中性",
checked = settings.theme.usesSystemColor,
- onCheckedChange = { enabled ->
- onThemeModeSelected(settings.theme.baseMode.withSystemColor(enabled))
- },
+ onCheckedChange = { enabled -> onThemeModeSelected(settings.theme.baseMode.withSystemColor(enabled)) },
enabled = state.loaded,
)
- if (settings.themeStyle == io.toolbox.core.data.ThemeStyle.LIQUID_GLASS) {
+ if (settings.themeStyle == ThemeStyle.LIQUID_GLASS) {
ToolBoxGroupDivider(startPadding = ToolBoxThemeTokens.spacing.oneHalf)
ToolBoxSwitchSettingRow(
title = "降低透明度",
modifier = Modifier.testTag(HostTestTags.AppearanceReduceTransparency),
- summary = "关闭实时模糊,使用同色实色材质。",
+ summary = "关闭实时模糊,使用实色材质",
checked = settings.reduceTransparency,
onCheckedChange = onReduceTransparencyChanged,
enabled = state.loaded,
@@ -145,11 +140,10 @@ internal fun AppearanceContent(
}
}
}
-
item("footer-gap") { Spacer(Modifier.height(ToolBoxThemeTokens.spacing.two)) }
item("footer") {
AppText(
- "外观仅影响 ToolBox 宿主界面,工具内部页面继续使用自己的设计。切换会自动保存并立即生效。",
+ "更改自动保存,仅影响 ToolBox;小工具内部样式不变。",
textStyle = ToolBoxThemeTokens.textStyles.metadata,
color = ToolBoxThemeTokens.colors.textSecondary,
)
@@ -157,6 +151,35 @@ internal fun AppearanceContent(
}
}
+/** Uses the live theme's real card, type and color tokens; no second theme or animated mock UI. */
+@Composable
+private fun AppearancePreview(style: ThemeStyle, mode: ThemeMode) {
+ val colors = ToolBoxThemeTokens.colors
+ Column(Modifier.fillMaxWidth().testTag("appearance_preview"), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ SectionHeader("颜色与排版预览")
+ SurfaceCard(Modifier.clearAndSetSemantics {
+ contentDescription = "外观预览:${style.displayLabel},${mode.baseLabel}"
+ }) {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Box(Modifier.size(40.dp).background(colors.softPrimary, RoundedCornerShape(ToolBoxThemeTokens.radii.control)),
+ contentAlignment = Alignment.Center) {
+ ToolBoxIcon(ToolBoxIconKey.Tools, null, tint = colors.primary)
+ }
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ AppText("工具名称", textStyle = ToolBoxThemeTokens.textStyles.title)
+ AppText("说明文字预览", textStyle = ToolBoxThemeTokens.textStyles.metadata, color = colors.textSecondary)
+ }
+ }
+ Spacer(Modifier.height(12.dp))
+ ToolBoxGroupDivider(startPadding = 0.dp)
+ Spacer(Modifier.height(12.dp))
+ AppText("${style.displayLabel} · ${mode.baseLabel}", color = colors.primary,
+ textStyle = ToolBoxThemeTokens.textStyles.metadata)
+ }
+ }
+}
+
private val ThemeMode.baseMode: ThemeMode
get() = when (this) {
ThemeMode.SYSTEM, ThemeMode.MONET_SYSTEM -> ThemeMode.SYSTEM
diff --git a/app/src/main/kotlin/io/toolbox/host/settings/SettingsScreen.kt b/app/src/main/kotlin/io/toolbox/host/settings/SettingsScreen.kt
index c2e8fc44..cb53db16 100644
--- a/app/src/main/kotlin/io/toolbox/host/settings/SettingsScreen.kt
+++ b/app/src/main/kotlin/io/toolbox/host/settings/SettingsScreen.kt
@@ -26,7 +26,6 @@ import io.toolbox.core.ui.component.ToolBoxGroupDivider
import io.toolbox.core.ui.component.ToolBoxGroupedSurface
import io.toolbox.core.ui.component.ToolBoxIconKey
import io.toolbox.core.ui.component.ToolBoxSettingRow
-import io.toolbox.core.ui.component.ToolBoxValueRow
import io.toolbox.core.ui.component.ToolBoxSwitchSettingRow
import io.toolbox.core.ui.component.ToolBoxTextButton
import io.toolbox.core.ui.component.ToolBoxBusyIndicator
@@ -92,16 +91,12 @@ internal fun SettingsContent(
item("background-error") { BackgroundSettingsFeedback(state, onRetryBackground) }
item("after-background-error") { Spacer(Modifier.height(ToolBoxThemeTokens.spacing.oneHalf)) }
}
- item("appearance-title") { SectionHeader("外观") }
- item("before-appearance") { Spacer(Modifier.height(ToolBoxThemeTokens.spacing.one)) }
item("appearance") {
ToolBoxGroupedSurface {
- ToolBoxValueRow("界面样式", if (state.settings.themeStyle == io.toolbox.core.data.ThemeStyle.MIUIX) "Miuix" else "Liquid Glass", icon = ToolBoxIconKey.Palette)
- ToolBoxGroupDivider()
ToolBoxSettingRow(
- title = "外观模式",
+ title = "外观",
modifier = Modifier.testTag(HostTestTags.SettingsAppearance),
- summary = state.settings.theme.baseLabel,
+ summary = "${state.settings.themeStyle.displayLabel} · ${state.settings.theme.baseLabel}",
icon = ToolBoxIconKey.Palette,
onClick = onAppearance,
enabled = state.loaded,
@@ -125,7 +120,7 @@ internal fun SettingsContent(
ToolBoxGroupDivider()
ToolBoxSettingRow(
title = "工具权限",
- summary = "按工具管理已声明的能力",
+ summary = "查看授权状态,按工具调整",
icon = ToolBoxIconKey.Shield,
onClick = onToolPermissions,
enabled = state.loaded,
@@ -134,7 +129,7 @@ internal fun SettingsContent(
}
item("backup-gap") { Spacer(Modifier.height(ToolBoxThemeTokens.spacing.two)) }
item("backup") { ToolBoxGroupedSurface {
- ToolBoxSettingRow("备份与恢复", summary = "宿主设置、工具包与应用数据", icon = ToolBoxIconKey.Folder,
+ ToolBoxSettingRow("备份与恢复", summary = "导出本地备份,或从文件恢复", icon = ToolBoxIconKey.Folder,
onClick = onBackupRestore, enabled = state.loaded, modifier = Modifier.testTag("settings_backup_restore"))
} }
item("support-gap") { Spacer(Modifier.height(ToolBoxThemeTokens.spacing.two)) }
@@ -143,7 +138,7 @@ internal fun SettingsContent(
item("support") {
ToolBoxGroupedSurface {
ToolBoxSettingRow(
- title = "Developer Help",
+ title = "开发者帮助",
summary = "离线手册与四个范例",
icon = ToolBoxIconKey.Code,
onClick = onDeveloperHelp,
@@ -151,7 +146,7 @@ internal fun SettingsContent(
)
ToolBoxGroupDivider()
ToolBoxSettingRow(
- title = "关于 ToolBox", summary = "${BuildConfig.VERSION_NAME} · OpenDesign",
+ title = "关于 ToolBox", summary = "版本 ${BuildConfig.VERSION_NAME}",
icon = ToolBoxIconKey.Tools, onClick = onAbout,
)
}
@@ -187,7 +182,13 @@ internal val SettingsUiState.backgroundProgressSummary: String?
null -> null
}
-private val io.toolbox.core.data.ThemeMode.baseLabel: String
+internal val io.toolbox.core.data.ThemeStyle.displayLabel: String
+ get() = when (this) {
+ io.toolbox.core.data.ThemeStyle.MIUIX -> "Miuix"
+ io.toolbox.core.data.ThemeStyle.LIQUID_GLASS -> "Liquid Glass"
+ }
+
+internal val io.toolbox.core.data.ThemeMode.baseLabel: String
get() = when (this) {
io.toolbox.core.data.ThemeMode.SYSTEM,
io.toolbox.core.data.ThemeMode.MONET_SYSTEM,
diff --git a/app/src/main/kotlin/io/toolbox/host/ui/CatalogActionPanels.kt b/app/src/main/kotlin/io/toolbox/host/ui/CatalogActionPanels.kt
index 8447c6e0..9f0adb42 100644
--- a/app/src/main/kotlin/io/toolbox/host/ui/CatalogActionPanels.kt
+++ b/app/src/main/kotlin/io/toolbox/host/ui/CatalogActionPanels.kt
@@ -19,6 +19,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
@@ -58,11 +59,26 @@ private fun CatalogToolOptionsSession(
) {
var page by rememberSaveable(tool.toolId) { mutableStateOf("options") }
var query by rememberSaveable(tool.toolId) { mutableStateOf("") }
- val newGroupDraft = rememberGroupDraft(null, listOf(tool.toolId))
+ var discardDestination by rememberSaveable(tool.toolId) { mutableStateOf(null) }
+ val initialMembers = listOf(tool.toolId)
+ val newGroupDraft = rememberGroupDraft(null, initialMembers)
val editorListState = rememberLazyListState()
val writes = rememberPanelWrites(state, onAction)
val session = rememberPanelSession()
val dismiss = { session.dismiss { writes.forgetAll(onAction); onDismiss() } }
+ val leaveEditor: (String) -> Unit = { destination ->
+ discardDestination = null
+ writes.forget(EDITOR_WRITE, onAction)
+ newGroupDraft.reset(initialMembers)
+ if (destination == "groups") page = "groups" else dismiss()
+ }
+ val requestLeave: (String) -> Unit = { destination ->
+ if (!writes.isWriting(EDITOR_WRITE, state)) {
+ if (page == "create" && newGroupDraft.hasChanges(null, initialMembers)) discardDestination = destination
+ else if (page == "create") leaveEditor(destination)
+ else dismiss()
+ }
+ }
val currentTool = state.tools.firstOrNull { it.toolId == tool.toolId }
if (state.isLoaded && currentTool == null) {
LaunchedEffect(tool.toolId) { dismiss() }
@@ -72,43 +88,50 @@ private fun CatalogToolOptionsSession(
val favorite = tool.toolId in state.layout.favorites
ToolBoxActionSheet(
title = if (page == "options") displayTool.name else if (page == "groups") "加入分组" else "新建分组",
- onDismissRequest = dismiss,
+ onDismissRequest = { requestLeave("dismiss") },
onBackRequest = {
- when (page) {
- "create" -> {
- writes.forget(EDITOR_WRITE, onAction)
- newGroupDraft.reset(listOf(tool.toolId))
- page = "groups"
- }
- "groups" -> page = "options"
+ when {
+ discardDestination != null -> discardDestination = null
+ page == "create" -> requestLeave("groups")
+ page == "groups" -> page = "options"
else -> dismiss()
}
},
) {
- when (page) {
+ val destination = discardDestination
+ if (destination != null) {
+ DiscardGroupChanges(onDiscard = { leaveEditor(destination) }, onKeepEditing = { discardDestination = null })
+ } else when (page) {
"create" -> GroupEditorContent(
group = null, state = state, creating = true,
draft = newGroupDraft, listState = editorListState,
writes = writes,
onAction = onAction,
- onCancel = { newGroupDraft.reset(listOf(tool.toolId)); page = "groups" }, onClose = dismiss,
- onSaved = { newGroupDraft.reset(listOf(tool.toolId)); page = "groups" },
+ onCancel = { requestLeave("groups") }, onClose = { requestLeave("dismiss") },
+ onSaved = { newGroupDraft.reset(initialMembers); page = "groups" },
)
"groups" -> Column(Modifier.testTag("catalog_group_picker")) {
val groups = state.layout.groups.filter { it.name.contains(query.trim(), ignoreCase = true) }
LazyColumn(Modifier.weight(1f, fill = false), verticalArrangement = Arrangement.spacedBy(8.dp)) {
item("heading") { PanelHeader("加入分组", dismiss, onBack = { page = "options" }) }
+ item("context") {
+ AppText("${displayTool.name} · 已加入 ${state.layout.groups.count { tool.toolId in it.members }} 个分组",
+ textStyle = ToolBoxThemeTokens.textStyles.metadata, color = ToolBoxThemeTokens.colors.textSecondary)
+ AppText("勾选后自动保存", textStyle = ToolBoxThemeTokens.textStyles.metadata, color = ToolBoxThemeTokens.colors.textSecondary)
+ }
item("search") {
ToolBoxSearchField(query, { query = it }, "搜索分组", Modifier.testTag("catalog_group_search"))
}
if (groups.isEmpty()) item("empty") {
PanelEmpty(if (state.layout.groups.isEmpty()) "还没有分组,创建一个来整理工具。" else "没有匹配的分组")
+ if (query.isNotBlank()) ToolBoxTextButton("清除搜索", { query = "" }, outlined = false)
}
items(groups, key = { "group:${it.id}" }) { group ->
val target = "group:${group.id}"
Column {
PanelCheckRow(
title = group.name,
+ summary = "${group.members.size} 个工具",
selected = tool.toolId in group.members,
enabled = !writes.isWriting(target, state),
busy = writes.isWriting(target, state),
@@ -185,29 +208,52 @@ private fun CatalogGroupEditorSession(
onAction: (CatalogAction) -> Unit,
onDismiss: () -> Unit,
) {
- // Dialog content has a separate saveable-state owner. Keep the complete
- // editing session in the caller's composition, including after restoration.
+ // Keep the draft and write receipts outside the sheet's conditional content.
val draft = rememberGroupDraft(group)
val listState = rememberLazyListState()
val writes = rememberPanelWrites(state, onAction)
val session = rememberPanelSession()
+ var discarding by rememberSaveable { mutableStateOf(false) }
val dismiss = { session.dismiss { writes.forgetAll(onAction); onDismiss() } }
+ val requestDismiss = {
+ if (!writes.isWriting(EDITOR_WRITE, state)) {
+ if (draft.hasChanges(group)) discarding = true else dismiss()
+ }
+ }
if (!creating && group == null) {
if (state.isLoaded) LaunchedEffect(Unit) { dismiss() }
return
}
ToolBoxActionSheet(
title = if (creating) "新建分组" else "编辑分组",
- onDismissRequest = dismiss,
+ onDismissRequest = requestDismiss,
onBackRequest = {
- if (draft.confirmingDelete.value) draft.confirmingDelete.value = false else dismiss()
+ when {
+ discarding -> discarding = false
+ draft.confirmingDelete.value -> if (!writes.isWriting(EDITOR_WRITE, state)) draft.confirmingDelete.value = false
+ else -> requestDismiss()
+ }
},
) {
- GroupEditorContent(group, state, creating, draft, listState, writes,
- onAction = onAction, onCancel = dismiss, onClose = dismiss, onSaved = dismiss)
+ if (discarding) {
+ DiscardGroupChanges(onDiscard = dismiss, onKeepEditing = { discarding = false })
+ } else {
+ GroupEditorContent(group, state, creating, draft, listState, writes,
+ onAction = onAction, onCancel = requestDismiss, onClose = requestDismiss, onSaved = dismiss)
+ }
}
}
+@Composable
+private fun DiscardGroupChanges(onDiscard: () -> Unit, onKeepEditing: () -> Unit) {
+ HostConfirmationContent(
+ title = "放弃本次修改?",
+ summary = "分组名称和成员尚未保存。继续编辑会保留当前选择。",
+ confirmLabel = "放弃修改", cancelLabel = "继续编辑", destructive = true,
+ onConfirm = onDiscard, onCancel = onKeepEditing,
+ )
+}
+
@Composable
internal fun CatalogFavoritesPicker(
state: CatalogUiState,
@@ -222,10 +268,17 @@ internal fun CatalogFavoritesPicker(
ToolBoxActionSheet("添加收藏", dismiss) {
LazyColumn(Modifier.fillMaxWidth().testTag("catalog_favorites_picker"), verticalArrangement = Arrangement.spacedBy(8.dp)) {
item("heading") { PanelHeader("添加收藏", dismiss) }
+ item("summary") {
+ AppText("已收藏 ${state.layout.favorites.size} 个 · 勾选后自动保存",
+ textStyle = ToolBoxThemeTokens.textStyles.metadata, color = ToolBoxThemeTokens.colors.textSecondary)
+ }
item("search") {
ToolBoxSearchField(query, { query = it }, "搜索工具", Modifier.testTag("catalog_favorites_search"))
}
- if (tools.isEmpty()) item("empty") { PanelEmpty(if (state.tools.isEmpty()) "导入工具后,可在这里添加收藏。" else "没有匹配的工具") }
+ if (tools.isEmpty()) item("empty") {
+ PanelEmpty(if (state.tools.isEmpty()) "导入工具后,可在这里添加收藏。" else "没有匹配的工具")
+ if (query.isNotBlank()) ToolBoxTextButton("清除搜索", { query = "" }, outlined = false)
+ }
items(tools, key = { "tool:${it.toolId}" }) { tool ->
val target = "favorite:${tool.toolId}"
Column {
@@ -256,6 +309,7 @@ private fun GroupEditorContent(
var members by draft.members
var query by draft.query
var confirmingDelete by draft.confirmingDelete
+ var selectedOnly by rememberSaveable(creating, group?.id) { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current
val finishInput = { focusManager.clearFocus(force = true); keyboard?.hide(); Unit }
@@ -269,9 +323,10 @@ private fun GroupEditorContent(
operationId?.let { onAction(CatalogAction.ForgetLayoutWrite(it)) }
writes.ids = writes.ids - EDITOR_WRITE
}
- val cancel = { finishInput(); forget(); onCancel() }
- val close = { finishInput(); forget(); onClose() }
- val back = { if (confirmingDelete) confirmingDelete = false else cancel() }
+ // Only the owning session discards the draft/receipt after an explicit decision.
+ val cancel = { finishInput(); if (!writing) onCancel() }
+ val close = { finishInput(); if (!writing) onClose() }
+ val back = { if (!writing) { if (confirmingDelete) confirmingDelete = false else cancel() } }
LaunchedEffect(operationId, status) {
if (operationId != null && status == CatalogLayoutWriteStatus.Succeeded) {
forget()
@@ -286,35 +341,35 @@ private fun GroupEditorContent(
}
if (confirmingDelete && group != null) {
LazyColumn(Modifier.fillMaxWidth().testTag("catalog_group_delete_confirmation"), verticalArrangement = Arrangement.spacedBy(16.dp)) {
- item("heading") { PanelHeader("删除分组", close, onBack = back) }
+ item("heading") { PanelHeader("删除分组", close, onBack = back, enabled = !writing) }
item("message") {
ToolBoxText("删除“${group.name}”?组内工具和收藏会保留。", style = ToolBoxThemeTokens.textStyles.body.copy(color = ToolBoxThemeTokens.colors.textPrimary))
}
error?.let { item("error") { PanelError(it, Modifier.testTag("catalog_group_error")) } }
item("actions") {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
- ToolBoxSecondaryButton("取消", { confirmingDelete = false }, Modifier.weight(1f))
- ToolBoxDestructiveButton(
- if (writing) "正在删除…" else "删除分组",
- onClick = {
- forget()
- val id = UUID.randomUUID().toString()
- writes.ids = writes.ids + (EDITOR_WRITE to id)
- onAction(CatalogAction.DeleteGroup(group.id, id))
- },
- modifier = Modifier.weight(1f).testTag("catalog_group_delete_confirm"),
- enabled = !writing,
- )
- }
+ PanelEditorActions(
+ cancel = { confirmingDelete = false }, cancelEnabled = !writing,
+ confirmLabel = if (writing) "正在删除…" else "删除分组",
+ confirmEnabled = !writing, destructive = true,
+ confirmTag = "catalog_group_delete_confirm",
+ confirm = {
+ forget()
+ val id = UUID.randomUUID().toString()
+ writes.ids = writes.ids + (EDITOR_WRITE to id)
+ onAction(CatalogAction.DeleteGroup(group.id, id))
+ },
+ )
}
}
return
}
- val tools = matchingTools(state.tools, query)
+ val memberIds = remember(members) { members.toSet() }
+ val selectedCount = state.tools.count { it.toolId in memberIds }
+ val tools = matchingTools(state.tools, query).let { if (selectedOnly) it.filter { tool -> tool.toolId in memberIds } else it }
Column(Modifier.fillMaxWidth().testTag("catalog_group_editor")) {
LazyColumn(Modifier.weight(1f, fill = false).testTag("catalog_group_members"), state = listState,
verticalArrangement = Arrangement.spacedBy(12.dp)) {
- item("heading") { PanelHeader(if (creating) "新建分组" else "编辑分组", close, onBack = back) }
+ item("heading") { PanelHeader(if (creating) "新建分组" else "编辑分组", close, onBack = back, enabled = !writing) }
item("name") {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ToolBoxText("分组名称", style = ToolBoxThemeTokens.textStyles.label.copy(color = ToolBoxThemeTokens.colors.textSecondary))
@@ -343,9 +398,26 @@ private fun GroupEditorContent(
item("search") {
ToolBoxSearchField(query, { query = it }, "搜索工具", Modifier.testTag("catalog_member_search"))
}
- if (tools.isEmpty()) item("empty") { PanelEmpty(if (state.tools.isEmpty()) "暂无工具,可以先保存空分组。" else "没有匹配的工具") }
+ item("selection-summary") {
+ Column(Modifier.fillMaxWidth()) {
+ AppText("已选 $selectedCount 个 / 共 ${state.tools.size} 个",
+ textStyle = ToolBoxThemeTokens.textStyles.metadata, color = ToolBoxThemeTokens.colors.textSecondary)
+ ToolBoxTextButton(if (selectedOnly) "显示全部工具" else "仅看已选", { selectedOnly = !selectedOnly },
+ Modifier.testTag("catalog_selected_only").semantics {
+ stateDescription = if (selectedOnly) "仅显示已选工具" else "显示全部工具"
+ }, enabled = !writing, outlined = false)
+ }
+ }
+ if (tools.isEmpty()) item("empty") {
+ PanelEmpty(when {
+ state.tools.isEmpty() -> "暂无工具,可以先保存空分组。"
+ selectedOnly -> "没有匹配的已选工具"
+ else -> "没有匹配的工具"
+ })
+ if (query.isNotBlank()) ToolBoxTextButton("清除搜索", { query = "" }, outlined = false)
+ }
items(tools, key = { "tool:${it.toolId}" }) { tool ->
- ToolCheckRow(tool, tool.toolId in members, !writing, "group-tool:${tool.toolId}") { selected ->
+ ToolCheckRow(tool, tool.toolId in memberIds, !writing, "group-tool:${tool.toolId}") { selected ->
members = if (selected) (members + tool.toolId).distinct() else members - tool.toolId
}
}
@@ -359,35 +431,53 @@ private fun GroupEditorContent(
}
}
error?.let { PanelError(it, Modifier.testTag("catalog_group_error")) }
- Row(Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
- ToolBoxSecondaryButton("取消", cancel, Modifier.weight(1f).testTag("catalog_group_cancel"))
- ToolBoxPrimaryButton(
- if (writing) "正在保存…" else "保存",
- onClick = {
- finishInput()
- forget()
- val id = UUID.randomUUID().toString()
- writes.ids = writes.ids + (EDITOR_WRITE to id)
- val installed = state.tools.mapTo(HashSet()) { it.toolId }
- onAction(CatalogAction.SaveGroup(if (creating) null else group?.id, name, members.filter(installed::contains), id))
- },
- modifier = Modifier.weight(1f).testTag("catalog_group_save"),
- enabled = !writing && name.isNotBlank(),
- )
+ PanelEditorActions(
+ cancel = cancel, cancelEnabled = !writing,
+ confirmLabel = if (writing) "正在保存…" else "保存",
+ confirmEnabled = !writing && name.isNotBlank(), confirmTag = "catalog_group_save",
+ confirm = {
+ finishInput()
+ forget()
+ val id = UUID.randomUUID().toString()
+ writes.ids = writes.ids + (EDITOR_WRITE to id)
+ val installed = state.tools.mapTo(HashSet()) { it.toolId }
+ onAction(CatalogAction.SaveGroup(if (creating) null else group?.id, name, members.filter(installed::contains), id))
+ },
+ )
+ }
+}
+
+@Composable
+private fun PanelEditorActions(
+ cancel: () -> Unit, cancelEnabled: Boolean,
+ confirmLabel: String, confirmEnabled: Boolean, confirmTag: String,
+ destructive: Boolean = false, confirm: () -> Unit,
+) {
+ val largeText = LocalDensity.current.fontScale >= 1.3f
+ BoxWithConstraints(Modifier.fillMaxWidth().padding(top = 12.dp)) {
+ val buttons: @Composable (Modifier) -> Unit = { modifier ->
+ ToolBoxSecondaryButton("取消", cancel, modifier.testTag("catalog_group_cancel"), enabled = cancelEnabled)
+ ToolBoxPrimaryButton(confirmLabel, confirm, modifier.testTag(confirmTag), enabled = confirmEnabled, destructive = destructive)
+ }
+ if (maxWidth < 300.dp || largeText) {
+ Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { buttons(Modifier.fillMaxWidth()) }
+ } else {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { buttons(Modifier.weight(1f)) }
}
}
}
@Composable
-private fun PanelHeader(title: String, onClose: () -> Unit, onBack: (() -> Unit)? = null, tool: CatalogTool? = null) {
+private fun PanelHeader(title: String, onClose: () -> Unit, onBack: (() -> Unit)? = null, tool: CatalogTool? = null, enabled: Boolean = true) {
ToolBoxActionSheetHeader(Modifier.testTag("catalog_panel_header")) {
Row(Modifier.fillMaxWidth().heightIn(min = 48.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- if (onBack != null) ToolBoxIconButton(ToolBoxIconKey.Back, "返回", onBack, Modifier.testTag("catalog_panel_back"))
+ if (onBack != null) ToolBoxIconButton(ToolBoxIconKey.Back, "返回", onBack, Modifier.testTag("catalog_panel_back"), enabled = enabled)
if (tool != null) CatalogToolGlyph(tool.toolId, tool.versionCode, tool.visual(ToolBoxThemeTokens.colors.primary))
ToolBoxText(title, Modifier.weight(1f).semantics { heading() }, style = ToolBoxThemeTokens.textStyles.title.copy(color = ToolBoxThemeTokens.colors.textPrimary))
ToolBoxIconButton(
ToolBoxIconKey.Close, "关闭", onClose,
Modifier.clip(RoundedCornerShape(ToolBoxThemeTokens.radii.full)).background(ToolBoxThemeTokens.colors.surfaceMuted).testTag("catalog_panel_close"),
+ enabled = enabled,
)
}
}
@@ -435,6 +525,7 @@ private fun PanelCheckRow(
modifier: Modifier = Modifier,
icon: @Composable () -> Unit,
busy: Boolean = false,
+ summary: String? = null,
onChecked: (Boolean) -> Unit,
) {
val colors = ToolBoxThemeTokens.colors
@@ -447,20 +538,23 @@ private fun PanelCheckRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
icon()
- ToolBoxText(title, Modifier.weight(1f), style = ToolBoxThemeTokens.textStyles.body.copy(
- color = if (enabled) colors.textPrimary else ToolBoxThemeTokens.disabledContent))
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ ToolBoxText(title, style = ToolBoxThemeTokens.textStyles.body.copy(
+ color = if (enabled) colors.textPrimary else ToolBoxThemeTokens.disabledContent))
+ if (!summary.isNullOrBlank()) ToolBoxText(summary, style = ToolBoxThemeTokens.textStyles.metadata.copy(color = colors.textSecondary))
+ }
Box(Modifier.size(24.dp), contentAlignment = Alignment.Center) {
- if (busy) ToolBoxBusyIndicator() else Checkbox(
- state = if (selected) ToggleableState.On else ToggleableState.Off,
- onClick = null, enabled = enabled, modifier = Modifier.clearAndSetSemantics { },
- colors = CheckboxDefaults.checkboxColors(
- checkedForegroundColor = colors.onPrimary,
- checkedBackgroundColor = colors.primary,
- uncheckedBackgroundColor = colors.divider,
- disabledCheckedBackgroundColor = colors.primary.copy(alpha = 0.5f),
- disabledUncheckedBackgroundColor = colors.divider,
- ),
- )
+ if (busy) ToolBoxBusyIndicator() else Checkbox(
+ state = if (selected) ToggleableState.On else ToggleableState.Off,
+ onClick = null, enabled = enabled, modifier = Modifier.clearAndSetSemantics { },
+ colors = CheckboxDefaults.checkboxColors(
+ checkedForegroundColor = colors.onPrimary,
+ checkedBackgroundColor = colors.primary,
+ uncheckedBackgroundColor = colors.divider,
+ disabledCheckedBackgroundColor = colors.primary.copy(alpha = 0.5f),
+ disabledUncheckedBackgroundColor = colors.divider,
+ ),
+ )
}
}
}
@@ -484,6 +578,9 @@ private fun matchingTools(tools: List, query: String): List, originalName: String, originalMembers: List): Boolean =
+ name.trim() != originalName.trim() || members != originalMembers
+
private class GroupDraft(
initialName: String,
initialMembers: List,
@@ -495,6 +592,9 @@ private class GroupDraft(
val query = mutableStateOf(initialQuery)
val confirmingDelete = mutableStateOf(initialConfirmingDelete)
+ fun hasChanges(group: CatalogGroup?, initialMembers: List = emptyList()) =
+ groupDraftHasChanges(name.value, members.value, group?.name.orEmpty(), group?.members ?: initialMembers)
+
fun reset(initialMembers: List) {
name.value = ""
members.value = initialMembers
diff --git a/app/src/test/kotlin/io/toolbox/host/backup/BackupPresentationTest.kt b/app/src/test/kotlin/io/toolbox/host/backup/BackupPresentationTest.kt
new file mode 100644
index 00000000..00848c65
--- /dev/null
+++ b/app/src/test/kotlin/io/toolbox/host/backup/BackupPresentationTest.kt
@@ -0,0 +1,29 @@
+package io.toolbox.host.backup
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class BackupPresentationTest {
+ @Test
+ fun skippedConflictsAreNotCountedAsOverwrites() {
+ val tools = listOf(
+ RestoreToolPlan("new", "New", "2", null),
+ RestoreToolPlan("update", "Update", "2", "1"),
+ RestoreToolPlan("skip", "Skip", "2", "1", "不兼容"),
+ )
+ assertEquals(RestorePlanCounts(2, 1, 1), restorePlanCounts(tools))
+ }
+
+ @Test
+ fun emptyArchiveStillHasAValidSettingsOnlySummary() {
+ assertEquals(RestorePlanCounts(0, 0, 0), restorePlanCounts(emptyList()))
+ }
+
+ @Test
+ fun allSkippedToolsDoNotPromiseAnyInstallOrOverwrite() {
+ assertEquals(RestorePlanCounts(0, 0, 2), restorePlanCounts(listOf(
+ RestoreToolPlan("a", "A", "2", "1", "跳过"),
+ RestoreToolPlan("b", "B", "2", null, "跳过"),
+ )))
+ }
+}
diff --git a/app/src/test/kotlin/io/toolbox/host/catalog/GroupDraftPresentationTest.kt b/app/src/test/kotlin/io/toolbox/host/catalog/GroupDraftPresentationTest.kt
new file mode 100644
index 00000000..942dac15
--- /dev/null
+++ b/app/src/test/kotlin/io/toolbox/host/catalog/GroupDraftPresentationTest.kt
@@ -0,0 +1,26 @@
+package io.toolbox.host.catalog
+
+import io.toolbox.host.ui.groupDraftHasChanges
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class GroupDraftPresentationTest {
+ @Test
+ fun unchangedDraftDoesNotRequireDiscardConfirmation() {
+ assertFalse(groupDraftHasChanges(" Work ", listOf("a"), "Work", listOf("a")))
+ assertFalse(groupDraftHasChanges("", listOf("seed"), "", listOf("seed")))
+ }
+
+ @Test
+ fun editingNameOrMembershipRequiresConfirmation() {
+ assertTrue(groupDraftHasChanges("New", listOf("a"), "Old", listOf("a")))
+ assertTrue(groupDraftHasChanges("Work", emptyList(), "Work", listOf("a")))
+ assertTrue(groupDraftHasChanges("Work", listOf("b", "a"), "Work", listOf("a", "b")))
+ }
+
+ @Test
+ fun newEmptyGroupIsStillADraftOnceNamed() {
+ assertTrue(groupDraftHasChanges("Travel", emptyList(), "", emptyList()))
+ }
+}
diff --git a/app/src/test/kotlin/io/toolbox/host/permissions/ToolPermissionSummaryTest.kt b/app/src/test/kotlin/io/toolbox/host/permissions/ToolPermissionSummaryTest.kt
new file mode 100644
index 00000000..e6f5a0c8
--- /dev/null
+++ b/app/src/test/kotlin/io/toolbox/host/permissions/ToolPermissionSummaryTest.kt
@@ -0,0 +1,32 @@
+package io.toolbox.host.permissions
+
+import io.toolbox.core.data.PermissionGrant
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ToolPermissionSummaryTest {
+ @Test
+ fun explicitRevocationOverridesTheSharedDefault() {
+ assertEquals(ToolPermissionSummary.Ready(1, 2), summarizeToolPermissions("t", setOf("storage", "network"), listOf(
+ PermissionGrant("t", "storage", false, 1), PermissionGrant("t", "network", true, 1),
+ )))
+ }
+
+ @Test
+ fun absentGrantsUseThePermissionDetailsPolicy() {
+ val declared = setOf("storage", "storage.secure", "device.basic", "clipboard.write", "haptics", "network", "camera")
+ assertEquals(ToolPermissionSummary.Ready(5, 7), summarizeToolPermissions("t", declared, emptyList()))
+ }
+
+ @Test
+ fun undeclaredAndOtherToolGrantsNeverInflateTheCount() {
+ assertEquals(ToolPermissionSummary.Ready(0, 1), summarizeToolPermissions("t", setOf("network"), listOf(
+ PermissionGrant("other", "network", true, 1), PermissionGrant("t", "camera", true, 1),
+ )))
+ }
+
+ @Test
+ fun noDeclaredPermissionsAreNotPresentedAsFullyAuthorized() {
+ assertEquals("未声明权限", summarizeToolPermissions("t", emptySet(), emptyList()).label)
+ }
+}
From 347360b66c3d8464efe9268eab374cb9d4e0b6ae Mon Sep 17 00:00:00 2001
From: DoraC <25716974+gkeyes@users.noreply.github.com>
Date: Tue, 22 Sep 2026 16:42:53 +0800
Subject: [PATCH 11/19] fix(ui): settle sheet position when dismissal is
intercepted
---
.../toolbox/core/ui/component/ToolBoxActionSheet.kt | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/core-ui/src/main/java/io/toolbox/core/ui/component/ToolBoxActionSheet.kt b/core-ui/src/main/java/io/toolbox/core/ui/component/ToolBoxActionSheet.kt
index 1e67c80e..e7cc219f 100644
--- a/core-ui/src/main/java/io/toolbox/core/ui/component/ToolBoxActionSheet.kt
+++ b/core-ui/src/main/java/io/toolbox/core/ui/component/ToolBoxActionSheet.kt
@@ -101,11 +101,13 @@ fun ToolBoxActionSheet(
}
if (released && (dragOffset >= dismissDistance || tracker.calculateVelocity().y >= dismissVelocity)) {
dismiss()
- } else {
- settleJob = scope.launch {
- settle.snapTo(dragOffset)
- settle.animateTo(0f) { dragOffset = value }
- }
+ }
+ // A save in progress or an unsaved-draft guard can keep this
+ // sheet open. Restore its position even when dismissal was requested.
+ // Actual dismissal disposes this scope and cancels the animation.
+ settleJob = scope.launch {
+ settle.snapTo(dragOffset)
+ settle.animateTo(0f) { dragOffset = value }
}
} catch (cancelled: CancellationException) {
// Back can replace this header while the same sheet stays open.
From b818122dec51e9b028f049a2fcf40bf6db63cd42 Mon Sep 17 00:00:00 2001
From: DoraC <25716974+gkeyes@users.noreply.github.com>
Date: Tue, 22 Sep 2026 16:51:50 +0800
Subject: [PATCH 12/19] feat(ui): complete secondary polish with contextual
runtime feedback
Preserve existing secondary-page changes and wire the cached tool name into loading and exit feedback without a new catalog subscription. Keep runtime readiness, WebView lifetime, navigation timing and confirmation behavior unchanged.
Use indeterminate feedback rather than a fabricated percentage. Keep short errors and startup warnings visible, while long runtime diagnostics retain their first reason and full selectable details. Preserve old composable signatures and add six JVM presentation regressions.
No changes to dependencies, permissions, database, package identity, version, signing or emulator-test policy.
---
.../host/navigation/ToolBoxNavigation.kt | 13 +++-
.../toolbox/host/ui/HostCapabilityScreens.kt | 61 ++++++++++++++++---
.../io/toolbox/host/ui/RuntimePresentation.kt | 25 ++++++++
.../host/ui/RuntimePresentationTest.kt | 54 ++++++++++++++++
4 files changed, 143 insertions(+), 10 deletions(-)
create mode 100644 app/src/main/kotlin/io/toolbox/host/ui/RuntimePresentation.kt
create mode 100644 app/src/test/kotlin/io/toolbox/host/ui/RuntimePresentationTest.kt
diff --git a/app/src/main/kotlin/io/toolbox/host/navigation/ToolBoxNavigation.kt b/app/src/main/kotlin/io/toolbox/host/navigation/ToolBoxNavigation.kt
index 93711fa1..68ada8f2 100644
--- a/app/src/main/kotlin/io/toolbox/host/navigation/ToolBoxNavigation.kt
+++ b/app/src/main/kotlin/io/toolbox/host/navigation/ToolBoxNavigation.kt
@@ -141,6 +141,12 @@ internal fun ToolBoxNavigation(
val allSecondaryRoutes = secondaryBackStack.filterIsInstance()
val currentPrimaryRoute = primaryBackStack.lastOrNull()
val runtimeRoute = allSecondaryRoutes.lastOrNull() as? RuntimeRoute
+ // Presentation identity only: reuse the cached catalog without keeping the covered page subscribed.
+ val runtimeTitle = remember(catalogViewModel, runtimeRoute?.toolId) {
+ runtimeRoute?.toolId?.let { id ->
+ catalogViewModel.state.value.tools.firstOrNull { it.toolId == id }?.name
+ }
+ }
val retainedRoutes = if (runtimeRoute == null) allSecondaryRoutes else allSecondaryRoutes.dropLast(1)
val entryProgress = remember(runtimeRoute) { Animatable(if (runtimeRoute == null) 0f else 1f) }
val sourceReturnProgress = remember(runtimeRoute) { Animatable(0f) }
@@ -327,6 +333,7 @@ internal fun ToolBoxNavigation(
dependencies = dependencies,
onPresentationReady = { runtimePresentationReady = true },
onBack = leaveRuntime,
+ toolName = runtimeTitle,
modifier = Modifier
.fillMaxSize()
.zIndex(1f)
@@ -355,7 +362,7 @@ internal fun ToolBoxNavigation(
translationX = trailingDirection * entryProgress.value * size.width
},
) {
- RuntimeShellPreviewContent()
+ RuntimeShellPreviewContent(toolName = runtimeTitle)
}
}
@@ -618,6 +625,7 @@ private fun RuntimeSessionLayer(
onPresentationReady: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
+ toolName: String? = null,
) {
val owner = remember(route) { RuntimeEntryViewModelStoreOwner() }
DisposableEffect(owner) {
@@ -634,6 +642,7 @@ private fun RuntimeSessionLayer(
viewModel = viewModel,
onBack = onBack,
onPresentationReady = onPresentationReady,
+ toolName = toolName,
)
}
}
@@ -655,4 +664,4 @@ private fun NavigationInputBlocker(modifier: Modifier = Modifier) {
private class RuntimeEntryViewModelStoreOwner : ViewModelStoreOwner {
override val viewModelStore = ViewModelStore()
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/io/toolbox/host/ui/HostCapabilityScreens.kt b/app/src/main/kotlin/io/toolbox/host/ui/HostCapabilityScreens.kt
index 12632b5e..1744f8e1 100644
--- a/app/src/main/kotlin/io/toolbox/host/ui/HostCapabilityScreens.kt
+++ b/app/src/main/kotlin/io/toolbox/host/ui/HostCapabilityScreens.kt
@@ -12,8 +12,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.progressSemantics
+import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -24,6 +27,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.viewinterop.AndroidView
@@ -32,17 +37,28 @@ import androidx.compose.ui.unit.dp
import io.toolbox.core.ui.component.ToolBoxModalDialog
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
+import io.toolbox.core.ui.component.ToolBoxBusyIndicator
import io.toolbox.core.ui.component.ToolBoxPrimaryButton
+import io.toolbox.core.ui.component.ToolBoxTextButton
import io.toolbox.core.ui.component.ToolBoxRuntimeScaffold
import io.toolbox.core.ui.theme.ToolBoxThemeTokens
import io.toolbox.host.runtime.RuntimeUiState
import io.toolbox.host.runtime.RuntimeViewModel
+/** Keep the existing call signature, including positional and trailing-lambda callers. */
@Composable
internal fun RuntimeShellScreen(
viewModel: RuntimeViewModel,
onBack: () -> Unit,
onPresentationReady: () -> Unit,
+) = RuntimeShellScreen(viewModel, onBack, onPresentationReady, toolName = null)
+
+@Composable
+internal fun RuntimeShellScreen(
+ viewModel: RuntimeViewModel,
+ onBack: () -> Unit,
+ onPresentationReady: () -> Unit,
+ toolName: String?,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -51,7 +67,7 @@ internal fun RuntimeShellScreen(
if ((state as? RuntimeUiState.Ready)?.mainEntryLoaded == true) onPresentationReady()
}
- RuntimeExitConfirmation(onConfirm = onBack)
+ RuntimeExitConfirmation(onConfirm = onBack, toolName = toolName)
ToolBoxRuntimeScaffold(
modifier = Modifier
.fillMaxSize()
@@ -63,7 +79,7 @@ internal fun RuntimeShellScreen(
.fillMaxSize(),
) {
when (val current = state) {
- RuntimeUiState.Loading -> RuntimeCenteredState("正在打开工具", "正在准备页面。")
+ RuntimeUiState.Loading -> RuntimeCenteredState(runtimeLoadingTitle(toolName), "正在准备页面。")
is RuntimeUiState.Error -> RuntimeErrorState(current.message, viewModel::retry)
is RuntimeUiState.Ready -> {
AndroidView(
@@ -91,9 +107,12 @@ internal fun RuntimeShellScreen(
}
}
+@Composable
+internal fun RuntimeExitConfirmation(onConfirm: () -> Unit) = RuntimeExitConfirmation(onConfirm, toolName = null)
+
/** Confirms host-level Back without detaching or replacing the running WebView. */
@Composable
-internal fun RuntimeExitConfirmation(onConfirm: () -> Unit) {
+internal fun RuntimeExitConfirmation(onConfirm: () -> Unit, toolName: String?) {
var visible by rememberSaveable { mutableStateOf(false) }
var leaving by remember { mutableStateOf(false) }
BackHandler {
@@ -104,7 +123,7 @@ internal fun RuntimeExitConfirmation(onConfirm: () -> Unit) {
ToolBoxModalDialog(onDismissRequest = { visible = false }) {
HostConfirmationContent(
title = "返回 ToolBox?",
- summary = "即将离开当前小工具,返回 ToolBox。请确认需要保留的内容已保存。",
+ summary = runtimeExitSummary(toolName),
confirmLabel = "返回 ToolBox",
cancelLabel = "继续使用",
onCancel = { visible = false },
@@ -121,13 +140,16 @@ internal fun RuntimeExitConfirmation(onConfirm: () -> Unit) {
}
@Composable
-internal fun RuntimeShellPreviewContent() {
+internal fun RuntimeShellPreviewContent() = RuntimeShellPreviewContent(toolName = null)
+
+@Composable
+internal fun RuntimeShellPreviewContent(toolName: String?) {
ToolBoxRuntimeScaffold(
modifier = Modifier
.fillMaxSize()
.background(ToolBoxThemeTokens.colors.background),
) {
- RuntimeCenteredState("正在打开工具", "正在准备页面。")
+ RuntimeCenteredState(runtimeLoadingTitle(toolName), "正在准备页面。")
}
}
@@ -139,6 +161,11 @@ private fun RuntimeCenteredState(title: String, detail: String) {
) {
Column(Modifier.widthIn(max = 480.dp).fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally) {
+ // The runtime exposes readiness, not a measurable percentage. Do not invent progress.
+ Box(Modifier.size(24.dp).progressSemantics(), contentAlignment = Alignment.Center) {
+ ToolBoxBusyIndicator()
+ }
+ Spacer(Modifier.height(ToolBoxThemeTokens.spacing.oneHalf))
AppText(
title,
modifier = Modifier.fillMaxWidth(),
@@ -160,11 +187,12 @@ private fun RuntimeCenteredState(title: String, detail: String) {
@Composable
private fun RuntimeErrorState(message: String, onRetry: () -> Unit) {
+ val presentation = remember(message) { runtimeErrorPresentation(message) }
Box(
Modifier.fillMaxSize().padding(ToolBoxThemeTokens.spacing.twoHalf),
contentAlignment = Alignment.Center,
) {
- HostStatusCard("工具暂时无法打开", message, onRetry)
+ HostStatusCard("工具暂时无法打开", presentation.summary, onRetry, presentation.details)
}
}
@@ -178,6 +206,7 @@ fun HostBootstrapScreen(loading: Boolean, message: String, onRetry: () -> Unit)
.padding(ToolBoxThemeTokens.spacing.twoHalf),
contentAlignment = Alignment.Center,
) {
+ // Startup and recovery warnings remain fully visible, never folded as runtime diagnostics.
HostStatusCard(
if (loading) "正在打开本机工具目录" else "ToolBox 暂时无法启动",
message, onRetry.takeUnless { loading },
@@ -186,7 +215,7 @@ fun HostBootstrapScreen(loading: Boolean, message: String, onRetry: () -> Unit)
}
@Composable
-private fun HostStatusCard(title: String, message: String, onRetry: (() -> Unit)?) {
+private fun HostStatusCard(title: String, message: String, onRetry: (() -> Unit)?, details: String? = null) {
// Short states stay centered; long errors remain reachable in landscape and at large font sizes.
Column(Modifier.widthIn(max = 480.dp).fillMaxWidth().verticalScroll(rememberScrollState())
.testTag("host_status_scroll")) {
@@ -214,6 +243,22 @@ private fun HostStatusCard(title: String, message: String, onRetry: (() -> Unit)
modifier = Modifier.fillMaxWidth().heightIn(min = ToolBoxThemeTokens.sizes.touchTarget),
)
}
+ details?.let { fullMessage ->
+ var expanded by rememberSaveable(fullMessage) { mutableStateOf(false) }
+ ToolBoxTextButton(
+ label = if (expanded) "收起错误详情" else "查看错误详情",
+ onClick = { expanded = !expanded },
+ modifier = Modifier.fillMaxWidth().testTag("runtime_error_details").semantics {
+ stateDescription = if (expanded) "已展开" else "已收起"
+ },
+ outlined = false,
+ )
+ if (expanded) SelectionContainer {
+ AppText(fullMessage, modifier = Modifier.fillMaxWidth(),
+ textStyle = ToolBoxThemeTokens.textStyles.metadata,
+ color = ToolBoxThemeTokens.colors.textSecondary)
+ }
+ }
}
}
}
diff --git a/app/src/main/kotlin/io/toolbox/host/ui/RuntimePresentation.kt b/app/src/main/kotlin/io/toolbox/host/ui/RuntimePresentation.kt
new file mode 100644
index 00000000..c09826de
--- /dev/null
+++ b/app/src/main/kotlin/io/toolbox/host/ui/RuntimePresentation.kt
@@ -0,0 +1,25 @@
+package io.toolbox.host.ui
+
+internal fun runtimeLoadingTitle(toolName: String?): String =
+ toolName?.trim()?.takeIf(String::isNotEmpty) ?: "正在打开工具"
+
+internal fun runtimeExitSummary(toolName: String?): String {
+ val identity = toolName?.trim()?.takeIf(String::isNotEmpty)?.let { "“$it”" } ?: "当前小工具"
+ return "即将离开$identity,返回 ToolBox。请确认需要保留的内容已保存。"
+}
+
+internal data class RuntimeErrorPresentation(val summary: String, val details: String?)
+
+/** Preserve the first actual reason in the summary and the original diagnostic text in full. */
+internal fun runtimeErrorPresentation(message: String): RuntimeErrorPresentation {
+ if (message.length <= 240 && message.count { it == '\n' } <= 2) {
+ return RuntimeErrorPresentation(message, null)
+ }
+ val firstLine = message.lineSequence().firstOrNull(String::isNotBlank)?.trim().orEmpty()
+ val summary = when {
+ firstLine.isEmpty() -> "工具未能完成加载。请展开错误详情查看完整原因。"
+ firstLine.length > 180 -> firstLine.take(180) + "…"
+ else -> firstLine
+ }
+ return RuntimeErrorPresentation(summary, message)
+}
diff --git a/app/src/test/kotlin/io/toolbox/host/ui/RuntimePresentationTest.kt b/app/src/test/kotlin/io/toolbox/host/ui/RuntimePresentationTest.kt
new file mode 100644
index 00000000..22839eea
--- /dev/null
+++ b/app/src/test/kotlin/io/toolbox/host/ui/RuntimePresentationTest.kt
@@ -0,0 +1,54 @@
+package io.toolbox.host.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class RuntimePresentationTest {
+ @Test
+ fun loadingUsesTheKnownNameAndFallsBackForMissingIdentity() {
+ assertEquals("笔记工具", runtimeLoadingTitle(" 笔记工具 "))
+ assertEquals("正在打开工具", runtimeLoadingTitle(null))
+ assertEquals("正在打开工具", runtimeLoadingTitle(" \n "))
+ }
+
+ @Test
+ fun exitKeepsTheSaveWarningWithOrWithoutAName() {
+ assertEquals("即将离开当前小工具,返回 ToolBox。请确认需要保留的内容已保存。", runtimeExitSummary(null))
+ assertEquals("即将离开“笔记工具”,返回 ToolBox。请确认需要保留的内容已保存。", runtimeExitSummary("笔记工具"))
+ }
+
+ @Test
+ fun shortErrorsRemainFullyVisible() {
+ val message = "NETWORK_UNAVAILABLE\n请检查网络后重试。"
+ val result = runtimeErrorPresentation(message)
+ assertEquals(message, result.summary)
+ assertNull(result.details)
+ }
+
+ @Test
+ fun longErrorsRetainTheFirstReasonAndAllOriginalDetails() {
+ val message = "权限校验失败\n" + "diagnostic ".repeat(40)
+ val result = runtimeErrorPresentation(message)
+ assertEquals("权限校验失败", result.summary)
+ assertEquals(message, result.details)
+ }
+
+ @Test
+ fun multilineErrorsDoNotDropBlankLinesOrDiagnosticText() {
+ val message = "\n错误原因\n第一项\n第二项"
+ val result = runtimeErrorPresentation(message)
+ assertEquals("错误原因", result.summary)
+ assertEquals(message, result.details)
+ }
+
+ @Test
+ fun veryLongSingleLineSummariesAreBoundedButRecoverable() {
+ val message = "X".repeat(300)
+ val result = runtimeErrorPresentation(message)
+ assertEquals(181, result.summary.length)
+ assertTrue(result.summary.endsWith("…"))
+ assertEquals(message, result.details)
+ }
+}
From 83b90e0e0633774b3c42b35b93176a6c7e147b44 Mon Sep 17 00:00:00 2001
From: DoraC <25716974+gkeyes@users.noreply.github.com>
Date: Tue, 22 Sep 2026 17:00:23 +0800
Subject: [PATCH 13/19] ci: sign latest stage-two Release APK with the original
certificate
---
.github/workflows/ui-polish-signed.yml | 31 +++++++++++++-------------
1 file changed, 16 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/ui-polish-signed.yml b/.github/workflows/ui-polish-signed.yml
index 8b60c02f..f4d3d4b0 100644
--- a/.github/workflows/ui-polish-signed.yml
+++ b/.github/workflows/ui-polish-signed.yml
@@ -20,12 +20,12 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 15
env:
- SOURCE_RUN: "35680323971"
- SOURCE_ARTIFACT: "10674187065"
- SOURCE_HEAD: "09dd867350d022a1e96c99d6c12a74051ec7635d"
- SOURCE_BUILD: "8b807ef395057954b73b6e6509eab8ff80786991"
- SOURCE_ZIP_SHA256: "0be51ecefed54f1a83d501a5cdd73ed4541140f0931f7ed0a7266597929821a6"
- SOURCE_APK_SHA256: "5bad9a1b881eb46881069567280419d4165ff0ca8a38bdae145db6b4590634bf"
+ SOURCE_RUN: "35707164552"
+ SOURCE_ARTIFACT: "10684679800"
+ SOURCE_HEAD: "b818122dec51e9b028f049a2fcf40bf6db63cd42"
+ SOURCE_BUILD: "986988fea15ea414bec3a0529004911253679711"
+ SOURCE_ZIP_SHA256: "0467358036ef266f59ef606a53fc50b3b546c336afbb7a87f8a3afc8886f4fd9"
+ SOURCE_APK_SHA256: "734b8705d5f4e3e3c617c713166e67f94f76767de3c793a19caea5f5bc40fe80"
REFERENCE_APK_SHA256: "193bb200934c5e04138166e071b1161f1500a530236d053e08bce22373092e3a"
RELEASE_CERT_SHA256: "849be1fd8066964d6194faa538fc0b17b4b17e5c116304e1d3be8ff725e4c71d"
steps:
@@ -77,7 +77,7 @@ jobs:
assert metadata['elements'][0]['versionCode'] == 38
assert metadata['elements'][0]['versionName'] == '0.8.5'
(p / 'unsigned.apk').write_bytes(apk)
- print('Pinned successful Release APK: io.toolbox.host 0.8.5 (38). No rebuild or UI tests.')
+ print('Pinned successful stage-two Release APK: io.toolbox.host 0.8.5 (38). No rebuild or UI tests.')
PY
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961
with:
@@ -117,13 +117,13 @@ jobs:
--ks-key-alias "$TOOLBOX_SIGNING_KEY_ALIAS" \
--ks-pass env:TOOLBOX_SIGNING_STORE_PASSWORD --key-pass env:TOOLBOX_SIGNING_KEY_PASSWORD \
--v1-signing-enabled false --v2-signing-enabled true --v3-signing-enabled false \
- --v4-signing-enabled false --out delivery/toolbox-v0.8.5-ui-polish-signed.apk input/aligned.apk
+ --v4-signing-enabled false --out delivery/toolbox-v0.8.5-ui-stage2-release.apk input/aligned.apk
- name: Verify actual APK certificates, package, version and unchanged payload
shell: bash
run: |
set -euo pipefail
tools="$ANDROID_HOME/build-tools/37.0.0"
- apk=delivery/toolbox-v0.8.5-ui-polish-signed.apk
+ apk=delivery/toolbox-v0.8.5-ui-stage2-release.apk
"$tools/apksigner" verify --verbose --print-certs "$apk" > delivery/SIGNATURE.txt
"$tools/apksigner" verify --verbose --print-certs input/reference.apk > delivery/REFERENCE_SIGNATURE.txt
cat delivery/SIGNATURE.txt delivery/REFERENCE_SIGNATURE.txt
@@ -149,7 +149,7 @@ jobs:
assert match, 'Missing package identity'
return match.groups()
assert identity(d / 'BADGING.txt') == identity(pathlib.Path('input/reference-badging.txt')) == ('io.toolbox.host', '38', '0.8.5')
- apk = d / 'toolbox-v0.8.5-ui-polish-signed.apk'
+ apk = d / 'toolbox-v0.8.5-ui-stage2-release.apk'
with zipfile.ZipFile('input/unsigned.apk') as before, zipfile.ZipFile(apk) as after:
assert before.namelist() == after.namelist(), 'Signing must not add or remove app entries'
for name in before.namelist():
@@ -164,21 +164,22 @@ jobs:
'certificate_sha256': actual, 'signed_apk_sha256': hashlib.sha256(apk.read_bytes()).hexdigest(),
'app_payload_unchanged': True, 'certificate_matches_original_release': True,
'android_ui_tests': 'NOT RUN - user-requested waiver', 'device_installation': 'NOT RUN',
- 'delivery_kind': 'signed UI-polish branch build; not a new official Release',
+ 'delivery_kind': 'signed UI-polish branch build including stages 1 and 2; not a new official Release',
}
(d / 'BUILD_RECEIPT.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + '\n')
print(json.dumps(receipt, indent=2))
PY
- (cd delivery && sha256sum toolbox-v0.8.5-ui-polish-signed.apk SIGNATURE.txt REFERENCE_SIGNATURE.txt BADGING.txt BUILD_RECEIPT.json > SHA256SUMS.txt)
- printf '%s\n' '## Original-signature UI-polish APK' \
- 'Signed the exact Release APK from successful run 35680323971; no rebuild.' \
+ (cd delivery && sha256sum toolbox-v0.8.5-ui-stage2-release.apk SIGNATURE.txt REFERENCE_SIGNATURE.txt BADGING.txt BUILD_RECEIPT.json > SHA256SUMS.txt)
+ printf '%s\n' '## Original-signature stage-two UI-polish APK' \
+ 'Signed the exact Release APK from successful run 35707164552; no rebuild.' \
+ 'Includes stages 1 and 2 plus contextual runtime feedback from source b818122.' \
'Actual APK certificate equals the published v0.8.5 certificate; package io.toolbox.host, version 0.8.5 (38).' \
'All APK entry contents are unchanged. UI tests and device installation: NOT RUN.' \
'No default-branch change, PR merge or public Release publication.' >> "$GITHUB_STEP_SUMMARY"
- name: Upload APK and signature evidence
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
- name: toolbox-v0.8.5-ui-polish-original-signature
+ name: toolbox-v0.8.5-ui-stage2-original-signature
path: delivery/
if-no-files-found: error
retention-days: 30
From 616b49f8ead9143905b51c5dd5f5718f35b83c11 Mon Sep 17 00:00:00 2001
From: DoraC <25716974+gkeyes@users.noreply.github.com>
Date: Tue, 22 Sep 2026 17:25:20 +0800
Subject: [PATCH 14/19] ci: prepare isolated source audit for background
reliability fixes
---
.github/workflows/background-reliability.yml | 32 ++++++++++++++++++++
1 file changed, 32 insertions(+)
create mode 100644 .github/workflows/background-reliability.yml
diff --git a/.github/workflows/background-reliability.yml b/.github/workflows/background-reliability.yml
new file mode 100644
index 00000000..4b53c011
--- /dev/null
+++ b/.github/workflows/background-reliability.yml
@@ -0,0 +1,32 @@
+name: Background reliability
+
+on:
+ push:
+ branches: ["codex/fix-watcher-background-20260922"]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: background-reliability-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ source:
+ name: Export exact source for code review (not a build)
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
+ with:
+ persist-credentials: false
+ - name: Archive tracked source only
+ run: |
+ git archive --format=tar.gz --output="$RUNNER_TEMP/toolbox-background-source.tar.gz" HEAD
+ printf '%s\n' 'Source audit only. No APK build, tests or signing have run.' >> "$GITHUB_STEP_SUMMARY"
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+ with:
+ name: background-source-${{ github.sha }}
+ path: ${{ runner.temp }}/toolbox-background-source.tar.gz
+ retention-days: 3
+ if-no-files-found: error
From 3c0795bc7912afb0f30fdcc71d3d54da3e4bfb10 Mon Sep 17 00:00:00 2001
From: DoraC <25716974+gkeyes@users.noreply.github.com>
Date: Tue, 22 Sep 2026 18:00:11 +0800
Subject: [PATCH 15/19] fix: recover background runtimes and unblock GitHub
watcher polling
Keep the installed stage-two host UI, port the actual watcher 1.1.0 presentation, and release watcher 1.1.1 (12). Bound native GitHub requests and polling leases, fence late responses, prioritize tracked run IDs and incremental discovery, decouple ETA learning, and post terminal notifications without artificial delay.
Recreate only eligible opted-in background runtimes after renderer/entry failure, with full identity/permission checks, three bounded attempts and explicit interrupted status. Fence old WebView callbacks and view release, cancel recovery on stop/revoke/replacement, and preserve reboot/process opt-ins.
Add 13 JS fault-injection and 10 JVM recovery regressions, with a branch-only Actions build. No emulator tests, no original signing changes, no default-branch update.
---
.github/workflows/background-reliability.yml | 63 +-
.../host/runtime/BackgroundRuntimeRecovery.kt | 79 ++
.../RuntimeLiveNotificationRenderer.kt | 4 +-
.../host/runtime/RuntimeSessionManager.kt | 106 +-
.../toolbox/host/runtime/RuntimeViewModel.kt | 9 +-
.../toolbox/host/ui/HostCapabilityScreens.kt | 5 +-
.../runtime/BackgroundRuntimeRecoveryTest.kt | 150 +++
examples/github-actions-watcher/app.js | 669 +++++++---
examples/github-actions-watcher/index.html | 133 +-
examples/github-actions-watcher/manifest.json | 12 +-
examples/github-actions-watcher/package.sh | 4 +-
.../github-actions-watcher/reliability.js | 79 ++
examples/github-actions-watcher/style.css | 1131 +++--------------
scripts/ci/tbx-targets.json | 1 +
scripts/tests/watcher-reliability.test.cjs | 184 +++
15 files changed, 1435 insertions(+), 1194 deletions(-)
create mode 100644 app/src/main/kotlin/io/toolbox/host/runtime/BackgroundRuntimeRecovery.kt
create mode 100644 app/src/test/kotlin/io/toolbox/host/runtime/BackgroundRuntimeRecoveryTest.kt
create mode 100644 examples/github-actions-watcher/reliability.js
create mode 100644 scripts/tests/watcher-reliability.test.cjs
diff --git a/.github/workflows/background-reliability.yml b/.github/workflows/background-reliability.yml
index 4b53c011..416a8458 100644
--- a/.github/workflows/background-reliability.yml
+++ b/.github/workflows/background-reliability.yml
@@ -13,20 +13,65 @@ concurrency:
cancel-in-progress: true
jobs:
- source:
- name: Export exact source for code review (not a build)
+ verify:
+ name: Build host and watcher; JVM and JS regressions (no emulator)
runs-on: ubuntu-24.04
+ timeout-minutes: 40
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
with:
persist-credentials: false
- - name: Archive tracked source only
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
+ with:
+ node-version: "22"
+ - name: Watcher fault injection and package integrity
+ run: |
+ set -euo pipefail
+ mkdir -p build/background-evidence
+ node --test scripts/tests/watcher-reliability.test.cjs | tee build/background-evidence/watcher-tests.txt
+ python3 scripts/ci/tbx.py build --tool github-actions-watcher
+ - name: Host contracts and package verification
+ run: |
+ node scripts/ci/verify-host-contract.mjs
+ PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/tests -p 'test_package_tool.py' -v
+ - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961
+ with:
+ distribution: temurin
+ java-version: "21"
+ - uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699
+ with:
+ log-accepted-android-sdk-licenses: false
+ packages: platform-tools platforms;android-37.0 build-tools;37.0.0
+ - uses: gradle/actions/setup-gradle@4733eaac7c1b0da527e4206b7671e0061de1ce37
+ - name: Compile Debug and Release APKs
+ run: ./gradlew --no-daemon :app:assembleDebug :app:assembleRelease
+ - name: Host JVM regressions including bounded runtime recovery
+ run: ./gradlew --no-daemon :core-data:testDebugUnitTest :tool-package:testDebugUnitTest :tool-runtime:testDebugUnitTest :app:testDebugUnitTest
+ - name: Record validation scope
run: |
- git archive --format=tar.gz --output="$RUNNER_TEMP/toolbox-background-source.tar.gz" HEAD
- printf '%s\n' 'Source audit only. No APK build, tests or signing have run.' >> "$GITHUB_STEP_SUMMARY"
- - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+ printf '%s\n' '## Background reliability validation' \
+ 'Debug/Release compilation, JVM tests, watcher JS fault injection and TBX integrity checks completed.' \
+ 'Android emulator/UI tests: NOT RUN, following the user-requested waiver.' \
+ 'Release signing and real-device installation: NOT RUN. PR/build APKs are not original-signature delivery packages.' >> "$GITHUB_STEP_SUMMARY"
+ - name: Upload APKs and watcher
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
- name: background-source-${{ github.sha }}
- path: ${{ runner.temp }}/toolbox-background-source.tar.gz
- retention-days: 3
+ name: background-fix-build-${{ github.sha }}
+ path: |
+ app/build/outputs/apk/
+ build/tbx/github-actions-watcher/delivery/
if-no-files-found: error
+ - name: Retain regression evidence
+ if: ${{ always() }}
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+ with:
+ name: background-fix-evidence-${{ github.sha }}
+ path: |
+ build/background-evidence/
+ app/build/test-results/
+ app/build/reports/tests/
+ core-data/build/test-results/
+ tool-package/build/test-results/
+ tool-runtime/build/test-results/
+ if-no-files-found: warn
+ retention-days: 14
diff --git a/app/src/main/kotlin/io/toolbox/host/runtime/BackgroundRuntimeRecovery.kt b/app/src/main/kotlin/io/toolbox/host/runtime/BackgroundRuntimeRecovery.kt
new file mode 100644
index 00000000..1f35af81
--- /dev/null
+++ b/app/src/main/kotlin/io/toolbox/host/runtime/BackgroundRuntimeRecovery.kt
@@ -0,0 +1,79 @@
+package io.toolbox.host.runtime
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+
+/** Main-dispatcher owned. Repeated renderer crashes share a bounded retry budget. */
+internal class BackgroundRuntimeRecovery(
+ private val scope: CoroutineScope,
+ private val nowMillis: () -> Long,
+ private val delays: List = listOf(1_000L, 5_000L, 15_000L),
+ private val attemptTimeoutMillis: Long = 30_000L,
+ private val budgetWindowMillis: Long = 10 * 60_000L,
+) {
+ private val jobs = mutableMapOf()
+ private val attempts = mutableMapOf>()
+
+ fun request(
+ toolId: String,
+ allowed: suspend () -> Boolean,
+ restart: suspend () -> Boolean,
+ onRecovering: (Int) -> Unit,
+ onInterrupted: () -> Unit,
+ ) {
+ if (jobs[toolId]?.isActive == true) return
+ val history = attempts.getOrPut(toolId) { mutableListOf() }
+ history.removeAll { nowMillis() - it >= budgetWindowMillis }
+ if (history.size >= delays.size) {
+ onInterrupted()
+ return
+ }
+ val task = scope.launch(start = CoroutineStart.LAZY) {
+ try {
+ while (history.size < delays.size) {
+ onRecovering(history.size + 1)
+ delay(delays[history.size])
+ history += nowMillis()
+ var permitted: Boolean? = null
+ val ready = withTimeoutOrNull(attemptTimeoutMillis) {
+ permitted = allowed()
+ permitted == true && restart()
+ }
+ if (ready == true) return@launch
+ // Revocation/stop is final; a read/start timeout consumes one
+ // attempt and is retried, with no unbounded check outside the timeout.
+ if (permitted == false) break
+ }
+ onInterrupted()
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (_: Exception) {
+ onInterrupted()
+ } finally {
+ if (jobs[toolId] === currentCoroutineContext()[Job]) jobs.remove(toolId)
+ }
+ }
+ jobs[toolId] = task
+ task.start()
+ }
+
+ /** The caller can join the cancelled job before replacing files or retrying. */
+ fun cancel(toolId: String): Job? {
+ attempts.remove(toolId)
+ return jobs.remove(toolId)?.also(Job::cancel)
+ }
+}
+
+/** Keep the original process-death and reboot opt-ins independent. */
+internal fun backgroundRestoreOptedIn(reason: String, processDeath: Boolean, reboot: Boolean): Boolean =
+ when (reason) {
+ "process" -> processDeath
+ "reboot" -> reboot
+ else -> false
+ }
diff --git a/app/src/main/kotlin/io/toolbox/host/runtime/RuntimeLiveNotificationRenderer.kt b/app/src/main/kotlin/io/toolbox/host/runtime/RuntimeLiveNotificationRenderer.kt
index a01c26e3..afe08f28 100644
--- a/app/src/main/kotlin/io/toolbox/host/runtime/RuntimeLiveNotificationRenderer.kt
+++ b/app/src/main/kotlin/io/toolbox/host/runtime/RuntimeLiveNotificationRenderer.kt
@@ -34,11 +34,11 @@ internal class RuntimeLiveNotificationRenderer(context: Context) {
stopCurrent: PendingIntent,
toolIcon: Bitmap? = null,
): Notification {
- val live = card.presentation
+ val live = card.presentation.takeIf { card.session.statusText == null }
val title = live?.request?.title ?: card.session.toolName
val content = live?.request?.let { request ->
listOfNotNull(request.primaryText, request.secondaryText?.takeIf(String::isNotBlank)).joinToString(" · ")
- } ?: "后台环境运行中,可打开工具或停止当前会话"
+ } ?: card.session.statusText ?: "后台环境运行中,可打开工具或停止当前会话"
// An omitted live body means no expanded prose, not a copy of the status line.
val body = if (live == null) content else live.request.body?.takeIf(String::isNotBlank)
val updatedAt = live?.request?.updatedAt ?: live?.receivedAt ?: card.session.startedAt
diff --git a/app/src/main/kotlin/io/toolbox/host/runtime/RuntimeSessionManager.kt b/app/src/main/kotlin/io/toolbox/host/runtime/RuntimeSessionManager.kt
index 5260dc9d..1dc98f8f 100644
--- a/app/src/main/kotlin/io/toolbox/host/runtime/RuntimeSessionManager.kt
+++ b/app/src/main/kotlin/io/toolbox/host/runtime/RuntimeSessionManager.kt
@@ -15,6 +15,7 @@ import android.location.LocationManager
import android.webkit.WebView
import io.toolbox.core.data.CoreDataRepositories
import io.toolbox.core.data.DataResult
+import io.toolbox.core.data.ToolVersion
import io.toolbox.core.data.ThemeMode
import io.toolbox.tool.runtime.RuntimeWebViewTheme
import io.toolbox.host.HostTrace
@@ -88,6 +89,7 @@ internal data class RuntimeBackgroundSessionUi(
val toolName: String,
val startedAt: Long,
val notificationId: Int,
+ val statusText: String? = null,
)
internal data class RuntimeForegroundDetachPlan(
@@ -132,6 +134,8 @@ internal class RuntimeSessionManager(
private val hosts = mutableMapOf()
private val openingTools = mutableMapOf()
private val visibleTools = mutableSetOf()
+ private val recoveryStatus = mutableMapOf()
+ private val recovery = BackgroundRuntimeRecovery(scope, android.os.SystemClock::elapsedRealtime)
private val sessionsByTool = mutableMapOf>()
private val notificationIds = RuntimeNotificationIds()
private val restoredToolNames = mutableMapOf()
@@ -200,6 +204,9 @@ internal class RuntimeSessionManager(
fun retry(toolId: String) {
scope.launch {
+ recovery.cancel(toolId)?.join()
+ recoveryStatus.remove(toolId)
+ openingTools[toolId]?.cancelAndJoin()
destroyHost(toolId)
stateFlow(toolId).value = RuntimeUiState.Loading
ensureRuntime(toolId, restoreReason = null)
@@ -257,7 +264,7 @@ internal class RuntimeSessionManager(
recovery.forEach { persisted ->
restoredToolNames[persisted.toolId] = persisted.toolName
val restorable = persisted.sessions.filter { session ->
- if (restoreReason == RESTORE_REASON_REBOOT) session.restoreAfterReboot else session.restoreAfterProcessDeath
+ backgroundRestoreOptedIn(restoreReason, session.restoreAfterProcessDeath, session.restoreAfterReboot)
}.map { it.copy(notificationId = notificationIds.claim(it.sessionId, it.notificationId)) }
(persisted.sessions.map(StoredRuntimeSession::sessionId) - restorable.map(StoredRuntimeSession::sessionId).toSet())
.forEach(notificationIds::release)
@@ -290,6 +297,8 @@ internal class RuntimeSessionManager(
}
suspend fun stopTool(toolId: String, removeAlarms: Boolean = true) = withContext(Dispatchers.Main.immediate) {
+ recovery.cancel(toolId)?.join()
+ recoveryStatus.remove(toolId)
val removedSessionIds = sessionsByTool.remove(toolId)?.keys.orEmpty()
removedSessionIds.forEach(::cancelReminder)
removedSessionIds.forEach(notificationIds::release)
@@ -308,6 +317,8 @@ internal class RuntimeSessionManager(
}
suspend fun releaseTool(toolId: String) = withContext(Dispatchers.Main.immediate) {
+ recovery.cancel(toolId)?.join()
+ recoveryStatus.remove(toolId)
// An IO preparation may not own a WebView yet. Drain it before a same-version
// replacement can publish new files or an old opener can acquire the new profile.
openingTools[toolId]?.cancelAndJoin()
@@ -445,8 +456,8 @@ internal class RuntimeSessionManager(
try {
// Await persisted appearance before constructing WebView, avoiding a light first document.
currentTheme = withContext(Dispatchers.IO) { repositories.settings.settings.first().theme }
+ val installed = withContext(Dispatchers.IO) { repositories.catalog.observeTool(toolId).first() }
val prepared = HostTrace.bestEffortAsyncSection("tool.prepare") {
- val installed = withContext(Dispatchers.IO) { repositories.catalog.observeTool(toolId).first() }
withContext(Dispatchers.IO) { preparer.prepare(toolId, installed) }
}
if (io.toolbox.host.backup.BackupRuntimeGate.paused) return
@@ -456,6 +467,13 @@ internal class RuntimeSessionManager(
stateFlow(toolId).value = RuntimeUiState.Error(failure.code.name, failure.message)
return
}
+ if (restoreReason != null && (
+ "background.runtime" !in runtime.declaredCapabilities ||
+ !backgroundRecoveryAllowed(toolId, checkNotNull(installed).currentVersion, restoreReason)
+ )) {
+ stateFlow(toolId).value = RuntimeUiState.Error("BACKGROUND_RESTORE_BLOCKED", "后台恢复已停止,请检查授权后重新打开工具。")
+ return
+ }
when (val permit = permitProvider.acquireRuntimePermit(toolId, awaitExistingRuntimeRelease = false)) {
is RuntimeCreationPermitResult.Rejected -> {
stateFlow(toolId).value = RuntimeUiState.Error(
@@ -470,9 +488,9 @@ internal class RuntimeSessionManager(
runtime = runtime,
creationPermit = permit.permit,
callbacks = RuntimeWebViewCallbacks(
- onMainEntryLoaded = { onMainEntryLoaded(toolId, restoreReason) },
- onMainEntryFailed = { message -> onRuntimeFailed(toolId, "ENTRY_LOAD_FAILED", message) },
- onRendererGone = { onRuntimeFailed(toolId, "RENDERER_GONE", "工具渲染进程已退出,点击重试可重新打开。") },
+ onMainEntryLoaded = { onMainEntryLoaded(toolId, runtime, restoreReason) },
+ onMainEntryFailed = { message -> onRuntimeFailed(toolId, runtime, "ENTRY_LOAD_FAILED", message) },
+ onRendererGone = { onRuntimeFailed(toolId, runtime, "RENDERER_GONE", "工具渲染进程已退出。") },
),
bridgeProvider = bridgeProvider(),
)
@@ -485,6 +503,7 @@ internal class RuntimeSessionManager(
}
val host = RuntimeHost(
runtime = runtime,
+ installedVersion = checkNotNull(installed).currentVersion,
webView = result.webView,
mainEntryLoaded = false,
state = when {
@@ -516,13 +535,16 @@ internal class RuntimeSessionManager(
}
}
- private fun onMainEntryLoaded(toolId: String, restoreReason: String?) {
+ private fun onMainEntryLoaded(toolId: String, runtime: PreparedToolRuntime, restoreReason: String?) {
scope.launch {
- val current = hosts[toolId] ?: return@launch
+ val current = hosts[toolId]?.takeIf { it.runtime === runtime } ?: return@launch
+ recoveryStatus.remove(toolId)
current.mainEntryLoaded = true
current.state = if (toolId in visibleTools) RuntimeHostState.ATTACHED else RuntimeHostState.BACKGROUND_DETACHED
stateFlow(toolId).value = current.toUiState()
restoreReason?.let { reason -> current.emitRestore(reason) }
+ updateSessionProjection()
+ refreshForegroundService()
}
}
@@ -539,11 +561,68 @@ internal class RuntimeSessionManager(
)
}
- private fun onRuntimeFailed(toolId: String, code: String, message: String) {
+ private fun onRuntimeFailed(toolId: String, runtime: PreparedToolRuntime, code: String, message: String) {
scope.launch {
+ // A delayed callback from a released WebView must not destroy its replacement.
+ val failed = hosts[toolId]?.takeIf { it.runtime === runtime } ?: return@launch
+ restoredToolNames[toolId] = runtime.toolName
destroyHost(toolId)
+ timersByTool.remove(toolId)?.values?.forEach(Job::cancel)
+ clearAllWatches(toolId)
+ liveNotifications.clearSessions(sessionsByTool[toolId].orEmpty().keys)
stateFlow(toolId).value = RuntimeUiState.Error(code, message)
+ requestBackgroundRecovery(toolId, failed.installedVersion)
+ }
+ }
+
+ private suspend fun backgroundRecoveryAllowed(
+ toolId: String,
+ expectedVersion: ToolVersion,
+ reason: String = RESTORE_REASON_PROCESS,
+ ): Boolean {
+ fun optedIn() = sessionsByTool[toolId].orEmpty().values.any {
+ backgroundRestoreOptedIn(reason, it.restoreAfterProcessDeath, it.restoreAfterReboot)
}
+ if (io.toolbox.host.backup.BackupRuntimeGate.paused || !optedIn()) return false
+ val valid = withContext(Dispatchers.IO) {
+ repositories.settings.settings.first().backgroundEnabled &&
+ repositories.catalog.observeTool(toolId).first()?.currentVersion == expectedVersion &&
+ repositories.grants.observeGrants(toolId).first().any { it.capability == "background.runtime" && it.granted }
+ }
+ return valid && !io.toolbox.host.backup.BackupRuntimeGate.paused &&
+ optedIn()
+ }
+
+ private fun requestBackgroundRecovery(toolId: String, expectedVersion: ToolVersion) {
+ if (sessionsByTool[toolId].isNullOrEmpty()) return
+ recovery.request(
+ toolId = toolId,
+ allowed = { backgroundRecoveryAllowed(toolId, expectedVersion) },
+ restart = {
+ destroyHost(toolId)
+ ensureRuntime(toolId, RESTORE_REASON_PROCESS)
+ val result = stateFlow(toolId).first { state ->
+ state is RuntimeUiState.Error || state is RuntimeUiState.Ready && state.mainEntryLoaded
+ }
+ result is RuntimeUiState.Ready && backgroundRecoveryAllowed(toolId, expectedVersion)
+ },
+ onRecovering = { attempt ->
+ recoveryStatus[toolId] = "后台环境中断,正在恢复($attempt/3)"
+ liveNotifications.clearSessions(sessionsByTool[toolId].orEmpty().keys)
+ updateSessionProjection()
+ refreshForegroundService()
+ },
+ onInterrupted = {
+ destroyHost(toolId)
+ timersByTool.remove(toolId)?.values?.forEach(Job::cancel)
+ clearAllWatches(toolId)
+ recoveryStatus[toolId] = "后台已中断,请打开工具重试或停止会话"
+ stateFlow(toolId).value = RuntimeUiState.Error("BACKGROUND_INTERRUPTED", recoveryStatus.getValue(toolId))
+ liveNotifications.clearSessions(sessionsByTool[toolId].orEmpty().keys)
+ updateSessionProjection()
+ refreshForegroundService()
+ },
+ )
}
private fun destroyHost(toolId: String) {
@@ -644,12 +723,17 @@ internal class RuntimeSessionManager(
if (sessionsByTool[toolId].isNullOrEmpty()) {
throw RuntimeHandlerException(RuntimeRpcErrorCode.INVALID_SESSION, "请先启动后台运行环境")
}
+ val expectedVersion = checkNotNull(hosts[toolId]).installedVersion
val jobs = timersByTool.getOrPut(toolId, ::linkedMapOf)
jobs.remove(key)?.cancel()
jobs[key] = scope.launch {
while (isActive) {
delay(intervalMillis)
- val host = hosts[toolId] ?: continue
+ val host = hosts[toolId]
+ if (host == null) {
+ requestBackgroundRecovery(toolId, expectedVersion)
+ continue
+ }
HardenedRuntimeWebView.emitEvent(
host.webView,
EVENT_BACKGROUND_TIMER,
@@ -797,6 +881,8 @@ internal class RuntimeSessionManager(
cancelReminder(removed.sessionId)
liveNotifications.clearSessions(listOf(sessionId))
if (sessionsByTool[toolId].isNullOrEmpty()) {
+ recovery.cancel(toolId)?.join()
+ recoveryStatus.remove(toolId)
timersByTool.remove(toolId)?.values?.forEach(Job::cancel)
clearBackgroundWatches(toolId)
cancelRuntimeNotifications(listOf(sessionId))
@@ -908,6 +994,7 @@ internal class RuntimeSessionManager(
toolName = hostNames[toolId] ?: restoredToolNames[toolId] ?: toolId,
startedAt = record.startedAt,
notificationId = record.notificationId,
+ statusText = recoveryStatus[toolId],
)
}
}.sortedBy(RuntimeBackgroundSessionUi::startedAt)
@@ -1093,6 +1180,7 @@ internal class RuntimeSessionManager(
private data class RuntimeHost(
val runtime: PreparedToolRuntime,
+ val installedVersion: ToolVersion,
val webView: WebView,
var mainEntryLoaded: Boolean,
var state: RuntimeHostState,
diff --git a/app/src/main/kotlin/io/toolbox/host/runtime/RuntimeViewModel.kt b/app/src/main/kotlin/io/toolbox/host/runtime/RuntimeViewModel.kt
index ff21dbae..5bef6683 100644
--- a/app/src/main/kotlin/io/toolbox/host/runtime/RuntimeViewModel.kt
+++ b/app/src/main/kotlin/io/toolbox/host/runtime/RuntimeViewModel.kt
@@ -1,5 +1,6 @@
package io.toolbox.host.runtime
+import android.webkit.WebView
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.StateFlow
@@ -17,7 +18,13 @@ internal class RuntimeViewModel(
fun reload() = sessions.reload(toolId)
- fun detached() = sessions.detachForeground(toolId)
+ fun detached(releasedView: WebView) {
+ // Releasing a failed/replaced WebView is not leaving the runtime page.
+ // The route's ViewModel owner remains responsible for final detachment.
+ if ((state.value as? RuntimeUiState.Ready)?.webView === releasedView) {
+ sessions.detachForeground(toolId)
+ }
+ }
override fun onCleared() {
sessions.detachForeground(toolId)
diff --git a/app/src/main/kotlin/io/toolbox/host/ui/HostCapabilityScreens.kt b/app/src/main/kotlin/io/toolbox/host/ui/HostCapabilityScreens.kt
index 1744f8e1..f0cf9a35 100644
--- a/app/src/main/kotlin/io/toolbox/host/ui/HostCapabilityScreens.kt
+++ b/app/src/main/kotlin/io/toolbox/host/ui/HostCapabilityScreens.kt
@@ -20,6 +20,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
+import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@@ -81,7 +82,7 @@ internal fun RuntimeShellScreen(
when (val current = state) {
RuntimeUiState.Loading -> RuntimeCenteredState(runtimeLoadingTitle(toolName), "正在准备页面。")
is RuntimeUiState.Error -> RuntimeErrorState(current.message, viewModel::retry)
- is RuntimeUiState.Ready -> {
+ is RuntimeUiState.Ready -> key(current.webView) {
AndroidView(
factory = { context ->
android.widget.FrameLayout(context).also { container ->
@@ -98,7 +99,7 @@ internal fun RuntimeShellScreen(
modifier = Modifier.fillMaxSize(),
onRelease = { releasedView ->
(releasedView as? android.view.ViewGroup)?.removeAllViews()
- viewModel.detached()
+ viewModel.detached(current.webView)
},
)
}
diff --git a/app/src/test/kotlin/io/toolbox/host/runtime/BackgroundRuntimeRecoveryTest.kt b/app/src/test/kotlin/io/toolbox/host/runtime/BackgroundRuntimeRecoveryTest.kt
new file mode 100644
index 00000000..d4988dc5
--- /dev/null
+++ b/app/src/test/kotlin/io/toolbox/host/runtime/BackgroundRuntimeRecoveryTest.kt
@@ -0,0 +1,150 @@
+package io.toolbox.host.runtime
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class BackgroundRuntimeRecoveryTest {
+ private fun TestScope.controller() = BackgroundRuntimeRecovery(
+ this, { testScheduler.currentTime }, delays = listOf(10L, 20L, 30L),
+ attemptTimeoutMillis = 100L, budgetWindowMillis = 1_000L,
+ )
+
+ @Test
+ fun retriesWithBackoffAndStopsAfterReadiness() = runTest {
+ val recovery = controller()
+ val attempts = mutableListOf()
+ var starts = 0
+ var interrupted = 0
+ recovery.request("tool", { true }, { ++starts == 2 }, { attempts += it }, { interrupted++ })
+ advanceUntilIdle()
+ assertEquals(listOf(1, 2), attempts)
+ assertEquals(2, starts)
+ assertEquals(0, interrupted)
+ assertEquals(30L, testScheduler.currentTime)
+ }
+
+ @Test
+ fun missingReadinessHasABoundedTimeoutAndRetryCount() = runTest {
+ val recovery = controller()
+ var starts = 0
+ var interrupted = 0
+ recovery.request("tool", { true }, { starts++; awaitCancellation() }, {}, { interrupted++ })
+ advanceUntilIdle()
+ assertEquals(3, starts)
+ assertEquals(1, interrupted)
+ assertEquals(360L, testScheduler.currentTime)
+ }
+
+ @Test
+ fun concurrentFailureSignalsDoNotCreateParallelRestarts() = runTest {
+ val recovery = controller()
+ var starts = 0
+ repeat(20) { recovery.request("tool", { true }, { starts++; true }, {}, {}) }
+ advanceUntilIdle()
+ assertEquals(1, starts)
+ }
+
+ @Test
+ fun stopBeforeDelayPreventsCreationAndInterruptedFeedback() = runTest {
+ val recovery = controller()
+ var starts = 0
+ var interrupted = 0
+ recovery.request("tool", { true }, { starts++; true }, {}, { interrupted++ })
+ runCurrent()
+ recovery.cancel("tool")?.join()
+ advanceUntilIdle()
+ assertEquals(0, starts)
+ assertEquals(0, interrupted)
+ }
+
+ @Test
+ fun revocationOrReplacementDuringBackoffPreventsCreation() = runTest {
+ val recovery = controller()
+ var allowed = true
+ var starts = 0
+ var interrupted = 0
+ recovery.request("tool", { allowed }, { starts++; true }, {}, { interrupted++ })
+ runCurrent()
+ allowed = false
+ advanceUntilIdle()
+ assertEquals(0, starts)
+ assertEquals(1, interrupted)
+ }
+
+ @Test
+ fun cancelJoinsAnInProgressPreparationBeforeFilesCanChange() = runTest {
+ val recovery = controller()
+ var started = false
+ var released = false
+ recovery.request("tool", { true }, {
+ started = true
+ try { awaitCancellation() } finally { released = true }
+ }, {}, {})
+ advanceTimeBy(10)
+ runCurrent()
+ assertTrue(started)
+ recovery.cancel("tool")?.join()
+ assertTrue(released)
+ advanceUntilIdle()
+ }
+
+ @Test
+ fun rapidSuccessfulRecreationsStillShareTheCrashBudget() = runTest {
+ val recovery = controller()
+ var starts = 0
+ var interrupted = 0
+ repeat(4) {
+ recovery.request("tool", { true }, { starts++; true }, {}, { interrupted++ })
+ advanceUntilIdle()
+ }
+ assertEquals(3, starts)
+ assertEquals(1, interrupted)
+ }
+
+ @Test
+ fun manualRetryResetsBudgetAndToolsHaveIndependentBudgets() = runTest {
+ val recovery = controller()
+ var starts = 0
+ recovery.request("a", { true }, { starts++; false }, {}, {})
+ advanceUntilIdle()
+ recovery.request("b", { true }, { starts++; true }, {}, {})
+ advanceUntilIdle()
+ assertEquals(4, starts)
+ recovery.cancel("a")?.join()
+ recovery.request("a", { true }, { starts++; true }, {}, {})
+ advanceUntilIdle()
+ assertEquals(5, starts)
+ }
+
+ @Test
+ fun stalledAuthorizationReadAlsoConsumesABoundedBudget() = runTest {
+ val recovery = controller()
+ var checks = 0
+ var starts = 0
+ var interrupted = 0
+ recovery.request("tool", { checks++; awaitCancellation() }, { starts++; true }, {}, { interrupted++ })
+ advanceUntilIdle()
+ assertEquals(3, checks)
+ assertEquals(0, starts)
+ assertEquals(1, interrupted)
+ }
+
+ @Test
+ fun rebootAndProcessOptInsRemainIndependent() {
+ assertTrue(backgroundRestoreOptedIn("reboot", processDeath = false, reboot = true))
+ assertFalse(backgroundRestoreOptedIn("process", processDeath = false, reboot = true))
+ assertTrue(backgroundRestoreOptedIn("process", processDeath = true, reboot = false))
+ assertFalse(backgroundRestoreOptedIn("reboot", processDeath = true, reboot = false))
+ assertFalse(backgroundRestoreOptedIn("unknown", processDeath = true, reboot = true))
+ }
+}
diff --git a/examples/github-actions-watcher/app.js b/examples/github-actions-watcher/app.js
index 80efa0da..4adf46b1 100644
--- a/examples/github-actions-watcher/app.js
+++ b/examples/github-actions-watcher/app.js
@@ -3,7 +3,7 @@
const API_ROOT = "https://api.github.com";
const API_VERSION = "2026-03-10";
- const USER_AGENT = "ToolBox-GitHub-Actions-Watcher/1.0.8";
+ const USER_AGENT = "ToolBox-GitHub-Actions-Watcher/1.1.1";
const STORAGE_KEY = "github-actions-watcher-state-v1";
const TOKEN_KEY = "github-actions-watcher-token";
const POLL_TIMER = "github-actions-watcher-poll";
@@ -11,6 +11,49 @@
const CLOCK_INTERVAL_MS = 10_000;
const TERMINAL_HOLD_MS = 120_000;
const model = window.GitHubWatcherModel;
+ const reliability = window.GitHubWatcherReliability;
+ const REQUEST_TIMEOUT_MS = 15_000;
+ const POLL_DEADLINE_MS = 60_000;
+ let activePoll = null;
+ let timingWork = null;
+ let nextTimingAt = 0;
+ let discoveryQueue = [];
+ let lastDiscoveryAt = 0;
+ let lastActiveSweepAt = 0;
+ let lastFrontPageAt = 0;
+ let activeCursor = 0;
+ let generation = 0;
+ const discoveryVisited = new Set();
+ let clockSessionId = null;
+ let bootPromise = null;
+ let resumeInFlight = null;
+ const terminalPosts = new Map();
+ const unavailableRuns = new Map();
+
+ async function bounded(value, lease, timeout = 20_000) {
+ if (lease) return lease.wait(value, timeout);
+ const operation = new reliability.Lease(timeout);
+ try { return await operation.wait(value); } finally { operation.invalidate(); }
+ }
+
+ function retireWork() {
+ generation += 1;
+ state.liveInFlight = false;
+ discoveryVisited.clear();
+ terminalPosts.clear();
+ unavailableRuns.clear();
+ activePoll?.invalidate();
+ timingWork?.invalidate();
+ activePoll = null;
+ timingWork = null;
+ state.pollInFlight = false;
+ state.pollStartedAt = null;
+ discoveryQueue = [];
+ lastDiscoveryAt = 0;
+ lastActiveSweepAt = 0;
+ lastFrontPageAt = 0;
+ clockSessionId = null;
+ }
const state = {
ready: false,
@@ -32,6 +75,8 @@
terminalStates: {},
watchStartedAt: null,
lastPollAt: null,
+ lastAttemptAt: null,
+ lastNotificationAt: null,
nextPollAt: null,
rateRemaining: null,
rateResetAt: null,
@@ -49,6 +94,9 @@
const toolbox = () => window.ToolBox;
let toastTimer = null;
let foregroundClock = null;
+ // Presentation-only preferences never change background tracking or timing keys.
+ const viewState = { selectedRunKey: null, detailsRunKey: null, jobsSignature: null, activeSignature: null, recentSignature: null, historyExpanded: false };
+
function cleanText(value) {
return String(value ?? "")
@@ -203,6 +251,8 @@
terminalStates: state.terminalStates,
watchStartedAt: state.watchStartedAt,
lastPollAt: state.lastPollAt,
+ lastAttemptAt: state.lastAttemptAt,
+ lastNotificationAt: state.lastNotificationAt,
nextPollAt: state.nextPollAt,
rateRemaining: state.rateRemaining,
rateResetAt: state.rateResetAt
@@ -231,6 +281,8 @@
if (saved.terminalStates && typeof saved.terminalStates === "object") state.terminalStates = saved.terminalStates;
state.watchStartedAt = Number.isFinite(saved.watchStartedAt) ? saved.watchStartedAt : null;
state.lastPollAt = Number.isFinite(saved.lastPollAt) ? saved.lastPollAt : null;
+ state.lastAttemptAt = Number.isFinite(saved.lastAttemptAt) ? saved.lastAttemptAt : null;
+ state.lastNotificationAt = Number.isFinite(saved.lastNotificationAt) ? saved.lastNotificationAt : null;
state.nextPollAt = Number.isFinite(saved.nextPollAt) ? saved.nextPollAt : null;
state.rateRemaining = Number.isFinite(saved.rateRemaining) ? saved.rateRemaining : null;
state.rateResetAt = Number.isFinite(saved.rateResetAt) ? saved.rateResetAt : null;
@@ -291,7 +343,8 @@
if (Number.isFinite(resetSeconds) && resetSeconds > 0) state.rateResetAt = resetSeconds * 1000;
}
- async function apiRequest(url, tokenOverride) {
+ async function apiRequest(url, tokenOverride, lease) {
+ lease?.assert();
const target = new URL(url);
if (target.origin !== API_ROOT || target.username || target.password) throw createError("github", "GitHub API 地址无效");
const headers = {
@@ -306,15 +359,17 @@
if (token) headers.Authorization = `Bearer ${token}`;
let response;
try {
- response = await toolbox().network.request({
+ response = await bounded(toolbox().network.request({
url,
method: "GET",
headers,
- });
+ timeoutMs: REQUEST_TIMEOUT_MS,
+ }), lease);
} catch (error) {
if (error?.code) throw error;
throw createError("offline", "GitHub API 网络请求失败");
}
+ lease?.assert();
updateRateState(response.headers);
const remainingHeader = headerValue(response.headers, "x-ratelimit-remaining");
const responseRemaining = remainingHeader === null ? Number.NaN : Number(remainingHeader);
@@ -325,7 +380,11 @@
}
const failure = model.classifyApiStatus(response.status, responseRemaining, retryAfterSeconds);
if (failure === "invalid_token") throw createError(failure, "GitHub Token 无效", response.status);
- if (failure === "rate_limit") throw createError(failure, "GitHub API rate limit exceeded", response.status);
+ if (failure === "rate_limit") {
+ state.rateRemaining = 0;
+ state.rateResetAt = Math.max(state.rateResetAt || 0, Date.now() + 60_000);
+ throw createError(failure, "GitHub API rate limit exceeded", response.status);
+ }
if (failure === "permission") throw createError(failure, "GitHub Actions read permission required", response.status);
if (failure === "not_found") throw createError(failure, "Repository or Actions resource not found", response.status);
if (failure) throw createError(failure, `GitHub API 返回 ${response.status}`, response.status);
@@ -337,14 +396,16 @@
}
}
- async function fetchPaged(firstUrl, collectionKey, tokenOverride) {
+ async function fetchPaged(firstUrl, collectionKey, tokenOverride, lease, maxPages = 20) {
const items = [];
let url = firstUrl;
const visited = new Set();
while (url) {
if (visited.has(url)) throw createError("github", "GitHub 分页链接发生循环");
+ if (visited.size >= maxPages) throw createError("github", "分页尚未完整读取,请缩小查询范围后重试");
visited.add(url);
- const response = await apiRequest(url, tokenOverride);
+ const response = await apiRequest(url, tokenOverride, lease);
+ lease?.assert();
const pageItems = collectionKey ? response.data?.[collectionKey] : response.data;
if (!Array.isArray(pageItems)) throw createError("github", `GitHub 响应缺少 ${collectionKey || "列表"}`);
items.push(...pageItems);
@@ -374,7 +435,8 @@
const base = `${API_ROOT}/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}`;
const repositoryResponse = await apiRequest(base);
const workflows = await fetchPaged(`${base}/actions/workflows?per_page=100`, "workflows", undefined);
- const runs = await fetchPaged(`${base}/actions/runs?per_page=100`, "workflow_runs");
+ // Setup only needs branch candidates, not the repository's entire build history.
+ const runs = (await apiRequest(`${base}/actions/runs?per_page=100`)).data.workflow_runs || [];
let repositoryBranches = [];
let branchCatalogComplete = false;
try {
@@ -486,6 +548,7 @@
}
function renderSetup() {
+ document.body.dataset.monitoring = String(state.monitoring && state.ready);
$("setup-screen").hidden = state.monitoring;
$("watch-screen").hidden = !state.monitoring;
if (!state.repository || !state.workflows.length) {
@@ -520,13 +583,22 @@
return model.pollInterval(state.hasToken, active);
}
- async function configureTimers() {
+ async function configureTimers(lease) {
+ if (!state.monitoring) return;
+ lease?.assert();
+ const started = state.pollStartedAt || Date.now();
+ const delay = reliability.nextDelay(started, currentPollInterval(), Date.now(),
+ state.rateRemaining === 0 ? state.rateResetAt || 0 : 0);
+ await bounded(toolbox().background.setTimer(POLL_TIMER, delay), lease, 5_000);
+ lease?.assert();
if (!state.monitoring) return;
- const interval = Math.max(currentPollInterval(), state.rateRemaining === 0 && state.rateResetAt
- ? state.rateResetAt - Date.now() : 0);
- await toolbox().background.setTimer(POLL_TIMER, interval);
- state.nextPollAt = Date.now() + interval;
- await toolbox().background.setTimer(CLOCK_TIMER, CLOCK_INTERVAL_MS);
+ state.nextPollAt = Date.now() + delay;
+ // The independent native clock is also a watchdog. Do not reset it every poll.
+ if (clockSessionId !== state.sessionId) {
+ await bounded(toolbox().background.setTimer(CLOCK_TIMER, CLOCK_INTERVAL_MS), lease, 5_000);
+ lease?.assert();
+ clockSessionId = state.sessionId;
+ }
}
async function ensureSession() {
@@ -546,21 +618,21 @@
let firstError = null;
for (const key of [POLL_TIMER, CLOCK_TIMER]) {
try {
- await toolbox().background.cancelTimer(key);
+ await bounded(toolbox().background.cancelTimer(key), null, 5_000);
} catch (error) {
if (error?.code !== "NOT_FOUND" && !firstError) firstError = error;
}
}
if (state.liveActive && state.sessionId) {
try {
- await toolbox().notifications.live.end(state.sessionId);
+ await bounded(toolbox().notifications.live.end(state.sessionId), null, 5_000);
} catch (error) {
if (error?.code !== "NOT_FOUND" && !firstError) firstError = error;
}
}
if (state.sessionId) {
try {
- await toolbox().background.stop(state.sessionId);
+ await bounded(toolbox().background.stop(state.sessionId), null, 5_000);
} catch (error) {
if (error?.code !== "NOT_FOUND" && !firstError) firstError = error;
}
@@ -577,6 +649,7 @@
showToast("请选择至少一个 workflow 和分支范围");
return;
}
+ retireWork();
setBusy(true);
setLoading(true, "启动守望", "正在创建可恢复的后台环境");
try {
@@ -589,6 +662,8 @@
branchMode,
branch: branchMode === "branch" ? branch : ""
};
+ state.runs = model.selectedRuns(state.discoveryRuns, workflowIds, branchMode, branch);
+ state.lastPollAt = null;
state.monitoring = true;
state.watchStartedAt = Date.now();
state.trackedRunKeys = state.discoveryRuns.filter(isActiveRun).map(model.runKey);
@@ -613,12 +688,13 @@
}
}
- async function fetchJobs(run) {
+ async function fetchJobs(run, lease) {
const key = model.runKey(run);
const jobs = await fetchPaged(
repoUrl(`/actions/runs/${run.id}/attempts/${Number(run.run_attempt) || 1}/jobs?per_page=100`),
- "jobs"
+ "jobs", undefined, lease, 10
);
+ lease?.assert();
state.jobsByRun[key] = jobs;
const terminal = state.terminalStates[key];
if (run.status === "completed" && terminal) {
@@ -634,28 +710,32 @@
return !state.terminalStates[key]?.jobsSynced || !Array.isArray(state.jobsByRun[key]);
}
- async function ensureTimingModel(run) {
+ async function ensureTimingModel(run, lease) {
const key = timingKey(run);
if (state.timingModels[key]) return state.timingModels[key];
try {
- const candidates = await fetchPaged(repoUrl(`/actions/workflows/${run.workflow_id}/runs?status=success&per_page=100`), "workflow_runs");
- const samples = model.selectHistoricalRuns(candidates, run);
+ const candidates = (await apiRequest(repoUrl(`/actions/workflows/${run.workflow_id}/runs?status=success&per_page=10&branch=${encodeURIComponent(run.head_branch || "")}`), undefined, lease)).data.workflow_runs || [];
+ const samples = model.selectHistoricalRuns(candidates, run).slice(0, 3);
const sampleJobs = {};
for (const sample of samples) {
sampleJobs[model.runKey(sample)] = await fetchPaged(
repoUrl(`/actions/runs/${sample.id}/attempts/${Number(sample.run_attempt) || 1}/jobs?per_page=100`),
- "jobs"
+ "jobs", undefined, lease, 5
);
}
+ lease?.assert();
state.timingModels[key] = model.buildTimingModel(samples, sampleJobs);
} catch (error) {
- if (["rate_limit", "offline"].includes(error?.kind)) throw error;
+ lease?.assert();
+ if (["rate_limit", "offline"].includes(error?.kind) || error?.code) throw error;
state.timingModels[key] = model.buildTimingModel([], {});
}
return state.timingModels[key];
}
function calculateEstimate(run, now = Date.now()) {
+ // A ticking notification is not a new GitHub response. Freeze estimates once stale.
+ if (state.lastPollAt && now - state.lastPollAt > Math.max(45_000, currentPollInterval() * 2)) now = state.lastPollAt;
const key = model.runKey(run);
const timing = state.timingModels[timingKey(run)] || null;
const jobs = finalJobsPending(run) ? [] : state.jobsByRun[key] || [];
@@ -684,7 +764,7 @@
const eligible = watchedRuns().filter((run) => {
if (isActiveRun(run)) return true;
const terminal = state.terminalStates[model.runKey(run)];
- return terminal && !terminal.posted && terminal.holdUntil > now;
+ return terminal && terminal.holdUntil > now;
});
return model.choosePrimaryRun(eligible);
}
@@ -699,61 +779,165 @@
}
}
+ function acceptRuns(runs, lease) {
+ lease.assert();
+ const selected = model.selectedRuns(runs, state.config.selectedWorkflowIds, state.config.branchMode, state.config.branch);
+ state.runs = reliability.mergeRuns(state.runs, selected);
+ trackDiscoveredRuns(selected);
+ registerTerminalRuns();
+ // Keep active/unannounced runs, plus a bounded recent-history view.
+ state.runs = state.runs.filter((run, index) => index < 100 || isActiveRun(run) ||
+ (state.terminalStates[model.runKey(run)] && !state.terminalStates[model.runKey(run)].posted));
+ }
+
+ function queueDiscovery() {
+ if (discoveryQueue.length) return;
+ const now = Date.now();
+ if (now - lastDiscoveryAt < (state.hasToken ? 60_000 : 300_000)) return;
+ const branch = state.config.branchMode === "branch" ? `&branch=${encodeURIComponent(state.config.branch)}` : "";
+ const since = new Date(Math.max(0, lastDiscoveryAt ? lastDiscoveryAt - 120_000 : now - 86_400_000)).toISOString();
+ discoveryVisited.clear();
+ // Each successful page advances a cursor. Never walk an unbounded history in one cycle.
+ discoveryQueue = state.config.selectedWorkflowIds.map((id) =>
+ repoUrl(`/actions/workflows/${id}/runs?per_page=100&created=${encodeURIComponent(">=" + since)}${branch}`));
+ lastDiscoveryAt = now;
+ if (now - lastActiveSweepAt >= (state.hasToken ? 600_000 : 1_800_000)) {
+ const statuses = ["in_progress", "queued", "waiting", "pending", "requested"];
+ discoveryQueue.push(...statuses.map((status) => repoUrl(`/actions/runs?status=${status}&per_page=100${branch}`)));
+ lastActiveSweepAt = now;
+ }
+ }
+
+ function learnTimingLater() {
+ const run = state.runs.find((item) => isActiveRun(item) && !state.timingModels[timingKey(item)]);
+ if (!run || timingWork || Date.now() < nextTimingAt || !state.hasToken || state.warning) return;
+ const lease = new reliability.Lease(25_000);
+ timingWork = lease;
+ nextTimingAt = Date.now() + 300_000;
+ // Optional ETA learning has a separate deadline and never holds the polling lock.
+ ensureTimingModel(run, lease).catch(() => {}).finally(() => {
+ if (timingWork === lease) timingWork = null;
+ lease.invalidate();
+ });
+ }
+
async function pollGitHub(manual) {
- if (!state.monitoring || state.pollInFlight) return;
+ if (!state.monitoring) return;
+ if (state.pollInFlight) {
+ if (activePoll && !activePoll.remaining()) activePoll.invalidate(reliability.timeoutError());
+ return;
+ }
if (state.rateResetAt && state.rateRemaining === 0 && Date.now() < state.rateResetAt) {
state.warning = "rate_limit";
state.warningMessage = `额度将在 ${formatClock(state.rateResetAt)} 恢复`;
- await updateLiveNotification();
+ state.nextPollAt = state.rateResetAt;
renderDashboard();
return;
}
+ const lease = new reliability.Lease(POLL_DEADLINE_MS);
+ activePoll = lease;
state.pollInFlight = true;
- state.pollStartedAt = Date.now();
+ state.lastAttemptAt = state.pollStartedAt = Date.now();
renderDashboard();
+ let detailError = null;
try {
- const allRuns = await fetchPaged(repoUrl("/actions/runs?per_page=100"), "workflow_runs");
- const filtered = model.selectedRuns(allRuns, state.config.selectedWorkflowIds, state.config.branchMode, state.config.branch)
- .sort((a, b) => (Date.parse(b.created_at || "") || 0) - (Date.parse(a.created_at || "") || 0));
- trackDiscoveredRuns(filtered);
- state.runs = filtered;
- registerTerminalRuns();
- const active = state.runs.filter(isActiveRun);
- const pendingTerminalRuns = watchedRuns().filter((run) => finalJobsPending(run)
- && !state.terminalStates[model.runKey(run)]?.posted);
- for (const run of [...active, ...pendingTerminalRuns]) {
- await fetchJobs(run);
+ // Current run IDs first: completion cannot disappear behind a newer history page.
+ const tracked = state.runs.filter((run) => (unavailableRuns.get(run.id) || 0) <= Date.now() &&
+ (isActiveRun(run) || finalJobsPending(run) && !state.terminalStates[model.runKey(run)]?.posted));
+ const ordered = tracked.length ? [...tracked.slice(activeCursor % tracked.length), ...tracked.slice(0, activeCursor % tracked.length)] : [];
+ const batch = ordered.slice(0, state.hasToken ? 6 : 1);
+ activeCursor += batch.length;
+ for (const run of batch) {
+ let latest;
+ try {
+ latest = (await apiRequest(repoUrl(`/actions/runs/${run.id}`), undefined, lease)).data;
+ } catch (error) {
+ lease.assert();
+ if (error?.status !== 404) throw error;
+ // A deleted/inaccessible run must not starve every other tracked run.
+ unavailableRuns.set(run.id, Date.now() + 300_000);
+ detailError = error;
+ continue;
+ }
+ acceptRuns([latest], lease);
+ state.lastPollAt = Date.now();
+ state.warning = null;
+ state.warningMessage = "";
+ await bounded(updateLiveNotification(), lease, 5_000);
+ await bounded(processTerminalResults(), lease, 5_000);
}
- for (const run of active) {
- await ensureTimingModel(run);
- calculateEstimate(run);
+ // Always refresh the front page periodically, even if old cursors are still
+ // draining. Otherwise a large backlog could hide builds started more recently.
+ let freshPageReads = 0;
+ if (Date.now() - lastFrontPageAt >= (state.hasToken ? 60_000 : 300_000) && lease.remaining() > 5_000) {
+ const branch = state.config.branchMode === "branch" ? `&branch=${encodeURIComponent(state.config.branch)}` : "";
+ const front = (await apiRequest(repoUrl(`/actions/runs?per_page=100${branch}`), undefined, lease)).data;
+ if (!Array.isArray(front?.workflow_runs)) throw createError("github", "GitHub 响应缺少构建列表");
+ acceptRuns(front.workflow_runs, lease);
+ state.lastPollAt = lastFrontPageAt = Date.now();
+ freshPageReads = 1;
}
+ queueDiscovery();
+ const pageBudget = (state.hasToken ? 4 : 1) - freshPageReads;
+ for (let page = 0; page < pageBudget && discoveryQueue.length && lease.remaining() > 5_000; page += 1) {
+ const url = discoveryQueue[0];
+ const response = await apiRequest(url, undefined, lease);
+ if (!Array.isArray(response.data?.workflow_runs)) throw createError("github", "GitHub 响应缺少构建列表");
+ acceptRuns(response.data.workflow_runs, lease);
+ discoveryQueue.shift();
+ discoveryVisited.add(url);
+ const next = model.parseNextLink(headerValue(response.headers, "link"));
+ if (next && !discoveryVisited.has(next) && !discoveryQueue.includes(next)) discoveryQueue.push(next);
+ state.lastPollAt = Date.now();
+ }
+ lease.assert();
state.warning = null;
state.warningMessage = "";
- state.lastPollAt = Date.now();
+ // Publish the authoritative run state before slower job-detail requests.
+ await bounded(updateLiveNotification(), lease, 5_000);
+ await bounded(processTerminalResults(), lease, 5_000);
+ const primary = chooseDisplayedRun();
+ const jobs = [...new Map([primary, ...batch, ...state.runs.filter(isActiveRun)].filter(Boolean)
+ .map((run) => [run.id, state.runs.find((current) => current.id === run.id) || run])).values()].slice(0, state.hasToken ? 6 : 1);
+ for (const run of jobs) {
+ if (lease.remaining() < 5_000) break;
+ try { await fetchJobs(run, lease); }
+ catch (error) { lease.assert(); detailError = error; break; }
+ }
+ lease.assert();
state.rateResetAt = state.rateRemaining === 0 ? state.rateResetAt : null;
- await updateLiveNotification();
- await processTerminalResults();
+ if (detailError) state.warningMessage = `部分构建或任务详情稍后重试:${errorLabel(detailError)}`;
+ await bounded(updateLiveNotification(), lease, 5_000);
} catch (error) {
- state.warning = error?.kind === "rate_limit" ? "rate_limit" : "offline";
+ if (activePoll !== lease || !state.monitoring) return;
+ state.warning = ["rate_limit", "invalid_token", "permission", "not_found"].includes(error?.kind) ? error.kind : "offline";
state.warningMessage = errorLabel(error);
- if (["invalid_token", "permission", "not_found"].includes(error?.kind)) {
- state.warning = error.kind;
- }
- await updateLiveNotification();
if (manual) showToast(errorLabel(error));
} finally {
- try {
- await configureTimers();
- } catch (error) {
- state.nextPollAt = null;
- state.warning = "timer";
- state.warningMessage = `自动刷新安排失败:${errorLabel(error)};可点击立即同步`;
+ if (activePoll === lease) {
+ // Retire before cleanup; cleanup is bounded separately and cannot mutate a new run.
+ lease.invalidate();
+ const cleanup = new reliability.Lease(8_000);
+ try {
+ if (state.monitoring) await configureTimers(cleanup);
+ } catch (error) {
+ if (activePoll === lease && state.monitoring) {
+ state.nextPollAt = Date.now() + currentPollInterval();
+ state.warningMessage = `定时器安排失败,将由后台时钟重试:${errorLabel(error)}`;
+ }
+ } finally {
+ cleanup.invalidate();
+ if (activePoll === lease) {
+ activePoll = null;
+ state.pollInFlight = false;
+ state.pollStartedAt = null;
+ // Storage/notification errors must never retain the polling lock.
+ bounded(persist(), null, 5_000).catch(() => {});
+ renderDashboard();
+ if (state.monitoring) learnTimingLater();
+ }
+ }
}
- state.pollInFlight = false;
- state.pollStartedAt = null;
- await persist();
- renderDashboard();
}
}
@@ -761,12 +945,17 @@
const estimate = run ? calculateEstimate(run) : { progress: 0 };
const warning = ["rate_limit", "offline"].includes(state.warning) ? state.warning : null;
const summary = model.buildNotificationSummary(state.config.fullName, run, estimate, warning);
+ if (!warning && (!state.lastPollAt || Date.now() - state.lastPollAt > Math.max(45_000, currentPollInterval() * 2))) {
+ summary.primaryText = `${summary.progress}% · 同步已延迟`;
+ summary.tone = "warning";
+ summary.color = "#F59E0B";
+ }
return {
sessionId: state.sessionId,
title: cleanText(summary.title),
primaryText: cleanText(summary.primaryText),
shortText: cleanText(summary.shortText),
- updatedAt: Date.now(),
+ updatedAt: state.lastPollAt || state.watchStartedAt || Date.now(),
progress: summary.progress,
accentColor: summary.color,
tone: summary.tone
@@ -776,70 +965,84 @@
async function updateLiveNotification() {
if (!state.monitoring || !state.sessionId || state.liveInFlight) return;
const run = chooseDisplayedRun();
- if (!run && !state.liveActive) return;
- if (!run && state.liveActive) return;
+ if (!run) return;
state.liveInFlight = true;
+ const ownerGeneration = generation;
+ const current = () => ownerGeneration === generation && state.monitoring;
try {
const request = liveRequestFor(run);
if (state.liveActive) {
try {
- await toolbox().notifications.live.update(request);
+ await bounded(toolbox().notifications.live.update(request), null, 5_000);
} catch (error) {
+ if (!current()) return;
if (!["NOT_FOUND", "INVALID_SESSION"].includes(error?.code)) throw error;
- await toolbox().notifications.live.start(request);
+ await bounded(toolbox().notifications.live.start(request), null, 5_000);
}
} else {
- await toolbox().notifications.live.start(request);
+ await bounded(toolbox().notifications.live.start(request), null, 5_000);
+ }
+ if (current()) {
state.liveActive = true;
+ state.lastNotificationAt = Date.now();
}
} catch (error) {
- state.warningMessage = `实时展示失败:${errorLabel(error)}`;
+ if (current()) state.warningMessage = `实时展示失败:${errorLabel(error)}`;
} finally {
- state.liveInFlight = false;
+ if (ownerGeneration === generation) state.liveInFlight = false;
}
}
async function processTerminalResults() {
- const now = Date.now();
- let endedPrimary = false;
+ const ownerGeneration = generation;
for (const run of watchedRuns()) {
const key = model.runKey(run);
const terminal = state.terminalStates[key];
- if (!terminal || terminal.posted || terminal.holdUntil > now) continue;
+ if (!terminal || terminal.posted || terminalPosts.has(key)) continue;
+ const postOwner = {};
+ terminalPosts.set(key, postOwner);
const workflow = configWorkflowName(run);
const title = `${workflow} · ${resultLabel(run)}`;
const body = `${state.config.fullName} · ${run.head_branch || "未知分支"} · ${String(run.head_sha || "").slice(0, 7)}`;
try {
- await toolbox().notifications.post(`github-watch-result-${run.id}-${run.run_attempt || 1}`, cleanText(title), cleanText(body));
+ await bounded(toolbox().notifications.post(`github-watch-result-${run.id}-${run.run_attempt || 1}`, cleanText(title), cleanText(body)), null, 5_000);
+ if (ownerGeneration !== generation || !state.monitoring) return;
terminal.posted = true;
- endedPrimary = true;
} catch (error) {
- state.warningMessage = `结果通知失败:${errorLabel(error)}`;
+ if (ownerGeneration === generation && state.monitoring) state.warningMessage = `结果通知失败:${errorLabel(error)}`;
+ } finally {
+ if (terminalPosts.get(key) === postOwner) terminalPosts.delete(key);
}
}
- if (endedPrimary && !state.runs.some(isActiveRun) && state.liveActive) {
+ if (ownerGeneration !== generation || !state.monitoring) return;
+ if (!chooseDisplayedRun() && !state.runs.some(isActiveRun) && state.liveActive) {
try {
- await toolbox().notifications.live.end(state.sessionId);
- state.liveActive = false;
+ await bounded(toolbox().notifications.live.end(state.sessionId), null, 5_000);
+ if (ownerGeneration === generation && state.monitoring) state.liveActive = false;
} catch (error) {
- if (error?.code !== "NOT_FOUND") state.warningMessage = `结束实时展示失败:${errorLabel(error)}`;
+ if (ownerGeneration === generation && state.monitoring && error?.code !== "NOT_FOUND") state.warningMessage = `结束实时展示失败:${errorLabel(error)}`;
}
}
}
async function clockTick(background) {
if (!state.monitoring) return;
+ if (activePoll && !activePoll.remaining()) activePoll.invalidate(reliability.timeoutError());
+ if (!state.pollInFlight && (!state.nextPollAt || state.nextPollAt <= Date.now())) pollGitHub(false);
+
for (const run of state.runs.filter(isActiveRun)) calculateEstimate(run);
renderDashboard();
if (background) {
await updateLiveNotification();
await processTerminalResults();
- await persist();
+ await bounded(persist(), null, 5_000).catch(() => {});
}
}
async function stopWatching() {
if (state.busy || !state.monitoring) return;
+ state.monitoring = false;
+ retireWork();
setBusy(true);
setLoading(true, "停止守望", "正在清理后台时钟和实时展示");
try {
@@ -850,7 +1053,7 @@
state.nextPollAt = null;
state.warning = null;
state.warningMessage = "";
- await persist();
+ await bounded(persist(true), null, 5_000);
renderAll();
showToast(cleanupError ? `守望已停止;部分清理失败:${errorLabel(cleanupError)}` : "守望已停止,后台资源已清理");
} catch (error) {
@@ -876,118 +1079,208 @@
return dot;
}
- function renderActiveRuns() {
+ // Display aliases only: retain GitHub names unchanged for estimates and debugging.
+ function friendlyName(value) {
+ const raw = cleanText(value);
+ const name = raw.replace(/@[0-9a-f]{40,64}\b/gi, "");
+ const aliases = [
+ [/^Set up job$/i, "准备构建环境"],
+ [/^Complete job$/i, "完成任务"],
+ [/^Enable accelerated behavior test emulator$/i, "启动测试模拟器"],
+ [/^Verify catalog, backup, execution identity, dialogs and Wasm on Android$/i, "验证 Android 行为"],
+ [/^Retain host unit test reports$/i, "保存单元测试报告"],
+ [/^Retain Android behavior and WebView evidence$/i, "保存行为验证结果"],
+ [/^Post (?:Run )?gradle\/actions\/setup-gradle$/i, "Gradle 环境收尾"],
+ [/^Post (?:Run )?actions\/setup-java$/i, "Java 环境收尾"],
+ [/^Post (?:Run )?actions\/checkout$/i, "清理工作目录"],
+ [/^(?:Run )?actions\/upload-artifact(?:@[^\s]+)?$/i, "上传构建产物"],
+ [/^(?:Run )?actions\/download-artifact(?:@[^\s]+)?$/i, "下载构建产物"],
+ [/^(?:Run )?actions\/checkout(?:@[^\s]+)?$/i, "获取仓库代码"],
+ [/^(?:Run )?actions\/setup-java(?:@[^\s]+)?$/i, "准备 Java 环境"],
+ [/^(?:Run )?actions\/setup-node(?:@[^\s]+)?$/i, "准备 Node.js 环境"],
+ [/^(?:Run )?gradle\/actions\/setup-gradle(?:@[^\s]+)?$/i, "准备 Gradle 环境"]
+ ];
+ return aliases.find(([pattern]) => pattern.test(name))?.[1] || name.replace(/^Run\s+/i, "") || "未命名步骤";
+ }
+
+ function displayedRun() {
+ const selected = state.runs.find((run) => model.runKey(run) === viewState.selectedRunKey && isActiveRun(run));
+ return selected || chooseDisplayedRun() || model.choosePrimaryRun(state.runs.filter(isActiveRun));
+ }
+
+ function visibleJobs(run) {
+ return !run || finalJobsPending(run) ? [] : state.jobsByRun[model.runKey(run)] || [];
+ }
+
+ function failedStep(jobs) {
+ const failed = (item) => ["failure", "timed_out", "action_required"].includes(item?.conclusion);
+ for (const job of jobs) {
+ const step = (job.steps || []).find(failed);
+ if (step) return { label: "失败步骤", name: step.name || job.name };
+ }
+ const job = jobs.find(failed);
+ return job ? { label: "失败任务", name: job.name } : null;
+ }
+
+ async function openGitHub(run = displayedRun()) {
+ const owner = state.config?.owner || state.repository?.owner || state.config?.fullName?.split("/")[0];
+ const repo = state.config?.repo || state.repository?.repo || state.config?.fullName?.split("/")[1];
+ if (!owner || !repo) return showToast("请先连接仓库");
+ // Construct a fixed-origin URL; never execute an arbitrary URL from API data.
+ const base = `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions`;
+ const id = String(run?.id || "");
+ const url = /^\d+$/.test(id) ? `${base}/runs/${id}` : base;
+ try {
+ if (!toolbox()?.browser?.open) throw new Error("当前宿主不支持打开链接");
+ await toolbox().browser.open(url);
+ } catch (error) {
+ showToast(error?.code === "PERMISSION_DENIED" || error?.code === "NOT_DECLARED"
+ ? "请在小工具权限中开启浏览器访问后重试" : `打开失败:${errorLabel(error)}`);
+ }
+ }
+
+ function renderActiveRuns(primary) {
+ const active = state.runs.filter(isActiveRun).filter((run) => model.runKey(run) !== (primary && model.runKey(primary)));
+ $("active-section").hidden = active.length === 0;
+ $("active-count").textContent = `${active.length} 个`;
+ const signature = JSON.stringify(active.map((run) => [model.runKey(run), run.status, run.name, run.head_branch]));
+ if (signature === viewState.activeSignature) return;
+ viewState.activeSignature = signature;
const list = $("active-runs");
list.replaceChildren();
- const active = state.runs.filter(isActiveRun);
- $("active-count").textContent = `${active.length} 个`;
- if (!active.length) {
- list.append(emptyNode("当前没有活动构建,后台将继续等待。"));
- return;
- }
for (const run of active) {
- const estimate = calculateEstimate(run);
- const card = document.createElement("article");
+ const card = document.createElement("button");
+ card.type = "button";
card.className = "run-card";
- const header = document.createElement("div");
- header.className = "run-card-header";
- const title = document.createElement("div");
+ const title = document.createElement("span");
title.className = "run-card-title";
- const strong = document.createElement("strong");
- strong.textContent = configWorkflowName(run);
- const sub = document.createElement("span");
- sub.textContent = `${run.head_branch || "未知分支"} · ${String(run.head_sha || "").slice(0, 7)} · #${run.run_number || run.id}`;
- title.append(strong, sub);
- const percent = document.createElement("span");
- percent.className = "run-percent";
- percent.textContent = run.status === "in_progress" ? `${estimate.progress}%` : resultLabel(run);
- header.append(title, percent);
- const meta = document.createElement("p");
- meta.className = "run-card-meta";
- meta.textContent = [estimate.job, estimate.step, `已用 ${formatDuration(estimate.elapsedMs, true)}`].filter(Boolean).join(" · ");
- card.append(header, meta);
+ const name = document.createElement("strong");
+ name.textContent = configWorkflowName(run);
+ const branch = document.createElement("span");
+ branch.textContent = run.head_branch || "未知分支";
+ title.append(name, branch);
+ const result = document.createElement("span");
+ result.className = "result-label";
+ result.dataset.result = runStatus(run) || "idle";
+ result.textContent = resultLabel(run);
+ card.append(title, result);
+ card.addEventListener("click", () => {
+ viewState.selectedRunKey = model.runKey(run);
+ $("active-section").open = false;
+ renderDashboard();
+ $("watch-title").setAttribute("tabindex", "-1");
+ $("watch-title").focus({ preventScroll: true });
+ $("hero-card").scrollIntoView({ block: "start" });
+ });
list.append(card);
}
}
function renderJobs(run) {
const list = $("job-list");
- const jobExpansion = new Map();
- for (const details of list.children) {
- if (details.dataset.jobKey) jobExpansion.set(details.dataset.jobKey, details.open);
+ const jobs = visibleJobs(run);
+ const runKey = run ? model.runKey(run) : "";
+ if (viewState.detailsRunKey !== runKey) {
+ viewState.detailsRunKey = runKey;
+ $("build-details").open = false;
+ $("technical-details").open = false;
}
+ const steps = jobs.flatMap((job) => job.steps || []);
+ const completed = steps.filter((step) => step.status === "completed").length;
+ $("jobs-summary").textContent = jobs.length ? `${jobs.length} 个任务 · ${completed}/${steps.length} 步` : "暂无步骤";
+ const raw = $("raw-names").checked;
+ const signature = JSON.stringify([runKey, run?.status, finalJobsPendingSafe(run), jobs, raw]);
+ if (signature === viewState.jobsSignature) return;
+ viewState.jobsSignature = signature;
+ const expansion = new Map([...list.children].map((details) => [details.dataset.jobKey, details.open]));
list.replaceChildren();
- if (!run) {
- list.append(emptyNode("发现运行中的构建后,将显示 job 与 step。"));
- return;
- }
- if (finalJobsPending(run)) {
- list.append(emptyNode("构建已结束,等待同步最终步骤。"));
- return;
- }
- const jobs = state.jobsByRun[model.runKey(run)] || [];
if (!jobs.length) {
- const message = run.status === "completed" ? "本次构建未生成 job。"
- : run.status === "queued" ? "构建仍在排队,GitHub 尚未生成 job。" : "尚未读取到 job 详情。";
- list.append(emptyNode(message));
+ list.append(emptyNode(!run ? "有新构建时,步骤会显示在这里。"
+ : finalJobsPending(run) ? "构建已结束,正在同步最终步骤。"
+ : run.status === "queued" ? "正在排队,等待运行器接手。" : "尚未获取到执行步骤,可稍后刷新。"));
return;
}
for (const job of jobs) {
const details = document.createElement("details");
- const jobKey = `${model.runKey(run)}:${job.id}`;
- details.dataset.jobKey = jobKey;
- details.open = jobExpansion.has(jobKey) ? jobExpansion.get(jobKey) : job.status === "in_progress";
+ const key = `${runKey}:${job.id}`;
+ details.dataset.jobKey = key;
+ details.open = expansion.get(key) === true;
const summary = document.createElement("summary");
- summary.append(stateDot(runStatus(job)));
const name = document.createElement("strong");
- name.textContent = cleanText(job.name || "未命名 job");
+ name.textContent = raw ? cleanText(job.name || "未命名任务") : friendlyName(job.name || "未命名任务");
const status = document.createElement("span");
status.className = "result-label";
status.dataset.result = runStatus(job) || "idle";
status.textContent = resultLabel(job);
- summary.append(name, status);
- const steps = document.createElement("ol");
- steps.className = "step-list";
+ const chevron = document.createElement("span");
+ chevron.className = "chevron";
+ chevron.setAttribute("aria-hidden", "true");
+ summary.append(stateDot(runStatus(job)), name, status, chevron);
+ const stepsList = document.createElement("ol");
+ stepsList.className = "step-list";
for (const step of job.steps || []) {
const row = document.createElement("li");
row.className = "step-row";
- row.append(stateDot(runStatus(step)));
+ row.dataset.state = runStatus(step) || "idle";
const stepName = document.createElement("span");
- stepName.textContent = cleanText(step.name || `步骤 ${step.number || ""}`);
+ const original = cleanText(step.name || `步骤 ${step.number || ""}`);
+ stepName.textContent = raw ? original : friendlyName(original);
+ stepName.title = original;
const duration = document.createElement("span");
const actual = model.durationMs(step.started_at, step.completed_at);
duration.textContent = actual === null ? resultLabel(step) : formatDuration(actual, true);
- row.append(stepName, duration);
- steps.append(row);
+ row.setAttribute("aria-label", `${original},${resultLabel(step)},${duration.textContent}`);
+ row.append(stateDot(runStatus(step)), stepName, duration);
+ stepsList.append(row);
}
- details.append(summary, steps);
+ details.append(summary, stepsList);
list.append(details);
}
}
+ function finalJobsPendingSafe(run) {
+ return run ? finalJobsPending(run) : false;
+ }
+
function renderRecentRuns() {
+ const recent = state.runs.filter((run) => run.status === "completed").slice(0, 10);
+ const signature = JSON.stringify([recent, viewState.historyExpanded]);
+ if (signature === viewState.recentSignature) return;
+ viewState.recentSignature = signature;
const list = $("recent-runs");
list.replaceChildren();
- const recent = state.runs.slice(0, 10);
+ const toggle = $("toggle-history");
+ toggle.hidden = recent.length <= 3;
+ toggle.textContent = viewState.historyExpanded ? "收起" : `查看全部 ${recent.length} 条`;
+ toggle.setAttribute("aria-expanded", String(viewState.historyExpanded));
if (!recent.length) {
- list.append(emptyNode("所选范围内暂无 workflow run。"));
+ list.append(emptyNode("完成的构建会保留在这里。"));
return;
}
- for (const run of recent) {
- const row = document.createElement("div");
+ for (const run of recent.slice(0, viewState.historyExpanded ? 10 : 3)) {
+ const row = document.createElement("button");
+ row.type = "button";
row.className = "recent-row";
- const main = document.createElement("div");
+ const main = document.createElement("span");
main.className = "recent-main";
const title = document.createElement("strong");
title.textContent = configWorkflowName(run);
const meta = document.createElement("span");
- meta.textContent = `${run.head_branch || "未知分支"} · #${run.run_number || run.id} · ${formatClock(Date.parse(run.created_at || ""))}`;
+ const timestamp = Date.parse(run.created_at || "");
+ const date = Number.isFinite(timestamp) ? new Intl.DateTimeFormat("zh-CN", {
+ month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false
+ }).format(new Date(timestamp)) : "时间未知";
+ const elapsed = model.durationMs(run.run_started_at || run.created_at, run.updated_at);
+ meta.textContent = `#${run.run_number || run.id} · ${date}${elapsed === null ? "" : ` · ${formatDuration(elapsed, true)}`}`;
+ row.title = `${configWorkflowName(run)} · ${run.head_branch || "未知分支"}`;
+ row.setAttribute("aria-label", `${row.title},${resultLabel(run)},在 GitHub 查看`);
main.append(title, meta);
const result = document.createElement("span");
result.className = "result-label";
result.dataset.result = runStatus(run) || "idle";
result.textContent = resultLabel(run);
- row.append(main, result);
+ row.append(stateDot(runStatus(run)), main, result);
+ row.addEventListener("click", () => openGitHub(run));
list.append(row);
}
}
@@ -1011,43 +1304,58 @@
function renderHero(run) {
const estimate = run ? calculateEstimate(run) : null;
- const presentation = model.statePresentation(run, ["rate_limit", "offline"].includes(state.warning) ? state.warning : null);
+ const presentation = model.statePresentation(run);
+ const fullName = state.config?.fullName || "";
+ const jobs = visibleJobs(run);
+ const steps = jobs.flatMap((job) => job.steps || []);
+ const failure = failedStep(jobs);
+ const terminal = run?.status === "completed";
+ const active = run?.status === "in_progress";
$("hero-card").dataset.tone = presentation.tone;
- $("watch-title").textContent = run ? presentation.label : "等待构建";
- $("run-repository").textContent = state.config?.fullName || "";
- $("run-workflow").textContent = run ? configWorkflowName(run) : "正在等待新的 workflow run";
- $("current-step").textContent = run
- ? [estimate.job, estimate.step].filter(Boolean).join(" · ") || presentation.label
- : "后台守望已启动";
+ $("hero-card").dataset.stale = String(Boolean(state.warning));
+ $("watch-title").textContent = fullName.split("/").slice(1).join("/") || fullName || "等待构建";
+ $("run-repository").textContent = fullName.split("/")[0] || "GitHub";
+ $("hero-state").textContent = presentation.label;
+ $("hero-state").dataset.state = run ? runStatus(run) || "idle" : "idle";
+ $("run-workflow").textContent = run ? configWorkflowName(run) : "正在关注所选工作流";
+ $("current-step").textContent = failure ? `${failure.label}:${friendlyName(failure.name)}`
+ : terminal ? (run.conclusion === "success" ? "构建完成" : presentation.label)
+ : active ? friendlyName(estimate.step || estimate.job || "正在准备构建")
+ : run ? "等待运行器接手,开始后会自动更新" : "有新构建时会自动跟进";
+ $("stage-dot").dataset.state = failure ? "failure" : run ? runStatus(run) || "idle" : "idle";
+ $("step-count").textContent = steps.length ? `${steps.filter((step) => step.status === "completed").length}/${steps.length} 步已结束` : "";
+ $("meta-repository").textContent = fullName;
+ $("meta-workflow").textContent = run ? configWorkflowName(run) : "--";
$("run-branch").textContent = run?.head_branch || (state.config?.branchMode === "all" ? "全部分支" : state.config?.branch || "--");
- $("run-sha").textContent = run?.head_sha ? String(run.head_sha).slice(0, 7) : "--";
+ $("run-sha").textContent = run?.head_sha || "--";
+ $("meta-run").textContent = run ? `#${run.run_number || run.id} · 第 ${run.run_attempt || 1} 次尝试` : "--";
const progress = estimate?.progress || 0;
- $("progress-ring").style.setProperty("--progress", `${progress * 3.6}deg`);
+ $("progress-fill").style.width = `${progress}%`;
$("progress-ring").setAttribute("aria-valuenow", String(progress));
+ $("progress-ring").setAttribute("aria-valuetext", run ? `${progress}% · ${presentation.label}` : "等待构建");
$("progress-value").textContent = run ? `${progress}%` : "--";
- $("elapsed-time").textContent = estimate ? formatDuration(estimate.elapsedMs) : "--";
- if (!estimate?.sampleCount) {
- $("remaining-time").textContent = run ? "等权步骤" : "等待样本";
- $("sample-caption").textContent = "暂无可用历史样本,当前按等权步骤估算。";
- } else if (estimate.remainingMs !== null) {
+ $("progress-caption").textContent = terminal ? "本次已结束" : "预估进度";
+ $("elapsed-time").textContent = estimate ? formatDuration(estimate.elapsedMs, true) : "--";
+ $("remaining-label").textContent = terminal ? "构建结果" : "预计剩余";
+ if (terminal) {
+ $("remaining-time").textContent = resultLabel(run);
+ } else if (estimate?.remainingMs !== null && estimate?.remainingMs !== undefined) {
$("remaining-time").textContent = `约 ${formatDuration(estimate.remainingMs, true)}`;
- $("sample-caption").textContent = `估算基于同类最近 ${estimate.sampleCount} 次成功构建的算术平均值。`;
- } else if (estimate.overrunMs > 0) {
- $("remaining-time").textContent = `超均值 ${formatDuration(estimate.overrunMs, true)}`;
- $("sample-caption").textContent = `已超过最近 ${estimate.sampleCount} 次构建的历史均值。`;
} else {
- $("remaining-time").textContent = "即将完成";
- $("sample-caption").textContent = `估算基于同类最近 ${estimate.sampleCount} 次成功构建。`;
+ $("remaining-time").textContent = estimate?.overrunMs > 0 ? "比平时稍久" : "待估算";
}
+ $("sample-caption").textContent = !estimate?.sampleCount
+ ? "进度按步骤估算,不等于实际耗时百分比;历史样本不足,暂不估计剩余时间。"
+ : `进度与剩余时间参考最近 ${estimate.sampleCount} 次同类成功构建,仅供参考。`;
}
function renderDashboard() {
if (!state.monitoring) return;
renderRuntimeChip();
- const run = chooseDisplayedRun() || model.choosePrimaryRun(state.runs.filter(isActiveRun));
+ const run = displayedRun();
renderHero(run);
renderWarning();
- renderActiveRuns();
+ renderActiveRuns(run);
renderJobs(run);
renderRecentRuns();
const interval = currentPollInterval();
@@ -1056,21 +1364,22 @@
const now = Date.now();
let countdown;
if (state.pollInFlight) {
- countdown = `正在同步 · 已等待 ${formatDuration(Math.max(0, now - state.pollStartedAt), true)}`;
+ countdown = `正在刷新 · ${formatDuration(Math.max(0, now - state.pollStartedAt), true)}`;
} else if (!state.nextPollAt) {
- countdown = "自动刷新未就绪,请立即同步";
+ countdown = "等待首次刷新";
} else if (state.nextPollAt <= now) {
- countdown = `刷新已延迟 ${formatDuration(now - state.nextPollAt, true)} · 可立即同步`;
+ countdown = `刷新已延迟 ${formatDuration(now - state.nextPollAt, true)} · 可手动刷新`;
} else {
const waitingForQuota = state.rateRemaining === 0 && state.rateResetAt > now;
- const label = waitingForQuota ? "额度恢复后刷新" : state.warning ? "下次重试" : "下次刷新";
+ const label = waitingForQuota ? "额度恢复后刷新" : state.warning ? "下次重试" : "自动刷新";
countdown = `${label} · ${formatDuration(Math.ceil((state.nextPollAt - now) / 1000) * 1000, true)}`;
}
- $("poll-countdown").textContent = countdown;
+ $("poll-countdown").textContent = countdown + (discoveryQueue.length ? ` · 新构建扫描中(${discoveryQueue.length} 页待查)` : "");
$("refresh-now").disabled = state.busy || state.pollInFlight;
$("poll-caption").textContent = state.lastPollAt
? `上次同步 ${formatClock(state.lastPollAt)} · ${tokenLabel}轮询 ${formatDuration(interval, true)}${rate}`
: `等待首次同步 · ${tokenLabel}轮询 ${formatDuration(interval, true)}`;
+ $("poll-caption").title = `最后尝试 ${formatClock(state.lastAttemptAt)} · 最后成功 ${formatClock(state.lastPollAt)} · 通知更新 ${formatClock(state.lastNotificationAt)}`;
}
function renderRuntimeChip() {
@@ -1210,6 +1519,12 @@
$("start-watching").addEventListener("click", startWatching);
$("refresh-now").addEventListener("click", () => pollGitHub(true));
$("stop-watching").addEventListener("click", stopWatching);
+ $("open-github").addEventListener("click", () => openGitHub());
+ $("raw-names").addEventListener("change", () => renderJobs(displayedRun()));
+ $("toggle-history").addEventListener("click", () => {
+ viewState.historyExpanded = !viewState.historyExpanded;
+ renderRecentRuns();
+ });
if (toolbox()?.background?.onTimer) {
toolbox().background.onTimer((event) => {
@@ -1218,18 +1533,20 @@
});
}
if (toolbox()?.background?.onRestore) {
- toolbox().background.onRestore(async () => {
- try {
- const saved = await toolbox().storage.get(STORAGE_KEY);
- restoreObject(saved);
- await loadToken();
- await reconcileSession();
+ toolbox().background.onRestore(() => {
+ if (resumeInFlight) return;
+ resumeInFlight = (async () => {
+ await bootPromise;
+ if (!state.ready || !state.monitoring) return;
+ if (state.pollInFlight) return;
+ clockSessionId = null;
+ await bounded(reconcileSession(), null, 10_000);
await pollGitHub(false);
- } catch (error) {
+ })().catch((error) => {
state.warning = "offline";
state.warningMessage = `恢复后同步失败:${errorLabel(error)}`;
renderAll();
- }
+ }).finally(() => { resumeInFlight = null; });
});
}
@@ -1251,5 +1568,5 @@
window.addEventListener("pagehide", stopForegroundClock);
window.addEventListener("pageshow", startForegroundClock);
startForegroundClock();
- boot();
+ bootPromise = boot();
})();
diff --git a/examples/github-actions-watcher/index.html b/examples/github-actions-watcher/index.html
index 7f3d3acb..7bca1372 100644
--- a/examples/github-actions-watcher/index.html
+++ b/examples/github-actions-watcher/index.html
@@ -12,7 +12,7 @@