Compare commits

...
Author SHA1 Message Date
yhh 7565bce0a5 fix(android): restore local setup and native activity returns
Package the authenticated Studio host so local provider setup has the same
owner capabilities as desktop. Use the existing asynchronous host channel for
camera and folder results, and let the Android container own system and IME
insets. Keep phone onboarding and connection controls usable in narrow views.

Use the Android system shell and retain command confinement settings through
the existing explicit sandbox-escape decision. Cancellation, refusal and a
missing approver remain closed.

Validation: Android 16 device provider probe/chat, repeated camera cancellation
with draft retention, native folder selection, file write/read, one-shot pwd,
keyboard resizing, light/dark bars and cold restart. Frontend: 64 tests, tsc
and build passed. Go: config, host/DNS, builtin and i18n tests passed. go vet,
make lint and make check passed.

The full boot suite still fails its linked-extension test on Windows link
creation and its session-scratch test because WSL has no /bin/bash.
2026-11-08 17:18:09 +08:00
yhh 03ce47f26a fix(settings): unify page layout and responsive dialogs
Group settings into readable sections, keep controls directly discoverable,
and preserve scope menus and standalone dialog headers on narrow screens.
Add interaction and responsive layout coverage for pages and overlays.

Validation: affected frontend tests, production/perf builds, make check,
go vet and pinned make lint pass. Builtin Go tests pass. Unchanged boot
suite has Windows link-permission and missing WSL bash failures and reaches
the default 10-minute timeout.
2026-11-08 14:33:27 +08:00
73 changed files with 1627 additions and 840 deletions

No files matched your search

