mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-10 01:51:40 -10:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7facc49510 | ||
|
|
e19fc12a59 | ||
|
|
2ff95dbc5e | ||
|
|
f4a646549c | ||
|
|
302b6732a5 | ||
|
|
3968f1e744 | ||
|
|
3844e7e558 | ||
|
|
c2c631d622 | ||
|
|
79ca0249d1 | ||
|
|
dcee062283 | ||
|
|
614b45c354 | ||
|
|
d09a7c557c | ||
|
|
c16b08bc40 | ||
|
|
513d19db20 | ||
|
|
f396db7c20 | ||
|
|
49e64b6d13 | ||
|
|
bccfb5d684 | ||
|
|
bd898c3f30 | ||
|
|
29230c68bf | ||
|
|
c12faae30a |
No files matched your search
@@ -1,293 +0,0 @@
|
||||
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
|
||||
@@ -158,9 +158,17 @@ 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
|
||||
@@ -218,6 +226,21 @@ 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
|
||||
|
||||
@@ -169,19 +169,6 @@ 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
|
||||
@@ -301,11 +288,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) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**ttmouse**](https://github.com/ttmouse) |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**chuiwenwei**](https://github.com/chuiwenwei) |
|
||||
| [**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) | [**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) |
|
||||
| [**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) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
|
||||
|
||||
+3
-12
@@ -156,15 +156,6 @@ 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:
|
||||
@@ -278,11 +269,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) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**ttmouse**](https://github.com/ttmouse) |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**chuiwenwei**](https://github.com/chuiwenwei) |
|
||||
| [**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) | [**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) |
|
||||
| [**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) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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/
|
||||
@@ -1,216 +0,0 @@
|
||||
---
|
||||
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`.
|
||||
@@ -1,263 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Vendored
-32
@@ -1,32 +0,0 @@
|
||||
# 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
|
||||
@@ -1,112 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,59 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,699 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
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" } ?: "")
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,741 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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
@@ -1,744 +0,0 @@
|
||||
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)
|
||||
@@ -1,66 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,14 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,62 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,13 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,26 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,7 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,35 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,28 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,31 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,27 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,15 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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
BIN
Binary file not shown.
@@ -1,12 +0,0 @@
|
||||
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
-251
@@ -1,251 +0,0 @@
|
||||
#!/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" "$@"
|
||||
Vendored
-94
@@ -1,94 +0,0 @@
|
||||
@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
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/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)"
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/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 {} \;
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/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)"
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,31 +0,0 @@
|
||||
// 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")
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/safety/sandbox"
|
||||
)
|
||||
@@ -57,11 +58,11 @@ func TestMemoryBenchAnswerIsolationAtSandboxBoundary(t *testing.T) {
|
||||
if err := validateAnswerIsolation(tasks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
work := t.TempDir()
|
||||
work := testenv.TempDir(t)
|
||||
if err := stageAnswerIsolation(tasks[0].answerRoot, work); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home := t.TempDir()
|
||||
home := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("[sandbox]\nbash = \"off\"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -105,7 +106,7 @@ func TestAnswerIsolationRefusesUnsafeWorkdirAndExistingConfig(t *testing.T) {
|
||||
if err := stageAnswerIsolation(answerRoot, filepath.Dir(answerRoot)); err == nil {
|
||||
t.Fatal("workdir inside the checkout was accepted")
|
||||
}
|
||||
work := t.TempDir()
|
||||
work := testenv.TempDir(t)
|
||||
path := filepath.Join(work, "reasonix.toml")
|
||||
if err := os.WriteFile(path, []byte("# task fixture\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -120,7 +121,7 @@ func TestAnswerIsolationRefusesUnsafeWorkdirAndExistingConfig(t *testing.T) {
|
||||
|
||||
func TestRunTaskDropsIsolationConfigBeforeGrading(t *testing.T) {
|
||||
requireShellStub(t)
|
||||
suite := t.TempDir()
|
||||
suite := testenv.TempDir(t)
|
||||
taskDir := filepath.Join(suite, "tasks", "demo")
|
||||
if err := os.MkdirAll(filepath.Join(taskDir, "memory", "project"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -129,7 +130,7 @@ func TestRunTaskDropsIsolationConfigBeforeGrading(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte(verify), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(t.TempDir(), "fake-agent")
|
||||
bin := filepath.Join(testenv.TempDir(t), "fake-agent")
|
||||
script := `#!/usr/bin/env bash
|
||||
set -e
|
||||
test -f reasonix.toml
|
||||
@@ -154,7 +155,7 @@ done
|
||||
|
||||
func TestMemoryBenchRejectsAnswerReadInTrajectory(t *testing.T) {
|
||||
requireShellStub(t)
|
||||
suite := t.TempDir()
|
||||
suite := testenv.TempDir(t)
|
||||
taskDir := filepath.Join(suite, "tasks", "demo")
|
||||
answer := filepath.Join(taskDir, "memory", "project", "fact.md")
|
||||
if err := os.MkdirAll(filepath.Dir(answer), 0o755); err != nil {
|
||||
@@ -166,7 +167,7 @@ func TestMemoryBenchRejectsAnswerReadInTrajectory(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte("#!/bin/sh\ntest -f answer.txt\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(t.TempDir(), "fake-agent")
|
||||
bin := filepath.Join(testenv.TempDir(t), "fake-agent")
|
||||
script := `#!/usr/bin/env bash
|
||||
set -e
|
||||
printf 'ok\n' > answer.txt
|
||||
@@ -194,7 +195,7 @@ done
|
||||
|
||||
func TestMissingAnswerTrajectoryKeepsOtherTasksRunning(t *testing.T) {
|
||||
requireShellStub(t)
|
||||
root := filepath.Join(t.TempDir(), "tasks")
|
||||
root := filepath.Join(testenv.TempDir(t), "tasks")
|
||||
var tasks []task
|
||||
for _, id := range []string{"missing", "complete"} {
|
||||
dir := filepath.Join(root, id)
|
||||
@@ -206,7 +207,7 @@ func TestMissingAnswerTrajectoryKeepsOtherTasksRunning(t *testing.T) {
|
||||
}
|
||||
tasks = append(tasks, task{ID: id, Prompt: id, TimeoutSec: 30, dir: dir, answerRoot: root})
|
||||
}
|
||||
bin := filepath.Join(t.TempDir(), "fake-agent")
|
||||
bin := filepath.Join(testenv.TempDir(t), "fake-agent")
|
||||
script := `#!/usr/bin/env bash
|
||||
set -e
|
||||
printf 'ok\n' > answer.txt
|
||||
@@ -251,7 +252,7 @@ func TestAnswerTrajectoryAuditDistinguishesReadsFromListingsAndDenials(t *testin
|
||||
{"glob lists name only", `{"event":{"kind":"tool_result","tool":{"name":"glob","args":"{\"pattern\":\"/repo/tasks/demo/memory/project/*.md\"}","output":"/repo/tasks/demo/memory/project/fact.md"}}}`, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "run.trajectory.jsonl")
|
||||
path := filepath.Join(testenv.TempDir(t), "run.trajectory.jsonl")
|
||||
if err := os.WriteFile(path, []byte(tc.data+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -264,7 +265,7 @@ func TestAnswerTrajectoryAuditDistinguishesReadsFromListingsAndDenials(t *testin
|
||||
}
|
||||
|
||||
func TestAnswerTrajectoryAuditScansEarlierSegments(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "tasks")
|
||||
root := filepath.Join(testenv.TempDir(t), "tasks")
|
||||
answer := filepath.Join(root, "demo", "memory", "project", "fact.md")
|
||||
if err := os.MkdirAll(filepath.Dir(answer), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -272,7 +273,7 @@ func TestAnswerTrajectoryAuditScansEarlierSegments(t *testing.T) {
|
||||
if err := os.WriteFile(answer, []byte("---\nname: fact\n---\nA fact body in the first trajectory leg.\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
for index, data := range []string{
|
||||
`{"event":{"kind":"tool_result","tool":{"name":"bash","args":"{\"command\":\"cat /repo/tasks/demo/memory/project/fact.md\"}","output":"A fact body in the first trajectory leg."}}}`,
|
||||
`{"event":{"kind":"text","text":"done"}}`,
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
// fixtureRows is a synthetic batch: three solvable tasks, one of them solved on
|
||||
@@ -64,7 +66,7 @@ func timeAt(s string) time.Time {
|
||||
|
||||
func writeFixtureBatch(t *testing.T) (string, batchManifest) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
if err := writeBatch(fixtureProvenance(dir), fixtureRows()); err != nil {
|
||||
t.Fatalf("write batch: %v", err)
|
||||
}
|
||||
@@ -174,7 +176,7 @@ func TestBatchAuditRoundTrip(t *testing.T) {
|
||||
// The axes carry what changes a figure's meaning, never the operator's machine:
|
||||
// meter/tape/trajectory paths and steer text stay out of the manifest.
|
||||
func TestBatchAxesKeepPathsAndFreeTextOut(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
prov := fixtureProvenance(dir)
|
||||
prov.axes = batchAxes(suiteConfig{
|
||||
profile: "delivery", cacheArm: "warm", effort: "high", anchor: "blind",
|
||||
@@ -260,7 +262,7 @@ func TestBatchAuditNamesTheFigureThatMoved(t *testing.T) {
|
||||
// manifest, no commit to tie the figures to, and rows cut short.
|
||||
func TestBatchAuditRefusesUnverifiableBatches(t *testing.T) {
|
||||
t.Run("missing manifest", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
writeRows(t, dir, fixtureRows())
|
||||
out, err := auditBatch(dir)
|
||||
if err == nil {
|
||||
@@ -387,7 +389,7 @@ const manifestWithoutOptionals = `{
|
||||
`
|
||||
|
||||
func TestBatchAuditReadsAbsentFieldsAsUnknown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
writeRows(t, dir, fixtureRows())
|
||||
_, canonical, err := readBatch(filepath.Join(dir, batchResultsName))
|
||||
if err != nil {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
fileencoding "reasonix/internal/base/fileutil/encoding"
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
// benchmarksRoot holds every committed task corpus. Validation discovers the
|
||||
@@ -363,7 +364,7 @@ func fullDecisions() map[authoringRule]ruleDecision {
|
||||
func TestCorpusPolicyValidationCatchesGaps(t *testing.T) {
|
||||
universe := func(t *testing.T, corpora ...string) (string, []corpusPolicy) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
var policies []corpusPolicy
|
||||
for _, name := range corpora {
|
||||
dir := tempCorpus(t, root, name, "t-"+name, "")
|
||||
@@ -437,7 +438,7 @@ func TestCorpusPolicyValidationCatchesGaps(t *testing.T) {
|
||||
name: "a registry entry that shadows the root's corpus by name",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
elsewhere := tempCorpus(t, t.TempDir(), "registered", "t-impostor", "")
|
||||
elsewhere := tempCorpus(t, testenv.TempDir(t), "registered", "t-impostor", "")
|
||||
policies[0].Dir = elsewhere
|
||||
return root, policies
|
||||
},
|
||||
@@ -505,7 +506,7 @@ func TestStopPolicyAppliesToEveryRegisteredCorpus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
corpus string
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestWilsonAndNewcombeMatchKnownValues(t *testing.T) {
|
||||
@@ -42,7 +44,7 @@ func writeReport(t *testing.T, dir, name, capability string, pass int, n int) st
|
||||
}
|
||||
|
||||
func TestReproductionSection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
a := writeReport(t, dir, "a.json", "sha256:aaaaaaaaaaaaaaaa", 40, 50)
|
||||
same := writeReport(t, dir, "b.json", "sha256:aaaaaaaaaaaaaaaa", 38, 50)
|
||||
got := reproductionSection(a, same)
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func withFakeIO(t *testing.T, w func(string, []byte, os.FileMode) error, r func(string) ([]byte, error)) {
|
||||
@@ -38,7 +40,7 @@ func TestRestoreSourceFailsWhenTheBytesDoNotComeBack(t *testing.T) {
|
||||
|
||||
// The real filesystem is the other witness: writing over a directory fails.
|
||||
func TestRestoreSourceFailsOnARealWriteError(t *testing.T) {
|
||||
if err := restoreSource(t.TempDir(), []byte("original")); err == nil {
|
||||
if err := restoreSource(testenv.TempDir(t), []byte("original")); err == nil {
|
||||
t.Fatal("restoring onto a directory reported success")
|
||||
}
|
||||
}
|
||||
@@ -47,7 +49,7 @@ func TestTreeMatchesSeesADirtyTree(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git unavailable")
|
||||
}
|
||||
repo := t.TempDir()
|
||||
repo := testenv.TempDir(t)
|
||||
git := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
@@ -88,7 +90,7 @@ func TestRunMutationFailsWhenTheRestoreFails(t *testing.T) {
|
||||
if _, err := exec.LookPath("go"); err != nil {
|
||||
t.Skip("go unavailable")
|
||||
}
|
||||
repo := t.TempDir()
|
||||
repo := testenv.TempDir(t)
|
||||
git := func(args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
@@ -150,7 +152,7 @@ func TestDifferentialPerTestFailsWhenRestoreFails(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git unavailable")
|
||||
}
|
||||
repo := t.TempDir()
|
||||
repo := testenv.TempDir(t)
|
||||
git := func(args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
@@ -197,7 +199,7 @@ func TestDifferentialPerTestFailsWhenRestoreFails(t *testing.T) {
|
||||
// tree must not be left on base just because the failure happened early. The
|
||||
// defer that restores is registered before the check for exactly this path.
|
||||
func TestDifferentialPerTestRestoresWhenTheBaseCheckFails(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
repo := testenv.TempDir(t)
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
|
||||
|
||||
@@ -29,7 +29,7 @@ printf '%%s' "$metrics" > metrics-path.txt
|
||||
printf '%%s' '%s' > "$metrics%s"
|
||||
exit %d
|
||||
`, body, suffix, code)
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
path := filepath.Join(testenv.TempDir(t), name)
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write stub: %v", err)
|
||||
}
|
||||
@@ -73,7 +73,7 @@ case "$metrics" in
|
||||
esac
|
||||
exit 0
|
||||
`
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
path := filepath.Join(testenv.TempDir(t), name)
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write stub: %v", err)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
const tapedStream = "data: {\"choices\":[{\"delta\":{\"content\":\"fixed\"}}]}\n\n" +
|
||||
@@ -44,7 +46,7 @@ func readAll(t *testing.T, resp *http.Response) string {
|
||||
// A run replayed from its tape gets the recorded stream byte for byte, never
|
||||
// reaches the provider, and is metered as the recorded run was.
|
||||
func TestTapeReplaysARecordedRunWithoutTheProvider(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
var calls atomic.Int32
|
||||
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
live := readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
@@ -71,7 +73,7 @@ func TestTapeReplaysARecordedRunWithoutTheProvider(t *testing.T) {
|
||||
// A kernel change that alters what the model is asked shows as the first
|
||||
// request whose body departs from the tape, named down to the message.
|
||||
func TestTapeReplayNamesTheFirstRequestThatDiffers(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
var calls atomic.Int32
|
||||
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
@@ -123,7 +125,7 @@ func waitFor(t *testing.T, what string, cond func() bool) {
|
||||
// A recording whose request file cannot be written is reported rather than
|
||||
// treated as a tape: request.json is what marks the exchange complete.
|
||||
func TestTapeSaveFailureIsReportedOnTheMeter(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
orig := tapeWriteFile
|
||||
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
|
||||
if strings.HasSuffix(path, "request.json") {
|
||||
@@ -161,7 +163,7 @@ func TestTapeSaveFailureIsReportedOnTheMeter(t *testing.T) {
|
||||
// A tape with no recorded request is incomplete, not a divergence: there is
|
||||
// nothing to compare against, and blaming the harness would be wrong.
|
||||
func TestTapeMissingRequestIsIncompleteNotADivergence(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
var calls atomic.Int32
|
||||
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
@@ -193,7 +195,7 @@ func TestTapeMissingRequestIsIncompleteNotADivergence(t *testing.T) {
|
||||
// first: stopping is what waits for the handlers whose deferred writes carry the
|
||||
// tape counters. Snapshotting before that misses the last exchange.
|
||||
func TestMeterRecordDrainsHandlersBeforeSnapshot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
var calls atomic.Int32
|
||||
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
defer stop()
|
||||
@@ -214,7 +216,7 @@ func TestMeterRecordDrainsHandlersBeforeSnapshot(t *testing.T) {
|
||||
// Re-recording over an existing tape must not let the previous run's
|
||||
// completeness marker make a partial new exchange look complete.
|
||||
func TestTapeReRecordClearsStaleCompletenessMarkers(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
var calls atomic.Int32
|
||||
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
@@ -263,7 +265,7 @@ func TestTapeReRecordClearsStaleCompletenessMarkers(t *testing.T) {
|
||||
// incomplete exchange and prints no line about one. Only the failure paths were
|
||||
// pinned before.
|
||||
func TestTapeCompleteRecordingReportsNothingIncomplete(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
var calls atomic.Int32
|
||||
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
defer stop()
|
||||
|
||||
@@ -7,11 +7,12 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
func TestUserChoseDirRejectsWhatALauncherInherits(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
base := testenv.TempDir(t)
|
||||
app := filepath.Join(base, "ReasonixStudio")
|
||||
exe := filepath.Join(app, "Reasonix Studio.exe")
|
||||
cases := []struct {
|
||||
@@ -33,7 +34,7 @@ func TestUserChoseDirRejectsWhatALauncherInherits(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
base := testenv.TempDir(t)
|
||||
app := filepath.Join(base, "ReasonixStudio")
|
||||
exe := filepath.Join(app, "Reasonix Studio.exe")
|
||||
mk := func(name string) string {
|
||||
@@ -68,7 +69,7 @@ func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
base := testenv.TempDir(t)
|
||||
home := filepath.Join(base, "state")
|
||||
app := filepath.Join(base, "app")
|
||||
a, b := filepath.Join(base, "a"), filepath.Join(base, "b")
|
||||
@@ -96,7 +97,7 @@ func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLaunchWithNothingChosenOrRememberedOpensTheAutoWorkspaceNotHome(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
base := testenv.TempDir(t)
|
||||
state := filepath.Join(base, "state")
|
||||
home := filepath.Join(base, "home")
|
||||
app := filepath.Join(base, "app")
|
||||
@@ -127,7 +128,7 @@ func TestLaunchWithNothingChosenOrRememberedOpensTheAutoWorkspaceNotHome(t *test
|
||||
}
|
||||
|
||||
func TestPickLaunchWorkspaceCreatesTheAutoWorkspaceLast(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
base := testenv.TempDir(t)
|
||||
app := filepath.Join(base, "app")
|
||||
exe := filepath.Join(app, "Reasonix Studio")
|
||||
auto := filepath.Join(base, "state", "workspaces", "auto")
|
||||
|
||||
@@ -112,7 +112,7 @@ func (r recordingRunner) Run(_ context.Context, input string) error {
|
||||
// hub, its config path and its capabilities are the ones the host builds.
|
||||
func testHub(t *testing.T, cfg config.ServeConfig) (*serve.Hub, chan string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
root := testenv.TempDir(t)
|
||||
got := make(chan string, 1)
|
||||
bc := serve.NewBroadcaster()
|
||||
ctrl := control.New(control.Options{
|
||||
@@ -382,7 +382,7 @@ func TestOnlyAPipeIsAParentLease(t *testing.T) {
|
||||
t.Error("the null device was read as a parent holding this host open")
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "not-a-pipe")
|
||||
path := filepath.Join(testenv.TempDir(t), "not-a-pipe")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -399,7 +399,7 @@ func TestOnlyAPipeIsAParentLease(t *testing.T) {
|
||||
// The whole run, driven the way Electron drives it: a pipe for the lease, one
|
||||
// line of handshake, traffic, then the parent letting go.
|
||||
func TestRunServesUntilTheParentLetsGo(t *testing.T) {
|
||||
page := t.TempDir()
|
||||
page := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(page, "index.html"), []byte("<title>studio</title>"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/platform/packagegrant"
|
||||
)
|
||||
|
||||
func TestStripPackageGrantsOnlyAnswersForAnApplication(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
for name, app := range map[string]string{
|
||||
"no application": "",
|
||||
"a path that is nothing": filepath.Join(dir, "missing.exe"),
|
||||
@@ -27,7 +28,7 @@ func TestStripPackageGrantsOnlyAnswersForAnApplication(t *testing.T) {
|
||||
// The shell reads this line as structure, so both lists are present even when
|
||||
// empty: a null would be a third answer it has to guess about.
|
||||
func TestStripPackageGrantsReportsOneLineOfJSON(t *testing.T) {
|
||||
app := filepath.Join(t.TempDir(), "Reasonix Studio.exe")
|
||||
app := filepath.Join(testenv.TempDir(t), "Reasonix Studio.exe")
|
||||
if err := os.WriteFile(app, []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestLinuxReleaseBuildsAndCollectsBothNativeArchitectures(t *testing.T) {
|
||||
@@ -84,7 +86,7 @@ func TestLinuxCIPackageMatrixPreservesUpstreamScope(t *testing.T) {
|
||||
{"pull_request", "desktop/electron/test/unit.mjs", true},
|
||||
} {
|
||||
t.Run(tc.event+"/"+tc.file, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
// A shell function owns command lookup on every host, including Git
|
||||
// Bash on Windows; a PATH shim can resolve the runner's real git.
|
||||
git := "git() { case \"$*\" in 'diff -z --name-only --no-renames fixture HEAD') printf '%s\\0' \"$MATRIX_TEST_FILE\" ;; 'diff --name-only --no-renames fixture HEAD') printf '%s\\n' \"$MATRIX_TEST_FILE\" ;; *) return 1 ;; esac; };\n"
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"aead.dev/minisign"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/platform/update"
|
||||
)
|
||||
|
||||
@@ -28,7 +29,7 @@ func TestSignFiles(t *testing.T) {
|
||||
t.Setenv("MINISIGN_PRIVATE_KEY", string(enc))
|
||||
t.Setenv("MINISIGN_PASSWORD", "pw")
|
||||
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
artifact := filepath.Join(dir, "Reasonix-linux-amd64.tar.gz")
|
||||
payload := []byte("pretend this is a release tarball")
|
||||
if err := os.WriteFile(artifact, payload, 0o644); err != nil {
|
||||
@@ -51,7 +52,7 @@ func TestSignFiles(t *testing.T) {
|
||||
// every platform is listed with a download URL, a parallel .minisig URL, and a
|
||||
// non-empty digest. The .minisig and latest.json files must be ignored.
|
||||
func TestGenManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
names := []string{
|
||||
"Reasonix-darwin-arm64.zip",
|
||||
"Reasonix-darwin-amd64.zip",
|
||||
@@ -158,7 +159,7 @@ func TestGenManifest(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenManifestCanReuseStableNotesForStandaloneRC(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "Reasonix-linux-amd64.tar.gz"), []byte("rc"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -181,7 +182,7 @@ func TestGenManifestCanReuseStableNotesForStandaloneRC(t *testing.T) {
|
||||
// TestGenManifestIgnoresUnknownNativePackages ensures a .deb without a known
|
||||
// platform key is skipped rather than inventing a native_packages entry.
|
||||
func TestGenManifestIgnoresUnknownNativePackages(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
for _, n := range []string{
|
||||
"Reasonix-linux-amd64.tar.gz",
|
||||
"Reasonix-mystery.deb",
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/platform/update"
|
||||
)
|
||||
|
||||
@@ -13,7 +14,7 @@ import (
|
||||
// the shared chunk store named; an unsigned index fails the release instead of
|
||||
// being offered.
|
||||
func TestManifestListsEachSignedDeltaFromTheMirror(t *testing.T) {
|
||||
dir, deltaDir := t.TempDir(), t.TempDir()
|
||||
dir, deltaDir := testenv.TempDir(t), testenv.TempDir(t)
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
if err := os.WriteFile(filepath.Join(dir, "ReasonixStudio-windows-amd64-installer.exe"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -9,11 +9,12 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/platform/update"
|
||||
)
|
||||
|
||||
func TestManifestRecordsEveryArtifactWithItsSignature(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
body := []byte("studio archive bytes")
|
||||
for _, name := range []string{
|
||||
@@ -71,7 +72,7 @@ func TestManifestRecordsEveryArtifactWithItsSignature(t *testing.T) {
|
||||
// on the Studio side does. Only what installs itself may resolve as a platform
|
||||
// asset, or the version panel offers a move it cannot make.
|
||||
func TestManifestResolvesOnlyWhatCanInstallItself(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
for _, name := range []string{
|
||||
"ReasonixStudio-linux-amd64.tar.gz",
|
||||
@@ -128,7 +129,7 @@ func TestManifestResolvesOnlyWhatCanInstallItself(t *testing.T) {
|
||||
|
||||
func TestEmptyDirIsAFailedRelease(t *testing.T) {
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
if err := run(t.TempDir(), "v0.1.0", "studio-v0.1.0", "", false); err == nil {
|
||||
if err := run(testenv.TempDir(t), "v0.1.0", "studio-v0.1.0", "", false); err == nil {
|
||||
t.Fatal("a release with no artifacts must fail, not publish an empty manifest")
|
||||
}
|
||||
}
|
||||
@@ -136,7 +137,7 @@ func TestEmptyDirIsAFailedRelease(t *testing.T) {
|
||||
// A mirrored release is fetched from the mirror first, with GitHub kept as the
|
||||
// address a client falls back to; every full artifact and its signature alike.
|
||||
func TestMirroredManifestPutsTheMirrorFirst(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
for _, name := range []string{
|
||||
"ReasonixStudio-windows-amd64-installer.exe",
|
||||
@@ -193,7 +194,7 @@ func TestUnmirroredManifestNamesOnlyGitHub(t *testing.T) {
|
||||
// Both Windows architectures ship an installer and a portable archive under
|
||||
// the same naming rule; each installer answers only its own platform key.
|
||||
func TestWindowsArchitecturesResolveToTheirOwnInstallers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
for _, name := range []string{
|
||||
"ReasonixStudio-windows-amd64-installer.exe",
|
||||
@@ -258,7 +259,7 @@ func TestRPMRidesBesideTheDebWithoutMovingItsKey(t *testing.T) {
|
||||
}
|
||||
return m
|
||||
}
|
||||
debOnly, both := t.TempDir(), t.TempDir()
|
||||
debOnly, both := testenv.TempDir(t), testenv.TempDir(t)
|
||||
write(debOnly, "ReasonixStudio-linux-amd64.deb")
|
||||
write(both, "ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.rpm")
|
||||
before, after := generate(debOnly), generate(both)
|
||||
@@ -292,7 +293,7 @@ func TestRPMRidesBesideTheDebWithoutMovingItsKey(t *testing.T) {
|
||||
// the second architecture must not move the first one's entries.
|
||||
func TestLinuxArm64PackagesAreKeyedBesideAmd64(t *testing.T) {
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
generate := func() update.Manifest {
|
||||
if err := run(dir, "v2.39.0", "studio-v2.39.0", "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -345,7 +346,7 @@ func TestLinuxArm64PackagesAreKeyedBesideAmd64(t *testing.T) {
|
||||
// replaces: it must not appear as a platform asset or a native package, and
|
||||
// adding it must not move any entry a shipped client reads.
|
||||
func TestAnAppImageIsOfferedButNeverResolvedAsAnUpdate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dir := testenv.TempDir(t)
|
||||
for _, name := range []string{"ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.AppImage", "ReasonixStudio-linux-arm64.AppImage"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -53,6 +53,12 @@ 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 # 执行过程:在跑的步骤自己在动、收起后由标题接着动,步骤行没有空栏
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// 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";
|
||||
@@ -20,10 +18,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.keyboard.press("Meta+Comma");
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
|
||||
await page.waitForSelector(".prefs-sheet");
|
||||
await page.waitForTimeout(300);
|
||||
const prefs = await box(page, ".prefs-sheet");
|
||||
const prefs = await box(page, ".prefs-screen");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForSelector(".prefs-sheet", { state: "detached" });
|
||||
await page.evaluate(() => document.querySelector('[data-action="feedback.open"]')?.click());
|
||||
@@ -32,7 +30,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 sits in the window's vertical middle`, Math.abs(prefs.top - prefs.bottom) <= 2, JSON.stringify(prefs));
|
||||
check(`${tag}: settings fills the window`, prefs.top === 0 && prefs.bottom === 0, 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));
|
||||
|
||||
@@ -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-sheet", 60);
|
||||
check(`${scheme}: Tab never leaves the settings sheet`, settingsOut.length === 0, settingsOut.slice(0, 3).join("; "));
|
||||
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("; "));
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
|
||||
@@ -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.open-page"][data-target="providers"]').click();
|
||||
await page.locator('[data-action="settings.page-index"][data-target="providers"]').click();
|
||||
await page.getByText("myrelay", { exact: true }).first().click();
|
||||
await page.getByText("从服务商读取可用模型").first().click();
|
||||
await page.waitForSelector(".mrows .mline");
|
||||
|
||||
@@ -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.open-page"][data-target="providers"]')?.click());
|
||||
await page.evaluate(() => document.querySelector('[data-action="settings.page-index"][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" });
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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.open-page"][data-target="peers"]').count()) === 1);
|
||||
check(`${tag}: session messages have a settings entry of their own`, (await page.locator('[data-action="settings.page-index"][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.open-page"][data-target="peers"]').click();
|
||||
await page.locator('[data-action="settings.page-index"][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,5 +1,3 @@
|
||||
// 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";
|
||||
|
||||
@@ -18,9 +16,17 @@ try {
|
||||
const room = await page.evaluate(() => {
|
||||
const main = document.querySelector(".prefs-main").getBoundingClientRect();
|
||||
const col = document.querySelector(".prefs-main .prefs-col").getBoundingClientRect();
|
||||
return { left: Math.round(col.left - main.left), right: Math.round(main.right - col.right), width: Math.round(col.width) };
|
||||
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)) };
|
||||
});
|
||||
assert.ok(room.width <= 1200, `the column keeps its cap (${room.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(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`);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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.open-page"][data-target="providers"]').click();
|
||||
await page.locator('[data-action="settings.page-index"][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,5 +1,3 @@
|
||||
// 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";
|
||||
@@ -17,18 +15,19 @@ 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, cy: r.y + r.height / 2, icon: [svg.width, svg.height], iconCy: svg.y + svg.height / 2 };
|
||||
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 };
|
||||
}));
|
||||
const spread = (key) => Math.max(...strip.map((s) => s[key])) - Math.min(...strip.map((s) => s[key]));
|
||||
check(`${scheme}: the shortcut strip has several buttons`, strip.length >= 5, `${strip.length}`);
|
||||
check(`${scheme}: the shortcut card has several entries`, 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 buttons and their icons sit on one horizontal line`, spread("cy") <= TOLERANCE && spread("iconCy") <= TOLERANCE, `${spread("cy")} / ${spread("iconCy")}`);
|
||||
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 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-feedback"].map((s) => {
|
||||
const rows = await page.evaluate(() => [".studio-new-task", ".studio-search", ".studio-tools-row", ".studio-feedback"].map((s) => {
|
||||
const row = document.querySelector(s);
|
||||
if (!row) return null;
|
||||
const r = row.getBoundingClientRect();
|
||||
|
||||
@@ -16,6 +16,7 @@ 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) => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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,14 +25,18 @@ try {
|
||||
}
|
||||
await page.locator(".prefs-nav button", { hasText: /^操作与性能/ }).first().click();
|
||||
await page.waitForTimeout(400);
|
||||
await page.getByRole("button", { name: "调整导航" }).click();
|
||||
const keys = await page.locator('#set-keys').elementHandle();
|
||||
await page.locator('[data-action="settings.page-index"][data-target="naventries"]').click();
|
||||
await page.waitForTimeout(600);
|
||||
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(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 };
|
||||
});
|
||||
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.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.deepEqual(errors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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();
|
||||
}
|
||||
@@ -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.open-page"][data-target="size"]').click();
|
||||
await page.locator('[data-action="settings.page-index"][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` });
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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();
|
||||
}
|
||||
@@ -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.open-page"][data-target="web-access"]').click();
|
||||
await page.locator('[data-action="settings.page-index"][data-target="web-access"]').click();
|
||||
await page.waitForSelector("#set-web-access");
|
||||
const r = await page.evaluate(() => {
|
||||
const g = document.querySelector("#set-web-access");
|
||||
|
||||
@@ -577,7 +577,6 @@ 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" },
|
||||
@@ -651,8 +650,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.open-page", kind: "navigation", target: "entity", proof: "interaction" },
|
||||
{ id: "settings.close-page", 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.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,11 +90,12 @@
|
||||
.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 { outline: 2px solid var(--focus); outline-offset: 2px; box-shadow: none; }
|
||||
.compose .btn.send:focus-visible, .compose .btn.stop: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: 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; }
|
||||
.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; }
|
||||
|
||||
.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 { color: var(--ok-ink); }
|
||||
.onb-demo .d.tool small.ok { display: inline-flex; align-items: center; gap: 4px; 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: 100; stroke-dashoffset: 100; animation: demo-draw 300ms ease-out forwards; }
|
||||
.onb-flow .onb-go .onb-check path { stroke-dasharray: 32; stroke-dashoffset: 32; 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,7 +1,6 @@
|
||||
[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; }
|
||||
@@ -9,6 +8,7 @@
|
||||
[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,13 +29,6 @@ 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/);
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
[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); }
|
||||
@@ -48,11 +47,8 @@
|
||||
[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 { background: var(--accent); color: var(--accent-fg); }
|
||||
[data-fold~="phone"] .compose .go .btn.send:not(:disabled) { 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,15 +119,13 @@ 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 with the pause mark", () => {
|
||||
it("makes send a 36px accent circle, and stop the same size", () => {
|
||||
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')).toMatch(/background:\s*var\(--accent\)/);
|
||||
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.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,10 +18,9 @@ describe("the settings search results", () => {
|
||||
expect(found).toMatch(/width:\s*auto/);
|
||||
});
|
||||
|
||||
it("cover the whole column, leaving the header row's height empty and the close button above them", () => {
|
||||
it("cover the whole column, leaving room above the results", () => {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,12 @@
|
||||
.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 {
|
||||
@@ -10,30 +19,37 @@
|
||||
.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: 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-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-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 h3 { font: var(--w-med) var(--fs-ui)/1.4 var(--ui); color: var(--text); }
|
||||
.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 .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 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 { 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.peerfield { width: 100%; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.prefs-col .prefs-card > .grp .grp-hd h3 { font-size: var(--fs-ui); font-weight: var(--w-med); }
|
||||
.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-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); }
|
||||
@@ -58,8 +74,7 @@
|
||||
.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-actions { grid-row: 1; position: absolute; top: 10px; right: 14px; z-index: 6; padding: 0; }
|
||||
.prefs-pagetitle { padding-right: 150px; }
|
||||
.prefs-pagetitle { padding-right: 0; }
|
||||
.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; }
|
||||
@@ -103,7 +118,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 h3 { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
.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"] .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; }
|
||||
@@ -252,7 +267,6 @@
|
||||
.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;
|
||||
@@ -263,28 +277,19 @@
|
||||
.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 h3 { color: var(--muted); font: var(--w-semi) var(--fs-meta) var(--ui); }
|
||||
.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 .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); }
|
||||
@@ -363,9 +368,6 @@
|
||||
.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; }
|
||||
@@ -388,7 +390,8 @@
|
||||
.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 { width: 64px; text-align: right; font: var(--w-reg) var(--fs-meta) var(--mono); }
|
||||
.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 .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; }
|
||||
@@ -406,13 +409,12 @@
|
||||
.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 h3 { font: var(--w-med) 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 .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); }
|
||||
@@ -436,24 +438,20 @@
|
||||
.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: 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; }
|
||||
}
|
||||
.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); }
|
||||
.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: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.prefs-card > .grp > .hint, .srow-desc { overflow-wrap: anywhere; white-space: normal; }
|
||||
.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 { 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; }
|
||||
.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; }
|
||||
.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; }
|
||||
@@ -479,4 +477,3 @@
|
||||
.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; }
|
||||
@@ -39,17 +39,25 @@
|
||||
|
||||
.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-quicknav { display: flex; align-items: center; gap: 2px; margin: 0; padding: 0 2px; }
|
||||
.studio-quicknav > .qspace { flex: 1; }
|
||||
.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 > button {
|
||||
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;
|
||||
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;
|
||||
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,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" },
|
||||
|
||||
@@ -253,11 +253,9 @@ 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" },
|
||||
|
||||
};
|
||||
@@ -123,15 +123,13 @@ 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." },
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
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" },
|
||||
@@ -38,7 +39,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}" },
|
||||
@@ -181,7 +182,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." },
|
||||
@@ -208,41 +209,40 @@ 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,28 +251,22 @@ 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: "Which entries the left rail shows, and in what order" },
|
||||
"settings.which_entries_the_left_rail_shows": { zh: "选择图标栏与侧栏工具卡片显示的入口和顺序", en: "Choose the entries and order for the icon rail and sidebar tools card" },
|
||||
"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" },
|
||||
|
||||
@@ -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 plan (estimated at the prices above)" },
|
||||
"usage.subscription_plan_estimated_at_the": { zh: "订阅套餐(估算)", en: "Subscription estimate" },
|
||||
"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" },
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -12,23 +12,37 @@ export function thoughtStartedAt(id: string): number | undefined {
|
||||
return thoughtSince.get(id);
|
||||
}
|
||||
|
||||
const isWaitingLine = (item: Item) => item.t === "user" && !!item.pending;
|
||||
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;
|
||||
}
|
||||
|
||||
export function appendText(items: Item[], text: string, field: "text" | "reasoning", source?: string, model?: string): Item[] {
|
||||
// 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 at = openSayIndex(items);
|
||||
if (field === "text") items = stopThought(items);
|
||||
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;
|
||||
}
|
||||
@@ -69,16 +83,10 @@ 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[] {
|
||||
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;
|
||||
}
|
||||
}
|
||||
const at = openSayIndex(items);
|
||||
// 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 || undefined;
|
||||
const measured = ev.thoughtMs;
|
||||
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 }];
|
||||
|
||||
@@ -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 } from "./say";
|
||||
import { appendText, foldMessage, sealSay, stopThought } from "./say";
|
||||
import { nextId } from "./ids";
|
||||
import { worked } from "./worked";
|
||||
import { chipLabel, IDLE, RUNNING } from "./chip";
|
||||
@@ -349,7 +349,10 @@ 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") return s.running === ev.running ? s : { ...s, running: ev.running };
|
||||
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 };
|
||||
}
|
||||
// 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.
|
||||
@@ -400,7 +403,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(s.items, ev.tool, true) }
|
||||
? { ...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;
|
||||
|
||||
case "tool_progress":
|
||||
|
||||
@@ -63,6 +63,13 @@ 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
|
||||
@@ -97,6 +104,9 @@ and selected states, while long titles truncate within the remaining width.
|
||||
|
||||
## 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
|
||||
@@ -218,6 +228,9 @@ and selected states, while long titles truncate within the remaining width.
|
||||
|
||||
## 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.
|
||||
@@ -311,6 +324,9 @@ and selected states, while long titles truncate within the remaining width.
|
||||
1 CSS px in both themes at 100% and 125% device scale.
|
||||
- The compact account button has no row divider; the footer owns the divider
|
||||
above all three controls.
|
||||
- Inside the workbench the file tree is the left column and the open file the right, in the dock as
|
||||
well as full width. Choosing a file leaves the tree open; choosing any other surface takes the
|
||||
whole column and folds it. Below 760px the same rule holds with the tree folded by default.
|
||||
- The docked workbench uses the divider's remembered width. Pointer dragging and
|
||||
keyboard resizing share that width; there is no intermediate width preset.
|
||||
- Full-workbench view preserves the divider width and keeps the conversation and
|
||||
@@ -856,81 +872,35 @@ 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.
|
||||
|
||||
## The settings sheet
|
||||
## Full-screen settings
|
||||
|
||||
- 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 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.
|
||||
|
||||
## 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
|
||||
@@ -1600,7 +1570,7 @@ A sub-agent row, by the list's own width:
|
||||
|
||||
## Dialogs centre on the window
|
||||
|
||||
- A veil (settings, feedback, a full screenshot, a checkpoint preview, a
|
||||
- A veil (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.
|
||||
@@ -1608,14 +1578,13 @@ 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"`) shows settings full screen, so it is
|
||||
excluded.
|
||||
- The phone layout (`data-fold~="scene"`) is excluded. Settings owns a full
|
||||
screen at every width and is outside the dialog-centering selector.
|
||||
|
||||
## Settings and feedback veils
|
||||
## Feedback veil
|
||||
|
||||
- `.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.
|
||||
- `.fbk-veil` uses a flat, 93% page-colour scrim in both themes without
|
||||
filtering the live 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
|
||||
@@ -1759,3 +1728,25 @@ 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.
|
||||
@@ -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: 1.1 }
|
||||
.winctl .wc svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2 }
|
||||
.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 }
|
||||
@@ -649,18 +649,18 @@
|
||||
.app[data-browser="off"] .pane:has(> .pbody[data-dock]) > .scroll[data-pane="browser"] { visibility: hidden; transition-property: visibility; transition-duration: 0s; transition-delay: var(--t-panel) }
|
||||
.pane:has(> .pbody[data-dock]) .workbench-body:not([data-files]) { grid-template-columns: minmax(0, 1fr) }
|
||||
.pane:has(> .pbody[data-dock]) .workbench-body:not([data-files]) .workbench-explorer { display: none }
|
||||
.pane:has(> .pbody[data-dock]) .workbench-body[data-files] { grid-template-columns: minmax(0, 1fr) }
|
||||
.pane:has(> .pbody[data-dock]) .workbench-body[data-files] .workbench-canvas { display: none }
|
||||
.pane:has(> .pbody[data-dock]) .workbench-body:not([data-files]) .workbench-canvas { grid-column: 1 }
|
||||
.bpanel { flex: 1; display: flex; flex-direction: column; min-height: 0 }
|
||||
.bbar { flex: none; display: flex; flex-direction: column; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--hair) }
|
||||
.bbar .vtabs { overflow-x: auto }
|
||||
.btab { max-width: 220px; overflow: hidden; text-overflow: ellipsis }
|
||||
.bnav { display: flex; align-items: center; gap: 4px }
|
||||
.bbtn {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
.bbtn:hover { color: var(--text); background: var(--overlay) }
|
||||
.bbtn:hover:not(:disabled) { color: var(--text); background: var(--overlay) }
|
||||
.bbtn:disabled { opacity: .3; cursor: default }
|
||||
.bbtn:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px }
|
||||
.baddr { flex: 1; min-width: 0 }
|
||||
.baddr input {
|
||||
@@ -827,7 +827,6 @@
|
||||
.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 +855,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 +1177,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: 10px }
|
||||
.comp-l .row .k { color: var(--ok); flex: none; width: 10px; font-size: var(--fs-micro) }
|
||||
.comp-l .row .x { color: var(--del); flex: none; width: 16px }
|
||||
.comp-l .row .k { color: var(--ok); flex: none; width: 16px }
|
||||
|
||||
.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 { font: var(--w-reg) var(--fs-micro)/1.5 var(--mono); width: 10px; flex: none; color: var(--ghost) }
|
||||
.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[data-done] { color: var(--faint) }
|
||||
.steps .s[data-done] .b { color: var(--ok) }
|
||||
.steps .s[data-now] { color: var(--text) }
|
||||
@@ -1364,7 +1363,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 +1555,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 +1574,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 i svg {
|
||||
.policy .vl .polwarn svg, .cachemoved > .studio-icon {
|
||||
width: 12px; height: 12px; fill: none; stroke: currentColor;
|
||||
stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round;
|
||||
}
|
||||
@@ -1587,7 +1586,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 +1792,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 i { font-style: normal; display: inline-grid; vertical-align: -1.5px }
|
||||
.cachemoved > .studio-icon { align-self: center }
|
||||
|
||||
.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 +1984,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 +2108,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 +2335,7 @@
|
||||
.advset:not(.appearance-advanced) > .grp:last-child { border-bottom: 0 }
|
||||
.grp-hd { display: flex; align-items: baseline; gap: 12px }
|
||||
|
||||
.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 > .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 .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 +2770,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; border: 0; border-radius: var(--r-xs);
|
||||
flex: none; width: 28px; height: 28px; display: grid; place-items: center; 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 +3295,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: 14px; font-style: normal; color: var(--err) }
|
||||
.probe .mk { flex: none; width: 16px; 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 +3574,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 }
|
||||
.acct-note { font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); color: var(--muted); margin: 0; overflow-wrap: anywhere }
|
||||
.acct-note[data-err] { color: var(--err-ink) }
|
||||
|
||||
.bk { display: flex; flex-direction: column; gap: 12px }
|
||||
@@ -3821,7 +3820,7 @@
|
||||
}
|
||||
.movemenu button:hover { background: var(--float-hi); color: var(--text) }
|
||||
.movemenu button[aria-checked="true"] { color: var(--text) }
|
||||
.movemenu button[aria-checked="true"]::after { content: " ·" }
|
||||
.movemenu button[aria-checked="true"] { display: flex; align-items: center; justify-content: space-between; gap: 8px }
|
||||
|
||||
.drawnby { margin-top: 6px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--ghost) }
|
||||
|
||||
@@ -3894,7 +3893,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 +4019,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,7 +4044,8 @@
|
||||
.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::before { content: "◦"; margin-right: 2px; opacity: .7 }
|
||||
.ptab-host { display: inline-flex; align-items: center; gap: 3px }
|
||||
.ptab-host .studio-icon { width: 12px; height: 12px; 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 }
|
||||
@@ -4208,8 +4208,9 @@
|
||||
.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 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-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 .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 }
|
||||
@@ -4328,6 +4329,7 @@
|
||||
.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);
|
||||
@@ -5184,7 +5186,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::after { content: "›"; margin-left: 8px; }
|
||||
.prefs-pagetitle .group { display: inline-flex; align-items: center; gap: 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; }
|
||||
@@ -5199,7 +5201,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 h3 { font: var(--w-semi) var(--fs-ui)/1.4 var(--ui); letter-spacing: 0; }
|
||||
.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 .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); }
|
||||
@@ -5208,7 +5210,6 @@
|
||||
.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); }
|
||||
|
||||
Loaded 100 of 709 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user