mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-10 01:51:40 -10:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3968f1e744 | ||
|
|
3844e7e558 | ||
|
|
c2c631d622 | ||
|
|
79ca0249d1 | ||
|
|
dcee062283 | ||
|
|
614b45c354 | ||
|
|
d09a7c557c | ||
|
|
c16b08bc40 | ||
|
|
513d19db20 | ||
|
|
f396db7c20 | ||
|
|
49e64b6d13 | ||
|
|
bccfb5d684 | ||
|
|
bd898c3f30 | ||
|
|
29230c68bf | ||
|
|
c12faae30a | ||
|
|
44e3a19f8d | ||
|
|
1738c499df | ||
|
|
9ee11c6b1f | ||
|
|
3af45c0b53 | ||
|
|
643bbad2b3 | ||
|
|
5a1dcb19d2 | ||
|
|
564177d7c2 | ||
|
|
a89b729586 | ||
|
|
6844599056 | ||
|
|
d38c8b99ad | ||
|
|
10b92ed99c | ||
|
|
2738768c4d | ||
|
|
4684e5d7b2 | ||
|
|
06fae21f08 | ||
|
|
d5ee56c1b7 | ||
|
|
45e7f52d70 |
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@ea17c68df8912ef543352723c149a84f56e3d413 # 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@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.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
|
||||
@@ -43,20 +43,20 @@ jobs:
|
||||
if: matrix.branch != ''
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
uses: github/codeql-action/init@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
|
||||
- name: Perform CodeQL Analysis
|
||||
if: matrix.branch == ''
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
uses: github/codeql-action/analyze@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
# Results from a checked-out line are filed under that line's ref, not the
|
||||
# default branch the schedule happened to run from.
|
||||
- name: Perform CodeQL Analysis (${{ matrix.branch }})
|
||||
if: matrix.branch != ''
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
uses: github/codeql-action/analyze@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
ref: refs/heads/${{ matrix.branch }}
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
- uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
- uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
- uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
- uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
@@ -608,7 +608,7 @@ jobs:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
- uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
@@ -911,7 +911,7 @@ jobs:
|
||||
# The page every archive carries beside the executable, built once here
|
||||
# rather than once per target: serve.FindPage looks for
|
||||
# frontend-next/dist next to the binary.
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
- uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
- uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
- uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
@@ -143,6 +143,7 @@ jobs:
|
||||
node perf/hoverradius.mjs
|
||||
node perf/jump-ends.mjs
|
||||
node perf/modelrows.mjs
|
||||
node perf/model-menu-badge.mjs
|
||||
node perf/explorersize.mjs
|
||||
node perf/git-panel.mjs
|
||||
node perf/session-move-to.mjs
|
||||
@@ -157,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
|
||||
@@ -217,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
|
||||
@@ -250,7 +274,7 @@ jobs:
|
||||
cache: true
|
||||
|
||||
- if: steps.scope.outputs.run == 'true'
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
@@ -435,7 +459,7 @@ jobs:
|
||||
cache: true
|
||||
cache-dependency-path: desktop/go.sum
|
||||
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
- uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
# The page sync also copies the repository license into the APK assets.
|
||||
app/src/main/jniLibs/
|
||||
app/src/main/assets/page/
|
||||
app/src/main/assets/LICENSE
|
||||
@@ -1,249 +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.
|
||||
|
||||
## Local host and mobile verification
|
||||
|
||||
The APK packages `cmd/reasonix-studio-host`, the same authenticated local host
|
||||
used by Studio. Android reads its versioned launch handshake from stdout and
|
||||
installs its HttpOnly session cookie before loading the WebView. Stderr remains
|
||||
the diagnostic stream; launch credentials never enter a URL or a log.
|
||||
|
||||
The native container applies the maximum of system-bar, cutout and keyboard
|
||||
insets to every page and overlay. The shared onboarding page has a dedicated layout
|
||||
for narrow viewports. Bundled pages are refreshed using the content-hashed Vite
|
||||
entry, including when installing another APK with the same version number.
|
||||
|
||||
To check the packaged page and real host without a phone, build
|
||||
`go build -o reasonix-verify.exe ./cmd/reasonix-studio-host`, run
|
||||
`android/scripts/sync-page.sh`, then `python android/scripts/verify-host.py`.
|
||||
|
||||
`KERNEL_BIN` can name another host executable. This checks authentication, origin
|
||||
rejection, model discovery, credential saving and first-provider activation in
|
||||
an isolated temporary home with a local test provider.
|
||||
|
||||
## 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 |
|
||||
| `verify-keyboard.mjs <adb-serial> [package]` | Checks keyboard resizing and composer visibility in an unlocked phone's foreground Debug WebView |
|
||||
| `run.sh [debug\|release]` | `adb install`, launch, follow the log |
|
||||
|
||||
`verify-page.sh` runs the isolated local-host smoke check described above.
|
||||
|
||||
## 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, reading the launch handshake, 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.** The host port detects Android's
|
||||
`ReasonixNative` bridge before the page loads. The browser-view bridge installed
|
||||
at page completion is read when needed.
|
||||
|
||||
Touch onboarding offers the current
|
||||
workspace and a native folder picker; desktop keeps its drag-and-drop choice.
|
||||
|
||||
`FolderPicker.kt` owns the storage permission and directory-selection round trip.
|
||||
Granting access resumes the picker. Cancelling or refusing it leaves the current
|
||||
workspace available. Only readable, writable local storage folders become paths
|
||||
for the kernel; document-provider and cloud URIs are not filesystem paths.
|
||||
|
||||
Folder selection and scanning use the existing asynchronous host-call channel.
|
||||
Activity results settle the page's Promise without blocking the WebView thread.
|
||||
Cancelling the camera keeps the connection card usable for another scan.
|
||||
|
||||
Android defaults to `/system/bin/sh` for local commands. This is the system
|
||||
shell, not bundled Bash; Git, language runtimes and build tools are not included.
|
||||
An explicit user shell configuration still takes precedence.
|
||||
|
||||
When command confinement is unavailable, enforced mode uses the existing
|
||||
sandbox-escape confirmation. Refusing it or having no approver keeps the command
|
||||
blocked; the sandbox setting is unchanged.
|
||||
|
||||
## 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,113 +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:requestLegacyExternalStorage="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,107 +0,0 @@
|
||||
package io.reasonix.mobile.app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.io.File
|
||||
|
||||
internal class FolderPicker(private val activity: AppCompatActivity, private val deliver: (String?) -> Unit) {
|
||||
private var startIn = ""
|
||||
private val tree = activity.registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
val path = uri?.let(::realPath)
|
||||
if (uri != null && path == null) message(R.string.workspace_unavailable)
|
||||
deliver(path)
|
||||
}
|
||||
private val settings = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
afterPermission()
|
||||
}
|
||||
private val legacy = activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||
afterPermission()
|
||||
}
|
||||
|
||||
fun hasAccess(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Environment.isExternalStorageManager()
|
||||
} else {
|
||||
ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun pick(path: String): Boolean {
|
||||
startIn = path
|
||||
if (hasAccess()) {
|
||||
launchTree()
|
||||
} else {
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle(R.string.wsperm_title)
|
||||
.setMessage(R.string.wsperm_body)
|
||||
.setPositiveButton(R.string.wsperm_go) { _, _ -> requestAccess() }
|
||||
.setNegativeButton(R.string.wsperm_later) { _, _ -> deliver(null) }
|
||||
.setOnCancelListener { deliver(null) }
|
||||
.show()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun requestAccess() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
legacy.launch(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE))
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
settings.launch(Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:${activity.packageName}")))
|
||||
}.recoverCatching {
|
||||
settings.launch(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION))
|
||||
}.onFailure {
|
||||
message(R.string.workspace_permission_denied)
|
||||
deliver(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun afterPermission() {
|
||||
if (hasAccess()) launchTree() else {
|
||||
message(R.string.workspace_permission_denied)
|
||||
deliver(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchTree() {
|
||||
val root = Environment.getExternalStorageDirectory().absolutePath
|
||||
val relative = if (startIn.startsWith("$root/")) startIn.removePrefix("$root/") else "Documents"
|
||||
val initial = DocumentsContract.buildDocumentUri(AUTHORITY, "primary:$relative")
|
||||
runCatching { tree.launch(initial) }.onFailure {
|
||||
message(R.string.workspace_unavailable)
|
||||
deliver(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun message(resource: Int) = Toast.makeText(activity, resource, Toast.LENGTH_LONG).show()
|
||||
|
||||
private fun realPath(uri: Uri): String? = runCatching {
|
||||
if (uri.authority != AUTHORITY) return@runCatching null
|
||||
val document = DocumentsContract.getTreeDocumentId(uri)
|
||||
val volume = document.substringBefore(':', "")
|
||||
val relative = document.substringAfter(':', "")
|
||||
if (!safeSegment(volume) || (relative.isNotEmpty() && !relative.split('/').all(::safeSegment))) return@runCatching null
|
||||
val root = if (volume == "primary") Environment.getExternalStorageDirectory() else File("/storage", volume)
|
||||
val target = File(root, relative).canonicalFile
|
||||
val base = root.canonicalPath
|
||||
if (target.path != base && !target.path.startsWith(base + File.separator)) return@runCatching null
|
||||
target.path.takeIf { target.isDirectory && target.canRead() && target.canWrite() }
|
||||
}.getOrNull()
|
||||
|
||||
private fun safeSegment(part: String): Boolean = part.isNotEmpty() && part != "." && part != ".." &&
|
||||
part.none { it == '/' || it == '\\' || it == '\u0000' }
|
||||
|
||||
private companion object {
|
||||
const val AUTHORITY = "com.android.externalstorage.documents"
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package io.reasonix.mobile.app
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.net.URI
|
||||
import java.util.concurrent.CompletableFuture
|
||||
|
||||
internal class HostConnection(val origin: String, val token: String) {
|
||||
companion object {
|
||||
fun read(process: Process): CompletableFuture<HostConnection> {
|
||||
val ready = CompletableFuture<HostConnection>()
|
||||
Thread({
|
||||
try {
|
||||
process.inputStream.bufferedReader().use { reader ->
|
||||
val line = reader.readLine() ?: error("应用内核未返回启动信息")
|
||||
val handshake = JSONObject(line)
|
||||
val origin = handshake.getString("origin")
|
||||
val token = handshake.getString("token")
|
||||
val uri = URI(origin)
|
||||
require(handshake.getInt("version") == 1 &&
|
||||
uri.scheme == "http" && uri.host == "127.0.0.1" &&
|
||||
uri.port in 1..65535 && uri.rawPath.orEmpty().isEmpty() &&
|
||||
uri.rawUserInfo == null && uri.rawQuery == null && uri.rawFragment == null &&
|
||||
token.matches(Regex("[0-9a-f]{64}"))) { "应用内核启动信息无效" }
|
||||
ready.complete(HostConnection(origin, token))
|
||||
while (reader.readLine() != null) { }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
ready.completeExceptionally(IllegalStateException("应用内核未返回有效的本地连接"))
|
||||
}
|
||||
}, "kernel-handshake").apply { isDaemon = true }.start()
|
||||
return ready
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,621 +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.MessageDigest
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Owns the local Studio host, its private launch handshake, and its bundled page. */
|
||||
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 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 host's per-launch credential, held only in memory. */
|
||||
@Volatile
|
||||
private var token: String? = null
|
||||
|
||||
/** Installed by the native shell before the WebView makes its first request. */
|
||||
fun sessionCookie(): String? = token?.let { "reasonix_token=$it; Path=/; HttpOnly; SameSite=Strict" }
|
||||
|
||||
/** 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?.takeIf { process?.isAlive == true }?.let { return@withContext Result.success(it) }
|
||||
var child: Process? = null
|
||||
runCatching {
|
||||
val binary = kernelBinary()
|
||||
publishPage()
|
||||
prepareHome()
|
||||
val started = launch(binary)
|
||||
child = started
|
||||
val connection = HostConnection.read(started).get(START_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
awaitServing(started, connection)
|
||||
token = connection.token
|
||||
origin = connection.origin
|
||||
process = started
|
||||
connection.origin
|
||||
}.onFailure {
|
||||
child?.destroyForcibly()
|
||||
origin = null
|
||||
token = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
}
|
||||
|
||||
/** The entry HTML identifies Vite's content-hashed asset graph, including same-version APK rebuilds. */
|
||||
private fun publishPage() {
|
||||
val marker = File(pageDir, ".unpacked")
|
||||
val entry = context.assets.open("page/index.html").use { it.readBytes() }
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(entry).joinToString("") { "%02x".format(it) }
|
||||
val stamp = "${BuildConfig.VERSION_NAME}:${BuildConfig.VERSION_CODE}:$digest"
|
||||
if (marker.isFile && marker.readText().trim() == stamp && File(pageDir, "index.html").isFile) 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()) {
|
||||
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(':', '-')
|
||||
|
||||
private fun launch(binary: File): Process {
|
||||
val workspace = launchWorkspaceDir()
|
||||
val command = listOf(
|
||||
binary.absolutePath,
|
||||
"-page", pageDir.absolutePath,
|
||||
"-studio-version", BuildConfig.VERSION_NAME,
|
||||
)
|
||||
val builder = ProcessBuilder(command)
|
||||
builder.environment().apply {
|
||||
put("REASONIX_HOME", home.absolutePath)
|
||||
put("HOME", home.absolutePath)
|
||||
put("SHELL", "/system/bin/sh")
|
||||
// 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)
|
||||
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.errorStream.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") }
|
||||
|
||||
/**
|
||||
* 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, connection: HostConnection) {
|
||||
val deadline = System.currentTimeMillis() + START_TIMEOUT_MS
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (!process.isAlive) {
|
||||
throw IllegalStateException("内核在开始服务前退出。\n${recentLog().takeLast(2000)}")
|
||||
}
|
||||
if (probe("${connection.origin}/", connection.token)) return
|
||||
Thread.sleep(POLL_MS)
|
||||
}
|
||||
throw IllegalStateException("内核已监听 ${connection.origin},但页面没有响应。\n${recentLog().takeLast(2000)}")
|
||||
}
|
||||
|
||||
private fun probe(url: String, launchToken: String): Boolean = runCatching {
|
||||
(URL(url).openConnection() as HttpURLConnection).run {
|
||||
connectTimeout = 1000
|
||||
readTimeout = 1000
|
||||
requestMethod = "GET"
|
||||
setRequestProperty("Cookie", "reasonix_token=$launchToken")
|
||||
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,619 +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}"
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
companion object {
|
||||
const val BRIDGE_VERSION = "5"
|
||||
private const val PICK_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,158 +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 android.widget.Button
|
||||
import android.widget.Toast
|
||||
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 androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
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)
|
||||
root.addView(Button(this).apply {
|
||||
text = getString(R.string.scan_close)
|
||||
setOnClickListener { setResult(RESULT_CANCELED); finish() }
|
||||
layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP or Gravity.START)
|
||||
})
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
|
||||
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
||||
insets
|
||||
}
|
||||
setContentView(root)
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
start()
|
||||
} else {
|
||||
requestCamera.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refuse(message: Int = R.string.scan_denied) {
|
||||
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
|
||||
setResult(RESULT_CANCELED)
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
val providerFuture = ProcessCameraProvider.getInstance(this)
|
||||
providerFuture.addListener({
|
||||
val provider = runCatching { providerFuture.get() }.getOrNull() ?: return@addListener refuse(R.string.scan_unavailable)
|
||||
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(R.string.scan_unavailable) }
|
||||
}, ContextCompat.getMainExecutor(this))
|
||||
}
|
||||
|
||||
private fun analyse(image: ImageProxy) {
|
||||
try {
|
||||
if (settled) return
|
||||
val text = Qr.decode(image) ?: return
|
||||
if (settled) return
|
||||
settled = true
|
||||
runOnUiThread {
|
||||
setResult(RESULT_OK, Intent().putExtra(EXTRA_TEXT, text))
|
||||
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,10 +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="page_dark">#171A18</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,39 +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">要直接读写手机公共文件夹,需要开启「所有文件访问」权限。点「去设置」授权后返回,会继续选择文件夹。使用应用默认工作区不需要此权限。</string>
|
||||
<string name="wsperm_go">去设置</string>
|
||||
<string name="wsperm_later">以后再说</string>
|
||||
<string name="workspace_permission_denied">未开启文件访问权限,仍可使用应用默认工作区。</string>
|
||||
<string name="workspace_unavailable">无法读写这个位置,请选择手机本地存储中的文件夹。</string>
|
||||
|
||||
<string name="scan_title">扫描电脑上的二维码</string>
|
||||
<string name="scan_hint">对准电脑上「设备访问」里的二维码,识别后即可连接这台电脑。</string>
|
||||
<string name="scan_button">扫码连接电脑</string>
|
||||
<string name="scan_denied">没有相机权限,无法扫码。请在系统设置里允许。</string>
|
||||
<string name="scan_close">返回</string>
|
||||
<string name="scan_unavailable">相机暂时不可用,请关闭其他占用相机的应用后重试。</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,62 +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)}"
|
||||
ldflags="-s -w"
|
||||
ldflags="$ldflags -X main.version=v$app_version"
|
||||
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-studio-host
|
||||
|
||||
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 @@
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
class Provider(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"data":[{"id":"smoke-model","object":"model"}]}')
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
|
||||
android = Path(__file__).resolve().parents[1]
|
||||
kernel = Path(os.environ.get("KERNEL_BIN", android.parent / "reasonix-verify.exe")).resolve()
|
||||
page = android / "app/src/main/assets/page"
|
||||
if not kernel.is_file() or not (page / "index.html").is_file():
|
||||
raise SystemExit("Build ./cmd/reasonix-studio-host as KERNEL_BIN and run sync-page.sh first.")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="reasonix-android-host-") as temporary:
|
||||
home = Path(temporary) / "home"
|
||||
workspace = Path(temporary) / "workspace"
|
||||
home.mkdir()
|
||||
workspace.mkdir()
|
||||
(home / "config.toml").write_text("providers = []\n", encoding="utf-8")
|
||||
env = {**os.environ, "REASONIX_HOME": str(home), "REASONIX_STATE_HOME": str(home),
|
||||
"REASONIX_CREDENTIALS_STORE": "file", "HOME": str(home)}
|
||||
logs = open(Path(temporary) / "host.log", "w", encoding="utf-8")
|
||||
child = subprocess.Popen([str(kernel), "-page", str(page)], cwd=workspace, env=env,
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=logs, text=True)
|
||||
provider = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Provider)
|
||||
threading.Thread(target=provider.serve_forever, daemon=True).start()
|
||||
try:
|
||||
announced = queue.Queue()
|
||||
threading.Thread(target=lambda: announced.put(child.stdout.readline()), daemon=True).start()
|
||||
connection = json.loads(announced.get(timeout=30))
|
||||
origin = connection["origin"]
|
||||
address = urllib.parse.urlsplit(origin)
|
||||
assert connection["version"] == 1 and address.hostname == "127.0.0.1" and address.scheme == "http"
|
||||
|
||||
def request(path, body=None, authenticated=True, request_origin=None):
|
||||
headers = {"Content-Type": "application/json", "Origin": request_origin or origin}
|
||||
if authenticated:
|
||||
headers["Cookie"] = "reasonix_token=" + connection["token"]
|
||||
payload = None if body is None else json.dumps(body).encode()
|
||||
req = urllib.request.Request(origin + path, data=payload, headers=headers)
|
||||
try:
|
||||
response = urllib.request.urlopen(req, timeout=30)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
raw = response.read()
|
||||
return response.status, json.loads(raw) if "json" in response.headers.get("Content-Type", "") else raw
|
||||
|
||||
assert request("/runtimes", authenticated=False)[0] == 403
|
||||
assert request("/runtimes", request_origin="https://untrusted.invalid")[0] == 403
|
||||
assert request("/")[0] == 200
|
||||
status, runtimes = request("/runtimes")
|
||||
assert status == 200
|
||||
entries = runtimes if isinstance(runtimes, list) else runtimes["runtimes"]
|
||||
route = "/rt/" + entries[0]["id"]
|
||||
assert request(route + "/provider-setup")[1]["required"]
|
||||
endpoint = "http://127.0.0.1:" + str(provider.server_port)
|
||||
status, probe = request(route + "/providers/probe", {"baseUrl": endpoint, "apiKey": "sk-smoke"})
|
||||
assert status == 200 and "smoke-model" in probe["models"], (status, probe)
|
||||
status, saved = request(route + "/providers", {
|
||||
"name": "smoke", "kind": "openai", "baseUrl": endpoint, "apiKey": "sk-smoke",
|
||||
"models": ["smoke-model"], "default": "smoke-model",
|
||||
})
|
||||
assert status == 200, (status, saved)
|
||||
status, setup = request(route + "/provider-setup")
|
||||
assert status == 404 or not setup.get("required"), (status, setup)
|
||||
print("Android host: page, authentication, origin guard, provider probe, save and activation passed.")
|
||||
finally:
|
||||
child.stdin.close()
|
||||
try:
|
||||
child.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.wait()
|
||||
child.stdout.close()
|
||||
logs.close()
|
||||
provider.shutdown()
|
||||
provider.server_close()
|
||||
@@ -1,72 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
const serial = process.argv[2];
|
||||
assert(serial, "Usage: node android/scripts/verify-keyboard.mjs <adb-serial> [package]");
|
||||
const pkg = process.argv[3] || "io.reasonix.mobile.app.debug";
|
||||
const sdk = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
||||
const binary = process.platform === "win32" ? "adb.exe" : "adb";
|
||||
const adb = sdk ? path.join(sdk, "platform-tools", binary) : binary;
|
||||
const run = (...args) => execFileSync(adb, ["-s", serial, ...args], { encoding: "utf8", timeout: 15000 });
|
||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const activity = run("shell", "dumpsys", "activity", "activities");
|
||||
assert(activity.split("\n").some((line) => line.includes("topResumedActivity") && line.includes(pkg) && line.includes("MainActivity")), "Unlock the phone and open Reasonix first");
|
||||
const pid = run("shell", "pidof", pkg).trim();
|
||||
const port = run("forward", "tcp:0", `localabstract:webview_devtools_remote_${pid}`).trim();
|
||||
let ws;
|
||||
try {
|
||||
const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(8000) })).json();
|
||||
const target = targets.find((entry) => entry.title === "Reasonix Studio");
|
||||
assert(target, "Reasonix Studio WebView is unavailable");
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => { ws.addEventListener("open", resolve, { once: true }); ws.addEventListener("error", reject, { once: true }); });
|
||||
const pending = new Map();
|
||||
let id = 0;
|
||||
ws.addEventListener("message", (event) => {
|
||||
const reply = JSON.parse(event.data);
|
||||
if (reply.id) pending.get(reply.id)?.(reply);
|
||||
});
|
||||
const call = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const request = ++id;
|
||||
const timer = setTimeout(() => { pending.delete(request); reject(new Error(`${method} timed out`)); }, 8000);
|
||||
pending.set(request, (reply) => {
|
||||
clearTimeout(timer); pending.delete(request);
|
||||
if (reply.error || reply.result?.exceptionDetails) reject(new Error(`${method} failed`));
|
||||
else resolve(reply.result);
|
||||
});
|
||||
ws.send(JSON.stringify({ id: request, method, params }));
|
||||
});
|
||||
const evaluate = async (expression) => (await call("Runtime.evaluate", { expression, returnByValue: true })).result.value;
|
||||
const shown = () => /mInputShown=true/.test(run("shell", "dumpsys", "input_method"));
|
||||
if (shown()) { run("shell", "input", "keyevent", "4"); await pause(500); }
|
||||
const measure = () => evaluate(`(() => {
|
||||
const input = document.querySelector('.compose textarea');
|
||||
const send = document.querySelector('[data-action="session.send"]');
|
||||
return { height: innerHeight, overflow: document.documentElement.scrollWidth - innerWidth,
|
||||
input: input?.getBoundingClientRect().toJSON(), send: send?.getBoundingClientRect().toJSON() };
|
||||
})()`);
|
||||
const before = await measure();
|
||||
assert(before.input?.width > 0, "Open a conversation with its composer visible");
|
||||
try {
|
||||
const point = { x: before.input.x + before.input.width / 2, y: before.input.y + before.input.height / 2 };
|
||||
await call("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [point] });
|
||||
await call("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
let after;
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
await pause(250); after = await measure();
|
||||
if (before.height - after.height > 100) break;
|
||||
}
|
||||
assert(shown(), "The software keyboard did not open");
|
||||
assert(before.height - after.height > 100, `Keyboard covers the page: viewport stayed ${before.height} -> ${after.height}`);
|
||||
assert(after.input.top >= 0 && after.input.bottom <= after.height, "Input is outside the keyboard viewport");
|
||||
assert(after.send.top >= 0 && after.send.bottom <= after.height, "Send button is outside the keyboard viewport");
|
||||
assert(after.overflow <= 1, "Keyboard causes horizontal overflow");
|
||||
console.log(JSON.stringify({ result: "passed", before: before.height, keyboard: after.height, inputBottom: after.input.bottom, sendBottom: after.send.bottom }));
|
||||
} finally {
|
||||
if (shown()) run("shell", "input", "keyevent", "4");
|
||||
}
|
||||
} finally {
|
||||
ws?.close();
|
||||
run("forward", "--remove", `tcp:${port}`);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python "$here/verify-host.py"
|
||||
@@ -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")
|
||||
@@ -1,11 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"reasonix/internal/base/netdns"
|
||||
)
|
||||
|
||||
func init() {
|
||||
netdns.Install(netdns.Parse(os.Getenv(netdns.EnvVar)))
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
"launcher-live": "node test/launcher_live.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "45.1.0",
|
||||
"electron": "45.2.3",
|
||||
"electron-builder": "^26.18.0",
|
||||
"playwright": "1.64.1"
|
||||
},
|
||||
|
||||
Generated
+5
-5
@@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
devDependencies:
|
||||
electron:
|
||||
specifier: 45.1.0
|
||||
version: 45.1.0([email protected])
|
||||
specifier: 45.2.3
|
||||
version: 45.2.3([email protected])
|
||||
electron-builder:
|
||||
specifier: ^26.18.0
|
||||
version: 26.18.0([email protected])([email protected])
|
||||
@@ -374,8 +374,8 @@ packages:
|
||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
electron@45.1.0:
|
||||
resolution: {integrity: sha512-i7qKVAh/KR5v1pf56NhGGX2QYHcdjvLgS7koL6ui50c+zUDGLRunR95nXcTpHPURKOQ4GDBG8BmXiDgOQ8DD0A==}
|
||||
electron@45.2.3:
|
||||
resolution: {integrity: sha512-Vurzu5UOA5nTGT4LqIwXD16QFGrOZdiXxF+1UP4KNsAOo/YtDcT3/u0jhP4WSwNJ0iLrLDi85hzBJ0arnGDhyw==}
|
||||
engines: {node: '>= 22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1551,7 +1551,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron@45.1.0([email protected]):
|
||||
electron@45.2.3([email protected]):
|
||||
dependencies:
|
||||
'@electron-internal/extract-zip': 1.0.5
|
||||
'@electron/get': 5.1.0([email protected])
|
||||
|
||||
@@ -28,7 +28,7 @@ class HostStartError extends Error {
|
||||
function start(binary, args, { onStderr, onExit, onAct, onSlow, systemLanguage, timeoutMs = HANDSHAKE_TIMEOUT_MS } = {}) {
|
||||
const env = { ...process.env };
|
||||
if (systemLanguage) env.REASONIX_SYSTEM_LANG = systemLanguage;
|
||||
const child = spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"], env });
|
||||
const child = spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"], env, windowsHide: true });
|
||||
child.stderr.setEncoding("utf8");
|
||||
if (onStderr) child.stderr.on("data", onStderr);
|
||||
if (onExit) child.on("exit", onExit);
|
||||
|
||||
@@ -6,9 +6,9 @@ const { execFileSync } = require("node:child_process");
|
||||
// one Studio, canonicalized where it lives. The kernel answers it, because the
|
||||
// home is resolved from an environment and a platform default this process
|
||||
// does not own.
|
||||
function instanceID(binary) {
|
||||
function instanceID(binary, run = execFileSync) {
|
||||
try {
|
||||
return execFileSync(binary, ["-instance-id"], { encoding: "utf8", timeout: 10000 }).trim();
|
||||
return run(binary, ["-instance-id"], { encoding: "utf8", timeout: 10000, windowsHide: true }).trim();
|
||||
} catch {
|
||||
// A kernel that cannot be run at all is a launch that is going to fail with
|
||||
// a better message in a moment. Refusing to start here would report it as
|
||||
|
||||
@@ -625,7 +625,7 @@ test("Windows is given the format it reads the small sizes from", () => {
|
||||
assert.equal(iconFile("linux"), "icon.png");
|
||||
});
|
||||
|
||||
const { profileFor } = require("../src/instance.js");
|
||||
const { profileFor, instanceID } = require("../src/instance.js");
|
||||
|
||||
// Two homes are two Studios, and the profile is what carries that into the
|
||||
// platform's own lock. Sharing one would make the second launch look like a
|
||||
@@ -641,6 +641,14 @@ test("each instance gets a profile of its own", () => {
|
||||
assert.equal(profileFor(base, ""), base);
|
||||
});
|
||||
|
||||
test("the instance probe runs the kernel without a console window of its own", () => {
|
||||
let options;
|
||||
const run = (binary, args, opts) => { options = { binary, args, opts }; return "abc123\n"; };
|
||||
assert.equal(instanceID("host", run), "abc123");
|
||||
assert.deepEqual(options.args, ["-instance-id"]);
|
||||
assert.equal(options.opts.windowsHide, true);
|
||||
});
|
||||
|
||||
// A program that cannot be started emits "error" and never "exit". Waiting for
|
||||
// the handshake instead spent the full timeout and then blamed the kernel for
|
||||
// saying nothing, which is where the launch failure on Windows hid: go build
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"census:verify": "node tools/ui-census/verify.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@a2ui/react": "0.14.0",
|
||||
"@a2ui/web_core": "0.14.0",
|
||||
"@a2ui/react": "0.15.0",
|
||||
"@a2ui/web_core": "0.15.0",
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
@@ -32,9 +32,9 @@
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.15",
|
||||
"@lezer/highlight": "^1.2.5",
|
||||
"@mantine/charts": "9.7.4",
|
||||
"@mantine/core": "9.7.4",
|
||||
"@mantine/hooks": "9.7.4",
|
||||
"@mantine/charts": "9.7.5",
|
||||
"@mantine/core": "9.7.5",
|
||||
"@mantine/hooks": "9.7.5",
|
||||
"@noble/curves": "2.4.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
@@ -44,10 +44,10 @@
|
||||
"dompurify": "^3.4.17",
|
||||
"exceljs": "4.4.0",
|
||||
"katex": "^0.19.1",
|
||||
"lucide-react": "^1.64.0",
|
||||
"lucide-react": "^1.69.0",
|
||||
"maplibre-gl": "6.16.0",
|
||||
"papaparse": "^5.7.0",
|
||||
"pdfjs-dist": "^6.4.299",
|
||||
"pdfjs-dist": "^6.5.438",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-map-gl": "8.1.3",
|
||||
@@ -64,18 +64,18 @@
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^8.0.8",
|
||||
"@babel/parser": "^8.1.0",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.8",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"@vitejs/plugin-react": "^6.2.1",
|
||||
"jsdom": "^30.1.3",
|
||||
"jszip": "3.10.2",
|
||||
"playwright": "^1.64.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.5",
|
||||
"vitest": "^5.0.5"
|
||||
"vite": "^8.3.6",
|
||||
"vitest": "^5.1.0"
|
||||
}
|
||||
}
|
||||
@@ -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 # 执行过程:在跑的步骤自己在动、收起后由标题接着动,步骤行没有空栏
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const shots = process.env.AGENT_SETTINGS_SHOTS;
|
||||
if (shots) await mkdir(shots, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const errors = [];
|
||||
try {
|
||||
for (const language of ["zh", "en"]) {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const width of [390, 736, 1024, 1600]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(10000);
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const url = new URL(base);
|
||||
url.searchParams.set("pref", language);
|
||||
url.searchParams.set("turns", "2");
|
||||
await page.goto(url.href, { waitUntil: "networkidle" });
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
|
||||
await page.locator('[data-action="settings.section"][data-value="session"]').click();
|
||||
await page.locator('#set-plan-approval [role="radio"]').first().waitFor();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
assert.equal(await page.locator(".page-index").count(), 0);
|
||||
assert.equal(await page.locator(".prefs-col > .prefs-card").count(), 5);
|
||||
assert.equal(await page.locator("#set-session-ref").isVisible(), true);
|
||||
assert.equal(await page.locator(".prefs-col > details").count(), 0);
|
||||
const layout = await page.evaluate(() => {
|
||||
const col = document.querySelector(".prefs-col");
|
||||
const rows = [...col.querySelectorAll(".srow")].filter((el) => el.checkVisibility());
|
||||
const cards = [...col.querySelectorAll(".prefs-card")].filter((el) => el.checkVisibility());
|
||||
return {
|
||||
columns: getComputedStyle(col).columnCount,
|
||||
lefts: cards.map((el) => Math.round(el.getBoundingClientRect().left)),
|
||||
clipped: rows.filter((el) => {
|
||||
const desc = el.querySelector(".srow-desc");
|
||||
return desc && (getComputedStyle(desc).whiteSpace === "nowrap" || desc.scrollWidth > desc.clientWidth + 1);
|
||||
}).length,
|
||||
overflow: col.scrollWidth - col.clientWidth,
|
||||
note: getComputedStyle(document.querySelector("#set-persona .apply")).display,
|
||||
};
|
||||
});
|
||||
assert.notEqual(layout.columns, "2");
|
||||
assert.equal(new Set(layout.lefts).size, 1);
|
||||
assert.equal(layout.clipped, 0, JSON.stringify(layout));
|
||||
assert.ok(layout.overflow <= 1, JSON.stringify(layout));
|
||||
assert.notEqual(layout.note, "none");
|
||||
if (shots && language === "zh" && [390, 1024].includes(width)) {
|
||||
await page.screenshot({ path: path.join(shots, `agent-settings-${width}-${scheme}.png`) });
|
||||
}
|
||||
assert.ok(await page.locator(".prefs-col").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
|
||||
await page.locator('#set-peers [data-action="settings.open-page"]').click();
|
||||
await page.locator('.deep-page[data-setting="peers"]').waitFor({ state: "visible" });
|
||||
assert.equal(await page.locator(".page-index").count(), 0);
|
||||
await page.locator('[data-action="settings.close-page"]').click();
|
||||
await page.locator("#set-plan-approval").waitFor({ state: "visible" });
|
||||
await page.locator('[data-action-change="settings.search"]').fill("session-ref");
|
||||
await page.locator('.prefs-found [data-target="session-ref"]').click();
|
||||
await page.locator("#set-session-ref").waitFor({ state: "visible" });
|
||||
await page.locator('[data-action-change="settings.search"]').fill("persona");
|
||||
await page.locator('.prefs-found [data-target="persona"]').click();
|
||||
await page.locator('.deep-page #set-persona[data-landed]').waitFor({ state: "visible" });
|
||||
assert.equal(await page.locator("#set-persona").count(), 1);
|
||||
console.log(`PASS agent settings: ${language} ${scheme} ${width}px`);
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -61,10 +61,18 @@ function benchPack(): ThemePack | null {
|
||||
|
||||
class BenchPort extends MockPort {
|
||||
private driverRunning: boolean | undefined;
|
||||
async providerHealthSettings() {
|
||||
return query.has("modelhealth") ? { enabled: true } : super.providerHealthSettings();
|
||||
}
|
||||
async providerHealth(name: string) {
|
||||
return query.has("modelhealth")
|
||||
? { connection: "reachable" as const, wallet: "unsupported" as const, checkedAt: Date.now() }
|
||||
: super.providerHealth(name);
|
||||
}
|
||||
opensFiles() { return query.has("deliveries") || super.opensFiles(); }
|
||||
revealsFiles() { return query.has("deliveries") || super.revealsFiles(); }
|
||||
async account() {
|
||||
const state = query.has("signedin") ? { signedIn: true, user: { handle: "demo", email: "[email protected]", label: "demo" } } : await super.account();
|
||||
const state = await super.account();
|
||||
const label = query.get("accountLabel");
|
||||
return label && state.signedIn && state.user ? { ...state, user: { ...state.user, label } } : state;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -1,28 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
|
||||
const PAGE = new URL(process.env.PERF_URL ?? "http://localhost:4399/perf.html");
|
||||
PAGE.searchParams.set("modelhealth", "1");
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1300, height: 900 } });
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".model-picker");
|
||||
await page.locator(".model-picker").first().click();
|
||||
await page.waitForSelector(".studio-model-menu .studio-item-line");
|
||||
const sizes = await page.evaluate(() => {
|
||||
const lines = [...document.querySelectorAll(".studio-model-menu .studio-item-line")].filter((line) => line.querySelector(".studio-item-badge"));
|
||||
for (const line of lines) line.querySelector(".lb").textContent = "workbuddy/deepseek-v4.1-flash-preview-extended";
|
||||
return lines.map((line) => {
|
||||
const badge = line.querySelector(".studio-item-badge").getBoundingClientRect();
|
||||
return { width: Math.round(badge.width), height: Math.round(badge.height) };
|
||||
await mkdir("perf/shots", { recursive: true });
|
||||
for (const lang of ["zh", "en"]) for (const width of [1300, 760, 390]) for (const scale of [1, 1.3]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: lang === "zh" ? "light" : "dark" });
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.addInitScript((language) => localStorage.setItem("rx-lang", language), lang);
|
||||
await page.goto(PAGE.href, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".model-picker");
|
||||
await page.evaluate((value) => document.documentElement.style.setProperty("--fs-scale", String(value)), scale);
|
||||
await page.locator(".model-picker").first().click();
|
||||
const menu = page.locator(".studio-model-menu:not([hidden])");
|
||||
await menu.locator('.menu-item-status[data-tone="positive"]').first().waitFor();
|
||||
const effortWrapped = await menu.locator(".menu-aside .studio-item-line").evaluateAll((lines) => lines.some((line) => {
|
||||
const meta = line.querySelector(".studio-item-meta");
|
||||
return meta && meta.getBoundingClientRect().top >= line.querySelector(".lb").getBoundingClientRect().bottom;
|
||||
}));
|
||||
assert.ok(!effortWrapped, `${lang}/${width}/${scale}: reasoning choices keep their label and tier together`);
|
||||
const vision = menu.locator(".menu-rows button.mi:has(.studio-item-badge)").first();
|
||||
const visionRef = await vision.getAttribute("data-value");
|
||||
await vision.click();
|
||||
await page.waitForFunction((value) => document.querySelector('.studio-model-menu:not([hidden]) button[data-on]')?.dataset.value === value, visionRef);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator(".model-picker").first().click();
|
||||
await menu.locator('.menu-item-status[data-tone="positive"]').first().waitFor();
|
||||
await menu.evaluate((element) => {
|
||||
for (const line of element.querySelectorAll(".menu-rows .studio-item-line")) {
|
||||
line.querySelector(".lb").textContent = "deepseek-v4.1-flash-preview-extended";
|
||||
}
|
||||
for (const head of element.querySelectorAll(".menu-rows .mi.head")) {
|
||||
head.querySelector(".lb").textContent = "Shanghai_AI_Lab";
|
||||
head.querySelector(".rt").textContent = "discovery-api.intern-ai.org.cn/compatible-mode/v1";
|
||||
}
|
||||
});
|
||||
});
|
||||
assert.ok(sizes.length > 0, "the menu has a model that reads images");
|
||||
for (const size of sizes) assert.ok(size.height <= 20, `a long model name leaves the badge on one line (${JSON.stringify(size)})`);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(`PASS model menu badge stays on one line beside a long name (${sizes.length} rows)`);
|
||||
const measured = await menu.evaluate((element) => {
|
||||
const rows = element.querySelector(".menu-rows");
|
||||
const sizes = [...rows.querySelectorAll(".studio-item-line")].map((line) => {
|
||||
const label = line.querySelector(".lb");
|
||||
const badge = line.querySelector(".studio-item-badge");
|
||||
const status = line.closest("button").querySelector(".menu-item-status");
|
||||
const labelBox = label.getBoundingClientRect();
|
||||
const badgeBox = badge?.getBoundingClientRect();
|
||||
return {
|
||||
labelWidth: labelBox.width,
|
||||
badgeHeight: badgeBox?.height,
|
||||
badgeLine: badge ? parseFloat(getComputedStyle(badge).lineHeight) + 4 : 0,
|
||||
badgeClipped: badge ? badge.scrollWidth > badge.clientWidth + 1 : false,
|
||||
badgeOverlap: badgeBox ? badgeBox.right > status.getBoundingClientRect().left : false,
|
||||
};
|
||||
});
|
||||
return { sizes, overflow: rows.scrollWidth - rows.clientWidth, menuOverflow: element.scrollWidth - element.clientWidth };
|
||||
});
|
||||
const tag = `${lang}/${width}/${scale}`;
|
||||
await menu.screenshot({ path: `perf/shots/model-menu-${lang}-${width}-${scale}.png` });
|
||||
assert.ok(measured.sizes.some((size) => size.badgeHeight), `${tag}: a model reads images`);
|
||||
for (const size of measured.sizes) {
|
||||
assert.ok(size.labelWidth >= 96 * scale, `${tag}: the model name remains readable (${JSON.stringify(size)})`);
|
||||
if (size.badgeHeight) assert.ok(size.badgeHeight <= size.badgeLine + 1 && !size.badgeClipped && !size.badgeOverlap, `${tag}: the image badge stays whole (${JSON.stringify(size)})`);
|
||||
}
|
||||
assert.ok(measured.overflow <= 1 && measured.menuOverflow <= 1, `${tag}: long endpoints do not create horizontal scrolling (${JSON.stringify(measured)})`);
|
||||
await menu.locator(".mfind").fill("kimi");
|
||||
const choice = menu.locator(".menu-rows button.mi:not(.plain)").first();
|
||||
const picked = await choice.getAttribute("data-value");
|
||||
assert.ok(picked?.includes("kimi"), `${tag}: search still finds the model`);
|
||||
await choice.click();
|
||||
await page.waitForFunction((value) => document.querySelector('.studio-model-menu:not([hidden]) button[data-on]')?.dataset.value === value, picked);
|
||||
await page.keyboard.press("Escape");
|
||||
await menu.waitFor({ state: "hidden" });
|
||||
await page.locator(".model-picker").first().click();
|
||||
await page.waitForFunction((value) => document.querySelector('.studio-model-menu:not([hidden]) button[data-on]')?.dataset.value === value, picked);
|
||||
assert.equal(await menu.locator(".mfind").inputValue(), "", `${tag}: reopening clears the search`);
|
||||
await page.keyboard.press("Escape");
|
||||
await menu.waitFor({ state: "hidden" });
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(`PASS model menu ${tag}: names, badges, connection status and endpoints fit`);
|
||||
await page.close();
|
||||
}
|
||||
} 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.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();
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const base = process.env.PERF_URL ?? "http://127.0.0.1:4399/perf.html";
|
||||
try {
|
||||
for (const language of ["zh", "en"]) {
|
||||
for (const [width, height] of [[320, 640], [390, 844], [430, 932], [782, 390], [1044, 884]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height }, hasTouch: width < 900, reducedMotion: "reduce" });
|
||||
const url = new URL(base);
|
||||
url.searchParams.set("onboarding", "1");
|
||||
url.searchParams.set("pref", language);
|
||||
await page.goto(url.href);
|
||||
await page.locator('[data-action="onboarding.begin"]').click();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const check = async (step) => {
|
||||
const clipped = await page.locator(".onb").evaluate((root) => {
|
||||
const shell = root.parentElement.getBoundingClientRect();
|
||||
return [...root.querySelectorAll("input, button, h1, .onb-steps, .onb-field, .onb-model")]
|
||||
.filter((el) => el.checkVisibility()).flatMap((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.left < shell.left - 1 || r.right > shell.right + 1 || (el.tagName !== "INPUT" && el.scrollWidth > el.clientWidth + 2)
|
||||
? [{ tag: el.tagName, class: el.className, left: r.left, right: r.right, overflow: el.scrollWidth - el.clientWidth }] : [];
|
||||
});
|
||||
});
|
||||
assert.deepEqual(clipped, [], `${language} ${width}x${height} ${step}: ${JSON.stringify(clipped)}`);
|
||||
};
|
||||
await check("connect");
|
||||
await page.locator("#onb-key").fill("sk-layout-test");
|
||||
if (width < 600) {
|
||||
await page.setViewportSize({ width, height: 360 });
|
||||
await check("keyboard");
|
||||
}
|
||||
await page.locator(".onb-acts .onb-go").click();
|
||||
await page.locator('.onb-card[data-step="model"]').waitFor();
|
||||
await check("model");
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.locator('[data-action="onboarding.back"]').click();
|
||||
assert.equal(await page.locator("#onb-key").inputValue(), "sk-layout-test");
|
||||
await page.locator('[data-action="onboarding.service"]').last().click();
|
||||
await page.locator("#onb-url").fill("https://relay.example/a/long/service/address/v1");
|
||||
await check("custom service");
|
||||
await page.locator(".onb-acts .onb-go").click();
|
||||
await page.locator('.onb-card[data-step="model"]').waitFor();
|
||||
await page.locator('[data-action="onboarding.start"]').click();
|
||||
await page.locator('.onb-card[data-step="project"]').waitFor();
|
||||
await check("project");
|
||||
if (width < 900) {
|
||||
assert.equal(await page.locator('[data-action="onboarding.start"]').isEnabled(), true);
|
||||
assert.equal(await page.getByText(language === "zh" ? "把文件夹拖到这里" : "Drop a folder here").count(), 0);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
console.log("Onboarding: both languages, phone, keyboard, landscape and desktop passed.");
|
||||
} 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();
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const url = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2";
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const width of [390, 1044]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 884 }, reducedMotion: "reduce" });
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
const sidebar = page.getByRole("toolbar", { name: "侧栏入口" });
|
||||
for (const name of [/^用量/, /^扩展$/, /^设置$/]) {
|
||||
const drawer = page.getByRole("button", { name: "会话列表", exact: true });
|
||||
const expand = page.getByRole("button", { name: "展开工作区栏", exact: true });
|
||||
if (await drawer.isVisible() && await drawer.getAttribute("aria-pressed") !== "true") await drawer.click();
|
||||
else if (await expand.isVisible()) await expand.click();
|
||||
await sidebar.getByRole("button", { name }).click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
const measure = () => page.evaluate(() => {
|
||||
const sheet = document.querySelector(".prefs-sheet");
|
||||
const bounds = (selector) => {
|
||||
const r = sheet.querySelector(selector).getBoundingClientRect();
|
||||
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
|
||||
};
|
||||
const button = bounds(".prefs-actions button");
|
||||
const hit = document.elementFromPoint((button.left + button.right) / 2, (button.top + button.bottom) / 2);
|
||||
return { solo: sheet.hasAttribute("data-solo"), title: bounds(".prefs-hd h1"), actions: bounds(".prefs-actions"), button, main: bounds(".prefs-main"), hitClose: !!hit?.closest('[data-action="settings.close"]') };
|
||||
});
|
||||
const check = (r) => {
|
||||
assert.ok(r.hitClose, "the close button stays visible and clickable above the content");
|
||||
assert.ok(r.actions.top >= r.title.top - 1 && r.actions.bottom <= r.title.bottom + 1, "the close area stays inside the title row");
|
||||
if (r.solo) {
|
||||
assert.ok(r.title.right >= r.button.right, "the title background covers the close area");
|
||||
assert.ok(Math.abs((r.button.top + r.button.bottom - r.title.top - r.title.bottom) / 2) <= 1, "the close button is centred in the title row");
|
||||
}
|
||||
assert.ok(r.button.bottom <= r.main.top || !r.solo, "the close button stays above the scrolling content");
|
||||
};
|
||||
const top = await measure();
|
||||
check(top);
|
||||
await page.locator(".prefs-main").evaluate((el) => { el.scrollTop = el.scrollHeight; });
|
||||
const scrolled = await measure();
|
||||
check(scrolled);
|
||||
assert.deepEqual(scrolled.button, top.button, "scrolling the body leaves the close button in place");
|
||||
await page.getByRole("button", { name: "关闭 Esc", exact: true }).click();
|
||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||
console.log(`PASS ${name}: header and close button at ${width}px`);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const url = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2";
|
||||
const browser = await chromium.launch();
|
||||
const failures = [];
|
||||
const check = async (name, run) => {
|
||||
try { await run(); console.log(`PASS ${name}`); }
|
||||
catch (error) { failures.push(`${name}: ${error.message}`); }
|
||||
};
|
||||
|
||||
async function contained(page, selector) {
|
||||
const boxes = await page.locator(selector).evaluateAll((elements) => elements.map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, width: r.width, height: r.height, vw: innerWidth, vh: innerHeight };
|
||||
}));
|
||||
assert.ok(boxes.length > 0, `${selector} exists`);
|
||||
for (const r of boxes) {
|
||||
assert.ok(r.width > 0 && r.height > 0, `${selector} is visible`);
|
||||
assert.ok(r.x >= 0 && r.right <= r.vw + 1, `${selector} fits horizontally: ${JSON.stringify(r)}`);
|
||||
assert.ok(r.y >= 0 && r.bottom <= r.vh + 1, `${selector} fits vertically: ${JSON.stringify(r)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function header(page) {
|
||||
await contained(page, '.prefs-sheet');
|
||||
await contained(page, '.prefs-actions button');
|
||||
assert.equal(await page.locator('.prefs-sheet').evaluate(el => el.scrollTop), 0, "focus only scrolls the page body");
|
||||
}
|
||||
|
||||
async function scopeRows(page) {
|
||||
const rows = await page.locator('.scope-pick .pk:has(.ct)').evaluateAll(elements => elements.map(el => {
|
||||
const name = el.querySelector('.nm').getBoundingClientRect();
|
||||
const path = el.querySelector('.pt').getBoundingClientRect();
|
||||
const badge = el.querySelector('.ct').getBoundingClientRect();
|
||||
return { name: { left: name.left, right: name.right, bottom: name.bottom }, path: { left: path.left, top: path.top }, badge: { left: badge.left } };
|
||||
}));
|
||||
assert.ok(rows.length > 0);
|
||||
for (const row of rows) {
|
||||
assert.ok(row.name.right <= row.badge.left && Math.abs(row.path.left - row.name.left) < 1 && row.path.top >= row.name.bottom, JSON.stringify(row));
|
||||
}
|
||||
}
|
||||
|
||||
async function open(page, name) {
|
||||
const drawer = page.getByRole("button", { name: "会话列表", exact: true });
|
||||
const expand = page.getByRole("button", { name: "展开工作区栏", exact: true });
|
||||
if (await drawer.isVisible() && await drawer.getAttribute("aria-pressed") !== "true") await drawer.click();
|
||||
else if (await expand.isVisible()) await expand.click();
|
||||
await page.getByRole("toolbar", { name: "侧栏入口" }).getByRole("button", { name }).click();
|
||||
await page.locator('.prefs-sheet').waitFor();
|
||||
}
|
||||
|
||||
async function deep(page, section, id) {
|
||||
await page.getByRole("tab", { name: new RegExp(`^${section}`) }).click();
|
||||
await page.locator(`.prefs-main [data-action="settings.open-page"][data-target="${id}"]`).first().click();
|
||||
await page.locator(`.deep-page[data-setting="${id}"]`).waitFor();
|
||||
}
|
||||
|
||||
try {
|
||||
for (const [width, height] of [[320, 740], [390, 844], [430, 932], [782, 390], [1044, 884], [1440, 900]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height }, reducedMotion: "reduce" });
|
||||
page.on("pageerror", error => failures.push(error.message));
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
const size = `${width}x${height}`;
|
||||
await open(page, /^扩展$/);
|
||||
await page.getByRole("button", { name: "Reasonix", exact: true }).click();
|
||||
await check(`${size}: standalone scope picker`, () => contained(page, '.scope-pick'));
|
||||
await check(`${size}: scope name, path and exception columns`, () => scopeRows(page));
|
||||
await page.getByRole("button", { name: "Reasonix", exact: true }).click();
|
||||
await page.getByRole("button", { name: "添加…", exact: true }).click();
|
||||
await check(`${size}: add menu`, () => contained(page, '.ext-add-menu'));
|
||||
await page.getByRole("menuitem", { name: /接入外部服务/ }).click();
|
||||
await page.getByRole("textbox", { name: "一段 JSON、一行命令,或一个 https 地址" }).fill('https://mcp.example.com/sse');
|
||||
await page.getByRole("button", { name: "查看内容", exact: true }).click();
|
||||
await page.locator('.addsrv[data-stage="confirm"]').waitFor();
|
||||
await check(`${size}: header after focusing MCP form`, () => header(page));
|
||||
await page.getByRole("button", { name: "返回", exact: true }).click();
|
||||
await page.getByRole("button", { name: "取消", exact: true }).click();
|
||||
await page.getByRole("tab", { name: "发现", exact: true }).click();
|
||||
await page.getByRole("button", { name: /^make-ui-not-ai @/ }).click();
|
||||
await check(`${size}: long marketplace source`, async () => {
|
||||
const r = await page.locator('.mkt-detail-meta .mono').evaluate(el => {
|
||||
const r = el.getBoundingClientRect(), parent = el.parentElement.getBoundingClientRect();
|
||||
return { right: r.right, parent: parent.right, scroll: el.scrollWidth, client: el.clientWidth };
|
||||
});
|
||||
assert.ok(r.right <= r.parent + 1 && r.scroll <= r.client + 1, JSON.stringify(r));
|
||||
});
|
||||
await page.getByRole("button", { name: "关闭 Esc", exact: true }).click();
|
||||
await open(page, /^设置$/);
|
||||
await page.getByRole("tab", { name: /^权限与安全/ }).click();
|
||||
await page.getByRole("button", { name: "Reasonix", exact: true }).click();
|
||||
await check(`${size}: settings scope picker`, () => contained(page, '.scope-pick'));
|
||||
await page.getByRole("button", { name: "Reasonix", exact: true }).click();
|
||||
await deep(page, "Agent 行为", "agent-presets");
|
||||
await page.getByRole("button", { name: "新建预设", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "预设名称", exact: true }).fill('Layout audit draft');
|
||||
await page.getByRole("button", { name: "返回预设列表", exact: true }).click();
|
||||
await check(`${size}: discard dialog`, () => contained(page, '.preset-confirm'));
|
||||
await page.getByRole("button", { name: "继续编辑", exact: true }).click();
|
||||
assert.equal(await page.getByRole("textbox", { name: "预设名称", exact: true }).inputValue(), 'Layout audit draft');
|
||||
await page.getByRole("button", { name: "返回预设列表", exact: true }).click();
|
||||
await page.getByRole("button", { name: "放弃更改", exact: true }).click();
|
||||
await deep(page, "数据", "storage");
|
||||
await page.getByRole("button", { name: "选择会话文件…", exact: true }).click();
|
||||
await check(`${size}: import dialog`, () => contained(page, '.ns-dlg'));
|
||||
await page.getByRole("dialog", { name: "导入原生会话", exact: true }).getByRole("button", { name: "取消", exact: true }).click();
|
||||
await check(`${size}: header after modal dismissal`, () => header(page));
|
||||
const signedIn = new URL(url);
|
||||
signedIn.searchParams.set("signedin", "1");
|
||||
await page.goto(signedIn.href, { waitUntil: "networkidle" });
|
||||
await open(page, /^设置$/);
|
||||
await deep(page, "数据", "backup");
|
||||
await page.getByRole("textbox", { name: "备注(可选)", exact: true }).fill('Layout audit backup');
|
||||
await page.getByRole("textbox", { name: "加密口令(至少 10 个字符)", exact: true }).fill('layout-audit-demo');
|
||||
await page.getByRole("textbox", { name: "再输一次", exact: true }).fill('layout-audit-demo');
|
||||
await page.getByRole("button", { name: "备份到账号", exact: true }).click();
|
||||
await page.getByRole("button", { name: "恢复…", exact: true }).click();
|
||||
await check(`${size}: backup passphrase dialog`, () => contained(page, '.bk-dlg'));
|
||||
await page.getByRole("textbox", { name: "加密口令", exact: true }).fill('layout-audit-demo');
|
||||
await page.getByRole("button", { name: "预览差异", exact: true }).click();
|
||||
await page.getByRole("button", { name: "恢复所选 1 项", exact: true }).waitFor();
|
||||
await check(`${size}: backup difference preview`, async () => {
|
||||
await contained(page, '.bk-dlg');
|
||||
assert.ok(await page.locator('.bk-dlg').evaluate(el => el.scrollWidth <= el.clientWidth + 1));
|
||||
});
|
||||
await page.getByRole("dialog", { name: "恢复备份", exact: true }).getByRole("button", { name: "取消", exact: true }).click();
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
assert.deepEqual(failures, [], failures.join('\n'));
|
||||
@@ -13,26 +13,30 @@ try {
|
||||
const lefts = () =>
|
||||
page.evaluate(() => {
|
||||
const left = (selector) => Math.round(document.querySelector(selector).getBoundingClientRect().left);
|
||||
return { title: left(".prefs-main .prefs-pagetitle"), col: left(".prefs-main .prefs-col") };
|
||||
return { title: left(".prefs-main .prefs-pagetitle"), index: left(".prefs-main .page-index"), col: left(".prefs-main .prefs-col") };
|
||||
});
|
||||
for (const name of ["模型", "连接", "常用"]) {
|
||||
await page.locator(".prefs-nav button", { hasText: new RegExp(`^${name}`) }).first().click();
|
||||
await page.waitForTimeout(400);
|
||||
const at = await lefts();
|
||||
assert.equal(await page.locator(".page-index").count(), 0);
|
||||
assert.equal(at.index, at.title, `${name}: the page index starts where the title does (${JSON.stringify(at)})`);
|
||||
assert.equal(at.col, at.title, `${name}: the cards start where the title does (${JSON.stringify(at)})`);
|
||||
console.log(`PASS ${name}: title and cards start at ${at.title}px without a duplicate index`);
|
||||
console.log(`PASS ${name}: title, index and cards all start at ${at.title}px`);
|
||||
}
|
||||
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");
|
||||
|
||||
Generated
+207
-207
@@ -9,11 +9,11 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@a2ui/react':
|
||||
specifier: 0.14.0
|
||||
version: 0.14.0([email protected]([email protected]))([email protected])([email protected])
|
||||
specifier: 0.15.0
|
||||
version: 0.15.0([email protected]([email protected]))([email protected])([email protected])
|
||||
'@a2ui/web_core':
|
||||
specifier: 0.14.0
|
||||
version: 0.14.0
|
||||
specifier: 0.15.0
|
||||
version: 0.15.0
|
||||
'@codemirror/commands':
|
||||
specifier: ^6.11.1
|
||||
version: 6.11.1
|
||||
@@ -69,14 +69,14 @@ importers:
|
||||
specifier: ^1.2.5
|
||||
version: 1.2.5
|
||||
'@mantine/charts':
|
||||
specifier: 9.7.4
|
||||
version: 9.7.4(@mantine/[email protected].4(@mantine/[email protected].4([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].4([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))
|
||||
specifier: 9.7.5
|
||||
version: 9.7.5(@mantine/[email protected].5(@mantine/[email protected].5([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].5([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))
|
||||
'@mantine/core':
|
||||
specifier: 9.7.4
|
||||
version: 9.7.4(@mantine/[email protected].4([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
specifier: 9.7.5
|
||||
version: 9.7.5(@mantine/[email protected].5([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@mantine/hooks':
|
||||
specifier: 9.7.4
|
||||
version: 9.7.4([email protected])
|
||||
specifier: 9.7.5
|
||||
version: 9.7.5([email protected])
|
||||
'@noble/curves':
|
||||
specifier: 2.4.0
|
||||
version: 2.4.0
|
||||
@@ -105,8 +105,8 @@ importers:
|
||||
specifier: ^0.19.1
|
||||
version: 0.19.1
|
||||
lucide-react:
|
||||
specifier: ^1.64.0
|
||||
version: 1.64.0(@types/[email protected])([email protected])
|
||||
specifier: ^1.69.0
|
||||
version: 1.69.0(@types/[email protected])([email protected])
|
||||
maplibre-gl:
|
||||
specifier: 6.16.0
|
||||
version: 6.16.0
|
||||
@@ -114,8 +114,8 @@ importers:
|
||||
specifier: ^5.7.0
|
||||
version: 5.7.0
|
||||
pdfjs-dist:
|
||||
specifier: ^6.4.299
|
||||
version: 6.4.299
|
||||
specifier: ^6.5.438
|
||||
version: 6.5.438
|
||||
react:
|
||||
specifier: ^19.3.1
|
||||
version: 19.3.1
|
||||
@@ -160,8 +160,8 @@ importers:
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@babel/parser':
|
||||
specifier: ^8.0.8
|
||||
version: 8.0.8
|
||||
specifier: ^8.1.0
|
||||
version: 8.1.0
|
||||
'@testing-library/react':
|
||||
specifier: ^16.3.3
|
||||
version: 16.3.3(@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
@@ -178,8 +178,8 @@ importers:
|
||||
specifier: ^19.3.0
|
||||
version: 19.3.0(@types/[email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.1.2
|
||||
version: 6.1.2([email protected].5(@types/[email protected])([email protected]))
|
||||
specifier: ^6.2.1
|
||||
version: 6.2.1([email protected].6(@types/[email protected])([email protected]))
|
||||
jsdom:
|
||||
specifier: ^30.1.3
|
||||
version: 30.1.3(@noble/[email protected])
|
||||
@@ -193,11 +193,11 @@ importers:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
vite:
|
||||
specifier: ^8.3.5
|
||||
version: 8.3.5(@types/[email protected])([email protected])
|
||||
specifier: ^8.3.6
|
||||
version: 8.3.6(@types/[email protected])([email protected])
|
||||
vitest:
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.5(@types/[email protected])([email protected](@noble/[email protected]))([email protected].5(@types/[email protected])([email protected]))
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0(@types/[email protected])([email protected](@noble/[email protected]))([email protected].6(@types/[email protected])([email protected]))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -206,16 +206,16 @@ packages:
|
||||
peerDependencies:
|
||||
'@a2ui/web_core': ^0.14.0
|
||||
|
||||
'@a2ui/[email protected]4.0':
|
||||
resolution: {integrity: sha512-kew3gVx4/U4qZ41FwyanDndCuBsx3s+5kPzjBt5N9w8E6UKSfwJpOmWD3Vy5WN5bxuiQKmorN+Ozh59ZLrwkMA==}
|
||||
'@a2ui/[email protected]5.0':
|
||||
resolution: {integrity: sha512-SkbdIMoRgtYNGh+nSEvK8YHDSAgS8LVFggDMCYdDqrUAWMLb2PS1unYOL8lfkXjskYK/+VoD7sa6X+I2F+F62w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
zod: ^3.25.76
|
||||
|
||||
'@a2ui/[email protected]4.0':
|
||||
resolution: {integrity: sha512-OEJOgcfRMW0lwActVl1NKToxE2Ff5G3mIBHDB6gKm0elGZSwAhB7bj+Ef2a/h2nrL+57VPnSR67YSNz9JVV4yw==}
|
||||
'@a2ui/[email protected]5.0':
|
||||
resolution: {integrity: sha512-S3k9rAJ9O55z5TlDeWy22ly3yQnDLO+XBWihlL5JQSuyxaCUmd7jgFnxO4eq/Bn6dSgdBF0WG8cve/euMoz8SA==}
|
||||
|
||||
'@asamuzakjp/[email protected]':
|
||||
resolution: {integrity: sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==}
|
||||
@@ -241,8 +241,8 @@ packages:
|
||||
resolution: {integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==}
|
||||
engines: {node: ^22.18.0 || >=24.11.0}
|
||||
|
||||
'@babel/parser@8.0.8':
|
||||
resolution: {integrity: sha512-Gq65JogAr0S2p0BzvfSJboyuiRnxDQApyOqHkKmkrvm+VlLK4gC3pKsiOKEdnhFBemrFbSnOu90mZZWDpiJnmQ==}
|
||||
'@babel/parser@8.1.0':
|
||||
resolution: {integrity: sha512-fpqZYAC+Tthr19JbEKxXhCGutvfm/+roMe+ULrgUKI+GVyzkQiTMTSheORcWIAzW8T3Oo3W5UavWSvAWmsp7fA==}
|
||||
engines: {node: ^22.18.0 || >=24.11.0}
|
||||
hasBin: true
|
||||
|
||||
@@ -250,8 +250,8 @@ packages:
|
||||
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@8.0.8':
|
||||
resolution: {integrity: sha512-iXSSZAKQWmaeAwGtFw10v9fg5hUiUs0L+w+Z2K11kym3pDNK+SF0VcAQPsEFvWcTSZ7LMgClMXDBvTbH0IlICg==}
|
||||
'@babel/types@8.1.0':
|
||||
resolution: {integrity: sha512-8V8aSTDx012YPmDQF30vrokyxVFdRAU2YBS2VzrnfLAGkP8floVnjckwgFcD0Jrvf6GWMSRdCrYVsk3tyMwZRg==}
|
||||
engines: {node: ^22.18.0 || >=24.11.0}
|
||||
|
||||
'@bramus/[email protected]':
|
||||
@@ -607,24 +607,24 @@ packages:
|
||||
'@lit/[email protected]':
|
||||
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
|
||||
|
||||
'@mantine/[email protected].4':
|
||||
resolution: {integrity: sha512-liMa0KIxcbE7goA0YqslEj9Ir8NVBV2158vW11J8g20+yrYe6fAqFu0nrtFMqN0Jm72wU0DMqCqZcrsKlWf0DQ==}
|
||||
'@mantine/[email protected].5':
|
||||
resolution: {integrity: sha512-udNEXI9NWs/BOXNOS868O4dNuQDzBNO9hP3ahFRe/i1It7kbFGlZ2P5y0HNYZ4+a38+coUeTuYaN7AJ7a41BiA==}
|
||||
peerDependencies:
|
||||
'@mantine/core': 9.7.4
|
||||
'@mantine/hooks': 9.7.4
|
||||
'@mantine/core': 9.7.5
|
||||
'@mantine/hooks': 9.7.5
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
recharts: '>=3.2.1'
|
||||
|
||||
'@mantine/[email protected].4':
|
||||
resolution: {integrity: sha512-c7aTGELNn74CxG9i5/tq9BHGh0lKdKmGGz+9iDF+gR7Ipt4U1TMM286lAmLpRrKL2bR+UEaN4blZ8UJmOuGJ4Q==}
|
||||
'@mantine/[email protected].5':
|
||||
resolution: {integrity: sha512-NiOTHpAZssi1Z5ISZBvcXEA4XDn0BWcxwpmmUthFx/eL0CsJ18FXMn/ydRCJmoeMGcIKmRE+pjt90KM70GOsUw==}
|
||||
peerDependencies:
|
||||
'@mantine/hooks': 9.7.4
|
||||
'@mantine/hooks': 9.7.5
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
|
||||
'@mantine/[email protected].4':
|
||||
resolution: {integrity: sha512-j6Xf9Sc1fhhGEKXg6BZA2geyP/Czqvo6Rsi0IduOCWgwl/MAAJbKRjRc7xe2+9vePLdfqLZqRIAOY5rta88uNA==}
|
||||
'@mantine/[email protected].5':
|
||||
resolution: {integrity: sha512-+3cNisLOUkzF77SjRKkG/V+svZii9WDGvcwlaxFH58b14MNohz1zWbV+WfjZt9qfzrIPPUOedks/z6PMo5rAFA==}
|
||||
peerDependencies:
|
||||
react: ^19.2.0
|
||||
|
||||
@@ -667,79 +667,79 @@ packages:
|
||||
'@marijn/[email protected]':
|
||||
resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
|
||||
|
||||
'@napi-rs/canvas-android-arm64@1.0.12':
|
||||
resolution: {integrity: sha512-Z8hg9BD7ABaZNhdM8O96IrfBaYLM7KzO/YyKA8eU9BDdc5fZJRTWyOhSAyOv59/ari6bOYa+p96h7zN6wbJRDA==}
|
||||
'@napi-rs/canvas-android-arm64@1.1.0':
|
||||
resolution: {integrity: sha512-mKrSmRyhWK4q699Q1xthbZIZi7JBvLtsRNnOZAeFaLPOFT1yo8y82aRC2nNZbXTGJXU7fVDONgGQPr0vo3kerw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@1.0.12':
|
||||
resolution: {integrity: sha512-dRtZCi/u1IOZyRjEDCA73PqgWakf5689K2jfv4uPRYGMJvEqtV+Eq7MIQkBJLmdfUPe7AktSiBZRml2pggnjRQ==}
|
||||
'@napi-rs/canvas-darwin-arm64@1.1.0':
|
||||
resolution: {integrity: sha512-iqwST4g0oH+x8VCpBIYh+MYokv/mVvkM+v9oJGB0d9A3P2qBh42ogJlVyWq9SpPCrAjS7tplMb1D3OIfxl+utw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@1.0.12':
|
||||
resolution: {integrity: sha512-YwvMgK5NBXwPY1AjZGXfPCG3hp/OFGM0Kw+JZHCOR/FYaeSOftaZRyxaDEGkJQSLEUZZIMNJXKCWgZYSzgWZEA==}
|
||||
'@napi-rs/canvas-darwin-x64@1.1.0':
|
||||
resolution: {integrity: sha512-jkeA8eVTxDum03LTvc32YJ28F6KB1U0gYwnWMfOAUQZvWWEh/kvONby4iWGJx3+Tw45UqMGMXjtw2GKNVZDhVg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.12':
|
||||
resolution: {integrity: sha512-LlOsZy8qh+qJq1euEvDZqa9muACS1fL1Ag38c7J+axAnA8NeXazMtqmUyKb4NVyft6DqbU/MesEj3C0bAFnv4Q==}
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@1.1.0':
|
||||
resolution: {integrity: sha512-Y4YkKG1ijoqkgUdTEEBNG4k+2BK5bR5jGL6S56YxCXYPwGfUd5nED8/74xyc7z+bpixPasCjSYoP3xYPOIGNxw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@1.0.12':
|
||||
resolution: {integrity: sha512-nvuqhi85GzaFl/6sid4qf7wwjHuGU6gfIuDyywUb/fB+kp6Fp3h8GyIAKss/QPil3zVO2r8g6O9ah+IOUoBrIw==}
|
||||
'@napi-rs/canvas-linux-arm64-gnu@1.1.0':
|
||||
resolution: {integrity: sha512-dE/XthqYkiWcBVtKFlZbUXwAYfDuQGqDWixznipJNmevLGpprNnTVQZyftm1RZJudkIOn2EkzYZDMMETM08Row==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@1.0.12':
|
||||
resolution: {integrity: sha512-Bf6+9PoEr9K06P/oyMab7zZOpW90KimbIuNsy/vL0VhZZKsb0B0rCC78cp0jXhK6PIM7v8t6uX1sn7isOZ8KEw==}
|
||||
'@napi-rs/canvas-linux-arm64-musl@1.1.0':
|
||||
resolution: {integrity: sha512-DqZ/CMcHlhU56pPjv9AP0h3U7MFQ5LzC1HzMyRMotL+PFsJmZJA/ziApPYVyyz78DaVLhI8t3So3ztqtijbYpg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@1.0.12':
|
||||
resolution: {integrity: sha512-j7VcZduLSRx60nv/wbdyH+fyB5QriWGuO8GrvMDlFlt7GNUKAw5lHAHpcuuQW3QwLOe+3P+3qryzyT3sW5pCqQ==}
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@1.1.0':
|
||||
resolution: {integrity: sha512-kJ7m5n2VffxWXM5QGa5lfq8ZN4LdOJEoz3aAJAmIqYvKiZw1gRdXqpucnRXsF6D9nLYiiIlevaAtAclR7c5ilg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@1.0.12':
|
||||
resolution: {integrity: sha512-twDb8ZNgoCF8EG6ka+Lk4etU+ea9lv6NiV5ptIf+thz3LgWuHBbt85c+YgL1s05VsHe4WYWK0f+GhR45IuqARg==}
|
||||
'@napi-rs/canvas-linux-x64-gnu@1.1.0':
|
||||
resolution: {integrity: sha512-uQ9JuJlRhQdHyttcMWOiwLkfIqkqaB0YFUVe835LUe12THt2b51HXkTItq0fxWT1jdhUPX2ylWZ1walkJyD6GQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@1.0.12':
|
||||
resolution: {integrity: sha512-mtZbBeNCdGA0MYQxivPmMOp5JMH80LaJLQeEMg7Nne517fvgGb+DWTJTmoE/qUW8YJ4nkiP2vQyr1XVGrH/Ktg==}
|
||||
'@napi-rs/canvas-linux-x64-musl@1.1.0':
|
||||
resolution: {integrity: sha512-has4CPPAkkCac/LZ13VAw5p+MJLdaKVoyrhumN/A7uJd6960xYkJNDZgUebyxiha82AFzXx6RV+QJg5qGG079g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@1.0.12':
|
||||
resolution: {integrity: sha512-HgGRuBUkFL8U99ogwUnTz3SmwUu7WKjmj0eOVWRqVZTPPyfWGEIMr+ycb8oDoNZcKM9vSiF1rpy3r+UIqqaKqw==}
|
||||
'@napi-rs/canvas-win32-arm64-msvc@1.1.0':
|
||||
resolution: {integrity: sha512-MJnktLKNijmXqdjTughHDZ9TDLZ9uUm2hvFXqaOqn0+w7lphXFnft6bpSgQAc9U1exc20UGIBlzGu9+kiCj9Ag==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@1.0.12':
|
||||
resolution: {integrity: sha512-SEEfblIAnYeS4UkeFPp9jw6ZWuHxaTqumUuksMgnMXmPLW8Ofb21uu7cujhw+lk4E9V3aWgGAE9f8g+CdoAU0Q==}
|
||||
'@napi-rs/canvas-win32-x64-msvc@1.1.0':
|
||||
resolution: {integrity: sha512-08575bFk7iiadxap4h+JxgGut9xDCHAGum6wh0eInN53zEMImkmEHi5MushX2FOmNO7qRNX7BPiDC5ZYBwHeEg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@1.0.12':
|
||||
resolution: {integrity: sha512-PSwhRdvizfuiSDlhE4Ld2jl/JqkrbSFFyZO0ycamE6mBpf1H0uUF3Tm8yQaVDeAwCNNDzxjRfrN22KmWLTDwcw==}
|
||||
'@napi-rs/canvas@1.1.0':
|
||||
resolution: {integrity: sha512-k+ph/D8H9GS8ZZ48okvHrKeJynkqAA1kMUZRuzRWNlQUDHXngq7oRaWMZBdmSfd9ZhVy9bwNKei/7wMboI/uMg==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@noble/[email protected]':
|
||||
@@ -750,8 +750,8 @@ packages:
|
||||
resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@oxc-project/[email protected]5.0':
|
||||
resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==}
|
||||
'@oxc-project/[email protected]6.0':
|
||||
resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==}
|
||||
|
||||
'@preact/[email protected]':
|
||||
resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==}
|
||||
@@ -767,98 +767,98 @@ packages:
|
||||
react-redux:
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==}
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==}
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==}
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==}
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==}
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==}
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==}
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==}
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==}
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==}
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==}
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==}
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==}
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==}
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==}
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -1129,13 +1129,13 @@ packages:
|
||||
maplibre-gl:
|
||||
optional: true
|
||||
|
||||
'@vitejs/plugin-react@6.1.2':
|
||||
resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==}
|
||||
'@vitejs/plugin-react@6.2.1':
|
||||
resolution: {integrity: sha512-9KyWCij3r0+qLG3Q62vBmv0VyevmYibAtxaCIVK9o0k6CXgaajZRIe8tpfchQcdZArJ5b/7OrIz4W3b2uxVRDA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
peerDependencies:
|
||||
'@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
|
||||
'@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 || ^0.3.0
|
||||
babel-plugin-react-compiler: ^1.0.0
|
||||
oxc-transform-react: ^0.152.0
|
||||
oxc-transform-react: '>=0.152.0 <0.157.0'
|
||||
vite: ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@rolldown/plugin-babel':
|
||||
@@ -1145,8 +1145,8 @@ packages:
|
||||
oxc-transform-react:
|
||||
optional: true
|
||||
|
||||
'@vitest/mocker@5.0.5':
|
||||
resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==}
|
||||
'@vitest/mocker@5.1.0':
|
||||
resolution: {integrity: sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9 || ^3.0.0
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -1156,8 +1156,8 @@ packages:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/spy@5.0.5':
|
||||
resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==}
|
||||
'@vitest/spy@5.1.0':
|
||||
resolution: {integrity: sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==}
|
||||
|
||||
'@xterm/[email protected]':
|
||||
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
||||
@@ -2075,8 +2075,8 @@ packages:
|
||||
resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
[email protected]4.0:
|
||||
resolution: {integrity: sha512-v626F/bcLNvFom6vzztHILtG9tUgXXbgA5AGkd82YkV401+Ko/NHyrWafbPwGofa+6p/EgDJcvEkIwsMH9098w==}
|
||||
[email protected]9.0:
|
||||
resolution: {integrity: sha512-UTFECm4yEgkgMN0kfLuxWcmlk+4u02Jrqehgk3ZXRx/Ud68OpP7hjkp67bvkBHmIpMaZCIveF1QDYeupO7XmYQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
@@ -2322,8 +2322,8 @@ packages:
|
||||
resolution: {integrity: sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==}
|
||||
hasBin: true
|
||||
|
||||
pdfjs-dist@6.4.299:
|
||||
resolution: {integrity: sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==}
|
||||
pdfjs-dist@6.5.438:
|
||||
resolution: {integrity: sha512-vg8Pw1x+AFKy4AMDvhuWl5cOYV8d698UoEANoFFhc79hqXDnZQqnOXoxw3HG907WHOg1JYOvLhP756boddFxpA==}
|
||||
engines: {node: '>=22.13.0 || >=24'}
|
||||
|
||||
[email protected]:
|
||||
@@ -2347,8 +2347,8 @@ packages:
|
||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
[email protected]0:
|
||||
resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==}
|
||||
[email protected]2:
|
||||
resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
[email protected]:
|
||||
@@ -2545,8 +2545,8 @@ packages:
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
hasBin: true
|
||||
|
||||
[email protected]5:
|
||||
resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==}
|
||||
[email protected]6:
|
||||
resolution: {integrity: sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
@@ -2874,8 +2874,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
[email protected].5:
|
||||
resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==}
|
||||
[email protected].6:
|
||||
resolution: {integrity: sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -2917,20 +2917,20 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@5.0.5:
|
||||
resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==}
|
||||
vitest@5.1.0:
|
||||
resolution: {integrity: sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==}
|
||||
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 5.0.5
|
||||
'@vitest/browser-preview': 5.0.5
|
||||
'@vitest/browser-playwright': 5.1.0
|
||||
'@vitest/browser-preview': 5.1.0
|
||||
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
|
||||
'@vitest/coverage-istanbul': 5.0.5
|
||||
'@vitest/coverage-v8': 5.0.5
|
||||
'@vitest/ui': 5.0.5
|
||||
'@vitest/coverage-istanbul': 5.1.0
|
||||
'@vitest/coverage-v8': 5.1.0
|
||||
'@vitest/ui': 5.1.0
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -3028,23 +3028,23 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@a2ui/[email protected](@a2ui/[email protected]4.0)':
|
||||
'@a2ui/[email protected](@a2ui/[email protected]5.0)':
|
||||
dependencies:
|
||||
'@a2ui/web_core': 0.14.0
|
||||
'@a2ui/web_core': 0.15.0
|
||||
dompurify: 3.4.11
|
||||
markdown-it: 14.3.2
|
||||
|
||||
'@a2ui/[email protected]4.0([email protected]([email protected]))([email protected])([email protected])':
|
||||
'@a2ui/[email protected]5.0([email protected]([email protected]))([email protected])([email protected])':
|
||||
dependencies:
|
||||
'@a2ui/markdown-it': 0.2.2(@a2ui/[email protected]4.0)
|
||||
'@a2ui/web_core': 0.14.0
|
||||
'@a2ui/markdown-it': 0.2.2(@a2ui/[email protected]5.0)
|
||||
'@a2ui/web_core': 0.15.0
|
||||
clsx: 2.1.1
|
||||
markdown-it: 14.3.2
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected])
|
||||
zod: 3.25.76
|
||||
|
||||
'@a2ui/[email protected]4.0':
|
||||
'@a2ui/[email protected]5.0':
|
||||
dependencies:
|
||||
'@lit/context': 1.1.6
|
||||
'@preact/signals-core': 1.14.4
|
||||
@@ -3081,13 +3081,13 @@ snapshots:
|
||||
|
||||
'@babel/[email protected]': {}
|
||||
|
||||
'@babel/parser@8.0.8':
|
||||
'@babel/parser@8.1.0':
|
||||
dependencies:
|
||||
'@babel/types': 8.0.8
|
||||
'@babel/types': 8.1.0
|
||||
|
||||
'@babel/[email protected]': {}
|
||||
|
||||
'@babel/types@8.0.8':
|
||||
'@babel/types@8.1.0':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 8.0.8
|
||||
'@babel/helper-validator-identifier': 8.0.6
|
||||
@@ -3488,18 +3488,18 @@ snapshots:
|
||||
dependencies:
|
||||
'@lit-labs/ssr-dom-shim': 1.6.0
|
||||
|
||||
'@mantine/[email protected].4(@mantine/[email protected].4(@mantine/[email protected].4([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].4([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))':
|
||||
'@mantine/[email protected].5(@mantine/[email protected].5(@mantine/[email protected].5([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].5([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@mantine/core': 9.7.4(@mantine/[email protected].4([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@mantine/hooks': 9.7.4([email protected])
|
||||
'@mantine/core': 9.7.5(@mantine/[email protected].5([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@mantine/hooks': 9.7.5([email protected])
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected])
|
||||
recharts: 3.10.1(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
|
||||
|
||||
'@mantine/[email protected].4(@mantine/[email protected].4([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
'@mantine/[email protected].5(@mantine/[email protected].5([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.27.20([email protected]([email protected]))([email protected])
|
||||
'@mantine/hooks': 9.7.4([email protected])
|
||||
'@mantine/hooks': 9.7.5([email protected])
|
||||
clsx: 2.1.1
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected])
|
||||
@@ -3509,7 +3509,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
'@mantine/[email protected].4([email protected])':
|
||||
'@mantine/[email protected].5([email protected])':
|
||||
dependencies:
|
||||
react: 19.3.1
|
||||
|
||||
@@ -3563,52 +3563,52 @@ snapshots:
|
||||
|
||||
'@marijn/[email protected]': {}
|
||||
|
||||
'@napi-rs/canvas-android-arm64@1.0.12':
|
||||
'@napi-rs/canvas-android-arm64@1.1.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@1.0.12':
|
||||
'@napi-rs/canvas-darwin-arm64@1.1.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@1.0.12':
|
||||
'@napi-rs/canvas-darwin-x64@1.1.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.12':
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@1.1.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@1.0.12':
|
||||
'@napi-rs/canvas-linux-arm64-gnu@1.1.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@1.0.12':
|
||||
'@napi-rs/canvas-linux-arm64-musl@1.1.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@1.0.12':
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@1.1.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@1.0.12':
|
||||
'@napi-rs/canvas-linux-x64-gnu@1.1.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@1.0.12':
|
||||
'@napi-rs/canvas-linux-x64-musl@1.1.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@1.0.12':
|
||||
'@napi-rs/canvas-win32-arm64-msvc@1.1.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@1.0.12':
|
||||
'@napi-rs/canvas-win32-x64-msvc@1.1.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@1.0.12':
|
||||
'@napi-rs/canvas@1.1.0':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 1.0.12
|
||||
'@napi-rs/canvas-darwin-arm64': 1.0.12
|
||||
'@napi-rs/canvas-darwin-x64': 1.0.12
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 1.0.12
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 1.0.12
|
||||
'@napi-rs/canvas-linux-arm64-musl': 1.0.12
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 1.0.12
|
||||
'@napi-rs/canvas-linux-x64-gnu': 1.0.12
|
||||
'@napi-rs/canvas-linux-x64-musl': 1.0.12
|
||||
'@napi-rs/canvas-win32-arm64-msvc': 1.0.12
|
||||
'@napi-rs/canvas-win32-x64-msvc': 1.0.12
|
||||
'@napi-rs/canvas-android-arm64': 1.1.0
|
||||
'@napi-rs/canvas-darwin-arm64': 1.1.0
|
||||
'@napi-rs/canvas-darwin-x64': 1.1.0
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 1.1.0
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 1.1.0
|
||||
'@napi-rs/canvas-linux-arm64-musl': 1.1.0
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 1.1.0
|
||||
'@napi-rs/canvas-linux-x64-gnu': 1.1.0
|
||||
'@napi-rs/canvas-linux-x64-musl': 1.1.0
|
||||
'@napi-rs/canvas-win32-arm64-msvc': 1.1.0
|
||||
'@napi-rs/canvas-win32-x64-msvc': 1.1.0
|
||||
optional: true
|
||||
|
||||
'@noble/[email protected]':
|
||||
@@ -3617,7 +3617,7 @@ snapshots:
|
||||
|
||||
'@noble/[email protected]': {}
|
||||
|
||||
'@oxc-project/[email protected]5.0': {}
|
||||
'@oxc-project/[email protected]6.0': {}
|
||||
|
||||
'@preact/[email protected]': {}
|
||||
|
||||
@@ -3633,49 +3633,49 @@ snapshots:
|
||||
react: 19.3.1
|
||||
react-redux: 9.3.0(@types/[email protected])([email protected])([email protected])
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
'@rolldown/[email protected]6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
'@rolldown/[email protected]6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
'@rolldown/[email protected]6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
'@rolldown/[email protected]6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
'@rolldown/[email protected]6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
'@rolldown/[email protected]6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
'@rolldown/[email protected]6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
'@rolldown/[email protected]6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
'@rolldown/[email protected]6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
'@rolldown/[email protected]6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
'@rolldown/[email protected]6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
'@rolldown/[email protected]6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
'@rolldown/[email protected]6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
'@rolldown/[email protected]6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]5':
|
||||
'@rolldown/[email protected]6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]': {}
|
||||
@@ -3867,21 +3867,21 @@ snapshots:
|
||||
optionalDependencies:
|
||||
maplibre-gl: 6.16.0
|
||||
|
||||
'@vitejs/plugin-react@6.1.2([email protected].5(@types/[email protected])([email protected]))':
|
||||
'@vitejs/plugin-react@6.2.1([email protected].6(@types/[email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.3.5(@types/[email protected])([email protected])
|
||||
vite: 8.3.6(@types/[email protected])([email protected])
|
||||
|
||||
'@vitest/mocker@5.0.5([email protected].5(@types/[email protected])([email protected]))':
|
||||
'@vitest/mocker@5.1.0([email protected].6(@types/[email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
'@vitest/spy': 5.0.5
|
||||
'@vitest/spy': 5.1.0
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 1.4.3
|
||||
optionalDependencies:
|
||||
vite: 8.3.5(@types/[email protected])([email protected])
|
||||
vite: 8.3.6(@types/[email protected])([email protected])
|
||||
|
||||
'@vitest/spy@5.0.5': {}
|
||||
'@vitest/spy@5.1.0': {}
|
||||
|
||||
'@xterm/[email protected]': {}
|
||||
|
||||
@@ -4948,7 +4948,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]4.0(@types/[email protected])([email protected]):
|
||||
[email protected]9.0(@types/[email protected])([email protected]):
|
||||
dependencies:
|
||||
react: 19.3.1
|
||||
optionalDependencies:
|
||||
@@ -5440,9 +5440,9 @@ snapshots:
|
||||
dependencies:
|
||||
resolve-protobuf-schema: 2.1.0
|
||||
|
||||
pdfjs-dist@6.4.299:
|
||||
pdfjs-dist@6.5.438:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 1.0.12
|
||||
'@napi-rs/canvas': 1.1.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -5456,7 +5456,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]0:
|
||||
[email protected]2:
|
||||
dependencies:
|
||||
nanoid: 3.3.20
|
||||
picocolors: 1.1.1
|
||||
@@ -5723,26 +5723,26 @@ snapshots:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
|
||||
[email protected]5:
|
||||
[email protected]6:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.155.0
|
||||
'@oxc-project/types': 0.156.0
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.15
|
||||
'@rolldown/binding-android-arm64': 1.2.15
|
||||
'@rolldown/binding-darwin-arm64': 1.2.15
|
||||
'@rolldown/binding-darwin-x64': 1.2.15
|
||||
'@rolldown/binding-freebsd-x64': 1.2.15
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.15
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.15
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.15
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.15
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.15
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.15
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.15
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.15
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.15
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.15
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.16
|
||||
'@rolldown/binding-android-arm64': 1.2.16
|
||||
'@rolldown/binding-darwin-arm64': 1.2.16
|
||||
'@rolldown/binding-darwin-x64': 1.2.16
|
||||
'@rolldown/binding-freebsd-x64': 1.2.16
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.16
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.16
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.16
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.16
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.16
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.16
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.16
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.16
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.16
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.16
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -6183,22 +6183,22 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
[email protected].5(@types/[email protected])([email protected]):
|
||||
[email protected].6(@types/[email protected])([email protected]):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
postcss: 8.5.30
|
||||
rolldown: 1.2.15
|
||||
postcss: 8.5.32
|
||||
rolldown: 1.2.16
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.5
|
||||
esbuild: 0.28.2
|
||||
fsevents: 2.3.3
|
||||
|
||||
vitest@5.0.5(@types/[email protected])([email protected](@noble/[email protected]))([email protected].5(@types/[email protected])([email protected])):
|
||||
vitest@5.1.0(@types/[email protected])([email protected](@noble/[email protected]))([email protected].6(@types/[email protected])([email protected])):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.5([email protected].5(@types/[email protected])([email protected]))
|
||||
'@vitest/mocker': 5.1.0([email protected].6(@types/[email protected])([email protected]))
|
||||
chai: 6.3.0
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
@@ -6209,7 +6209,7 @@ snapshots:
|
||||
tinybench: 6.2.2
|
||||
tinyexec: 1.3.1
|
||||
tinyglobby: 0.2.17
|
||||
vite: 8.3.5(@types/[email protected])([email protected])
|
||||
vite: 8.3.6(@types/[email protected])([email protected])
|
||||
why-is-node-running: 3.2.1
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.5
|
||||
|
||||
@@ -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
|
||||
@@ -662,6 +661,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "reasoning.effort", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "reasoning.effort-preview", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "settings.sections-scroll", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "settings.page-index", kind: "navigation", target: "entity", proof: "interaction" },
|
||||
{ id: "files.open", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "palette.pick", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "palette.scope", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
@@ -16,7 +16,6 @@ export const WORKBENCH_ACTIONS: UIAction[] = [
|
||||
{ id: "workbench.change-open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.section-fold", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.changes-page", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.launch-find", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.dismiss", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.launch-browser", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.launch-terminal", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
@@ -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); }
|
||||
@@ -133,6 +134,13 @@
|
||||
:root .composer-menu.studio-model-menu .studio-item-line > .lb { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-med) var(--fs-ui)/1.3 var(--ui); }
|
||||
:root .composer-menu.studio-model-menu .studio-item-meta { flex: none; color: var(--faint); font: var(--w-reg) var(--fs-micro)/1.3 var(--ui); white-space: nowrap; }
|
||||
:root .composer-menu.studio-model-menu .studio-item-badge { flex: none; white-space: nowrap; }
|
||||
:root .composer-menu.studio-model-menu .menu-rows .mi.head .lb { min-width: 0; max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
:root .composer-menu.studio-model-menu .menu-rows .mi.head .rt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
:root .composer-menu.studio-model-menu .menu-rows .studio-item-line { display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; gap: 2px 8px; }
|
||||
:root .composer-menu.studio-model-menu .menu-rows .studio-item-line > .lb { grid-column: 1 / -1; }
|
||||
:root .composer-menu.studio-model-menu .menu-rows .studio-item-meta { grid-column: 1; grid-row: 2; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
:root .composer-menu.studio-model-menu .menu-rows .studio-item-badge { grid-column: 2; grid-row: 2; }
|
||||
:root .composer-menu.studio-model-menu .menu-rows .mi .tx > .ds { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
:root .composer-menu.studio-model-menu .mi[data-on]::after { color: var(--net-ink); }
|
||||
:root .composer-menu.studio-effort-menu .mi.head { padding: 12px 14px 8px; }
|
||||
:root .composer-menu.studio-effort-menu .mi.head:first-child .lb { color: var(--text); font: var(--w-semi) var(--fs-ui)/1.3 var(--ui); }
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
.toast-actions .act[data-primary] { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
|
||||
.toast[data-stacked] .toast-actions { margin: 4px 0 0 26px; gap: 6px; }
|
||||
.preset-confirm-veil { background: color-mix(in oklab, var(--page) 80%, transparent); }
|
||||
.preset-confirm { width: 380px; max-width: 100%; max-height: 100%; padding: 0; overflow: auto; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); }
|
||||
.preset-confirm { width: 380px; max-width: 100%; padding: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); }
|
||||
.preset-confirm > header { display: flex; align-items: center; gap: 8px; padding: 14px 16px 0; }
|
||||
.preset-confirm > header h3 { flex: 1; margin: 0; font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); }
|
||||
.preset-confirm .pc-x { width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--muted); display: inline-grid; place-items: center; flex: none; cursor: pointer; }
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
.onb-shell { grid-template-columns: minmax(0, 1fr); }
|
||||
.onb, .onb-field, .onb-field > input { min-width: 0; }
|
||||
.onb-field > label { flex-wrap: wrap; }
|
||||
.onb-flow .onb-card { display: flex; flex-direction: column; gap: 14px; animation: onb-step-in var(--t-panel) var(--ease-out, ease-out) both; }
|
||||
.onb-flow .onb-card[data-back] { animation-name: onb-step-back; }
|
||||
.onb-flow .onb-card[data-step="welcome"] { animation: none; }
|
||||
@@ -79,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; }
|
||||
@@ -109,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) {
|
||||
@@ -119,9 +116,6 @@
|
||||
.onb-drop > .studio-icon { width: 22px; height: 22px; color: var(--faint); }
|
||||
.onb-drop[data-over] { border-color: var(--accent); background: var(--overlay); animation: onb-dash 1.2s linear infinite; background-image: repeating-linear-gradient(45deg, transparent 0 8px, color-mix(in srgb, var(--accent) 6%, transparent) 8px 16px); background-size: 22.6px 22.6px; }
|
||||
.onb-drop[data-chosen] { border-style: solid; border-color: var(--accent); background: var(--raised); }
|
||||
.onb-drop[data-touch] { border-style: solid; align-items: stretch; min-height: 0; padding: 16px; gap: 16px; }
|
||||
.onb-drop[data-touch] > button { min-height: 44px; border: 1px solid var(--border); border-radius: var(--r-md); }
|
||||
.onb-drop[data-touch] .onb-chosen > :is(b, small) { overflow-wrap: anywhere; white-space: normal; }
|
||||
.onb-chosen { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 2px 10px; max-width: 100%; text-align: start; animation: onb-land 260ms var(--ease-out) both; }
|
||||
.onb-chosen > .studio-icon { grid-row: 1 / span 2; width: 22px; height: 22px; color: var(--text); }
|
||||
.onb-chosen > b { color: var(--text); font: var(--w-semi) var(--fs-ui) var(--ui); }
|
||||
@@ -135,37 +129,3 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.onb-drop[data-over], .onb-chosen, .onb-stage[data-leaving] .onb-shell, .compose[data-rise] { animation: none !important; }
|
||||
}
|
||||
|
||||
@container onboarding (max-width: 600px) {
|
||||
.onb-brandbar { height: 56px; padding-inline: 24px; }
|
||||
.onb-shell { width: calc(100% - 24px); margin: 16px auto; border-radius: var(--r-lg); }
|
||||
.onb { padding: 20px 16px; }
|
||||
.onb-flow .onb-card { gap: 16px; }
|
||||
.onb-steps { gap: 8px; margin-bottom: 0; padding-bottom: 14px; border-bottom: 1px solid var(--hair); }
|
||||
.onb-steps ol { gap: 8px; }
|
||||
.onb-steps li { flex: none; gap: 6px; }
|
||||
.onb-steps li[data-state="current"] { flex: 1; white-space: normal; }
|
||||
.onb-steps li:not([data-state="current"]) span { display: none; }
|
||||
.onb-steps li + li::before { display: none; }
|
||||
.onb-steps .onb-skip { flex: none; min-height: 44px; }
|
||||
.onb-t { font-size: var(--fs-title); line-height: 1.35; overflow-wrap: anywhere; }
|
||||
.onb-s { margin: -8px 0 0; }
|
||||
.onb-services { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.onb-service { min-height: 60px; padding: 10px; justify-content: center; gap: 4px; }
|
||||
.onb-service b { flex-wrap: wrap; overflow-wrap: anywhere; }
|
||||
.onb-field { gap: 8px; }
|
||||
.onb-field > label { gap: 8px; }
|
||||
.onb-field > label code { display: none; }
|
||||
.onb-getkey { margin-left: 0; min-height: 32px; }
|
||||
.onb-field > input { min-height: 48px; font-size: 16px; }
|
||||
.onb-flow .onb-acts { display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; margin-top: 4px; }
|
||||
.onb-flow .onb-acts .onb-go { width: 100%; min-width: 0; min-height: 48px; }
|
||||
.onb-flow .onb-acts .onb-skip { min-height: 44px; }
|
||||
.onb-flow .onb-note, .onb-flow .onb-err { overflow-wrap: anywhere; }
|
||||
.onb-model { flex-wrap: wrap; min-width: 0; min-height: 52px; }
|
||||
.onb-model b { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.onb-model small { flex-basis: 100%; padding-left: 24px; margin: 0; }
|
||||
.onb-welcome { padding: 24px 0 8px; }
|
||||
.onb-welcome .onb-acts { width: 100%; }
|
||||
.onb-foot { margin-top: auto; padding: 8px 24px 20px; text-align: center; white-space: normal; }
|
||||
}
|
||||
@@ -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 64px 8px 16px; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-actions { grid-column: 1; grid-row: 1; justify-self: end; align-self: center; margin: 0; padding: 8px 10px; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-hd h1 { grid-column: 1; grid-row: 1; padding: 14px 16px 8px; }
|
||||
[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; }
|
||||
@@ -20,8 +20,6 @@
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-main .prefs-card > .grp > .grp-items { grid-column: 1; grid-row: auto; margin-top: 8px; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-main .srv > summary, [data-fold~="phone"] .prefs-sheet .prefs-main .srv > .srv-hd { flex-wrap: wrap; row-gap: 8px; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-main .srv .srv-sub { flex-wrap: wrap; white-space: normal; overflow: visible; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-main .srv .srv-sub > * { max-width: 100%; white-space: normal; overflow: visible; overflow-wrap: anywhere; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-main .srv .srv-sub > .meta { flex-basis: 100%; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-main .skrow { flex-wrap: wrap; row-gap: 6px; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-main .skrow .sk-main { flex: 1 1 12em; min-width: min(12em, 100%); }
|
||||
[data-fold~="phone"] .prefs-sheet::before, [data-fold~="phone"] .prefs-sheet::after { display: none; }
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -36,9 +36,8 @@
|
||||
.bk-chip:has(> input:disabled) { opacity: .55; cursor: default; }
|
||||
.bk .bk-warn { color: var(--warn-ink); }
|
||||
.bk .bk-wait { color: var(--warn-ink); }
|
||||
.bk-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 10px; }
|
||||
.bk-fields > label { display: block; min-width: 0; }
|
||||
.bk-fields > label.full { grid-column: 1 / -1; }
|
||||
.bk-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.bk-fields > label { display: block; }
|
||||
.bk-fields > label > span { display: block; margin: 10px 0 6px; font: var(--w-med) var(--fs-meta)/1.4 var(--ui); color: var(--muted); }
|
||||
.bk-fields input { width: 100%; height: 32px; box-sizing: border-box; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface); color: var(--text); font: var(--w-reg) var(--fs-ui) var(--ui); }
|
||||
.ns-done, .ns-fail { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding: 10px 12px; border-radius: var(--r-md); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); }
|
||||
@@ -47,11 +46,11 @@
|
||||
.ns-fail { background: color-mix(in srgb, var(--warn) 12%, transparent); }
|
||||
.ns-fail .studio-icon { color: var(--warn-ink); }
|
||||
.ns-done .studio-icon, .ns-fail .studio-icon { flex: none; width: 16px; height: 16px; }
|
||||
.ns-veil { position: fixed; inset: 0; z-index: 60; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); place-items: center; padding: 20px; background: color-mix(in oklab, var(--page) 80%, transparent); }
|
||||
.ns-dlg { width: 440px; max-width: 100%; max-height: 100%; overflow: auto; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); }
|
||||
.ns-veil { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 20px; background: color-mix(in oklab, var(--page) 80%, transparent); }
|
||||
.ns-dlg { width: 440px; max-width: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); }
|
||||
.ns-dlg > header { display: flex; align-items: center; gap: 8px; padding: 14px 16px 0; }
|
||||
.ns-dlg > header h3 { flex: 1; margin: 0; font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); }
|
||||
.ns-dlg .ns-x { flex: none; width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--muted); display: inline-grid; place-items: center; cursor: pointer; }
|
||||
.ns-dlg .ns-x { width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--muted); display: inline-grid; place-items: center; cursor: pointer; }
|
||||
.ns-dlg .ns-x:hover { background: var(--overlay); color: var(--text); }
|
||||
.ns-dlg > p { margin: 0; padding: 10px 16px 0; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.7 var(--ui); }
|
||||
.ns-dlg .ns-drop { display: flex; flex-direction: column; align-items: center; gap: 8px; margin: 12px 16px 16px; padding: 22px 16px; border: 1px dashed var(--border); border-radius: var(--r-md); color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); }
|
||||
|
||||
Loaded 100 of 356 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user