Compare commits

..
Author SHA1 Message Date
SivanCola 5c928c53be docs: update acknowledgments contributors 2026-11-09 03:25:55 +00:00
280 changed files with 10818 additions and 2805 deletions

No files matched your search

+293
View File
@@ -0,0 +1,293 @@
name: Android
# The Android shell under `android/` ships from the same tag the desktop line
# publishes, and lands on the same release page.
#
# It is a separate workflow rather than a job inside release-studio.yml for two
# reasons. The toolchains share nothing — Go, the Android SDK, a Gradle wrapper —
# so a runner carrying both would install three toolchains to build one artifact.
# And `signing-contract` pins which workflows may reach the SignPath credentials;
# this one never touches them, and keeping it out of that graph is what keeps the
# fingerprint stable.
#
# A failure here does not hold back the desktop release: the two are independent
# by construction, and an APK is worth less than the desktop builds it sits
# beside.
on:
push:
tags: ['studio-v*']
workflow_dispatch:
inputs:
tag:
description: "Existing studio tag to build for and attach to (for example studio-v2.51.0)"
required: false
type: string
ref:
description: "A branch to build as a trial run of this workflow; nothing is attached to any release"
required: false
type: string
permissions:
contents: write # upload the APK to the release the tag belongs to
concurrency:
group: android-${{ inputs.tag || inputs.ref || github.ref_name }}
cancel-in-progress: false
jobs:
resolve:
name: resolve the tag
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
sha: ${{ steps.release.outputs.sha }}
candidate: ${{ steps.release.outputs.candidate }}
publish: ${{ steps.release.outputs.publish }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
persist-credentials: false
# The same tag grammar release-studio.yml enforces, spelled the same way:
# two workflows that disagree about which tags name a release would publish
# one artifact for a tag the other refused.
- name: Resolve tag and version
id: release
env:
IN_TAG: ${{ inputs.tag }}
IN_REF: ${{ inputs.ref }}
REF_NAME: ${{ github.ref_name }}
run: |
# A trial run builds a branch as a throwaway version and publishes nothing.
if [ -n "$IN_REF" ] && [ -z "$IN_TAG" ]; then
sha="$(git rev-parse --verify "origin/$IN_REF^{commit}")"
{ echo "tag="; echo "version=v0.0.0-dev.${sha:0:7}"; echo "sha=$sha"
echo "candidate=false"; echo "publish=false"; } >> "$GITHUB_OUTPUT"
echo "trial build of $IN_REF at $sha"
exit 0
fi
tag="${IN_TAG:-$REF_NAME}"
semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?$'
if [[ ! "$tag" =~ ^studio-v(.+)$ ]] || [[ ! "${BASH_REMATCH[1]}" =~ $semver ]]; then
echo "::error::studio release tag must be studio-vMAJOR.MINOR.PATCH[-PRERELEASE], got: $tag"
exit 1
fi
sha="$(git rev-parse --verify "refs/tags/$tag^{commit}")"
if ! git merge-base --is-ancestor "$sha" origin/studio; then
echo "::error::$tag is not on studio history"
exit 1
fi
version="${tag#studio-}"
candidate=false
if [[ "${version#v}" =~ $semver ]] && [ -n "${BASH_REMATCH[4]}" ]; then
candidate=true
fi
{ echo "tag=$tag"; echo "version=$version"; echo "sha=$sha"
echo "candidate=$candidate"; echo "publish=true"; } >> "$GITHUB_OUTPUT"
echo "resolved $tag -> $version at $sha (candidate=$candidate)"
build:
name: build the APK
needs: resolve
runs-on: ubuntu-latest
permissions:
contents: read
# `publish` decides whether to attach anything, and the only honest answer to
# "will this APK install" is whether a key was restored.
outputs:
signed: ${{ steps.key.outputs.signed }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
cache-dependency-path: go.sum
# pnpm first: setup-node's pnpm cache needs the executable to exist.
- uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v4
with:
version: 10
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0
with:
distribution: temurin
java-version: "21"
cache: gradle
# The SDK is the one thing nothing sets for you, per android/README.md. The
# runner image carries one; this asserts the two pieces the build names
# rather than re-installing them, so a missing package fails here by name
# instead of surfacing later as an unresolved compileSdk.
- name: Provide the Android SDK
run: |
if [ -z "${ANDROID_HOME:-}" ]; then
echo "::error::ANDROID_HOME is unset; the runner image is expected to provide the Android SDK"
exit 1
fi
for piece in "platforms/android-35" "build-tools/35.0.0"; do
if [ ! -d "$ANDROID_HOME/$piece" ]; then
echo "::error::the Android SDK at $ANDROID_HOME has no $piece; android/README.md names it as a requirement"
exit 1
fi
done
echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> "$GITHUB_ENV"
# A key that exists signs; a key that does not leaves the artifact
# unsigned, which is what a release with no key has always produced. The
# decision is the repository's, not this workflow's: it reads the four
# secrets android/README.md already names.
- name: Restore the signing key
id: key
env:
KEYSTORE_BASE64: ${{ secrets.REASONIX_KEYSTORE_BASE64 }}
STORE_PASSWORD: ${{ secrets.REASONIX_KEYSTORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.REASONIX_KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.REASONIX_KEY_ALIAS }}
run: |
if [ -z "$KEYSTORE_BASE64" ] || [ -z "$STORE_PASSWORD" ] || [ -z "$KEY_ALIAS" ]; then
echo "signed=false" >> "$GITHUB_OUTPUT"
echo "no signing key configured: the APK will be unsigned"
exit 0
fi
mkdir -p android/keystore
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > android/keystore/release.jks
{
echo "storeFile=keystore/release.jks"
echo "storePassword=${STORE_PASSWORD}"
echo "keyAlias=${KEY_ALIAS}"
# PKCS#12 keystores use one password for both; only write the second
# when it is genuinely a different fact.
[ -n "$KEY_PASSWORD" ] && echo "keyPassword=${KEY_PASSWORD}"
} > android/keystore/keystore.properties
chmod 600 android/keystore/*
echo "signed=true" >> "$GITHUB_OUTPUT"
echo "signing key restored"
- name: Build the kernel and the page
working-directory: android
env:
VERSION: ${{ needs.resolve.outputs.version }}
run: |
export REASONIX_APP_VERSION="${VERSION#v}"
bash ./scripts/build-kernel.sh
bash ./scripts/sync-page.sh
# The one end-to-end check that needs no phone: the kernel the shell starts,
# serving the page the APK carries. Running it here means an APK that would
# open blank fails the build instead of the install.
- name: Verify the packaged page
working-directory: android
run: |
(cd .. && go build -o reasonix-verify ./cmd/reasonix)
KERNEL_BIN="$PWD/../reasonix-verify" bash ./scripts/verify-page.sh
- name: Assemble
working-directory: android
env:
VERSION: ${{ needs.resolve.outputs.version }}
run: |
# The version comes from the tag, so the About screen and the update
# check name this release. versionCode keeps its existing shape, which
# is why the build digit stays 1: it is a single decimal place, and a
# number that grows past it would collide with the next patch.
./gradlew assembleRelease --no-daemon "-PreasonixVersion=${VERSION#v}"
- name: Name the artifact
id: apk
working-directory: android/app/build/outputs/apk/release
run: |
src="$(ls ./*.apk | head -n 1)"
if [ -z "$src" ]; then
echo "::error::assembleRelease produced no APK"
exit 1
fi
case "$src" in
*-unsigned.apk) name="ReasonixMobile-${{ needs.resolve.outputs.version }}-unsigned.apk" ;;
*) name="ReasonixMobile-${{ needs.resolve.outputs.version }}.apk" ;;
esac
mv "$src" "$name"
sha256sum "$name" > "$name.sha256"
{
echo "path=$PWD/$name"
echo "name=$name"
} >> "$GITHUB_OUTPUT"
ls -l "$name" "$name.sha256"
# An unsigned package is not a lesser APK, it is an unusable one: Android's
# package manager rejects it outright rather than reporting a signature
# mismatch. Say so where the run summary can be read, instead of letting a
# green build imply a shippable artifact.
- name: Say what an unsigned build means
if: steps.key.outputs.signed != 'true'
run: |
echo "::warning::no signing key configured, so this APK is unsigned. Android refuses to install an unsigned package, so the release will carry no APK; the file stays a workflow artifact for whoever signs it."
{
echo "### No APK on this release"
echo ""
echo "The build produced an unsigned APK, which Android refuses to install."
echo "It is attached to this run as a workflow artifact only. Add the four"
echo "signing secrets to publish one."
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v8
with:
name: android-apk
path: |
android/app/build/outputs/apk/release/*.apk
android/app/build/outputs/apk/release/*.sha256
if-no-files-found: error
publish:
name: attach the APK
needs: [resolve, build]
# Nothing to attach unless the build was signed. Skipping is the honest
# outcome: a release page with an APK nobody can install is worse than a
# release page without one.
if: needs.build.outputs.signed == 'true' && needs.resolve.outputs.publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: android-apk
path: dist
# The release itself belongs to release-studio.yml, which creates it and
# owns its notes. This waits for it rather than creating a second one: two
# jobs racing to define a release is how a page ends up with the wrong body.
- name: Attach to the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
REPO: ${{ github.repository }}
run: |
# 90 minutes: on the last Studio release the page appeared about 40
# minutes after the tag run started, and the APK build spends part of
# that before this wait begins.
for _ in $(seq 1 360); do
if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
gh release upload "$TAG" dist/*.apk dist/*.sha256 --repo "$REPO" --clobber
echo "attached to $TAG"
exit 0
fi
sleep 15
done
echo "::error::release $TAG did not appear within 90 minutes; the APK was built but not published"
exit 1
-23
View File
@@ -158,17 +158,9 @@ jobs:
node perf/codeeditor-size.mjs
node perf/composer-reflow.mjs
node perf/composer-toolbar-row.mjs
node perf/control-icons.mjs
node perf/nav-selection.mjs
node perf/action-icons.mjs
node perf/rowhit.mjs
node perf/prefs-wide-centered.mjs
node perf/settings-fullscreen.mjs
node perf/settings-page-column.mjs
node perf/sidebar-tools.mjs
node perf/select-alignment.mjs
node perf/provider-fields.mjs
node perf/thought-clock.mjs
node perf/tab-pip.mjs
node perf/syntax-tokens.mjs
node perf/tab-close-inside.mjs
@@ -226,21 +218,6 @@ jobs:
node perf/narrow-rail.mjs
node perf/selfcheck.mjs branch-tooltip
- name: Keep UI control previews
if: always() && steps.spa.outputs.run == 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: studio-ui-control-previews
path: |
desktop/frontend-next/perf/shots/control-icons-*.png
desktop/frontend-next/perf/shots/nav-selection-*.png
desktop/frontend-next/perf/shots/settings-full-*.png
desktop/frontend-next/perf/shots/sidebar-tools-*.png
desktop/frontend-next/perf/shots/select-alignment-*.png
desktop/frontend-next/perf/shots/provider-fields-*.png
desktop/frontend-next/perf/shots/thought-clock-*.png
retention-days: 7
# What a hosted page does over the relay, end to end: the production bundle,
# the real Studio host and kernel, a real Chromium; only the accounts API and
# the relay are stand-ins (tools/relayfake). Event-transport changes are
+16 -3
View File
@@ -169,6 +169,19 @@ Every package ships with a `.minisig` signature, and the release carries
The same release also carries the 2.x `reasonix` CLI as archives for
`darwin|linux|windows × amd64|arm64`.
The Android build ships on that release as `ReasonixMobile-<version>.apk`
(`arm64`, Android 8.0 or newer). The kernel and the page run on the phone, and
the agent reaches your servers over SSH; pairing with a computer is done by
scanning the code it shows.
The APK is signed with the Android release key rather than minisign, so it
carries a `.sha256` and no `.minisig`.
When that key is absent the build is named
`ReasonixMobile-<version>-unsigned.apk`. Android refuses to install an unsigned
package, so those builds stay a workflow artifact for whoever wants to sign one,
and the release carries no APK at all.
### Reasonix 1.x (stable)
The 1.x CLI installs through npm on any supported platform, or Homebrew on
@@ -288,11 +301,11 @@ A small list of folks whose work has shaped Reasonix the most — the current to
<!-- reasonix-top-contributors:start -->
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**chuiwenwei**](https://github.com/chuiwenwei) |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**ttmouse**](https://github.com/ttmouse) |
| [**lifu963**](https://github.com/lifu963) | [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**xxoingr**](https://github.com/xxoingr) |
| [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) |
| [**light-front-theory**](https://github.com/light-front-theory) | [**imcreazy**](https://github.com/imcreazy) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) |
| [**par73e**](https://github.com/par73e) | [**Bernardxu123**](https://github.com/Bernardxu123) | [**lancy69**](https://github.com/lancy69) | [**Guan0923**](https://github.com/Guan0923) |
| [**light-front-theory**](https://github.com/light-front-theory) | [**imcreazy**](https://github.com/imcreazy) | [**lancy69**](https://github.com/lancy69) | [**eghrhegpe**](https://github.com/eghrhegpe) |
| [**noreply**](https://github.com/noreply) | [**par73e**](https://github.com/par73e) | [**kevin9327**](https://github.com/kevin9327) | [**Bernardxu123**](https://github.com/Bernardxu123) |
<!-- reasonix-top-contributors:end -->
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
+12 -3
View File
@@ -156,6 +156,15 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
同一个发布页也提供 2.x 的 `reasonix` CLI 归档(`darwin|linux|windows × amd64|arm64`)。
安卓版在同一个发布页上,文件名是 `ReasonixMobile-<版本>.apk`(`arm64`,Android 8.0 及以上)。
这个 APK 用 Android 发布密钥签名,不走 minisign,因此只有一个 `.sha256`,没有 `.minisig`。
该密钥缺失时产物名为 `ReasonixMobile-<版本>-unsigned.apk`。Android 不接受未签名的包,
这类构建只留作 workflow 产物供自行签名,发布页上不会挂 APK。
内核与界面都跑在手机上,Agent 通过 SSH 连接你自己的服务器;与电脑配对时扫它显示的二维码。
### Reasonix 1.x(稳定版)
1.x CLI 在任意支持的平台上都可以通过 npm 安装,macOS 也可以使用 Homebrew:
@@ -269,11 +278,11 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
<!-- reasonix-top-contributors:start -->
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**chuiwenwei**](https://github.com/chuiwenwei) |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**ttmouse**](https://github.com/ttmouse) |
| [**lifu963**](https://github.com/lifu963) | [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**xxoingr**](https://github.com/xxoingr) |
| [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) |
| [**light-front-theory**](https://github.com/light-front-theory) | [**imcreazy**](https://github.com/imcreazy) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) |
| [**par73e**](https://github.com/par73e) | [**Bernardxu123**](https://github.com/Bernardxu123) | [**lancy69**](https://github.com/lancy69) | [**Guan0923**](https://github.com/Guan0923) |
| [**light-front-theory**](https://github.com/light-front-theory) | [**imcreazy**](https://github.com/imcreazy) | [**lancy69**](https://github.com/lancy69) | [**eghrhegpe**](https://github.com/eghrhegpe) |
| [**noreply**](https://github.com/noreply) | [**par73e**](https://github.com/par73e) | [**kevin9327**](https://github.com/kevin9327) | [**Bernardxu123**](https://github.com/Bernardxu123) |
<!-- reasonix-top-contributors:end -->
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
+22
View File
@@ -0,0 +1,22 @@
# Build output and local tooling state.
build/
.gradle/
.kotlin/
local.properties
*.iml
.idea/
# The signing key and the file that names it.
#
# A key is not part of the project: it is the identity one person ships under,
# and publishing it would let anyone else sign an APK that installs over
# everyone's. Neither the keystore nor its password belongs in version control.
# `app/build.gradle` reads both from here when present and falls back to the
# debug key when they are not, so a fresh clone builds without them.
keystore/
# Build inputs, produced by scripts/ before Gradle runs.
# jniLibs/ holds the cross-compiled kernel, assets/page/ the built
# frontend. assets/LICENSE is hand-placed and stays tracked.
app/src/main/jniLibs/
app/src/main/assets/page/
+216
View File
@@ -0,0 +1,216 @@
---
owner: @esengine
backup: @SivanCola
status: active
reviewed: 2026-10-20
---
# Reasonix Mobile — the Android shell
Runs Reasonix as an Android app: the kernel ships inside the APK and starts on the
device, the interface is the same `desktop/frontend-next` build the desktop Studio
serves, and the agent reaches servers over SSH.
This file is for people building it. What a person installing it needs — what it
does, how to pair with a computer, what it cannot do — belongs in the release
notes.
## One command
```bash
./scripts/build.sh release # or debug
```
Three steps, in this order:
1. `build-kernel.sh` → `app/src/main/jniLibs/arm64-v8a/libreasonix.so`
2. `sync-page.sh` → `app/src/main/assets/page/`
3. `./gradlew assembleRelease`
Output: `app/build/outputs/apk/release/app-release.apk`
The kernel and the page are **inputs to packaging, not Gradle build steps**.
Running `gradle assembleRelease` on its own fails on purpose and names the script
that produces whichever input is missing (`requireBuildInputs` in
`app/build.gradle`): an APK with no kernel in it should not be buildable by
accident.
## Requirements
| Component | Version |
| --- | --- |
| JDK | 21 (17 also works) |
| Android SDK | `platforms;android-35`, `build-tools;35.0.0`, `platform-tools` |
| Gradle | the committed wrapper, 8.14.3 (for AGP 8.11.1) |
| Go | 1.26+ |
| Node / pnpm | Node 24+ / pnpm 10 |
```bash
export ANDROID_HOME="$HOME/Android/Sdk"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export JAVA_HOME="/path/to/jdk-21"
export PATH="$PATH:$(go env GOPATH)/bin"
```
`ANDROID_HOME` is the one thing nothing sets for you.
## Scripts
| Script | What it does |
| --- | --- |
| `build.sh [debug\|release]` | The whole chain |
| `build-kernel.sh` | Cross-compiles the kernel only (`GOOS=android GOARCH=arm64 CGO_ENABLED=0`) |
| `sync-page.sh` | Builds the frontend, copies `dist/` into assets, copies `LICENSE` |
| `verify-page.sh` | **No phone needed**: starts the real kernel against the packaged page |
| `run.sh [debug\|release]` | `adb install`, launch, follow the log |
`verify-page.sh` is the one to run after a change: it starts the kernel with the
same arguments `Kernel.kt` uses (`--auth token`, `--token-file`, `--port-file`,
`-page`) and checks that it binds a port and answers 200. Without a device, that
is as deep as the chain can be verified.
## Where the code is
| File | Owns |
| --- | --- |
| `MainActivity.kt` | The WebView, the back gesture, downloads, external links, renderer restart |
| `Kernel.kt` | Unpacking the page, starting and stopping the kernel, waiting for the port, health probes, logs, the workspace directory |
| `KernelService.kt` | The foreground service that keeps a long turn alive |
| `NativeBridge.kt` | `@JavascriptInterface`: what the page may ask the phone for |
| `BrowserShellServer.kt` | Loopback HTTP server the kernel's browser tools drive the overlay WebView through |
| `Downloader.kt` | Writing a kernel-produced file through the system document picker |
The page reaches the shell through `window.reasonixHost` and `ReasonixNative`,
defined in `desktop/frontend-next/src/port/host.ts` (`AndroidHost`).
**Phone and desktop share one page build.** What differs only on a phone is
decided at runtime, not by a second set of components. `phoneShell()` in
`src/port/host.ts` and the `[data-shell="android"]` selector in `mobile.css` both
read `<html data-shell="android">`, which `index.html` sets before the bundle
loads.
One attribute feeding both is what keeps style and components from disagreeing
about what a phone is.
## What the page may ask the phone for
`NativeBridge` is reachable from whatever document the WebView holds, so every
method that touches the device asks `trusted()` first.
The activity answers by comparing the **main frame's** origin against the
kernel's and against the one paired computer the person scanned a code for. A
page that navigated elsewhere is not trusted, even though it may still hold the
object.
Five methods answer without the check — `version`, `systemTheme`, `safeArea`,
`setLayerOpen`, `setRunning` — because they report back what the page already
knows about its own window and grant nothing.
`openInApp(url)` is the pairing decision, not a navigation one: it loads a URL in
this window only for the origin the scanner just returned, and only once.
Without that, any page holding the kernel origin could hand itself the bridge.
External links leave through the browser. A non-web scheme is routed to the
system only for `mailto:` and `tel:`; everything else is dropped, because an
`intent://` URL can start an arbitrary exported activity with caller-supplied
extras.
## Permissions, and what they cost
`MANAGE_EXTERNAL_STORAGE` — opening a folder on the phone as a workspace. The
kernel is a Go binary: it opens real paths with `open(2)`, and SAF hands back a
`content://` URI it cannot open.
Requested from the folder-picker action, never at launch. Android shows no dialog
for it and opens system settings instead.
**There is no OS-level sandbox around this kernel.** The PR sets the sandbox to
off on Android, so confining the agent to the chosen folder is Reasonix's own
write-root check and nothing else. A shell tool with all-files access can reach
all shared storage.
`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` — Doze freezes the app group, which stops
a turn that is still running. Asked once when a turn actually runs, never at
launch, and the person can refuse. Refusing costs battery life during long turns
and nothing else.
**Google Play restricts this permission**: fine for a sideloaded build, a
store-policy risk if this is ever listed.
`FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_DATA_SYNC` — keeping the kernel alive
while the screen is off. `dataSync` is capped per 24 hours from Android 15. It is
the closest honest description of "an agent is working".
`CAMERA` — reading the pairing code a computer shows. Requested when the person
asks to scan, never at launch. `ScanActivity` is not exported.
`READ_EXTERNAL_STORAGE` / `WRITE_EXTERNAL_STORAGE` — the same folder walk below
API 33. `maxSdkVersion`-gated; above 33 they are superseded by the permission
above.
`allowBackup=false` with explicit extraction rules, so a `keystore` or a token in
app storage never leaves the device in a cloud backup.
## Cleartext HTTP
The kernel serves its page on `127.0.0.1:<port>`, and a paired computer serves
the same page over its LAN address. Both are plain HTTP.
Neither address can be written into a `network-security-config`: the port is
chosen at start, the LAN address at scan time, and the format matches literals
and domains, not subnets.
So the config permits cleartext and the boundary is in the code instead.
`shouldOverrideUrlLoading` sends every other http(s) origin to the browser, and
the bridge answers only the kernel and the just-paired computer. A plaintext page
on the LAN cannot become the document holding the bridge.
## What goes into the APK
| Content | Size | Why it is shaped this way |
| --- | --- | --- |
| `lib/arm64-v8a/libreasonix.so` | 45 MB | Go static binary, stripped. It is a `.so` rather than a file in `assets/` because only `nativeLibraryDir` is guaranteed executable — a binary copied to app storage fails on a device whose data partition is mounted `noexec`. |
| `assets/page/` | 14 MB | The frontend build, including the Inter and Noto Sans SC fonts, `noCompress` |
| `assets/LICENSE` | 1 KB | The repository's MIT licence, verbatim. MIT requires the notice and the licence text to accompany a binary distribution. |
`assets/LICENSE` is generated by `sync-page.sh` from the repository's root
`LICENSE`, not committed a second time: a checked-in copy drifts when the root
file is updated.
It sits in `assets/` rather than `assets/page/` because that directory is removed
and rebuilt on every build.
A release APK is about 31 MB.
## Building
**The Gradle wrapper is committed**, jar and all. `build.sh` refuses to run
without `gradlew` rather than generating one from whatever `gradle` is on `PATH`:
the dependency versions are part of the build.
`gradle-wrapper.properties` pins `distributionSha256Sum`, so a mirror that serves
a different distribution zip fails verification instead of being unpacked and
run. Dependency repositories are the official ones only — a mirror belongs in a
user-level init script.
**Signing.** A release with no key configured comes out **unsigned**
(`app-release-unsigned.apk`) with a warning. It used to fall back to the debug
key, which produced an artifact that looked shippable and was signed by a
per-machine key nobody chose: it could not install over the previous build.
Point the build at a real key by writing `keystore/keystore.properties`
(`storeFile`, `storePassword`, `keyAlias`, and optionally `keyPassword`) or by
setting the matching `REASONIX_KEYSTORE*` environment variables. Key material
stays out of the repository and out of build logs.
Debug builds always use the debug key. Signing a debug build with the release key
would let anyone who can read the keystore produce a build that installs over the
shipped one under the same signature.
**Non-ASCII checkout paths.** AGP refuses to build under a path with non-ASCII
characters on Windows unless `android.overridePathCheck=true` is set. That is a
property of one machine's disk layout, not of this project, so it is not in the
repository's `gradle.properties`.
Set it in a user-level `~/.gradle/gradle.properties` or pass
`-Pandroid.overridePathCheck=true`.
+263
View File
@@ -0,0 +1,263 @@
// The app module.
//
// Two things ship inside the APK and neither is fetched at runtime: the kernel
// binary, and the built page it serves. Bundling the page is what makes the app
// work offline and start instantly; bundling the kernel is what lets it run
// `reasonix serve` on the phone itself, which is the whole point — the agent
// starts locally and reaches the server over SSH, so the interface, the model
// catalogue and the credentials all live on the phone.
//
// The kernel is placed as `jniLibs/arm64-v8a/libreasonix.so`, not under
// `assets/`. That is not a trick for its own sake: Android permits executing a
// file under `nativeLibraryDir` and nothing under `filesDir` has that
// guarantee, so a binary shipped the ordinary way would have to be copied out
// and made executable at runtime — which fails outright on a device mounted
// noexec, and fails confusingly on the ones that are merely unusual. `scripts/
// build-kernel.sh` writes it there; the name is fixed by the packaging rules.
plugins {
id "com.android.application"
id "org.jetbrains.kotlin.android"
}
// One version, read by the About screen and the update check.
//
// A published build takes its version from the tag it was cut for, so the About
// screen and the update check name that release rather than the number this file
// was last edited to. Without the property — a local build — the numbers below
// are the answer.
def publishedVersion = findProperty("reasonixVersion") as String
def versionMajor = 2
def versionMinor = 0
def versionPatch = 0
def versionBuild = 1
if (publishedVersion) {
def parsed = publishedVersion =~ /^(\d+)\.(\d+)\.(\d+)/
if (!parsed.find()) {
throw new GradleException("reasonixVersion must start with MAJOR.MINOR.PATCH, got: ${publishedVersion}")
}
versionMajor = parsed.group(1) as int
versionMinor = parsed.group(2) as int
versionPatch = parsed.group(3) as int
}
// Where the two build inputs are expected. Both are produced from the checkout
// beside this module, by the scripts named in each message.
def upstream = rootProject.file("..")
def pageDir = new File(projectDir, "src/main/assets/page")
def kernelSo = new File(projectDir, "src/main/jniLibs/arm64-v8a/libreasonix.so")
android {
namespace "io.reasonix.mobile.app"
compileSdk 35
defaultConfig {
applicationId "io.reasonix.mobile.app"
minSdk 26
// 35: the API level this app is actually built and tested against.
//
// It was briefly lowered to 26 while chasing an install failure that
// turned out to be a package-name collision with an unrelated app on the
// user's phone — the signature error named the wrong cause. Nothing about
// the target level ever caused it, so it goes back to the level the code
// is really written for.
//
// One consequence to keep in mind: from 35, Android 15 forces the window
// edge-to-edge and ignores a request to lay out inside the safe area. The
// window therefore *is* drawn under the status bar and the home indicator,
// and the only correct answer is for the page to pad itself — which is
// what `MainActivity` feeds real inset values for, since a WebView does
// not implement `env(safe-area-inset-*)`.
targetSdk 35
// versionCode must strictly increase, because Android refuses an update
// whose versionCode is not higher. The build digit is a single decimal
// place, so the scheme collides once a patch reaches 10: 2.55.10 and
// 2.56.0 both come out as 25601. Patch releases above 9 are not expected
// on Studio, and widening the digit now would renumber every build, so
// the constraint is recorded rather than fixed.
versionCode versionMajor * 10000 + versionMinor * 100 + versionPatch * 10 + versionBuild
versionName "${versionMajor}.${versionMinor}.${versionPatch}"
// The kernel is built for arm64 only, so every other ABI would ship an
// APK that installs and then cannot start. Naming the one real ABI keeps
// the store listing and the installer honest.
ndk {
abiFilters "arm64-v8a"
}
resourceConfigurations += ["zh", "en"]
}
// Android identifies an app by package name *and* signing key: an APK signed
// with a different key is not an update, it is an impostor, and the
// installer refuses it with "the signature does not match the installed
// app". Gradle's default release key is the debug key in the user's home
// directory, which is per-machine, so a build made on a second computer
// could never be installed over one made on the first.
//
// Whoever ships a build therefore pins a key — and that key is theirs, not
// the project's. It lives beside the checkout, in `keystore/`, which is
// ignored; so does the file naming it. A checkout without either signs with
// the debug key instead, which is what a contributor building locally wants
// and is what the README tells them to expect. Point the build at a real key
// by writing `keystore/keystore.properties` (storeFile, storePassword,
// keyAlias) or by setting REASONIX_KEYSTORE / REASONIX_KEYSTORE_PASSWORD /
// REASONIX_KEY_ALIAS.
def keyProperties = new Properties()
def keyPropertiesFile = rootProject.file("keystore/keystore.properties")
if (keyPropertiesFile.exists()) {
keyPropertiesFile.withInputStream { keyProperties.load(it) }
}
def keyStorePath = keyProperties.getProperty("storeFile") ?: System.getenv("REASONIX_KEYSTORE")
def keyStorePassword = keyProperties.getProperty("storePassword") ?: System.getenv("REASONIX_KEYSTORE_PASSWORD")
// The key's own password is a separate fact from the store's. PKCS#12 keystores
// almost always use one password for both, so falling back to the store's
// password keeps those working — but assuming they are equal is how a keystore
// that separates them gets a build failure at signing time, after the work is
// done, with an error that does not say which of the two was wrong.
def keyPasswordValue = keyProperties.getProperty("keyPassword") ?: System.getenv("REASONIX_KEY_PASSWORD") ?: keyStorePassword
def keyAliasName = keyProperties.getProperty("keyAlias") ?: System.getenv("REASONIX_KEY_ALIAS")
def haveReleaseKey = keyStorePath && keyStorePassword && keyAliasName &&
rootProject.file(keyStorePath).exists()
signingConfigs {
if (haveReleaseKey) {
releaseKey {
storeFile rootProject.file(keyStorePath)
storePassword keyStorePassword
keyAlias keyAliasName
keyPassword keyPasswordValue
// v1 is not optional here. Android 7+ reads v2 and ignores v1, so
// a v2-only APK installs on a modern phone — and then fails on the
// compatibility layers older devices and HarmonyOS' 卓易通 run,
// which know only the JAR signature and report its absence as a
// signature *mismatch*. Signing both is what makes one APK install
// everywhere.
enableV1Signing = true
enableV2Signing = true
enableV3Signing = true
}
}
}
// Nothing native is compiled here — the one .so is a prebuilt the kernel
// script writes — so the external native build is left alone entirely.
buildTypes {
debug {
applicationIdSuffix ".debug"
versionNameSuffix "-debug"
// The debug key, always. Signing a debug build with the release key
// would mean anyone who can read the release keystore can produce a
// build that installs over the shipped one under the same signature,
// and it buys nothing: the debug package is suffixed `.debug`, so the
// two already install side by side.
}
release {
// The kernel is a stripped Go binary and the page is minified, but
// the Kotlin shell still benefits and shipping the shrunk build is
// how a size regression gets noticed.
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
// A release with no key of its own comes out *unsigned*
// (`app-release-unsigned.apk`). It used to fall back to the debug key
// so the artifact would install, which produced a release that looked
// shippable and was signed by a per-machine key nobody chose: it could
// not be installed over the previous build, and every machine made a
// different one. An unsigned artifact says what it is.
if (haveReleaseKey) {
signingConfig signingConfigs.releaseKey
} else {
logger.warn(
"release build has no signing key: producing an unsigned APK. " +
"Write keystore/keystore.properties (storeFile, storePassword, keyAlias) " +
"or set REASONIX_KEYSTORE / REASONIX_KEYSTORE_PASSWORD / REASONIX_KEY_ALIAS."
)
}
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
// AGP 8 stops generating BuildConfig unless asked. `Kernel` reads the
// version name and code out of it to decide whether the bundled page
// needs unpacking, so it is asked for.
buildConfig = true
}
packaging {
jniLibs {
// `extractNativeLibs=true`: the kernel must exist as a real file on
// disk to be executed, and an uncompressed-and-mmap'd .so is not
// one. This is the only reason the flag is on.
useLegacyPackaging true
}
resources {
excludes += ["META-INF/*.version", "META-INF/*.kotlin_module", "kotlin/**", "DebugProbesKt.bin"]
}
}
// The page is read out of assets by `Kernel.publish`, not by the resource
// system, so it is not compressed — a page is already-compressed text and
// shrinking it again only costs startup time.
androidResources {
noCompress += ["json", "woff2", "ttf", "woff", "txt", "svg"]
}
lint {
abortOnError true
warningsAsErrors false
checkReleaseBuilds true
}
}
dependencies {
implementation "androidx.core:core-ktx:1.15.0"
implementation "androidx.appcompat:appcompat:1.7.0"
implementation "androidx.activity:activity-ktx:1.9.3"
implementation "androidx.webkit:webkit:1.12.1"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.7"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"
// Scanning the pairing code a computer shows.
//
// CameraX for the preview and the frame stream, ZXing for the decode, and
// deliberately not ML Kit: this app installs on phones whose HarmonyOS
// compatibility layer has no Google Play Services, so a decoder that needs
// GMS would work on the developer's phone and fail on the user's. ZXing
// core is pure Java with no runtime dependency at all, which is what makes
// the scanner work on both.
implementation "androidx.camera:camera-core:1.4.1"
implementation "androidx.camera:camera-camera2:1.4.1"
implementation "androidx.camera:camera-lifecycle:1.4.1"
implementation "androidx.camera:camera-view:1.4.1"
implementation "com.google.zxing:core:3.5.3"
}
// Fail before the packaging step with something a person can act on, rather
// than shipping an APK whose kernel is missing or whose page is a directory the
// build never filled. The two messages name the script that produces each.
tasks.register("requireBuildInputs") {
doFirst {
if (!kernelSo.exists()) {
throw new GradleException(
"内核二进制缺失:${kernelSo}\n先运行 ./scripts/build-kernel.sh(在 android/ 目录下)交叉编译内核。")
}
if (!pageDir.exists() || !new File(pageDir, "index.html").exists()) {
throw new GradleException(
"前端页面缺失:${pageDir}/index.html\n先运行 ./scripts/sync-page.sh(在 android/ 目录下)构建并同步页面。")
}
}
}
tasks.configureEach { task ->
if (task.name.startsWith("merge") && (task.name.contains("Assets") || task.name.contains("JniLibFolders"))) {
task.dependsOn("requireBuildInputs")
}
}
+32
View File
@@ -0,0 +1,32 @@
# R8 rules for the shell.
#
# There is very little to keep here, and that is the point: the app's own code
# is reached from the manifest and from Android's own callbacks, both of which
# R8 already traces, and nothing in it is loaded reflectively. What follows is
# the two places where a name is looked up rather than called.
# The WebView's JavaScript bridge, if one is ever added, is resolved by name on
# the Java side and by the string the page uses. Keeping annotated members makes
# that survive shrinking rather than failing at runtime with a method-not-found
# that only shows up on a release build.
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
# The Kernel's child process is started by absolute path, and its own log is
# read as text; neither is reflective, so nothing else needs keeping.
# Line numbers make a stack trace from a release build readable, which is the
# only way a crash report from a phone can be acted on.
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
# Kotlin's `@Metadata` annotation is present on every class the compiler touched
# and nothing in this app reflects over it. It is tempting to `-assumenosideeffects`
# it away, and that was tried: any pattern broad enough to name its members also
# matches the methods every class inherits from `Object` — `equals`, `hashCode`,
# `toString` — and R8 then treats those as removable, which is a silent
# behaviour change for a few kilobytes. Left as a warning suppression instead:
# the annotation is dropped from the shrunk output anyway, because nothing reads
# it.
-dontwarn kotlin.Metadata
+112
View File
@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The shell's manifest.
There is no INTERNET-adjacent permission beyond INTERNET itself: the page and
the kernel talk over loopback, and reaching a server is the kernel's job over
a socket it owns. Reading and writing files goes through the system's document
picker (SAF), which hands back a grant for one file at a time — so no
storage permission is declared and none is needed.
`usesCleartextTraffic` is off here, and cleartext is re-enabled in the network
security config only because the kernel serves its page on `127.0.0.1` and a
paired computer serves the same page over its LAN address — both plain HTTP.
The config says what that costs and what bounds it.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Only to tell the person the kernel is still running while the screen is
off; it is a foreground service with a dismissible notification, not a
background wake-up. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- From API 34 every foreground-service *type* needs its own permission as
well as the general one: naming dataSync in the manifest without asking
for it is a SecurityException at startForeground, on the one code path
that keeps a long task alive. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Only to read the pairing code a computer shows. Requested at the
moment the person asks to scan, never at launch. -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- The kernel is a child process, and a task left running is one the person
expects to finish with the screen off. Android's own doze can freeze the
whole app group instead, which is what the shell asks to be exempt from
— once, when a turn is actually running, never at launch. It is a
request the person can refuse, and refusing it only costs battery life
during a long turn. -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Every-file access, for opening a folder on this phone as a workspace.
The kernel is a plain Go binary: it opens paths with `open(2)`, and a
document URI is not a path it can open. SAF would hand back exactly
that unusable URI, so the one honest permission is the broad one —
granted by the person in system settings, which is the only place
Android offers it. Nothing here reads storage for any other purpose. -->
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<!-- Below API 33 the same folder walk needs the older pair; from 33 they are
superseded by the permission above and asking for them would be a lie. -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<!-- A phone without a touchscreen cannot drive this interface; saying so
keeps the listing honest rather than letting it install on a TV. -->
<uses-feature android:name="android.hardware.touchscreen" android:required="true" />
<!-- The camera is optional: a phone without one is still a fine client for
a kernel it already knows, and it can still pair by opening the link the
computer shows. Requiring it would take the app off those devices. -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<application
android:name=".App"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Reasonix"
android:usesCleartextTraffic="false"
tools:targetApi="35">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode|density|fontScale|smallestScreenSize"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Reasonix">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- The kernel outlives the screen: a turn that takes minutes would
otherwise be killed when the phone is pocketed. The service is
`stopWithTask="false"` so swiping the app away ends it deliberately
rather than silently, and `foregroundServiceType="dataSync"` is the
closest honest description of "an agent is working". -->
<service
android:name=".KernelService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:stopWithTask="false" />
<!-- Reading the pairing code a computer shows. Its own activity rather
than a dialog, because the camera preview needs the whole screen to
be worth pointing at anything. Not exported: only this app starts
it, and a scanner another app could open is one that could be made
to read a code on someone else's behalf. -->
<activity
android:name=".ScanActivity"
android:exported="false"
android:screenOrientation="portrait"
android:theme="@style/Theme.Reasonix.Scan" />
</application>
</manifest>
@@ -0,0 +1,59 @@
package io.reasonix.mobile.app
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
/**
* The process-wide facts this app needs.
*
* The kernel is one child process, so there is exactly one of it no matter how
* many activities exist: it is held here, lazily, and the activity and the
* service both reach the same instance. Nothing else is stored — the theme, the
* language and every preference belong to the page and live in its localStorage.
*/
class App : Application() {
/**
* The kernel, created on first use.
*
* Lazy rather than eager so that merely launching the process to answer a
* notification does not unpack a 15MB page.
*/
val kernel: Kernel by lazy { Kernel(this) }
override fun onCreate() {
super.onCreate()
createChannel()
}
/**
* A channel the person can mute without muting the app, and one they can
* find: an agent that runs for minutes on a phone has to say so somewhere
* the system will not kill, and a notification is the only place Android
* offers. `IMPORTANCE_MIN` because the notification is a receipt, not an
* interruption — the work it reports is on another machine.
*/
private fun createChannel() {
// No SDK_INT guard: minSdk is 26, which is the release NotificationChannel
// arrived in, so every device this app installs on has it.
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_KERNEL) != null) return
val channel = NotificationChannel(
CHANNEL_KERNEL,
getString(R.string.channel_kernel),
NotificationManager.IMPORTANCE_MIN,
).apply {
description = getString(R.string.channel_kernel_desc)
setShowBadge(false)
enableVibration(false)
setSound(null, null)
}
manager.createNotificationChannel(channel)
}
companion object {
const val CHANNEL_KERNEL = "reasonix-kernel"
}
}
@@ -0,0 +1,699 @@
package io.reasonix.mobile.app
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.util.Log
import android.webkit.WebView
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.ByteArrayOutputStream
import java.io.OutputStream
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* The kernel's browser, as a loopback HTTP server.
*
* The Go kernel (`internal/platform/browser/shell.go`) drives a WebView by
* POSTing JSON to a handful of paths on 127.0.0.1. This class is the other end
* of that conversation: it accepts connections on a random port, parses the
* minimal HTTP the kernel sends, and translates each path into an operation on
* the overlay WebView.
*
* The server is deliberately tiny — no connection pooling, no keep-alive, no
* chunked encoding — because the kernel is its only client and sends one
* request per connection. A `ServerSocket` accept loop on a daemon thread is
* the whole transport; each request is handled synchronously on its own thread.
*
* WebView operations must run on the UI thread. Each handler posts to the main
* looper and waits on a latch, with a timeout that bounds the worst case (a
* page that hangs the JS thread) to something the kernel's own 30-second client
* timeout will not outlast.
*
* Memory note on `/screenshot`: a full-page `WebView.draw` into an ARGB_8888
* bitmap at device resolution can reach tens of MB for a long page. The bitmap
* is downsampled by a factor of 2 in each dimension before PNG encoding, which
* keeps the peak allocation under ~10 MB for a typical 1080p viewport. The
* original is recycled immediately after the downsample.
*/
/** One thing a page asked the network for, as the WebView's request
* interceptor reports it. The interceptor is handed the method and the URL
* and nothing about the response, so a caller may show exactly this much and
* must not read it as a completed exchange. */
data class BrowserRequest(val method: String, val url: String, val kind: String)
class BrowserShellServer(
/** The overlay WebView, when one already exists. The routes that act on an
* open page — back, forward, reload, close — use this: with no page there
* is nothing to act on, and creating one to do nothing to it would start a
* renderer nobody asked for. */
private val existingView: () -> WebView?,
/** The overlay WebView, created on first use. The routes that drive a page
* use this, because the kernel can open one before the panel has ever been
* shown: the page's own panel asks for the view when it is displayed, and
* an agent's `browser_open` can arrive first. Requiring the view to exist
* already answered every such call with "browser view not ready", which
* reads as a broken engine rather than as a view that was not created yet.
* Called on the UI thread. */
private val ensureView: () -> WebView,
/** Why the page in the view is the browser's own error page rather than a
* site, as `(url, chromium error)`; null when the last load succeeded.
* `/read` asks this, because extracting `innerText` from the error page
* returns the browser's own "this page could not be loaded" notice, which
* the kernel would hand to the model as though the site had said it. */
private val loadFailure: () -> Pair<String, String>? = { null },
/** Begin a navigation: forget the load failure recorded for the page being
* left, drop its request list, and start the wait a following `/read`
* joins. Called on the UI thread, before the new address is loaded, so the
* wait is in place before any callback for the new page can fire. */
private val startNavigation: () -> Unit = {},
/** What the page currently in the view has asked the network for, oldest
* first. The WebView reports a request's method and URL to its interceptor
* but nothing about the response, so that is all a caller may show. */
private val requests: () -> List<BrowserRequest> = { emptyList() },
/** Wait for the navigation last asked for to settle — the page arrived, or
* its load failed — up to the given milliseconds. Called on the request's
* own thread, never the UI thread: the settle is signalled from the UI
* thread, so waiting on it there would deadlock. `/load` starts a
* navigation and returns before the page has been visited; without this a
* `/read` right behind it still sees the URL of the page before, and a
* failure recorded for that page was handed back as the new one's answer. */
private val awaitNavigation: (Long) -> Unit = {},
) {
companion object {
private const val TAG = "BrowserShellServer"
/** How long a UI-thread operation may take before the handler gives up.
* The kernel's client timeout is 30 s; staying under it means the kernel
* sees a clean error rather than a dropped connection. */
private const val UI_TIMEOUT_MS = 25_000L
/** The maximum text `/read` returns. Matches the kernel's `maxReadText`
* (400 << 10). The shell truncates here and says so, rather than letting
* the kernel truncate silently. */
private const val MAX_READ_TEXT = 400 * 1024
/** Screenshot downsample factor. 2 means half width and half height. */
private const val SCREENSHOT_SCALE = 2
/** The address the kernel dials. Both ends must agree on the family:
* see [start] for what goes wrong when they do not. */
private const val LOOPBACK_V4 = "127.0.0.1"
/** How long `/read` waits for a navigation it follows to settle before
* reading whatever the view is on. A page that never finishes loading
* must not hold the tool call open to the kernel's own timeout. */
private const val NAV_SETTLE_MS = 10_000L
/** How long `/read` then waits for the page's own script to answer.
* `NAV_SETTLE_MS + READ_SCRIPT_MS` has to stay under [UI_TIMEOUT_MS],
* and that under the kernel's 30-second client timeout: a read that
* waited out both budgets would outlast the caller, which would see a
* dropped connection instead of this shell's answer. 10 + 15 = 25 s. */
private const val READ_SCRIPT_MS = 15_000L
}
@Volatile
private var serverSocket: ServerSocket? = null
/** The port the server is listening on. Valid after [start] returns. */
@Volatile
var port: Int = 0
private set
/**
* The bearer credential every request must carry. Minted by the activity
* and handed to the kernel through the child's environment, so the server
* answers only the process this shell started.
*
* A phone's loopback is not a boundary: any other app may connect to
* 127.0.0.1, and the routes here run script in and read text out of
* whatever page is loaded. Binding to `getLoopbackAddress()` keeps other
* machines out but not other apps, so the credential is what makes "only
* the kernel this shell started" true.
*/
@Volatile
var token: String = ""
private set
private val mainHandler = Handler(Looper.getMainLooper())
/**
* Start listening on 127.0.0.1 with an ephemeral port. The port is available
* via [port] immediately after this returns, and the credential the kernel
* must present via [token].
*/
fun start() {
token = newToken()
// The loopback address is spelled out as IPv4, not taken from
// `InetAddress.getLoopbackAddress()`. That call is allowed to answer
// either family and Android's libcore answers IPv6 (`::1`), while the
// kernel's client is hardcoded to `http://127.0.0.1:<port>`
// (`internal/assembly/boot/browser.go`). A socket bound to `::1` does
// not answer a dial to `127.0.0.1`, so every browser call failed with
// "connection refused" while the port was bound and reported — the
// failure looked like a server that never started. Both ends have to
// name the same family, and IPv4 is the one the kernel names.
val ss = ServerSocket(0, 16, java.net.InetAddress.getByName(LOOPBACK_V4))
serverSocket = ss
port = ss.localPort
Log.i(TAG, "browser shell server listening on $LOOPBACK_V4:$port")
Thread({
while (!ss.isClosed) {
try {
val client = ss.accept()
Thread({ handleClient(client, token) }, "browser-shell-conn").apply { isDaemon = true }.start()
} catch (_: SocketException) {
// Server was closed; exit the loop.
}
}
}, "browser-shell-accept").apply { isDaemon = true }.start()
}
fun stop() {
runCatching { serverSocket?.close() }
serverSocket = null
}
/** A fresh 256-bit credential, hex-encoded, for one launch of the server. */
private fun newToken(): String {
val bytes = ByteArray(32)
java.security.SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
// ---- HTTP plumbing --------------------------------------------------------
private fun handleClient(client: Socket, expected: String) {
try {
client.soTimeout = 30_000
val input = BufferedInputStream(client.getInputStream())
val output = client.getOutputStream()
// Parse the request line.
val requestLine = readLine(input) ?: return
val parts = requestLine.split(" ", limit = 3)
if (parts.size < 2) return
val method = parts[0]
val path = parts[1].substringBefore('?')
// Read headers.
var contentLength = 0
var bearer: String? = null
while (true) {
val header = readLine(input) ?: break
if (header.isEmpty()) break
val lower = header.lowercase()
if (lower.startsWith("content-length:")) {
contentLength = header.substringAfter(':').trim().toIntOrNull() ?: 0
} else if (lower.startsWith("authorization:")) {
bearer = header.substringAfter(':').trim().removePrefix("Bearer ").trim()
}
}
// Nothing behind this server is safe to hand out on the strength of
// an address: the port is on loopback, which any other app on the
// phone can dial, and the routes run script in and read text out of
// the loaded page. The credential the kernel was started with is the
// only thing that says a caller is that kernel. Every route is
// refused without it, /health included — a probe needs no exemption.
if (expected.isEmpty() || bearer != expected) {
writeResponse(output, 401, """{"error":"unauthorized"}""")
return
}
// Read body.
val body = if (contentLength > 0) {
val buf = ByteArray(contentLength)
var read = 0
while (read < contentLength) {
val n = input.read(buf, read, contentLength - read)
if (n == -1) break
read += n
}
String(buf, 0, read, Charsets.UTF_8)
} else ""
// Route.
val (status, responseBody) = route(method, path, body)
writeResponse(output, status, responseBody)
} catch (e: Exception) {
Log.w(TAG, "connection error: ${e.message}")
} finally {
runCatching { client.close() }
}
}
private fun route(method: String, path: String, body: String): Pair<Int, String> {
return when {
method == "GET" && path == "/health" -> 200 to """{"ok":true}"""
method == "POST" && path == "/load" -> handleLoad(body)
method == "POST" && path == "/close" -> handleClose()
method == "POST" && path == "/read" -> handleRead()
method == "POST" && path == "/screenshot" -> handleScreenshot()
method == "POST" && path == "/click" -> handleClick(body)
method == "POST" && path == "/eval" -> handleEval(body)
method == "POST" && path == "/back" -> handleBack()
method == "POST" && path == "/forward" -> handleForward()
method == "POST" && path == "/reload" -> handleReload()
method == "POST" && path == "/requests" -> handleRequests()
else -> 404 to """{"error":"not found"}"""
}
}
private fun writeResponse(out: OutputStream, status: Int, body: String) {
val reason = when (status) {
200 -> "OK"
404 -> "Not Found"
500 -> "Internal Server Error"
else -> "Error"
}
val bytes = body.toByteArray(Charsets.UTF_8)
val header = "HTTP/1.1 $status $reason\r\n" +
"Content-Type: application/json\r\n" +
"Content-Length: ${bytes.size}\r\n" +
"Connection: close\r\n" +
"\r\n"
out.write(header.toByteArray(Charsets.UTF_8))
out.write(bytes)
out.flush()
}
private fun readLine(input: BufferedInputStream): String? {
val sb = StringBuilder()
while (true) {
val b = input.read()
if (b == -1) return if (sb.isEmpty()) null else sb.toString()
if (b == '\r'.code) {
val next = input.read()
if (next == '\n'.code) return sb.toString()
sb.append(b.toChar())
if (next != -1) sb.append(next.toChar())
continue
}
if (b == '\n'.code) return sb.toString()
sb.append(b.toChar())
}
}
// ---- endpoint handlers ----------------------------------------------------
/** POST /load {"url":"..."} → {"url":"...","title":"..."} */
private fun handleLoad(body: String): Pair<Int, String> {
val json = runCatching { JSONObject(body) }.getOrNull()
?: return 500 to """{"error":"invalid request body"}"""
val url = json.optString("url", "")
if (url.isEmpty()) return 500 to """{"error":"missing url"}"""
val result = AtomicReference("")
val latch = CountDownLatch(1)
mainHandler.post {
val wv = ensureView()
// The failure recorded for the page being left stops being this
// tab's, and the wait a following `/read` joins is armed before the
// new address is loaded, so no callback for the new page can fire
// ahead of it. `/load` answers before the page arrives, which is
// what left the old failure to be read back as the new page's.
startNavigation()
wv.loadUrl(url)
// The title and final URL are not available synchronously after
// loadUrl; answer with what was requested. The kernel stores this
// as the tab's URL and reads the real one on the next /read.
result.set(JSONObject().apply {
put("url", url)
put("title", "")
}.toString())
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout waiting for UI thread"}"""
}
return 200 to result.get()
}
/** POST /close {} → 200 */
private fun handleClose(): Pair<Int, String> {
val latch = CountDownLatch(1)
mainHandler.post {
val wv = existingView()
if (wv != null) {
wv.loadUrl("about:blank")
wv.clearHistory()
}
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
return 200 to "{}"
}
/**
* POST /read {} → {"title":"...","url":"...","text":"..."}
*
* Injects a script that extracts readable text from the page, preferring
* `<article>` or `<main>` and falling back to `body.innerText`. The result
* is truncated to 400 KB (matching the kernel's `maxReadText`); when
* truncated, a note is appended so the model knows the text was cut.
*/
private fun handleRead(): Pair<Int, String> {
// The page the caller just asked for has not necessarily been visited
// yet: `/load` returns as soon as it has started the navigation. Waiting
// here, off the UI thread, is what makes this read answer about that
// page instead of the one before it.
awaitNavigation(NAV_SETTLE_MS)
val result = AtomicReference("")
val latch = CountDownLatch(1)
mainHandler.post {
val wv = ensureView()
// A page that did not load is not content. Whether the view is on
// the browser's own error page is read from the view itself rather
// than from a remembered failure: a failure recorded for the page
// before a new navigation would otherwise be handed back as this
// page's answer, long after the page the caller asked for had
// loaded. The remembered failure is still what names the address
// that failed and why, since the error page's own URL does not.
if ((wv.url ?: "").startsWith("chrome-error:")) {
val (url, reason) = loadFailure() ?: ((wv.url ?: "") to "ERR_FAILED")
result.set(JSONObject().apply {
put("url", url)
put("title", "")
put("reason", reason)
put("text", "")
}.toString())
latch.countDown()
return@post
}
// The extraction script: prefer article/main for content pages,
// fall back to body. innerText preserves visual line breaks. It
// also stamps a ref on every element a person could act on, so the
// kernel can name one in an act step the way the desktop backend
// names an accessibility node. A ref already on an element is kept,
// so it stays the same across snapshots of one document; a fresh
// document starts the numbering over, and the kernel only accepts a
// ref from the snapshot it last read.
val script = """
(function(){
var seq = window.__rxRefSeq || 0;
var used = {};
var marked = document.querySelectorAll('[data-rx-ref]');
for (var i = 0; i < marked.length; i++) { used[marked[i].getAttribute('data-rx-ref')] = true; }
function roleOf(el) {
var tag = el.tagName.toLowerCase();
var role = (el.getAttribute('role') || '').toLowerCase();
if (role) return role;
if (tag === 'a') return 'link';
if (tag === 'button') return 'button';
if (tag === 'select') return 'combobox';
if (tag === 'textarea') return 'textbox';
if (tag === 'input') {
var t = (el.getAttribute('type') || 'text').toLowerCase();
if (t === 'checkbox' || t === 'radio' || t === 'submit' || t === 'button' || t === 'reset') return t;
return 'textbox';
}
return 'generic';
}
function nameOf(el) {
var n = el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.getAttribute('name') || '';
if (!n) { n = (el.innerText || el.textContent || '').trim(); }
return n.replace(/\s+/g, ' ').slice(0, 160);
}
var sel = 'a[href],button,input,select,textarea,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[contenteditable=true]';
var els = document.querySelectorAll(sel);
var elements = [];
for (var j = 0; j < els.length; j++) {
var el = els[j];
var ref = el.getAttribute('data-rx-ref');
if (!ref) {
do { seq++; ref = 'e' + seq; } while (used[ref]);
el.setAttribute('data-rx-ref', ref);
used[ref] = true;
}
var raw = (el.value != null && String(el.value) !== '') ? String(el.value).slice(0, 160) : '';
elements.push({ref: ref, role: roleOf(el), name: nameOf(el), value: raw});
}
window.__rxRefSeq = seq;
var el2 = document.querySelector('article') || document.querySelector('main') || document.body;
var text = el2 ? (el2.innerText || '') : '';
var title = document.title || '';
var url = location.href;
return JSON.stringify({title: title, url: url, text: text, elements: elements});
})()
""".trimIndent()
wv.evaluateJavascript(script) { value ->
// evaluateJavascript returns a JSON-encoded string (the result
// is double-encoded). Strip the outer quotes.
val parsed = parseJsResult(value)
if (parsed != null) {
try {
val obj = JSONObject(parsed)
var text = obj.optString("text", "")
if (text.length > MAX_READ_TEXT) {
text = text.substring(0, MAX_READ_TEXT) +
"\n\n[text truncated at 400 KB]"
}
result.set(JSONObject().apply {
put("title", obj.optString("title", ""))
put("url", obj.optString("url", ""))
put("text", text)
put("elements", obj.optJSONArray("elements") ?: JSONArray())
}.toString())
} catch (e: Exception) {
result.set(JSONObject().apply {
put("title", "")
put("url", wv.url ?: "")
put("text", parsed.take(MAX_READ_TEXT))
}.toString())
}
} else {
result.set(JSONObject().apply {
put("title", "")
put("url", wv.url ?: "")
put("text", "")
}.toString())
}
latch.countDown()
}
}
if (!latch.await(READ_SCRIPT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout waiting for page script"}"""
}
return 200 to result.get()
}
/**
* POST /screenshot {} → {"data":"<base64>","mime":"image/png"}
*
* Draws the WebView into a bitmap, downsamples by SCREENSHOT_SCALE to keep
* memory bounded, and encodes as PNG → base64. For a 1080×2400 viewport the
* full bitmap is ~10 MB; the downsampled one is ~2.5 MB. The full bitmap is
* recycled immediately after scaling.
*/
private fun handleScreenshot(): Pair<Int, String> {
val result = AtomicReference("")
val failed = AtomicBoolean(false)
val latch = CountDownLatch(1)
mainHandler.post {
val wv = ensureView()
try {
val width = wv.width.takeIf { it > 0 } ?: 1080
val height = wv.height.takeIf { it > 0 } ?: 1920
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
wv.draw(canvas)
// Downsample to bound the PNG size and the base64 string.
val scaled = Bitmap.createScaledBitmap(
bmp,
width / SCREENSHOT_SCALE,
height / SCREENSHOT_SCALE,
true,
)
if (scaled !== bmp) bmp.recycle()
val baos = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 85, baos)
scaled.recycle()
val b64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
result.set(JSONObject().apply {
put("data", b64)
put("mime", "image/png")
}.toString())
} catch (e: Exception) {
// A 200 with an error body would be read by the kernel as a
// screenshot whose data is empty, and it would build a `data:`
// URI out of nothing. The status is what tells it this failed.
result.set("""{"error":"screenshot failed: ${e.message}"}""")
failed.set(true)
}
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
if (failed.get()) return 500 to result.get()
return 200 to result.get()
}
/** POST /click {"selector":"..."} → {"ok":true|false} */
private fun handleClick(body: String): Pair<Int, String> {
val json = runCatching { JSONObject(body) }.getOrNull()
?: return 500 to """{"error":"invalid request body"}"""
val selector = json.optString("selector", "")
if (selector.isEmpty()) return 500 to """{"error":"missing selector"}"""
val result = AtomicReference("")
val latch = CountDownLatch(1)
mainHandler.post {
val wv = ensureView()
// Escape the selector for embedding in a JS string.
val escaped = selector.replace("\\", "\\\\").replace("'", "\\'")
val script = """
(function(){
try {
var el = document.querySelector('$escaped');
if (!el) return JSON.stringify({ok: false});
el.click();
return JSON.stringify({ok: true});
} catch(e) {
return JSON.stringify({ok: false});
}
})()
""".trimIndent()
wv.evaluateJavascript(script) { value ->
val parsed = parseJsResult(value)
result.set(parsed ?: """{"ok":false}""")
latch.countDown()
}
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
return 200 to result.get()
}
/** POST /eval {"script":"..."} → {"result":"..."} */
private fun handleEval(body: String): Pair<Int, String> {
val json = runCatching { JSONObject(body) }.getOrNull()
?: return 500 to """{"error":"invalid request body"}"""
val script = json.optString("script", "")
if (script.isEmpty()) return 500 to """{"error":"missing script"}"""
val result = AtomicReference("")
val latch = CountDownLatch(1)
mainHandler.post {
val wv = ensureView()
wv.evaluateJavascript(script) { value ->
// The kernel expects a plain string in "result". The value from
// evaluateJavascript is JSON-encoded; unwrap it.
val unwrapped = parseJsResult(value) ?: ""
result.set(JSONObject().apply {
put("result", unwrapped)
}.toString())
latch.countDown()
}
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout waiting for eval"}"""
}
return 200 to result.get()
}
/** POST /back → 200 */
private fun handleBack(): Pair<Int, String> {
val latch = CountDownLatch(1)
mainHandler.post {
existingView()?.let { if (it.canGoBack()) it.goBack() }
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
return 200 to "{}"
}
/** POST /forward → 200 */
private fun handleForward(): Pair<Int, String> {
val latch = CountDownLatch(1)
mainHandler.post {
existingView()?.let { if (it.canGoForward()) it.goForward() }
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
return 200 to "{}"
}
/** POST /reload → 200 */
private fun handleReload(): Pair<Int, String> {
val latch = CountDownLatch(1)
mainHandler.post {
val wv = existingView()
// A view sitting on the browser's own error page is not on the site,
// and reloading it would retry the error page itself. The address
// that failed is loaded again instead — which is the page a caller
// means by "reload" — read before `startNavigation` clears it.
val failed = if (wv != null && (wv.url ?: "").startsWith("chrome-error:"))
loadFailure()?.first else null
startNavigation()
if (wv != null) {
if (failed != null) wv.loadUrl(failed) else wv.reload()
}
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
return 200 to "{}"
}
/** POST /requests {} → {"requests":[{"method","url","kind"},…]} */
private fun handleRequests(): Pair<Int, String> {
val arr = JSONArray()
for (r in requests()) {
arr.put(JSONObject().apply {
put("method", r.method)
put("url", r.url)
put("kind", r.kind)
})
}
return 200 to JSONObject().apply { put("requests", arr) }.toString()
}
// ---- helpers ----------------------------------------------------------------
/**
* Unwrap the result of `evaluateJavascript`. The callback receives a
* JSON-encoded string: a JS string "hello" arrives as `"\"hello\""`, a
* number 42 as `"42"`, null as `"null"`. Strip the outer JSON encoding to
* get the raw value.
*/
private fun parseJsResult(value: String?): String? {
if (value == null || value == "null") return null
// The value is a JSON literal. If it is a JSON string, decode it.
return try {
if (value.startsWith("\"") && value.endsWith("\"")) {
JSONObject().apply { }.let {
// Use JSONObject's string decoding: wrap in an object.
JSONObject("""{"v":$value}""").optString("v", "")
}
} else {
value
}
} catch (_: Exception) {
value
}
}
}
@@ -0,0 +1,119 @@
package io.reasonix.mobile.app
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.os.Build
/**
* The phone's DNS servers, as the kernel needs them.
*
* The kernel is a CGO-free Go binary, and Go's own source explains why that
* matters:
*
* ```
* // DNS requests don't work on Android, so prefer the cgo resolver.
* // Issue #10714.
* case "android":
* ```
*
* A normal Android program resolves names through `getaddrinfo`, which reaches
* the `netd` daemon and gets the servers of the network that is actually up.
* A static binary has no cgo resolver, so Go reads `/etc/resolv.conf` instead —
* and Android has no such file. Go then falls back to `127.0.0.1:53`, where
* nothing listens, and every lookup fails: the setup screen reports
* "连接失败" for every provider, including the official ones, on a phone whose
* browser reaches them fine.
*
* So they are read here and handed to the kernel through `REASONIX_DNS`. This is
* the same source the system itself uses, so a VPN, a private DNS hostname, or a
* network that changed since boot is all reflected without this code knowing
* anything about them.
*/
object Dns {
/**
* The active network's servers, comma separated, or "" when there is none.
*
* Read from the *active* network rather than from every link, because a phone
* commonly has several at once — Wi-Fi, mobile data, a VPN — and the servers
* of a network that is not carrying traffic are the wrong ones to ask.
*/
fun servers(context: Context): String {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return ""
val properties: LinkProperties = runCatching { manager.activeNetwork }.getOrNull()
?.let { network -> runCatching { manager.getLinkProperties(network) }.getOrNull() }
?: return ""
return properties.dnsServers
.mapNotNull { address -> address.hostAddress }
.filter { it.isNotBlank() }
.filter { routable(it) }
.sortedBy { preference(it) }
.joinToString(",")
}
/**
* Whether an address can actually carry a query.
*
* A link-local address (fe80::/10) without its zone cannot: the packet has
* no interface to leave by, so it is dropped, nothing answers, and the
* lookup hangs until the kernel's own timeout expires — which is reported as
* a timeout rather than as a failure to resolve, and so reads like the
* network is slow instead of like the address is unusable.
*
* Android reports the zone as a `%wlan0` suffix on `hostAddress`, and the
* address is only usable with it kept. Nothing here trims it: an earlier
* version called `substringBefore('%')`, which turned a usable link-local
* server into an unroutable one.
*/
private fun routable(host: String): Boolean =
!(host.startsWith("fe80:", ignoreCase = true) && !host.contains('%'))
/**
* IPv4 first, then IPv6.
*
* A phone commonly reports both, and its IPv6 entry is often a router's
* link-local address — reachable on one network and not the next. The
* kernel tries the servers in the order it is given them, and stops at the
* first that answers; putting the address family that shares the network's
* own numbering first means its timeout is spent on a server that works.
*/
private fun preference(host: String): Int = if (host.contains(':')) 1 else 0
/**
* The private-DNS setting, for the one case the address list cannot answer.
*
* Since Android 9 a phone may use DNS-over-TLS, and then `dnsServers` holds a
* name rather than an address — a name this kernel cannot resolve *because*
* resolving it is the thing that is broken. There is no way for the shell to
* do that lookup for it, so this reports the situation instead of handing
* over an unusable value: the caller says so in the log, and the person can
* turn private DNS off or set it to automatic.
*/
fun looksLikeDot(servers: String): Boolean =
servers.isNotBlank() && servers.split(',').any { part ->
val host = part.trim()
host.isNotEmpty() && host.none { it == ':' } && !host.all { it.isDigit() || it == '.' }
}
/** Whether the system requires a VPN or private DNS, which changes nothing
* here but is worth saying in a bug report. */
fun describe(context: Context): String {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return "no connectivity service"
val network = runCatching { manager.activeNetwork }.getOrNull() ?: return "no active network"
val properties = runCatching { manager.getLinkProperties(network) }.getOrNull()
?: return "no link properties"
// The DHCP server arrived in API 30, not 23: asked for below that it is a
// NoSuchMethodError on a device this app supports (minSdk 26), and the
// answer is only ever read out of this string by a person debugging.
val dhcp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
properties.dhcpServerAddress?.hostAddress
} else {
null
}
return "iface=${properties.interfaceName} dns=${properties.dnsServers.joinToString(",") { it.hostAddress ?: "?" }}" +
(dhcp?.let { " dhcp=$it" } ?: "")
}
}
@@ -0,0 +1,87 @@
package io.reasonix.mobile.app
import android.content.Context
import android.net.Uri
import android.webkit.CookieManager
import android.widget.Toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.FileNotFoundException
import java.net.HttpURLConnection
import java.net.URL
/**
* Putting a file the page produced onto the phone's disk.
*
* A WebView has no downloads shelf and cannot write to the system's document
* picker itself, so the two halves are joined here: the page asks, the picker
* answers with a `content://` the app has been granted, and this copies the
* bytes across.
*
* The request carries the WebView's own cookie, read out of the jar the page
* authenticated in. That is the only reason this is not a plain `openStream`:
* the kernel's download route is behind the session the page established, and a
* shell that fetched without it would get a 403 and no explanation.
*/
object Downloader {
suspend fun run(
context: Context,
url: String,
userAgent: String,
mime: String,
name: String,
target: Uri,
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 60_000
requestMethod = "GET"
if (userAgent.isNotBlank()) setRequestProperty("User-Agent", userAgent)
// The download route answers with the same session cookie the
// page is using; without it the kernel refuses the request.
cookieFor(url)?.let { setRequestProperty("Cookie", it) }
}
try {
val code = connection.responseCode
if (code !in 200..299) {
throw IllegalStateException(
context.getString(R.string.download_failed_status, code),
)
}
val resolver = context.contentResolver
val output = resolver.openOutputStream(target, "w")
?: throw FileNotFoundException(target.toString())
output.use { out ->
connection.inputStream.use { input -> input.copyTo(out) }
}
// `copyTo` answers with a byte count; what the caller wants to
// know is that there were no bytes missing, not how many.
Unit
} finally {
connection.disconnect()
}
}
}
/**
* The cookie the page authenticated with, if any.
*
* `CookieManager` is the WebView's own store, so this asks the exact jar the
* request will be checked against rather than a copy this shell kept.
*/
private fun cookieFor(url: String): String? =
runCatching { CookieManager.getInstance().getCookie(url) }.getOrNull()?.takeIf { it.isNotBlank() }
/** A short receipt for a tap that otherwise does nothing visible. */
fun report(context: Context, name: String, result: Result<Unit>) {
val message = result.fold(
onSuccess = { context.getString(R.string.download_saved, name) },
onFailure = { failure ->
context.getString(R.string.download_failed, failure.message ?: "")
},
)
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
}
@@ -0,0 +1,741 @@
package io.reasonix.mobile.app
import android.content.Context
import android.content.res.AssetManager
import org.json.JSONArray
import org.json.JSONObject
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.SecureRandom
import java.util.Locale
/**
* The Reasonix kernel, as this app runs it: one child process serving loopback,
* plus the two things it needs on disk to start — the page it serves and a
* credential for this launch.
*
* The shape is the desktop host's (`cmd/reasonix-studio-host`), narrowed to what
* a phone does not have. That host binds a socket, mints a credential, speaks a
* handshake down a pipe and holds a lease; here the credential goes through
* `--token-file` and the bound address through `--port-file`, because a phone's
* app process has no pipe to hand a child and no reason to care which port was
* free. Everything after that — the kernel's own gate, its auth middleware, its
* CSRF check — is untouched, so the security of the surface is the one upstream
* tested, not one this shell invented.
*
* One instance lives in the app process. The activity asks it for a URL; the
* foreground service asks it to stay alive. Both see the same process, so there
* is nothing to synchronise beyond a lock on start/stop.
*/
class Kernel(private val context: Context) {
/** Where everything the kernel owns lives. Nothing here is backed up. */
private val home = File(context.filesDir, "home")
/**
* The folder the agent works in, which is not the folder the kernel lives in.
*
* These used to be the same directory, and that is what made the workbench
* unreadable: the kernel's home holds `config.toml`, `projects/`, `cache/`,
* `stats/` and the rest of its own bookkeeping, and a workspace pointed at
* it shows all of that as if the person had put it there. Reported as "why
* does the default workspace have so many files, the desktop version does
* not generate these" — the desktop does generate them, in a different
* directory, because its workspace is whatever folder it was opened on.
*
* Split apart, the workbench shows what the agent produced and nothing else.
* `write_roots` follows the workspace, so the file tools stay confined to
* the folder the person actually works in rather than to the kernel's own
* home — which is the stricter answer, and the one the desktop already has.
*/
private val workspaceDir = File(context.filesDir, "workspace")
/**
* The directory the kernel calls `REASONIX_HOME`, for the one caller that
* has to put a file where the kernel can read it — a private key the person
* picked from their Documents folder.
*/
fun homeDir(): File = home
/** The folder the agent works in. */
fun workspaceDir(): File = workspaceDir
/** The built page, unpacked once per version. */
private val pageDir = File(context.filesDir, "page")
private val tokenFile = File(home, "remote/launch.token")
private val portFile = File(home, "remote/serve.port")
private val pidFile = File(home, "remote/serve.pid")
private val lock = Any()
@Volatile
private var process: Process? = null
/** The port the shell's browser HTTP server listens on, set by the
* activity before `start()` is called. Zero means no server is up, and
* the kernel leaves its browser tools suspended (the correct degradation
* on a phone with no Chromium). */
@Volatile
var browserShellPort: Int = 0
/** The bearer credential for that server, set beside the port. Sent on
* every browser call so the server can refuse anything that did not come
* from the kernel this shell started — loopback on a phone is reachable
* by every other app. Empty when there is no server. */
@Volatile
var browserShellToken: String = ""
/** What the WebView should load, once the kernel has bound a port. */
@Volatile
var origin: String? = null
private set
/**
* The launch credential, as the URL fragment the page trades for a cookie.
* Kept in memory only: the file on disk belongs to the kernel's own
* bookkeeping, and a phone's app data is not a place to leave a second copy.
*/
@Volatile
private var token: String? = null
/**
* The URL the WebView loads first. It carries the credential in the
* fragment, which the page exchanges for an HttpOnly cookie before it makes
* any request — the same path a shared `reasonix web` link takes, so the
* token never enters a request line, a history entry or a log.
*/
fun entryUrl(): String? {
val origin = origin ?: return null
val token = token ?: return null
return "$origin/#token=$token"
}
/** The plain origin, for reloads once the cookie is set. */
fun plainUrl(): String? = origin
fun isRunning(): Boolean = process?.isAlive == true
/**
* Bring the kernel up, or answer the URL of the one already up.
*
* Starting is idempotent by intent: the activity and the service both call
* this, and whichever arrives second joins the running kernel instead of
* racing it for the port.
*/
suspend fun start(): Result<String> = withContext(Dispatchers.IO) {
synchronized(lock) {
origin?.let { return@withContext Result.success(it) }
runCatching {
val binary = kernelBinary()
publishPage()
prepareHome()
val launchToken = mintToken()
token = launchToken
val child = launch(binary, launchToken)
// The port is the kernel's answer, not this shell's guess. It
// writes the file after binding, so waiting on the file is
// waiting on the bind.
val bound = awaitPort(child)
val url = "http://$bound"
// A kernel that bound a port and then died would look healthy
// here and broken in the WebView; the two facts are checked
// together, and only a kernel that is answering is adopted.
awaitServing(child, url)
origin = url
process = child
url
}.onFailure { origin = null; process = null }
}
}
/**
* Stop the kernel.
*
* The graceful signal first: the kernel drains its listeners and closes the
* runtimes it opened, which is what lets a later launch adopt the sessions
* rather than find them locked. `destroyForcibly` is the second attempt, and
* only after a wait — a phone that is being shut down should not keep a
* half-killed agent.
*/
fun stop() {
synchronized(lock) {
val p = process ?: return
process = null
origin = null
token = null
runCatching {
p.destroy()
if (!p.waitFor(3, java.util.concurrent.TimeUnit.SECONDS)) {
p.destroyForcibly()
}
}
portFile.delete()
pidFile.delete()
}
}
// ---- the two inputs that ship inside the APK -------------------------
/**
* The kernel binary.
*
* It is packaged as a native library, so Android has already extracted it to
* a directory it allows execution from and given it the executable bit —
* which is the one thing a file under app storage does not get for free.
*/
private fun kernelBinary(): File {
val dir = File(context.applicationInfo.nativeLibraryDir)
val binary = File(dir, "libreasonix.so")
if (!binary.exists()) {
throw IllegalStateException(
"内核未随 APK 安装:${binary.path}\n这是一次构建问题,请重新安装完整 APK。",
)
}
return binary
}
/**
* Write the bundled page where the kernel can serve it.
*
* It goes to app storage rather than being served out of the APK because the
* kernel's `--page` is a directory on a real filesystem; there is no
* archive-aware variant, and adding one would be a change to the kernel for
* this shell's convenience alone.
*
* Unpacking is versioned: the marker holds the app's version name, so an
* update replaces the page and a relaunch does not. A partially written
* directory — the app killed mid-copy — is caught by the same marker never
* having been written, so the next launch rewrites it.
*/
private fun publishPage() {
val marker = File(pageDir, ".unpacked")
val stamp = "${BuildConfig.VERSION_NAME}:${BuildConfig.VERSION_CODE}"
if (marker.isFile && marker.readText().trim() == stamp) return
pageDir.deleteRecursively()
pageDir.mkdirs()
copyAssetTree(context.assets, "page", pageDir)
if (!File(pageDir, "index.html").isFile) {
throw IllegalStateException("随 APK 分发的页面不完整:缺少 index.html")
}
marker.writeText(stamp)
}
/**
* A directory tree out of assets.
*
* `AssetManager.list` on a file answers with an empty array, so a "file" is
* told from a directory by trying to open it — which is the only signal the
* API offers and the reason this is not a one-liner.
*/
private fun copyAssetTree(assets: AssetManager, from: String, to: File) {
val children = assets.list(from).orEmpty()
if (children.isEmpty()) {
// A leaf: open it as a stream. A directory that happens to be empty
// is indistinguishable and harmless to skip.
runCatching {
assets.open(from).use { input ->
FileOutputStream(to).use { output -> input.copyTo(output) }
}
}
return
}
to.mkdirs()
for (child in children) {
copyAssetTree(assets, "$from/$child", File(to, child))
}
}
/**
* Prepare the kernel's home.
*
* `REASONIX_HOME` is what every path in the kernel hangs off — config,
* sessions, the remote state directory its token file lands in — so pointing
* it at app storage is what keeps a phone's Reasonix separate from anything
* else and away from global storage entirely.
*/
private fun prepareHome() {
File(home, "remote").mkdirs()
// The workspace directory not existing yet is what marks the first run
// after this build split it from the kernel's home. That is the one
// moment a migration is due, and doing it here means it happens before
// the kernel is asked for anything.
val firstSplit = !workspaceDir.exists()
workspaceDir.mkdirs()
if (firstSplit) {
adoptSessionsFromHome()
}
// Every launch, not only the first. The rewrite is cheap and
// idempotent, and an entry that survived the first pass would otherwise
// survive forever: `firstSplit` is spent the moment the directory
// exists, so a build that cleaned the list once and missed leaves the
// home on the sidebar for good. Running it unconditionally costs one
// small read and makes the whole thing self-correcting.
forgetHomeFromWorkspaceList()
}
/**
* Drop the kernel's own home from the sidebar's remembered projects.
*
* The list is written whenever a pane opens, and until this build the pane
* opened on the home itself — so an upgraded install carries a "project"
* that is really the kernel's own directory. Leaving it there would put the
* person back where they started: one tap on it and the workbench is full
* of `config.toml`, `cache/` and `projects/` again, which is the complaint
* this whole change answers. Only that one path is removed; every folder
* someone added on purpose stays.
*
* Parsed rather than pattern-matched, because the file's two shapes — a bare
* array of paths and an object with `paths` and `launch` — both have to keep
* whatever else they hold.
*/
private fun forgetHomeFromWorkspaceList() {
val file = File(home, "serve-workspaces.json")
if (!file.isFile) return
val text = runCatching { file.readText() }.getOrNull() ?: return
val wrong = runCatching { homeNames() }.getOrNull() ?: return
val rewritten = runCatching { withoutHome(text, wrong) }.getOrNull() ?: return
if (!rewritten.dropped) return
runCatching { file.writeText(rewritten.text) }
.onSuccess { Log.i(TAG, "dropped the kernel home from the project list") }
.onFailure { Log.w(TAG, "could not rewrite the project list: ${it.message}") }
}
/**
* Every name of the kernel's home on this device — not just the one this
* process happens to write.
*
* Android shows one directory under two names: `/data/data/<pkg>` and
* `/data/user/0/<pkg>` are two mounts of the same tree, the same inode
* either way, and which name a path carries depends on how it was written
* rather than on where it points. The list is written by the kernel, whose
* working directory this shell set from a path it built itself; a lookup
* that compared against only one spelling would silently keep the entry
* this exists to remove. Both names are compared, so either spelling is
* recognised.
*/
private fun homeNames(): Set<String> {
val path = home.absolutePath
val names = mutableSetOf(path)
val user = "/data/user/0/"
val data = "/data/data/"
when {
path.startsWith(user) -> names.add(data + path.removePrefix(user))
path.startsWith(data) -> names.add(user + path.removePrefix(data))
}
return names
}
/** What a rewrite produced, and whether it actually removed anything. */
private class Rewrite(val text: String, val dropped: Boolean)
/**
* The list without the home, in whichever shape it arrived, and whether
* anything was actually dropped from it.
*
* The flag matters because `JSONObject` re-serialises: it escapes every
* `/` and does not preserve key order, so a round trip never reproduces
* the original bytes. Deciding to write on "the text differs" alone would
* therefore rewrite the kernel's file on every single launch, log a drop
* that never happened, and race the kernel for a file it also writes.
*/
private fun withoutHome(text: String, wrong: Set<String>): Rewrite {
if (text.trimStart().startsWith("[")) {
val paths = JSONArray(text)
val kept = JSONArray()
var dropped = false
for (i in 0 until paths.length()) {
val path = paths.optString(i)
if (path in wrong) {
dropped = true
continue
}
kept.put(path)
}
return if (dropped) Rewrite(kept.toString(), true) else Rewrite(text, false)
}
val list = JSONObject(text)
val kept = JSONArray()
var dropped = false
val paths = list.optJSONArray("paths")
if (paths != null) {
for (i in 0 until paths.length()) {
val path = paths.optString(i)
if (path in wrong) {
dropped = true
continue
}
kept.put(path)
}
if (dropped) list.put("paths", kept)
}
if (list.optString("launch") in wrong) {
list.remove("launch")
dropped = true
}
return if (dropped) Rewrite(list.toString(), true) else Rewrite(text, false)
}
/**
* Move the conversations that were filed under the kernel's own home.
*
* The transcript store is keyed by the workspace root — one directory per
* project, under `<state>/projects/<slug>/sessions` — and until this build
* the root was the home itself. Splitting the two therefore moves every
* existing conversation to a slug nothing looks under, which reads as "my
* history is gone" rather than as a reorganisation. So the old directory is
* renamed onto the new slug once.
*
* Renaming the project directory rather than just `sessions/` carries the
* rest of it too: `memory/` is filed the same way, and a project's memory
* lost while its transcripts were kept would be a stranger failure than
* either alone.
*
* The slug is the absolute path with its separators flattened, which is
* what `config.WorkspaceSlug` does on the kernel's side. Only the home's own
* slug is touched: a slug for some other folder is left exactly where it is,
* because those are projects someone added on purpose.
*/
private fun adoptSessionsFromHome() {
val projects = File(home, "projects")
if (!projects.isDirectory) return
val from = projects.resolve(slugOf(home.absolutePath))
if (!from.isDirectory) return
val to = projects.resolve(slugOf(workspaceDir.absolutePath))
// A target already there means a previous run half-finished, or the
// person opened this folder before. Either way its contents win:
// merging two transcript directories is not a thing this can do safely,
// and the old one is still on disk to be looked at.
if (to.exists()) {
Log.i(TAG, "${to.name} already exists; leaving ${from.name} in place")
return
}
if (!from.renameTo(to)) {
Log.w(TAG, "could not move ${from.name} to ${to.name}; history stays under the old slug")
} else {
Log.i(TAG, "moved conversations from ${from.name} to ${to.name}")
}
}
/** The kernel's `config.WorkspaceSlug`, for the two paths that matter here. */
private fun slugOf(path: String): String = path.replace('/', '-').replace('\\', '-').replace(':', '-')
/**
* The launch credential.
*
* Minted here rather than read from configuration: a credential a person can
* set is one anything that can read their files can present. 32 bytes, hex,
* the same shape the desktop host mints.
*/
private fun mintToken(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
val token = bytes.joinToString("") { "%02x".format(it) }
tokenFile.parentFile?.mkdirs()
tokenFile.writeText("$token\n")
// 0600 by intent, and the app's own private directory means it is
// already unreachable to every other app on the device.
runCatching {
tokenFile.setReadable(false, false)
tokenFile.setReadable(true, true)
tokenFile.setWritable(false, false)
tokenFile.setWritable(true, true)
}
return token
}
/**
* Start the child.
*
* `--addr 127.0.0.1:0` lets the kernel pick a free port; `--port-file`
* makes it announce which. `--auth token` is the mode a phone needs: the
* page authenticates, and a state-changing request without the credential
* is refused. `--no-open` is belt and braces — a serve never opens a
* browser, and a phone has no browser to open.
*/
private fun launch(binary: File, token: String): Process {
portFile.delete()
pidFile.delete()
// The folder the agent works in, resolved before the command line is
// built so it can be named on it. See the note under the environment
// block for why naming it is not the same as setting the directory.
val workspace = launchWorkspaceDir()
val command = listOf(
binary.absolutePath,
"serve",
"--addr", "127.0.0.1:0",
"--auth", "token",
"--token-file", tokenFile.absolutePath,
"--port-file", portFile.absolutePath,
"--pid-file", pidFile.absolutePath,
"--no-open",
"-page", pageDir.absolutePath,
// The project root, said outright. Without this the kernel derives
// it from its working directory, and on this phone the two did not
// agree: the sidebar named the shared folder, `write_roots` confined
// the file tools to the app's own directory, and every write into the
// folder the person was looking at came back
// `workspace.write_outside_scope`. A flag is a fact the kernel was
// told rather than one it inferred from a mount it sees differently.
"--dir", workspace.absolutePath,
)
val builder = ProcessBuilder(command)
builder.environment().apply {
put("REASONIX_HOME", home.absolutePath)
put("HOME", home.absolutePath)
// The kernel's own language, for its tool failures, approval
// prompts and error sentences. Without this it detects from the
// POSIX locale variables, and Android sets none of them for an
// app: the kernel then answers in English while the page in front
// of the person is Chinese, and every sentence added for a Chinese
// reader was dead text. The tag, not the bare language, so a
// Traditional Chinese device resolves to zh-TW rather than zh.
// An explicit `language` in the kernel's config.toml still wins:
// DetectLanguage applies the configured value over this.
val languageTag = Locale.getDefault().toLanguageTag()
if (languageTag.isNotBlank() && languageTag != "und") {
put("REASONIX_LANG", languageTag)
}
// Android has no /tmp. Everything that wants a scratch directory —
// the shell tool, tool output spooling, sqlite journals — reads this.
put("TMPDIR", context.cacheDir.absolutePath)
// The phone's own DNS servers. A CGO-free Go binary cannot read them
// for itself — Android keeps them in `netd`, and Go looks for a
// `/etc/resolv.conf` that does not exist — so without this the kernel
// resolves nothing and every provider probe reports "连接失败" on a
// device whose browser reaches the same address fine.
val dns = Dns.servers(context)
if (dns.isNotBlank()) {
put("REASONIX_DNS", dns)
Log.i(TAG, "kernel DNS: $dns (${Dns.describe(context)})")
if (Dns.looksLikeDot(dns)) {
// Private DNS (DNS-over-TLS) is on, and it hands back a
// hostname this kernel cannot resolve by definition. Saying
// so here is what turns "连不上" into something actionable.
Log.w(TAG, "kernel DNS looks like a private-DNS hostname; turn private DNS off or set it to automatic")
}
} else {
Log.w(TAG, "kernel DNS: none reported by the system")
}
// The shell's browser server, when one is listening. The kernel
// reads this to bind its browser tools to the shell's WebView
// instead of looking for a Chromium binary that does not exist on
// a phone. When absent (zero), the variable is not set and the
// kernel keeps the browser tools suspended — the correct
// degradation for a phone with no engine to drive.
val shellPort = browserShellPort
if (shellPort > 0) {
put("REASONIX_SHELL_BROWSER_PORT", shellPort.toString())
// The credential the server checks. Set only alongside the
// port: a token with nothing listening would be a variable the
// kernel carries for no reason.
val shellToken = browserShellToken
if (shellToken.isNotBlank()) {
put("REASONIX_SHELL_BROWSER_TOKEN", shellToken)
}
}
}
// The agent's folder, not the kernel's. `REASONIX_HOME` above is still
// where the config, the sessions and the transcripts live; this is what
// the workspace root resolves to, because the kernel reads the working
// directory for it.
//
// The folder the person last opened, when it is still usable — the same
// answer the desktop host gives (`launchWorkspace`), and the reason a
// phone session used to work in the app's own `files/workspace` while
// the sidebar said it was in the shared folder: the launch entry in
// `serve-workspaces.json` was written and then ignored, and `write_roots`
// is derived from the working directory, so the file tools were confined
// to the app's private folder and every write into the shared one was
// refused. `workspaceDir` is still the answer for a first run and for a
// folder since deleted, but the choice is never silent — see
// [launchWorkspaceDir]. `workspace` was resolved above so it could also
// be named on the command line.
builder.directory(workspace)
// `PWD` is what the kernel's own `os.Getwd` prefers: Go uses it whenever
// it names the same directory as `.`, which is exactly this case, and it
// is the only way to make the kernel resolve its root to the string this
// shell chose. Without it `os.Getwd` falls back to the kernel's view of
// the mount, and on Android `/storage/emulated/0/...` and the path the
// FUSE layer reports for it need not be spelled the same way. The kernel
// derives `write_roots` from that root, so the two spellings have to be
// the same one.
builder.environment().put("PWD", workspace.absolutePath)
builder.redirectErrorStream(true)
val process = builder.start()
// The kernel's own log is the only place a startup failure says why, and
// a phone has no terminal to read. It is drained on a thread and kept
// ring-buffered so the About screen can show the last of it.
Thread({ drain(process) }, "kernel-log").apply { isDaemon = true }.start()
return process
}
/**
* The folder the person last opened, or null when there is not one.
*
* `serve-workspaces.json` is the kernel's own record of the projects the
* sidebar lists, and its `launch` entry is which of them a session starts
* in. Both shapes the file has are read — a bare array of paths, and an
* object with `paths` and `launch` — because both are written in the wild
* and a reader that only knew one would silently fall back.
*
* The entry has to be one of the listed paths, which is the same rule the
* kernel applies when it reads this file (`workspacelist.Read` blanks a
* `launch` that is not in `paths`). It matters more here than there: the
* working directory decides `write_roots`, so whatever this answers is
* where the agent's file tools may write — and on a phone that permission
* reaches all of shared storage. A file with a `launch` outside its own
* list is one this shell did not write, and it gets the listed answer.
*
* Null for anything unusable: no file, unreadable JSON, a launch that is
* not among the listed folders, an entry that has since been deleted, or a
* path that is now a file. Every one of those means the same thing to the
* caller — start where this shell always started.
*/
private fun rememberedLaunchDir(): File? {
val file = File(home, "serve-workspaces.json")
if (!file.isFile) return null
val text = runCatching { file.readText() }.getOrNull() ?: return null
val launch = runCatching {
// A bare array is the file's other shape — the one older kernels
// wrote, and therefore the one a person upgrading is most likely to
// have. Without this branch `JSONObject(text)` throws on it and the
// whole function falls back, which is the same mismatch (the
// sidebar naming one folder, the session working in another) this
// exists to fix. `withoutHome` below reads both shapes the same way.
if (text.trimStart().startsWith("[")) {
val arr = JSONArray(text)
if (arr.length() == 0) return@runCatching null
arr.optString(0)
} else {
val json = JSONObject(text)
val paths = json.optJSONArray("paths")
?.let { arr -> (0 until arr.length()).mapNotNull { arr.optString(it).takeIf(String::isNotBlank) } }
.orEmpty()
if (paths.isEmpty()) return@runCatching null
json.optString("launch").takeIf { it.isNotBlank() && it in paths } ?: paths.first()
}
}.getOrNull()?.takeIf { it.isNotBlank() } ?: return null
val dir = runCatching { File(launch) }.getOrNull() ?: return null
if (!dir.isDirectory) {
// A launch entry this process cannot stat is exactly the failure the
// caller exists to avoid, and falling back silently is what made it
// take three releases to notice: the session opened in the app's own
// folder while the sidebar named the shared one. A phone has no
// terminal, so the log is the only place the reason can be said —
// and `exists`/`readable` tell apart "deleted" from "not permitted",
// which need different answers from the person.
Log.w(
TAG,
"launch entry $launch is not a usable directory " +
"(exists=${dir.exists()}, readable=${dir.canRead()})",
)
return null
}
return dir
}
/**
* The folder the kernel should start in.
*
* Either the launch entry — the folder the person last opened, which is
* also the one the sidebar shows — or the app's own folder when there is
* none. Whichever it is, it is logged, because the two disagreeing is the
* bug this exists to fix and a silent choice is what let it survive.
*/
private fun launchWorkspaceDir(): File {
val remembered = rememberedLaunchDir()
if (remembered != null) {
Log.i(TAG, "kernel workspace: ${remembered.absolutePath} (the remembered launch project)")
return remembered
}
Log.i(TAG, "kernel workspace: ${workspaceDir.absolutePath} (no remembered launch project)")
return workspaceDir
}
private val logLines = ArrayDeque<String>()
private fun drain(process: Process) {
runCatching {
process.inputStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
Log.i(TAG, line)
synchronized(logLines) {
logLines.addLast(line)
while (logLines.size > LOG_KEEP) logLines.removeFirst()
}
}
}
}
}
/** The kernel's tail, newest last, for a bug report or the About screen. */
fun recentLog(): String = synchronized(logLines) { logLines.joinToString("\n") }
private fun awaitPort(process: Process): String {
val deadline = System.currentTimeMillis() + START_TIMEOUT_MS
while (System.currentTimeMillis() < deadline) {
if (!process.isAlive) {
throw IllegalStateException("内核启动即退出。\n${recentLog().takeLast(2000)}")
}
val text = if (portFile.isFile) runCatching { portFile.readText().trim() }.getOrNull() else null
// The file is written by the kernel on the way to serving, and the
// kernel rewrites it with the real port when it was given :0.
if (!text.isNullOrBlank() && text.contains(":")) return text
Thread.sleep(POLL_MS)
}
throw IllegalStateException("内核在 ${START_TIMEOUT_MS / 1000}s 内没有报告监听地址。\n${recentLog().takeLast(2000)}")
}
/**
* Wait until the bound port answers.
*
* A port that is open but not yet serving is the normal first second of a
* kernel's life; a WebView pointed at it would show a connection error and
* cache the failure, so the wait belongs here rather than in the page.
*/
private fun awaitServing(process: Process, origin: String) {
val deadline = System.currentTimeMillis() + START_TIMEOUT_MS
while (System.currentTimeMillis() < deadline) {
if (!process.isAlive) {
throw IllegalStateException("内核在开始服务前退出。\n${recentLog().takeLast(2000)}")
}
if (probe("$origin/")) return
Thread.sleep(POLL_MS)
}
throw IllegalStateException("内核已监听 $origin,但页面没有响应。\n${recentLog().takeLast(2000)}")
}
private fun probe(url: String): Boolean = runCatching {
(URL(url).openConnection() as HttpURLConnection).run {
connectTimeout = 1000
readTimeout = 1000
requestMethod = "GET"
try {
responseCode in 200..399
} finally {
disconnect()
}
}
}.getOrDefault(false)
private companion object {
const val TAG = "ReasonixKernel"
const val LOG_KEEP = 400
const val POLL_MS = 120L
const val START_TIMEOUT_MS = 30_000L
}
}
@@ -0,0 +1,139 @@
package io.reasonix.mobile.app
import android.Manifest
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* Keeps the kernel alive while the app is not on screen.
*
* The reason this exists at all: an agent turn that takes minutes is a normal
* turn, and Android reclaims a backgrounded app's child process within seconds
* of the screen going off. Without the service, pocketing the phone would kill
* a running task — the one failure mode that makes a phone a bad client for
* something that works on its own.
*
* A foreground service needs a notification the person can see, which is also
* the honest thing to show: the kernel is running and what it is doing lives on
* another machine.
*/
class KernelService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Promoted before `start()`: a service that starts work first and
// notifies after has a window in which the system may kill it, and the
// work it would have been protecting is exactly what is being started.
startForegroundNotification()
val kernel = (application as App).kernel
scope.launch {
kernel.start().onFailure { failure ->
// A kernel that will not start is a fact the person needs, and a
// notification that only ever said "running" would be a lie.
notify(SERVICE_NOTICE, getString(R.string.notify_failed_title), failure.message.orEmpty())
stopSelf()
}
}
// Sticky, so a kernel killed under memory pressure comes back when there
// is room — which is what a person who left a task running expects.
return START_STICKY
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private fun startForegroundNotification() {
val notification = buildNotification(
getString(R.string.notify_running_title),
getString(R.string.notify_running_body),
)
// `dataSync` matches the manifest's declaration. On 34+ the type is
// mandatory and a mismatch throws, so it is named in both places.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceCompat.startForeground(
this,
SERVICE_NOTICE,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
} else {
startForeground(SERVICE_NOTICE, notification)
}
}
private fun buildNotification(title: String, body: String): Notification {
val open = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, App.CHANNEL_KERNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setContentIntent(open)
// The notification is a receipt, not an alarm; swiping it away
// leaves the kernel running, which is what the person meant by
// swiping it.
.setOngoing(false)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.build()
}
private fun notify(id: Int, title: String, body: String) {
// The notification permission is granted at runtime from Android 13 and
// the person can refuse it. Without this check the call throws
// SecurityException and takes the service down with it — the opposite of
// what a notification that could not be shown should do. Below 13 the
// permission does not exist and posting always succeeds.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
return
}
runCatching {
NotificationManagerCompat.from(this).notify(id, buildNotification(title, body))
}
}
companion object {
private const val SERVICE_NOTICE = 1
/** Idempotent: called on every start of the activity, and by the boot
* path, and safe to repeat. */
fun start(context: Context) {
ContextCompat.startForegroundService(context, Intent(context, KernelService::class.java))
}
fun stop(context: Context) {
context.stopService(Intent(context, KernelService::class.java))
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,744 @@
package io.reasonix.mobile.app
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentValues
import android.content.Context
import android.content.res.Configuration
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.webkit.JavascriptInterface
import android.webkit.MimeTypeMap
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* What the page can ask the phone for.
*
* This is the whole surface, and it is deliberately tiny. Android's
* `@JavascriptInterface` is a bridge into the app's process from a WebView whose
* content is a web page: every method here is reachable by anything that can run
* script in that page, so each one is written as a verb with no address
* parameter it does not need, and none of them can name a path outside the
* kernel's own home.
*
* It is deliberately *not* the Electron preload bridge. That one exposes window
* management, a filesystem walker, an editor launcher and a browser view host —
* all of which are meaningless on a phone, and the two that were not (open a URL,
* save a file) already have a way to happen here: `WebViewClient` routes external
* links to the browser, and the page's own download reaches the WebView's
* `DownloadListener`.
*
* The one thing a phone must add is `importIntoHome`: the kernel runs on this
* device and can only read paths under its own home, so a private key the person
* has in their Documents folder has to be copied across. Nothing else the page
* wants is out of its reach.
*/
class NativeBridge(
private val context: Context,
private val kernel: Kernel,
private val onOpen: (String) -> Unit,
) {
/**
* Whether the document calling may use this bridge.
*
* `addJavascriptInterface` makes the object reachable from whatever page the
* WebView holds, so a link the model produced — or a cleartext page on the
* LAN — would otherwise get `importIntoHome`, `pickFolder`, `hostCall` and
* the clipboard. The activity answers by comparing the main frame's origin
* against the kernel's and the one paired computer the person confirmed.
*
* Anything that reaches the device checks this first. The five methods that
* do not — version, systemTheme, safeArea, setLayerOpen, setRunning — report
* back what the page already knows about its own window and grant nothing.
*/
var trusted: () -> Boolean = { false }
/** The version the page reads to decide whether this bridge is the one it
* knows. Bumped when a method's contract changes, not when one is added. */
@JavascriptInterface
fun version(): String = BRIDGE_VERSION
/**
* Copy a file the person picks into the kernel's home, answering with its
* path there — or "" when they dismissed the picker.
*
* `kind` names the directory inside the home, because that is a fact about
* the kernel's layout and not about the file: an SSH private key belongs in
* `.ssh`, and a dotenv the kernel reads for passphrases belongs at the root
* of the home where `config.Load` looks for it.
*
* The path that comes back is *inside the home*, never the document
* provider's URI. That is the point: the kernel is a child process with no
* access to the provider, so a `content://` would be a path it cannot open.
*/
@JavascriptInterface
fun importIntoHome(kind: String, name: String): String {
if (!trusted()) return ""
val dir = when (kind) {
"identity" -> File(kernel.homeDir(), ".ssh")
"env" -> kernel.homeDir()
else -> return ""
}
val safeName = sanitise(name)
if (safeName.isEmpty()) return ""
val picked = pickFile() ?: return ""
return try {
dir.mkdirs()
val target = File(dir, safeName)
context.contentResolver.openInputStream(picked)?.use { input ->
target.outputStream().use { output -> input.copyTo(output) }
} ?: return ""
// A private key with group or world read is one OpenSSH refuses to
// use, and the refusal reads as "bad permissions" from a long way
// away. Setting it here is what makes the file usable at all.
if (kind == "identity") {
runCatching {
target.setReadable(false, false)
target.setReadable(true, true)
target.setWritable(false, false)
target.setWritable(true, true)
}
}
target.absolutePath
} catch (_: Exception) {
// A copy that failed leaves nothing behind; the caller sees "" and
// the person can try again.
""
}
}
/**
* Open a URL in the device's browser.
*
* The page asks for this when a link points somewhere outside the kernel —
* a provider's console, a documentation page — and loading it in place would
* strand the person in a window with no address bar and no way back.
*/
@JavascriptInterface
fun openExternal(url: String) {
if (!trusted()) return
// Only what a browser can open. A `file://` or `content://` here would
// be the page reaching into the device through a method named for
// leaving it.
if (!url.startsWith("http://") && !url.startsWith("https://")) return
onOpen(url)
}
/** Put text on the clipboard. A WebView's own `navigator.clipboard` write is
* gated behind a permission Android grants only to a focused document, so
* the one thing the page actually offers to copy — a token, a command, a
* path — has no other way out.
*
* Answers whether the write landed. A platform that refuses it raises
* rather than answering, and an exception must not cross
* `@JavascriptInterface` as a silent success — the page would say 已复制
* over an empty clipboard. Caught here, the answer is the one the caller
* can act on: false, and the button says 复制失败 instead. */
@JavascriptInterface
fun copyText(text: String): Boolean = if (!trusted()) false else runCatching {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("reasonix", text))
}.isSuccess
/**
* Write text where the person picks, and answer with the path it went to.
*
* The page's own route to a file — a blob URL on an `<a download>` — reaches
* nothing here: a WebView has no downloads shelf, and its `DownloadListener`
* fires only for a real navigation, not for a blob the page just built. So
* the text crosses the bridge and the shell opens the system's document
* picker, which is also the only way to reach a folder the person can find
* again afterwards.
*
* "" is a dismissal, the same answer `importIntoHome` gives, and the page
* reads the two the same way.
*/
/**
* Put a copy of a file where a phone's own file manager will show it.
*
* `saveText` asks the person where to put it, which is the right question
* on a desktop and the wrong one here: the kernel's workspace is
* app-private storage, so the file someone wants is in a place no file
* manager can open, and a picker that starts at "recent" is another place
* to lose it. This answers the actual question — "where is it" — by
* putting it in Downloads, in a folder named after the app, under a name
* that is the file's own.
*
* The answer is the path relative to shared storage, so the page can say
* where the file went rather than only that it worked. "" means the write
* failed or the platform refused it.
*
* MediaStore on Android 10 and later, a plain file before that. The two
* are not interchangeable: from 10 the app may not write another app's
* files directly, and MediaStore is the only way in; before it there is no
* MediaStore collection for downloads and the public directory is writable
* with the storage permission this app does not ask for, so that branch is
* best-effort and says so by answering "".
*/
@JavascriptInterface
fun exportToDownloads(name: String, content: String): String {
if (!trusted()) return ""
val safeName = sanitise(name).ifEmpty { "reasonix.txt" }
return exportFile(safeName, content).orEmpty()
}
@JavascriptInterface
fun saveText(name: String, content: String): String {
if (!trusted()) return ""
val safeName = sanitise(name).ifEmpty { "reasonix.txt" }
return saveFile(safeName, content) ?: ""
}
/**
* Hand text to whatever the device shares with — a chat app, the mail
* client, a note app.
*
* The share sheet rather than a second save dialog: "where is that file"
* is the question this answers, and on a phone a document someone wants to
* keep or send usually goes through one of those apps rather than a folder.
*
* Shared as text with the filename as its subject, which is what every
* target understands. A file attachment would need a `FileProvider` and a
* cache copy for the same words to arrive.
*/
@JavascriptInterface
fun shareText(name: String, content: String): Boolean {
if (!trusted()) return false
val safeName = sanitise(name).ifEmpty { "reasonix.txt" }
val posted = java.util.concurrent.CountDownLatch(1)
var ok = false
main.post {
ok = runCatching { askForShare(safeName, content) }.getOrDefault(false)
posted.countDown()
}
runCatching { posted.await(SHARE_TIMEOUT_SECONDS, TimeUnit.SECONDS) }
return ok
}
/**
* The device's own light/dark answer.
*
* The page resolves its theme from `localStorage` first and only asks the
* system when nothing is saved, so this is what makes a fresh install match
* the phone instead of defaulting to light.
*/
@JavascriptInterface
fun systemTheme(): String {
val night = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return if (night == Configuration.UI_MODE_NIGHT_YES) "dark" else "light"
}
/**
* The window's real safe-area insets, in CSS pixels, as
* `top,right,bottom,left`.
*
* A WebView does not implement `env(safe-area-inset-*)`: those are an iOS
* Safari feature, and on Android every one of them resolves to `0`. So a
* page that pads itself with them — which `mobile.css` does — is un-padded
* on exactly the platform this shell exists for, and the first line of the
* interface sits under the status bar.
*
* The values have to come from here for a second reason too: from Android 15
* the window is edge-to-edge whether the app asks for it or not, so the bars
* really are drawn over the page and there is no window-manager switch that
* could move the content back down.
*
* Answered as a string because that is what crosses the bridge; the page
* sets it as a CSS custom property and `mobile.css` reads it.
*/
@JavascriptInterface
fun safeArea(): String {
val insets = onInsets?.invoke() ?: return "0,0,0,0"
return "${insets.left},${insets.top},${insets.right},${insets.bottom}"
}
/**
* Read the pairing code a computer is showing.
*
* A phone cannot type what a computer renders as a QR code — it is a
* one-time code inside a URL, and the whole reason it is a picture is that
* nobody reads one aloud. So the page asks for the camera and gets the text
* back, which is the same direction the computer's own dialog describes.
*
* The answer is the code's own text, not a parsed result: what a pairing
* fragment means is the page's to decide, and it already knows how — the
* `#pair=` handling ships with the interface.
*/
@JavascriptInterface
fun scanPairingCode(): String = if (!trusted()) "" else scanWithCamera()
/**
* Load a URL in this window instead of handing it to the browser.
*
* The page asks for this once, right after a scan: the code it read points at
* the computer's own Studio, and the whole point of scanning is to end up
* looking at that computer. Going out to a browser would leave the app on one
* screen and the paired machine on another, with the back gesture an exit
* rather than a return.
*
* Only http and https are accepted. The page is this repository's own build
* and is trusted, but a scanner reads whatever is held in front of it, so the
* one value that arrives here from outside is checked: a `file://` or
* `javascript:` that happened to be on a printed code must not become a
* navigation.
*/
@JavascriptInterface
fun openInApp(url: String) {
if (!trusted()) return
val trimmed = url.trim()
if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) return
main.post { onOpenInApp?.invoke(trimmed) }
}
/** How the activity loads a URL in the window it owns. Set by the activity. */
var onOpenInApp: ((String) -> Unit)? = null
// ---- the native browser view host bridge ----
//
// The page's `host.ts` looks for `window.reasonixHost` with Promise-based
// methods (showBrowserView, hideBrowserView, etc.). `@JavascriptInterface`
// is synchronous, so the round trip is: JS generates an id, calls
// `hostCall(id, method, args)`, Kotlin does the work asynchronously, then
// pushes the result back via `evaluateJavascript` calling
// `window.__reasonixHostReply(id, json)`. The injected script (in
// MainActivity) wires each Promise to that reply channel.
/** How the activity handles an async host call. Set by the activity. */
var onHostCall: ((id: String, method: String, args: String) -> Unit)? = null
/**
* Entry point for the page's `window.reasonixHost` Promise methods.
*
* `id` is a unique call identifier the JS side generated; `method` is the
* method name (e.g. "showBrowserView"); `args` is the JSON-encoded argument
* array. The activity handles the call asynchronously and replies through
* `evaluateJavascript` with `window.__reasonixHostReply(id, resultJson)`.
*/
@JavascriptInterface
fun hostCall(id: String, method: String, args: String) {
if (!trusted()) return
onHostCall?.invoke(id, method, args)
}
/**
* Whether the page has a panel up that the back gesture should close first.
*
* Written by the page, read by the activity's back handler. The page owns the
* answer — it is the only thing that knows what is open, and a second
* implementation of that question in the shell would be one that drifts — so
* the page reports the fact and the shell keeps the action.
*
* Volatile because the two ends are on different threads: the page sets it
* from the WebView's JavaScript thread, the back gesture reads it on the main
* one, and a stale read here is a panel that does not close.
*/
@Volatile
var layerOpen: Boolean = false
private set
@JavascriptInterface
fun setLayerOpen(open: Boolean) {
layerOpen = open
}
// ---- the picker, which is an Activity result in a synchronous world ----
private val pending = AtomicReference<Uri?>(null)
private val latch = AtomicReference<CountDownLatch?>(null)
/** The code the camera read, held between the activity's answer and the
* wait below — the same round trip the file picker takes. */
private val scanned = AtomicReference<String?>(null)
private val scanLatch = AtomicReference<CountDownLatch?>(null)
/** The main thread, because starting an Activity result must happen there. */
private val main = Handler(Looper.getMainLooper())
/**
* How to ask for a file. Set by the activity, which owns the Activity Result
* API; it answers whether the ask was dispatched, not whether the person
* picked anything — that arrives through `deliver`.
*/
var askForFile: () -> Boolean = { false }
/**
* How the activity reports the window's insets. Set by the activity, which is
* the only thing that can see them, and asked on every call rather than
* cached — a rotation, a fold, or the keyboard opening all change them, and a
* stale answer here is a stripe of interface under the status bar.
*/
var onInsets: (() -> Insets?)? = null
/**
* How to open the camera. Set by the activity, which owns the Activity
* Result API; like `askForFile` it answers whether the ask was dispatched,
* and the code itself arrives through `deliverScan`.
*/
var askForScan: () -> Boolean = { false }
/**
* How to put a file where the person picks. Set by the activity, which owns
* the Activity Result API; it answers whether the ask was dispatched, and
* the chosen place arrives through `deliverSave`.
*/
var askForSave: (name: String, content: String) -> Boolean = { _, _ -> false }
/**
* How to open the share sheet. Set by the activity, which is the only thing
* that can start an Intent; it answers whether anything on the device took
* the text.
*/
var askForShare: (name: String, content: String) -> Boolean = { _, _ -> false }
/** The place the save dialog answered with, held between the two halves. */
private val saveTarget = AtomicReference<Uri?>(null)
private val saveLatch = AtomicReference<CountDownLatch?>(null)
/**
* Ask where to put a file and write it there.
*
* The same latch round trip as `pickFile`, and for the same reason:
* `@JavascriptInterface` cannot suspend, so the WebView's thread waits while
* the picker is open — a modal panel the person is looking at.
*/
private fun saveFile(name: String, content: String): String? {
val gate = CountDownLatch(1)
saveLatch.set(gate)
saveTarget.set(null)
val posted = CountDownLatch(1)
var dispatched = false
main.post {
dispatched = runCatching { askForSave(name, content) }.getOrDefault(false)
posted.countDown()
}
val ready = runCatching { posted.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
if (!ready || !dispatched) {
saveLatch.set(null)
return null
}
return try {
if (!gate.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) null
else saveTarget.get()?.toString()
} catch (_: InterruptedException) {
null
} finally {
saveLatch.set(null)
}
}
/** Called by the activity once the bytes are written. A null uri is a dismissal. */
fun deliverSave(uri: Uri?) {
saveTarget.set(uri)
saveLatch.get()?.countDown()
}
/**
* Read the pairing code a computer is showing, and return it.
*
* The same shape as `pickFile` and for the same reason: a
* `@JavascriptInterface` method is called on the WebView's thread and must
* return a value, so the camera is opened on the main thread and this one
* waits. The block lasts as long as the person is aiming, which is why the
* timeout is generous — a scan that gave up after a few seconds would be
* one nobody could complete.
*
* An empty string is "no code", which is what a refusal, a cancellation and
* a timeout all are. The caller acts on the text, so a distinct error would
* only be a fourth way to say the same thing.
*/
private fun scanWithCamera(): String {
val gate = CountDownLatch(1)
scanLatch.set(gate)
scanned.set(null)
val posted = CountDownLatch(1)
var dispatched = false
main.post {
dispatched = runCatching { askForScan() }.getOrDefault(false)
posted.countDown()
}
val ready = runCatching { posted.await(SCAN_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
if (!ready || !dispatched) {
scanLatch.set(null)
return ""
}
return try {
if (!gate.await(SCAN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) "" else scanned.get().orEmpty()
} catch (_: InterruptedException) {
""
} finally {
scanLatch.set(null)
}
}
/** Called by the activity when the scanner answers. Null is a dismissal. */
fun deliverScan(text: String?) {
scanned.set(text)
scanLatch.get()?.countDown()
}
/**
* Ask for one file and wait for the answer.
*
* `@JavascriptInterface` methods cannot suspend: the WebView calls them on
* its own thread and expects a value back. So the ask is posted to the main
* thread and this one waits on a latch. The cost is a blocked JS thread for
* as long as the picker is open — which is a modal panel the person is
* looking at, with nothing else in the page running.
*
* A timeout is set rather than waiting forever: a picker that never answers
* (the activity destroyed underneath it) would otherwise pin a WebView
* thread until the process died.
*/
private fun pickFile(): Uri? {
val gate = CountDownLatch(1)
latch.set(gate)
pending.set(null)
val posted = CountDownLatch(1)
var dispatched = false
main.post {
dispatched = runCatching { askForFile() }.getOrDefault(false)
posted.countDown()
}
val ready = runCatching { posted.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
if (!ready || !dispatched) {
latch.set(null)
return null
}
return try {
if (!gate.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) null else pending.get()
} catch (_: InterruptedException) {
null
} finally {
latch.set(null)
}
}
/** Called by the activity when the picker answers. A null uri is a dismissal. */
fun deliver(uri: Uri?) {
pending.set(uri)
latch.get()?.countDown()
}
/** The folder under Downloads that this app's exports land in. */
private fun exportFolder(): String = "Reasonix"
/** The MIME type this name declares, or null when the platform does not know
* it.
*
* Where the type is written, the name and the type agree, and that agreement
* is the whole fix: `.md` is answered `text/markdown` here, so MediaStore
* has nothing to correct and the export stays `报告.md` instead of being
* renamed `报告.md.txt` by the `text/plain` it used to be given. Where the
* type is unknown the field is left off, which leaves MediaStore to derive
* the same `application/octet-stream` it would have been told — the name
* still stands on its own, which is all this is for. */
private fun mimeFor(name: String): String? {
val ext = name.substringAfterLast('.', "").lowercase()
if (ext.isEmpty() || ext == name.lowercase()) return null
return runCatching { MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) }.getOrNull()
}
private fun exportFile(name: String, content: String): String? = runCatching {
val bytes = content.toByteArray(Charsets.UTF_8)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = context.contentResolver
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, name)
// The type is derived here rather than declared, and it is
// written only when the name really has an extension that maps
// to one.
//
// It used to be `text/plain` for everything, and MediaStore
// answers a name whose extension disagrees with the declared
// type by appending its own: exporting `报告.md` landed in
// Downloads as `报告.md.txt`, and any source file came out with
// the wrong second extension. Deriving the type from the name
// instead keeps the two in agreement — which is what stops the
// rename — and a name the platform has no type for is left off
// entirely rather than declared as `application/octet-stream`,
// so nothing is corrected there either.
mimeFor(name)?.let { put(MediaStore.Downloads.MIME_TYPE, it) }
put(
MediaStore.Downloads.RELATIVE_PATH,
Environment.DIRECTORY_DOWNLOADS + "/" + exportFolder(),
)
// Hidden until the bytes are all there, so a file manager that
// reads the folder mid-write cannot show half a document.
put(MediaStore.Downloads.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
?: return@runCatching null
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: return@runCatching null
values.clear()
values.put(MediaStore.Downloads.IS_PENDING, 0)
resolver.update(uri, values, null, null)
"${Environment.DIRECTORY_DOWNLOADS}/${exportFolder()}/$name"
} else {
val dir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
exportFolder(),
)
if (!dir.exists() && !dir.mkdirs()) return@runCatching null
val target = File(dir, name)
target.writeBytes(bytes)
target.absolutePath
}
}.getOrNull()
private fun sanitise(name: String): String {
// A name from the page is used as a filename, so it is reduced to one:
// no separators, no traversal, and nothing a filesystem treats as
// special. The default is the name the field it came from suggests.
val trimmed = name.trim().substringAfterLast('/').substringAfterLast('\\')
if (trimmed.isEmpty() || trimmed == "." || trimmed == "..") return ""
return trimmed.filter { it.isLetterOrDigit() || it in "._-" }.take(64)
}
/**
* Whether a turn is running, so the screen can be kept awake for one.
*
* The page owns the answer — it is the only thing that knows if the kernel
* is working — and the shell owns the flag, because a window flag is not
* something a page can set. Nothing is asked at launch: a window that is
* merely open is one the person may be reading, and holding the screen on
* for that is holding it on for nothing.
*/
@JavascriptInterface
fun setRunning(running: Boolean) {
onRunning?.invoke(running)
}
/** How the activity applies that flag. Set by the activity. */
var onRunning: ((Boolean) -> Unit)? = null
/**
* Whether the app may read every file, which is what lets the kernel open a
* folder on this phone as a workspace.
*
* Asked rather than assumed: the permission is granted by hand in system
* settings, so the page has to be able to say "not yet" rather than fail on
* the first `stat`. Answered `true` below API 30, where ordinary read
* permission is enough and the concept does not exist.
*/
@JavascriptInterface
fun hasAllFilesAccess(): Boolean = filesAccess?.invoke() ?: true
var filesAccess: (() -> Boolean)? = null
/** Open the system page where that permission is granted. The only place
* Android offers it — there is no dialog for this one. */
@JavascriptInterface
fun requestAllFilesAccess() {
if (!trusted()) return
askForFilesAccess?.invoke()
}
var askForFilesAccess: (() -> Unit)? = null
/**
* Whether this app is exempt from the system's battery optimisation.
*
* A turn runs in a child process under a foreground service. Under doze the
* whole app group is frozen anyway, so the turn stops making progress while
* the screen is off — which is the state the person left it in on purpose.
* Unrestricted is the answer, and it is a question Android answers.
*/
@JavascriptInterface
fun batteryUnrestricted(): Boolean = if (!trusted()) false else (batteryFree?.invoke() ?: true)
var batteryFree: (() -> Boolean)? = null
/** Ask for that exemption. Unlike the storage one, this has a real dialog. */
@JavascriptInterface
fun requestBatteryUnrestricted() {
if (!trusted()) return
askForBattery?.invoke()
}
var askForBattery: (() -> Unit)? = null
/**
* Ask for a folder on this phone and answer with its real filesystem path.
*
* "" is a dismissal, the same answer `importIntoHome` gives — and, unlike
* that one, an empty string is a normal outcome here rather than a failure.
* The `startIn` hint is a path the kernel knows; the picker may ignore it,
* which is the picker's business.
*
* The round trip is the same latch as `pickFile`, for the same reason: a
* document-tree picker is another activity, and this call cannot suspend.
*/
@JavascriptInterface
fun pickFolder(startIn: String): String {
if (!trusted()) return ""
val gate = CountDownLatch(1)
folderLatch.set(gate)
folderPending.set(null)
val posted = CountDownLatch(1)
var dispatched = false
main.post {
dispatched = runCatching { askForFolder(startIn) }.getOrDefault(false)
posted.countDown()
}
val ready = runCatching { posted.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
if (!ready || !dispatched) {
folderLatch.set(null)
return ""
}
return try {
if (!gate.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) "" else folderPending.get().orEmpty()
} catch (_: InterruptedException) {
""
} finally {
folderLatch.set(null)
}
}
var askForFolder: (String) -> Boolean = { false }
private val folderPending = AtomicReference<String?>(null)
private val folderLatch = AtomicReference<CountDownLatch?>(null)
/** Called by the activity with the folder's real path, or null on dismissal. */
fun deliverFolder(path: String?) {
folderPending.set(path ?: "")
folderLatch.get()?.countDown()
}
companion object {
const val BRIDGE_VERSION = "4"
private const val PICK_TIMEOUT_SECONDS = 5L * 60
/** Long, because this is however long someone takes to aim. A shorter
* one would turn "still lining it up" into a failed scan. */
private const val SCAN_TIMEOUT_SECONDS = 5L * 60
/** Only as long as the sheet takes to appear: the answer is "something
* took it", which is known the moment the Intent resolves. */
private const val SHARE_TIMEOUT_SECONDS = 15L
}
}
/**
* The window's insets, in CSS pixels.
*
* A named type rather than four numbers because the page reads them as one
* thing: half an inset is a layout that is wrong in one direction, and a caller
* that passed the wrong pair would be indistinguishable from a device with no
* cutout at all.
*/
data class Insets(val left: Int, val top: Int, val right: Int, val bottom: Int)
@@ -0,0 +1,66 @@
package io.reasonix.mobile.app
import androidx.camera.core.ImageProxy
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
/**
* Reading a QR code out of one camera frame.
*
* ZXing core and nothing else: it is pure Java, so it runs identically on a
* phone with Google Play Services and on one whose HarmonyOS compatibility
* layer has none. A decoder that leaned on ML Kit would work on the machine it
* was written on and fail on the one that needs it.
*
* The frame arrives as YUV_420_888, whose Y plane is already the greyscale
* bitmap ZXing wants — no conversion, no colour maths, just the bytes. That is
* the whole reason a phone can decode 30 frames a second without warming up.
*/
object Qr {
private val reader = MultiFormatReader().apply {
setHints(
mapOf(
// Only the form the computer actually renders. Leaving the
// reader open to every 1D barcode makes each frame slower for
// a case that cannot occur, and a slower frame is a code that
// has already left the viewfinder.
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
DecodeHintType.TRY_HARDER to true,
),
)
}
/** The code's text, or null when this frame holds none. */
fun decode(image: ImageProxy): String? {
val plane = image.planes.firstOrNull() ?: return null
val buffer = plane.buffer
val data = ByteArray(buffer.remaining())
buffer.get(data)
// `rowStride` is padded to a hardware alignment and is usually wider
// than the picture. Passing the width as the row length is what tells
// ZXing where each row actually ends; getting it wrong shears the image
// and every code reads as garbage.
val source = PlanarYUVLuminanceSource(
data,
plane.rowStride,
image.height,
0,
0,
image.width,
image.height,
false,
)
return runCatching {
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text
}.getOrNull().also {
// The reader keeps per-frame state between calls; resetting is what
// stops a partial match in one frame from corrupting the next.
reader.reset()
}
}
}
@@ -0,0 +1,146 @@
package io.reasonix.mobile.app
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.Gravity
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import java.util.concurrent.Executors
/**
* The camera, held open just long enough to read one pairing code.
*
* A phone that wants to drive a computer cannot type the code that computer
* shows: it is a base64 fragment inside a URL, and the whole point of showing it
* as a QR code is that nobody reads one aloud. So this is the way in — the same
* gesture the computer's own dialog asks for, from the other side.
*
* The decode is ZXing rather than ML Kit on purpose. ML Kit needs Google Play
* Services, and this app installs on phones whose HarmonyOS compatibility layer
* has none: a GMS-backed scanner would work on a developer's phone and fail on
* the device that needs it. ZXing core is pure Java, so the scanner behaves the
* same on both.
*
* It returns the first code it reads and finishes. A pairing code is
* single-use and short-lived, so "keep scanning after a hit" is not a feature —
* it is a way to spend a code that a later frame would have discarded.
*/
class ScanActivity : AppCompatActivity() {
private lateinit var preview: PreviewView
private lateinit var hint: TextView
private val executor = Executors.newSingleThreadExecutor()
/** Set once a code is found, so the frames in flight behind it do not each
* try to finish the activity. */
@Volatile
private var settled = false
private val requestCamera = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) start() else refuse()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val root = FrameLayout(this)
preview = PreviewView(this).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
// The frames are analysed, not composed: filling the view and
// cropping is right here and would be wrong for a camera app.
scaleType = PreviewView.ScaleType.FILL_CENTER
}
hint = TextView(this).apply {
text = getString(R.string.scan_hint)
setTextColor(0xFFFFFFFF.toInt())
textSize = 14f
gravity = Gravity.CENTER
val pad = (16 * resources.displayMetrics.density).toInt()
setPadding(pad, pad, pad, pad)
// Readable over whatever the camera is pointing at, which is the
// one background a design system cannot choose.
setBackgroundColor(0x99000000.toInt())
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.BOTTOM,
)
}
root.addView(preview)
root.addView(hint)
setContentView(root)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
) {
start()
} else {
requestCamera.launch(Manifest.permission.CAMERA)
}
}
private fun refuse() {
// No dialog of our own: the permission prompt already said no, and a
// second window repeating it is one more tap to dismiss. The caller
// gets the same answer as a cancelled scan.
setResult(RESULT_CANCELED)
finish()
}
private fun start() {
val providerFuture = ProcessCameraProvider.getInstance(this)
providerFuture.addListener({
val provider = runCatching { providerFuture.get() }.getOrNull() ?: return@addListener refuse()
val previewUseCase = Preview.Builder().build().also { it.setSurfaceProvider(preview.surfaceProvider) }
val analysis = ImageAnalysis.Builder()
// Only the newest frame matters: a stale one describes where the
// camera was, and a code that has already left the viewfinder
// must not win over one that is in it now.
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { it.setAnalyzer(executor, ::analyse) }
runCatching {
provider.unbindAll()
provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, previewUseCase, analysis)
}.onFailure { refuse() }
}, ContextCompat.getMainExecutor(this))
}
private fun analyse(image: ImageProxy) {
try {
if (settled) return
val text = Qr.decode(image) ?: return
if (settled) return
settled = true
setResult(RESULT_OK, Intent().putExtra(EXTRA_TEXT, text))
// Finishing from the analysis thread is the whole reason `settled`
// exists; the activity teardown has to happen on the main one.
runOnUiThread { finish() }
} finally {
image.close()
}
}
override fun onDestroy() {
super.onDestroy()
executor.shutdown()
}
companion object {
const val EXTRA_TEXT = "text"
}
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The mark, drawn rather than shipped as a bitmap: one R in the app's own ink
on the palette's ground, which scales to every launcher's mask without a
density bucket per size. The hole in the R is the glyph's own counter, cut
with evenOdd rather than painted over. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#F4F1EA"
android:fillType="evenOdd"
android:pathData="M39,30 L39,78 L47,78 L47,60 L54.5,60 L65,78 L74.5,78 L62.5,58 C68.6,55.8 72.6,50.6 72.6,43.6 C72.6,35.4 66.8,30 57.6,30 Z M47,37 L56.4,37 C61.2,37 64.4,39.4 64.4,43.8 C64.4,48.2 61.2,50.9 56.4,50.9 L47,50.9 Z" />
<path
android:fillColor="#A6680C"
android:pathData="M62,66 L74,66 L74,78 L62,78 Z" />
</vector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The status-bar glyph, not the launcher icon: Android renders a notification
icon as a mask, so anything that is not a solid silhouette on transparency
comes out as a grey blob. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FFFFFF"
android:pathData="M3,4h18v2H3zM3,9h18v2H3zM3,14h12v2H3zM3,19h12v2H3zM17.5,13.2l1.6,2.6 1.6,-2.6h2.3l-2.8,4.3V21h-2.2v-3.5L15.2,13.2z" />
</vector>
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The screen between the tap and the page.
It exists for a reason that is not cosmetic: the kernel has to be unpacked and
started before there is a page to show, and on a cold start that is about a
second. A blank window for a second reads as a broken app; a line of text that
says what is happening does not.
The progress bar is indeterminate and driven by nothing: the WebView's own
progress is mirrored onto it while the page loads, and before that it simply
turns, which is the honest description of "we do not know how long".
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/page"
android:orientation="vertical"
android:gravity="center"
android:paddingStart="32dp"
android:paddingEnd="32dp">
<TextView
android:id="@+id/splash_wordmark"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textColor="#8A857B"
android:textSize="22sp"
android:letterSpacing="0.08"
android:fontFamily="sans-serif-medium" />
<ProgressBar
android:id="@+id/splash_bar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="132dp"
android:layout_height="wrap_content"
android:layout_marginTop="26dp"
android:indeterminate="true" />
<TextView
android:id="@+id/splash_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:gravity="center"
android:text="@string/splash_connecting"
android:textColor="#8A857B"
android:textSize="13sp"
android:lineSpacingMultiplier="1.5" />
<Button
android:id="@+id/splash_retry"
style="?android:attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:minHeight="44dp"
android:text="@string/splash_retry"
android:textColor="#A6680C"
android:visibility="gone" />
</LinearLayout>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The adaptive launcher icon.
minSdk is 26, which is the release that introduced adaptive icons, so this
is the only launcher asset the app needs: there is no device running it
that would ask for a bitmap fallback. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<!-- The safe zone is the middle 66dp of 108dp; the glyph is drawn inside it
so no launcher's mask can clip it. -->
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Reasonix</string>
<string name="channel_kernel">Kernel running</string>
<string name="channel_kernel_desc">Shown while the Reasonix kernel runs in the background. Can be muted on its own in system settings.</string>
<string name="notify_running_title">Reasonix is running</string>
<string name="notify_running_body">A task is still going. Switch back to see it.</string>
<string name="notify_failed_title">Reasonix could not start</string>
<string name="err_kernel_unreachable">Could not reach the kernel. Reopen the app from your home screen.</string>
<string name="download_default_name">reasonix-download</string>
<string name="download_saved">Saved: %1$s</string>
<string name="download_failed">Save failed: %1$s</string>
<string name="download_failed_status">Save failed: the server answered %1$d</string>
<string name="splash_connecting">Starting the kernel…</string>
<string name="splash_retry">Retry</string>
<string name="scan_title">Scan the code on the computer</string>
<string name="scan_hint">Point at the QR code in the computer\'s device panel to connect to it.</string>
<string name="scan_button">Scan to connect</string>
<string name="scan_denied">No camera permission, so scanning is unavailable. Allow it in system settings.</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The dark palette's page colour. `DayNight` on the theme picks this up when
the system is dark, which is what keeps the frame before the WebView from
flashing light at someone using a dark phone at night. -->
<resources>
<color name="page">#0E1014</color>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The light palette's page colour, and only what the window needs before the
WebView paints. The interface's own colours are `tokens.css`'s and are the
page's business; these two exist so the first frame is the app's ground
rather than the platform default white. -->
<resources>
<color name="page">#F4F1EA</color>
<color name="boot">#07080C</color>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The launcher ground: the interface's dark page colour. It is the same in
both themes because a launcher icon is not themed by the app. -->
<resources>
<color name="ic_launcher_background">#14161C</color>
</resources>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Every string the shell itself shows. The interface's own text belongs to
the page and lives in its i18n bundle; these are only the few lines that
exist before it, or when it cannot load. -->
<resources>
<string name="app_name">Reasonix</string>
<string name="channel_kernel">内核运行中</string>
<string name="channel_kernel_desc">Reasonix 内核在后台运行时显示,可在系统设置中单独关闭。</string>
<string name="notify_running_title">Reasonix 正在运行</string>
<string name="notify_running_body">任务继续进行,切回应用即可查看。</string>
<string name="notify_failed_title">Reasonix 无法启动</string>
<string name="err_kernel_unreachable">连接内核失败。请返回桌面重新打开应用。</string>
<string name="download_default_name">reasonix-download</string>
<string name="download_saved">已保存:%1$s</string>
<string name="download_failed">保存失败:%1$s</string>
<string name="download_failed_status">保存失败:服务器返回 %1$d</string>
<string name="splash_connecting">正在启动内核…</string>
<string name="splash_retry">重试</string>
<string name="wsperm_title">需要「所有文件访问」权限</string>
<string name="wsperm_body">要打开手机上的文件夹作为工作区,需要先允许本应用访问所有文件。点「去设置」,在列表里打开 Reasonix 的开关,然后返回这里。</string>
<string name="wsperm_go">去设置</string>
<string name="wsperm_later">以后再说</string>
<string name="scan_title">扫描电脑上的二维码</string>
<string name="scan_hint">对准电脑上「设备访问」里的二维码,识别后即可连接这台电脑。</string>
<string name="scan_button">扫码连接电脑</string>
<string name="scan_denied">没有相机权限,无法扫码。请在系统设置里允许。</string>
<string name="share_title">分享文件</string>
</resources>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The shell's own furniture: a window background matching the page's light
palette before the WebView paints, and a transparent system bar so the
page's own safe-area padding is what the notch meets. The page themes
itself; this exists only so the first frame is not a white flash. -->
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Reasonix" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">@color/page</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- The page draws its own edge-to-edge layout and pads itself with the
safe-area insets from mobile.css. Laying the window out edge to edge
while leaving the bars transparent is what makes those insets real
numbers instead of zero. -->
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="p">shortEdges</item>
<item name="android:enforceStatusBarContrast" tools:targetApi="q">false</item>
<item name="android:enforceNavigationBarContrast" tools:targetApi="q">false</item>
</style>
<!-- The camera. A black ground because the preview has not drawn yet and a
light window behind a dark viewfinder is a flash in the dark; the bars
stay transparent so the preview reaches the edges, which is what makes
a code near the top of the screen scannable. -->
<style name="Theme.Reasonix.Scan" parent="Theme.Reasonix">
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Backups are off, and this file says why rather than leaving it implicit.
A Reasonix home holds provider keys, an account token and every session
transcript. Android's auto-backup would copy all of it to the person's cloud
drive and, on a device-to-device transfer, to a new phone — which is a
credential leaving the device by a route nobody chose. `allowBackup="false"`
in the manifest turns that off; these two stanzas are the belt to its braces,
excluding everything explicitly so that a later change to `allowBackup` cannot
quietly start copying credentials.
What a person wants to move between phones, the kernel already moves: its own
export writes a file the person hands to the system's picker.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Cleartext HTTP is permitted, and the platform has no way to permit less.
Two kinds of page load over plain HTTP: the kernel's own page on
`127.0.0.1:<port>`, and the same page served by a paired computer on whatever
address its router handed out. The port is chosen when the kernel starts and
the LAN address when a code is scanned, so neither can be written here — and a
`domain-config` matches literals and domains, never a subnet.
So the boundary is not this file. `MainActivity` decides which cleartext origin
may become the window: `shouldOverrideUrlLoading` sends every http(s) origin
that is not the kernel's or the one just paired out to the browser, and the
native bridge answers only those two. A plaintext page on the LAN therefore
cannot become the document holding the bridge, which is what the reviewer's
"deliberate path for LAN pairing" asks for.
HTTPS is untouched: nothing here downgrades a request, and `trust-anchors`
keeps the system roots for every TLS connection that is made.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
+15
View File
@@ -0,0 +1,15 @@
// The build itself lives in app/build.gradle; this file declares the plugin
// versions once so the app module can be read on its own.
//
// AGP 8.11.1 is the pairing for the Gradle 8.14 toolchain this machine has:
// AGP 8.7 and older refuse a Gradle that new, and AGP 8.13+ wants a Gradle this
// one does not have. Both numbers are pinned rather than ranged, because a
// build that silently moved either one would be a different build.
plugins {
id "com.android.application" version "8.11.1" apply false
id "org.jetbrains.kotlin.android" version "2.1.20" apply false
}
tasks.register("clean", Delete) {
delete rootProject.layout.buildDirectory
}
+15
View File
@@ -0,0 +1,15 @@
# AndroidX is the only copy of the support libraries this project uses; the
# non-transitive R class keeps the app's own resources from aliasing library
# ones, which is what makes `R.string.*` unambiguous here.
android.useAndroidX=true
android.nonTransitiveRClass=true
# The daemon is what keeps a second `gradle assembleDebug` from re-reading the
# whole SDK, and the heap is sized for the R8 pass over the WebView and the
# packaged page rather than for this handful of Kotlin files.
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.caching=true
kotlin.code.style=official
File diff suppressed because it is too large. Load diff
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
# Pinning the distribution's checksum is what makes the download verifiable: a
# mirror that served a different zip would fail here instead of being unpacked
# and run. The value is Gradle's own, from
# https://services.gradle.org/distributions/gradle-8.14.3-bin.zip.sha256
distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
#
# Build the Reasonix kernel for Android and place it where the APK packager
# expects a native library.
#
# Why a shell script and not a Gradle task: the kernel is a Go module that lives
# outside this Gradle build, with its own toolchain version pinned in go.mod. A
# Gradle task that invoked `go build` would make the Android build depend on a Go
# toolchain being configured for it, and would rebuild a 21MB binary on every
# `assembleDebug` even when nothing in the kernel changed. This runs when you
# mean it to, and the APK task only checks that the result is there.
#
# Output: app/src/main/jniLibs/arm64-v8a/libreasonix.so
# The name is not cosmetic. Android extracts files under `jniLibs/` to
# `nativeLibraryDir` and permits execution *there*; a binary shipped in
# `assets/` would have to be copied to app storage and chmod'd, which fails on
# a device whose data partition is mounted noexec. `lib*.so` is the shape the
# packager recognises as a native library, so the kernel wears it.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
android="$here/.."
upstream="$android/.."
out="$android/app/src/main/jniLibs/arm64-v8a/libreasonix.so"
if [ ! -d "$upstream" ]; then
echo "kernel source not found: $upstream" >&2
echo "run this from a checkout: the parent of android/ must be the kernel source." >&2
exit 1
fi
# GOOS=android needs nothing else: the kernel is CGO-free, which is what makes a
# single static binary possible in the first place. `-s -w` drops the symbol and
# DWARF tables — about a third of the file — and nothing on a phone reads them.
export GOOS=android
export GOARCH=arm64
export CGO_ENABLED=0
# The app version, read from the module's own build.gradle so the two cannot
# drift. The official build stamps these through `-X` (see upstream's Makefile);
# without them the kernel reports `version=dev revision=unknown`, which a phone
# then shows next to the documentation it was built with and makes a bug report
# from that build impossible to place. There is no `.git` here, so the revision
# names the package the corpus shipped in rather than a commit hash — the honest
# answer when there is no commit to name.
app_gradle="$android/app/build.gradle"
version_field() {
sed -n "s/^def $1 *= *\([0-9]*\).*/\1/p" "$app_gradle" | head -n1
}
app_version="${REASONIX_APP_VERSION:-$(version_field versionMajor).$(version_field versionMinor).$(version_field versionPatch)}"
build_time_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
ldflags="-s -w"
ldflags="$ldflags -X main.version=v$app_version"
ldflags="$ldflags -X main.buildTimeUTC=$build_time_utc"
ldflags="$ldflags -X reasonix/internal/tools/productdocs.linkedVersion=v$app_version"
ldflags="$ldflags -X reasonix/internal/tools/productdocs.linkedRevision=apk-v$app_version"
echo "building the kernel (android/arm64, v$app_version) ..."
mkdir -p "$(dirname "$out")"
cd "$upstream"
go build -trimpath -ldflags="$ldflags" -o "$out" ./cmd/reasonix
size=$(wc -c < "$out")
echo "kernel ready: $out ($((size / 1024 / 1024)) MiB)"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
#
# One command from a clean checkout to an installable APK.
#
# The order matters and is the whole content of this script: the kernel and the
# page are inputs to packaging, not build steps inside it, so they are produced
# first and the Gradle task only assembles what is already there. Running
# `gradle assembleRelease` directly on a fresh checkout fails on purpose (see
# `requireBuildInputs` in app/build.gradle) rather than producing an APK with no
# kernel in it.
#
# Usage: ./scripts/build.sh [debug|release] (default: release)
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
android="$here/.."
variant="${1:-release}"
case "$variant" in
debug|release) ;;
*) echo "usage: $0 [debug|release]" >&2; exit 2 ;;
esac
"$here/build-kernel.sh"
"$here/sync-page.sh"
cd "$android"
# The wrapper is committed, jar and all, and it is the only thing that runs the
# build. Generating one here would tie the dependency versions to whichever
# `gradle` happened to be on PATH, which is the opposite of a pinned build.
if [ ! -f gradlew ]; then
echo "gradlew is missing: this repository commits the Gradle wrapper, so the checkout is incomplete" >&2
echo "(a release build does not use `gradle` from PATH: that would tie dependency versions to the machine)" >&2
exit 1
fi
task="assemble$([ "$variant" = release ] && echo Release || echo Debug)"
echo "packaging the APK ($variant) ..."
./gradlew "$task" --no-daemon
apk_dir="$android/app/build/outputs/apk/$variant"
echo
echo "done. Installable artifacts:"
find "$apk_dir" -name '*.apk' -maxdepth 1 -print -exec ls -lh {} \;
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
#
# Install the built APK on a connected phone and show its log.
#
# `adb` comes from the Android SDK rather than this project, because the SDK is
# already how the app is built. Point ANDROID_HOME at it, or have `adb` on PATH.
#
# Usage: ./scripts/run.sh [debug|release]
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
android="$here/.."
variant="${1:-release}"
adb="${ADB:-${ANDROID_HOME:-$HOME/AndroidSDK}/platform-tools/adb}"
if ! command -v "$adb" >/dev/null 2>&1; then
adb="adb"
fi
apk_dir="$android/app/build/outputs/apk/$variant"
apk=$(find "$apk_dir" -name '*.apk' -maxdepth 1 2>/dev/null | head -n1)
if [ -z "$apk" ]; then
echo "no $variant APK found; run ./scripts/build.sh $variant first" >&2
exit 1
fi
echo "installing $apk ..."
"$adb" install -r "$apk"
# The device count is checked before the app is started so an emulator-less
# machine gets "no devices" rather than a launch failure that reads like a bug.
if [ "$("$adb" devices | grep -c 'device$')" -eq 0 ]; then
echo "no device attached; skipping the launch." >&2
exit 0
fi
package="io.reasonix.mobile.app"
[ "$variant" = debug ] && package="io.reasonix.mobile.app.debug"
"$adb" shell monkey -p "$package" -c android.intent.category.LAUNCHER 1 >/dev/null
echo
echo "following the log (Ctrl-C to stop) ..."
"$adb" logcat -v color "ReasonixKernel:V" "AndroidRuntime:E" "*:S"
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
#
# Build the interface and copy it where the APK will carry it.
#
# The page that ships is the same build the desktop Studio serves — that is the
# whole reason the mobile layout lives in `desktop/frontend-next` rather than in
# an Android project's own web root. Nothing is edited on the way through: the
# only thing this script does that `pnpm build` does not is copy `dist/` into the
# app's assets.
#
# Output: app/src/main/assets/page/
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
android="$here/.."
frontend="$android/../desktop/frontend-next"
dest="$android/app/src/main/assets/page"
if [ ! -d "$frontend" ]; then
echo "frontend project not found: $frontend" >&2
exit 1
fi
cd "$frontend"
# pnpm is the package manager the lockfile is written for; npm would resolve a
# different tree than the one that was tested.
if ! command -v pnpm >/dev/null 2>&1; then
echo "pnpm 10 is required: npm install -g pnpm@10" >&2
exit 1
fi
if [ ! -d node_modules ]; then
echo "installing frontend dependencies ..."
pnpm install --frozen-lockfile
fi
echo "building the frontend ..."
pnpm build
rm -rf "$dest"
mkdir -p "$dest"
cp -R dist/. "$dest/"
if [ ! -f "$dest/index.html" ]; then
echo "the build produced no index.html; the frontend build probably failed." >&2
exit 1
fi
count=$(find "$dest" -type f | wc -l)
size=$(du -sk "$dest" | cut -f1)
echo "page ready: $dest ($count files, $((size / 1024)) MiB)"
# MIT requires the copyright notice and the licence text to travel with a binary
# distribution, so the APK carries it. Generated from the repository's own
# `LICENSE` rather than committed a second time: a checked-in copy is one more
# thing that drifts when the root file is updated.
root_license="$android/../LICENSE"
if [ ! -f "$root_license" ]; then
echo "LICENSE not found: $root_license" >&2
exit 1
fi
cp "$root_license" "$android/app/src/main/assets/LICENSE"
echo "licence: app/src/main/assets/LICENSE (copied from the repository's LICENSE)"
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
#
# Reproduce what `Kernel.kt` does, on this machine, against the page that is
# actually packaged into the APK.
#
# This is the closest thing to an end-to-end test that runs without a phone: the
# launch flags, the token file, the port file and the `--page` directory are the
# same ones the Kotlin shell passes, and the page is read out of
# `app/src/main/assets/page` rather than the frontend's own `dist/`. If this
# serves a working interface, the only untested part is Android itself.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
android="$here/.."
page="$android/app/src/main/assets/page"
kernel="${KERNEL_BIN:-$android/../reasonix-verify.exe}"
work="${VERIFY_DIR:-/tmp/reasonix-verify}"
rm -rf "$work"
mkdir -p "$work/home/remote"
if [ ! -f "$kernel" ]; then
echo "no runnable kernel; build one first:" >&2
echo " (cd .. && go build -o reasonix-verify.exe ./cmd/reasonix)" >&2
exit 1
fi
if [ ! -f "$page/index.html" ]; then
echo "no packaged page: $page/index.html" >&2
exit 1
fi
# 32 random bytes as hex — the same shape `Kernel.mintToken` writes.
token=$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')
# The kernel refuses a token file other users can read.
(umask 077; printf '%s\n' "$token" > "$work/home/remote/launch.token")
export REASONIX_HOME="$work/home"
export HOME="$work/home"
export TMPDIR="$work"
echo "kernel: $kernel"
echo "page: $page"
echo "token: ${token:0:8}... (${#token} chars)"
"$kernel" serve \
--addr 127.0.0.1:0 \
--auth token \
--token-file "$work/home/remote/launch.token" \
--port-file "$work/home/remote/serve.port" \
--pid-file "$work/home/remote/serve.pid" \
--no-open \
-page "$page" \
> "$work/kernel.log" 2>&1 &
echo $! > "$work/kernel.pid"
# Wait for the port file the kernel writes after binding, which is exactly the
# wait `Kernel.awaitPort` performs.
for _ in $(seq 1 150); do
if [ -s "$work/home/remote/serve.port" ]; then break; fi
if ! kill -0 "$(cat "$work/kernel.pid")" 2>/dev/null; then
echo "the kernel exited; log:" >&2
cat "$work/kernel.log" >&2
exit 1
fi
sleep 0.2
done
if [ ! -s "$work/home/remote/serve.port" ]; then
echo "the kernel reported no listening address; log:" >&2
cat "$work/kernel.log" >&2
exit 1
fi
port=$(cat "$work/home/remote/serve.port")
echo "listening on: $port"
# The two things the WebView will do: fetch the page, and exist on the origin.
probe=$(curl -s -o /dev/null -w '%{http_code}' "http://$port/")
echo "GET / -> $probe"
if [ "$probe" != "200" ]; then
echo "the page did not answer; log:" >&2
cat "$work/kernel.log" >&2
exit 1
fi
cat > "$work/entry.txt" <<EOF
origin=http://$port
entry=http://$port/#token=$token
token=$token
EOF
echo "entry written: $work/entry.txt"
echo "kernel log (first 10 lines):"
head -10 "$work/kernel.log"
+31
View File
@@ -0,0 +1,31 @@
// Reasonix Mobile — the Android shell around the Reasonix kernel.
//
// The shell is deliberately thin: it unpacks the kernel and the built page that
// ship inside the APK, starts `reasonix serve` on a loopback port, and points a
// WebView at it. Everything the person sees and does is the same page the
// desktop Studio and `reasonix web` serve, because it is literally the same
// build of `desktop/frontend-next` — which is what keeps one interface honest
// across the three surfaces, and why the page is packaged rather than fetched.
pluginManagement {
repositories {
// The official repositories only. A mirror listed here would be an
// unverified trust anchor for anything that ends up in a signed build:
// the artifact it serves is whichever copy landed there. A machine that
// needs a mirror belongs on a user-level init script, where the choice
// stays with the person building rather than travelling with the source.
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ReasonixMobile"
include(":app")
-6
View File
@@ -53,12 +53,6 @@ node perf/queue.mjs # 待送达:正文读不回来时,「改」不许
node perf/reask.mjs # 改写框:没有换行的长段落,框要长到让人读得到全文,宽屏和手机宽度各一遍
node perf/composer-reflow.mjs # Draft and skill-chip wrapping during column transitions, interface zoom and native scrollbar gutters
node perf/composer-toolbar-row.mjs # The buttons under the composer stay on one row from 900 to 400 px of conversation width, with the widest realistic labels
node perf/control-icons.mjs # Composer and browser action icons: readable geometry, alignment, running states, focus and labels
node perf/nav-selection.mjs # Navigation selection: quiet theme surfaces, icon contrast and keyboard focus in default, sky and photo packs
node perf/select-alignment.mjs # Selects: rendered text alignment and keyboard dismissal in both languages, schemes, widths and font sizes
node perf/settings-fullscreen.mjs # All settings categories fill the window with fixed gutters, single-column cards and contained controls at wide/phone widths and enlarged text
node perf/settings-page-column.mjs # Pill navigation scrolls to expanded controls without replacing editors or hiding them under the sticky index
node perf/sidebar-tools.mjs # Named footer card: saved order and visibility, hover transfer, click, keyboard focus and viewport containment
node perf/workbench-save.mjs # File toolbar sizing, Save contrast and edit/save/read states: light/dark × Chinese/English × desktop/browser × wide/phone
node perf/onboarding.mjs # First-run setup: zoom-aware card bounds, resize, scrolling, focus and completion
node perf/running.mjs # 执行过程:在跑的步骤自己在动、收起后由标题接着动,步骤行没有空栏
@@ -1,135 +0,0 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = new URL(process.env.PERF_URL ?? "http://localhost:4399/perf.html");
const browser = await chromium.launch();
const measure = (buttons) => buttons.map((button) => {
const icon = button.querySelector("svg");
if (!icon) return { missing: true };
const box = button.getBoundingClientRect();
const svg = icon.getBoundingClientRect();
const glyph = icon.getBBox();
const unit = svg.width / icon.viewBox.baseVal.width;
return {
missing: false,
glyphWidth: glyph.width * unit,
glyphHeight: glyph.height * unit,
offsetX: Math.abs(svg.x + svg.width / 2 - box.x - box.width / 2),
offsetY: Math.abs(svg.y + svg.height / 2 - box.y - box.height / 2),
fits: svg.left >= box.left && svg.right <= box.right && svg.top >= box.top && svg.bottom <= box.bottom,
accessible: !!button.getAttribute("aria-label") && icon.getAttribute("aria-hidden") === "true",
};
});
const check = (sizes, label, centered = true) => {
assert.ok(sizes.length > 0, `${label}: controls exist`);
for (const size of sizes) {
assert.ok(!size.missing && size.accessible && size.fits, `${label}: named SVG controls fit (${JSON.stringify(size)})`);
assert.ok(size.glyphWidth >= 9 && size.glyphHeight >= 9, `${label}: symbols remain legible (${JSON.stringify(size)})`);
if (centered) assert.ok(size.offsetX <= 1 && size.offsetY <= 1, `${label}: symbols are centered (${JSON.stringify(size)})`);
}
};
try {
await mkdir("perf/shots", { recursive: true });
for (const colorScheme of ["light", "dark"]) for (const width of [1300, 390]) for (const scale of [1, 1.3]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme, reducedMotion: "reduce" });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.addInitScript(() => localStorage.setItem("rx-lang", "zh"));
const url = new URL(base);
url.searchParams.set("running", "1");
await page.goto(url.href, { waitUntil: "networkidle" });
await page.locator('.compose [data-action="session.stop"]').waitFor();
await page.evaluate((value) => document.documentElement.style.setProperty("--fs-scale", String(value)), scale);
const actions = page.locator(".compose .go");
const send = actions.locator('[data-action="session.send"]');
const stop = actions.locator('[data-action="session.stop"]');
const label = `${colorScheme}/${width}/${scale}`;
assert.equal(await send.isDisabled(), true, `${label}: an empty running draft cannot send`);
const disabledBackground = await send.evaluate((button) => getComputedStyle(button).backgroundColor);
check(await actions.locator("button").evaluateAll(measure), label);
await actions.screenshot({ path: `perf/shots/control-icons-${colorScheme}-${width}-${scale}.png` });
await page.locator(".compose textarea").fill("继续检查");
assert.equal(await send.isEnabled(), true, `${label}: a running draft can steer`);
await send.evaluate(async (button) => {
getComputedStyle(button).backgroundColor;
await Promise.all(button.getAnimations().map((animation) => animation.finished.catch(() => {})));
});
assert.notEqual(await send.evaluate((button) => getComputedStyle(button).backgroundColor), disabledBackground, `${label}: a disabled send looks inactive`);
check(await send.evaluateAll(measure), `${label}/steer`, false);
await stop.focus();
assert.ok(await stop.evaluate((button) => parseFloat(getComputedStyle(button).outlineWidth) >= 2), `${label}: keyboard focus is visible`);
await stop.click();
await page.waitForFunction(() => document.querySelector('[data-action="session.stop"]')?.getAttribute("aria-busy") === "true");
assert.equal(await stop.isDisabled(), true, `${label}: a pending stop cannot be repeated`);
check(await stop.evaluateAll(measure), `${label}/stopping`);
assert.deepEqual(errors, []);
await page.close();
console.log(`PASS composer icons ${label}: legibility, alignment, focus and running states`);
}
for (const colorScheme of ["light", "dark"]) {
const page = await browser.newPage({ viewport: { width: 1300, height: 900 }, colorScheme, reducedMotion: "reduce" });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.addInitScript(() => {
localStorage.setItem("rx-lang", "zh"); localStorage.setItem("rx-nav-rail", "on");
window.reasonixHost = {
shell: "electron", platform: "windows", titleBar: false,
isWindowMaximised: async () => false, setUnreadBadge: async () => {},
showBrowserView: async () => {}, hideBrowserView: async () => {}, freezeBrowserView: async () => "",
controlBrowserView: async () => {}, navigateBrowserView: async () => "", onBrowserLoadState: () => () => {},
};
});
const url = new URL(base);
url.searchParams.set("pref", "zh");
url.searchParams.set("ws", "2");
url.searchParams.set("sess", "6");
url.searchParams.set("agentpage", "web");
await page.goto(url.href, { waitUntil: "networkidle" });
assert.deepEqual(errors, []);
await page.locator('.navbtn[aria-label="文件"]').first().click();
await page.locator('[data-action-click="workbench.tab"][data-target="browser:view-a"]').click();
const buttons = page.locator(".bnav .bbtn");
await buttons.first().waitFor();
assert.equal(await buttons.count(), 4, "the browser exposes four navigation actions");
check(await buttons.evaluateAll(measure), `${colorScheme}/browser`);
for (const button of await buttons.all()) assert.equal(await button.getAttribute("title"), await button.getAttribute("aria-label"));
await page.locator(".bnav").screenshot({ path: `perf/shots/control-icons-browser-${colorScheme}.png` });
assert.deepEqual(errors, []);
await page.close();
console.log(`PASS browser icons ${colorScheme}: legibility, alignment and action labels`);
}
for (const colorScheme of ["light", "dark"]) for (const width of [1300, 390]) {
const page = await browser.newPage({ viewport: { width: 1300, height: 900 }, colorScheme, reducedMotion: "reduce" });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(`${base}?pref=zh&ws=2&sess=6&turns=2`, { waitUntil: "networkidle" });
const label = `${colorScheme}/${width}/library`;
const toolIcons = page.locator(".sym svg");
assert.ok(await toolIcons.count() > 0, `${label}: transcript icons exist`);
assert.ok(await toolIcons.evaluateAll((icons) => icons.every((icon) => icon.classList.contains("lucide"))), `${label}: tool cards use library icons`);
await page.locator('.model-picker').click();
assert.ok(await page.locator(".studio-model-menu .mi-mono .lucide-server").count() > 0, `${label}: custom providers use service icons`);
assert.equal(await page.locator(".studio-model-menu .mi-mono").first().textContent(), "");
assert.ok(await page.locator(".studio-model-menu .menu-current.lucide-check").count() > 0, `${label}: selection uses a library check`);
await page.keyboard.press("Escape");
await page.locator('[data-action="chrome.settings"]').evaluate((button) => button.click());
await page.locator('.prefs-nav [data-value="controls"]').click();
await page.setViewportSize({ width, height: 900 });
const order = page.locator('[data-action="appearance.nav-entry-move"]');
await order.first().scrollIntoViewIfNeeded();
check(await order.evaluateAll(measure), `${label}/reorder`);
const down = order.locator('.lucide-arrow-down').first().locator('..');
const value = await down.getAttribute("data-value");
await down.focus();
await page.keyboard.press("Enter");
const moved = page.locator(`[data-action="appearance.nav-entry-move"][data-value="${value.replace(/:down$/, ":up")}"]`);
assert.equal(await moved.isEnabled(), true, `${label}: reordering remains keyboard operable`);
await page.locator("#set-naventries").screenshot({ path: `perf/shots/control-icons-library-${colorScheme}-${width}.png` });
assert.deepEqual(errors, []);
await page.close();
console.log(`PASS semantic icons ${label}: tools, providers, selection and keyboard reordering`);
}
} finally {
await browser.close();
}
+5 -3
View File
@@ -1,3 +1,5 @@
// Settings and feedback centre on the window, not on the area below the title
// bar; a phone-width window keeps settings full screen. Geometry needs a real browser.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
@@ -18,10 +20,10 @@ for (const [w, h] of [[1600, 1000], [1280, 760], [390, 800]]) {
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?turns=5&pref=en`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.keyboard.press("Meta+Comma");
await page.waitForSelector(".prefs-sheet");
await page.waitForTimeout(300);
const prefs = await box(page, ".prefs-screen");
const prefs = await box(page, ".prefs-sheet");
await page.keyboard.press("Escape");
await page.waitForSelector(".prefs-sheet", { state: "detached" });
await page.evaluate(() => document.querySelector('[data-action="feedback.open"]')?.click());
@@ -30,7 +32,7 @@ for (const [w, h] of [[1600, 1000], [1280, 760], [390, 800]]) {
const fbk = await box(page, ".fbk");
const tag = `${w}x${h}`;
if (w > 760) {
check(`${tag}: settings fills the window`, prefs.top === 0 && prefs.bottom === 0, JSON.stringify(prefs));
check(`${tag}: settings sits in the window's vertical middle`, Math.abs(prefs.top - prefs.bottom) <= 2, JSON.stringify(prefs));
check(`${tag}: feedback sits in the window's vertical middle`, Math.abs(fbk.top - fbk.bottom) <= 2, JSON.stringify(fbk));
} else {
check(`${tag}: settings still reaches the bottom of a phone-width window`, prefs.bottom === 0, JSON.stringify(prefs));
+2 -2
View File
@@ -38,8 +38,8 @@ for (const scheme of ["light", "dark"]) {
await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => /备份原文件并修复/.test(x.textContent))?.click());
await page.waitForTimeout(300);
await page.evaluate(() => document.activeElement?.blur());
const settingsOut = await outside(".prefs-screen", 60);
check(`${scheme}: Tab never leaves the full-screen settings surface`, settingsOut.length === 0, settingsOut.slice(0, 3).join("; "));
const settingsOut = await outside(".prefs-sheet", 60);
check(`${scheme}: Tab never leaves the settings sheet`, settingsOut.length === 0, settingsOut.slice(0, 3).join("; "));
await page.keyboard.press("Escape");
await page.waitForTimeout(400);
+1 -1
View File
@@ -19,7 +19,7 @@ try {
await page.waitForSelector(".app", { timeout: 20000 });
await page.getByRole("button", { name: "设置", exact: true }).click();
await page.locator('[role="tab"][title="模型"]').click();
await page.locator('[data-action="settings.page-index"][data-target="providers"]').click();
await page.locator('[data-action="settings.open-page"][data-target="providers"]').click();
await page.getByText("myrelay", { exact: true }).first().click();
await page.getByText("从服务商读取可用模型").first().click();
await page.waitForSelector(".mrows .mline");
+1 -1
View File
@@ -24,7 +24,7 @@ for (const scheme of ["light", "dark"]) {
await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => /备份原文件并修复/.test(x.textContent))?.click());
await page.waitForTimeout(400);
await page.evaluate(() => document.querySelector('[role="tab"][title="模型"]')?.click());
await page.evaluate(() => document.querySelector('[data-action="settings.page-index"][data-target="providers"]')?.click());
await page.evaluate(() => document.querySelector('[data-action="settings.open-page"][data-target="providers"]')?.click());
await page.waitForSelector(".pdetail", { state: "attached" });
await page.evaluate(() => [...document.querySelectorAll("button,li,a")].find((x) => /myrelay-work/.test(x.textContent) && x.textContent.length < 80)?.click());
await page.waitForSelector(".pdetail .mlhead", { state: "attached" });
@@ -1,57 +0,0 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = new URL(process.env.PERF_URL ?? "http://localhost:4399/perf.html");
const browser = await chromium.launch();
try {
await mkdir("perf/shots", { recursive: true });
for (const colorScheme of ["light", "dark"]) for (const pack of ["default", "sky", "photo"]) {
const page = await browser.newPage({ viewport: { width: 1300, height: 900 }, colorScheme, reducedMotion: "reduce" });
await page.addInitScript(() => { localStorage.setItem("rx-lang", "zh"); localStorage.setItem("rx-nav-rail", "on"); });
const url = new URL(base);
for (const [key, value] of Object.entries({ pref: "zh", ws: "2", sess: "6", turns: "1" })) url.searchParams.set(key, value);
if (pack !== "default") url.searchParams.set("pack", pack);
await page.goto(url.href, { waitUntil: "networkidle" });
const current = page.locator('.navbtn[aria-current="page"]');
await current.waitFor();
const label = `${colorScheme}/${pack}`;
await page.locator(".nav").screenshot({ path: `perf/shots/nav-selection-${colorScheme}-${pack}.png` });
const colors = await current.evaluate((button) => {
const ink = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
const rgb = (color) => {
ink.clearRect(0, 0, 1, 1); ink.fillStyle = color; ink.fillRect(0, 0, 1, 1);
return [...ink.getImageData(0, 0, 1, 1).data];
};
const style = getComputedStyle(button);
const bg = rgb(style.backgroundColor);
const fg = rgb(style.color);
const over = (top, bottom) => {
const a = top[3] / 255, b = bottom[3] / 255, opacity = a + b * (1 - a);
return opacity ? top.slice(0, 3).map((v, i) => (v * a + bottom[i] * b * (1 - a)) / opacity).concat(opacity * 255) : top;
};
let surface = [0, 0, 0, 0];
for (let element = button; element; element = element.parentElement) surface = over(surface, rgb(getComputedStyle(element).backgroundColor));
const anchor = document.createElement("span");
anchor.style.backgroundColor = "var(--accent)";
button.append(anchor);
const accent = rgb(getComputedStyle(anchor).backgroundColor);
anchor.remove();
const luminance = (color) => color.slice(0, 3).map((v) => {
const s = v / 255; return s <= .04045 ? s / 12.92 : ((s + .055) / 1.055) ** 2.4;
}).reduce((sum, v, i) => sum + v * [.2126, .7152, .0722][i], 0);
const [a, b] = [luminance(over(fg, surface)), luminance(surface)].sort((x, y) => y - x);
return { bg, accent, contrast: (a + .05) / (b + .05), marker: getComputedStyle(button, "::before").width };
});
assert.notDeepEqual(colors.bg, colors.accent, `${label}: navigation uses a quiet selection surface`);
assert.ok(colors.contrast >= 3, `${label}: the selected icon stays legible (${JSON.stringify(colors)})`);
assert.ok(parseFloat(colors.marker) >= 2, `${label}: a side marker also identifies the current section`);
await current.focus();
assert.ok(await current.evaluate((button) => parseFloat(getComputedStyle(button).outlineWidth) >= 2), `${label}: keyboard focus stays visible`);
assert.equal(await current.getAttribute("aria-label"), "会话");
await page.close();
console.log(`PASS navigation selection ${label}: quiet surface, contrast and focus`);
}
} finally {
await browser.close();
}
@@ -40,12 +40,12 @@ for (const scheme of ["light", "dark"]) {
await page.waitForSelector('[data-action="settings.section"][data-value="tools"]');
await section("session");
await page.waitForTimeout(300);
check(`${tag}: session messages have a settings entry of their own`, (await page.locator('[data-action="settings.page-index"][data-target="peers"]').count()) === 1);
check(`${tag}: session messages have a settings entry of their own`, (await page.locator('[data-action="settings.open-page"][data-target="peers"]').count()) === 1);
await section("tools");
await page.waitForTimeout(300);
check(`${tag}: and no longer sit at the foot of tools and permissions`, (await page.locator('[role="switch"][aria-label="启用会话间消息"]').count()) === 0);
await section("session");
await page.locator('[data-action="settings.page-index"][data-target="peers"]').click();
await page.locator('[data-action="settings.open-page"][data-target="peers"]').click();
await page.waitForSelector('[role="switch"][aria-label="启用会话间消息"]', { timeout: 8000 });
const s = await page.evaluate(() => {
const sw = (n) => document.querySelector(`[role="switch"][aria-label="${n}"]`);
@@ -1,3 +1,5 @@
// A wide settings window centres its content column between the nav and
// the window edge: the room left over is shared, not piled up on the right.
import assert from "node:assert/strict";
import { chromium } from "playwright";
@@ -16,17 +18,9 @@ try {
const room = await page.evaluate(() => {
const main = document.querySelector(".prefs-main").getBoundingClientRect();
const col = document.querySelector(".prefs-main .prefs-col").getBoundingClientRect();
const screen = document.querySelector(".prefs").getBoundingClientRect();
const sheet = document.querySelector(".prefs-sheet").getBoundingClientRect();
const nav = document.querySelector(".prefs-nav").getBoundingClientRect();
const cards = [...document.querySelectorAll(".prefs-col > .prefs-card")].map((c) => c.getBoundingClientRect());
return { sheet: { left: sheet.left, right: sheet.right, bottom: sheet.bottom }, nav: nav.width, left: Math.round(col.left - main.left), right: Math.round(main.right - col.right), width: Math.round(col.width), screen: { x: screen.x, y: screen.y, width: screen.width, height: screen.height }, single: cards.every((c, i) => Math.abs(c.width - col.width) < 1 && (!i || c.top >= cards[i - 1].bottom)) };
return { left: Math.round(col.left - main.left), right: Math.round(main.right - col.right), width: Math.round(col.width) };
});
assert.deepEqual(room.screen, { x: 0, y: 0, width, height: 900 }, "settings occupies the application window");
assert.deepEqual(room.sheet, { left: 0, right: width, bottom: 900 });
assert.equal(room.nav, 240);
assert.ok(room.single, "all cards fill one content column");
assert.ok(room.left >= 20 && room.left <= 32 && room.right >= 20 && room.right <= 32, `${width}px: only fixed content gutters remain (${JSON.stringify(room)})`);
assert.ok(room.width <= 1200, `the column keeps its cap (${room.width})`);
assert.ok(Math.abs(room.left - room.right) <= 2, `${width}px: the room is shared evenly (left ${room.left}, right ${room.right})`);
assert.deepEqual(errors, []);
console.log(`PASS ${width}px: column ${room.width}px with ${room.left}px each side`);
@@ -1,84 +0,0 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const browser = await chromium.launch();
try {
await mkdir("perf/shots", { recursive: true });
for (const colorScheme of ["light", "dark"]) for (const language of ["zh", "en"]) for (const width of [1800, 1400, 1000, 390]) for (const scale of [1, 1.3]) {
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 }, colorScheme, reducedMotion: "reduce" });
page.setDefaultTimeout(10000);
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("requestfailed", (request) => errors.push(`${request.url()}: ${request.failure()?.errorText}`));
await page.addInitScript((value) => localStorage.setItem("rx-lang", value), language);
await page.goto(`${base}?pref=${language}&turns=1`, { waitUntil: "networkidle" });
assert.deepEqual(errors, [], "the browser fixture and its styles load successfully");
await page.locator('[data-action="chrome.settings"]').evaluate((button) => button.click());
await page.locator('.prefs-nav [data-value="model"]').click();
await page.getByText("myrelay", { exact: true }).first().click();
const form = page.locator(".addp[data-edit]");
await form.locator('.token-limits input').first().fill("1600000");
await form.locator('[data-value="model-window"]').last().fill("1000000");
await form.locator('[data-value="model-max-output"]').last().fill("1000000");
await page.setViewportSize({ width, height: 1000 });
await page.evaluate(async (value) => {
document.documentElement.style.setProperty("--fs-scale", String(value));
await document.fonts.ready;
}, scale);
const label = `${colorScheme}-${language}-${width}-${scale}`;
const billing = form.locator('[data-value="billing-mode"]');
const currency = form.locator('[data-value="model-price-currency"]').last();
await currency.selectOption("USD");
await billing.selectOption("subscription_equivalent");
const measure = () => form.evaluate((root) => {
const context = document.createElement("canvas").getContext("2d");
const fields = [...root.querySelectorAll('.ml-row > .unit-field input, .ml-row > select, [data-value="billing-mode"]')];
const clipped = fields.flatMap((field) => {
const style = getComputedStyle(field);
context.font = style.font;
const text = field.tagName === "SELECT" ? field.selectedOptions[0].textContent : field.value || field.placeholder;
const available = field.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
const needed = context.measureText(text).width;
return needed > available + 1 ? [{ field: field.getAttribute("aria-label"), text, needed, available }] : [];
});
const rows = [...root.querySelectorAll(".ml-row")];
const uneven = rows.flatMap((row) => {
const heights = [...row.querySelectorAll(":scope > .unit-field, :scope > select")].map((field) => field.getBoundingClientRect().height);
return Math.max(...heights) - Math.min(...heights) > 1 ? [heights] : [];
});
const outside = fields.filter((field) => {
const box = field.getBoundingClientRect(), parent = field.closest(".ml-row, label").getBoundingClientRect();
return box.right > parent.right + 1 || box.left < parent.left - 1;
}).map((field) => field.getAttribute("aria-label"));
for (const row of rows) {
const controls = row.querySelectorAll(":scope > .unit-field, :scope > select");
if (row.getBoundingClientRect().right - controls[controls.length - 1].getBoundingClientRect().right > 1) outside.push("unfilled row");
}
return { clipped, uneven, outside };
});
const selected = await measure();
await form.locator(".ml-group").first().scrollIntoViewIfNeeded();
await page.screenshot({ path: `perf/shots/provider-fields-${label}.png` });
assert.deepEqual(selected, { clipped: [], uneven: [], outside: [] }, `${label}: values, inherited hints and the full billing choice fit aligned controls`);
await billing.selectOption("payg");
assert.deepEqual(await measure(), { clipped: [], uneven: [], outside: [] }, `${label}: the default billing choice fits too`);
await currency.focus();
await page.keyboard.press("Space");
await page.waitForFunction(() => document.querySelector('[data-value="model-price-currency"]:open'));
await page.keyboard.press("Enter");
assert.equal(await currency.inputValue(), "USD", `${label}: keyboard confirmation preserves the currency`);
await form.locator('[data-value="model-window"]').last().fill("");
assert.deepEqual(await measure(), { clipped: [], uneven: [], outside: [] }, `${label}: clearing a value reveals its complete inheritance hint`);
await form.locator('.token-limits input').first().fill("");
assert.deepEqual(await measure(), { clipped: [], uneven: [], outside: [] }, `${label}: an undeclared shared window also fits`);
await billing.scrollIntoViewIfNeeded();
await page.screenshot({ path: `perf/shots/provider-fields-prices-${label}.png` });
assert.deepEqual(errors, []);
await page.close();
console.log(`PASS provider fields ${label}: text fit, aligned heights, currency, billing and inheritance`);
}
} finally {
await browser.close();
}
@@ -19,7 +19,7 @@ try {
await page.waitForSelector(".app", { timeout: 20000 });
await page.getByRole("button", { name: "设置", exact: true }).click();
await page.locator('[role="tab"][title="模型"]').click();
await page.locator('[data-action="settings.page-index"][data-target="providers"]').click();
await page.locator('[data-action="settings.open-page"][data-target="providers"]').click();
await page.waitForSelector('[data-action-click="provider.select"]');
const text = await page.evaluate(() => [...document.querySelectorAll('[data-action-click="provider.select"]')].map((b) => b.textContent ?? ""));
check(`${scheme}: the server on port 8756 is listed`, text.some((t) => t.includes("127.0.0.1:8756")), text.join(" | "));
@@ -1,3 +1,5 @@
// With the icon rail hidden, the sidebar's shortcut strip is one row of equal icon buttons on a single line,
// and the rows around it (new session, search, feedback) draw their icon and label at the sidebar's own edge.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=1&sess=1&turns=1";
@@ -15,19 +17,18 @@ try {
await ctx.addInitScript(() => { try { localStorage.setItem("rx-nav-rail", "off"); } catch {} });
const page = await ctx.newPage();
await page.goto(BASE, { waitUntil: "networkidle" });
await page.locator(".studio-tools").hover();
await page.waitForSelector(".studio-quicknav > button");
const strip = await page.evaluate(() => [...document.querySelectorAll(".studio-quicknav > button")].map((b) => {
const r = b.getBoundingClientRect();
const svg = b.querySelector("svg, .studio-icon").getBoundingClientRect();
return { w: r.width, h: r.height, left: r.left, cy: r.y + r.height / 2, icon: [svg.width, svg.height], iconCy: svg.y + svg.height / 2 };
return { w: r.width, h: r.height, cy: r.y + r.height / 2, icon: [svg.width, svg.height], iconCy: svg.y + svg.height / 2 };
}));
const spread = (key) => Math.max(...strip.map((s) => s[key])) - Math.min(...strip.map((s) => s[key]));
check(`${scheme}: the shortcut card has several entries`, strip.length >= 5, `${strip.length}`);
check(`${scheme}: the shortcut strip has several buttons`, strip.length >= 5, `${strip.length}`);
check(`${scheme}: the shortcut buttons share one size`, spread("w") <= TOLERANCE && spread("h") <= TOLERANCE, JSON.stringify(strip.map((s) => [s.w, s.h])));
check(`${scheme}: the shortcut rows align and center their icons`, spread("left") <= TOLERANCE && strip.every((s) => Math.abs(s.cy - s.iconCy) <= TOLERANCE), JSON.stringify(strip.map((s) => [s.left, s.cy - s.iconCy])));
check(`${scheme}: the shortcut buttons and their icons sit on one horizontal line`, spread("cy") <= TOLERANCE && spread("iconCy") <= TOLERANCE, `${spread("cy")} / ${spread("iconCy")}`);
check(`${scheme}: the shortcut icons share one size`, new Set(strip.map((s) => s.icon.join("x"))).size === 1, strip.map((s) => s.icon.join("x")).join(" "));
const rows = await page.evaluate(() => [".studio-new-task", ".studio-search", ".studio-tools-row", ".studio-feedback"].map((s) => {
const rows = await page.evaluate(() => [".studio-new-task", ".studio-search", ".studio-feedback"].map((s) => {
const row = document.querySelector(s);
if (!row) return null;
const r = row.getBoundingClientRect();
@@ -16,7 +16,6 @@ try {
await ctx.addInitScript(() => { try { localStorage.setItem("rx-nav-rail", "off"); } catch {} });
const page = await ctx.newPage();
await page.goto(BASE, { waitUntil: "networkidle" });
await page.locator(".studio-tools").hover();
await page.waitForSelector(".studio-quicknav > button");
await page.mouse.move(1270, 890);
const looks = await page.evaluate(() => [...document.querySelectorAll(".studio-quicknav > button")].map((b) => {
@@ -1,61 +0,0 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const browser = await chromium.launch();
try {
await mkdir("perf/shots", { recursive: true });
for (const colorScheme of ["light", "dark"]) for (const width of [1300, 390]) for (const language of ["zh", "en"]) for (const scale of [1, 1.3]) {
const page = await browser.newPage({ viewport: { width: 1300, height: 900 }, colorScheme, reducedMotion: "reduce" });
page.setDefaultTimeout(10000);
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.addInitScript((value) => localStorage.setItem("rx-lang", value), language);
await page.goto(`${base}?pref=${language}&turns=1`, { waitUntil: "networkidle" });
await page.locator('[data-action="chrome.settings"]').click();
await page.locator('.prefs-nav [data-value="common"]').click();
const select = page.locator('[data-action="appearance.language"]');
await select.waitFor();
await page.setViewportSize({ width, height: 900 });
await page.evaluate(async (value) => {
document.documentElement.style.setProperty("--fs-scale", String(value));
await document.fonts.ready;
}, scale);
const label = `${colorScheme}-${width}-${language}-${scale}`;
const colors = await select.evaluate((field) => ({ ink: getComputedStyle(field).color, ground: getComputedStyle(field).backgroundColor }));
const shot = await select.screenshot({ path: `perf/shots/select-alignment-${label}.png` });
const bounds = await page.evaluate(async ({ data, ink, ground }) => {
const picture = new Image();
picture.src = `data:image/png;base64,${data}`;
await picture.decode();
const canvas = document.createElement("canvas");
canvas.width = picture.width; canvas.height = picture.height;
const ctx = canvas.getContext("2d");
const rgb = (color) => { ctx.fillStyle = color; ctx.fillRect(0, 0, 1, 1); return [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3); };
const fg = rgb(ink), bg = rgb(ground);
ctx.drawImage(picture, 0, 0);
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
const distance = (pixel, color) => color.reduce((sum, channel, i) => sum + (channel - pixel[i]) ** 2, 0);
let top = canvas.height, bottom = -1;
for (let y = 1; y < canvas.height - 1; y++) for (let x = 12; x < canvas.width - 34; x++) {
const pixel = pixels.slice((y * canvas.width + x) * 4, (y * canvas.width + x) * 4 + 3);
if (distance(pixel, fg) < distance(pixel, bg)) { top = Math.min(top, y); bottom = Math.max(bottom, y); }
}
return { top, bottom, height: canvas.height, offset: Math.abs((top + bottom + 1 - canvas.height) / 2) };
}, { data: shot.toString("base64"), ...colors });
assert.ok(bounds.bottom > bounds.top, `${label}: the selected text is visible`);
assert.ok(bounds.offset <= 2, `${label}: text is vertically centered (${JSON.stringify(bounds)})`);
assert.deepEqual(await select.locator("option").allTextContents(), language === "zh" ? ["跟随系统", "简体中文", "英文"] : ["Follow the system", "简体中文", "English"]);
await select.focus();
await page.keyboard.press("Space");
await page.waitForFunction(() => document.querySelector('[data-action="appearance.language"]').matches(":open"));
await page.keyboard.press("Escape");
assert.equal(await page.evaluate(() => localStorage.getItem("rx-lang")), language, `${label}: dismissing the picker preserves the language`);
assert.deepEqual(errors, []);
await page.close();
console.log(`PASS select ${label}: centered text, language names and keyboard dismissal`);
}
} finally {
await browser.close();
}
@@ -1,47 +0,0 @@
import assert from "node:assert/strict";
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const context = await browser.newContext({ viewport: { width: 1500, height: 900 }, colorScheme: scheme, deviceScaleFactor: 2 });
const page = await context.newPage();
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.locator(".prefs-nav button", { hasText: /^常用/ }).first().click();
await page.waitForTimeout(500);
const png = await page.locator("select").first().screenshot();
const ink = await page.evaluate(async ({ image }) => {
const img = new Image();
img.src = image;
await img.decode();
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const context2d = canvas.getContext("2d");
context2d.drawImage(img, 0, 0);
const data = context2d.getImageData(0, 0, img.width, img.height).data;
const at = (x, y) => [data[(y * img.width + x) * 4], data[(y * img.width + x) * 4 + 1], data[(y * img.width + x) * 4 + 2]];
const bg = at(Math.floor(img.width / 2), 6);
let top = img.height;
let bottom = -1;
for (let y = 4; y < img.height - 4; y++) {
for (let x = 16; x < img.width * 0.7; x++) {
const p = at(x, y);
if (Math.abs(p[0] - bg[0]) + Math.abs(p[1] - bg[1]) + Math.abs(p[2] - bg[2]) > 150) {
top = Math.min(top, y);
bottom = Math.max(bottom, y);
}
}
}
return { above: top, below: img.height - 1 - bottom };
}, { image: `data:image/png;base64,${png.toString("base64")}` });
assert.ok(Math.abs(ink.above - ink.below) <= 4, `${scheme}: the chosen text sits in the middle of its select (${JSON.stringify(ink)})`);
console.log(`PASS ${scheme}: select text centred (${ink.above}px above, ${ink.below}px below at 2x)`);
await context.close();
}
} finally {
await browser.close();
}
@@ -1,33 +0,0 @@
import assert from "node:assert/strict";
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 1500, height: 900 }, deviceScaleFactor: 2 });
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.locator(".prefs-nav button", { hasText: /^模型/ }).first().click();
await page.waitForTimeout(400);
await page.getByRole("button", { name: "按用途设置" }).click();
await page.waitForTimeout(500);
const fit = await page.evaluate(() => {
const select = document.querySelector("select.usage-pick");
const option = document.createElement("option");
option.value = "long";
option.textContent = "deepseek-v4.1-flash-1017-preview-extended · 读图";
select.appendChild(option);
select.value = "long";
select.dispatchEvent(new Event("change", { bubbles: true }));
return new Promise((resolve) => requestAnimationFrame(() => {
const box = select.getBoundingClientRect();
resolve({ height: Math.round(box.height), overflow: select.scrollHeight - select.clientHeight, whiteSpace: getComputedStyle(select).whiteSpace });
}));
});
assert.equal(fit.whiteSpace, "nowrap", "a select never wraps the name it shows");
assert.ok(fit.overflow <= 2, `a long chosen name does not spill out of its box (${JSON.stringify(fit)})`);
console.log("PASS a long chosen name stays on one line inside its select");
} finally {
await browser.close();
}
@@ -1,60 +0,0 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const browser = await chromium.launch();
try {
await mkdir("perf/shots", { recursive: true });
for (const colorScheme of ["light", "dark"]) for (const width of [1600, 390]) for (const scale of [1, 1.3]) {
const page = await browser.newPage({ viewport: { width: 1300, height: 1000 }, colorScheme, reducedMotion: "reduce" });
page.setDefaultTimeout(10000);
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(`${base}?pref=zh&turns=1`, { waitUntil: "networkidle" });
await page.locator('[data-action="chrome.settings"]').click();
await page.locator('.prefs-nav').waitFor();
await page.setViewportSize({ width, height: 1000 });
await page.evaluate((size) => document.documentElement.style.setProperty('--fs-scale', String(size)), scale);
const sections = await page.locator('.prefs-nav [role="tab"]').evaluateAll((tabs) => tabs.map((tab) => tab.dataset.value));
for (const section of sections) {
await page.locator(`.prefs-nav [data-value="${section}"]`).click();
await page.waitForTimeout(200);
const geometry = await page.locator('.prefs-screen').evaluate((root) => {
const sheet = root.querySelector('.prefs-sheet').getBoundingClientRect();
const panel = root.querySelector('.prefs-main');
const col = panel.querySelector('.prefs-col').getBoundingClientRect();
const cards = [...panel.querySelectorAll('.prefs-col > .prefs-card')].map((card) => card.getBoundingClientRect());
const nav = root.querySelector('.prefs-nav').getBoundingClientRect();
return {
sheet: { left: sheet.left, right: sheet.right, bottom: sheet.bottom }, nav: nav.width,
gutters: [col.left - panel.getBoundingClientRect().left, panel.getBoundingClientRect().right - col.right],
spill: panel.scrollWidth - panel.clientWidth,
logsFit: [...panel.querySelectorAll('#set-logs .acct-note')].every((note) => note.scrollWidth <= note.clientWidth + 1),
single: cards.every((card, i) => Math.abs(card.width - col.width) < 1 && (!i || card.top >= cards[i - 1].bottom)),
};
});
const label = `${colorScheme} ${width}px ${scale} ${section}`;
if (geometry.spill > 1 || !geometry.logsFit) {
console.log(label, await page.locator('.prefs-main').evaluate((panel) => [...panel.querySelectorAll('*')]
.filter((node) => node.clientWidth > 1 && node.scrollWidth > node.clientWidth + 1)
.map((node) => ({ tag: node.tagName, class: node.className, width: node.clientWidth, scroll: node.scrollWidth }))));
await page.screenshot({ path: `perf/shots/settings-full-failed-${colorScheme}-${width}-${scale}-${section}.png` });
}
assert.deepEqual(geometry.sheet, { left: 0, right: width, bottom: 1000 }, label);
assert.ok(geometry.single, `${label}: full-width cards in one column`);
assert.ok(geometry.spill <= 1, `${label}: horizontal overflow ${geometry.spill}px`);
assert.ok(geometry.logsFit, `${label}: log paths wrap within their rows`);
if (width > 640) {
assert.equal(geometry.nav, 240, label);
assert.deepEqual(geometry.gutters, [24, 24], label);
}
if (section === 'common' && scale === 1) await page.screenshot({ path: `perf/shots/settings-full-${colorScheme}-${width}.png` });
}
assert.deepEqual(errors, []);
console.log(`PASS full-screen settings ${colorScheme} ${width}px scale ${scale}: ${sections.length} categories`);
await page.close();
}
} finally {
await browser.close();
}
@@ -25,18 +25,14 @@ try {
}
await page.locator(".prefs-nav button", { hasText: /^操作与性能/ }).first().click();
await page.waitForTimeout(400);
const keys = await page.locator('#set-keys').elementHandle();
await page.locator('[data-action="settings.page-index"][data-target="naventries"]').click();
await page.getByRole("button", { name: "调整导航" }).click();
await page.waitForTimeout(600);
assert.ok(await keys.evaluate((node) => node.isConnected), "locating a region preserves sibling editors");
const placement = await page.locator('#set-naventries').evaluate((node) => {
const panel = node.closest('.prefs-main');
const index = panel.querySelector('.page-index').getBoundingClientRect();
return { top: node.getBoundingClientRect().top, index: index.bottom, bottom: panel.getBoundingClientRect().bottom };
const shown = await page.evaluate(() => {
const card = document.querySelector(".prefs-col .deep-page").closest(".prefs-card");
return [...card.children].filter((c) => !c.classList.contains("deep-page") && getComputedStyle(c).display !== "none").length;
});
assert.ok(placement.top >= placement.index && placement.top < placement.bottom, `located region clears the pill index: ${JSON.stringify(placement)}`);
assert.equal(await page.locator('.deep-page').count(), 0);
console.log("PASS locating a region scrolls without replacing any editors");
assert.equal(shown, 0, "an open sub-page shows nothing of the page it was opened from");
console.log("PASS sub-page: nothing of the parent page remains above it");
assert.deepEqual(errors, []);
} finally {
await browser.close();
@@ -1,73 +0,0 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const browser = await chromium.launch();
try {
await mkdir("perf/shots", { recursive: true });
for (const colorScheme of ["light", "dark"]) for (const width of [1440, 390]) for (const hidden of [false, true]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme, reducedMotion: "reduce" });
page.setDefaultTimeout(10000);
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.addInitScript((hide) => {
localStorage.setItem("rx-nav-rail", "off");
localStorage.setItem("rx-lang", "zh");
localStorage.setItem("rx-nav-entries", JSON.stringify({ order: ["remote", "usage"], hidden: hide ? ["usage", "files", "tools", "ext", "memory", "remote"] : [] }));
}, hidden);
await page.goto(`${base}?pref=zh&turns=1`, { waitUntil: "networkidle" });
if (width === 390) await page.locator('.studio-menu').click();
const root = page.locator(".studio-tools");
const settings = root.locator('[data-action="chrome.settings"]');
assert.equal(await settings.innerText(), "设置");
const alignment = await settings.evaluate((button) => {
const icon = button.querySelector('svg').getBoundingClientRect();
const feedback = document.querySelector('.studio-feedback svg').getBoundingClientRect();
return Math.abs(icon.x - feedback.x);
});
assert.ok(alignment <= 4, `settings and feedback icons align: ${alignment}`);
if (hidden) {
assert.equal(await root.locator('.studio-tools-toggle').count(), 0);
await root.hover();
await page.waitForTimeout(250);
assert.equal(await page.getByRole('menu', { name: '侧栏入口' }).count(), 0);
} else {
await root.hover();
const menu = page.getByRole('menu', { name: '侧栏入口' });
await menu.waitFor();
assert.deepEqual(await menu.locator('button > span').allTextContents(), ['远程', '用量', '文件', '工具与权限', '扩展', '记忆']);
const item = menu.getByRole('menuitem', { name: '文件' });
await item.hover();
await page.waitForTimeout(220);
assert.ok(await menu.isVisible(), 'pointer can cross the gap into the card');
const bounds = await menu.boundingBox();
assert.ok(bounds.x >= 0 && bounds.x + bounds.width <= width && bounds.y >= 0 && bounds.y + bounds.height <= 900);
assert.equal(await menu.locator('button svg.lucide').count(), 6);
await page.screenshot({ path: `perf/shots/sidebar-tools-${colorScheme}-${width}.png` });
await page.mouse.move(width - 5, 10);
await menu.waitFor({ state: 'hidden' });
const toggle = root.locator('.studio-tools-toggle');
await toggle.focus();
await page.keyboard.press('Enter');
await page.waitForFunction(() => document.activeElement?.getAttribute('role') === 'menuitem');
await page.keyboard.press('End');
assert.equal(await page.locator(':focus > span').innerText(), '记忆');
await page.keyboard.press('Escape');
assert.ok(await toggle.evaluate((node) => node === document.activeElement));
await toggle.click();
await item.click();
await menu.waitFor({ state: 'hidden' });
assert.equal(await page.locator('.app').getAttribute('data-browser'), 'on');
if (width === 390) await page.locator('.studio-menu').click();
}
await settings.click();
await page.locator('.prefs-screen').waitFor();
assert.equal(await page.getByRole('menu', { name: '侧栏入口' }).count(), 0);
assert.deepEqual(errors, []);
console.log(`PASS sidebar ${colorScheme} ${width}px ${hidden ? 'all hidden' : 'named hover/click/keyboard card'}`);
await page.close();
}
} finally {
await browser.close();
}
+1 -1
View File
@@ -37,7 +37,7 @@ for (const scheme of ["light", "dark"]) {
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.locator(".prefs-nav button", { hasText: "外观" }).first().click();
await page.locator('[data-action="settings.page-index"][data-target="size"]').click();
await page.locator('[data-action="settings.open-page"][data-target="size"]').click();
await page.locator('[data-action="appearance.text-size"]', { hasText: new RegExp(`^${name}$`) }).click();
await page.waitForTimeout(200);
if (shotDir && scale === 1.3) await page.screenshot({ path: `${shotDir}/${scheme}-settings-1.3.png` });
@@ -1,53 +0,0 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const browser = await chromium.launch();
try {
await mkdir("perf/shots", { recursive: true });
for (const colorScheme of ["light", "dark"]) {
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 }, colorScheme, reducedMotion: "reduce" });
page.setDefaultTimeout(10000);
await page.addInitScript(() => localStorage.setItem("rx-lang", "zh"));
await page.goto(`${base}?pref=zh&turns=1`, { waitUntil: "networkidle" });
const pane = page.locator(".pane[data-active]");
await pane.waitFor();
const before = await pane.locator(".think").count();
const now = 1_700_000_000_000;
await page.clock.setFixedTime(now);
const feed = (event) => page.evaluate((ev) => window.__feed(ev), event);
await feed({ kind: "turn_started" });
await feed({ kind: "reasoning", text: "计时回归" });
await pane.locator(".think[data-live]").waitFor();
await page.clock.setFixedTime(now + 1200);
await page.waitForFunction(() => document.querySelector('.pane[data-active] .think[data-live] .think-label')?.textContent.includes("1.2"));
const tool = { id: "clock-call", name: "bash", readOnly: true, issuer: "model" };
await feed({ kind: "tool_dispatch", tool: { ...tool, partial: true } });
await page.waitForFunction(() => !document.querySelector('.pane[data-active] .think[data-live]'));
const labels = () => pane.locator(".think-label:not(.think-out)").allTextContents();
assert.equal((await labels()).at(-1), "思考 1.2 秒 · 4 字");
await page.clock.setFixedTime(now + 1_800_000);
await page.waitForTimeout(200);
assert.equal((await labels()).at(-1), "思考 1.2 秒 · 4 字", "a tool's running time never becomes thinking time");
await feed({ kind: "text", text: "检查工具输出。" });
await feed({ kind: "message", text: "检查工具输出。", reasoning: "计时回归", thoughtMs: 1100 });
await feed({ kind: "tool_dispatch", tool });
await feed({ kind: "tool_result", tool: { ...tool, output: "done" } });
assert.equal(await pane.locator(".think").count(), before + 1, "interleaved tool events keep one response card");
await feed({ kind: "reasoning", text: "第二轮" });
await page.clock.setFixedTime(now + 1_800_500);
await feed({ kind: "message", text: "完成。", reasoning: "第二轮", thoughtMs: 500 });
await feed({ kind: "turn_done" });
await page.clock.setFixedTime(now + 3_600_000);
await page.waitForTimeout(300);
assert.equal(await pane.locator(".think[data-live]").count(), 0);
assert.deepEqual((await labels()).slice(-2), ["思考 1.1 秒 · 4 字", "思考 0.5 秒 · 3 字"]);
await pane.locator(".think").last().scrollIntoViewIfNeeded();
await page.screenshot({ path: `perf/shots/thought-clock-${colorScheme}.png` });
await page.close();
console.log(`PASS ${colorScheme}: completed thoughts stay still after tools, later rounds and another 30 minutes`);
}
} finally {
await browser.close();
}
+1 -1
View File
@@ -23,7 +23,7 @@ for (const scheme of ["light", "dark"]) {
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector('[data-action="settings.section"][data-value="tools"]');
await page.evaluate(() => document.querySelector('[data-action="settings.section"][data-value="tools"]')?.click());
await page.locator('[data-action="settings.page-index"][data-target="web-access"]').click();
await page.locator('[data-action="settings.open-page"][data-target="web-access"]').click();
await page.waitForSelector("#set-web-access");
const r = await page.evaluate(() => {
const g = document.querySelector("#set-web-access");
+3 -2
View File
@@ -577,6 +577,7 @@ export const ACTIONS: UIAction[] = [
{ id: "tray.icon", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "tray.close-to-tray", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "notify.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "notify.more", kind: "view", target: "none", proof: "interaction" },
{ id: "notify.turn-done", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "notify.approval", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "notify.ask", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -650,8 +651,8 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.effects", kind: "view", target: "none", proof: "browser" },
{ id: "pane.close", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "settings.section", kind: "navigation", target: "none", proof: "interaction" },
{ id: "settings.edit-persona", kind: "navigation", target: "none", proof: "interaction" },
{ id: "sidebar.tools", kind: "view", target: "none", proof: "interaction" },
{ id: "settings.open-page", kind: "navigation", target: "entity", proof: "interaction" },
{ id: "settings.close-page", kind: "navigation", target: "none", proof: "interaction" },
{ id: "settings.page", kind: "navigation", target: "none", proof: "interaction" },
// Finding a setting changes what is on screen and nothing else: it reaches
// no kernel state, and the jump it offers is settings.section, because
@@ -90,12 +90,11 @@
.compose .btn.send { border: 0; background: var(--accent); color: var(--accent-fg); }
.compose .btn.send:hover:not(:disabled) { background: var(--accent); filter: brightness(1.18); }
.compose .btn.send:disabled { background: var(--overlay); color: var(--ghost); filter: none; opacity: 1; }
.compose .btn.send:focus-visible, .compose .btn.stop:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; box-shadow: none; }
.compose .btn.send:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; box-shadow: none; }
.compose .btn.send[data-running]:not(:disabled) { width: auto; display: inline-flex; gap: 5px; padding: 0 12px 0 10px; border-radius: var(--r-pill); background: var(--accent); color: var(--accent-fg); font: var(--w-med) var(--fs-ui)/1 var(--ui); }
.compose .btn.stop, .compose .btn.stop[data-primary] { border: 0; background: color-mix(in srgb, var(--err) 12%, transparent); color: var(--err-ink); }
.compose .btn.stop:hover:not(:disabled) { background: color-mix(in srgb, var(--err) 20%, transparent); color: var(--err-ink); }
.compose .btn :is(.ic, .ic > svg) { width: 18px; height: 18px; }
.compose .btn.send .ic > svg { stroke-width: 2; }
.compose .btn.stop, .compose .btn.stop[data-primary] { border: 1px solid var(--err); background: transparent; color: var(--err-ink); }
.compose .btn.stop:hover { background: color-mix(in srgb, var(--err) 9%, transparent); border-color: var(--err); color: var(--err-ink); }
.compose .btn :is(.ic, .ic > svg) { width: 13px; height: 13px; }
.studio-meterrail { left: 0; right: 0; padding: 0 2px; gap: 0; color: var(--faint); font: var(--w-reg) var(--fs-micro)/1 var(--ui); }
.studio-meterrail b { color: var(--muted); font-weight: var(--w-med); }
@@ -76,7 +76,7 @@
.onb-demo .d.tool code { color: var(--faint); font: var(--w-reg) var(--fs-micro) var(--mono); }
.onb-demo .d.tool small { margin-left: auto; color: var(--faint); font: var(--w-reg) var(--fs-micro) var(--mono); }
.onb-demo .d.tool small.add { color: var(--add); }
.onb-demo .d.tool small.ok { display: inline-flex; align-items: center; gap: 4px; color: var(--ok-ink); }
.onb-demo .d.tool small.ok { color: var(--ok-ink); }
.onb-demo .d.done { margin-top: 4px; padding-top: 10px; border-top: 1px solid var(--hair); color: var(--ok-ink); }
.onb-demo .demo-spin { color: var(--net); }
.onb-demo .demo-spin path { animation: demo-draw 1.2s ease-in-out infinite; }
@@ -106,7 +106,7 @@
.onb-flow .onb-go .onb-spin { width: 16px; height: 16px; color: currentColor; opacity: 1; }
.onb-flow .onb-go .onb-spin path { animation: demo-draw 1.1s ease-in-out infinite; }
.onb-flow .onb-go .onb-check { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.onb-flow .onb-go .onb-check path { stroke-dasharray: 32; stroke-dashoffset: 32; animation: demo-draw 300ms ease-out forwards; }
.onb-flow .onb-go .onb-check path { stroke-dasharray: 100; stroke-dashoffset: 100; animation: demo-draw 300ms ease-out forwards; }
.onb-flow .onb-found[data-enter] { animation: onb-banner-in 260ms var(--ease-out) both; }
@keyframes onb-banner-in { from { opacity: 0; transform: translateY(-8px); } }
@media (prefers-reduced-motion: reduce) {
@@ -1,6 +1,7 @@
[data-fold~="phone"] .prefs { inset: 0; padding: 0; }
[data-fold~="phone"] .prefs-sheet { width: 100%; height: 100%; border: 0; border-radius: 0; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto auto minmax(0, 1fr); }
[data-fold~="phone"] .prefs-sheet .prefs-hd h1 { grid-column: 1; grid-row: 1; padding: 14px 16px 8px; }
[data-fold~="phone"] .prefs-sheet .prefs-actions { grid-column: 1; grid-row: 1; justify-self: end; align-self: center; padding: 8px 10px; }
[data-fold~="phone"] .prefs-sheet .prefs-find { grid-column: 1; grid-row: 2; width: calc(100% - 24px); margin: 0 12px 8px; }
[data-fold~="phone"] .prefs-sheet > .prefs-hd > .prefs-find { grid-column: 1; grid-row: 2; width: calc(100% - 24px); margin: 0 12px 8px; }
[data-fold~="phone"] .prefs-sheet .prefs-nav { grid-column: 1; grid-row: 3; display: flex; flex-direction: row; gap: 4px; padding: 4px 28px 8px 10px; mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--hair); scrollbar-width: none; }
@@ -8,7 +9,6 @@
[data-fold~="phone"] .prefs-sheet .prefs-nav :is(.navsec, .nv) { display: none; }
[data-fold~="phone"] .prefs-sheet .prefs-nav button { flex: none; min-height: 32px; padding: 6px 10px; }
[data-fold~="phone"] .prefs-sheet .prefs-main { grid-column: 1; grid-row: 4; padding: 16px 14px 48px; }
[data-fold~="phone"] .prefs-sheet .prefs-main > .page-index { top: -16px; }
[data-fold~="phone"] .prefs-sheet[data-solo] .prefs-main { grid-column: 1; grid-row: 2 / -1; }
[data-fold~="phone"] .prefs-sheet .prefs-main .prefs-pagetitle { display: grid; gap: 6px; padding-right: 0; }
[data-fold~="phone"] .prefs-sheet .prefs-main .prefs-pagetitle > * { max-width: none; width: auto; }
@@ -29,6 +29,13 @@ describe("the settings sheet on a phone", () => {
expect(rule(S + " .prefs-main")).toMatch(/grid-column:\s*1/);
});
it("puts the close button in the title row, at the right", () => {
const actions = rule(S + " .prefs-actions");
expect(actions).toMatch(/grid-column:\s*1/);
expect(actions).toMatch(/grid-row:\s*1/);
expect(actions).toMatch(/justify-self:\s*end/);
});
it("turns the category list into a strip that scrolls sideways without a scrollbar", () => {
const nav = rule(S + " .prefs-nav");
expect(nav).toMatch(/display:\s*flex/);
+5 -1
View File
@@ -30,6 +30,7 @@
[data-fold~="phone"] .flow .call[data-approval] .apv-ft .apv-more { display: block; order: 3; grid-column: 1 / -1; justify-self: start; min-height: 32px; padding: 0; border: 0; background: none; color: var(--link); font: var(--w-reg) var(--fs-meta)/1 var(--ui); cursor: pointer; }
.turntools .turn-more { display: none; }
.btn.stop .ic-pause { display: none; }
[data-fold~="phone"] .compose .composecaps { display: none; }
[data-fold~="phone"] .compose .turntools { flex-wrap: wrap; align-items: center; gap: 6px; row-gap: 8px; }
[data-fold~="phone"] .compose .turntools > .studio-attach:first-child { box-sizing: border-box; width: 32px; height: 32px; padding: 0; border: 1px solid var(--border); border-radius: 50%; background: transparent; color: var(--muted); }
@@ -47,8 +48,11 @@
[data-fold~="phone"] .compose .turntools[data-more] > :not(:first-child, .studio-model-group, .turn-more) { display: flex; }
[data-fold~="phone"] .compose .go { align-self: flex-start; gap: 6px; }
[data-fold~="phone"] .compose .go .btn.send, [data-fold~="phone"] .compose .go .btn.stop { box-sizing: border-box; width: 36px; height: 36px; min-width: 0; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center; }
[data-fold~="phone"] .compose .go .btn.send:not(:disabled) { background: var(--accent); color: var(--accent-fg); }
[data-fold~="phone"] .compose .go .btn.send { background: var(--accent); color: var(--accent-fg); }
[data-fold~="phone"] .compose .go .btn.stop { background: var(--overlay); color: var(--text); }
[data-fold~="phone"] .compose .go .btn > span:not(.ic) { display: none; }
[data-fold~="phone"] .compose .go .btn svg { width: 15px; height: 15px; stroke-width: 1.45; }
[data-fold~="phone"] .compose .btn.stop .ic-stop { display: none; }
[data-fold~="phone"] .compose .btn.stop .ic-pause { display: inline; }
[data-fold~="phone"] .compose .turntools > :first-child { order: 0; }
[data-fold~="phone"] .compose .turntools[data-more]::after { content: ""; order: 3; flex: 0 0 100%; height: 0; }
@@ -119,13 +119,15 @@ describe("the composer's tool row on a phone", () => {
expect(rule(row + " > .turn-more")).toMatch(/display:\s*inline-grid/);
});
it("makes send a 36px accent circle, and stop the same size", () => {
it("makes send a 36px accent circle, and stop the same size with the pause mark", () => {
const both = rule('[data-fold~="phone"] .compose .go .btn.send, [data-fold~="phone"] .compose .go .btn.stop');
expect(both).toMatch(/width:\s*36px/);
expect(both).toMatch(/height:\s*36px/);
expect(both).toMatch(/border-radius:\s*50%/);
expect(rule('\n[data-fold~="phone"] .compose .go .btn.send:not(:disabled)')).toMatch(/background:\s*var\(--accent\)/);
expect(rule('\n[data-fold~="phone"] .compose .go .btn.send')).toMatch(/background:\s*var\(--accent\)/);
expect(rule('\n[data-fold~="phone"] .compose .go .btn.stop')).toMatch(/background:\s*var\(--overlay\)/);
expect(rule('[data-fold~="phone"] .compose .btn.stop .ic-stop')).toMatch(/display:\s*none/);
expect(rule(".btn.stop .ic-pause")).toMatch(/display:\s*none/);
});
it("drops the keyboard hints, which a touch screen has no keys for", () => {
@@ -18,9 +18,10 @@ describe("the settings search results", () => {
expect(found).toMatch(/width:\s*auto/);
});
it("cover the whole column, leaving room above the results", () => {
it("cover the whole column, leaving the header row's height empty and the close button above them", () => {
expect(found).toMatch(/top:\s*0/);
expect(found).toMatch(/padding:\s*58px /);
expect(studio).toMatch(/\.prefs-hd \{ min-height: 58px/);
expect(rule(':root:not([data-fold~="phone"]) .prefs-actions')).toMatch(/z-index:\s*6/);
});
});
+46 -43
View File
@@ -1,12 +1,3 @@
.prefs.prefs-screen { inset: 0; display: flex; flex-direction: column; align-items: stretch; padding: 0; backdrop-filter: none; -webkit-backdrop-filter: none; }
.prefs-screen > .prefs-sheet { flex: 1; min-height: 0; width: 100%; height: auto; border: 0; border-radius: 0; box-shadow: none; }
.prefs-windowbar { flex: none; display: flex; align-items: center; justify-content: space-between; min-height: var(--chrome-h, 48px); padding: 0 12px; border-bottom: 1px solid var(--hair); background: var(--page); -webkit-app-region: drag; }
.prefs-back { display: inline-flex; align-items: center; gap: 8px; min-height: 32px; padding: 0 10px; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--muted); font: var(--w-med) var(--fs-ui)/1.4 var(--ui); cursor: pointer; -webkit-app-region: no-drag; }
.prefs-back:hover:not(:disabled) { background: var(--overlay); color: var(--text); }
.prefs-back:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.prefs-back:disabled { opacity: .5; cursor: default; }
.prefs-windowbar .winctl { margin-left: auto; }
[data-titlebar="app"][data-platform="darwin"] .prefs-windowbar { padding-left: var(--lights-w, 80px); }
.prefs-sheet { grid-template-columns: 240px minmax(0, 1fr); border-radius: var(--r-lg); background: var(--surface); }
.prefs-hd h1 { padding: 14px 18px 10px; background: var(--page); font: var(--w-semi) var(--fs-lead)/1.3 var(--ui); letter-spacing: 0; }
.prefs-sheet > .prefs-hd > .prefs-find {
@@ -19,37 +10,30 @@
.prefs-nav button { height: 30px; min-height: 30px; padding: 0 10px; gap: 9px; border-radius: var(--r-sm); color: var(--muted); font: var(--w-reg) var(--fs-ui) var(--ui); }
.prefs-nav button[aria-selected="true"] { background: var(--overlay); color: var(--text); font-weight: var(--w-reg); }
.prefs-nav button .nv { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint); }
.prefs-main { padding: 24px 24px 48px; background: var(--surface); }
.prefs-pagetitle { display: flex; flex-direction: column; margin: 0 0 16px; gap: 6px; max-width: none; align-items: flex-start; }
.prefs-pagetitle h2 { font: var(--w-semi) var(--fs-display)/1.3 var(--ui); }
.prefs-pagetitle .desc { display: block; font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); color: var(--muted); }
.prefs-pagetitle .count { white-space: nowrap; }
.prefs-col { width: 100%; max-width: none; margin: 0; min-width: 0; }
.prefs-card { box-sizing: border-box; width: 100%; margin: 0 0 16px; padding: 18px 20px 8px; border: 1px solid var(--hair); border-radius: var(--r-lg); background: var(--raised); overflow: visible; }
.prefs-card > header { padding: 0; margin: 0 0 12px; }
.prefs-card > header h3 { margin: 0; font: var(--w-semi) var(--fs-title)/1.4 var(--ui); }
.prefs-card > header p { display: block; margin: 4px 0 0; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
.prefs-main { padding: 18px 24px; background: var(--surface); }
.prefs-pagetitle { margin: 0 0 12px; gap: 10px; max-width: none; align-items: baseline; }
.prefs-pagetitle h2 { font: var(--w-semi) var(--fs-title)/1.3 var(--ui); }
.prefs-pagetitle .desc { font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); color: var(--faint); }
.prefs-col { max-width: 880px; margin-inline: auto; }
.prefs-card { margin: 0 0 14px; padding: 12px 18px 4px; border: 1px solid var(--hair); border-radius: var(--r-lg); background: var(--raised); overflow: visible; }
.prefs-card > header { padding: 0; margin: 0 0 4px; }
.prefs-card > header h3 { margin: 0; font: var(--w-semi) var(--fs-ui)/1.4 var(--ui); }
.prefs-card > header p { display: none; }
.prefs-card > .grp { margin: 0; padding: 10px 0; border: 0; border-top: 1px solid var(--hair); border-radius: 0; background: none; box-shadow: none; column-gap: 16px; }
.prefs-card > .grp .grp-hd :is(h3, h4) { font: var(--w-med) var(--fs-ui)/1.4 var(--ui); color: var(--text); }
.prefs-card > .grp .grp-hd h3 { font: var(--w-med) var(--fs-ui)/1.4 var(--ui); color: var(--text); }
.prefs-card > .grp .hint { margin: 2px 0 0; font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); color: var(--faint); max-width: none; }
.prefs-card > .grp .apply { margin: 2px 0 0; font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); color: var(--faint); }
.prefs-card .seg { padding: 2px; gap: 2px; border-radius: var(--r-sm); background: var(--overlay); }
.prefs-card .seg button { height: 26px; min-height: 26px; padding: 0 10px; border-radius: var(--r-xs); font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted); }
.prefs-card .seg button[aria-pressed="true"], .prefs-card .seg button[aria-checked="true"] { background: var(--raised); color: var(--text); font-weight: var(--w-med); box-shadow: 0 1px 2px rgb(0 0 0 / .07); }
.prefs-card select { width: 140px; height: 30px; padding: 0 30px 0 10px; overflow: hidden; white-space: nowrap; border-radius: var(--r-sm); border: 1px solid var(--border); background-color: var(--surface); font: var(--w-reg) var(--fs-ui) var(--ui); }
.prefs-card select { width: 140px; height: 30px; padding: 0 10px; border-radius: var(--r-sm); border: 1px solid var(--border); background-color: var(--surface); font: var(--w-reg) var(--fs-ui) var(--ui); }
.prefs-card select.peerfield { width: 100%; text-overflow: ellipsis; white-space: nowrap; }
.prefs-card .addp select { width: 100%; height: 32px; }
.prefs-card .addp .unit-field { height: 32px; }
.prefs-card .addp .ml-group { container: model-fields / inline-size; font-size: var(--fs-meta); }
.prefs-card .addp .ml-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.prefs-card .addp .ml-row .me-name { grid-column: 1 / -1; }
.prefs-card .addp .mp-row { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.prefs-card .addp .ml-row > select { font-size: var(--fs-meta); }
.prefs-card .addp .mp-source-fields { grid-template-columns: repeat(auto-fit, minmax(min(100%, 22em), 1fr)); }
@container model-fields (width < 74em) { .prefs-card .addp .mp-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@container model-fields (width < 45em) { .prefs-card .addp .ml-row { grid-template-columns: minmax(0, 1fr); } }
.prefs-col .prefs-card > .grp .grp-hd :is(h3, h4) { font-size: var(--fs-ui); font-weight: var(--w-med); }
.prefs-col .prefs-card > .grp .grp-hd h3 { font-size: var(--fs-ui); font-weight: var(--w-med); }
.prefs-main { container: prefsmain / inline-size; }
@container prefsmain (min-width: 860px) {
.prefs-col[data-cols="2"] { max-width: 1400px; column-count: 2; column-gap: 16px; }
.prefs-col[data-cols="2"] > .prefs-card { break-inside: avoid; }
}
.prefs-card > .srow, .grp[data-setting="global-shortcut"] .srow { display: flex; align-items: center; gap: 16px; padding: 10px 0; border-top: 1px solid var(--hair); }
.srow-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.srow-name { color: var(--text); font: var(--w-med) var(--fs-ui)/1.4 var(--ui); }
@@ -74,7 +58,8 @@
.prefs-col .prefs-card .seg button { font-weight: var(--w-reg); }
.prefs-col .prefs-card .seg button[aria-pressed="true"], .prefs-col .prefs-card .seg button[aria-checked="true"] { font-weight: var(--w-med); }
.prefs-main { grid-row: 1 / span 3; }
.prefs-pagetitle { padding-right: 0; }
.prefs-actions { grid-row: 1; position: absolute; top: 10px; right: 14px; z-index: 6; padding: 0; }
.prefs-pagetitle { padding-right: 150px; }
.prefs-hd h1 { padding: 14px 18px 10px; }
.prefs-sheet > .prefs-hd > .prefs-find { margin: 0 10px 8px; width: calc(100% - 20px); }
.prefs-nav { padding: 0 10px 14px; gap: 2px; }
@@ -118,7 +103,7 @@
.ext-add-menu small { color: var(--faint); font: var(--w-med) var(--fs-micro) var(--ui); }
.grp[data-setting="ext-runtime"] { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 10px 2px; border: 0; background: none; box-shadow: none; }
.grp[data-setting="ext-runtime"] .grp-hd { order: 2; margin: 0; }
.grp[data-setting="ext-runtime"] .grp-hd :is(h3, h4) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.grp[data-setting="ext-runtime"] .grp-hd h3 { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.grp[data-setting="ext-runtime"] .hint { order: 1; flex: 1; min-width: 240px; margin: 0; color: var(--faint); font: var(--w-med) var(--fs-meta)/1.6 var(--ui); }
.grp[data-setting="ext-runtime"] .grp-items { order: 3; flex-basis: 100%; }
.grp[data-setting="ext-runtime"] .apply { display: none; }
@@ -267,6 +252,7 @@
.rmthost-menu > button { min-height: 30px; padding: 4px 10px; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--text); font: var(--w-med) var(--fs-ui) var(--ui); text-align: start; white-space: nowrap; cursor: pointer; }
.rmthost-menu > button:hover:not(:disabled) { background: var(--overlay); }
.rmthost-menu > button[data-danger] { color: var(--err-ink); }
.grp-link { padding: 0; }
.grp-row {
display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 14px; box-sizing: border-box;
border: 0; border-radius: inherit; background: transparent; color: var(--text); text-align: start; cursor: pointer;
@@ -277,19 +263,28 @@
.grp-row-t small { overflow: hidden; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); white-space: nowrap; text-overflow: ellipsis; }
.grp-row > .now { flex: none; color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); }
.grp-row > .studio-icon { flex: none; width: 14px; height: 14px; color: var(--faint); }
.prefs-col[data-deep] :not(.deep-page, .deep-page *, :has(.deep-page)) { display: none !important; }
.prefs-col[data-deep] :is(.prefs-card, .grp):has(.deep-page) { padding: 0; border: 0; background: none; box-shadow: none; }
.deep-page { display: flex; flex-direction: column; gap: 12px; animation: m-panel var(--t-panel) var(--ease-out, ease-out); }
.deep-crumb { display: flex; align-items: center; gap: 6px; color: var(--faint); font: var(--w-med) var(--fs-meta) var(--ui); }
.deep-crumb button { padding: 0; border: 0; background: none; color: var(--muted); font: inherit; cursor: pointer; }
.deep-crumb button:hover { color: var(--text); text-decoration: underline; }
.deep-crumb b { color: var(--text); font-weight: var(--w-semi); }
@media (prefers-reduced-motion: reduce) { .deep-page { animation: none; } }
.prefs-sheet[data-solo] { grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr); }
.prefs-sheet[data-solo] .prefs-find { display: none; }
.prefs-sheet[data-solo]::before, .prefs-sheet[data-solo]::after { display: none; }
.prefs-sheet[data-solo] .prefs-hd h1 { border-bottom: 1px solid var(--hair); }
.prefs-sheet[data-solo] .prefs-hd h1 { grid-column: 1; grid-row: 1; padding: 16px 22px 8px; font-size: var(--fs-title); }
.prefs-sheet[data-solo] .prefs-actions { grid-column: 2; grid-row: 1; border-bottom: 1px solid var(--hair); background: none; }
.prefs-sheet[data-solo] .prefs-main { grid-column: 1 / -1; grid-row: 2; }
.prefs-sheet[data-solo] .prefs-col { max-width: none; }
.ext-md { display: grid; grid-template-columns: minmax(300px, 400px) minmax(0, 1fr); gap: 16px; align-items: start; }
.ext-md-list { min-width: 0; }
.ext-md-list .grp { margin: 0; padding: 0 0 8px; border: 0; border-radius: 0; background: none; box-shadow: none; }
.ext-md-list .grp-hd { margin: 10px 0 2px; padding: 0 10px; }
.ext-md-list .grp-hd :is(h3, h4) { color: var(--muted); font: var(--w-semi) var(--fs-meta) var(--ui); }
.ext-md-list .grp-hd h3 { color: var(--muted); font: var(--w-semi) var(--fs-meta) var(--ui); }
.ext-md-list .grp-hd .now { padding: 0; background: none; color: var(--faint); }
.ext-md-list .srv > :not(summary):not(.srv-hd) { display: none; }
.ext-md-list .srv > summary, .ext-md-list .srv > .srv-hd, .ext-md-list .skrow { cursor: pointer; border-radius: var(--r-md); }
@@ -368,6 +363,9 @@
.ulegend i[data-s="miss"], .ustack .utip i[data-s="miss"] { background: var(--chart-miss); }
.ustack .utip .r { display: flex; justify-content: space-between; gap: 12px; color: var(--muted); font: var(--w-med) var(--fs-micro) var(--ui); }
.ustack .utip .r b { color: var(--text); font-weight: var(--w-semi); }
.prefs { padding: 24px; }
.prefs-sheet { width: min(1360px, 100%); height: min(900px, 100%); }
.srow-ctl .unit-field input { width: 72px; }
.srow-val { color: var(--muted); font: var(--w-reg) var(--fs-meta) var(--ui); max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.srow-val > * { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.prefs-card > .grp[data-inline] { display: grid; grid-template-columns: minmax(0, 1fr) fit-content(320px); align-items: center; column-gap: 16px; row-gap: 2px; }
@@ -390,8 +388,7 @@
.prefs-icon:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.prefs-icon .studio-icon { width: 16px; height: 16px; }
.prefs-card .unit-field { align-items: center; gap: 6px; height: 28px; padding: 0 10px 0 2px; border: 1px solid var(--hair); border-radius: var(--r-sm); background: transparent; }
.prefs-card .unit-field input { flex: 1; width: 0; text-align: right; font: var(--w-reg) var(--fs-meta) var(--mono); }
.prefs-card :is(.srow-ctl, .threshold-control) .unit-field input { flex: none; width: 64px; }
.prefs-card .unit-field input { width: 64px; text-align: right; font: var(--w-reg) var(--fs-meta) var(--mono); }
.prefs-card .threshold-control { width: auto; justify-content: flex-start; }
.prefs-card .threshold-control .unit-field { flex: none; }
.prefs-card .unit-field > i { padding: 0; border: 0; color: var(--faint); font: var(--w-reg) var(--fs-meta) var(--ui); font-style: normal; }
@@ -409,12 +406,13 @@
.usage .urow-t .n { font: var(--w-semi) var(--fs-meta) var(--mono); }
.usage .urow-t .v { font: var(--w-reg) var(--fs-meta) var(--mono); }
.usage .utwo .ucard + .ucard .ucard-h h3 { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted); }
.deep-page .grp-hd h3 { font: var(--w-semi) var(--fs-title)/1.3 var(--ui); }
.memdetail .facts h4 { font: var(--w-med) var(--fs-micro) var(--ui); }
.memhead .btn { font: var(--w-reg) var(--fs-meta) var(--ui); }
.ext-bar .mkt-tabs button { font: var(--w-reg) var(--fs-meta) var(--ui); }
.ext-bar .mkt-tabs button[aria-selected="true"] { font-weight: var(--w-med); }
.ext-bar .ext-add-btn { font: var(--w-reg) var(--fs-meta) var(--ui); }
.ext-md-list .grp-hd :is(h3, h4) { font: var(--w-med) var(--fs-meta) var(--ui); }
.ext-md-list .grp-hd h3 { font: var(--w-med) var(--fs-meta) var(--ui); }
.ext-md-list .grp-hd .now { font: var(--w-reg) var(--fs-meta) var(--ui); }
.ext-md-list .srv > summary b, .ext-md-list .srv > .srv-hd b, .ext-md-list .skrow .nm { font-weight: var(--w-med); }
.ext-detail-hd > b { font: var(--w-semi) var(--fs-title) var(--ui); }
@@ -438,20 +436,24 @@
.changed-keys { display: flex; flex-direction: column; gap: 4px; margin: 4px 0 0; padding: 8px 0 2px; border-top: 1px solid var(--hair); list-style: none; }
.changed-keys li { color: var(--muted); font: var(--w-reg) var(--fs-meta) var(--mono); }
.prefs-card > .grp { grid-template-columns: minmax(0, 1fr) fit-content(320px); }
.prefs-main > :is(.prefs-pagetitle, .page-index) { max-width: none; margin-inline: 0; }
.prefs-main > .page-index { position: sticky; top: -24px; z-index: 2; padding-block: 12px; background: var(--surface); border-bottom: 1px solid var(--hair); }
.prefs-main > :is(.prefs-pagetitle, .page-index) { max-width: 880px; margin-inline: auto; }
@container prefsmain (min-width: 860px) {
.prefs-main:has(.prefs-col[data-cols="2"]) > :is(.prefs-pagetitle, .page-index) { max-width: 1400px; }
}
.apply[data-usual] { display: none; }
.prefs-card > .grp > .apply { display: none; }
.scope-tag { display: inline-grid; place-items: center; height: 18px; padding: 0 7px; border-radius: var(--r-pill); background: var(--overlay); color: var(--muted); font: var(--w-reg) var(--fs-micro)/1 var(--ui); white-space: nowrap; }
.prefs-card > .grp { padding-block: 6px; min-height: 36px; box-sizing: border-box; }
.prefs-card > .srow { padding-block: 6px; min-height: 36px; box-sizing: border-box; }
.prefs-card > .grp > .hint, .srow-desc { overflow-wrap: anywhere; white-space: normal; }
.prefs-card > .grp > .hint, .srow-desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.prefs-card > .grp > .hint { margin: 0; line-height: 1.4; }
.prefs-card > .grp > .grp-hd { display: flex; align-items: center; gap: 8px; }
.prefs-pagetitle { flex-wrap: wrap; }
.prefs-pagetitle > .usual { margin: 0; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); }
.srow-name { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; }
.prefs-pagetitle > .usual { flex-basis: 100%; margin: 0; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); }
.srow-name { display: flex; align-items: center; gap: 8px; }
.srow-meta > .apply:not([data-usual]) { display: none; }
.prefs-strip { position: fixed; inset: 0 0 auto; height: var(--chrome-h, 39px); z-index: 50; background: color-mix(in srgb, var(--page) 40%, transparent); -webkit-backdrop-filter: blur(14px) saturate(.92); backdrop-filter: blur(14px) saturate(.92); pointer-events: none; }
:root[data-fold~="scene"] .prefs-strip { display: none; }
.usage .ucharts-spend { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.usage .utable th { font: var(--w-med) var(--fs-micro) var(--ui); }
.usage .utable th, .usage .utable td { padding-block: 9px; }
@@ -477,3 +479,4 @@
.page-index .pi-label { margin-right: 2px; color: var(--faint); font: var(--w-reg) var(--fs-meta) var(--ui); }
.page-index .spill { height: 26px; }
:root:not([data-fold~="phone"]) .prefs-found { top: 0; left: 240px; right: 0; bottom: 0; width: auto; max-height: none; padding: 58px 22px 14px; border: 0; border-left: 1px solid var(--hair); border-radius: 0 var(--r-lg) var(--r-lg) 0; background: var(--surface); box-shadow: none; }
:root:not([data-fold~="phone"]) .prefs-actions { position: relative; z-index: 6; }
+5 -13
View File
@@ -39,25 +39,17 @@
.railfoot { flex: none; display: flex; flex-direction: column; gap: 0; padding: 0 10px 10px; border: 0; background: transparent; }
.railfoot::before { content: ""; display: block; height: 1px; margin: 6px 4px; background: var(--hair); }
.studio-tools-row { display: flex; align-items: center; min-height: 32px; gap: 2px; }
.studio-tools-row > button { display: inline-flex; align-items: center; gap: 10px; height: 32px; padding: 0 10px; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.4 var(--ui); cursor: pointer; }
.studio-tools-row > .studio-settings { flex: 1; min-width: 0; text-align: left; }
.studio-tools-row > .studio-tools-toggle { flex: none; width: 32px; padding: 0; justify-content: center; }
.studio-tools-row > button:hover, .studio-tools-toggle[aria-expanded="true"] { background: var(--overlay); color: var(--text); }
.studio-tools-row > button:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.studio-tools-row .upd-dot { margin-left: auto; }
.studio-quicknav { position: fixed; z-index: 80; display: flex; flex-direction: column; gap: 2px; box-sizing: border-box; width: 260px; max-width: calc(100vw - 24px); max-height: calc(100vh - 24px); overflow-y: auto; margin: 0; padding: 6px; border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--float); box-shadow: var(--shadow-float); }
.studio-quicknav[hidden] { display: none; }
.studio-quicknav { display: flex; align-items: center; gap: 2px; margin: 0; padding: 0 2px; }
.studio-quicknav > .qspace { flex: 1; }
.studio-quicknav > button {
position: relative; width: 100%; min-height: 36px; padding: 8px 10px; flex: none; box-sizing: border-box;
display: flex; align-items: center; gap: 10px; border: 0; border-radius: var(--r-sm);
background: transparent; color: var(--text); font: var(--w-reg) var(--fs-ui)/1.4 var(--ui); text-align: left; cursor: pointer;
position: relative; width: 28px; height: 28px; padding: 0; flex: none; box-sizing: border-box;
display: inline-grid; place-items: center; border: 0; border-radius: var(--r-sm);
background: transparent; color: var(--muted); cursor: pointer;
transition: background var(--m-line), color var(--m-line), transform var(--t-press);
}
.studio-quicknav > button:hover { background: var(--overlay); color: var(--text); }
.studio-quicknav > button:active { transform: scale(.94); }
.studio-quicknav > button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.studio-quicknav > button > :is(small, kbd) { margin-left: auto; color: var(--faint); font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); }
.studio-quicknav > button :is(svg, .studio-icon) { width: 16px; height: 16px; stroke-width: 1.45; }
.studio-quicknav :is(.qdot, .upd-dot) { position: absolute; top: 3px; right: 3px; width: 7px; height: 7px; box-sizing: border-box; border-radius: 50%; border: 1.5px solid var(--page); box-shadow: none; }
.studio-quicknav .qdot[data-tone="ok"] { background: var(--ok); }
+2 -2
View File
@@ -2,7 +2,7 @@ import type { Messages } from "./types";
export const LOOK: Messages = {
"look.a_few_of_name_s": { zh: "{name} 有几项没生效", en: "A few of {name}'s entries did not take" },
"look.advanced_look": { zh: "高级外观", en: "Advanced appearance" },
"look.at_the_current_window_size": { zh: "当前窗口尺寸下,此壁纸只有上下会被裁剪,左右正好填满,因此横向焦点无法调整。", en: "At the current window size this wallpaper is cropped only top and bottom and fills the width exactly, so the horizontal focus cannot be moved." },
"look.at_the_current_window_size_2": { zh: "当前窗口尺寸下,此壁纸只有左右会被裁剪,上下正好填满,因此纵向焦点无法调整。", en: "At the current window size this wallpaper is cropped only left and right and fills the height exactly, so the vertical focus cannot be moved." },
"look.back_to_the_default_typeface": { zh: "恢复默认字体", en: "Back to the default typeface" },
@@ -41,7 +41,7 @@ export const LOOK: Messages = {
"look.if_the_window_keeps_your": { zh: "窗口一直占用显卡、或界面花屏时,先试「视觉效果:节能」,不行再改为「仅软件渲染」:改用 CPU 绘制,显卡不再参与。设置保存后,需退出并重新打开 Studio 才会生效。", en: "If the window keeps your GPU busy or draws garbage, try Visual effects: Reduced first; if that is not enough, choose Software only: the CPU draws the window and the GPU stays out of it. The choice is saved now and applies after you quit and reopen Studio." },
"look.illustrated_and_background_themes_you": { zh: "插画与背景主题,可随时切回基础主题", en: "Illustrated and background themes; you can return to a foundation theme at any time" },
"look.import_theme": { zh: "导入主题…", en: "Import theme…" },
"look.import_themes_custom_colours": { zh: "导入主题、自定义颜色", en: "Import themes, custom colours" },
"look.import_wallpaper_engine": { zh: "导入 Wallpaper Engine…", en: "Import Wallpaper Engine…" },
"look.imported_name": { zh: "已导入「{name}」。", en: "Imported “{name}”." },
"look.in_effect_now": { zh: "立即生效", en: "In effect now" },
+4 -2
View File
@@ -253,9 +253,11 @@ export const MODELS: Messages = {
"models.with_no_level_picked_the": { zh: "未选档位时按继承处理。", en: "With no level picked, the model inherits." },
"models.you_can_write_proxy_password": { zh: "可填写 ${PROXY_PASSWORD}", en: "You can write ${PROXY_PASSWORD}" },
"models.check_while_the_list_opens": { zh: "打开模型列表时顺便检查连接和余额,不发送生成请求", en: "Check connections and balances when the model list opens; no generation request is sent" },
"models.services_connected_n": { zh: "已接入 {n} 个服务;地址、密钥、模型列表在这里改", en: "{n} services connected; change addresses, keys and model lists here" },
"models.manage_services": { zh: "管理服务", en: "Manage services" },
"models.services": { zh: "服务", en: "Services" },
"models.uses_and_context": { zh: "用途与上下文", en: "Uses and context" },
"models.which_model_for_each_use": { zh: "对话、规划、子代理、看图分别用哪个模型", en: "Which model chat, planning, subagents and image reading each use" },
"models.set_by_use": { zh: "按用途设置", en: "Set by use" },
"models.compacts_from_this_share": { zh: "上下文到多少开始压缩;默认窗口的 {p}%", en: "How full the context gets before it compacts; {p}% of the window by default" },
};
+5 -3
View File
@@ -123,13 +123,15 @@ export const SAFETY: Messages = {
"safety.writing_files_every_write_tool": { zh: "写文件(全部写入工具)", en: "writing files (every write tool)" },
"safety.ask_before_writing_or_running": { zh: "写文件、跑命令前要不要先问", en: "Whether to ask before writing files or running commands" },
"safety.allow_and_deny_by_command": { zh: "按命令、路径写死的允许和拒绝;拒绝规则永远优先", en: "Allow and deny by command or path; a deny rule always wins" },
"safety.edit_rules_n": { zh: "编辑规则({n} 条)", en: "Edit rules ({n})" },
"safety.ask_before_copied_text": { zh: "照抄网页或工具结果里的文字去调用工具前先问你", en: "Ask before a call that copies text from a web page or tool result" },
"safety.which_tools_the_model_sees": { zh: "模型能看到哪些工具", en: "Which tools the model can see" },
"safety.view_and_adjust_n": { zh: "查看与调整({n})", en: "View and adjust ({n})" },
"safety.commands_write_only_here": { zh: "命令只能写工作区、临时目录和工具缓存", en: "Commands can only write to the workspace, temp folders and tool caches" },
"safety.which_program_runs_a_command": { zh: "跑命令用哪个程序", en: "Which program runs a command" },
"safety.when_two_sessions_edit_one": { zh: "两个会话同时改一个项目时怎么处理", en: "What happens when two sessions edit one project" },
"safety.web": { zh: "联网", en: "Web" },
"safety.search_and_fetch_when_needed": { zh: "需要时搜索网页、抓取页面内容", en: "Search the web and fetch pages when needed" },
"safety.details": { zh: "详细设置", en: "Details" },
"safety.what_outside_text_covers": { zh: "调用逐字复制了网页或工具结果里的文字时,Auto 和 YOLO 也先问你。只认用户配置;已授权的 MCP 服务器、改写过或编码过的文字不在其内。无人可问时(无头 YOLO),这类调用会被拒绝而不是执行。修改会重建运行时,任务运行期间无法变更。默认关闭。", en: "When a call copies text word for word from a web page or a tool result, Auto and YOLO ask you first. Only your user config counts; servers you have authorised, and text that was reworded or encoded, are not covered. When nobody can be asked (headless YOLO) such a call is refused instead of run. Changing it rebuilds the runtime and cannot be done while a task runs. Off by default." },
};
+26 -20
View File
@@ -1,7 +1,6 @@
import type { Messages } from "./types";
export const SETTINGS: Messages = {
"settings.back_to_workspace": { zh: "返回工作区", en: "Back to workspace" },
"settings.a_system_notification_when_a": { zh: "一轮结束、等你处理时,系统通知提醒你", en: "A system notification when a turn ends or something waits for you" },
"settings.account": { zh: "账号", en: "Account" },
"settings.account_and_about": { zh: "账号与关于", en: "Account and About" },
@@ -39,7 +38,7 @@ export const SETTINGS: Messages = {
"settings.data": { zh: "数据", en: "Data" },
"settings.default_0_nothing_is_folded": { zh: "默认 0:不自动收起,打开的会话都保留。设成一个数字后,打开的会话超过它时,会收起最久没动的空闲会话;对话仍在列表里,随时可以重新打开。保留得越多,占用的内存越多。", en: "Default 0: nothing is folded away and every open session stays. Set a number and, once more sessions than that are open, the idle one untouched longest is folded away; its conversation stays in the list and can be opened again. The more you keep, the more memory it uses." },
"settings.detailed": { zh: "详细", en: "Detailed" },
"settings.details": { zh: "细项", en: "Details" },
"settings.door_does_not_offer_model": { zh: "{door} 不提供 {model},请在下方选择该来源支持的模型。", en: "{door} does not offer {model}. Choose one it does provide below." },
"settings.drafts_a_plan_read_only": { zh: "仅生成计划,不写入", en: "Drafts a plan, read-only" },
"settings.empty_is_automatic_1_to": { zh: "留空表示自动;可填 1 到 {max}", en: "Empty is automatic; 1 to {max}" },
@@ -182,7 +181,7 @@ export const SETTINGS: Messages = {
"settings.tool_approvals": { zh: "工具批准", en: "Tool approvals" },
"settings.type_this_code_into_the": { zh: "在浏览器里打开的页面输入这串代码。没自动打开就手动访问 {uri}", en: "Type this code into the page your browser opened. If none opened, go to {uri}" },
"settings.use_send_now_in_the": { zh: "可在待发送队列中选择“立即发送”来停止当前回复并开始新一轮。暂停、编辑和取回仍然可用。", en: "Use Send now in the queue to stop the current response and start a new turn. Hold, edit, and take back remain available." },
"settings.where_you_are": { zh: "当前位置", en: "Where you are" },
"settings.changes_apply_from_the_next_turn": { zh: "启用、停用、安装或更新后,从下一轮开始生效;正在跑的这一轮不受影响。改了扩展自己的代码,用「重载运行时」重新读取。", en: "Enabling, disabling, installing or updating takes effect from the next turn; the one running is unaffected. After editing an extension's own code, use Reload runtime to read it again." },
"settings.used_for_posting_to_the": { zh: "用于社区发帖与崩溃问题跟进。与模型 API key 无关 —— 登录不会上传你的对话、代码或密钥。", en: "Used for posting to the community and following up crashes. Unrelated to your model API keys — signing in uploads none of your conversations, code or secrets." },
"settings.warns_when_the_drive_holding": { zh: "项目或会话所在的磁盘剩余空间很少时提醒(不足 5 GB 或不足 3%),同一块盘长时间不重复提醒;只提醒,不会清理任何文件", en: "Warns when the drive holding your project or sessions runs short (under 5 GB or 3%), and does not repeat for the same drive for hours. It only warns; it never deletes anything." },
@@ -209,40 +208,41 @@ export const SETTINGS: Messages = {
"settings.token_unit": { zh: "令牌", en: "tokens" },
"settings.edit_persona": { zh: "编辑人设", en: "Edit persona" },
"settings.persona_hint": { zh: "写在每个会话开头的身份说明", en: "The identity note written at the start of every session" },
"settings.presets_hint": { zh: "工具、模型、提示词的组合;新任务默认用哪个", en: "A bundle of tools, model and prompt; which one new tasks start with" },
"settings.sessions_send_each_other": { zh: "让一个会话给另一个会话发消息(实验)", en: "Let one session send another a message (experimental)" },
"settings.persona_and_presets": { zh: "人设与预设", en: "Persona and presets" },
"settings.manage_presets": { zh: "管理预设", en: "Manage presets" },
"settings.which_currency_costs_show": { zh: "费用用什么币种显示", en: "Which currency costs are shown in" },
"settings.days_before_archiving": { zh: "多少天没动的会话自动归档", en: "How many idle days before a conversation is archived" },
"settings.migrated_sessions_in_the_sidebar": { zh: "旧版迁移过来的会话是否出现在侧栏", en: "Whether sessions migrated from 1.x show in the sidebar" },
"settings.where_session_records_go": { zh: "会话记录写到哪里", en: "Where session records are written" },
"settings.open_memory": { zh: "打开记忆", en: "Open memory" },
"settings.what_it_remembers": { zh: "它记住的东西:星系、遗忘、整理", en: "What it remembers: constellations, forgetting, tidying" },
"settings.view_usage": { zh: "查看用量", en: "View usage" },
"settings.usage_hint": { zh: "输入、输出、费用,按天和按模型看", en: "Input, output and cost, by day and by model" },
"settings.manage_storage": { zh: "管理", en: "Manage" },
"settings.storage_hint": { zh: "会话、索引、缓存占了多少空间", en: "How much space sessions, indexes and caches take" },
"settings.export_and_import": { zh: "导出与导入…", en: "Export and import…" },
"settings.config_in_one_file": { zh: "把模型、扩展、记忆等配置加密导出成一个文件", en: "Export models, extensions and memory to one encrypted file" },
"settings.back_up_to_the_cloud": { zh: "登录后把配置备份到云端", en: "Back up your configuration to the cloud once signed in" },
"settings.size_of_requests_sent": { zh: "发给模型服务商的请求有多大", en: "How large the requests sent to model providers are" },
"settings.other_machines": { zh: "其他机器", en: "Other machines" },
"settings.network_hint": { zh: "代理和网络诊断", en: "Proxy and network diagnostics" },
"settings.remote_hint": { zh: "连接其他机器上的 Reasonix", en: "Connect to Reasonix on other machines" },
"settings.remote_machines": { zh: "远程机器", en: "Remote machines" },
"settings.phone_hint": { zh: "用手机访问这台电脑上的会话,含通知设置", en: "Reach this computer's sessions from a phone, with notification settings" },
"settings.set_up_phone_access": { zh: "设置手机访问", en: "Set up phone access" },
"settings.account_hint": { zh: "社区发帖、崩溃跟进时用;与模型密钥无关", en: "Used for community posts and crash follow-up; unrelated to model keys" },
"settings.sign_in": { zh: "登录", en: "Sign in" },
"settings.account_info": { zh: "账号信息", en: "Account" },
"settings.version_and_diagnostics": { zh: "版本与诊断", en: "Version and diagnostics" },
"settings.versions_hint": { zh: "当前版本、可用更新、回退", en: "Current version, available updates, rolling back" },
"settings.versions_and_updates": { zh: "版本与更新", en: "Versions and updates" },
"settings.logs_hint": { zh: "出问题时附上的日志位置", en: "Where the logs to attach when something goes wrong are" },
"settings.trace_hint": { zh: "把执行轨迹导出给开发者排查", en: "Export execution traces for a developer to look into" },
"settings.about_note": { zh: "社区与贡献者、赞助在这里展示,不是开关。", en: "The community, contributors and sponsorship are shown here; they are not switches." },
"settings.look_at": { zh: "查看", en: "View" },
"settings.set_up_trace": { zh: "导出轨迹", en: "Export traces" },
"settings.about": { zh: "关于", en: "About" },
"settings.preview_office_in_workbench": { zh: "在工作台里预览 Word、Excel、PPT", en: "Preview Word, Excel and PowerPoint in the workbench" },
"settings.divider_before_new_answer": { zh: "回到会话时,在新回答前画一条分隔线", en: "A divider before the new answer when you return to a session" },
@@ -251,22 +251,28 @@ export const SETTINGS: Messages = {
"settings.how_long_before_a_page_sleeps": { zh: "网页在后台闲置多久后休眠", en: "How long a background page idles before it sleeps" },
"settings.hint_text_in_the_composer": { zh: "输入框里的提示文字", en: "The hint text inside the composer" },
"settings.buttons_shown_on_row_hover": { zh: "会话行悬停时显示的按钮", en: "Buttons shown when you hover a session row" },
"settings.which_entries_the_left_rail_shows": { zh: "选择图标栏与侧栏工具卡片显示的入口和顺序", en: "Choose the entries and order for the icon rail and sidebar tools card" },
"settings.which_entries_the_left_rail_shows": { zh: "左侧导航显示哪些入口、什么顺序", en: "Which entries the left rail shows, and in what order" },
"settings.finished_sessions_stay_in_running": { zh: "会话结束后在进行中列表里再留多久", en: "How long a finished session stays in the running list" },
"settings.theme_card": { zh: "主题", en: "Theme" },
"settings.text_card": { zh: "文字", en: "Text" },
"settings.accent_and_overall_tone": { zh: "强调色和整体色调", en: "Accent colour and overall tone" },
"settings.chat_background_image_or_video": { zh: "对话背景图片或视频", en: "The conversation's background image or video" },
"settings.choose_wallpaper": { zh: "选择壁纸", en: "Choose wallpaper" },
"settings.frosted_glass_glow_effects": { zh: "毛玻璃、光晕等视觉效果", en: "Frosted glass, glow and other effects" },
"settings.interface_and_code_typeface": { zh: "界面和代码字体", en: "Interface and code typefaces" },
"settings.change": { zh: "更换", en: "Change" },
"settings.overall_text_size": { zh: "整体文字大小", en: "Overall text size" },
"settings.adjust": { zh: "调整", en: "Adjust" },
"settings.weight_of_text": { zh: "文字粗细", en: "Weight of text" },
"settings.contrast_between_text_and_background": { zh: "文字和底色的对比", en: "Contrast between text and background" },
"settings.view_and_edit": { zh: "查看与修改", en: "View and edit" },
"settings.all_shortcuts_incl_global_and_esc": { zh: "所有快捷键,含全局显示 / 隐藏、Esc 停止", en: "All shortcuts, including global show / hide and Esc to stop" },
"settings.adjust_navigation": { zh: "调整导航", en: "Adjust navigation" },
"settings.remember_window_position_and_size": { zh: "记住窗口位置和大小、标题栏样式", en: "Remember window position and size, and the title bar style" },
"settings.process_card": { zh: "过程", en: "Process" },
"settings.files_and_previews_card": { zh: "文件与预览", en: "Files and previews" },
"settings.notices_card": { zh: "提示", en: "Notices" },
"settings.older_turns_fold_to_a_line": { zh: "较早的对话折叠成一行", en: "Earlier turns fold into one line" },
"settings.generated_files_as_clickable_cards": { zh: "生成的文件显示成可以点开的卡片", en: "Generated files appear as cards you can open" },
"settings.tabs_and_navigation_card": { zh: "标签与导航", en: "Tabs and navigation" },
"settings.while_running_card": { zh: "运行中", en: "While running" },
+1 -1
View File
@@ -111,7 +111,7 @@ export const USAGE: Messages = {
"usage.spend": { zh: "支出", en: "Spend" },
"usage.spend_2": { zh: "花费", en: "Spend" },
"usage.start_date": { zh: "开始日期", en: "Start date" },
"usage.subscription_plan_estimated_at_the": { zh: "订阅套餐(估算)", en: "Subscription estimate" },
"usage.subscription_plan_estimated_at_the": { zh: "订阅套餐(按价格折算的估算)", en: "Subscription plan (estimated at the prices above)" },
"usage.subscription_use_priced_at_api": { zh: "按 API 价格估算的订阅用量,不是实际扣费", en: "Subscription use priced at API rates; not a charge" },
"usage.subscription_use_priced_at_api_2": { zh: "订阅用量按 API 价格估算,不是实际扣费,也不计入上面的实付", en: "Subscription use priced at API rates. It is not a charge and is not part of the paid figure above." },
"usage.the_backend_system_one_asks": { zh: "system_one 询问的后端", en: "The backend system_one asks" },
@@ -1,96 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fromHistory, initialState, reduce, type Item, type SessionEvent } from "./session";
import { thoughtStartedAt } from "./say";
beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(1_700_000_000_000); });
afterEach(() => vi.useRealTimers());
const ev = (kind: string, fields: Record<string, unknown> = {}) => ({ kind, ...fields }) as SessionEvent;
const tool = { id: "call-1", name: "bash", issuer: "model" };
const says = (items: Item[]) => items.filter((item): item is Extract<Item, { t: "say" }> => item.t === "say");
const thinking = () => [ev("turn_started"), ev("reasoning", { text: "Inspect first. " })].reduce(reduce, initialState);
describe("one assistant response across interleaved events", () => {
it("stops thinking when tool arguments start, then settles the original card with the message frame", () => {
let state = thinking();
const id = says(state.items)[0].id;
vi.advanceTimersByTime(1200);
state = reduce(state, ev("tool_dispatch", { tool: { ...tool, partial: true } }));
expect(says(state.items)[0]).toMatchObject({ id, done: false, thoughtMs: 1200 });
expect(thoughtStartedAt(id)).toBeUndefined();
vi.advanceTimersByTime(30_000);
state = reduce(state, ev("text", { text: "I will inspect the files." }));
state = reduce(state, ev("message", { reasoning: "Inspect first.", text: "I will inspect the files.", thoughtMs: 1100 }));
expect(says(state.items)).toHaveLength(1);
expect(says(state.items)[0]).toMatchObject({ id, done: true, thoughtMs: 1100, reasoning: "Inspect first." });
state = reduce(state, ev("tool_dispatch", { tool }));
state = reduce(state, ev("tool_result", { tool: { ...tool, output: "files" } }));
state = reduce(state, ev("reasoning", { text: "Now edit." }));
expect(says(state.items)).toHaveLength(2);
expect(says(state.items)[1]).toMatchObject({ done: false, reasoning: "Now edit." });
});
it("keeps the active response across notices, pending input and child tool events", () => {
let state = thinking();
state = reduce(state, ev("notice", { text: "A background task finished." }));
state = reduce(state, ev("__user", { text: "Also check styles", pending: true }));
state = reduce(state, ev("tool_dispatch", { tool: { ...tool, parentId: "worker" } }));
vi.advanceTimersByTime(800);
state = reduce(state, ev("reasoning", { text: "Then compare." }));
expect(says(state.items)).toHaveLength(1);
expect(says(state.items)[0]).toMatchObject({ reasoning: "Inspect first. Then compare.", done: false });
expect(says(state.items)[0].thoughtMs).toBeUndefined();
state = reduce(state, ev("text", { text: "Compared." }));
expect(says(state.items)[0].thoughtMs).toBe(800);
});
it("seals the response at full dispatch even when its message frame was missed", () => {
let state = thinking();
vi.advanceTimersByTime(900);
state = reduce(state, ev("tool_dispatch", { tool }));
expect(says(state.items)[0]).toMatchObject({ done: true, thoughtMs: 900 });
state = reduce(state, ev("tool_result", { tool }));
state = reduce(state, ev("reasoning", { text: "The next round." }));
expect(says(state.items)).toHaveLength(2);
});
it("keeps an explicit zero measurement from the host over a local estimate", () => {
let state = thinking();
vi.advanceTimersByTime(900);
state = reduce(state, ev("message", { reasoning: "Inspect first.", thoughtMs: 0 }));
expect(says(state.items)[0].thoughtMs).toBe(0);
});
});
describe("canonical idle state after an interrupted event stream", () => {
it("seals the thought once without inventing a turn outcome", () => {
let state = thinking();
vi.advanceTimersByTime(2400);
state = reduce(state, ev("__running", { running: false }));
expect(says(state.items)[0]).toMatchObject({ done: true, thoughtMs: 2400 });
expect(state.terminal).toBeNull();
const settled = state;
vi.advanceTimersByTime(1_800_000);
state = reduce(state, ev("__running", { running: false }));
expect(state).toBe(settled);
expect(says(state.items)[0].thoughtMs).toBe(2400);
});
it("also seals a stream whose turn-start event was missed", () => {
let state = reduce(initialState, ev("reasoning", { text: "Already streaming." }));
expect(state.running).toBe(false);
state = reduce(state, ev("__running", { running: false }));
expect(says(state.items)[0].done).toBe(true);
});
it("does not close an ongoing response or start a clock on history", () => {
const state = thinking();
expect(reduce(state, ev("__running", { running: true }))).toBe(state);
const history = fromHistory([{ role: "assistant", content: "Done.", reasoning: "Checked.", thoughtMs: 1600 }]);
const restored = reduce(state, { kind: "__restore", ...history });
const idle = reduce(restored, ev("__running", { running: false }));
expect(says(idle.items)).toHaveLength(1);
expect(says(idle.items)[0]).toMatchObject({ done: true, thoughtMs: 1600 });
expect(thoughtStartedAt(says(idle.items)[0].id)).toBeUndefined();
});
});
+17 -25
View File
@@ -12,37 +12,23 @@ export function thoughtStartedAt(id: string): number | undefined {
return thoughtSince.get(id);
}
function openSayIndex(items: Item[]): number {
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i];
if (item.t === "say") return item.done ? -1 : i;
if (item.t === "user" && !item.pending) return -1;
}
return -1;
}
// A partial tool dispatch ends thinking but its response still owes a message frame.
export function stopThought(items: Item[]): Item[] {
const at = openSayIndex(items);
if (at < 0) return items;
const item = items[at] as Extract<Item, { t: "say" }>;
if (!item.reasoning || item.thoughtMs !== undefined) return items;
const thoughtMs = Math.max(0, Date.now() - (thoughtSince.get(item.id) ?? Date.now()));
thoughtSince.delete(item.id);
const next = items.slice();
next[at] = { ...item, thoughtMs };
return next;
}
const isWaitingLine = (item: Item) => item.t === "user" && !!item.pending;
export function appendText(items: Item[], text: string, field: "text" | "reasoning", source?: string, model?: string): Item[] {
const at = openSayIndex(items);
if (field === "text") items = stopThought(items);
// A line typed while the model is still writing waits in the transcript after
// the open message without being part of it, so the message continues past it.
let at = items.length - 1;
while (at >= 0 && isWaitingLine(items[at])) at--;
const last = items[at];
if (last && last.t === "say" && !last.done) {
const next = items.slice();
// Thinking runs until the first answer token, so the clock is the gap
// between the two streams, not the length of either.
const stop = field === "text" && last.thoughtMs === undefined && last.reasoning;
next[at] = {
...last,
[field]: (last[field] ?? "") + text,
...(stop ? { thoughtMs: Date.now() - (thoughtSince.get(last.id) ?? Date.now()) } : null),
};
return next;
}
@@ -83,10 +69,16 @@ export function sealSay(items: Item[], all = false, cut?: ThoughtCut): Item[] {
// agree. A stream that lost a chunk is therefore repaired here, never
// preserved; an empty field is not content and overwrites nothing.
export function foldMessage(items: Item[], ev: WireEvent): Item[] {
const at = openSayIndex(items);
let at = -1;
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].t === "say" && !(items[i] as Extract<Item, { t: "say" }>).done) {
at = i;
break;
}
}
// The kernel's measure wins over this window's: it is the one the transcript
// keeps, so a reopened turn says the same thing.
const measured = ev.thoughtMs;
const measured = ev.thoughtMs || undefined;
if (at < 0) {
if (!ev.text && !ev.reasoning) return items;
return [...items, { t: "say", id: nextId(), text: ev.text ?? "", reasoning: ev.reasoning, done: true, source: ev.source, thoughtMs: measured }];
+3 -6
View File
@@ -22,7 +22,7 @@ export { currentStep, stepDone, stepLabel };
import { askAnnounced, askEngaged, askFromCall, promptOpen, prompted, sealByReceipt } from "./prompts";
import { stampPlan } from "./plan_card";
import { nameTurnStart, othersSteer } from "./turn_start";
import { appendText, foldMessage, sealSay, stopThought } from "./say";
import { appendText, foldMessage, sealSay } from "./say";
import { nextId } from "./ids";
import { worked } from "./worked";
import { chipLabel, IDLE, RUNNING } from "./chip";
@@ -349,10 +349,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
// /status's own answer, because the record cannot end a turn: the kernel
// commits a text-only round before it decides whether the turn continues,
// so only the kernel saying idle can close one. Equal answers fold to s.
if (ev.kind === "__running") {
const items = ev.running ? s.items : sealSay(s.items, true);
return s.running === ev.running && items === s.items ? s : { ...s, running: ev.running, items };
}
if (ev.kind === "__running") return s.running === ev.running ? s : { ...s, running: ev.running };
// A wait only text or reasoning could end outlived every turn whose first
// packet was a tool call: the retry line and its clock stayed up for the rest
// of the turn, over calls that were running fine.
@@ -403,7 +400,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
case "tool_dispatch":
return ev.tool
? { ...s, doing: ev.tool.name, executions: noteTool(s.executions, ev.tool, false), items: foldTool(ev.tool.parentId ? s.items : ev.tool.partial ? stopThought(s.items) : sealSay(s.items), ev.tool, true) }
? { ...s, doing: ev.tool.name, executions: noteTool(s.executions, ev.tool, false), items: foldTool(s.items, ev.tool, true) }
: s;
case "tool_progress":
+78 -66
View File
@@ -63,13 +63,6 @@ and selected states, while long titles truncate within the remaining width.
hierarchy to weight alone. `.side .lbl` is listed there for specificity: the
right column's same-named rule comes later in the file.
## Select alignment
Styleable native selects center their value on the cross axis. Their fixed
control height and centered background arrow must share the same text center,
including settings rows and enlarged interface fonts. The browser still owns
the picker and keyboard navigation.
## Wallpaper and sky
- `body::before` is the theme's picture, when a pack ships one. It sits behind
@@ -104,9 +97,6 @@ the picker and keyboard navigation.
## Title bar and chrome
- Navigation selection uses the theme's tinted surface and readable accent
ink, with a side marker and a light inset border. Theme packs use the same
treatment to distinguish page position from solid primary-action buttons.
- `-webkit-app-region` is Chromium's drag property. Inside the shell this row
*is* the title bar, so it has to be the drag handle, and every control on it
opts back out. Membership is what the markup says is interactive, not a list of the
@@ -228,9 +218,6 @@ the picker and keyboard navigation.
## Composer shelf
- Send and stop use 18px Lucide icons inside their circular hit targets. Stop
keeps its filled square on every screen size; a tinted surface distinguishes
it without an outline competing with the symbol.
- Model choices reserve the first line for the model name. Protocol and image
capability share the second line; the capability badge and connection status
keep their width, while long names and endpoints truncate inside the menu.
@@ -869,35 +856,81 @@ what exists.
mixed text. The "get a key" entry sits at the label's right end with tracking
zeroed, because the label's .06em is for Latin small labels.
## Full-screen settings
## The settings sheet
- Settings fills the application window and owns a draggable header with a
labelled return action and native window controls. The header remains outside
the settings scroller and search results, inside its focus boundary.
- Desktop navigation stays 240px wide. Content uses the remaining width with
fixed 24px side gutters; every section card fills one column without a cap.
Phone navigation becomes a horizontal strip with 14px content gutters.
- Categories keep their editors expanded. Pill navigation scrolls below the
sticky index without replacing editors or discarding drafts. Leaving a
category retains the existing unsaved-change guard.
- Page, card and group headings use distinct sizes; groups use h4 beneath card
h3 headings. Descriptions wrap and related controls share a card.
- `settings-fullscreen.mjs` checks every category in both schemes and widths,
including enlarged text; `settings-page-column.mjs` checks pill landing.
- Search overlays the content column while preserving its editors. Results use
a full-width name followed by wrapping category, scope and effect metadata.
- Scope and effect are facts with neutral styling. Landing briefly marks the
located setting; changing category resets only the content scroller.
- Navigation labels keep their content width before secondary counts yield.
Search aliases never become separate settings or controls.
- One title bar exists — `.chrome` — and it owns the window buttons and the drag
region; this layer starts below it, so the window buttons still work while
settings is open.
- The backdrop dims and softens but stays thin: the sheet over it is glass, and
what shows through glass has to be light.
- A floating layer fades in rather than sliding: a full-bleed blurred backdrop
shows its edge the moment it moves. The sheet is conditionally rendered, so it
had an entrance and no way out — it simply stopped existing. A view transition
gives it both ends. Only the named element takes part; without naming the root
explicitly, every swap also cross-fades the whole window.
- The change preview uses the same pair — a dimmed, blurred backdrop and a
raised surface starting below the title bar — for the same reason, including
the same view transition. The diff inside it drops the top margin it carries
for use inside a card, where it is one element among several; here it is the
only content.
- A very faint gilt line under the header says this is the window's own thing,
not content.
- The header's expand/restore and close buttons share a 32px hit-region height,
matching the search control's minimum height. Inline-flex centres SVG and text
content independently of font fallback; native window controls retain their own sizing.
- The search entry sits beside the title. That position used to hold one
explanation addressed to two of the three tiers; that sentence now lives on
each settings block (`.apply`). Results are laid under the header without
repainting settings itself: looking for something should not take away the
page you are reading.
- Search results give the setting name a full-width first row. Section and
scope/apply metadata share a wrapping second row; neither can consume the
name's width. Results retain their content height inside the scrolling list.
- What changing a setting costs is a fact, not a warning: one colour for all
three tiers, only lighter than body text. What deserves colour is a present
state such as "cannot be changed while a turn is running".
- A search landing flashes once: it is navigation feedback, not a changed
setting.
- The nav column is 224px because of the Chinese names: "Tools and permissions"
needs 138px at that width while a row leaves the name 99.6px. The extra 16px
does not close the gap but decides how many characters of the value survive
beside it. It is named `.navsec`, not `.navgrp`: the rail already owns that
name for its columns of buttons.
- The nav doubles as a readout: the current value hangs at the right, so opening
settings shows at a glance what this session runs under — which was the only
reason the separate status bar existed.
- The value gives up its width first. Chinese names fit this column and English
ones do not, and a row that truncates the name says nothing. The name's basis
is its content width rather than 0: at 0 the row never "overflows", the name
simply takes what is left and is clipped, and the value is never asked to
yield. The value's shrink factor is 99, not 1: with both shrinkable the browser
splits the overflow in proportion to their bases and takes a slice off the name
too — 38px off "工具与权限" in the English build.
- A bar props the selected row up from the left. In a column of equal pills a
fill alone gives out on one side or the other.
- `.nv` is real information, not decoration: `--ghost` at 10.5px measures 2:1 and
cannot be read.
- The settings column hugs the nav rather than centring: with only a left rail,
centring suspends the content as an island. 760px is a reading measure for
prose; the extensions page is a table of five columns and is the one page
widened, or its descriptions are clipped.
- Changing section is one navigation: what changes is the column, not each block
arriving separately. Four blocks each flying in is four announcements of "here
is something new" when nothing happened — you walked to another page. The
section mark is the section's mark, not its state: it appears on every page, so
tinting it amber says every page needs you.
- A state is said in the vocabulary of the separation it modifies: with no line
to tint, hover lifts the fill and focus rings.
- The advanced fold hides three complete settings blocks, not a note, so its
summary is drawn at a settings row's weight. Collapsed, `.ds` says whether
anything inside has been changed: hiding a setting that is in force leaves the
page disagreeing with the window.
- The page heading says where you are; block headings stay strong enough to scan
the individual decisions. A command named in a note is set in the interface's
own mono, because a bare `<code>` inherits the browser's default family.
## Settings rows
Log paths and account notes wrap within their rows, including long unbroken
paths at enlarged text sizes. Their content never relies on the card gutters
to absorb horizontal overflow.
- One option per row, shared with the ask card as one control: two copies drift
apart. `.prow`'s only differences are its own disabled and danger states.
- Risk is present before you click, not red after you select: with "allow
@@ -1567,7 +1600,7 @@ A sub-agent row, by the list's own width:
## Dialogs centre on the window
- A veil (feedback, a full screenshot, a checkpoint preview, a
- A veil (settings, feedback, a full screenshot, a checkpoint preview, a
sub-agent transcript) starts below the title bar so the bar stays usable.
- Centred in that box alone, a dialog would sit half a bar below the window's
middle, which reads as off balance against the whole window.
@@ -1575,13 +1608,14 @@ A sub-agent row, by the list's own width:
bar, so the box it centres in is symmetric about the window's middle. A border
rather than padding because padding on these veils is set per breakpoint, and
percentage heights inside then leave room for it on their own.
- The phone layout (`data-fold~="scene"`) is excluded. Settings owns a full
screen at every width and is outside the dialog-centering selector.
- The phone layout (`data-fold~="scene"`) shows settings full screen, so it is
excluded.
## Feedback veil
## Settings and feedback veils
- `.fbk-veil` uses a flat, 93% page-colour scrim in both themes without
filtering the live content on every frame.
- `.prefs` and `.fbk-veil` use the same flat, 93% page-colour scrim in both
themes, not a blurred backdrop. Settings stays visually separate from live
content without filtering that content on every frame.
- A backdrop filter over the whole window is redone every frame while anything
beneath it moves; a running turn's spinners are enough.
- The scrim is opaque enough that what shows through reads as dimmed, which is
@@ -1725,25 +1759,3 @@ A sub-agent row, by the list's own width:
- Appearance → Conversation display offers “Show collapsed plan progress,” off by default and persisted on this machine. Changing it immediately updates mounted plan cards.
- A nonempty collapsed plan draws the expanded plan's `.prog2` track below its summary, using completed steps over total steps from `stepDone`. Pending and running steps do not count as complete.
- Opening the card replaces the optional track with the existing expanded progress. Goal-only, empty and hidden cards have no progress track; collapse, Escape, outside dismissal and focus return remain unchanged.
## Provider form fields
- A compound input gives its editable text the space left after the unit label.
Only standalone setting-row and threshold controls use a fixed numeric width.
- Provider inputs and selects share a 32px height. Model names occupy their own
row; capacity and price fields fill the remaining grid width. Container queries
reduce columns using the form's available width and scaled text size.
- Billing choices use short names, with their meaning explained beneath the
picker. The provider form's selects fill their column and reserve arrow space.
- `provider-fields.mjs` checks full numeric values, inheritance hints, currency
and billing choices at wide, intermediate and phone widths, both languages,
both schemes and enlarged text.
## Sidebar tool card
- The footer keeps Settings and Feedback as labelled rows aligned at the left.
Enabled shortcuts appear in a card with library icons and names, preserving
their saved order and visibility. Hiding every shortcut removes the chevron.
- Hover waits briefly before opening and closing so the pointer can cross to
the card. Click and keyboard activation also work; Escape restores focus.
The portaled card follows its anchor and stays inside the viewport.
+39 -41
View File
@@ -104,7 +104,7 @@
}
:root[data-boot] .app { animation: m-card 1.2s cubic-bezier(.4, 0, .2, 1) both }
:root[data-boot] .chrome::after {
content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none;
background: linear-gradient(105deg, transparent 34%,
@@ -122,7 +122,7 @@
:root[data-boot] .chrome::after { display: none }
}
[data-titlebar="app"] .chrome { -webkit-app-region: drag }
[data-titlebar="app"] .chrome
*:not(:is(button, a[href], input, select, textarea, summary, .picker,
@@ -160,7 +160,7 @@
display: grid; place-items: center; width: 44px; border: 0; background: transparent;
color: var(--faint); cursor: pointer; transition: background var(--m-line), color var(--m-line);
}
.winctl .wc svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2 }
.winctl .wc svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 1.1 }
.winctl .wc:hover { background: var(--overlay); color: var(--text) }
.winctl .wc.close:hover { background: var(--err); color: #fff }
.winctl .wc:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px }
@@ -180,7 +180,7 @@
font-weight: var(--w-med); color: var(--muted);
flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.picker { position: relative; flex: none }
.isolab { flex: none; white-space: nowrap; font: var(--w-med) var(--fs-micro) var(--ui); letter-spacing: var(--track-cap); color: var(--muted); padding: 1.5px 6px; border-radius: var(--r-pill); background: var(--overlay) }
.isolab[hidden] { display: none }
@@ -331,7 +331,7 @@
}
.mode:hover .lb, .mode[aria-expanded="true"] .lb { color: var(--muted) }
.mode:hover .vl, .mode[aria-expanded="true"] .vl { color: var(--text) }
.mode .ic { display: grid; place-items: center; width: 12px; height: 12px; flex: none }
.mode .ic svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round }
.mode.danger { color: var(--err-ink); border-color: color-mix(in srgb, var(--err) 45%, var(--border)) }
@@ -342,7 +342,7 @@
background: color-mix(in srgb, var(--err) 12%, transparent);
animation: m-pulse var(--t-live) var(--ease) infinite;
}
.sep { width: 1px; height: 14px; flex: none; margin-inline: -1px; background: var(--border); opacity: .75 }
.side .block { transition: opacity var(--m-panel) , filter var(--m-panel) }
@@ -362,7 +362,7 @@
.thbtn svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round }
.pip { width: 6px; height: 6px; border-radius: 50%; background: var(--ghost); flex: none }
.rmark { width: 15px; height: 15px; flex: none; display: block; color: var(--ghost); transition: color var(--m-panel) }
.rmark path {
fill: none; stroke: currentColor; stroke-width: 1.7;
@@ -374,7 +374,7 @@
.pane[data-run="running"] .rmark path { stroke-dashoffset: 0 }
.pane[data-run="halt"] .rmark { color: var(--accent-ink) }
.pane[data-run="done"] .rmark { color: var(--ok) }
.queue {
@@ -433,7 +433,7 @@
width: 5px; height: 5px; flex: none; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 11%, transparent);
}
.qi .pill {
margin-left: 0; padding: 2px 7px; border-radius: var(--r-xs);
font: var(--w-med) var(--fs-micro) var(--mono); letter-spacing: .03em;
@@ -473,7 +473,7 @@
}
.qi .x:hover, .qi .x:focus-visible { color: var(--text); background: var(--overlay) }
.call[data-pending] > .c { opacity: .7 }
.app {
@@ -597,7 +597,7 @@
.ws .where .tag { flex: none; color: var(--accent-ink) }
.ws[data-iso="on"] .where .tag { color: var(--ok-ink) }
.main { position: relative; display: flex; flex-direction: column; min-height: 0; background: var(--surface) }
.vtabs { display: flex; align-items: center; gap: 2px; min-width: 0 }
@@ -657,11 +657,10 @@
.btab { max-width: 220px; overflow: hidden; text-overflow: ellipsis }
.bnav { display: flex; align-items: center; gap: 4px }
.bbtn {
flex: none; display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--r-sm);
background: none; color: var(--faint); cursor: pointer;
flex: none; width: 28px; height: 28px; border: 0; border-radius: var(--r-sm);
background: none; color: var(--faint); cursor: pointer; font: var(--w-med) var(--fs-ui) var(--ui);
}
.bbtn:hover:not(:disabled) { color: var(--text); background: var(--overlay) }
.bbtn:disabled { opacity: .3; cursor: default }
.bbtn:hover { color: var(--text); background: var(--overlay) }
.bbtn:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px }
.baddr { flex: 1; min-width: 0 }
.baddr input {
@@ -828,6 +827,7 @@
.call[data-k="host"] > .c .hl .nm { font-weight: var(--w-reg); color: var(--muted) }
.call[data-k="say"] > .g .sym { background: none; box-shadow: none; color: var(--text) }
.call[data-k="say"] > .g .sym svg { fill: currentColor }
.call[data-k="say"] > .g {
background: linear-gradient(color-mix(in srgb, var(--border) 88%, transparent) 0 0)
@@ -856,7 +856,7 @@
.hl { display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap; transition: opacity var(--m-exit) }
.hl[data-swap] { opacity: 0 }
.enterbox[data-enter] .hl .cost { animation: m-panel var(--t-card) var(--ease-out) both }
.call > .g .sym {
transition: opacity var(--t-card) var(--ease-out),
box-shadow var(--t-card) var(--ease-out);
@@ -1178,12 +1178,12 @@
.comp details { margin-top: var(--y-close) }
.comp-l { margin-top: 5px; display: flex; flex-direction: column; gap: 3px }
.comp-l .row { display: flex; gap: 8px; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint) }
.comp-l .row .x { color: var(--del); flex: none; width: 16px }
.comp-l .row .k { color: var(--ok); flex: none; width: 16px }
.comp-l .row .x { color: var(--del); flex: none; width: 10px }
.comp-l .row .k { color: var(--ok); flex: none; width: 10px; font-size: var(--fs-micro) }
.steps { margin-top: 6px; display: flex; flex-direction: column; gap: 4px }
.steps .s { display: flex; align-items: baseline; gap: 9px; font-size: var(--fs-ui); color: var(--muted) }
.steps .s .b { display: inline-flex; align-items: center; justify-content: center; font: var(--w-reg) var(--fs-micro)/1.5 var(--mono); width: 16px; flex: none; color: var(--ghost) }
.steps .s .b { font: var(--w-reg) var(--fs-micro)/1.5 var(--mono); width: 10px; flex: none; color: var(--ghost) }
.steps .s[data-done] { color: var(--faint) }
.steps .s[data-done] .b { color: var(--ok) }
.steps .s[data-now] { color: var(--text) }
@@ -1364,7 +1364,7 @@
.intake[data-tone="ref"] { color: var(--net) }
.intake[data-tone="insert"] { color: var(--ok-ink) }
.intake[data-tone="reject"] { color: var(--faint) }
.shots {
list-style: none; margin: 0; display: flex; flex-wrap: nowrap; align-items: center; gap: 7px;
padding: 0 2px 8px; overflow-x: auto; overscroll-behavior-inline: contain; scrollbar-width: thin;
@@ -1556,7 +1556,7 @@
.go { display: inline-flex; align-items: center; gap: 7px; flex: none }
.compose .row > * { position: relative; z-index: 8; animation: m-card var(--m-panel) both }
.compose .row [data-pending] { pointer-events: none; opacity: .5; transition: opacity var(--m-line) }
.compose .row:has([data-pending])::after {
@@ -1575,7 +1575,7 @@
.policy .vl .polrisk { color: var(--err-ink); font-weight: var(--w-med) }
.policy .vl .polwarn { margin-right: 4px; font-style: normal; display: inline-grid; vertical-align: -1.5px }
.policy .vl .polwarn svg, .cachemoved > .studio-icon {
.policy .vl .polwarn svg, .cachemoved i svg {
width: 12px; height: 12px; fill: none; stroke: currentColor;
stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round;
}
@@ -1587,7 +1587,7 @@
color: var(--err-ink);
box-shadow: var(--lit), 0 0 0 1px color-mix(in srgb, var(--err) 42%, transparent);
}
.polmenu {
min-width: 296px; padding: 13px 14px 12px; left: auto; right: 0;
display: flex; flex-direction: column; gap: 16px;
@@ -1793,7 +1793,7 @@
display: flex; align-items: baseline; gap: 5px; margin: 8px 0 0;
font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--warn-ink);
}
.cachemoved > .studio-icon { align-self: center }
.cachemoved i { font-style: normal; display: inline-grid; vertical-align: -1.5px }
.bar { display: flex; height: 3px; overflow: hidden; background: var(--hair); gap: 1px }
.bar i { display: block; transition: flex-grow var(--t-live) var(--ease); position: relative }
@@ -1985,7 +1985,7 @@
.mode .vl, .mode.danger::before, .compose .row > *, .call[data-running] > .g .sym { animation: none !important }
.gutter[data-edge]::after { animation: m-pulse var(--t-card) var(--ease) }
.compose .row:has([data-pending])::after { animation: none; background: var(--net); opacity: .5 }
.scroll { scroll-behavior: auto }
.btn:active, .navbtn:active,
@@ -2109,7 +2109,7 @@
animation: m-panel var(--t-panel) var(--ease-spring) both;
}
@supports (view-transition-name: none) {
:root { view-transition-name: none }
.prefs { view-transition-name: prefsveil; animation: none }
@@ -2336,7 +2336,7 @@
.advset:not(.appearance-advanced) > .grp:last-child { border-bottom: 0 }
.grp-hd { display: flex; align-items: baseline; gap: 12px }
.grp > .grp-hd :is(h3, h4), .grp > h3.grp-hd { margin: 0; font: var(--w-semi) var(--fs-lead)/1.35 var(--ui); color: var(--text); letter-spacing: -.005em }
.grp > .grp-hd h3, .grp > h3.grp-hd { margin: 0; font: var(--w-semi) var(--fs-lead)/1.35 var(--ui); color: var(--text); letter-spacing: -.005em }
.grp .now {
margin-left: auto; flex: none; padding: 2px 7px; border-radius: var(--r-pill);
background: var(--overlay); font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint);
@@ -2771,7 +2771,7 @@
.addp-head strong { display: block; font: var(--w-semi) var(--fs-lead) var(--ui); color: var(--text) }
.addp-head p { margin: 4px 0 0; font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); color: var(--muted) }
.addp-close {
flex: none; width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: var(--r-xs);
flex: none; width: 28px; height: 28px; border: 0; border-radius: var(--r-xs);
background: transparent; color: var(--faint); font: var(--w-reg) var(--fs-title)/1 var(--ui); cursor: pointer;
}
.addp-close:hover { background: var(--overlay); color: var(--text) }
@@ -3296,7 +3296,7 @@
.probes { margin-top: 14px; display: flex; flex-direction: column }
.probe { display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap; padding: 7px 0; border-top: 1px solid var(--hair) }
.probe:last-of-type { border-bottom: 1px solid var(--hair) }
.probe .mk { flex: none; width: 16px; font-style: normal; color: var(--err) }
.probe .mk { flex: none; width: 14px; font-style: normal; color: var(--err) }
.probe[data-ok] .mk { color: var(--ok) }
.probe .lb { flex: none; min-width: 68px; font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--muted) }
.probe .dt { flex: 1; min-width: 0; font: var(--w-reg) var(--fs-ui) var(--mono); color: var(--text); word-break: break-all }
@@ -3575,7 +3575,7 @@
.acct-who { display: flex; align-items: baseline; gap: 9px }
.acct-who .nm { font: var(--w-med) var(--fs-ui) var(--ui); color: var(--text) }
.acct-who .em { font: var(--w-reg) var(--fs-ui) var(--mono); color: var(--muted) }
.acct-note { font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); color: var(--muted); margin: 0; overflow-wrap: anywhere }
.acct-note { font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); color: var(--muted); margin: 0 }
.acct-note[data-err] { color: var(--err-ink) }
.bk { display: flex; flex-direction: column; gap: 12px }
@@ -3821,7 +3821,7 @@
}
.movemenu button:hover { background: var(--float-hi); color: var(--text) }
.movemenu button[aria-checked="true"] { color: var(--text) }
.movemenu button[aria-checked="true"] { display: flex; align-items: center; justify-content: space-between; gap: 8px }
.movemenu button[aria-checked="true"]::after { content: " ·" }
.drawnby { margin-top: 6px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--ghost) }
@@ -3894,7 +3894,7 @@
.oobe[data-play] .oobe-sub { opacity: 0; animation: m-card .8s var(--ease-out) 2.1s forwards }
@keyframes oobebrand { from { opacity: 0; transform: translateY(10px); filter: blur(6px) }
to { opacity: 1; transform: none; filter: none } }
.oobe-lines { position: relative; height: 96px; width: 100%; margin-top: 4px }
.oobe-line { position: absolute; inset: 0; display: grid; place-items: center; opacity: 0 }
.oobe-line b { display: block; font: var(--w-reg) calc(30px * var(--fs-scale, 1))/1.35 var(--ui); color: #fff }
@@ -4020,7 +4020,7 @@
[data-vt="tab"]::view-transition-new(tabbody) { animation: m-card var(--m-card) both }
[data-vt="pane"]::view-transition-old(pane) { animation: m-exit var(--t-card) var(--ease-in) both }
[data-vt="pane"]::view-transition-new(pane) { animation: m-card var(--m-panel) both }
.panetabs {
position: relative;
flex: none; display: flex; align-items: stretch; gap: 2px;
@@ -4045,8 +4045,7 @@
.ptab-ws, .ptab-host { flex: 0 1 auto; min-width: 0; max-width: 40%; overflow: hidden; text-overflow: ellipsis }
.ptab-ws { font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--ghost) }
.ptab-host { font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--net) }
.ptab-host { display: inline-flex; align-items: center; gap: 3px }
.ptab-host .studio-icon { width: 12px; height: 12px; opacity: .7 }
.ptab-host::before { content: "◦"; margin-right: 2px; opacity: .7 }
.ptab .pip { flex: none; background: var(--ghost) }
.ptab[data-run="running"] .pip { background: var(--net); animation: m-pulse var(--t-live) ease-in-out infinite }
@@ -4209,9 +4208,8 @@
.movess-cols h3 { margin: 0 0 4px; font: var(--w-med) var(--fs-meta) var(--ui); color: var(--text) }
.movess-cols ul { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 3px }
.movess-cols li { position: relative; padding-left: 16px; font: var(--w-reg) var(--fs-meta)/1.6 var(--ui); color: var(--muted) }
.movess-cols li .studio-icon { position: absolute; left: 0; top: 3px; width: 12px; height: 12px }
.movess-cols section[data-part="travels"] .studio-icon { color: var(--ok-ink) }
.movess-cols section[data-part="stays"] .studio-icon { color: var(--warn-ink) }
.movess-cols section[data-part="travels"] li::before { content: "✓"; position: absolute; left: 0; color: var(--ok-ink) }
.movess-cols section[data-part="stays"] li::before { content: "×"; position: absolute; left: 2px; color: var(--warn-ink) }
.movess .picknote { margin: 10px 0 0 }
.movess-act { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px }
.movess-empty { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 14px 0 }
@@ -4330,7 +4328,6 @@
.rmtcands { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 10px 0 2px }
.rmtcands .cap { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--ghost) }
.rmtcands .rmtcand {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 9px; border: 1px dashed var(--border); border-radius: var(--r-pill);
background: transparent; color: var(--faint); cursor: pointer;
font: var(--w-reg) var(--fs-meta) var(--mono);
@@ -5187,7 +5184,7 @@
.prefs-pagetitle .desc { display: inline-flex; gap: 0; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
.prefs-pagetitle .desc .what + .count::before { content: "·"; margin: 0 6px; }
.prefs-pagetitle .group { color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); }
.prefs-pagetitle .group { display: inline-flex; align-items: center; gap: 8px; }
.prefs-pagetitle .group::after { content: "›"; margin-left: 8px; }
.prefs-pagetitle h2 { margin: 0; color: var(--text); font: var(--w-semi) var(--fs-title)/1.3 var(--ui); }
.prefs-card { margin-bottom: 16px; border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--surface); overflow: hidden; }
.prefs-card > header { padding: 16px 20px 12px; }
@@ -5202,7 +5199,7 @@
.prefs-card > .grp > .grp-hd, .prefs-card > .grp > .hint, .prefs-card > .grp > .apply { grid-column: 1; }
.prefs-card > .grp:has(> .grp-items > :is(.seg, select)) > .grp-items { grid-column: 2; grid-row: 1 / span 3; margin-top: 0; align-items: flex-end; text-align: right; }
.prefs-card > .grp:has(> .grp-items > :is(.seg, select)) > .grp-hd .now { display: none; }
.prefs-card > .grp .grp-hd :is(h3, h4) { font: var(--w-semi) var(--fs-ui)/1.4 var(--ui); letter-spacing: 0; }
.prefs-card > .grp .grp-hd h3 { font: var(--w-semi) var(--fs-ui)/1.4 var(--ui); letter-spacing: 0; }
.prefs-card > .grp .hint { margin: 2px 0 0; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
.prefs-card > .grp .apply { margin: 4px 0 0; padding: 0; border: 0; color: var(--faint); font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); }
.prefs-sheet { position: relative; display: grid; grid-template-columns: 240px minmax(0, 1fr); grid-template-rows: auto auto minmax(0, 1fr); }
@@ -5211,6 +5208,7 @@
.prefs-hd h1 { grid-column: 1; grid-row: 1; padding: 18px 18px 0; background: var(--surface); }
.prefs-find { grid-column: 1; grid-row: 2; max-width: none; margin: 0; border-radius: var(--r-md); border-width: 0 0 0 0; box-shadow: inset 0 0 0 1px var(--border); background: var(--raised); }
.prefs-sheet > .prefs-hd > .prefs-find { margin: 0; align-self: start; }
.prefs-actions { grid-column: 2; grid-row: 1; justify-self: end; display: flex; align-items: center; gap: 8px; padding: 12px 14px; }
.prefs-nav { grid-column: 1; grid-row: 3; }
.prefs-main { grid-column: 2; grid-row: 2 / span 2; }
.prefs-found { top: 96px; left: 12px; right: auto; width: min(460px, calc(100% - 24px)); border-top: 1px solid var(--border); border-radius: var(--r-md); }
@@ -2,9 +2,9 @@
display: inline-flex; align-items: center; gap: 3px; padding: 0 5px; border: 1px solid var(--border); border-radius: 999px; background: transparent; cursor: pointer;
color: var(--muted); font: var(--w-reg) var(--fs-micro)/1.5 var(--mono);
}
.chrome-context-parts .chrome-context-more .studio-icon { width: 12px; height: 12px; transition: transform var(--t-line); }
.chrome-context-parts .chrome-context-more::after { content: "›"; transition: transform var(--t-line); }
.chrome-context-parts .chrome-context-more[aria-expanded="true"] { color: var(--text); background: var(--overlay); }
.chrome-context-parts .chrome-context-more[aria-expanded="true"] .studio-icon { transform: rotate(90deg); }
.chrome-context-parts .chrome-context-more[aria-expanded="true"]::after { transform: rotate(90deg); }
.chrome-context-parts .chrome-context-more:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.chrome-context-items { list-style: none; margin: 6px 0 0; padding: 0 0 0 8px; border-left: 1px solid var(--border); display: grid; grid-template-columns: minmax(0, 1fr); gap: 3px; }
.chrome-context-items li { min-width: 0; display: flex; align-items: center; gap: 8px; color: var(--muted); font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); }
@@ -1,3 +1,3 @@
:root:not([data-fold~="scene"]) :is(.fbk-veil, .image-preview, .cpv, .agent-tx-scrim) {
:root:not([data-fold~="scene"]) :is(.prefs, .fbk-veil, .image-preview, .cpv, .agent-tx-scrim) {
border-bottom: var(--chrome-h, 39px) solid transparent;
}
@@ -4,6 +4,5 @@ const CSS = import.meta.glob("./dialogcenter.css", { query: "?raw", import: "def
const css = CSS["./dialogcenter.css"]!;
it("keeps the transparent bottom border that centres a dialog under the title bar", () => {
expect(css).not.toContain(".prefs");
expect(css).toMatch(/:is\(\.fbk-veil, \.image-preview, \.cpv, \.agent-tx-scrim\)\s*\{\s*border-bottom:\s*var\(--chrome-h, 39px\) solid transparent;/);
expect(css).toMatch(/:is\(\.prefs, \.fbk-veil, \.image-preview, \.cpv, \.agent-tx-scrim\)\s*\{\s*border-bottom:\s*var\(--chrome-h, 39px\) solid transparent;/);
});
-1
View File
@@ -66,7 +66,6 @@ select[data-size="sm"] { height: 24px; padding: 0 26px 0 8px; font-size: var(--f
@supports (appearance: base-select) {
select:not([multiple]), select:not([multiple])::picker(select) { appearance: base-select; }
select:not([multiple]) { align-items: center; }
select:not([multiple])::picker-icon { display: none; }
select:not([multiple])::picker(select) {
box-sizing: border-box; margin-block: 4px; padding: 4px; max-height: min(320px, 50vh);
+1 -1
View File
@@ -2,7 +2,7 @@
.modlist li { display: grid; gap: 2px; padding: 8px 10px; border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--surface) }
.modlist .modhead { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 12px }
.modlist b { font-weight: var(--w-med); overflow-wrap: anywhere }
.modlist .modstate { display: inline-flex; align-items: center; gap: 4px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted) }
.modlist .modstate { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted) }
.modlist li[data-state="running"] .modstate { color: var(--ok, var(--text)) }
.modlist li[data-state="disabled"] .modstate { color: var(--danger, var(--text)) }
.modlist li[data-state="restarting"] .modstate { color: var(--warn, var(--text)) }
@@ -20,6 +20,11 @@ const SOURCES = import.meta.glob(["../**/*.tsx", "!../**/*.test.*"], {
// welcome screen is the one deliberate exception — its STUDIO line and tagline
// are set in the terminal's own face because that is the product's vernacular.
const MARKS: Record<string, string> = {
x: "× — the close mark, sized to hold its cell beside ✓",
"ptab-x": "× — same mark on a pane tab",
ah: "› — the affordance arrow",
"mode-toggle": "Aa and .* — the glyphs of the search flags they switch, which are the flags' own notation",
prompt: "› — the composer's own prompt character",
esc: "Esc — a keycap, which is lettering on a key rather than prose",
"oobe-brand": "the lockup's STUDIO line, deliberately set in the terminal's face",
"oobe-sub": "the lockup's tagline, set with it",
@@ -32,8 +37,8 @@ const MARKS: Record<string, string> = {
// while the work is outstanding. Never widen this to land a change.
const MIXED = new Set([
"c", "cnt", "cost", "cur", "d", "delta", "dt", "fail", "k",
"lb", "meta", "n", "nm", "ntimes", "path", "pill", "pt", "r", "rmtcand", "rt",
"sc", "st", "sz", "tag", "u", "uspan", "v",
"lb", "meta", "mk", "n", "nm", "ntimes", "path", "pill", "pt", "r", "rmtcand", "rt",
"sc", "st", "sym", "sz", "tag", "u", "uspan", "v",
]);
const TRANSLATORS = ["t", "tx", "plural"];
@@ -93,6 +93,8 @@
.chrome-context-foot .compact-now > .btn { height: 24px; padding: 0 10px; border: 1px solid transparent; border-radius: var(--r-pill); background: transparent; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1 var(--ui); }
.chrome-context-foot .compact-now > .btn:hover { background: var(--overlay); color: var(--text); }
.chrome-context-card .chrome-context-more { display: inline-flex; align-items: center; gap: 5px; height: auto; padding: 0; border: 0; background: none; cursor: pointer; }
.chrome-context-card .chrome-context-more::after { content: ""; width: 5px; height: 5px; border-top: 1.45px solid var(--faint); border-right: 1.45px solid var(--faint); transform: rotate(45deg); }
.chrome-context-card .chrome-context-more[aria-expanded="true"]::after { transform: rotate(135deg); }
.chrome-context-card:not(.studio-overview-card) > .chrome-context-items { margin: 2px 14px 4px; padding: 2px 0 2px 10px; gap: 2px; }
@media (pointer: coarse) {
.chrome-context-card:not(.studio-overview-card) > .chrome-context-parts > div { height: 36px; }
@@ -51,8 +51,4 @@ describe("a select's popup list", () => {
it("gives every option a finger-sized row on a touch screen", () => {
expect(block()).toMatch(/@media \(pointer: coarse\)\s*\{[^}]*select:not\(\[multiple\]\) option\s*\{[^}]*min-height:\s*40px/);
});
it("keeps the chosen text centred in the box, which the engine's own layout leaves at the top", () => {
expect(rule(block(), "select:not([multiple])")).toMatch(/align-items:\s*center/);
});
});
+24 -19
View File
@@ -108,8 +108,8 @@ body { background: var(--page); }
.navgrp { gap: 3px; }
.navbtn { width: 36px; height: 36px; border-radius: var(--r-sm); }
.navbtn:hover { background: var(--overlay); box-shadow: none; }
.navbtn[aria-current] { color: var(--accent-ink); background: var(--accent-wash); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 18%, var(--border)); }
.navbtn[aria-current]::before { left: -5px; top: 10px; bottom: 10px; background: var(--accent-ink); }
.navbtn[aria-current] { color: var(--text); background: var(--overlay); }
.navbtn[aria-current]::before { left: -5px; top: 10px; bottom: 10px; background: var(--text); }
.navhint { left: calc(100% + 8px); }
.rail {
background: color-mix(in srgb, var(--page) 94%, var(--surface));
@@ -453,13 +453,13 @@ body { background: var(--page) !important; }
/* Prototype sidebar */
.studio-icon {
width: 16px;
height: 16px;
width: 15px;
height: 15px;
display: block;
flex: 0 0 auto;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
@@ -596,9 +596,13 @@ body { background: var(--page) !important; }
}
.flow .call[data-k="say"] > .g .sym {
width: 21px; height: 21px; margin-top: 0; border-radius: var(--r-sm);
background: var(--accent); color: var(--accent-fg); box-shadow: none;
background: var(--accent); color: transparent; box-shadow: none; font-size: 0;
}
.flow .call[data-k="say"] > .g .sym svg { width: 15px; height: 15px; display: block; }
.flow .call[data-k="say"] > .g .sym::after {
content: "R"; width: 100%; height: 100%; display: grid; place-items: center;
color: var(--accent-fg); font: var(--w-med) var(--fs-micro)/1 var(--ui);
}
.flow .call[data-k="say"] > .g .sym svg { display: none; }
.flow .call[data-k="say"][data-quiet] > .g,
.flow .call[data-k="say"][data-quiet] > .c > .hl { display: none; }
.flow .call[data-k="say"] > .c { padding: 0 0 var(--y-say); }
@@ -1113,7 +1117,7 @@ body { background: var(--page) !important; }
border: 0; border-radius: var(--r-sm); background: transparent; color: var(--faint); cursor: pointer;
}
.studio-browser-toolbar > button:hover:not(:disabled) { color: var(--text); background: var(--float); }
.studio-browser-toolbar svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.studio-browser-toolbar svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.35; stroke-linecap: round; stroke-linejoin: round; }
.studio-browser-toolbar {
min-height: 46px; flex: none; display: flex; align-items: center; gap: 4px; padding: 8px 9px;
border-bottom: 1px solid var(--hair); background: var(--surface);
@@ -1129,12 +1133,6 @@ body { background: var(--page) !important; }
.studio-browser-address input { width: 100%; min-width: 0; padding: 0; border: 0; outline: 0; background: transparent; color: var(--text); font: var(--w-reg) var(--fs-micro)/1 var(--ui); }
.studio-browser-toolbar .studio-browser-go { width: auto; padding: 0 8px; color: #adb9d8; font: var(--w-med) var(--fs-micro)/1 var(--ui); }
.studio-browser-frame { width: 100%; min-height: 0; flex: 1; border: 0; background: var(--page); }
.studio-browser-start { display: grid; place-items: center; padding: 32px; color: var(--text); text-align: center; }
.studio-browser-start > div { width: min(520px, 100%); }
.studio-browser-start .browser-start-mark { box-sizing: content-box; padding: 10px; margin: 0 auto 18px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface); color: var(--muted); }
.studio-browser-start h1 { margin: 0 0 8px; font-size: var(--fs-title); }
.studio-browser-start p { margin: 0; color: var(--faint); font-size: var(--fs-ui); line-height: 1.7; }
.studio-browser-start .browser-start-hint { margin-top: 22px; padding: 11px 14px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface); text-align: left; }
.studio-browser-note,
.studio-browser-error { flex: none; margin: 0; padding: 7px 12px; border-top: 1px solid var(--hair); background: var(--surface); color: var(--ghost); font: var(--w-reg) var(--fs-micro)/1.45 var(--ui); }
.studio-browser-note { display: flex; align-items: center; gap: 10px; }
@@ -1303,7 +1301,7 @@ body { background: var(--page) !important; }
:root[data-theme="light"] .pane,
:root[data-theme="light"] .pane > .scroll { background: var(--reading-surface, var(--page)) !important; }
:root[data-theme="light"] .flow .call[data-k="say"] > .g .sym { background: var(--accent); }
:root[data-theme="light"] .flow .call[data-k="say"] > .g .sym { color: var(--accent-fg); }
:root[data-theme="light"] .flow .call[data-k="say"] > .g .sym::after { color: var(--accent-fg); }
:root[data-theme="light"] .flow .call[data-k="say"] > .c > .hl .nm { color: var(--muted); }
:root[data-theme="light"] .flow .call[data-k="say"] > .c > .out > .txt { color: var(--text); }
:root[data-theme="light"] .flow .call[data-k="say"] .copy[data-icon]:hover { color: var(--text); background: var(--overlay); border-color: var(--border); }
@@ -2026,7 +2024,7 @@ button, input, textarea { font-size: var(--fs-ui); }
.portmenu .mi .dot { display: none; }
.portmenu .mi[data-on] { background: var(--hair); }
.portmenu .mi[data-on]::before { display: none; }
.portmenu .menu-current { align-self: center; margin-left: 8px; color: var(--text); }
.portmenu .mi[data-on]::after { content: "✓"; align-self: center; margin-left: 8px; color: var(--text); font: var(--w-med) var(--fs-micro)/1 var(--ui); }
.portmenu .mi.head { gap: 8px; min-height: 34px; align-items: center; padding-bottom: 8px; margin-bottom: 3px; border-bottom: 1px solid var(--border); border-radius: 0; }
.portmenu .mi.head .lb { color: var(--text); font-size: var(--fs-micro); }
.portmenu .mi.head .rt { color: var(--ghost); font: var(--w-reg) var(--fs-micro)/1 var(--ui); }
@@ -2076,9 +2074,14 @@ button, input, textarea { font-size: var(--fs-ui); }
.studio-effort-menu .studio-effort-strength i:nth-child(3) { height: 10px; }
.studio-effort-menu .studio-effort-strength i:nth-child(4) { height: 13px; }
.studio-effort-menu .studio-effort-strength i[data-on] { background: #9a9f94; }
.studio-effort-menu .studio-effort-strength .studio-icon { color: var(--faint); }
.studio-effort-menu .mi[data-recommended] .studio-effort-strength::before {
content: "✦";
color: var(--faint);
font: var(--w-reg) var(--fs-micro)/1 var(--ui);
}
.studio-effort-menu .mi[data-recommended] .studio-effort-strength i { display: none; }
.studio-effort-menu .mi[data-strength="0"] .studio-effort-strength { visibility: hidden; }
.studio-effort-menu .menu-current { margin-left: 0; }
.studio-effort-menu .mi[data-on]::after { margin-left: 0; }
.polmenu {
width: 310px;
@@ -2531,7 +2534,7 @@ button, input, textarea { font-size: var(--fs-ui); }
.flow .ask-hint,
.flow .opt .ds { color: var(--muted); }
.flow .call[data-k="say"] > .g .sym { background: var(--text); }
.flow .call[data-k="say"] > .g .sym { color: var(--page); }
.flow .call[data-k="say"] > .g .sym::after { color: var(--page); }
.flow .ask { background: var(--raised) !important; border-color: var(--border) !important; }
.tool-disclosure > summary.hl:hover,
@@ -3539,6 +3542,8 @@ button, input, textarea { font-size: var(--fs-ui); }
:root[data-pack] .chrome-context-card section > button { color: var(--link); }
:root[data-pack] .studio-brand-accent { color: var(--brand); }
:root[data-pack] .navbtn[aria-current],
:root[data-pack] .navbtn[aria-current]::before { background: var(--accent); }
:root[data-pack] .btn.send:not(:disabled) { background: var(--accent); color: var(--accent-fg); }
:root[data-pack] .studio-effort-menu .studio-effort-strength i[data-on] { background: var(--accent); }
:root[data-pack] .compose:hover::after,
@@ -1,20 +1,23 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import "./testkit";
import { MockPort } from "../port/mock";
import type { AccountState, AgentPort } from "../port/port";
import { AccountSettings } from "./AccountSettings";
import { SettingDeepContext } from "./SettingDeep";
afterEach(cleanup);
function draw(state: AccountState | null) {
const go = vi.fn();
const port = new MockPort() as unknown as AgentPort;
render(
<>
<SettingDeepContext.Provider value={{ pages: true, open: null, section: "账号与关于", go }}>
<AccountSettings port={port} homePort={port} state={state} unread="" reload={() => {}} />
</>,
</SettingDeepContext.Provider>,
);
return go;
}
it("is account, version and diagnostics, and about: one row per setting", () => {
@@ -24,16 +27,16 @@ it("is account, version and diagnostics, and about: one row per setting", () =>
});
it("offers sign-in when signed out and the account when signed in", () => {
draw({ signedIn: false });
expect(document.querySelector('[data-action="account.sign-in"]')).toBeTruthy();
const go = draw({ signedIn: false });
fireEvent.click(screen.getByRole("button", { name: "登录" }));
expect(go).toHaveBeenLastCalledWith("account");
cleanup();
draw({ signedIn: true, user: { handle: "demo", email: "[email protected]", label: "demo" } });
expect(screen.getByText("[email protected]")).toBeTruthy();
expect(document.querySelector('[data-action="account.sign-out"]')).toBeTruthy();
expect(screen.getByRole("button", { name: "账号信息" })).toBeTruthy();
});
it("shows versions and diagnostics inline", async () => {
draw({ signedIn: false });
await waitFor(() => expect(document.querySelector("#set-versions .vnow")).toBeTruthy());
expect(document.querySelector('#set-logs .grp-items')).toBeTruthy();
it("opens versions and logs from their pills", () => {
const go = draw({ signedIn: false });
fireEvent.click(screen.getByRole("button", { name: "版本与更新" }));
expect(go).toHaveBeenLastCalledWith("versions");
});
@@ -22,15 +22,15 @@ export function AccountSettings({ port, homePort, state, unread, reload }: Props
return (
<>
<SectionCard title={t("settings.account")}>
<Group id="account" title={t("settings.account")} hint={t("settings.account_hint")}>
<Group id="account" pill={t(state?.signedIn ? "settings.account_info" : "settings.sign_in")} title={t("settings.account")} hint={t("settings.account_hint")}>
<Account port={port} state={state} unread={unread} reload={reload} />
</Group>
</SectionCard>
<SectionCard title={t("settings.version_and_diagnostics")}>
<Group id="versions" title={t("common.version")} hint={t("settings.versions_hint")}>
<Group id="versions" pill={t("settings.versions_and_updates")} title={t("common.version")} hint={t("settings.versions_hint")}>
<Versions port={homePort} />
</Group>
<Group id="logs" title={t("about.logs_and_abnormal_exit_records")} hint={t("settings.logs_hint")}>
<Group id="logs" pill={t("safety.details")} title={t("about.logs_and_abnormal_exit_records")} hint={t("settings.logs_hint")}>
<Logs port={homePort} />
</Group>
<TraceExport port={port} />
@@ -38,10 +38,10 @@ export function AccountSettings({ port, homePort, state, unread, reload }: Props
<SectionCard title={t("settings.about")}>
<p className="srow-desc">{t("settings.about_note")}</p>
<AuthorLine port={homePort} />
<Group id="community" title={t("common.community_and_contributors")} hint={t("about.join_the_community_for_help")}>
<Group id="community" pill={t("settings.look_at")} title={t("common.community_and_contributors")} hint={t("about.join_the_community_for_help")}>
<Community port={homePort} />
</Group>
<Group id="sponsor" title={t("about.support_the_project")}>
<Group id="sponsor" pill={t("settings.look_at")} title={t("about.support_the_project")}>
<Sponsor port={homePort} />
</Group>
</SectionCard>
+1 -2
View File
@@ -1,4 +1,3 @@
import { StudioIcon } from "./StudioIcon";
import { useCallback, useEffect, useState } from "react";
import { t } from "../i18n";
import type { Protocol, ProviderEntry, ProviderPreset, ProviderProbe } from "../port/port";
@@ -201,7 +200,7 @@ export function AddProvider({
<strong>{t("models.add_a_model_source")}</strong>
<p>{t("models.use_the_service_documentation_connection")}</p>
</div>
<button className="addp-close" onClick={onCancel} disabled={busy || testing} aria-label={t("common.cancel")}><StudioIcon name="close" /></button>
<button className="addp-close" onClick={onCancel} disabled={busy || testing} aria-label={t("common.cancel")}>×</button>
</div>
{baseUrl.trim() === "" && <PresetGallery load={loadPresets} onPick={setPreset} disabled={busy || checkingModel !== ""} />}
<div className="fields">
@@ -5,15 +5,16 @@ import "./testkit";
import { MockPort } from "../port/mock";
import type { AgentPort } from "../port/port";
import { AgentSettings } from "./AgentSettings";
import { SettingDeepContext } from "./SettingDeep";
afterEach(cleanup);
function draw() {
const go = vi.fn();
render(
<>
<AgentSettings port={new MockPort() as unknown as AgentPort} hub={{ tree: async () => [] }} persona={false} onPersona={go} onChanged={() => {}} />
</>,
<SettingDeepContext.Provider value={{ pages: true, open: null, section: "Agent 行为", go }}>
<AgentSettings port={new MockPort() as unknown as AgentPort} hub={{ tree: async () => [] }} persona={false} onChanged={() => {}} />
</SettingDeepContext.Provider>,
);
return go;
}
@@ -27,20 +28,23 @@ it("is four cards of single rows, plan mode and the display switches living else
for (const id of ["plan-mode", "changed-files", "fold-work", "feedback-level-notices"]) expect(document.querySelector(`[data-setting="${id}"]`), id).toBeNull();
});
it("opens the persona editor while keeping presets inline", async () => {
it("opens the presets page from both the persona row and the presets row", () => {
const go = draw();
fireEvent.click(screen.getByRole("button", { name: "编辑人设" }));
expect(go).toHaveBeenCalledExactlyOnceWith();
await waitFor(() => expect(document.querySelector('[data-action="agent-preset.reload"]')).toBeTruthy());
expect(go).toHaveBeenLastCalledWith("agent-presets");
fireEvent.click(screen.getByRole("button", { name: "管理预设" }));
expect(go).toHaveBeenLastCalledWith("agent-presets");
});
it("exposes hooks and scheduled runs directly in the automation card", async () => {
draw();
await waitFor(() => {
for (const id of ["hooks", "scheduled-runs"]) {
const region = document.querySelector(`#set-${id}`)!;
expect(region.querySelector(".grp-items")).toBeTruthy();
expect(region.querySelector("button")).toBeTruthy();
}
});
it("opens the hooks and the scheduled-runs pages from the automation card", async () => {
const go = draw();
const card = [...document.querySelectorAll<HTMLElement>(".prefs-card")].find((c) => c.querySelector("header h3")?.textContent === "自动化")!;
await waitFor(() => expect([...card.querySelectorAll("button")].filter((b) => b.textContent === "管理")).toHaveLength(2));
const buttons = [...card.querySelectorAll("button")].filter((b) => b.textContent === "管理");
fireEvent.click(buttons[0]);
expect(go).toHaveBeenLastCalledWith("hooks");
fireEvent.click(buttons[1]);
expect(go).toHaveBeenLastCalledWith("scheduled-runs");
expect(card.textContent).toContain("在工具调用前后运行你自己的命令");
expect(card.textContent).toContain("到点让 Agent 只读地跑一遍并留下报告");
});
+11 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import { t } from "../i18n";
import type { HubPort } from "../port/hub";
import type { AgentPort } from "../port/port";
@@ -13,6 +13,7 @@ import { PlanApproval } from "./PlanApproval";
import { ProgressWatch } from "./ProgressWatch";
import { SectionCard } from "./SectionCard";
import { SessionRef } from "./SessionRef";
import { SettingDeep, SettingDeepContext } from "./SettingDeep";
import { SubagentClaims } from "./SubagentClaims";
import { TurnNarrationSetting } from "./TurnNarrationSetting";
@@ -20,7 +21,6 @@ interface Props {
port: AgentPort;
hub: Pick<HubPort, "tree">;
persona: boolean;
onPersona: () => void;
onChanged: () => void;
}
@@ -37,7 +37,8 @@ function useAutomationCounts(port: AgentPort) {
return counts;
}
export function AgentSettings({ port, hub, persona, onPersona, onChanged }: Props) {
export function AgentSettings({ port, hub, persona, onChanged }: Props) {
const deep = useContext(SettingDeepContext);
const counts = useAutomationCounts(port);
const hooksHint = t("settings.hooks_hint") + (counts.hooks ? ` · ${t("settings.n_hooks", { n: counts.hooks })}` : "");
const runsHint = t("settings.scheduled_runs_hint") + (counts.schedules
@@ -46,17 +47,19 @@ export function AgentSettings({ port, hub, persona, onPersona, onChanged }: Prop
return (
<>
<SectionCard title={t("settings.persona_and_presets")}>
<div className="srow" id="set-persona" data-setting="persona">
<div className="srow" data-setting="persona">
<div className="srow-meta">
<span className="srow-name">{t("persona.system_persona")}</span>
<span className="srow-desc">{t("settings.persona_hint")}</span>
<ApplyNote id="persona" />
</div>
<div className="srow-ctl">
<button type="button" className="spill" data-action="settings.edit-persona" onClick={() => onPersona()}>{t("settings.edit_persona")}</button>
<button type="button" className="spill" data-action="settings.open-page" data-target="agent-presets" onClick={() => deep.go("agent-presets")}>{t("settings.edit_persona")}</button>
</div>
</div>
<AgentPresets port={port} focusPersona={persona} />
<SettingDeep id="agent-presets" title={t("persona.agent_presets")} hint={t("settings.presets_hint")} act={t("settings.manage_presets")}>
<AgentPresets port={port} focusPersona={persona} />
</SettingDeep>
</SectionCard>
<SectionCard title={t("settings.plans_and_questions")}>
<PlanApproval port={port} />
@@ -70,10 +73,10 @@ export function AgentSettings({ port, hub, persona, onPersona, onChanged }: Prop
<PeersSection port={port} hub={hub} />
</SectionCard>
<SectionCard title={t("settings.automation")}>
<Group id="hooks" title={t("settings.hooks")} hint={hooksHint}>
<Group id="hooks" title={t("settings.hooks")} hint={hooksHint} pill={t("settings.manage_storage")}>
<Hooks port={port} onChanged={onChanged} />
</Group>
<ScheduledRuns port={port} hint={runsHint} />
<ScheduledRuns port={port} hint={runsHint} pill={t("settings.manage_storage")} />
</SectionCard>
</>
);
Loaded 100 of 280 files, more files were not shown because too many files have changed in this diff. Show more