+2 -2
View File
@@ -16,7 +16,7 @@ local.properties
keystore/
# Build inputs, produced by scripts/ before Gradle runs.
# jniLibs/ holds the cross-compiled kernel, assets/page/ the built
# frontend. assets/LICENSE is hand-placed and stays tracked.
# 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
+45 -12
View File
@@ -15,6 +15,26 @@ This file is for people building it. What a person installing it needs — what
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
@@ -62,19 +82,17 @@ export PATH="$PATH:$(go env GOPATH)/bin"
| `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` is the one to run after a change: it starts the kernel with the
same arguments `Kernel.kt` uses (`--auth token`, `--token-file`, `--port-file`,
`-page`) and checks that it binds a port and answers 200. Without a device, that
is as deep as the chain can be verified.
`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, waiting for the port, health probes, logs, the workspace directory |
| `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 |
@@ -83,14 +101,29 @@ is as deep as the chain can be verified.
The page reaches the shell through `window.reasonixHost` and `ReasonixNative`,
defined in `desktop/frontend-next/src/port/host.ts` (`AndroidHost`).
**Phone and desktop share one page build.** What differs only on a phone is
decided at runtime, not by a second set of components. `phoneShell()` in
`src/port/host.ts` and the `[data-shell="android"]` selector in `mobile.css` both
read `<html data-shell="android">`, which `index.html` sets before the bundle
loads.
**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.
One attribute feeding both is what keeps style and components from disagreeing
about what a phone is.
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
+1
View File
@@ -70,6 +70,7 @@
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">
@@ -0,0 +1,107 @@
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"
}
}
@@ -0,0 +1,34 @@
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
}
}
}
@@ -12,27 +12,11 @@ import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.SecureRandom
import java.security.MessageDigest
import java.util.Locale
import java.util.concurrent.TimeUnit
/**
* The Reasonix kernel, as this app runs it: one child process serving loopback,
* plus the two things it needs on disk to start — the page it serves and a
* credential for this launch.
*
* The shape is the desktop host's (`cmd/reasonix-studio-host`), narrowed to what
* a phone does not have. That host binds a socket, mints a credential, speaks a
* handshake down a pipe and holds a lease; here the credential goes through
* `--token-file` and the bound address through `--port-file`, because a phone's
* app process has no pipe to hand a child and no reason to care which port was
* free. Everything after that — the kernel's own gate, its auth middleware, its
* CSRF check — is untouched, so the security of the surface is the one upstream
* tested, not one this shell invented.
*
* One instance lives in the app process. The activity asks it for a URL; the
* foreground service asks it to stay alive. Both see the same process, so there
* is nothing to synchronise beyond a lock on start/stop.
*/
/** 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. */
@@ -69,10 +53,6 @@ class Kernel(private val context: Context) {
/** The built page, unpacked once per version. */
private val pageDir = File(context.filesDir, "page")
private val tokenFile = File(home, "remote/launch.token")
private val portFile = File(home, "remote/serve.port")
private val pidFile = File(home, "remote/serve.pid")
private val lock = Any()
@Volatile
@@ -97,25 +77,12 @@ class Kernel(private val context: Context) {
var origin: String? = null
private set
/**
* The launch credential, as the URL fragment the page trades for a cookie.
* Kept in memory only: the file on disk belongs to the kernel's own
* bookkeeping, and a phone's app data is not a place to leave a second copy.
*/
/** The host's per-launch credential, held only in memory. */
@Volatile
private var token: String? = null
/**
* The URL the WebView loads first. It carries the credential in the
* fragment, which the page exchanges for an HttpOnly cookie before it makes
* any request — the same path a shared `reasonix web` link takes, so the
* token never enters a request line, a history entry or a log.
*/
fun entryUrl(): String? {
val origin = origin ?: return null
val token = token ?: return null
return "$origin/#token=$token"
}
/** 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
@@ -131,31 +98,26 @@ class Kernel(private val context: Context) {
*/
suspend fun start(): Result<String> = withContext(Dispatchers.IO) {
synchronized(lock) {
origin?.let { return@withContext Result.success(it) }
origin?.takeIf { process?.isAlive == true }?.let { return@withContext Result.success(it) }
var child: Process? = null
runCatching {
val binary = kernelBinary()
publishPage()
prepareHome()
val launchToken = mintToken()
token = launchToken
val child = launch(binary, launchToken)
// The port is the kernel's answer, not this shell's guess. It
// writes the file after binding, so waiting on the file is
// waiting on the bind.
val bound = awaitPort(child)
val url = "http://$bound"
// A kernel that bound a port and then died would look healthy
// here and broken in the WebView; the two facts are checked
// together, and only a kernel that is answering is adopted.
awaitServing(child, url)
origin = url
process = child
url
}.onFailure { origin = null; process = null }
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
}
}
}
@@ -180,8 +142,6 @@ class Kernel(private val context: Context) {
p.destroyForcibly()
}
}
portFile.delete()
pidFile.delete()
}
}
@@ -205,23 +165,13 @@ class Kernel(private val context: Context) {
return binary
}
/**
* Write the bundled page where the kernel can serve it.
*
* It goes to app storage rather than being served out of the APK because the
* kernel's `--page` is a directory on a real filesystem; there is no
* archive-aware variant, and adding one would be a change to the kernel for
* this shell's convenience alone.
*
* Unpacking is versioned: the marker holds the app's version name, so an
* update replaces the page and a relaunch does not. A partially written
* directory — the app killed mid-copy — is caught by the same marker never
* having been written, so the next launch rewrites it.
*/
/** The entry HTML identifies Vite's content-hashed asset graph, including same-version APK rebuilds. */
private fun publishPage() {
val marker = File(pageDir, ".unpacked")
val stamp = "${BuildConfig.VERSION_NAME}:${BuildConfig.VERSION_CODE}"
if (marker.isFile && marker.readText().trim() == stamp) return
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()
@@ -243,12 +193,8 @@ class Kernel(private val context: Context) {
private fun copyAssetTree(assets: AssetManager, from: String, to: File) {
val children = assets.list(from).orEmpty()
if (children.isEmpty()) {
// A leaf: open it as a stream. A directory that happens to be empty
// is indistinguishable and harmless to skip.
runCatching {
assets.open(from).use { input ->
FileOutputStream(to).use { output -> input.copyTo(output) }
}
assets.open(from).use { input ->
FileOutputStream(to).use { output -> input.copyTo(output) }
}
return
}
@@ -432,69 +378,18 @@ class Kernel(private val context: Context) {
/** The kernel's `config.WorkspaceSlug`, for the two paths that matter here. */
private fun slugOf(path: String): String = path.replace('/', '-').replace('\\', '-').replace(':', '-')
/**
* The launch credential.
*
* Minted here rather than read from configuration: a credential a person can
* set is one anything that can read their files can present. 32 bytes, hex,
* the same shape the desktop host mints.
*/
private fun mintToken(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
val token = bytes.joinToString("") { "%02x".format(it) }
tokenFile.parentFile?.mkdirs()
tokenFile.writeText("$token\n")
// 0600 by intent, and the app's own private directory means it is
// already unreachable to every other app on the device.
runCatching {
tokenFile.setReadable(false, false)
tokenFile.setReadable(true, true)
tokenFile.setWritable(false, false)
tokenFile.setWritable(true, true)
}
return token
}
/**
* Start the child.
*
* `--addr 127.0.0.1:0` lets the kernel pick a free port; `--port-file`
* makes it announce which. `--auth token` is the mode a phone needs: the
* page authenticates, and a state-changing request without the credential
* is refused. `--no-open` is belt and braces — a serve never opens a
* browser, and a phone has no browser to open.
*/
private fun launch(binary: File, token: String): Process {
portFile.delete()
pidFile.delete()
// The folder the agent works in, resolved before the command line is
// built so it can be named on it. See the note under the environment
// block for why naming it is not the same as setting the directory.
private fun launch(binary: File): Process {
val workspace = launchWorkspaceDir()
val command = listOf(
binary.absolutePath,
"serve",
"--addr", "127.0.0.1:0",
"--auth", "token",
"--token-file", tokenFile.absolutePath,
"--port-file", portFile.absolutePath,
"--pid-file", pidFile.absolutePath,
"--no-open",
"-page", pageDir.absolutePath,
// The project root, said outright. Without this the kernel derives
// it from its working directory, and on this phone the two did not
// agree: the sidebar named the shared folder, `write_roots` confined
// the file tools to the app's own directory, and every write into the
// folder the person was looking at came back
// `workspace.write_outside_scope`. A flag is a fact the kernel was
// told rather than one it inferred from a mount it sees differently.
"--dir", workspace.absolutePath,
"-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
@@ -573,7 +468,6 @@ class Kernel(private val context: Context) {
// derives `write_roots` from that root, so the two spellings have to be
// the same one.
builder.environment().put("PWD", workspace.absolutePath)
builder.redirectErrorStream(true)
val process = builder.start()
// The kernel's own log is the only place a startup failure says why, and
@@ -670,7 +564,7 @@ class Kernel(private val context: Context) {
private fun drain(process: Process) {
runCatching {
process.inputStream.bufferedReader().useLines { lines ->
process.errorStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
Log.i(TAG, line)
synchronized(logLines) {
@@ -685,21 +579,6 @@ class Kernel(private val context: Context) {
/** The kernel's tail, newest last, for a bug report or the About screen. */
fun recentLog(): String = synchronized(logLines) { logLines.joinToString("\n") }
private fun awaitPort(process: Process): String {
val deadline = System.currentTimeMillis() + START_TIMEOUT_MS
while (System.currentTimeMillis() < deadline) {
if (!process.isAlive) {
throw IllegalStateException("内核启动即退出。\n${recentLog().takeLast(2000)}")
}
val text = if (portFile.isFile) runCatching { portFile.readText().trim() }.getOrNull() else null
// The file is written by the kernel on the way to serving, and the
// kernel rewrites it with the real port when it was given :0.
if (!text.isNullOrBlank() && text.contains(":")) return text
Thread.sleep(POLL_MS)
}
throw IllegalStateException("内核在 ${START_TIMEOUT_MS / 1000}s 内没有报告监听地址。\n${recentLog().takeLast(2000)}")
}
/**
* Wait until the bound port answers.
*
@@ -707,23 +586,24 @@ class Kernel(private val context: Context) {
* kernel's life; a WebView pointed at it would show a connection error and
* cache the failure, so the wait belongs here rather than in the page.
*/
private fun awaitServing(process: Process, origin: String) {
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("$origin/")) return
if (probe("${connection.origin}/", connection.token)) return
Thread.sleep(POLL_MS)
}
throw IllegalStateException("内核已监听 $origin,但页面没有响应。\n${recentLog().takeLast(2000)}")
throw IllegalStateException("内核已监听 ${connection.origin},但页面没有响应。\n${recentLog().takeLast(2000)}")
}
private fun probe(url: String): Boolean = runCatching {
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 {
@@ -9,9 +9,7 @@ import android.graphics.Canvas
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.os.PowerManager
import android.provider.DocumentsContract
import android.provider.Settings
import android.util.Base64
import android.util.Log
@@ -34,6 +32,7 @@ import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
@@ -193,6 +192,7 @@ class MainActivity : AppCompatActivity() {
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
rootLayout = FrameLayout(this)
web = WebView(this)
@@ -206,6 +206,7 @@ class MainActivity : AppCompatActivity() {
FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT),
)
setContentView(rootLayout)
syncWindowTheme("light")
configureWebView()
// Keep the renderer alive while the app is off screen.
@@ -258,7 +259,7 @@ class MainActivity : AppCompatActivity() {
// `env(safe-area-inset-*)` is an iOS Safari feature and a WebView answers
// it with 0, so without this the interface starts under the status bar.
installInsetsListener()
ViewCompat.requestApplyInsets(web)
ViewCompat.requestApplyInsets(rootLayout)
// The kernel runs under the service, so it survives the screen going off
// and a task that takes minutes finishes. Starting it here as well is
@@ -386,80 +387,11 @@ class MainActivity : AppCompatActivity() {
return picked.takeIf { it.isNotEmpty() }?.toTypedArray()
}
/**
* A folder on this phone, as a workspace.
*
* `OpenDocumentTree` is the only system picker for a directory, and it
* answers with a tree URI — not the path the kernel opens. The translation
* below is the one that makes it usable: the tree's document id names the
* volume and the relative folder, and with every-file access granted those
* map onto a real directory the kernel can `stat`.
*
* That mapping is why the permission is asked for first. Without it the path
* is real but unreadable, and the kernel reports an empty or missing folder
* — a failure a long way from its cause.
*/
private val pickFolderTree = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
bridge.deliverFolder(uri?.let(::realPathFromTree))
}
private val nativeResults = mutableMapOf<String, String>()
private val folders = FolderPicker(this) { path -> finishNativePicker("pickFolder", path) }
/**
* A tree URI as the filesystem path a child process can open, or null when
* the volume is one this mapping does not cover (a cloud or MTP provider).
*
* The path is checked before it is handed over, because the mapping is a
* guess about a naming convention rather than a lookup: the document id is
* the volume and the folder *below* it, and this turns that into a string.
* A tree whose id carries `..` would compose into a path outside the folder
* the person picked — and the kernel, once given the all-files permission,
* would open it.
*
* Every part of the id is checked, and the volume is the part that is easy
* to miss: a document id of `..:data` composes to `/storage/../data`, which
* `canonicalFile` collapses to `/data` and `isDirectory` then accepts, since
* it really is a directory. Rejecting only the folder half left exactly the
* traversal this function exists to refuse.
*
* The prefix check at the end is the belt to that braces: whatever the parts
* spelled, the answer has to still be the folder that was asked for.
*/
private fun realPathFromTree(uri: Uri): String? = runCatching {
val docId = DocumentsContract.getTreeDocumentId(uri)
val type = docId.substringBefore(':', "")
val rel = docId.substringAfter(':', "")
if (!isSafeSegment(type) || !isSafeRelPath(rel)) return@runCatching null
val base = when (type) {
"primary" -> Environment.getExternalStorageDirectory().absolutePath
// A removable volume: its document id is the volume's own name
// (`1234-ABCD`), which is the directory under `/storage`.
else -> File("/storage", type).path
}
val target = if (rel.isEmpty()) base else "$base/$rel"
val dir = File(target).canonicalFile
if (!dir.isDirectory) return@runCatching null
// Still under the volume it claimed to be on. A path that resolved
// somewhere else is not the folder that was picked, whatever it is.
val root = File(base).canonicalFile
val canonical = dir.canonicalPath
if (canonical != root.canonicalPath && !canonical.startsWith(root.canonicalPath + File.separator)) {
return@runCatching null
}
canonical
}.getOrNull()
/** One path component from a document id: a name, never a direction. `.`
* and `..` are refused because they are the two spellings of "not a name"
* that a filesystem still resolves. */
private fun isSafeSegment(part: String): Boolean {
if (part.isEmpty() || part == "." || part == "..") return false
return !part.contains('/') && !part.contains('\\') && !part.contains('\u0000')
}
/** The folder part of a document id: a chain of safe components, each of
* which is checked, so a `..` cannot hide behind a later segment. */
private fun isSafeRelPath(rel: String): Boolean {
if (rel.isEmpty()) return true
return rel.split('/').all { isSafeSegment(it) }
private fun finishNativePicker(method: String, value: String?) {
nativeResults.remove(method)?.let { replyHost(it, JSONObject.quote(value.orEmpty())) }
}
/**
@@ -480,7 +412,7 @@ class MainActivity : AppCompatActivity() {
// the person's confirmation that that origin may become the window, and
// it is spent by the one `openInApp` that follows.
confirmedPairOrigin = text?.let { originOf(Uri.parse(it.trim())) }
bridge.deliverScan(text)
finishNativePicker("scanPairingCode", text)
}
/**
@@ -511,7 +443,7 @@ class MainActivity : AppCompatActivity() {
pickIdentity.launch(arrayOf("*/*"))
true
}
bridge.onInsets = { currentInsets }
bridge.onInsets = { Insets(0, 0, 0, 0) }
bridge.askForSave = { name, content ->
pendingSave = name to content
runCatching { saveTextFile.launch(name) }
@@ -520,110 +452,33 @@ class MainActivity : AppCompatActivity() {
.isSuccess
}
bridge.askForShare = { name, content -> shareText(name, content) }
bridge.askForFolder = { startIn ->
// The picker opens at its own last location; `startIn` is a hint it
// may ignore, so a failure to use it is not a failure to ask.
pickFolderTree.launch(null)
true
}
bridge.onRunning = { running -> runOnUiThread { applyRunning(running) } }
bridge.filesAccess = { hasAllFilesAccess() }
bridge.askForFilesAccess = { openAllFilesSettings() }
bridge.filesAccess = folders::hasAccess
bridge.askForFilesAccess = { runOnUiThread { folders.requestAccess() } }
bridge.batteryFree = { batteryUnrestricted() }
bridge.askForBattery = { requestBatteryExemption() }
bridge.askForScan = {
// The camera is live from the moment this opens, so the ask is the
// launch itself; there is no second step to report.
scanCode.launch(Intent(this, ScanActivity::class.java))
true
}
bridge.onOpenInApp = { url -> openInApp(url) }
bridge.onHostCall = { id, method, args -> handleHostCall(id, method, args) }
target.addJavascriptInterface(bridge, "ReasonixNative")
}
/**
* The window's real insets, in CSS pixels, recomputed on every change.
*
* Read from the WebView's own window insets rather than from the decor view,
* and every value is the max of the system bars and the display cutout: a
* phone with a transparent status bar and a hole-punch camera reports a small
* or zero bar inset and a real cutout inset, and padding by only the former
* leaves the interface under the camera.
*/
private var currentInsets = Insets(0, 0, 0, 0)
private fun installInsetsListener() {
ViewCompat.setOnApplyWindowInsetsListener(web) { _, insets ->
val density = resources.displayMetrics.density.takeIf { it > 0f } ?: 1f
fun px(value: Int) = Math.round(value / density)
val system = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val cutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout())
val values = Insets(
left = px(maxOf(system.left, cutout.left)),
top = px(maxOf(system.top, cutout.top)),
right = px(maxOf(system.right, cutout.right)),
bottom = px(maxOf(system.bottom, cutout.bottom)),
ViewCompat.setOnApplyWindowInsetsListener(rootLayout) { view, insets ->
val safe = insets.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() or WindowInsetsCompat.Type.ime(),
)
currentInsets = values
pushInsets(values)
// The keyboard rides its own channel. It is not a system bar: it
// comes and goes with focus, and only the composer rises above it.
//
// `isVisible` gates the number. An IME inset of zero is not the
// same as "no keyboard" — a device can report the navigation bar's
// height on this type while the keyboard is down, and feeding that
// to the page would lift the composer by a keyboard nobody opened.
val ime = if (insets.isVisible(WindowInsetsCompat.Type.ime())) {
px(insets.getInsets(WindowInsetsCompat.Type.ime()).bottom)
} else {
0
}
pushIme(ime)
insets
view.setPadding(safe.left, safe.top, safe.right, safe.bottom)
WindowInsetsCompat.CONSUMED
}
}
/**
* Hand the page the insets it cannot compute for itself.
*
* A WebView answers `env(safe-area-inset-*)` with `0` on every Android
* version — those are an iOS Safari feature — so `mobile.css`'s padding,
* which is written against them, does nothing here and the first row of the
* interface sits behind the status bar. Android 15 makes that unconditional
* by forcing the window edge-to-edge whatever the app asks for, so the
* numbers have to be pushed in rather than computed in CSS.
*
* Pushed on every inset change, not once: the values move when the phone is
* rotated, when a foldable is unfolded, and when the keyboard opens.
*/
private fun pushInsets(values: Insets) {
web.evaluateJavascript(
"window.__reasonixInsets && window.__reasonixInsets(${values.left},${values.top},${values.right},${values.bottom});",
null,
)
}
/**
* Hand the page the keyboard's height, which it cannot reliably measure.
*
* The page reads `visualViewport` for this, and that works only while the
* window stays edge to edge: Android 15 forces that from targetSdk 35, but a
* WebView told to `adjustResize` shrinks the window instead, and then the
* visual and layout viewports are equal and the difference the page
* computes is zero while the keyboard is up. The window insets answer in
* exactly that case, so the two sources are both sent and the page takes
* the larger — whichever arrangement the platform picked, the number is the
* keyboard's real height.
*
* Pushed on every inset change, including the one that reports the keyboard
* going away, so the composer comes back down with it.
*/
private fun pushIme(bottom: Int) {
web.evaluateJavascript(
"window.__reasonixIme && window.__reasonixIme($bottom);",
null,
)
private fun syncWindowTheme(scheme: String) {
val light = scheme != "dark"
WindowCompat.getInsetsController(window, web).apply {
isAppearanceLightStatusBars = light
isAppearanceLightNavigationBars = light
}
rootLayout.setBackgroundColor(getColor(if (light) R.color.page else R.color.page_dark))
}
private fun installClients() {
@@ -788,6 +643,8 @@ class MainActivity : AppCompatActivity() {
// the old object was reachable only from the destroyed window.
installBridge(fresh)
installClients()
installInsetsListener()
ViewCompat.requestApplyInsets(rootLayout)
splash.alpha = 1f
splash.visibility = View.VISIBLE
@@ -798,10 +655,11 @@ class MainActivity : AppCompatActivity() {
private fun load(origin: String) {
installClients()
// The fragment path: the page exchanges the token for an HttpOnly cookie
// before its first request, exactly as a shared `reasonix web` link does.
val entry = (application as App).kernel.entryUrl() ?: origin
web.loadUrl(entry)
val cookie = (application as App).kernel.sessionCookie() ?: return
android.webkit.CookieManager.getInstance().setCookie(origin, cookie) { accepted ->
if (accepted) web.loadUrl(origin)
else showFailure(IllegalStateException("无法建立应用内核的本地会话"))
}
}
/**
@@ -910,31 +768,6 @@ class MainActivity : AppCompatActivity() {
web.keepScreenOn = running
}
/** Whether this app may read every file. Below API 30 the concept does not
* exist and the answer is yes. */
private fun hasAllFilesAccess(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Environment.isExternalStorageManager() else true
/**
* The one place Android offers the every-file permission.
*
* There is no runtime dialog for it — it is a special access, granted by
* hand per app — so the only honest implementation is to open the page that
* holds the switch. A person who does not find it there loses nothing: the
* app keeps working on the folders it already has.
*/
private fun openAllFilesSettings() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
val intent = Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:$packageName"),
)
runCatching { startActivity(intent) }.recoverCatching {
// Some ROMs do not route the per-app page; the list is the fallback.
startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION))
}
}
/** Whether this app is exempt from the system's battery optimisation. */
private fun batteryUnrestricted(): Boolean {
val power = getSystemService(POWER_SERVICE) as? PowerManager ?: return true
@@ -1165,6 +998,24 @@ class MainActivity : AppCompatActivity() {
runOnUiThread {
try {
when (method) {
"syncTheme" -> {
syncWindowTheme(JSONArray(args).optString(0, "light"))
replyHost(id, "null")
}
"pickFolder", "scanPairingCode" -> {
if (nativeResults.isNotEmpty()) {
replyHost(id, JSONObject.quote(""))
} else {
nativeResults[method] = id
runCatching {
if (method == "pickFolder") folders.pick(JSONArray(args).optString(0, ""))
else {
confirmedPairOrigin = null
scanCode.launch(Intent(this, ScanActivity::class.java))
}
}.onFailure { finishNativePicker(method, null) }
}
}
"showBrowserView" -> {
val arr = JSONArray(args)
val rect = if (arr.length() > 1) arr.optJSONObject(1) else null
@@ -1383,7 +1234,7 @@ class MainActivity : AppCompatActivity() {
};
function call(method, args) {
return new Promise(function(resolve, reject) {
var id = String(nextId++);
var id = Date.now() + ':' + String(nextId++);
pending[id] = { resolve: resolve, reject: reject };
try {
ReasonixNative.hostCall(id, method, JSON.stringify(args || []));
@@ -1397,6 +1248,9 @@ class MainActivity : AppCompatActivity() {
shell: 'electron',
platform: 'android',
titleBar: false,
syncTheme: function(mode, scheme) { return call('syncTheme', [scheme]); },
pickFolder: function(startIn) { return call('pickFolder', [startIn]); },
scanPairingCode: function() { return call('scanPairingCode', []); },
showBrowserView: function(target, rect) { return call('showBrowserView', [target, rect]); },
hideBrowserView: function() { return call('hideBrowserView', []); },
freezeBrowserView: function() { return call('freezeBrowserView', []); },
@@ -1419,6 +1273,7 @@ class MainActivity : AppCompatActivity() {
answerBrowserLogin: function(id, username, password) { return call('answerBrowserLogin', [id, username, password]); },
trustBrowserCertificate: function(target) { return call('trustBrowserCertificate', [target]); }
};
window.dispatchEvent(new Event('reasonix-host-ready'));
})();
""".trimIndent(), null)
}
@@ -261,21 +261,6 @@ class NativeBridge(
return "${insets.left},${insets.top},${insets.right},${insets.bottom}"
}
/**
* Read the pairing code a computer is showing.
*
* A phone cannot type what a computer renders as a QR code — it is a
* one-time code inside a URL, and the whole reason it is a picture is that
* nobody reads one aloud. So the page asks for the camera and gets the text
* back, which is the same direction the computer's own dialog describes.
*
* The answer is the code's own text, not a parsed result: what a pairing
* fragment means is the page's to decide, and it already knows how — the
* `#pair=` handling ships with the interface.
*/
@JavascriptInterface
fun scanPairingCode(): String = if (!trusted()) "" else scanWithCamera()
/**
* Load a URL in this window instead of handing it to the browser.
*
@@ -355,11 +340,6 @@ class NativeBridge(
private val pending = AtomicReference<Uri?>(null)
private val latch = AtomicReference<CountDownLatch?>(null)
/** The code the camera read, held between the activity's answer and the
* wait below — the same round trip the file picker takes. */
private val scanned = AtomicReference<String?>(null)
private val scanLatch = AtomicReference<CountDownLatch?>(null)
/** The main thread, because starting an Activity result must happen there. */
private val main = Handler(Looper.getMainLooper())
@@ -378,13 +358,6 @@ class NativeBridge(
*/
var onInsets: (() -> Insets?)? = null
/**
* How to open the camera. Set by the activity, which owns the Activity
* Result API; like `askForFile` it answers whether the ask was dispatched,
* and the code itself arrives through `deliverScan`.
*/
var askForScan: () -> Boolean = { false }
/**
* How to put a file where the person picks. Set by the activity, which owns
* the Activity Result API; it answers whether the ask was dispatched, and
@@ -442,51 +415,6 @@ class NativeBridge(
saveLatch.get()?.countDown()
}
/**
* Read the pairing code a computer is showing, and return it.
*
* The same shape as `pickFile` and for the same reason: a
* `@JavascriptInterface` method is called on the WebView's thread and must
* return a value, so the camera is opened on the main thread and this one
* waits. The block lasts as long as the person is aiming, which is why the
* timeout is generous — a scan that gave up after a few seconds would be
* one nobody could complete.
*
* An empty string is "no code", which is what a refusal, a cancellation and
* a timeout all are. The caller acts on the text, so a distinct error would
* only be a fourth way to say the same thing.
*/
private fun scanWithCamera(): String {
val gate = CountDownLatch(1)
scanLatch.set(gate)
scanned.set(null)
val posted = CountDownLatch(1)
var dispatched = false
main.post {
dispatched = runCatching { askForScan() }.getOrDefault(false)
posted.countDown()
}
val ready = runCatching { posted.await(SCAN_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
if (!ready || !dispatched) {
scanLatch.set(null)
return ""
}
return try {
if (!gate.await(SCAN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) "" else scanned.get().orEmpty()
} catch (_: InterruptedException) {
""
} finally {
scanLatch.set(null)
}
}
/** Called by the activity when the scanner answers. Null is a dismissal. */
fun deliverScan(text: String?) {
scanned.set(text)
scanLatch.get()?.countDown()
}
/**
* Ask for one file and wait for the answer.
*
@@ -670,63 +598,10 @@ class NativeBridge(
var askForBattery: (() -> Unit)? = null
/**
* Ask for a folder on this phone and answer with its real filesystem path.
*
* "" is a dismissal, the same answer `importIntoHome` gives — and, unlike
* that one, an empty string is a normal outcome here rather than a failure.
* The `startIn` hint is a path the kernel knows; the picker may ignore it,
* which is the picker's business.
*
* The round trip is the same latch as `pickFile`, for the same reason: a
* document-tree picker is another activity, and this call cannot suspend.
*/
@JavascriptInterface
fun pickFolder(startIn: String): String {
if (!trusted()) return ""
val gate = CountDownLatch(1)
folderLatch.set(gate)
folderPending.set(null)
val posted = CountDownLatch(1)
var dispatched = false
main.post {
dispatched = runCatching { askForFolder(startIn) }.getOrDefault(false)
posted.countDown()
}
val ready = runCatching { posted.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
if (!ready || !dispatched) {
folderLatch.set(null)
return ""
}
return try {
if (!gate.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) "" else folderPending.get().orEmpty()
} catch (_: InterruptedException) {
""
} finally {
folderLatch.set(null)
}
}
var askForFolder: (String) -> Boolean = { false }
private val folderPending = AtomicReference<String?>(null)
private val folderLatch = AtomicReference<CountDownLatch?>(null)
/** Called by the activity with the folder's real path, or null on dismissal. */
fun deliverFolder(path: String?) {
folderPending.set(path ?: "")
folderLatch.get()?.countDown()
}
companion object {
const val BRIDGE_VERSION = "4"
const val BRIDGE_VERSION = "5"
private const val PICK_TIMEOUT_SECONDS = 5L * 60
/** Long, because this is however long someone takes to aim. A shorter
* one would turn "still lining it up" into a failed scan. */
private const val SCAN_TIMEOUT_SECONDS = 5L * 60
/** Only as long as the sheet takes to appear: the answer is "something
* took it", which is known the moment the Intent resolves. */
private const val SHARE_TIMEOUT_SECONDS = 15L
@@ -8,6 +8,8 @@ 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
@@ -17,6 +19,8 @@ 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
/**
@@ -82,6 +86,16 @@ class ScanActivity : AppCompatActivity() {
}
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) ==
@@ -93,10 +107,8 @@ class ScanActivity : AppCompatActivity() {
}
}
private fun refuse() {
// No dialog of our own: the permission prompt already said no, and a
// second window repeating it is one more tap to dismiss. The caller
// gets the same answer as a cancelled scan.
private fun refuse(message: Int = R.string.scan_denied) {
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
setResult(RESULT_CANCELED)
finish()
}
@@ -104,7 +116,7 @@ class ScanActivity : AppCompatActivity() {
private fun start() {
val providerFuture = ProcessCameraProvider.getInstance(this)
providerFuture.addListener({
val provider = runCatching { providerFuture.get() }.getOrNull() ?: return@addListener refuse()
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
@@ -116,7 +128,7 @@ class ScanActivity : AppCompatActivity() {
runCatching {
provider.unbindAll()
provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, previewUseCase, analysis)
}.onFailure { refuse() }
}.onFailure { refuse(R.string.scan_unavailable) }
}, ContextCompat.getMainExecutor(this))
}
@@ -126,10 +138,10 @@ class ScanActivity : AppCompatActivity() {
val text = Qr.decode(image) ?: return
if (settled) return
settled = true
setResult(RESULT_OK, Intent().putExtra(EXTRA_TEXT, text))
// Finishing from the analysis thread is the whole reason `settled`
// exists; the activity teardown has to happen on the main one.
runOnUiThread { finish() }
runOnUiThread {
setResult(RESULT_OK, Intent().putExtra(EXTRA_TEXT, text))
finish()
}
} finally {
image.close()
}
@@ -5,5 +5,6 @@
rather than the platform default white. -->
<resources>
<color name="page">#F4F1EA</color>
<color name="page_dark">#171A18</color>
<color name="boot">#07080C</color>
</resources>
+5 -1
View File
@@ -23,13 +23,17 @@
<string name="splash_retry">重试</string>
<string name="wsperm_title">需要「所有文件访问」权限</string>
<string name="wsperm_body">要打开手机上的文件夹作为工作区,需要先允许本应用访问所有文件。点「去设置」,在列表里打开 Reasonix 的开关,然后返回这里。</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 -3
View File
@@ -48,17 +48,15 @@ version_field() {
sed -n "s/^def $1 *= *\([0-9]*\).*/\1/p" "$app_gradle" | head -n1
}
app_version="${REASONIX_APP_VERSION:-$(version_field versionMajor).$(version_field versionMinor).$(version_field versionPatch)}"
build_time_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
ldflags="-s -w"
ldflags="$ldflags -X main.version=v$app_version"
ldflags="$ldflags -X main.buildTimeUTC=$build_time_utc"
ldflags="$ldflags -X reasonix/internal/tools/productdocs.linkedVersion=v$app_version"
ldflags="$ldflags -X reasonix/internal/tools/productdocs.linkedRevision=apk-v$app_version"
echo "building the kernel (android/arm64, v$app_version) ..."
mkdir -p "$(dirname "$out")"
cd "$upstream"
go build -trimpath -ldflags="$ldflags" -o "$out" ./cmd/reasonix
go build -trimpath -ldflags="$ldflags" -o "$out" ./cmd/reasonix-studio-host
size=$(wc -c < "$out")
echo "kernel ready: $out ($((size / 1024 / 1024)) MiB)"
+95
View File
@@ -0,0 +1,95 @@
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()
+72
View File
@@ -0,0 +1,72 @@
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 -92
View File
@@ -1,95 +1,4 @@
#!/usr/bin/env bash
#
# Reproduce what `Kernel.kt` does, on this machine, against the page that is
# actually packaged into the APK.
#
# This is the closest thing to an end-to-end test that runs without a phone: the
# launch flags, the token file, the port file and the `--page` directory are the
# same ones the Kotlin shell passes, and the page is read out of
# `app/src/main/assets/page` rather than the frontend's own `dist/`. If this
# serves a working interface, the only untested part is Android itself.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
android="$here/.."
page="$android/app/src/main/assets/page"
kernel="${KERNEL_BIN:-$android/../reasonix-verify.exe}"
work="${VERIFY_DIR:-/tmp/reasonix-verify}"
rm -rf "$work"
mkdir -p "$work/home/remote"
if [ ! -f "$kernel" ]; then
echo "no runnable kernel; build one first:" >&2
echo " (cd .. && go build -o reasonix-verify.exe ./cmd/reasonix)" >&2
exit 1
fi
if [ ! -f "$page/index.html" ]; then
echo "no packaged page: $page/index.html" >&2
exit 1
fi
# 32 random bytes as hex — the same shape `Kernel.mintToken` writes.
token=$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')
# The kernel refuses a token file other users can read.
(umask 077; printf '%s\n' "$token" > "$work/home/remote/launch.token")
export REASONIX_HOME="$work/home"
export HOME="$work/home"
export TMPDIR="$work"
echo "kernel: $kernel"
echo "page: $page"
echo "token: ${token:0:8}... (${#token} chars)"
"$kernel" serve \
--addr 127.0.0.1:0 \
--auth token \
--token-file "$work/home/remote/launch.token" \
--port-file "$work/home/remote/serve.port" \
--pid-file "$work/home/remote/serve.pid" \
--no-open \
-page "$page" \
> "$work/kernel.log" 2>&1 &
echo $! > "$work/kernel.pid"
# Wait for the port file the kernel writes after binding, which is exactly the
# wait `Kernel.awaitPort` performs.
for _ in $(seq 1 150); do
if [ -s "$work/home/remote/serve.port" ]; then break; fi
if ! kill -0 "$(cat "$work/kernel.pid")" 2>/dev/null; then
echo "the kernel exited; log:" >&2
cat "$work/kernel.log" >&2
exit 1
fi
sleep 0.2
done
if [ ! -s "$work/home/remote/serve.port" ]; then
echo "the kernel reported no listening address; log:" >&2
cat "$work/kernel.log" >&2
exit 1
fi
port=$(cat "$work/home/remote/serve.port")
echo "listening on: $port"
# The two things the WebView will do: fetch the page, and exist on the origin.
probe=$(curl -s -o /dev/null -w '%{http_code}' "http://$port/")
echo "GET / -> $probe"
if [ "$probe" != "200" ]; then
echo "the page did not answer; log:" >&2
cat "$work/kernel.log" >&2
exit 1
fi
cat > "$work/entry.txt" <<EOF
origin=http://$port
entry=http://$port/#token=$token
token=$token
EOF
echo "entry written: $work/entry.txt"
echo "kernel log (first 10 lines):"
head -10 "$work/kernel.log"
exec python "$here/verify-host.py"
+11
View File
@@ -0,0 +1,11 @@
package main
import (
"os"
"reasonix/internal/base/netdns"
)
func init() {
netdns.Install(netdns.Parse(os.Getenv(netdns.EnvVar)))
}
@@ -0,0 +1,74 @@
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();
}
+1 -1
View File
@@ -64,7 +64,7 @@ class BenchPort extends MockPort {
opensFiles() { return query.has("deliveries") || super.opensFiles(); }
revealsFiles() { return query.has("deliveries") || super.revealsFiles(); }
async account() {
const state = await super.account();
const state = query.has("signedin") ? { signedIn: true, user: { handle: "demo", email: "[email protected]", label: "demo" } } : await super.account();
const label = query.get("accountLabel");
return label && state.signedIn && state.user ? { ...state, user: { ...state.user, label } } : state;
}
@@ -0,0 +1,58 @@
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();
}
@@ -0,0 +1,51 @@
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();
}
@@ -0,0 +1,132 @@
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,15 +13,15 @@ try {
const lefts = () =>
page.evaluate(() => {
const left = (selector) => Math.round(document.querySelector(selector).getBoundingClientRect().left);
return { title: left(".prefs-main .prefs-pagetitle"), index: left(".prefs-main .page-index"), col: left(".prefs-main .prefs-col") };
return { title: left(".prefs-main .prefs-pagetitle"), 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(at.index, at.title, `${name}: the page index starts where the title does (${JSON.stringify(at)})`);
assert.equal(await page.locator(".page-index").count(), 0);
assert.equal(at.col, at.title, `${name}: the cards start where the title does (${JSON.stringify(at)})`);
console.log(`PASS ${name}: title, index and cards all start at ${at.title}px`);
console.log(`PASS ${name}: title and cards start at ${at.title}px without a duplicate index`);
}
await page.locator(".prefs-nav button", { hasText: /^操作与性能/ }).first().click();
await page.waitForTimeout(400);
-1
View File
@@ -662,7 +662,6 @@ 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" },
+1 -1
View File
@@ -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%; padding: 0; overflow: hidden; 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%; 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 > 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,3 +1,6 @@
.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; }
@@ -116,6 +119,9 @@
.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); }
@@ -129,3 +135,37 @@
@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,7 @@
[data-fold~="phone"] .prefs { inset: 0; padding: 0; }
[data-fold~="phone"] .prefs-sheet { width: 100%; height: 100%; border: 0; border-radius: 0; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto auto minmax(0, 1fr); }
[data-fold~="phone"] .prefs-sheet .prefs-hd h1 { grid-column: 1; grid-row: 1; padding: 14px 16px 8px; }
[data-fold~="phone"] .prefs-sheet .prefs-actions { grid-column: 1; grid-row: 1; justify-self: end; align-self: center; padding: 8px 10px; }
[data-fold~="phone"] .prefs-sheet .prefs-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-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; }
@@ -20,6 +20,8 @@
[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; }
@@ -36,8 +36,9 @@
.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: 1fr 1fr; gap: 10px; }
.bk-fields > label { display: block; }
.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 > 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); }
@@ -46,11 +47,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; 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-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-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 { 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 { 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: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); }
@@ -32,8 +32,8 @@ describe("the export chips follow the board", () => {
expect(rule(".bk-chip[data-on] .box")).toMatch(/background:\s*var\(--accent\)/);
});
it("lays the two passphrase fields side by side", () => {
expect(rule(".bk-fields")).toMatch(/grid-template-columns:\s*1fr 1fr/);
it("keeps passphrase fields readable and stacks them when two columns cannot fit", () => {
expect(rule(".bk-fields")).toMatch(/grid-template-columns:\s*repeat\(auto-fit, minmax\(min\(100%, 180px\), 1fr\)\)/);
});
});
@@ -22,6 +22,6 @@ describe("the settings search results", () => {
expect(found).toMatch(/top:\s*0/);
expect(found).toMatch(/padding:\s*58px /);
expect(studio).toMatch(/\.prefs-hd \{ min-height: 58px/);
expect(rule(':root:not([data-fold~="phone"]) .prefs-actions')).toMatch(/z-index:\s*6/);
expect(rule('.prefs-actions')).toMatch(/z-index:\s*6/);
});
});
+50 -34
View File
@@ -16,30 +16,27 @@
.prefs-pagetitle .desc { font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); color: var(--faint); }
.prefs-col { max-width: 880px; margin-inline: auto; }
.prefs-card { margin: 0 0 14px; padding: 12px 18px 4px; border: 1px solid var(--hair); border-radius: var(--r-lg); background: var(--raised); overflow: visible; }
.prefs-card > header { padding: 0; margin: 0 0 4px; }
.prefs-card > header h3 { margin: 0; font: var(--w-semi) var(--fs-ui)/1.4 var(--ui); }
.prefs-card > header { padding: 0; margin: 0 0 6px; }
.prefs-card > header h3 { margin: 0; font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); }
.prefs-card > header p { display: none; }
.prefs-card > .grp { margin: 0; padding: 10px 0; border: 0; border-top: 1px solid var(--hair); border-radius: 0; background: none; box-shadow: none; column-gap: 16px; }
.prefs-card > .grp .grp-hd h3 { font: var(--w-med) var(--fs-ui)/1.4 var(--ui); color: var(--text); }
.prefs-card > .grp .hint { margin: 2px 0 0; font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); color: var(--faint); max-width: none; }
.prefs-card > .grp .hint { margin: 2px 0 0; font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); color: var(--muted); max-width: none; }
.prefs-card > .grp .apply { margin: 2px 0 0; font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); color: var(--faint); }
.prefs-card .seg { padding: 2px; gap: 2px; border-radius: var(--r-sm); background: var(--overlay); }
.prefs-card .seg button { height: 26px; min-height: 26px; padding: 0 10px; border-radius: var(--r-xs); font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted); }
.prefs-card .seg button[aria-pressed="true"], .prefs-card .seg button[aria-checked="true"] { background: var(--raised); color: var(--text); font-weight: var(--w-med); box-shadow: 0 1px 2px rgb(0 0 0 / .07); }
.prefs-card select { width: 140px; height: 30px; padding: 0 10px; border-radius: var(--r-sm); border: 1px solid var(--border); background-color: var(--surface); font: var(--w-reg) var(--fs-ui) var(--ui); }
.prefs-card select.peerfield { width: 100%; text-overflow: ellipsis; white-space: nowrap; }
.prefs-card .ml-row select { width: 100%; min-width: 0; }
.prefs-col .prefs-card > .grp .grp-hd h3 { font-size: var(--fs-ui); font-weight: var(--w-med); }
.prefs-main { container: prefsmain / inline-size; }
@container prefsmain (min-width: 860px) {
.prefs-col[data-cols="2"] { max-width: 1400px; column-count: 2; column-gap: 16px; }
.prefs-col[data-cols="2"] > .prefs-card { break-inside: avoid; }
}
.prefs-card > .srow, .grp[data-setting="global-shortcut"] .srow { display: flex; align-items: center; gap: 16px; padding: 10px 0; border-top: 1px solid var(--hair); }
.srow-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.prefs-card > .srow, .grp[data-setting="global-shortcut"] .srow { display: flex; align-items: center; gap: 24px; padding: 10px 0; border-top: 1px solid var(--hair); }
.srow-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.srow-name { color: var(--text); font: var(--w-med) var(--fs-ui)/1.4 var(--ui); }
.srow-desc { color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
.srow-desc { color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
.srow-meta > .apply { margin: 0; padding: 0; border: 0; color: var(--faint); font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); }
.srow-ctl { flex: none; display: flex; align-items: center; gap: 8px; }
.srow-ctl { flex: none; display: flex; align-items: center; gap: 8px; max-width: 100%; flex-wrap: wrap; }
.spill { height: 24px; padding: 0 10px; border: 1px solid var(--border); border-radius: 999px; background: transparent; color: var(--text); font: var(--w-reg) var(--fs-meta) var(--ui); white-space: nowrap; cursor: pointer; }
.spill:hover:not(:disabled) { background: var(--overlay); }
.spill:disabled { opacity: .5; cursor: default; }
@@ -58,8 +55,8 @@
.prefs-col .prefs-card .seg button { font-weight: var(--w-reg); }
.prefs-col .prefs-card .seg button[aria-pressed="true"], .prefs-col .prefs-card .seg button[aria-checked="true"] { font-weight: var(--w-med); }
.prefs-main { grid-row: 1 / span 3; }
.prefs-actions { grid-row: 1; position: absolute; top: 10px; right: 14px; z-index: 6; padding: 0; }
.prefs-pagetitle { padding-right: 150px; }
.prefs-actions { grid-row: 1; position: relative; align-self: start; margin: 10px 14px 0 0; z-index: 6; padding: 0; }
.prefs-pagetitle { padding-right: 40px; box-sizing: border-box; }
.prefs-hd h1 { padding: 14px 18px 10px; }
.prefs-sheet > .prefs-hd > .prefs-find { margin: 0 10px 8px; width: calc(100% - 20px); }
.prefs-nav { padding: 0 10px 14px; gap: 2px; }
@@ -78,7 +75,7 @@
.prefs-hd h1 { padding-bottom: 12px; line-height: normal; }
.prefs-sheet > .prefs-hd > .prefs-find { height: 32px; min-height: 32px; }
.ext-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
.ext-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 12px; margin-bottom: 14px; }
.ext-bar .mkt-tabs { margin-bottom: 0; }
.ext-add { position: relative; margin-inline-start: auto; }
.ext-add-btn {
@@ -156,6 +153,7 @@
.mkt-entry .mkt-desc { margin-top: 8px; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); }
.mkt-detail-meta { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 8px; padding-bottom: 10px; color: var(--faint); font: var(--w-reg) var(--fs-meta) var(--ui); }
.mkt-detail-meta .mono { font-family: var(--mono); }
.mkt-detail-meta > span { min-width: 0; overflow-wrap: anywhere; }
.mkt-entry > .mkt-vote { gap: 10px; padding-block: 10px; border-top: 1px solid var(--hair); }
.mkt-entry .mkt-vote-bar { flex: none; width: 120px; height: 6px; border-radius: var(--r-xs); background: var(--overlay); }
.mkt-entry .mkt-vote-rate { color: var(--muted); font: var(--w-reg) var(--fs-meta) var(--ui); }
@@ -276,8 +274,8 @@
.prefs-sheet[data-solo] .prefs-find { display: none; }
.prefs-sheet[data-solo]::before, .prefs-sheet[data-solo]::after { display: none; }
.prefs-sheet[data-solo] .prefs-hd h1 { border-bottom: 1px solid var(--hair); }
.prefs-sheet[data-solo] .prefs-hd h1 { grid-column: 1; grid-row: 1; padding: 16px 22px 8px; font-size: var(--fs-title); }
.prefs-sheet[data-solo] .prefs-actions { grid-column: 2; grid-row: 1; border-bottom: 1px solid var(--hair); background: none; }
.prefs-sheet[data-solo] .prefs-hd h1 { grid-column: 1 / -1; grid-row: 1; padding: 16px 64px 12px 22px; font-size: var(--fs-title); }
.prefs-sheet[data-solo] .prefs-actions { grid-column: 2; grid-row: 1; align-self: center; margin: 0; padding: 0 14px; background: none; }
.prefs-sheet[data-solo] .prefs-main { grid-column: 1 / -1; grid-row: 2; }
.prefs-sheet[data-solo] .prefs-col { max-width: none; }
.ext-md { display: grid; grid-template-columns: minmax(300px, 400px) minmax(0, 1fr); gap: 16px; align-items: start; }
@@ -436,22 +434,48 @@
.changed-keys { display: flex; flex-direction: column; gap: 4px; margin: 4px 0 0; padding: 8px 0 2px; border-top: 1px solid var(--hair); list-style: none; }
.changed-keys li { color: var(--muted); font: var(--w-reg) var(--fs-meta) var(--mono); }
.prefs-card > .grp { grid-template-columns: minmax(0, 1fr) fit-content(320px); }
.prefs-main > :is(.prefs-pagetitle, .page-index) { max-width: 880px; margin-inline: auto; }
@container prefsmain (min-width: 860px) {
.prefs-main:has(.prefs-col[data-cols="2"]) > :is(.prefs-pagetitle, .page-index) { max-width: 1400px; }
}
.prefs-main > .prefs-pagetitle { max-width: 880px; margin-inline: auto; }
.apply[data-usual] { display: none; }
.prefs-card > .grp > .apply { display: none; }
.prefs-card > .grp > .apply:not([data-usual]) { display: block; grid-row: 3; }
.scope-tag { display: inline-grid; place-items: center; height: 18px; padding: 0 7px; border-radius: var(--r-pill); background: var(--overlay); color: var(--muted); font: var(--w-reg) var(--fs-micro)/1 var(--ui); white-space: nowrap; }
.prefs-card > .grp { padding-block: 6px; min-height: 36px; box-sizing: border-box; }
.prefs-card > .srow { padding-block: 6px; min-height: 36px; box-sizing: border-box; }
.prefs-card > .grp > .hint, .srow-desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.prefs-card > .grp, .prefs-card > .srow { padding-block: 10px; min-height: 60px; box-sizing: border-box; }
.prefs-card > .grp > .hint, .srow-desc { overflow: visible; text-overflow: clip; white-space: normal; overflow-wrap: anywhere; }
.prefs-card > .grp > .hint { margin: 0; line-height: 1.4; }
.prefs-card > .grp > .grp-hd { display: flex; align-items: center; gap: 8px; }
.prefs-pagetitle { flex-wrap: wrap; }
.prefs-pagetitle > .usual { flex-basis: 100%; margin: 0; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); }
.srow-name { display: flex; align-items: center; gap: 8px; }
.srow-meta > .apply:not([data-usual]) { display: none; }
.srow-meta > .apply:not([data-usual]) { display: block; margin-top: 2px; }
.prefs-col .srow-name .scope-tag, .prefs-col .grp-hd > .scope-tag { display: none; }
.prefs-col > .grp { margin: 0 0 14px; padding: 12px 18px; border: 1px solid var(--hair); border-radius: var(--r-lg); background: var(--raised); box-shadow: none; }
.prefs-col > .grp > .grp-hd h3 { font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); }
.prefs-col > .grp > .hint { margin-top: 6px; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
@container prefsmain (max-width: 580px) {
.prefs-card, .prefs-col > .grp { padding-inline: 16px; }
.ext-bar { position: relative; }
.ext-bar .ext-add { position: static; }
.ext-add-menu { max-width: 100%; box-sizing: border-box; }
.prefs-card > .srow { flex-wrap: wrap; gap: 10px 16px; }
.srow-meta { flex: 1 1 220px; }
.srow-ctl { margin-left: auto; }
.prefs-card > .grp { grid-template-columns: minmax(0, 1fr); }
.prefs-card > .grp > .grp-items { grid-column: 1; grid-row: auto; margin-top: 8px; }
.prefs-card > .grp[data-inline] > .grp-items { justify-self: end; max-width: 100%; }
.prefs-col .seg { max-width: 100%; flex-wrap: wrap; box-sizing: border-box; }
.prefs-col .seg button { flex: 1 1 auto; height: auto; min-height: 28px; white-space: normal; }
.msearch { flex-wrap: wrap; }
.msearch .mfetch { max-width: 100%; height: auto; min-height: 28px; padding-block: 5px; white-space: normal; }
.mline { grid-template-columns: 28px minmax(0, 1fr); }
.mline .mops { grid-column: 2; }
.rules .fallback { flex-wrap: wrap; }
.rules .fallback .k { max-width: 100%; overflow-wrap: anywhere; }
.prefs-col .lrow { flex-wrap: wrap; gap: 8px 12px; }
.prefs-col .lrow > .sc { max-width: 100%; white-space: normal; overflow-wrap: anywhere; }
.srv .srv-ab { flex-wrap: wrap; }
.srv .srv-ab .w { max-width: 100%; box-sizing: border-box; }
.usage .uranges { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); width: 100%; box-sizing: border-box; border-radius: var(--r-md); }
.usage .uranges button { min-width: 0; padding-inline: 8px; white-space: normal; }
}
.prefs-strip { position: fixed; inset: 0 0 auto; height: var(--chrome-h, 39px); z-index: 50; background: color-mix(in srgb, var(--page) 40%, transparent); -webkit-backdrop-filter: blur(14px) saturate(.92); backdrop-filter: blur(14px) saturate(.92); pointer-events: none; }
:root[data-fold~="scene"] .prefs-strip { display: none; }
.usage .ucharts-spend { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
@@ -463,8 +487,6 @@
.scoperow .scopeinfo { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 10px; margin: 8px 0 12px; padding: 8px 12px; border-radius: var(--r-md); background: var(--page); color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.6 var(--ui); }
.scoperow .scopeinfo .pt { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); }
.scoperow .scopeinfo .br { padding: 1px 7px; border-radius: var(--r-pill); background: var(--overlay); font: var(--w-reg) var(--fs-micro) var(--mono); }
.scoperow .scopeanchor { position: relative; }
.scoperow .scopeanchor .scope-pick { top: calc(100% + 4px); right: 0; left: auto; }
.scoperow .who .cv { margin-left: 6px; font-style: normal; font-size: var(--fs-micro); color: var(--faint); }
.scopeaway { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; padding: 8px 10px 8px 12px; border-radius: var(--r-md); background: color-mix(in srgb, var(--warn) 12%, transparent); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
.scopeaway > span { flex: 1; min-width: 0; }
@@ -472,11 +494,5 @@
.scopeaway .act:hover { background: var(--overlay); }
.ext-bar .ext-scope { margin-left: auto; }
.ext-bar .ext-scope + .ext-add { margin-inline-start: 0; }
.scopeanchor { position: relative; display: inline-block; }
.scopeanchor .scope-pick { top: calc(100% + 4px); right: 0; left: auto; }
.scoperow .scopeanchor .scope-pick { top: calc(100% + 4px); right: 0; left: auto; }
.page-index { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 14px; }
.page-index .pi-label { margin-right: 2px; color: var(--faint); font: var(--w-reg) var(--fs-meta) var(--ui); }
.page-index .spill { height: 26px; }
.scopeanchor { display: inline-block; }
:root:not([data-fold~="phone"]) .prefs-found { top: 0; left: 240px; right: 0; bottom: 0; width: auto; max-height: none; padding: 58px 22px 14px; border: 0; border-left: 1px solid var(--hair); border-radius: 0 var(--r-lg) var(--r-lg) 0; background: var(--surface); box-shadow: none; }
:root:not([data-fold~="phone"]) .prefs-actions { position: relative; z-index: 6; }
@@ -85,6 +85,8 @@
.phonecard .pc-route { padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--r-md); }
.phonecard .pc-route[data-on] { border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
.phonecard .pc-route + .pc-route { margin-top: 6px; }
.phone-pairing > .btn, .phone-pairing .pc-route > .btn { width: 100%; min-height: 44px; margin-top: 12px; }
.phone-pairing .pc-route > b { display: block; overflow-wrap: anywhere; }
.phonecard .pc-route .pc-note, .phonecard .pc-route .pc-none { margin: 6px 0 0; color: var(--faint); font: var(--w-med) var(--fs-micro)/1.6 var(--ui); }
.phonecard .pc-code { display: flex; align-items: center; justify-items: initial; gap: 14px; }
.phonecard .pc-code > img { flex: none; width: 112px; height: 112px; }
@@ -2,6 +2,8 @@ import type { Messages } from "./types";
export const ONBOARDING: Messages = {
"onboarding.choose_a_folder": { zh: "选择文件夹…", en: "Choose a folder…" },
"onboarding.current_workspace": { zh: "当前工作区", en: "Current workspace" },
"onboarding.phone_project_hint": { zh: "直接在当前工作区开始,或选择手机上的其他文件夹。", en: "Start in the current workspace, or choose another folder on your phone." },
"onboarding.choose_another": { zh: "换一个…", en: "Choose another…" },
"onboarding.choose_the_default_model": { zh: "选默认模型", en: "Choose the default model" },
"onboarding.common_services": { zh: "常用服务", en: "Common services" },
@@ -35,7 +37,7 @@ export const ONBOARDING: Messages = {
"onboarding.zhipu": { zh: "智谱", en: "Zhipu" },
"onboarding.hand_it_a_task": { zh: "欢迎使用 Reasonix", en: "Welcome to Reasonix" },
"onboarding.what_it_does_and_shows": { zh: "连接一个模型服务,选好默认模型,就可以在项目里开始对话。", en: "Connect a model service and pick a default model, then start a conversation in a project." },
"onboarding.about_a_minute": { zh: "大约 1 分钟 · key 只存在这台电脑上", en: "About a minute · the key stays on this computer" },
"onboarding.about_a_minute": { zh: "大约 1 分钟 · key 只保存在当前设备上", en: "About a minute · the key stays on this device" },
"onboarding.connect_one_model_service": { zh: "连接一个模型服务", en: "Connect a model service" },
"onboarding.pick_one_and_fill_the_key": { zh: "选一家,填上 key 就能用。以后可以再加别的。", en: "Pick one and fill in the key. You can add others later." },
"onboarding.deepseek_hint": { zh: "推荐 · 便宜", en: "Recommended · low cost" },
@@ -1,6 +1,12 @@
import type { Messages } from "./types";
export const REMOTE: Messages = {
"remote.scan_connect_computer": { zh: "扫码连接电脑", en: "Scan to connect a computer" },
"remote.scan_computer_hint": { zh: "在电脑的「设备访问」中显示二维码,再用手机扫描。", en: "Show a QR code in Device access on your computer, then scan it here." },
"remote.open_camera_scan": { zh: "打开相机扫码", en: "Open camera to scan" },
"remote.invalid_pairing_code": { zh: "这不是有效的 Reasonix 连接二维码,请重新扫描。", en: "This is not a valid Reasonix connection code. Please scan again." },
"remote.confirm_scanned_computer": { zh: "确认地址后,将切换到这台电脑的工作区。", en: "Confirm this address to switch to the computer’s workspace." },
"remote.connect_scanned_computer": { zh: "连接这台电脑", en: "Connect to this computer" },
"remote.a_first_connect_installs_a": { zh: "首次连接需在该机器上安装 reasonix", en: "A first connect installs a reasonix on that machine" },
"remote.a_forward_did_not_attach": { zh: "部分转发未建立", en: "A forward did not attach" },
"remote.a_path_on_that_machine": { zh: "该机器上的路径", en: "A path on that machine" },
@@ -6,6 +6,7 @@ export const SETTINGS: Messages = {
"settings.account_and_about": { zh: "账号与关于", en: "Account and About" },
"settings.account_version_logs_community": { zh: "账号、版本、日志、社区", en: "Account, version, logs, community" },
"settings.advanced": { zh: "高级", en: "Advanced" },
"settings.runtime_monitoring": { zh: "运行监测", en: "Progress monitoring" },
"settings.after_the_window_closes_the": { zh: "关闭窗口后需通过托盘图标重新打开主界面,下方选项依赖该图标。", en: "After the window closes, the tray icon is how you reopen the interface; the option below depends on it." },
"settings.agent_behaviour": { zh: "Agent 行为", en: "Agent behaviour" },
"settings.ai_file_delivery_cards": { zh: "AI 文件交付卡片", en: "AI file delivery cards" },
@@ -99,7 +100,6 @@ export const SETTINGS: Messages = {
"settings.never": { zh: "不显示", en: "Never" },
"settings.new_answer_divider": { zh: "新回答分隔线", en: "New-answer divider" },
"settings.no_external_services_connected": { zh: "尚未接入外部服务。", en: "No external services connected." },
"settings.on_this_page": { zh: "本页包含", en: "On this page" },
"settings.no_matching_setting": { zh: "没有匹配的设置", en: "No matching setting" },
"settings.no_plugin_packages_installed": { zh: "尚未安装插件包。", en: "No plugin packages installed." },
"settings.no_skills_in_this_working": { zh: "当前工作目录下没有技能。", en: "No skills in this working directory." },
@@ -115,7 +115,7 @@ export const SETTINGS: Messages = {
"settings.opening": { zh: "正在打开…", en: "Opening…" },
"settings.other_sections_and_your_session": { zh: "其他分区与当前会话不受影响;关闭设置后重新打开可重试。", en: "Other sections and your session are unaffected; close Settings and reopen to try again." },
"settings.outside_text": { zh: "外部文字", en: "Outside text" },
"settings.page_usual": { zh: "本页设置默认:本机,立即生效;例外的在名字旁标出", en: "Settings here apply on this machine at once; exceptions are marked beside the name" },
"settings.page_usual": { zh: "默认在本机立即生效,例外会单独注明", en: "Applies immediately on this machine unless noted otherwise" },
"settings.palette": { zh: "配色", en: "Palette" },
"settings.permissions_and_safety": { zh: "权限与安全", en: "Permissions and safety" },
"settings.persona": { zh: "人设", en: "Persona" },
@@ -196,9 +196,9 @@ export const SETTINGS: Messages = {
"settings.with_it_off_or_hidden": { zh: "关闭或收起时,这些页面仍可从设置和侧栏打开", en: "With it off or hidden, these pages still open from settings and the sidebar" },
"settings.workbench_full_screen": { zh: "工作台全屏", en: "Workbench full screen" },
"settings.you_choose_where": { zh: "写入位置由你选择", en: "You choose where" },
"settings.show_plans_before_running": { zh: "计划做好后要不要先给你看", en: "Whether a finished plan is shown to you before it runs" },
"settings.every_time": { zh: "每次", en: "Every time" },
"settings.not_needed": { zh: "不需要", en: "Not needed" },
"settings.show_plans_before_running": { zh: "计划完成后,等待你确认或自动开始执行", en: "When a plan is ready, wait for your confirmation or start automatically" },
"settings.wait_for_confirmation": { zh: "等待确认", en: "Wait for approval" },
"settings.start_automatically": { zh: "自动开始", en: "Start automatically" },
"settings.say_what_a_step_did": { zh: "每做完一步要不要说一句在干什么", en: "Whether to say a line about what each step did" },
"settings.per_task": { zh: "按任务", en: "Per task" },
"settings.whole_workspace": { zh: "整个工作区", en: "Whole workspace" },
+82 -10
View File
@@ -82,6 +82,9 @@ export interface HostPort {
* to open: the shell owns the dialog, the kernel owns which workspace runs,
* so the page carries one to the other. */
pickFolder(startIn: string): Promise<string | null>;
canScanPairingCode(): boolean;
scanPairingCode(): Promise<string | null>;
openInApp(url: string): void;
/** Whether this shell can show a file in the platform's file manager. */
revealsFiles(): boolean;
/** Show an entry of the workspace behind the pane at base, "" being its
@@ -203,6 +206,7 @@ interface ElectronBridge {
saveBytes(name: string, bytes: Uint8Array): Promise<string>;
showTextContextMenu?(request: TextContextMenuRequest): Promise<TextContextMenuResult>;
pickFolder(startIn: string): Promise<string>;
scanPairingCode?(): Promise<string>;
revealPath?(base: string, path: string): Promise<Refusal | null>;
openFile?(base: string, path: string): Promise<Refusal | null>;
revealWorkspace?(root: string): Promise<Refusal | null>;
@@ -296,6 +300,9 @@ class ElectronHost implements HostPort {
pickFolder(startIn: string) {
return this.api.pickFolder(startIn);
}
canScanPairingCode() { return false; }
scanPairingCode(): Promise<string | null> { return Promise.resolve(null); }
openInApp(_url: string) {}
revealsFiles() {
return typeof this.api.revealPath === "function" && typeof this.api.revealWorkspace === "function";
}
@@ -360,7 +367,7 @@ class BrowserHost implements HostPort {
inShell() {
return false;
}
describe() {
describe(): Promise<HostInfo> {
return Promise.resolve({ shell: "browser" as const, platform: "", titleBar: false });
}
minimiseWindow() {}
@@ -375,7 +382,7 @@ class BrowserHost implements HostPort {
captureWindow() {
return Promise.resolve(null);
}
syncTheme() {}
syncTheme(_mode: string, _scheme: "light" | "dark") {}
showTextContextMenu() {
return Promise.resolve(null);
}
@@ -406,9 +413,12 @@ class BrowserHost implements HostPort {
saveBytes() {
return Promise.resolve(null);
}
pickFolder() {
pickFolder(_startIn: string): Promise<string | null> {
return Promise.resolve(null);
}
canScanPairingCode() { return false; }
scanPairingCode(): Promise<string | null> { return Promise.resolve(null); }
openInApp(_url: string) {}
revealsFiles() {
return false;
}
@@ -435,28 +445,90 @@ class BrowserHost implements HostPort {
drawsBrowserViews() {
return false;
}
showBrowserView() {}
showBrowserView(_target: string, _rect: ViewRect) {}
hideBrowserView() {}
freezeBrowserView() {
return Promise.resolve("");
}
controlBrowserView() {}
navigateBrowserView() {
controlBrowserView(_target: string, _action: BrowserControl) {}
navigateBrowserView(_target: string, _address: string) {
return Promise.resolve<BrowserRefusal>("scheme");
}
onBrowserLoadState() {
onBrowserLoadState(_listener: (state: BrowserLoadState) => void) {
return () => {};
}
onBrowserLogin() {
onBrowserLogin(_listener: (ask: BrowserLogin) => void) {
return () => {};
}
answerBrowserLogin() {}
trustBrowserCertificate() {
answerBrowserLogin(_id: string, _username: string, _password: string) {}
trustBrowserCertificate(_target: string) {
return Promise.resolve(false);
}
}
interface AndroidBridge {
openExternal(url: string): void;
openInApp(url: string): void;
}
function androidApi(): Promise<ElectronBridge> {
const ready = bridge();
if (ready) return Promise.resolve(ready);
return new Promise((resolve, reject) => {
const loaded = () => {
const api = bridge();
if (!api) return;
clearTimeout(timeout);
window.removeEventListener("reasonix-host-ready", loaded);
resolve(api);
};
const timeout = setTimeout(() => {
window.removeEventListener("reasonix-host-ready", loaded);
reject(new Error("Android interface is not ready; please try again."));
}, 10000);
window.addEventListener("reasonix-host-ready", loaded);
});
}
class AndroidHost extends BrowserHost {
constructor(private readonly native: AndroidBridge) { super(); }
override inShell() { return true; }
override describe(): Promise<HostInfo> {
return Promise.resolve({ shell: "electron", platform: "android", titleBar: false });
}
override syncTheme(mode: string, scheme: "light" | "dark") {
void androidApi().then(api => api.syncTheme?.(mode, scheme)).catch(() => {});
}
override async pickFolder(startIn: string) { return (await androidApi()).pickFolder(startIn); }
override canScanPairingCode() { return true; }
override async scanPairingCode() { return (await androidApi()).scanPairingCode?.() ?? ""; }
override openInApp(url: string) { this.native.openInApp(url); }
override openExternal(url: string) { this.native.openExternal(url); }
private views() {
const api = bridge();
return api ? new ElectronHost(api) : null;
}
override drawsBrowserViews() { return this.views()?.drawsBrowserViews() ?? false; }
override showBrowserView(target: string, rect: ViewRect) { this.views()?.showBrowserView(target, rect); }
override hideBrowserView() { this.views()?.hideBrowserView(); }
override freezeBrowserView() { return this.views()?.freezeBrowserView() ?? Promise.resolve(""); }
override controlBrowserView(target: string, action: BrowserControl) { this.views()?.controlBrowserView(target, action); }
override navigateBrowserView(target: string, address: string) {
return this.views()?.navigateBrowserView(target, address) ?? Promise.resolve<BrowserRefusal>("scheme");
}
override onBrowserLoadState(listener: (state: BrowserLoadState) => void) {
return this.views()?.onBrowserLoadState(listener) ?? (() => {});
}
override onBrowserLogin(listener: (ask: BrowserLogin) => void) {
return this.views()?.onBrowserLogin(listener) ?? (() => {});
}
override answerBrowserLogin(id: string, username: string, password: string) { this.views()?.answerBrowserLogin(id, username, password); }
override trustBrowserCertificate(target: string) { return this.views()?.trustBrowserCertificate(target) ?? Promise.resolve(false); }
}
function pick(): HostPort {
const native = (window as unknown as { ReasonixNative?: AndroidBridge }).ReasonixNative;
if (native) return new AndroidHost(native);
const api = bridge();
if (api) return new ElectronHost(api);
return new BrowserHost();
@@ -0,0 +1,74 @@
import { afterEach, expect, it, vi } from "vitest";
afterEach(() => { vi.unstubAllGlobals(); vi.resetModules(); });
it("recognizes Android at startup and waits for its asynchronous picker bridge", async () => {
const pickFolder = vi.fn(async () => "/storage/emulated/0/Documents/Reasonix");
const openExternal = vi.fn();
const blockingPicker = vi.fn(() => { throw new Error("synchronous picker must not run"); });
const win = Object.assign(new EventTarget(), { ReasonixNative: { pickFolder: blockingPicker, openExternal }, reasonixHost: undefined as unknown });
vi.stubGlobal("window", win);
const { host } = await import("./host");
expect(await host().describe()).toEqual({ shell: "electron", platform: "android", titleBar: false });
const selection = host().pickFolder("/storage/emulated/0/Documents");
win.reasonixHost = { pickFolder };
win.dispatchEvent(new Event("reasonix-host-ready"));
expect(await selection).toBe("/storage/emulated/0/Documents/Reasonix");
expect(blockingPicker).not.toHaveBeenCalled();
expect(pickFolder).toHaveBeenCalledWith("/storage/emulated/0/Documents");
await host().openExternal("https://example.com");
expect(openExternal).toHaveBeenCalledWith("https://example.com");
});
it("keeps Android picker cancellation distinct from an unsupported browser picker", async () => {
vi.stubGlobal("window", { ReasonixNative: { openExternal: vi.fn() }, reasonixHost: { pickFolder: async () => "" } });
const { host } = await import("./host");
expect(await host().pickFolder("")).toBe("");
expect(host().inShell()).toBe(true);
});
it("synchronizes Android system bars with the page theme after the bridge is ready", async () => {
const syncTheme = vi.fn();
const win = Object.assign(new EventTarget(), { ReasonixNative: {}, reasonixHost: undefined as unknown });
vi.stubGlobal("window", win);
const { host } = await import("./host");
host().syncTheme("dark", "dark");
win.reasonixHost = { syncTheme };
win.dispatchEvent(new Event("reasonix-host-ready"));
await vi.waitFor(() => expect(syncTheme).toHaveBeenCalledWith("dark", "dark"));
host().syncTheme("light", "light");
await vi.waitFor(() => expect(syncTheme).toHaveBeenCalledWith("light", "light"));
});
it("returns a cancelled scan without navigating and can scan again", async () => {
const openInApp = vi.fn();
const scanPairingCode = vi.fn().mockResolvedValueOnce("").mockResolvedValueOnce("http://192.168.1.2:4321/#pair=test");
vi.stubGlobal("window", { ReasonixNative: { openInApp }, reasonixHost: { scanPairingCode } });
const { host } = await import("./host");
expect(host().canScanPairingCode()).toBe(true);
expect(await host().scanPairingCode()).toBe("");
expect(openInApp).not.toHaveBeenCalled();
expect(await host().scanPairingCode()).toBe("http://192.168.1.2:4321/#pair=test");
host().openInApp("http://192.168.1.2:4321/#pair=test");
expect(openInApp).toHaveBeenCalledOnce();
});
it("uses browser views attached after Android startup without exposing desktop window controls", async () => {
const win: { ReasonixNative: unknown; reasonixHost?: unknown } = { ReasonixNative: { pickFolder: () => "", openExternal: vi.fn() } };
vi.stubGlobal("window", win);
const { host } = await import("./host");
const shell = host();
expect(shell.drawsBrowserViews()).toBe(false);
const showBrowserView = vi.fn();
const navigateBrowserView = vi.fn(async () => "");
win.reasonixHost = { showBrowserView, navigateBrowserView };
expect(shell.drawsBrowserViews()).toBe(true);
const rect = { x: 0, y: 0, width: 320, height: 480 };
shell.showBrowserView("page", rect);
expect(showBrowserView).toHaveBeenCalledWith("page", rect);
expect(await shell.navigateBrowserView("page", "https://example.com")).toBe("");
expect(navigateBrowserView).toHaveBeenCalledWith("page", "https://example.com");
expect(await shell.isWindowMaximised()).toBe(false);
expect(await shell.graphics()).toBeNull();
expect(() => shell.minimiseWindow()).not.toThrow();
});
@@ -17,6 +17,13 @@ async function portOver(win: Record<string, unknown>, current = "/w/current") {
afterEach(() => vi.unstubAllGlobals());
describe("asking for a folder", () => {
it.each(["/storage/emulated/0/Documents/project", ""])("carries Android's native answer %j through the runtime port", async (answer) => {
const pickFolder = vi.fn(async () => answer);
const port = await portOver({ ReasonixNative: {}, reasonixHost: { pickFolder } }, "/data/user/0/app/files/workspace");
expect(await port.pickFolder()).toBe(answer);
expect(pickFolder).toHaveBeenCalledWith("/data/user/0/app/files/workspace");
});
it("opens the Electron panel on the workspace the kernel reports", async () => {
const opened: string[] = [];
const port = await portOver({
@@ -13,6 +13,32 @@ gate or a component enforces it, that is named.
`TOKENS.md` holds the vocabulary — colour, type, motion and space — that these
rules are written in.
## Settings pages
- Primary groups stay expanded in one column, with frequent choices first.
Section headings provide the overview without a second navigation row.
Page titles describe their purpose without repeating the sidebar count.
Search reaches individual controls without remounting the page.
- Rows keep descriptions readable while using compact vertical spacing.
Collaboration, automation and monitoring never require opening a disclosure.
- Descriptions wrap; controls move below them in narrow columns. Exceptional
scope and timing come from the catalogue's ApplyNote below each description.
Cards, typography and controls use the existing theme tokens.
- Management editors retain their own navigation and forms. Narrow pages wrap
controls and extension toolbars; the category strip alone scrolls sideways.
Entering or leaving an editor starts at the top with one return breadcrumb.
Usage tables retain their labelled horizontal scroller for exact figures.
- Close controls share the header's grid row without positional offsets.
Standalone windows extend the title background across the full row and
centre the close button above the scrolling content.
- The sheet clips overflow without becoming a second scroll container. Focus
movement and keyboard navigation scroll the content, leaving the header fixed.
- Project scope pickers use the shared viewport placement and dismissal hooks.
Their portalled list stays within the viewport when the page scrolls or resizes.
Marketplace source addresses wrap within their detail card.
- Confirmation and import backdrops use shrinkable grid tracks in both axes.
Dialogs fit the available width and height, and scroll internally on short screens.
## Session row spacing
Session rows reserve an 8px inline-end inset inside their highlight. The shared
@@ -1756,3 +1782,26 @@ A sub-agent row, by the list's own width:
- Appearance → Conversation display offers “Show collapsed plan progress,” off by default and persisted on this machine. Changing it immediately updates mounted plan cards.
- A nonempty collapsed plan draws the expanded plan's `.prog2` track below its summary, using completed steps over total steps from `stepDone`. Pending and running steps do not count as complete.
- Opening the card replaces the optional track with the existing expanded progress. Goal-only, empty and hidden cards have no progress track; collapse, Escape, outside dismissal and focus return remain unchanged.
## Mobile setup
Onboarding uses a zero-minimum grid track so the step labels and key help cannot
widen the card beyond its viewport.
Below 600px, the current step keeps its label,
services form two equal columns, and actions form a full-width vertical group.
The page owns vertical scrolling, including while the keyboard shortens the
viewport. Long model identifiers and connection errors wrap within the card.
Touch input offers the active runtime's workspace as the initial project choice.
The folder picker replaces desktop drag instructions, and long paths wrap in the
selected-folder card. Cancelling the picker keeps that choice available.
Android device access opens a scan-and-connect card. Its actions retain 44px
touch targets, and a scanned computer's origin wraps before the person confirms
the connection. The pairing credential is never rendered in the card.
Android applies system bars, display cutouts and keyboard insets at the native
container. The WebView and browser overlay receive the remaining viewport, with
no extra page padding. System-bar icon contrast follows the page's resolved
light/dark theme through the host port.
+7 -8
View File
@@ -2103,7 +2103,8 @@
}
.prefs-sheet {
width: min(1120px, 100%); height: min(800px, 100%);
display: flex; flex-direction: column; overflow: hidden;
min-width: 0; min-height: 0;
display: flex; flex-direction: column; overflow: clip;
border: 1px solid var(--border); border-radius: var(--r-lg);
background: var(--float); box-shadow: var(--shadow-float);
animation: m-panel var(--t-panel) var(--ease-spring) both;
@@ -2584,8 +2585,8 @@
.scopebar .away { flex-basis: 100%; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--accent-ink) }
.scope-pick {
position: absolute; z-index: 3; top: calc(100% + 4px); left: 8px; min-width: 320px; max-width: 460px;
max-height: 340px; overflow-y: auto; padding: 6px;
position: fixed; z-index: 60; width: min(320px, calc(100vw / var(--zoom, 1) - 24px));
max-height: min(340px, calc(100dvh / var(--zoom, 1) - 24px)); overflow-y: auto; padding: 6px;
display: flex; flex-direction: column; gap: 1px;
border: 1px solid var(--border); border-radius: var(--r-md);
background: var(--float); box-shadow: var(--shadow-float);
@@ -2610,14 +2611,14 @@
}
.scope-pick .pk:hover { background: var(--float-hi) }
.scope-pick .pk[aria-selected="true"] { border-color: color-mix(in srgb, var(--accent) 45%, transparent) }
.scope-pick .pk .nm { display: flex; align-items: baseline; gap: 6px; font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--text) }
.scope-pick .pk .nm { grid-column: 1; grid-row: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 6px; overflow-wrap: anywhere; font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--text) }
.scope-pick .pk .cur { font: var(--w-reg) var(--fs-micro) var(--ui); font-style: normal; color: var(--faint) }
.scope-pick .pk .pt {
grid-column: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
grid-column: 1; grid-row: 2; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--faint);
}
.scope-pick .pk .ct {
grid-row: 1 / span 2; align-self: center; font-style: normal; white-space: nowrap;
grid-column: 2; grid-row: 1 / span 2; align-self: center; font-style: normal; white-space: nowrap;
padding: 1px 7px; border-radius: var(--r-pill);
font: var(--w-reg) var(--fs-micro) var(--ui); color: var(--accent-ink);
background: color-mix(in srgb, var(--accent) 12%, transparent);
@@ -5180,9 +5181,7 @@
}
}
.prefs-pagetitle { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; margin: 0 auto 14px; max-width: 880px; }
.prefs-pagetitle .count { color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); }
.prefs-pagetitle .desc { display: inline-flex; gap: 0; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
.prefs-pagetitle .desc .what + .count::before { content: "·"; margin: 0 6px; }
.prefs-pagetitle .group { color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); }
.prefs-pagetitle .group::after { content: "›"; margin-left: 8px; }
.prefs-pagetitle h2 { margin: 0; color: var(--text); font: var(--w-semi) var(--fs-title)/1.3 var(--ui); }
@@ -88,7 +88,7 @@
.preset-page-footer .btn { min-width: 84px; }
.composition-extra { border-top: 1px solid var(--hair); margin-top: 24px; padding-top: 16px; }
.composition-extra summary { cursor: pointer; color: var(--muted); }
.preset-confirm-veil { position: fixed; inset: 0; display: grid; place-items: center; padding: 20px; background: color-mix(in oklab, var(--page) 80%, transparent); z-index: 1100; }
.preset-confirm-veil { position: fixed; inset: 0; 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); z-index: 1100; }
.preset-confirm { width: min(100%, 420px); padding: 24px; border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--surface); box-shadow: var(--lit); }
.preset-confirm h3 { margin: 0 0 12px; font-size: var(--fs-title); }
.preset-confirm p { color: var(--muted); margin: 0 0 24px; line-height: 1.6; }
@@ -9,23 +9,30 @@ import { SettingDeepContext } from "./SettingDeep";
afterEach(cleanup);
function draw() {
function draw(port = new MockPort()) {
const go = vi.fn();
render(
<SettingDeepContext.Provider value={{ pages: true, open: null, section: "Agent 行为", go }}>
<AgentSettings port={new MockPort() as unknown as AgentPort} hub={{ tree: async () => [] }} persona={false} onChanged={() => {}} />
<AgentSettings port={port as unknown as AgentPort} hub={{ tree: async () => [] }} persona={false} onChanged={() => {}} />
</SettingDeepContext.Provider>,
);
return go;
}
it("is four cards of single rows, plan mode and the display switches living elsewhere", async () => {
it("shows every group directly without an extra navigation row", async () => {
draw();
expect([...document.querySelectorAll(".prefs-card > header h3")].map((h) => h.textContent)).toEqual(["人设与预设", "计划与提问", "协作", "自动化"]);
const names = ["计划与提问", "人设与预设", "协作", "自动化", "运行监测"];
expect([...document.querySelectorAll(".prefs-card > header h3")].map((h) => h.textContent)).toEqual(names);
expect(screen.queryByRole("navigation", { name: "本页包含" })).toBeNull();
expect(screen.queryByText("更多行为设置")).toBeNull();
for (const id of ["persona", "agent-presets", "plan-approval", "ask-timeout", "turn-narration", "progress-watch", "session-ref", "subagent-claims", "peers", "hooks", "scheduled-runs"]) {
await waitFor(() => expect(document.querySelector(`[data-setting="${id}"]`), id).toBeTruthy());
}
for (const id of ["plan-mode", "changed-files", "fold-work", "feedback-level-notices"]) expect(document.querySelector(`[data-setting="${id}"]`), id).toBeNull();
for (const id of ["progress-watch", "session-ref", "subagent-claims", "peers", "hooks", "scheduled-runs"]) {
expect(document.querySelector(`[data-setting="${id}"]`)?.closest("details")).toBeNull();
}
expect(document.querySelector('#set-persona .scope-tag')?.textContent).toBe("下次会话构建时生效");
});
it("opens the presets page from both the persona row and the presets row", () => {
+14 -17
View File
@@ -6,7 +6,6 @@ import { AgentPresets } from "./AgentPresets";
import { Hooks } from "./Hooks";
import { Group } from "./Group";
import { ScheduledRuns } from "./ScheduledRuns";
import { ApplyNote } from "./Group";
import { AskTimeout } from "./AskTimeout";
import { PeersSection } from "./PeersSection";
import { PlanApproval } from "./PlanApproval";
@@ -14,6 +13,7 @@ import { ProgressWatch } from "./ProgressWatch";
import { SectionCard } from "./SectionCard";
import { SessionRef } from "./SessionRef";
import { SettingDeep, SettingDeepContext } from "./SettingDeep";
import { PillButton, SettingRow } from "./SettingRow";
import { SubagentClaims } from "./SubagentClaims";
import { TurnNarrationSetting } from "./TurnNarrationSetting";
@@ -46,26 +46,20 @@ export function AgentSettings({ port, hub, persona, onChanged }: Props) {
: "");
return (
<>
<SectionCard title={t("settings.persona_and_presets")}>
<div className="srow" data-setting="persona">
<div className="srow-meta">
<span className="srow-name">{t("persona.system_persona")}</span>
<span className="srow-desc">{t("settings.persona_hint")}</span>
<ApplyNote id="persona" />
</div>
<div className="srow-ctl">
<button type="button" className="spill" data-action="settings.open-page" data-target="agent-presets" onClick={() => deep.go("agent-presets")}>{t("settings.edit_persona")}</button>
</div>
</div>
<SettingDeep id="agent-presets" title={t("persona.agent_presets")} hint={t("settings.presets_hint")} act={t("settings.manage_presets")}>
<AgentPresets port={port} focusPersona={persona} />
</SettingDeep>
</SectionCard>
<SectionCard title={t("settings.plans_and_questions")}>
<PlanApproval port={port} />
<AskTimeout port={port} />
<TurnNarrationSetting port={port} />
<ProgressWatch port={port} />
</SectionCard>
<SectionCard title={t("settings.persona_and_presets")}>
{deep.open !== "agent-presets" && (
<SettingRow id="persona" title={t("persona.system_persona")} desc={t("settings.persona_hint")}>
<PillButton action="settings.open-page" target="agent-presets" onClick={() => deep.go("agent-presets")}>{t("settings.edit_persona")}</PillButton>
</SettingRow>
)}
<SettingDeep id="agent-presets" title={t("persona.agent_presets")} hint={t("settings.presets_hint")} act={t("settings.manage_presets")}>
<AgentPresets port={port} focusPersona={persona} />
</SettingDeep>
</SectionCard>
<SectionCard title={t("settings.collaboration")}>
<SessionRef port={port} />
@@ -78,6 +72,9 @@ export function AgentSettings({ port, hub, persona, onChanged }: Props) {
</Group>
<ScheduledRuns port={port} hint={runsHint} pill={t("settings.manage_storage")} />
</SectionCard>
<SectionCard title={t("settings.runtime_monitoring")}>
<ProgressWatch port={port} />
</SectionCard>
</>
);
}
+1 -1
View File
@@ -528,10 +528,10 @@ export function App({ hub }: { hub: HubPort }) {
);
const branchSession = useBranchSession({ panePorts, runtimes, runsRef, openPane });
const first = useFirstRun({
port: activePort, setup, welcomed, setSetup, setWelcomed, reloadPanes,
project: {
current: runtimes.find((runtime) => runtime.id === active)?.root,
pick: async () => (await hub.pickFolder()) || null,
add: async (dir) => { const workspace = await hub.addWorkspace(dir); await reloadTree(); await openPane({ root: workspace.root }); },
},
@@ -1,6 +1,9 @@
import { useState } from "react";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { t } from "../i18n";
import type { CapabilityScope } from "../port/port";
import { useDismiss } from "./dismiss";
import { beside, pinToViewport, useFollow, zoom } from "./place";
// A switch flipped here answers for this project, not for every project. That
// is the useful default and also the invisible one, so the row that carries an
@@ -23,21 +26,39 @@ export function ScopePill({
scope: CapabilityScope; scopes: CapabilityScope[]; onPick: (root: string) => void;
}) {
const [open, setOpen] = useState(false);
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
const close = useCallback(() => { setOpen(false); trigger.current?.focus({ preventScroll: true }); }, []);
const place = useCallback(() => {
if (!panel.current || !trigger.current) return;
const rect = trigger.current.getBoundingClientRect();
const width = panel.current.offsetWidth * zoom();
pinToViewport(panel.current, rect.right - width, beside(rect, panel.current.offsetHeight * zoom(), innerHeight, "below", 4), 12);
}, []);
useFollow(open, place, scopes, { anchor: trigger, container: panel });
useDismiss(open, panel, close, trigger);
useEffect(() => {
if (!open || !panel.current) return;
const observer = new ResizeObserver(place);
observer.observe(panel.current);
return () => observer.disconnect();
}, [open, place]);
return (
<span className="scopeanchor">
<button type="button" className="spill who" data-action="ext.scope" aria-expanded={open} onClick={() => setOpen(!open)}>
<button ref={trigger} type="button" className="spill who" data-action="ext.scope" aria-haspopup="listbox" aria-expanded={open} onClick={() => setOpen(!open)}>
{scope.label || scope.name}
<i className="cv" aria-hidden>▾</i>
</button>
{open && (
{open && createPortal(
<ScopePicker
panel={panel}
scopes={scopes}
at={scope.root}
onPick={(root) => {
setOpen(false);
close();
onPick(root);
}}
/>
/>, document.body,
)}
</span>
);
@@ -96,9 +117,9 @@ export function ScopeAway({ scope, onBack }: { scope: CapabilityScope; onBack: (
// worth finding are the ones already carrying an exception — so that is the
// filter, and search only appears when the list is long enough to need it.
function ScopePicker({
scopes, at, onPick,
panel, scopes, at, onPick,
}: {
scopes: CapabilityScope[]; at: string; onPick: (root: string) => void;
panel: RefObject<HTMLDivElement | null>; scopes: CapabilityScope[]; at: string; onPick: (root: string) => void;
}) {
const [q, setQ] = useState("");
const [onlyChanged, setOnlyChanged] = useState(false);
@@ -109,7 +130,7 @@ function ScopePicker({
return !needle || (sc.label || sc.name).toLowerCase().includes(needle) || sc.root.toLowerCase().includes(needle);
});
return (
<div className="scope-pick" role="listbox">
<div ref={panel} className="scope-pick" role="listbox" aria-label={t("ext.manage_which_project")}>
{searchable && (
<input
className="q"
@@ -19,6 +19,24 @@ function draw(onSkip?: () => void) {
}
describe("first-run setup", () => {
it("keeps service selection visible without opening the touch keyboard", async () => {
const original = window.matchMedia;
window.matchMedia = ((query: string) => ({ matches: query === "(pointer: coarse)" })) as typeof window.matchMedia;
try {
const { user } = draw();
await user.click(screen.getByRole("button", { name: "开始设置" }));
expect(document.activeElement).not.toBe(screen.getByPlaceholderText("sk-…"));
const other = screen.getByRole("radio", { name: /MiMo/ });
await user.click(other);
expect(document.activeElement).toBe(other);
await user.click(screen.getByPlaceholderText("sk-…"));
await user.keyboard("sk-touch");
expect((screen.getByPlaceholderText("sk-…") as HTMLInputElement).value).toBe("sk-touch");
} finally {
window.matchMedia = original;
}
});
it("walks from the welcome through a listed service to a default model, without asking how it is spoken to", async () => {
const { save, onDone, user } = draw();
expect(screen.getByRole("heading", { name: "欢迎使用 Reasonix" })).toBeTruthy();
+1 -1
View File
@@ -113,7 +113,7 @@ export function Onboarding({ port, setup, onDone, onSkip, project }: Props) {
useEffect(() => {
void port.protocols().then(setCatalog).catch(() => setCatalog([]));
}, [port]);
useEffect(() => { if (step === "connect") first.current?.focus(); }, [step, pick]);
useEffect(() => { if (step === "connect" && !matchMedia?.("(pointer: coarse)").matches) first.current?.focus(); }, [step, pick]);
// Where this vendor hands out keys, and — for the refusals that are settled
// there rather than here — where this particular failure sends the reader.
@@ -0,0 +1,46 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { OnboardingProject } from "./OnboardingProject";
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
function phone(pick = vi.fn(async (): Promise<string | null> => null)) {
vi.spyOn(window, "matchMedia").mockImplementation((query) => ({ matches: query === "(pointer: coarse)" }) as MediaQueryList);
const project = { current: "/data/user/0/reasonix/files/workspace", pick, add: vi.fn(async (_dir: string) => {}) };
const onDone = vi.fn();
render(<OnboardingProject project={project} onDone={onDone} onSkip={vi.fn()} />);
return { project, onDone, user: userEvent.setup() };
}
it("lets a phone start in the current workspace without adding it again", async () => {
const { user, project, onDone } = phone();
expect(screen.queryByText("把文件夹拖到这里")).toBeNull();
await user.click(screen.getByRole("button", { name: "开始" }));
await waitFor(() => expect(onDone).toHaveBeenCalledOnce());
expect(project.add).not.toHaveBeenCalled();
});
it("keeps the current workspace after cancelling the phone picker", async () => {
const { user, project, onDone } = phone();
await user.click(screen.getByRole("button", { name: "换一个…" }));
expect(project.pick).toHaveBeenCalledOnce();
expect(screen.queryByRole("alert")).toBeNull();
await user.click(screen.getByRole("button", { name: "开始" }));
await waitFor(() => expect(onDone).toHaveBeenCalledOnce());
});
it("opens the selected phone folder and lets a failed open be retried", async () => {
const { user, project, onDone } = phone(vi.fn(async () => "/storage/emulated/0/Documents/demo"));
project.add.mockRejectedValueOnce(new Error("Cannot open this folder"));
await user.click(screen.getByRole("button", { name: "换一个…" }));
await screen.findByText("demo");
await user.click(screen.getByRole("button", { name: "开始" }));
await screen.findByRole("alert");
expect(onDone).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "开始" }));
await waitFor(() => expect(onDone).toHaveBeenCalledOnce());
expect(project.add).toHaveBeenLastCalledWith("/storage/emulated/0/Documents/demo");
});
@@ -5,17 +5,17 @@ import { StudioIcon } from "./StudioIcon";
import { useFileDrop } from "./filedrop";
export interface ProjectChoice {
current?: string;
pick: () => Promise<string | null>;
add: (path: string) => Promise<void>;
}
const nameOf = (path: string) => path.replace(/[/\\]+$/, "").split(/[/\\]/).pop() || path;
// The last step: which folder the work happens in. A folder can be dropped on
// the box or picked; once chosen it sits in the box and "Start" lights up.
export function OnboardingProject({ project, onDone, onSkip }: { project: ProjectChoice; onDone: () => void; onSkip: () => void }) {
const touch = window.matchMedia("(pointer: coarse)").matches;
const [over, setOver] = useState(false);
const [dir, setDir] = useState("");
const [dir, setDir] = useState(touch ? project.current ?? "" : "");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const drop = useFileDrop((d) => { if (!busy && d.paths[0]) { setDir(d.paths[0]); setErr(""); } }, setOver);
@@ -32,7 +32,7 @@ export function OnboardingProject({ project, onDone, onSkip }: { project: Projec
if (!dir || busy) return;
setBusy(true);
try {
await project.add(dir);
if (dir !== project.current) await project.add(dir);
onDone();
} catch (e) {
setErr(reason(e));
@@ -42,18 +42,18 @@ export function OnboardingProject({ project, onDone, onSkip }: { project: Projec
return (
<>
<h1 className="onb-t">{t("onboarding.which_project")}</h1>
<p className="onb-s">{t("onboarding.drop_to_choose")}</p>
<div className="onb-drop" ref={drop} data-over={over ? "" : undefined} data-chosen={dir ? "" : undefined}>
<p className="onb-s">{t(touch ? "onboarding.phone_project_hint" : "onboarding.drop_to_choose")}</p>
<div className="onb-drop" ref={drop} data-touch={touch ? "" : undefined} data-over={over ? "" : undefined} data-chosen={dir ? "" : undefined}>
{dir ? (
<div className="onb-chosen">
<StudioIcon name="folder" />
<b>{nameOf(dir)}</b>
<b>{touch && dir === project.current ? t("onboarding.current_workspace") : nameOf(dir)}</b>
<small title={dir}>{dir}</small>
</div>
) : (
<>
<StudioIcon name="folder" />
<span>{t("onboarding.drop_a_folder_here")}</span>
<span>{t(touch ? "onboarding.choose_a_folder" : "onboarding.drop_a_folder_here")}</span>
</>
)}
<button type="button" className="onb-skip" data-action="onboarding.pick-folder" disabled={busy} onClick={() => void pick()}>
@@ -1,50 +0,0 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { PageIndex } from "./PageIndex";
import { NAV, SETTINGS, pageHome } from "./prefsnav";
import { pageIndexOf } from "./settingPageIndex";
afterEach(cleanup);
const entries = (section: string) => SETTINGS.filter((e) => !e.hidden && !e.within && pageHome(e.section) === section);
describe("the index at the top of a settings page", () => {
it("names every setting the page holds, one button each, so folded-in ones have a visible way in", () => {
const items = entries("session");
render(<PageIndex items={items} onGo={() => {}} />);
expect(screen.getByRole("navigation", { name: "本页包含" })).toBeTruthy();
expect(screen.getAllByRole("button")).toHaveLength(items.length);
for (const e of items) expect(document.querySelector(`[data-target="${e.anchor}"]`), e.anchor).toBeTruthy();
});
it("goes to the setting that was pressed, with the entry it was drawn from", async () => {
const go = vi.fn();
const items = entries("session");
render(<PageIndex items={items} onGo={go} />);
const peers = items.find((e) => e.anchor === "peers")!;
await userEvent.click(document.querySelector(`[data-target="peers"]`) as HTMLElement);
expect(go).toHaveBeenCalledWith(peers);
});
it("says nothing on a page too small to need it", () => {
const { container } = render(<PageIndex items={entries("session").slice(0, 2)} onGo={() => {}} />);
expect(container.innerHTML).toBe("");
});
});
describe("which settings a page's index lists", () => {
it("is every visible setting of the page that is not drawn inside another one", () => {
const shown = pageIndexOf(SETTINGS, "session");
expect(shown.map((e) => e.anchor)).toEqual(entries("session").map((e) => e.anchor));
});
it("leaves no setting off every page: each is listed, or sits inside one that is", () => {
const homes = new Set(NAV.flatMap(([, items]) => items.map(([id]) => id as string)));
const listed = new Set(SETTINGS.filter((e) => !e.hidden && !e.within && homes.has(pageHome(e.section))).map((e) => e.anchor));
const lost = SETTINGS.filter((e) => !e.hidden && !listed.has(e.anchor) && !(e.within && listed.has(e.within))).map((e) => e.anchor);
expect(lost).toEqual([]);
});
});
@@ -1,19 +0,0 @@
import { t } from "../i18n";
import type { SettingEntry } from "./prefsnav";
/** What a page holds, as a row of buttons under its title. Several pages carry
* others folded into them, and a setting that lives there has no other sign
* of it until someone scrolls past it. */
export function PageIndex({ items, onGo }: { items: readonly SettingEntry[]; onGo: (entry: SettingEntry) => void }) {
if (items.length < 3) return null;
return (
<nav className="page-index" aria-label={t("settings.on_this_page")}>
<span className="pi-label" aria-hidden="true">{t("settings.on_this_page")}</span>
{items.map((e) => (
<button key={e.anchor} type="button" className="spill" data-action="settings.page-index" data-target={e.anchor} onClick={() => onGo(e)}>
{t(e.title)}
</button>
))}
</nav>
);
}
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { MockHub } from "../port/mock_hub";
import { PhonePop } from "./PhonePop";
const api = vi.hoisted(() => ({ canScanPairingCode: () => true, scanPairingCode: vi.fn(), openInApp: vi.fn() }));
vi.mock("../port/host", () => ({ host: () => api }));
afterEach(cleanup);
beforeEach(() => { api.scanPairingCode.mockReset(); api.openInApp.mockReset(); });
it("returns from a cancelled camera to a usable connection card and can scan again", async () => {
let finish: (value: string) => void = () => {};
api.scanPairingCode.mockImplementationOnce(() => new Promise<string>((resolve) => { finish = resolve; })).mockResolvedValueOnce("");
render(<PhonePop hub={new MockHub()} />);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "扫码连接电脑" }));
const scan = screen.getByRole("button", { name: "打开相机扫码" });
await user.click(scan);
expect((scan as HTMLButtonElement).disabled).toBe(true);
finish("");
await waitFor(() => expect((scan as HTMLButtonElement).disabled).toBe(false));
expect(api.openInApp).not.toHaveBeenCalled();
expect(screen.queryByRole("alert")).toBeNull();
await user.click(scan);
await waitFor(() => expect(api.scanPairingCode).toHaveBeenCalledTimes(2));
});
it("shows the scanned computer before navigating and keeps an invalid code on the same page", async () => {
api.scanPairingCode.mockResolvedValueOnce("javascript:alert(1)").mockResolvedValueOnce("http://192.168.1.2:4321/#pair=test");
render(<PhonePop hub={new MockHub()} />);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "扫码连接电脑" }));
await user.click(screen.getByRole("button", { name: "打开相机扫码" }));
await screen.findByRole("alert");
expect(api.openInApp).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "打开相机扫码" }));
await screen.findByText("http://192.168.1.2:4321");
expect(screen.queryByRole("alert")).toBeNull();
expect(api.openInApp).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "连接这台电脑" }));
expect(api.openInApp).toHaveBeenCalledWith("http://192.168.1.2:4321/#pair=test");
});
@@ -0,0 +1,51 @@
import { useCallback, useRef, useState } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import { host } from "../port/host";
import { useDismiss } from "./dismiss";
import { StudioIcon } from "./StudioIcon";
import { TooltipButton } from "./TooltipButton";
export function PhonePairing() {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [target, setTarget] = useState<URL | null>(null);
const [error, setError] = useState("");
const box = useRef<HTMLDivElement>(null);
useDismiss(open, box, useCallback(() => setOpen(false), []));
const scan = async () => {
setBusy(true);
setError("");
setTarget(null);
try {
const code = await host().scanPairingCode();
if (!code) return;
const url = new URL(code);
const local = new URLSearchParams(url.hash.slice(1)).has("pair");
const cloud = url.origin === "https://reasonix.io" && url.pathname === "/remote/" && url.searchParams.has("device");
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || !(local || cloud)) {
setError(t("remote.invalid_pairing_code"));
return;
}
setTarget(url);
} catch (e) {
setError(e instanceof TypeError ? t("remote.invalid_pairing_code") : reason(e));
} finally {
setBusy(false);
}
};
return <div className="phonepop" ref={box}>
<TooltipButton className="thbtn phone-action" data-action="device.scan-card" aria-expanded={open}
aria-label={t("remote.scan_connect_computer")} title={t("remote.scan_connect_computer")} onClick={() => setOpen(v => !v)}>
<StudioIcon name="link" />
</TooltipButton>
{open && <div className="phonecard phone-pairing" role="dialog" aria-label={t("remote.scan_connect_computer")}>
<header><span><b>{t("remote.scan_connect_computer")}</b><small>{t("remote.scan_computer_hint")}</small></span></header>
{error && <p className="pc-err" role="alert">{error}</p>}
{target && <section className="pc-route"><b>{target.origin}</b><p className="pc-note">{t("remote.confirm_scanned_computer")}</p>
<button className="btn" data-primary data-action="device.connect-scanned" onClick={() => host().openInApp(target.href)}>{t("remote.connect_scanned_computer")}</button>
</section>}
<button className="btn" data-action="device.scan" disabled={busy} onClick={() => void scan()}>{t("remote.open_camera_scan")}</button>
</div>}
</div>;
}
+7 -1
View File
@@ -8,11 +8,17 @@ import { useDismiss } from "./dismiss";
import { clock, deviceLabel, type Share, useShare } from "./PhoneAccess";
import { Switch } from "./Switch";
import { TooltipButton } from "./TooltipButton";
import { host } from "../port/host";
import { PhonePairing } from "./PhonePairing";
export function PhonePop({ hub }: { hub: HubPort }) {
return host().canScanPairingCode() ? <PhonePairing /> : <SharePop hub={hub} />;
}
/** The chrome's way to put a phone on this window: a code on a card, one
* press from anywhere. Drawn only where the kernel has a window to share —
* a browser tab or a paired phone gets nothing here. */
export function PhonePop({ hub }: { hub: HubPort }) {
function SharePop({ hub }: { hub: HubPort }) {
const [open, setOpen] = useState(false);
const box = useRef<HTMLDivElement>(null);
const close = useCallback(() => setOpen(false), []);
@@ -9,23 +9,27 @@ import type { AgentPort } from "../port/port";
afterEach(cleanup);
const every = () => screen.findByRole("radio", { name: "每次" });
const never = () => screen.findByRole("radio", { name: "不需要" });
const every = () => screen.findByRole("radio", { name: "等待确认" });
const never = () => screen.findByRole("radio", { name: "自动开始" });
describe("the plan-approval switch", () => {
it("starts off, says what it releases and what it leaves alone, and saves the opposite of what it shows", async () => {
it("describes both choices and saves only a different selection", async () => {
const p = new MockPort() as unknown as AgentPort;
const save = vi.spyOn(p, "savePlanApproval");
render(<PlanApproval port={p} />);
const ask = await every();
expect(ask.getAttribute("aria-checked")).toBe("true");
const why = screen.getByText("计划做好后要不要先给你看").getAttribute("title") ?? "";
const why = screen.getByText("计划完成后,等待你确认或自动开始执行").getAttribute("title") ?? "";
expect(why).toMatch(/工具批准、沙箱和拒绝规则照常生效/);
expect(why).toMatch(/项目配置不能替你打开/);
await userEvent.click(ask);
expect(save).not.toHaveBeenCalled();
await userEvent.click(await never());
expect(save).toHaveBeenCalledWith(true);
await waitFor(() => expect((screen.getByRole("radio", { name: "不需要" })).getAttribute("aria-checked")).toBe("true"));
await userEvent.click(screen.getByRole("radio", { name: "每次" }));
await waitFor(() => expect((screen.getByRole("radio", { name: "自动开始" })).getAttribute("aria-checked")).toBe("true"));
await userEvent.click(await never());
expect(save).toHaveBeenCalledTimes(1);
await userEvent.click(screen.getByRole("radio", { name: "等待确认" }));
expect(save).toHaveBeenLastCalledWith(false);
});
@@ -22,11 +22,12 @@ export function PlanApproval({ port }: { port: AgentPort }) {
if (!state) return <div className="empty">{t("plan.could_not_read_the_plan")}</div>;
const flip = async () => {
const pick = async (auto: boolean) => {
if (auto === state.auto) return;
setBusy(true);
setError("");
try {
setState(await port.savePlanApproval(!state.auto));
setState(await port.savePlanApproval(auto));
} catch (e) {
setError(reason(e));
} finally {
@@ -38,7 +39,7 @@ export function PlanApproval({ port }: { port: AgentPort }) {
<>
<SettingRow id="plan-approval" title={t("settings.plan_approval")} desc={t("settings.show_plans_before_running")} more={t("plan.a_submitted_plan_starts_without")}>
<SegChoice label={t("settings.plan_approval")} value={state.auto ? "auto" : "ask"} action="plan-approval.auto" disabled={busy}
onPick={() => void flip()} options={[["ask", t("settings.every_time")], ["auto", t("settings.not_needed")]]} />
onPick={(value) => void pick(value === "auto")} options={[["ask", t("settings.wait_for_confirmation")], ["auto", t("settings.start_automatically")]]} />
</SettingRow>
{error && <div className="why">{error}</div>}
</>
+1 -1
View File
@@ -56,7 +56,7 @@ export function SettingDeep({ id, title, now, hint, act, ctl, children }: { id:
<span aria-hidden="true">/</span>
<b aria-current="page">{title}</b>
</nav>
{children}
<SettingDeepContext.Provider value={{ ...deep, pages: false }}>{children}</SettingDeepContext.Provider>
</div>
);
}
+7 -8
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { StudioIcon } from "./StudioIcon";
import { useModalFocus } from "./useModalFocus";
import { SettingsLeaveContext, useSettingsLeave } from "./SettingsLeave";
@@ -6,8 +6,7 @@ import { t } from "../i18n";
import { listenAction } from "./listen";
import { useRuntimeReload } from "./RuntimeReload";
import type { CapabilityScope, McpEntry, ModelEntry, PluginPackage, SkillEntry } from "../port/port";
import { usePageIndex, useSettingsSearch } from "./useSettingsSearch";
import { PageIndex } from "./PageIndex";
import { useSettingsSearch } from "./useSettingsSearch";
import { useSettingLanding } from "./useSettingLanding";
import { bytes, tokens as fmtTokens } from "../i18n/format";
import { STORAGE_TALLY_DELAY, cachedStorageTotal, storageTotal } from "./storageTally";
@@ -62,8 +61,6 @@ const ELSEWHERE: string[] = [];
// label. A failed server keeps its error on the row that names it.
const NET_MODE: Record<string, string> = { auto: "look.follow_the_system", env: "settings.environment_variables", custom: "about.manual", off: "models.direct" };
const TWO_COLUMN: readonly Section[] = ["common", "session", "tools", "ext", "display", "controls", "appearance", "storage"];
const homeFor = (section: Section, anchor: string): Section => {
const entry = SETTING_AT(anchor);
return pageHome(entry ? entry.section : section);
@@ -314,7 +311,6 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
// 搜索命中的是这张表里的条目,包括当前没有挂载的那些分区;能不能显示由 shown
// 决定,和它有没有被渲染过无关。
const found = useSettingsSearch(query, shown, remoteBook);
const pageItems = usePageIndex(at, shown, remoteBook);
// 落点:跳过去之后短暂标一下「就是这里」。这是导航反馈,不是状态变化。
const pathOf = (section: Section) => {
const home = pageHome(section);
@@ -333,6 +329,10 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
if (section === "ext") setExtTab(anchor === "market" ? "market" : "installed");
});
};
useLayoutEffect(() => {
const panel = root.current?.querySelector<HTMLElement>(".prefs-main");
if (panel) panel.scrollTop = 0;
}, [at, deepAt, pageNow]);
useSettingLanding(root, landed, at, setLanded);
return (
@@ -415,7 +415,6 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
<div className="prefs-main" key={at} role="tabpanel" aria-labelledby={pageNow ? `prefs-${at}-${pageNow}` : `prefs-${at}`} data-sec={at}>
<ConfigTrouble port={port} onRepaired={onChanged} />
{!solo && <SettingsTitle at={at} />}
{!solo && !deepAt && <PageIndex items={pageItems} onGo={(entry) => go(entry.section, entry.anchor)} />}
<Boundary
fallback={
<div className="find" data-lvl="err" role="alert">
@@ -427,7 +426,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
{/* Keyed on the section: replacing this column is the navigation,
and it is the one thing here whose mount really is the cause. */}
<SettingDeepContext.Provider value={{ pages: !solo, open: deepAt, section: t(SECTION_NAME[at] ?? at), go: (id) => navigation.leave(() => { setLanded(""); setDeep(id); }) }}>
<div className="prefs-col" key={`${at}:${deepAt ?? ""}`} data-cols={TWO_COLUMN.includes(at) && !deepAt && !solo ? 2 : undefined} data-deep={deepAt ?? undefined}>
<div className="prefs-col" key={`${at}:${deepAt ?? ""}`} data-deep={deepAt ?? undefined}>
{solo ? <SoloStyle section={soloSection} /> : <SettingsPageStyle section={at} page={pageNow} />}
{/* Every refused write on every page lands in one state, so it is
said in one place. Copied onto the pages somebody remembered, it
+2 -4
View File
@@ -79,16 +79,14 @@ export function SettingsNav({ at, values, shown, danger, disabled, onSelect, pag
export function SettingsTitle({ at }: { at: Section }) {
const group = NAV.find(([, items]) => items.some(([id]) => id === at));
const name = group?.[1].find(([id]) => id === at)?.[1] ?? at;
const count = SETTINGS.filter((e) => !e.hidden && !e.within && pageHome(e.section) === at).length;
const desc = SECTION_DESC[at];
return (
<div className="prefs-pagetitle">
{group?.[0] && <span className="group">{t(group[0])}</span>}
<h2>{t(name)}</h2>
{(desc || count > 0) && (
{desc && (
<span className="desc">
{desc && <span className="what">{t(desc)}</span>}
{count > 0 && <span className="count">{t("settings.n_items", { n: count })}</span>}
<span className="what">{t(desc)}</span>
</span>
)}
<p className="usual">{t("settings.page_usual")}</p>
@@ -1,21 +1,15 @@
import { useLayoutEffect, useRef } from "react";
import type { Section } from "./prefsnav";
import { hiddenAnchors } from "./settingPages";
import { LOOK_FAMILY, SETTINGS } from "./prefsnav";
export function SettingsPageStyle({ section, page }: { section: Section; page: string | undefined }) {
const style = useRef<HTMLStyleElement>(null);
useLayoutEffect(() => {
const panel = style.current?.closest<HTMLElement>(".prefs-main");
if (panel) panel.scrollTop = 0;
}, [section, page]);
const hidden = LOOK_FAMILY.includes(section)
? SETTINGS.filter((e) => LOOK_FAMILY.includes(e.section) && e.section !== section).map((e) => e.anchor)
: page ? hiddenAnchors(section, page) : [];
if (hidden.length === 0) return null;
const blocks = hidden.map((id) => `.prefs-col [data-setting="${id}"]`).join(",");
const wrappers = hidden.map((id) => `.prefs-col details:has(> [data-setting="${id}"])`).join(",");
return <style ref={style}>{`${blocks}{display:none}${wrappers}{display:none}`}</style>;
return <style>{`${blocks}{display:none}${wrappers}{display:none}`}</style>;
}
/** A section drawn as a page of its own keeps only its own blocks out of the
@@ -6,11 +6,11 @@ import { SettingsTitle } from "./SettingsNav";
afterEach(cleanup);
it("names the page, how many settings it holds and what it is for", () => {
it("names the page and what it is for without repeating the navigation count", () => {
const { container } = render(<SettingsTitle at="model" />);
const bar = container.querySelector(".prefs-pagetitle")!;
expect(bar.querySelector("h2")?.textContent).toBe("模型");
expect(bar.querySelector(".count")?.textContent).toMatch(/^\d+ 项$/);
expect(bar.querySelector(".count")).toBeNull();
expect(bar.querySelector(".desc .what")?.textContent).toBe("接哪个服务、每种用途用哪个模型、上下文怎么维护");
expect(bar.querySelector(".group")).toBeNull();
});
@@ -38,6 +38,31 @@ describe("the extension scope row in settings", () => {
expect(onPick).toHaveBeenCalledWith("/work/site");
expect(screen.queryByRole("option")).toBeNull();
});
it("dismisses the project picker with Escape and returns focus without changing the scope", async () => {
const onPick = vi.fn();
render(<ScopeRow scope={scope()} scopes={scopes} onPick={onPick} />);
const trigger = screen.getByRole("button", { name: /app/ });
await userEvent.click(trigger);
await userEvent.click(screen.getByRole("button", { name: "只看有例外的" }));
expect(screen.getAllByRole("option")).toHaveLength(1);
await userEvent.keyboard("{Escape}");
expect(screen.queryByRole("listbox")).toBeNull();
expect(document.activeElement).toBe(trigger);
expect(onPick).not.toHaveBeenCalled();
});
it("dismisses on an outside press and can reopen to select a project", async () => {
const onPick = vi.fn();
render(<><ScopeRow scope={scope()} scopes={scopes} onPick={onPick} /><button>Outside</button></>);
await userEvent.click(screen.getByRole("button", { name: /app/ }));
await userEvent.click(screen.getByRole("button", { name: "Outside" }));
expect(screen.queryByRole("listbox")).toBeNull();
expect(onPick).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: /app/ }));
await userEvent.click(screen.getByRole("option", { name: /site/ }));
expect(onPick).toHaveBeenCalledExactlyOnceWith("/work/site");
});
});
describe("the extension page naming another project", () => {
@@ -1,6 +0,0 @@
import { pageHome, type Section, type SettingEntry } from "./prefsnav";
/** The settings a page's index lists: everything on the page, folded-in pages
* included, except what is drawn inside another setting's own block. */
export const pageIndexOf = (visible: readonly SettingEntry[], section: Section): SettingEntry[] =>
visible.filter((e) => !e.within && pageHome(e.section) === section);
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { parse } from "@babel/parser";
import { renderToStaticMarkup } from "react-dom/server";
import { Group } from "./Group";
import { SETTINGS, SETTING_AT, type SettingScope } from "./prefsnav";
import { NAV, SETTINGS, SETTING_AT, pageHome, type SettingScope } from "./prefsnav";
import { walk, type Node } from "./roots";
// The settings screen has to answer two questions about itself: where a
@@ -84,6 +84,13 @@ function rendered(): { ids: string[]; unnamed: string[] } {
describe("the settings catalogue", () => {
const seen = rendered();
it("gives every visible setting a navigable page or an editor on one", () => {
const homes = new Set(NAV.flatMap(([, items]) => items.map(([id]) => id as string)));
const entries = new Set(SETTINGS.filter((e) => !e.hidden && !e.within && homes.has(pageHome(e.section))).map((e) => e.anchor));
const lost = SETTINGS.filter((e) => !e.hidden && !entries.has(e.anchor) && !(e.within && entries.has(e.within))).map((e) => e.anchor);
expect(lost).toEqual([]);
});
it("carries every block the screen renders", () => {
const known = new Set(SETTINGS.map((e) => e.anchor));
const missing = [...new Set(seen.ids)].filter((id) => !known.has(id));
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, waitFor } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import "./testkit";
import { Settings } from "./Settings";
import type { SessionStatus } from "../port/port";
@@ -9,11 +9,11 @@ import { MockHub } from "../port/mock_hub";
afterEach(cleanup);
function draw(at?: string) {
function draw(at?: string, port = new MockPort()) {
render(
<Settings
hub={new MockHub() as never}
port={new MockPort()}
port={port}
at={at}
status={{ preset: "balanced", toolApprovalMode: "ask" } as SessionStatus}
theme="light" onTheme={() => {}} contrast="" onContrast={() => {}} weight="" onWeight={() => {}}
@@ -29,7 +29,106 @@ const shown = (id: string) => {
return !!el && getComputedStyle(el).display !== "none";
};
describe("the shared settings overview", () => {
it("starts an editor at the top even when its page has no hidden sections", async () => {
draw("tools");
const button = await screen.findByRole("button", { name: /^查看与调整(\d+)$/ });
const main = document.querySelector<HTMLElement>(".prefs-main")!;
main.scrollTop = 400;
fireEvent.click(button);
await waitFor(() => expect(document.querySelector('.deep-page[data-setting="tool-list"]')).toBeTruthy());
expect(main.scrollTop).toBe(0);
main.scrollTop = 200;
fireEvent.click(screen.getByRole("button", { name: "权限与安全" }));
await waitFor(() => expect(document.querySelector(".deep-page")).toBeNull());
expect(main.scrollTop).toBe(0);
});
it.each(["common", "model", "session", "tools", "ext", "display", "controls", "appearance", "storage", "network", "account"])("%s exposes its sections in one reading column without a duplicate index", async (at) => {
draw(at);
await waitFor(() => expect(document.querySelector(`.prefs-main[data-sec="${at}"] [data-setting]`)).toBeTruthy());
expect(screen.queryByRole("navigation", { name: "本页包含" })).toBeNull();
expect(document.querySelector(".prefs-col")?.hasAttribute("data-cols")).toBe(false);
expect(document.querySelector(".prefs-pagetitle .count")).toBeNull();
const sections = [...document.querySelectorAll(".prefs-col > .prefs-card")];
for (const section of sections) expect(section.closest("details")).toBeNull();
});
});
describe("the agent behaviour page", () => {
it("lands on the persona editor instead of a hidden overview row", async () => {
draw("session:persona");
await waitFor(() => expect(document.querySelector('.deep-page[data-setting="agent-presets"] #set-persona')).toBeTruthy());
const target = document.querySelector("#set-persona");
expect(target?.closest(".deep-page")?.getAttribute("data-setting")).toBe("agent-presets");
expect(document.querySelectorAll("#set-persona")).toHaveLength(1);
expect(target?.hasAttribute("data-landed")).toBe(true);
});
it("uses one column with visible groups and still lands on the exact search result", async () => {
draw("session");
await waitFor(() => expect(shown("session-ref")).toBe(true));
expect(document.querySelector(".prefs-col")?.hasAttribute("data-cols")).toBe(false);
expect(document.querySelector('#set-session-ref')!.closest("details")).toBeNull();
fireEvent.change(screen.getByRole("textbox", { name: "搜索设置" }), { target: { value: "session-ref" } });
fireEvent.click(await screen.findByRole("option", { name: /引用历史会话/ }));
await waitFor(() => expect(document.querySelector('#set-session-ref')?.hasAttribute("data-landed")).toBe(true));
});
it("retains an invalid draft and its error when search locates another setting on the page", async () => {
const port = new MockPort();
const read = vi.spyOn(port, "sessionRefSettings");
const save = vi.spyOn(port, "saveSessionRefSettings");
draw("session", port);
const input = await screen.findByRole("textbox", { name: "引用的最大长度" });
fireEvent.change(input, { target: { value: "1" } });
fireEvent.blur(input);
await screen.findByText(/请输入 500 到 200000/);
expect(screen.queryByRole("navigation", { name: "本页包含" })).toBeNull();
const search = screen.getByRole("textbox", { name: "搜索设置" });
for (const [query, anchor, name] of [["无进展", "progress-watch", /长时间无进展/], ["session-ref", "session-ref", /引用历史会话/]] as const) {
fireEvent.change(search, { target: { value: query } });
const hit = await screen.findByRole("option", { name });
expect(hit.getAttribute("data-target")).toBe(anchor);
fireEvent.click(hit);
await waitFor(() => expect(document.getElementById(`set-${anchor}`)?.hasAttribute("data-landed")).toBe(true));
}
expect(screen.getByRole("textbox", { name: "引用的最大长度" })).toBe(input);
expect((input as HTMLInputElement).value).toBe("1");
expect(screen.getByText(/请输入 500 到 200000/)).toBeTruthy();
expect(read).toHaveBeenCalledTimes(1);
expect(save).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: "9000" } });
fireEvent.blur(input);
await waitFor(() => expect(save).toHaveBeenCalledWith(9000));
});
it("opens a nested editor directly and returns to the overview", async () => {
draw("session:peers");
await waitFor(() => expect(document.querySelector('.deep-page[data-setting="peers"]')).toBeTruthy());
expect(screen.queryByRole("navigation", { name: "本页包含" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Agent 行为" }));
await waitFor(() => expect(document.querySelector(".deep-page")).toBeNull());
expect(document.querySelector('#set-plan-approval')).toBeTruthy();
expect(screen.queryByRole("navigation", { name: "本页包含" })).toBeNull();
});
});
describe("the permissions page", () => {
it.each(["tool-list", "web-access"])("opens %s with one return path and keeps its controls usable", async (id) => {
const port = new MockPort();
const read = vi.spyOn(port, id === "tool-list" ? "toolSettings" : "webAccessSettings");
draw(`tools:${id}`, port);
await waitFor(() => expect(document.querySelector(`#set-${id}`)).toBeTruthy());
expect(screen.getAllByRole("navigation", { name: "当前位置" })).toHaveLength(1);
expect(document.querySelectorAll(`.deep-page[data-setting="${id}"]`)).toHaveLength(1);
expect(screen.getAllByRole("switch").length).toBeGreaterThan(0);
expect(read).toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "权限与安全" }));
await waitFor(() => expect(document.querySelector(".deep-page")).toBeNull());
expect(document.querySelector('#set-approval')).toBeTruthy();
});
it("is one page with sub-headings, and every block is on it", async () => {
draw("tools");
await waitFor(() => expect(shown("approval")).toBe(true));
@@ -1,6 +1,5 @@
import { useMemo } from "react";
import { SETTINGS } from "./prefsnav";
import { pageIndexOf } from "./settingPageIndex";
import { searchSettings } from "./settingSearch";
import type { Section } from "./prefsnav";
import { useShellGraphics } from "./GraphicsSection";
@@ -27,9 +26,3 @@ export function useSettingsSearch(query: string, shown: (id: Section) => boolean
return q ? searchSettings(visible, q) : null;
}, [query, visible]);
}
/** What a page holds, for its index. */
export function usePageIndex(at: Section, shown: (id: Section) => boolean, shownKey: unknown) {
const visible = useVisibleSettings(shown, shownKey);
return useMemo(() => pageIndexOf(visible, at), [visible, at]);
}
+1 -1
View File
@@ -225,7 +225,7 @@ var English = Messages{
MemoryApprovalArchiveFmt: "Archive memory %q",
SandboxEscapeSubjectFallback: "run shell command unconfined once",
SandboxEscapeSubjectPrefix: "run unconfined once: ",
SandboxEscapeWrapReason: "Windows does not provide an OS-level Bash sandbox for this command. Run it unconfined one time? This bypasses OS isolation for this command only.",
SandboxEscapeWrapReason: "This host cannot provide an OS-level sandbox for this command. Run it unconfined one time? This bypasses OS isolation for this command only.",
SandboxEscapeRuntimeReason: "The OS sandbox could not start this command. Run it unconfined one time? This bypasses OS isolation for this command only.",
SandboxEscapeDeclined: "the user declined to run this command without the OS sandbox - do not retry it unconfined; ask how they would like to proceed.",
EgressApprovalReasonFmt: "A bash command wants to reach %s, which is not in [sandbox] allowed_domains. Allow it?",
+1 -1
View File
@@ -225,7 +225,7 @@ var Chinese = Messages{
MemoryApprovalArchiveFmt: "归档记忆 %q",
SandboxEscapeSubjectFallback: "仅本次不进沙箱运行 shell 命令",
SandboxEscapeSubjectPrefix: "仅本次不进沙箱运行:",
SandboxEscapeWrapReason: "Windows 不提供这条命令所需的 OS 级 Bash 沙箱。是否仅本次不受限运行?这只会对此命令绕过 OS 隔离。",
SandboxEscapeWrapReason: "当前设备无法提供这条命令所需的 OS 级沙箱。是否仅本次不受限运行?这只会对此命令绕过 OS 隔离。",
SandboxEscapeRuntimeReason: "OS 沙箱无法启动这条命令。是否仅本次不受限运行?这只会对此命令绕过 OS 隔离。",
SandboxEscapeDeclined: "用户拒绝在没有 OS 沙箱的情况下运行这条命令;不要不进沙箱重试,请询问用户希望如何继续。",
EgressApprovalReasonFmt: "一条 bash 命令要访问 %s,它不在 [sandbox] allowed_domains 中。是否允许?",
+1 -1
View File
@@ -226,7 +226,7 @@ var ChineseTraditional = Messages{
MemoryApprovalArchiveFmt: "封存記憶 %q",
SandboxEscapeSubjectFallback: "僅本次不進沙箱執行 shell 命令",
SandboxEscapeSubjectPrefix: "僅本次不進沙箱執行:",
SandboxEscapeWrapReason: "Windows 不提供這條命令所需的 OS 級 Bash 沙箱。是否僅本次不受限執行?這只會對此命令繞過 OS 隔離。",
SandboxEscapeWrapReason: "目前裝置無法提供這條命令所需的 OS 級沙箱。是否僅本次不受限執行?這只會對此命令繞過 OS 隔離。",
SandboxEscapeRuntimeReason: "OS 沙箱無法啟動這條命令。是否僅本次不受限執行?這只會對此命令繞過 OS 隔離。",
SandboxEscapeDeclined: "使用者拒絕在沒有 OS 沙箱的情況下執行這條命令;不要不進沙箱重試,請詢問使用者希望如何繼續。",
EgressApprovalReasonFmt: "一條 bash 命令要存取 %s,它不在 [sandbox] allowed_domains 中。是否允許?",
+2 -1
View File
@@ -10,6 +10,7 @@ import (
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"golang.org/x/text/unicode/norm"
@@ -1317,7 +1318,7 @@ func Default() *Config {
UI: UIConfig{Theme: "auto", ShowTurnUsage: true, ShowChangedFiles: true},
Desktop: DesktopConfig{ConversationWidth: "standard"},
Billing: BillingConfig{},
Tools: ToolsConfig{PresentFiles: true},
Tools: ToolsConfig{PresentFiles: true, Shell: defaultShell(runtime.GOOS)},
// Set here, not left to the zero value: an absent [secrets] still protects.
Secrets: SecretsConfig{ProtectCredentialFiles: true},
Notifications: NotificationsConfig{
+7
View File
@@ -23,6 +23,13 @@ type ShellConfig struct {
Env map[string]string `toml:"env"`
}
func defaultShell(platform string) ShellConfig {
if platform == "android" {
return ShellConfig{Prefer: "bash", Path: "/system/bin/sh"}
}
return ShellConfig{}
}
// warnShellEnvProblems reports [tools.shell] env the load could not honour: a
// project table it discarded, and entries that cannot become environment entries
// (the bash tool drops those, so they would silently never take effect).
@@ -0,0 +1,29 @@
package config
import (
"testing"
"github.com/BurntSushi/toml"
)
func TestAndroidShellDefaultsSurviveUnrelatedSettingsAndAllowUserOverride(t *testing.T) {
cfg := Default()
cfg.Tools.Shell = defaultShell("android")
if _, err := toml.Decode("language = 'zh'\n[tools.shell.env]\nEDITOR = 'vi'\n", cfg); err != nil {
t.Fatal(err)
}
if cfg.Tools.Shell.Prefer != "bash" || cfg.Tools.Shell.Path != "/system/bin/sh" || cfg.Tools.Shell.Env["EDITOR"] != "vi" {
t.Fatalf("Android shell = %+v", cfg.Tools.Shell)
}
if _, err := toml.Decode("[tools.shell]\nprefer = 'bash'\npath = '/data/local/bin/bash'\n", cfg); err != nil {
t.Fatal(err)
}
if cfg.Tools.Shell.Path != "/data/local/bin/bash" {
t.Fatalf("user shell override = %q", cfg.Tools.Shell.Path)
}
for _, platform := range []string{"windows", "linux", "darwin"} {
if shell := defaultShell(platform); shell.Prefer != "" || shell.Path != "" {
t.Fatalf("%s must retain shell discovery: %+v", platform, shell)
}
}
}
+5 -1
View File
@@ -34,9 +34,13 @@ func init() { tool.RegisterBuiltin(bash{}) }
var (
bashSandboxCommand = sandbox.Command
bashSandboxEscapePromptEnabled = func() bool { return runtime.GOOS == "windows" }
bashSandboxEscapePromptEnabled = func() bool { return bashSandboxEscapePlatform(runtime.GOOS) }
)
func bashSandboxEscapePlatform(platform string) bool {
return platform == "windows" || platform == "android"
}
// bash runs a shell command. sb, when it enforces, wraps the command in an OS
// sandbox; the zero value registered at init runs unconfined and is overridden
// per run by ConfineBash. shell is the resolved interpreter (real bash, or
@@ -20,6 +20,45 @@ type fakeSandboxEscapeApprover struct {
sessionChecks []sandbox.EscapeRequest
}
func TestAndroidShellRequiresAnExplicitSandboxEscapeDecision(t *testing.T) {
oldPrompt, oldCommand := bashSandboxEscapePromptEnabled, bashSandboxCommand
t.Cleanup(func() { bashSandboxEscapePromptEnabled, bashSandboxCommand = oldPrompt, oldCommand })
bashSandboxEscapePromptEnabled = func() bool { return bashSandboxEscapePlatform("android") }
bashSandboxCommand = func(spec sandbox.Spec, sh sandbox.Shell, command string) ([]string, bool) {
return unconfinedShellArgv(sh, command), false
}
sh := sandbox.ResolveShell("", "", nil)
for _, tc := range []struct {
name string
approver *fakeSandboxEscapeApprover
allowed bool
}{
{name: "no approver"},
{name: "denied", approver: &fakeSandboxEscapeApprover{}},
{name: "approved once", approver: &fakeSandboxEscapeApprover{allow: true}, allowed: true},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
if tc.approver != nil {
ctx = sandbox.WithEscapeApprover(ctx, tc.approver)
}
command := echoForShell(sh, "android-confirmed-command")
out, err := (bash{sb: sandbox.Spec{Mode: "enforce"}, shell: sh}).Execute(ctx, argsJSON(t, map[string]any{"command": command}))
if tc.allowed != (err == nil) || strings.Contains(out, "android-confirmed-command") != tc.allowed {
t.Fatalf("allowed=%v: output=%q error=%v", tc.allowed, out, err)
}
if tc.approver != nil && (len(tc.approver.calls) != 1 || tc.approver.calls[0].Command != command) {
t.Fatalf("approval requests = %+v, want the exact command", tc.approver.calls)
}
})
}
for _, platform := range []string{"linux", "darwin", "freebsd"} {
if bashSandboxEscapePlatform(platform) {
t.Fatalf("%s unexpectedly permits sandbox escape prompts", platform)
}
}
}
func (f *fakeSandboxEscapeApprover) ApproveSandboxEscape(ctx context.Context, req sandbox.EscapeRequest) (bool, string, error) {
f.calls = append(f.calls, req)
return f.allow, f.reason, nil