mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 01:48:26 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5ee56c1b7 | ||
|
|
45e7f52d70 |
No files matched your search
+2
-2
@@ -16,7 +16,7 @@ local.properties
|
||||
keystore/
|
||||
|
||||
# Build inputs, produced by scripts/ before Gradle runs.
|
||||
# The page sync also copies the repository license into the APK assets.
|
||||
# jniLibs/ holds the cross-compiled kernel, assets/page/ the built
|
||||
# frontend. assets/LICENSE is hand-placed and stays tracked.
|
||||
app/src/main/jniLibs/
|
||||
app/src/main/assets/page/
|
||||
app/src/main/assets/LICENSE
|
||||
+12
-45
@@ -15,26 +15,6 @@ 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
|
||||
@@ -82,17 +62,19 @@ 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` runs the isolated local-host smoke check described above.
|
||||
`verify-page.sh` is the one to run after a change: it starts the kernel with the
|
||||
same arguments `Kernel.kt` uses (`--auth token`, `--token-file`, `--port-file`,
|
||||
`-page`) and checks that it binds a port and answers 200. Without a device, that
|
||||
is as deep as the chain can be verified.
|
||||
|
||||
## Where the code is
|
||||
|
||||
| File | Owns |
|
||||
| --- | --- |
|
||||
| `MainActivity.kt` | The WebView, the back gesture, downloads, external links, renderer restart |
|
||||
| `Kernel.kt` | Unpacking the page, starting and stopping the kernel, reading the launch handshake, health probes, logs, the workspace directory |
|
||||
| `Kernel.kt` | Unpacking the page, starting and stopping the kernel, waiting for the port, health probes, logs, the workspace directory |
|
||||
| `KernelService.kt` | The foreground service that keeps a long turn alive |
|
||||
| `NativeBridge.kt` | `@JavascriptInterface`: what the page may ask the phone for |
|
||||
| `BrowserShellServer.kt` | Loopback HTTP server the kernel's browser tools drive the overlay WebView through |
|
||||
@@ -101,29 +83,14 @@ export PATH="$PATH:$(go env GOPATH)/bin"
|
||||
The page reaches the shell through `window.reasonixHost` and `ReasonixNative`,
|
||||
defined in `desktop/frontend-next/src/port/host.ts` (`AndroidHost`).
|
||||
|
||||
**Phone and desktop share one page build.** The host port detects Android's
|
||||
`ReasonixNative` bridge before the page loads. The browser-view bridge installed
|
||||
at page completion is read when needed.
|
||||
**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.
|
||||
|
||||
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.
|
||||
One attribute feeding both is what keeps style and components from disagreeing
|
||||
about what a phone is.
|
||||
|
||||
## What the page may ask the phone for
|
||||
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
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">
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
package io.reasonix.mobile.app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.io.File
|
||||
|
||||
internal class FolderPicker(private val activity: AppCompatActivity, private val deliver: (String?) -> Unit) {
|
||||
private var startIn = ""
|
||||
private val tree = activity.registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
val path = uri?.let(::realPath)
|
||||
if (uri != null && path == null) message(R.string.workspace_unavailable)
|
||||
deliver(path)
|
||||
}
|
||||
private val settings = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
afterPermission()
|
||||
}
|
||||
private val legacy = activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||
afterPermission()
|
||||
}
|
||||
|
||||
fun hasAccess(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Environment.isExternalStorageManager()
|
||||
} else {
|
||||
ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun pick(path: String): Boolean {
|
||||
startIn = path
|
||||
if (hasAccess()) {
|
||||
launchTree()
|
||||
} else {
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle(R.string.wsperm_title)
|
||||
.setMessage(R.string.wsperm_body)
|
||||
.setPositiveButton(R.string.wsperm_go) { _, _ -> requestAccess() }
|
||||
.setNegativeButton(R.string.wsperm_later) { _, _ -> deliver(null) }
|
||||
.setOnCancelListener { deliver(null) }
|
||||
.show()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun requestAccess() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
legacy.launch(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE))
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
settings.launch(Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:${activity.packageName}")))
|
||||
}.recoverCatching {
|
||||
settings.launch(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION))
|
||||
}.onFailure {
|
||||
message(R.string.workspace_permission_denied)
|
||||
deliver(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun afterPermission() {
|
||||
if (hasAccess()) launchTree() else {
|
||||
message(R.string.workspace_permission_denied)
|
||||
deliver(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchTree() {
|
||||
val root = Environment.getExternalStorageDirectory().absolutePath
|
||||
val relative = if (startIn.startsWith("$root/")) startIn.removePrefix("$root/") else "Documents"
|
||||
val initial = DocumentsContract.buildDocumentUri(AUTHORITY, "primary:$relative")
|
||||
runCatching { tree.launch(initial) }.onFailure {
|
||||
message(R.string.workspace_unavailable)
|
||||
deliver(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun message(resource: Int) = Toast.makeText(activity, resource, Toast.LENGTH_LONG).show()
|
||||
|
||||
private fun realPath(uri: Uri): String? = runCatching {
|
||||
if (uri.authority != AUTHORITY) return@runCatching null
|
||||
val document = DocumentsContract.getTreeDocumentId(uri)
|
||||
val volume = document.substringBefore(':', "")
|
||||
val relative = document.substringAfter(':', "")
|
||||
if (!safeSegment(volume) || (relative.isNotEmpty() && !relative.split('/').all(::safeSegment))) return@runCatching null
|
||||
val root = if (volume == "primary") Environment.getExternalStorageDirectory() else File("/storage", volume)
|
||||
val target = File(root, relative).canonicalFile
|
||||
val base = root.canonicalPath
|
||||
if (target.path != base && !target.path.startsWith(base + File.separator)) return@runCatching null
|
||||
target.path.takeIf { target.isDirectory && target.canRead() && target.canWrite() }
|
||||
}.getOrNull()
|
||||
|
||||
private fun safeSegment(part: String): Boolean = part.isNotEmpty() && part != "." && part != ".." &&
|
||||
part.none { it == '/' || it == '\\' || it == '\u0000' }
|
||||
|
||||
private companion object {
|
||||
const val AUTHORITY = "com.android.externalstorage.documents"
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package io.reasonix.mobile.app
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.net.URI
|
||||
import java.util.concurrent.CompletableFuture
|
||||
|
||||
internal class HostConnection(val origin: String, val token: String) {
|
||||
companion object {
|
||||
fun read(process: Process): CompletableFuture<HostConnection> {
|
||||
val ready = CompletableFuture<HostConnection>()
|
||||
Thread({
|
||||
try {
|
||||
process.inputStream.bufferedReader().use { reader ->
|
||||
val line = reader.readLine() ?: error("应用内核未返回启动信息")
|
||||
val handshake = JSONObject(line)
|
||||
val origin = handshake.getString("origin")
|
||||
val token = handshake.getString("token")
|
||||
val uri = URI(origin)
|
||||
require(handshake.getInt("version") == 1 &&
|
||||
uri.scheme == "http" && uri.host == "127.0.0.1" &&
|
||||
uri.port in 1..65535 && uri.rawPath.orEmpty().isEmpty() &&
|
||||
uri.rawUserInfo == null && uri.rawQuery == null && uri.rawFragment == null &&
|
||||
token.matches(Regex("[0-9a-f]{64}"))) { "应用内核启动信息无效" }
|
||||
ready.complete(HostConnection(origin, token))
|
||||
while (reader.readLine() != null) { }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
ready.completeExceptionally(IllegalStateException("应用内核未返回有效的本地连接"))
|
||||
}
|
||||
}, "kernel-handshake").apply { isDaemon = true }.start()
|
||||
return ready
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,27 @@ import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Owns the local Studio host, its private launch handshake, and its bundled page. */
|
||||
/**
|
||||
* The Reasonix kernel, as this app runs it: one child process serving loopback,
|
||||
* plus the two things it needs on disk to start — the page it serves and a
|
||||
* credential for this launch.
|
||||
*
|
||||
* The shape is the desktop host's (`cmd/reasonix-studio-host`), narrowed to what
|
||||
* a phone does not have. That host binds a socket, mints a credential, speaks a
|
||||
* handshake down a pipe and holds a lease; here the credential goes through
|
||||
* `--token-file` and the bound address through `--port-file`, because a phone's
|
||||
* app process has no pipe to hand a child and no reason to care which port was
|
||||
* free. Everything after that — the kernel's own gate, its auth middleware, its
|
||||
* CSRF check — is untouched, so the security of the surface is the one upstream
|
||||
* tested, not one this shell invented.
|
||||
*
|
||||
* One instance lives in the app process. The activity asks it for a URL; the
|
||||
* foreground service asks it to stay alive. Both see the same process, so there
|
||||
* is nothing to synchronise beyond a lock on start/stop.
|
||||
*/
|
||||
class Kernel(private val context: Context) {
|
||||
|
||||
/** Where everything the kernel owns lives. Nothing here is backed up. */
|
||||
@@ -53,6 +69,10 @@ 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
|
||||
@@ -77,12 +97,25 @@ class Kernel(private val context: Context) {
|
||||
var origin: String? = null
|
||||
private set
|
||||
|
||||
/** The host's per-launch credential, held only in memory. */
|
||||
/**
|
||||
* The launch credential, as the URL fragment the page trades for a cookie.
|
||||
* Kept in memory only: the file on disk belongs to the kernel's own
|
||||
* bookkeeping, and a phone's app data is not a place to leave a second copy.
|
||||
*/
|
||||
@Volatile
|
||||
private var token: String? = null
|
||||
|
||||
/** 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 URL the WebView loads first. It carries the credential in the
|
||||
* fragment, which the page exchanges for an HttpOnly cookie before it makes
|
||||
* any request — the same path a shared `reasonix web` link takes, so the
|
||||
* token never enters a request line, a history entry or a log.
|
||||
*/
|
||||
fun entryUrl(): String? {
|
||||
val origin = origin ?: return null
|
||||
val token = token ?: return null
|
||||
return "$origin/#token=$token"
|
||||
}
|
||||
|
||||
/** The plain origin, for reloads once the cookie is set. */
|
||||
fun plainUrl(): String? = origin
|
||||
@@ -98,26 +131,31 @@ class Kernel(private val context: Context) {
|
||||
*/
|
||||
suspend fun start(): Result<String> = withContext(Dispatchers.IO) {
|
||||
synchronized(lock) {
|
||||
origin?.takeIf { process?.isAlive == true }?.let { return@withContext Result.success(it) }
|
||||
var child: Process? = null
|
||||
origin?.let { return@withContext Result.success(it) }
|
||||
|
||||
runCatching {
|
||||
val binary = kernelBinary()
|
||||
publishPage()
|
||||
prepareHome()
|
||||
val started = launch(binary)
|
||||
child = started
|
||||
val connection = HostConnection.read(started).get(START_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
awaitServing(started, connection)
|
||||
token = connection.token
|
||||
origin = connection.origin
|
||||
process = started
|
||||
connection.origin
|
||||
}.onFailure {
|
||||
child?.destroyForcibly()
|
||||
origin = null
|
||||
token = null
|
||||
process = null
|
||||
}
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +180,8 @@ class Kernel(private val context: Context) {
|
||||
p.destroyForcibly()
|
||||
}
|
||||
}
|
||||
portFile.delete()
|
||||
pidFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,13 +205,23 @@ class Kernel(private val context: Context) {
|
||||
return binary
|
||||
}
|
||||
|
||||
/** The entry HTML identifies Vite's content-hashed asset graph, including same-version APK rebuilds. */
|
||||
/**
|
||||
* Write the bundled page where the kernel can serve it.
|
||||
*
|
||||
* It goes to app storage rather than being served out of the APK because the
|
||||
* kernel's `--page` is a directory on a real filesystem; there is no
|
||||
* archive-aware variant, and adding one would be a change to the kernel for
|
||||
* this shell's convenience alone.
|
||||
*
|
||||
* Unpacking is versioned: the marker holds the app's version name, so an
|
||||
* update replaces the page and a relaunch does not. A partially written
|
||||
* directory — the app killed mid-copy — is caught by the same marker never
|
||||
* having been written, so the next launch rewrites it.
|
||||
*/
|
||||
private fun publishPage() {
|
||||
val marker = File(pageDir, ".unpacked")
|
||||
val 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
|
||||
val stamp = "${BuildConfig.VERSION_NAME}:${BuildConfig.VERSION_CODE}"
|
||||
if (marker.isFile && marker.readText().trim() == stamp) return
|
||||
|
||||
pageDir.deleteRecursively()
|
||||
pageDir.mkdirs()
|
||||
@@ -193,8 +243,12 @@ class Kernel(private val context: Context) {
|
||||
private fun copyAssetTree(assets: AssetManager, from: String, to: File) {
|
||||
val children = assets.list(from).orEmpty()
|
||||
if (children.isEmpty()) {
|
||||
assets.open(from).use { input ->
|
||||
FileOutputStream(to).use { output -> input.copyTo(output) }
|
||||
// A leaf: open it as a stream. A directory that happens to be empty
|
||||
// is indistinguishable and harmless to skip.
|
||||
runCatching {
|
||||
assets.open(from).use { input ->
|
||||
FileOutputStream(to).use { output -> input.copyTo(output) }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -378,18 +432,69 @@ 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(':', '-')
|
||||
|
||||
private fun launch(binary: File): Process {
|
||||
/**
|
||||
* The launch credential.
|
||||
*
|
||||
* Minted here rather than read from configuration: a credential a person can
|
||||
* set is one anything that can read their files can present. 32 bytes, hex,
|
||||
* the same shape the desktop host mints.
|
||||
*/
|
||||
private fun mintToken(): String {
|
||||
val bytes = ByteArray(32)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
val token = bytes.joinToString("") { "%02x".format(it) }
|
||||
tokenFile.parentFile?.mkdirs()
|
||||
tokenFile.writeText("$token\n")
|
||||
// 0600 by intent, and the app's own private directory means it is
|
||||
// already unreachable to every other app on the device.
|
||||
runCatching {
|
||||
tokenFile.setReadable(false, false)
|
||||
tokenFile.setReadable(true, true)
|
||||
tokenFile.setWritable(false, false)
|
||||
tokenFile.setWritable(true, true)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the child.
|
||||
*
|
||||
* `--addr 127.0.0.1:0` lets the kernel pick a free port; `--port-file`
|
||||
* makes it announce which. `--auth token` is the mode a phone needs: the
|
||||
* page authenticates, and a state-changing request without the credential
|
||||
* is refused. `--no-open` is belt and braces — a serve never opens a
|
||||
* browser, and a phone has no browser to open.
|
||||
*/
|
||||
private fun launch(binary: File, token: String): Process {
|
||||
portFile.delete()
|
||||
pidFile.delete()
|
||||
// The folder the agent works in, resolved before the command line is
|
||||
// built so it can be named on it. See the note under the environment
|
||||
// block for why naming it is not the same as setting the directory.
|
||||
val workspace = launchWorkspaceDir()
|
||||
val command = listOf(
|
||||
binary.absolutePath,
|
||||
"serve",
|
||||
"--addr", "127.0.0.1:0",
|
||||
"--auth", "token",
|
||||
"--token-file", tokenFile.absolutePath,
|
||||
"--port-file", portFile.absolutePath,
|
||||
"--pid-file", pidFile.absolutePath,
|
||||
"--no-open",
|
||||
"-page", pageDir.absolutePath,
|
||||
"-studio-version", BuildConfig.VERSION_NAME,
|
||||
// The project root, said outright. Without this the kernel derives
|
||||
// it from its working directory, and on this phone the two did not
|
||||
// agree: the sidebar named the shared folder, `write_roots` confined
|
||||
// the file tools to the app's own directory, and every write into the
|
||||
// folder the person was looking at came back
|
||||
// `workspace.write_outside_scope`. A flag is a fact the kernel was
|
||||
// told rather than one it inferred from a mount it sees differently.
|
||||
"--dir", workspace.absolutePath,
|
||||
)
|
||||
val builder = ProcessBuilder(command)
|
||||
builder.environment().apply {
|
||||
put("REASONIX_HOME", home.absolutePath)
|
||||
put("HOME", home.absolutePath)
|
||||
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
|
||||
@@ -468,6 +573,7 @@ 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
|
||||
@@ -564,7 +670,7 @@ class Kernel(private val context: Context) {
|
||||
|
||||
private fun drain(process: Process) {
|
||||
runCatching {
|
||||
process.errorStream.bufferedReader().useLines { lines ->
|
||||
process.inputStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
Log.i(TAG, line)
|
||||
synchronized(logLines) {
|
||||
@@ -579,6 +685,21 @@ 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.
|
||||
*
|
||||
@@ -586,24 +707,23 @@ 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, connection: HostConnection) {
|
||||
private fun awaitServing(process: Process, origin: String) {
|
||||
val deadline = System.currentTimeMillis() + START_TIMEOUT_MS
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (!process.isAlive) {
|
||||
throw IllegalStateException("内核在开始服务前退出。\n${recentLog().takeLast(2000)}")
|
||||
}
|
||||
if (probe("${connection.origin}/", connection.token)) return
|
||||
if (probe("$origin/")) return
|
||||
Thread.sleep(POLL_MS)
|
||||
}
|
||||
throw IllegalStateException("内核已监听 ${connection.origin},但页面没有响应。\n${recentLog().takeLast(2000)}")
|
||||
throw IllegalStateException("内核已监听 $origin,但页面没有响应。\n${recentLog().takeLast(2000)}")
|
||||
}
|
||||
|
||||
private fun probe(url: String, launchToken: String): Boolean = runCatching {
|
||||
private fun probe(url: 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,7 +9,9 @@ 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
|
||||
@@ -32,7 +34,6 @@ 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
|
||||
@@ -192,7 +193,6 @@ class MainActivity : AppCompatActivity() {
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
rootLayout = FrameLayout(this)
|
||||
web = WebView(this)
|
||||
@@ -206,7 +206,6 @@ 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.
|
||||
@@ -259,7 +258,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(rootLayout)
|
||||
ViewCompat.requestApplyInsets(web)
|
||||
|
||||
// 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
|
||||
@@ -387,11 +386,80 @@ class MainActivity : AppCompatActivity() {
|
||||
return picked.takeIf { it.isNotEmpty() }?.toTypedArray()
|
||||
}
|
||||
|
||||
private val nativeResults = mutableMapOf<String, String>()
|
||||
private val folders = FolderPicker(this) { path -> finishNativePicker("pickFolder", path) }
|
||||
/**
|
||||
* 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 fun finishNativePicker(method: String, value: String?) {
|
||||
nativeResults.remove(method)?.let { replyHost(it, JSONObject.quote(value.orEmpty())) }
|
||||
/**
|
||||
* 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) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -412,7 +480,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())) }
|
||||
finishNativePicker("scanPairingCode", text)
|
||||
bridge.deliverScan(text)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -443,7 +511,7 @@ class MainActivity : AppCompatActivity() {
|
||||
pickIdentity.launch(arrayOf("*/*"))
|
||||
true
|
||||
}
|
||||
bridge.onInsets = { Insets(0, 0, 0, 0) }
|
||||
bridge.onInsets = { currentInsets }
|
||||
bridge.askForSave = { name, content ->
|
||||
pendingSave = name to content
|
||||
runCatching { saveTextFile.launch(name) }
|
||||
@@ -452,33 +520,110 @@ 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 = folders::hasAccess
|
||||
bridge.askForFilesAccess = { runOnUiThread { folders.requestAccess() } }
|
||||
bridge.filesAccess = { hasAllFilesAccess() }
|
||||
bridge.askForFilesAccess = { openAllFilesSettings() }
|
||||
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(rootLayout) { view, insets ->
|
||||
val safe = insets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() or WindowInsetsCompat.Type.ime(),
|
||||
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)),
|
||||
)
|
||||
view.setPadding(safe.left, safe.top, safe.right, safe.bottom)
|
||||
WindowInsetsCompat.CONSUMED
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
/**
|
||||
* 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 installClients() {
|
||||
@@ -643,8 +788,6 @@ 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
|
||||
@@ -655,11 +798,10 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
private fun load(origin: String) {
|
||||
installClients()
|
||||
val cookie = (application as App).kernel.sessionCookie() ?: return
|
||||
android.webkit.CookieManager.getInstance().setCookie(origin, cookie) { accepted ->
|
||||
if (accepted) web.loadUrl(origin)
|
||||
else showFailure(IllegalStateException("无法建立应用内核的本地会话"))
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -768,6 +910,31 @@ 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
|
||||
@@ -998,24 +1165,6 @@ 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
|
||||
@@ -1234,7 +1383,7 @@ class MainActivity : AppCompatActivity() {
|
||||
};
|
||||
function call(method, args) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var id = Date.now() + ':' + String(nextId++);
|
||||
var id = String(nextId++);
|
||||
pending[id] = { resolve: resolve, reject: reject };
|
||||
try {
|
||||
ReasonixNative.hostCall(id, method, JSON.stringify(args || []));
|
||||
@@ -1248,9 +1397,6 @@ 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', []); },
|
||||
@@ -1273,7 +1419,6 @@ 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,6 +261,21 @@ 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.
|
||||
*
|
||||
@@ -340,6 +355,11 @@ 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())
|
||||
|
||||
@@ -358,6 +378,13 @@ 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
|
||||
@@ -415,6 +442,51 @@ 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.
|
||||
*
|
||||
@@ -598,10 +670,63 @@ 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 = "5"
|
||||
const val BRIDGE_VERSION = "4"
|
||||
private const val PICK_TIMEOUT_SECONDS = 5L * 60
|
||||
|
||||
/** Long, because this is however long someone takes to aim. A shorter
|
||||
* one would turn "still lining it up" into a failed scan. */
|
||||
private const val SCAN_TIMEOUT_SECONDS = 5L * 60
|
||||
|
||||
/** Only as long as the sheet takes to appear: the answer is "something
|
||||
* took it", which is known the moment the Intent resolves. */
|
||||
private const val SHARE_TIMEOUT_SECONDS = 15L
|
||||
|
||||
@@ -8,8 +8,6 @@ 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
|
||||
@@ -19,8 +17,6 @@ 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
|
||||
|
||||
/**
|
||||
@@ -86,16 +82,6 @@ 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) ==
|
||||
@@ -107,8 +93,10 @@ class ScanActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun refuse(message: Int = R.string.scan_denied) {
|
||||
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
|
||||
private fun refuse() {
|
||||
// No dialog of our own: the permission prompt already said no, and a
|
||||
// second window repeating it is one more tap to dismiss. The caller
|
||||
// gets the same answer as a cancelled scan.
|
||||
setResult(RESULT_CANCELED)
|
||||
finish()
|
||||
}
|
||||
@@ -116,7 +104,7 @@ class ScanActivity : AppCompatActivity() {
|
||||
private fun start() {
|
||||
val providerFuture = ProcessCameraProvider.getInstance(this)
|
||||
providerFuture.addListener({
|
||||
val provider = runCatching { providerFuture.get() }.getOrNull() ?: return@addListener refuse(R.string.scan_unavailable)
|
||||
val provider = runCatching { providerFuture.get() }.getOrNull() ?: return@addListener refuse()
|
||||
val previewUseCase = Preview.Builder().build().also { it.setSurfaceProvider(preview.surfaceProvider) }
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
// Only the newest frame matters: a stale one describes where the
|
||||
@@ -128,7 +116,7 @@ class ScanActivity : AppCompatActivity() {
|
||||
runCatching {
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, previewUseCase, analysis)
|
||||
}.onFailure { refuse(R.string.scan_unavailable) }
|
||||
}.onFailure { refuse() }
|
||||
}, ContextCompat.getMainExecutor(this))
|
||||
}
|
||||
|
||||
@@ -138,10 +126,10 @@ class ScanActivity : AppCompatActivity() {
|
||||
val text = Qr.decode(image) ?: return
|
||||
if (settled) return
|
||||
settled = true
|
||||
runOnUiThread {
|
||||
setResult(RESULT_OK, Intent().putExtra(EXTRA_TEXT, text))
|
||||
finish()
|
||||
}
|
||||
setResult(RESULT_OK, Intent().putExtra(EXTRA_TEXT, text))
|
||||
// Finishing from the analysis thread is the whole reason `settled`
|
||||
// exists; the activity teardown has to happen on the main one.
|
||||
runOnUiThread { finish() }
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,5 @@
|
||||
rather than the platform default white. -->
|
||||
<resources>
|
||||
<color name="page">#F4F1EA</color>
|
||||
<color name="page_dark">#171A18</color>
|
||||
<color name="boot">#07080C</color>
|
||||
</resources>
|
||||
@@ -23,17 +23,13 @@
|
||||
<string name="splash_retry">重试</string>
|
||||
|
||||
<string name="wsperm_title">需要「所有文件访问」权限</string>
|
||||
<string name="wsperm_body">要直接读写手机公共文件夹,需要开启「所有文件访问」权限。点「去设置」授权后返回,会继续选择文件夹。使用应用默认工作区不需要此权限。</string>
|
||||
<string name="wsperm_body">要打开手机上的文件夹作为工作区,需要先允许本应用访问所有文件。点「去设置」,在列表里打开 Reasonix 的开关,然后返回这里。</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>
|
||||
@@ -48,15 +48,17 @@ 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-studio-host
|
||||
go build -trimpath -ldflags="$ldflags" -o "$out" ./cmd/reasonix
|
||||
|
||||
size=$(wc -c < "$out")
|
||||
echo "kernel ready: $out ($((size / 1024 / 1024)) MiB)"
|
||||
@@ -1,95 +0,0 @@
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
class Provider(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"data":[{"id":"smoke-model","object":"model"}]}')
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
|
||||
android = Path(__file__).resolve().parents[1]
|
||||
kernel = Path(os.environ.get("KERNEL_BIN", android.parent / "reasonix-verify.exe")).resolve()
|
||||
page = android / "app/src/main/assets/page"
|
||||
if not kernel.is_file() or not (page / "index.html").is_file():
|
||||
raise SystemExit("Build ./cmd/reasonix-studio-host as KERNEL_BIN and run sync-page.sh first.")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="reasonix-android-host-") as temporary:
|
||||
home = Path(temporary) / "home"
|
||||
workspace = Path(temporary) / "workspace"
|
||||
home.mkdir()
|
||||
workspace.mkdir()
|
||||
(home / "config.toml").write_text("providers = []\n", encoding="utf-8")
|
||||
env = {**os.environ, "REASONIX_HOME": str(home), "REASONIX_STATE_HOME": str(home),
|
||||
"REASONIX_CREDENTIALS_STORE": "file", "HOME": str(home)}
|
||||
logs = open(Path(temporary) / "host.log", "w", encoding="utf-8")
|
||||
child = subprocess.Popen([str(kernel), "-page", str(page)], cwd=workspace, env=env,
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=logs, text=True)
|
||||
provider = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Provider)
|
||||
threading.Thread(target=provider.serve_forever, daemon=True).start()
|
||||
try:
|
||||
announced = queue.Queue()
|
||||
threading.Thread(target=lambda: announced.put(child.stdout.readline()), daemon=True).start()
|
||||
connection = json.loads(announced.get(timeout=30))
|
||||
origin = connection["origin"]
|
||||
address = urllib.parse.urlsplit(origin)
|
||||
assert connection["version"] == 1 and address.hostname == "127.0.0.1" and address.scheme == "http"
|
||||
|
||||
def request(path, body=None, authenticated=True, request_origin=None):
|
||||
headers = {"Content-Type": "application/json", "Origin": request_origin or origin}
|
||||
if authenticated:
|
||||
headers["Cookie"] = "reasonix_token=" + connection["token"]
|
||||
payload = None if body is None else json.dumps(body).encode()
|
||||
req = urllib.request.Request(origin + path, data=payload, headers=headers)
|
||||
try:
|
||||
response = urllib.request.urlopen(req, timeout=30)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
raw = response.read()
|
||||
return response.status, json.loads(raw) if "json" in response.headers.get("Content-Type", "") else raw
|
||||
|
||||
assert request("/runtimes", authenticated=False)[0] == 403
|
||||
assert request("/runtimes", request_origin="https://untrusted.invalid")[0] == 403
|
||||
assert request("/")[0] == 200
|
||||
status, runtimes = request("/runtimes")
|
||||
assert status == 200
|
||||
entries = runtimes if isinstance(runtimes, list) else runtimes["runtimes"]
|
||||
route = "/rt/" + entries[0]["id"]
|
||||
assert request(route + "/provider-setup")[1]["required"]
|
||||
endpoint = "http://127.0.0.1:" + str(provider.server_port)
|
||||
status, probe = request(route + "/providers/probe", {"baseUrl": endpoint, "apiKey": "sk-smoke"})
|
||||
assert status == 200 and "smoke-model" in probe["models"], (status, probe)
|
||||
status, saved = request(route + "/providers", {
|
||||
"name": "smoke", "kind": "openai", "baseUrl": endpoint, "apiKey": "sk-smoke",
|
||||
"models": ["smoke-model"], "default": "smoke-model",
|
||||
})
|
||||
assert status == 200, (status, saved)
|
||||
status, setup = request(route + "/provider-setup")
|
||||
assert status == 404 or not setup.get("required"), (status, setup)
|
||||
print("Android host: page, authentication, origin guard, provider probe, save and activation passed.")
|
||||
finally:
|
||||
child.stdin.close()
|
||||
try:
|
||||
child.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.wait()
|
||||
child.stdout.close()
|
||||
logs.close()
|
||||
provider.shutdown()
|
||||
provider.server_close()
|
||||
@@ -1,72 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
const serial = process.argv[2];
|
||||
assert(serial, "Usage: node android/scripts/verify-keyboard.mjs <adb-serial> [package]");
|
||||
const pkg = process.argv[3] || "io.reasonix.mobile.app.debug";
|
||||
const sdk = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
||||
const binary = process.platform === "win32" ? "adb.exe" : "adb";
|
||||
const adb = sdk ? path.join(sdk, "platform-tools", binary) : binary;
|
||||
const run = (...args) => execFileSync(adb, ["-s", serial, ...args], { encoding: "utf8", timeout: 15000 });
|
||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const activity = run("shell", "dumpsys", "activity", "activities");
|
||||
assert(activity.split("\n").some((line) => line.includes("topResumedActivity") && line.includes(pkg) && line.includes("MainActivity")), "Unlock the phone and open Reasonix first");
|
||||
const pid = run("shell", "pidof", pkg).trim();
|
||||
const port = run("forward", "tcp:0", `localabstract:webview_devtools_remote_${pid}`).trim();
|
||||
let ws;
|
||||
try {
|
||||
const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(8000) })).json();
|
||||
const target = targets.find((entry) => entry.title === "Reasonix Studio");
|
||||
assert(target, "Reasonix Studio WebView is unavailable");
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => { ws.addEventListener("open", resolve, { once: true }); ws.addEventListener("error", reject, { once: true }); });
|
||||
const pending = new Map();
|
||||
let id = 0;
|
||||
ws.addEventListener("message", (event) => {
|
||||
const reply = JSON.parse(event.data);
|
||||
if (reply.id) pending.get(reply.id)?.(reply);
|
||||
});
|
||||
const call = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const request = ++id;
|
||||
const timer = setTimeout(() => { pending.delete(request); reject(new Error(`${method} timed out`)); }, 8000);
|
||||
pending.set(request, (reply) => {
|
||||
clearTimeout(timer); pending.delete(request);
|
||||
if (reply.error || reply.result?.exceptionDetails) reject(new Error(`${method} failed`));
|
||||
else resolve(reply.result);
|
||||
});
|
||||
ws.send(JSON.stringify({ id: request, method, params }));
|
||||
});
|
||||
const evaluate = async (expression) => (await call("Runtime.evaluate", { expression, returnByValue: true })).result.value;
|
||||
const shown = () => /mInputShown=true/.test(run("shell", "dumpsys", "input_method"));
|
||||
if (shown()) { run("shell", "input", "keyevent", "4"); await pause(500); }
|
||||
const measure = () => evaluate(`(() => {
|
||||
const input = document.querySelector('.compose textarea');
|
||||
const send = document.querySelector('[data-action="session.send"]');
|
||||
return { height: innerHeight, overflow: document.documentElement.scrollWidth - innerWidth,
|
||||
input: input?.getBoundingClientRect().toJSON(), send: send?.getBoundingClientRect().toJSON() };
|
||||
})()`);
|
||||
const before = await measure();
|
||||
assert(before.input?.width > 0, "Open a conversation with its composer visible");
|
||||
try {
|
||||
const point = { x: before.input.x + before.input.width / 2, y: before.input.y + before.input.height / 2 };
|
||||
await call("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [point] });
|
||||
await call("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
let after;
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
await pause(250); after = await measure();
|
||||
if (before.height - after.height > 100) break;
|
||||
}
|
||||
assert(shown(), "The software keyboard did not open");
|
||||
assert(before.height - after.height > 100, `Keyboard covers the page: viewport stayed ${before.height} -> ${after.height}`);
|
||||
assert(after.input.top >= 0 && after.input.bottom <= after.height, "Input is outside the keyboard viewport");
|
||||
assert(after.send.top >= 0 && after.send.bottom <= after.height, "Send button is outside the keyboard viewport");
|
||||
assert(after.overflow <= 1, "Keyboard causes horizontal overflow");
|
||||
console.log(JSON.stringify({ result: "passed", before: before.height, keyboard: after.height, inputBottom: after.input.bottom, sendBottom: after.send.bottom }));
|
||||
} finally {
|
||||
if (shown()) run("shell", "input", "keyevent", "4");
|
||||
}
|
||||
} finally {
|
||||
ws?.close();
|
||||
run("forward", "--remove", `tcp:${port}`);
|
||||
}
|
||||
@@ -1,4 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Reproduce what `Kernel.kt` does, on this machine, against the page that is
|
||||
# actually packaged into the APK.
|
||||
#
|
||||
# This is the closest thing to an end-to-end test that runs without a phone: the
|
||||
# launch flags, the token file, the port file and the `--page` directory are the
|
||||
# same ones the Kotlin shell passes, and the page is read out of
|
||||
# `app/src/main/assets/page` rather than the frontend's own `dist/`. If this
|
||||
# serves a working interface, the only untested part is Android itself.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python "$here/verify-host.py"
|
||||
android="$here/.."
|
||||
page="$android/app/src/main/assets/page"
|
||||
kernel="${KERNEL_BIN:-$android/../reasonix-verify.exe}"
|
||||
|
||||
work="${VERIFY_DIR:-/tmp/reasonix-verify}"
|
||||
rm -rf "$work"
|
||||
mkdir -p "$work/home/remote"
|
||||
|
||||
if [ ! -f "$kernel" ]; then
|
||||
echo "no runnable kernel; build one first:" >&2
|
||||
echo " (cd .. && go build -o reasonix-verify.exe ./cmd/reasonix)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$page/index.html" ]; then
|
||||
echo "no packaged page: $page/index.html" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 32 random bytes as hex — the same shape `Kernel.mintToken` writes.
|
||||
token=$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')
|
||||
# The kernel refuses a token file other users can read.
|
||||
(umask 077; printf '%s\n' "$token" > "$work/home/remote/launch.token")
|
||||
|
||||
export REASONIX_HOME="$work/home"
|
||||
export HOME="$work/home"
|
||||
export TMPDIR="$work"
|
||||
|
||||
echo "kernel: $kernel"
|
||||
echo "page: $page"
|
||||
echo "token: ${token:0:8}... (${#token} chars)"
|
||||
|
||||
"$kernel" serve \
|
||||
--addr 127.0.0.1:0 \
|
||||
--auth token \
|
||||
--token-file "$work/home/remote/launch.token" \
|
||||
--port-file "$work/home/remote/serve.port" \
|
||||
--pid-file "$work/home/remote/serve.pid" \
|
||||
--no-open \
|
||||
-page "$page" \
|
||||
> "$work/kernel.log" 2>&1 &
|
||||
echo $! > "$work/kernel.pid"
|
||||
|
||||
# Wait for the port file the kernel writes after binding, which is exactly the
|
||||
# wait `Kernel.awaitPort` performs.
|
||||
for _ in $(seq 1 150); do
|
||||
if [ -s "$work/home/remote/serve.port" ]; then break; fi
|
||||
if ! kill -0 "$(cat "$work/kernel.pid")" 2>/dev/null; then
|
||||
echo "the kernel exited; log:" >&2
|
||||
cat "$work/kernel.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
if [ ! -s "$work/home/remote/serve.port" ]; then
|
||||
echo "the kernel reported no listening address; log:" >&2
|
||||
cat "$work/kernel.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
port=$(cat "$work/home/remote/serve.port")
|
||||
echo "listening on: $port"
|
||||
|
||||
# The two things the WebView will do: fetch the page, and exist on the origin.
|
||||
probe=$(curl -s -o /dev/null -w '%{http_code}' "http://$port/")
|
||||
echo "GET / -> $probe"
|
||||
if [ "$probe" != "200" ]; then
|
||||
echo "the page did not answer; log:" >&2
|
||||
cat "$work/kernel.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$work/entry.txt" <<EOF
|
||||
origin=http://$port
|
||||
entry=http://$port/#token=$token
|
||||
token=$token
|
||||
EOF
|
||||
|
||||
echo "entry written: $work/entry.txt"
|
||||
echo "kernel log (first 10 lines):"
|
||||
head -10 "$work/kernel.log"
|
||||
@@ -1,11 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"reasonix/internal/base/netdns"
|
||||
)
|
||||
|
||||
func init() {
|
||||
netdns.Install(netdns.Parse(os.Getenv(netdns.EnvVar)))
|
||||
}
|
||||
@@ -28,7 +28,7 @@ class HostStartError extends Error {
|
||||
function start(binary, args, { onStderr, onExit, onAct, onSlow, systemLanguage, timeoutMs = HANDSHAKE_TIMEOUT_MS } = {}) {
|
||||
const env = { ...process.env };
|
||||
if (systemLanguage) env.REASONIX_SYSTEM_LANG = systemLanguage;
|
||||
const child = spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"], env });
|
||||
const child = spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"], env, windowsHide: true });
|
||||
child.stderr.setEncoding("utf8");
|
||||
if (onStderr) child.stderr.on("data", onStderr);
|
||||
if (onExit) child.on("exit", onExit);
|
||||
|
||||
@@ -6,9 +6,9 @@ const { execFileSync } = require("node:child_process");
|
||||
// one Studio, canonicalized where it lives. The kernel answers it, because the
|
||||
// home is resolved from an environment and a platform default this process
|
||||
// does not own.
|
||||
function instanceID(binary) {
|
||||
function instanceID(binary, run = execFileSync) {
|
||||
try {
|
||||
return execFileSync(binary, ["-instance-id"], { encoding: "utf8", timeout: 10000 }).trim();
|
||||
return run(binary, ["-instance-id"], { encoding: "utf8", timeout: 10000, windowsHide: true }).trim();
|
||||
} catch {
|
||||
// A kernel that cannot be run at all is a launch that is going to fail with
|
||||
// a better message in a moment. Refusing to start here would report it as
|
||||
|
||||
@@ -625,7 +625,7 @@ test("Windows is given the format it reads the small sizes from", () => {
|
||||
assert.equal(iconFile("linux"), "icon.png");
|
||||
});
|
||||
|
||||
const { profileFor } = require("../src/instance.js");
|
||||
const { profileFor, instanceID } = require("../src/instance.js");
|
||||
|
||||
// Two homes are two Studios, and the profile is what carries that into the
|
||||
// platform's own lock. Sharing one would make the second launch look like a
|
||||
@@ -641,6 +641,14 @@ test("each instance gets a profile of its own", () => {
|
||||
assert.equal(profileFor(base, ""), base);
|
||||
});
|
||||
|
||||
test("the instance probe runs the kernel without a console window of its own", () => {
|
||||
let options;
|
||||
const run = (binary, args, opts) => { options = { binary, args, opts }; return "abc123\n"; };
|
||||
assert.equal(instanceID("host", run), "abc123");
|
||||
assert.deepEqual(options.args, ["-instance-id"]);
|
||||
assert.equal(options.opts.windowsHide, true);
|
||||
});
|
||||
|
||||
// A program that cannot be started emits "error" and never "exit". Waiting for
|
||||
// the handshake instead spent the full timeout and then blamed the kernel for
|
||||
// saying nothing, which is where the launch failure on Windows hid: go build
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const shots = process.env.AGENT_SETTINGS_SHOTS;
|
||||
if (shots) await mkdir(shots, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const errors = [];
|
||||
try {
|
||||
for (const language of ["zh", "en"]) {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const width of [390, 736, 1024, 1600]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(10000);
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const url = new URL(base);
|
||||
url.searchParams.set("pref", language);
|
||||
url.searchParams.set("turns", "2");
|
||||
await page.goto(url.href, { waitUntil: "networkidle" });
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
|
||||
await page.locator('[data-action="settings.section"][data-value="session"]').click();
|
||||
await page.locator('#set-plan-approval [role="radio"]').first().waitFor();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
assert.equal(await page.locator(".page-index").count(), 0);
|
||||
assert.equal(await page.locator(".prefs-col > .prefs-card").count(), 5);
|
||||
assert.equal(await page.locator("#set-session-ref").isVisible(), true);
|
||||
assert.equal(await page.locator(".prefs-col > details").count(), 0);
|
||||
const layout = await page.evaluate(() => {
|
||||
const col = document.querySelector(".prefs-col");
|
||||
const rows = [...col.querySelectorAll(".srow")].filter((el) => el.checkVisibility());
|
||||
const cards = [...col.querySelectorAll(".prefs-card")].filter((el) => el.checkVisibility());
|
||||
return {
|
||||
columns: getComputedStyle(col).columnCount,
|
||||
lefts: cards.map((el) => Math.round(el.getBoundingClientRect().left)),
|
||||
clipped: rows.filter((el) => {
|
||||
const desc = el.querySelector(".srow-desc");
|
||||
return desc && (getComputedStyle(desc).whiteSpace === "nowrap" || desc.scrollWidth > desc.clientWidth + 1);
|
||||
}).length,
|
||||
overflow: col.scrollWidth - col.clientWidth,
|
||||
note: getComputedStyle(document.querySelector("#set-persona .apply")).display,
|
||||
};
|
||||
});
|
||||
assert.notEqual(layout.columns, "2");
|
||||
assert.equal(new Set(layout.lefts).size, 1);
|
||||
assert.equal(layout.clipped, 0, JSON.stringify(layout));
|
||||
assert.ok(layout.overflow <= 1, JSON.stringify(layout));
|
||||
assert.notEqual(layout.note, "none");
|
||||
if (shots && language === "zh" && [390, 1024].includes(width)) {
|
||||
await page.screenshot({ path: path.join(shots, `agent-settings-${width}-${scheme}.png`) });
|
||||
}
|
||||
assert.ok(await page.locator(".prefs-col").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
|
||||
await page.locator('#set-peers [data-action="settings.open-page"]').click();
|
||||
await page.locator('.deep-page[data-setting="peers"]').waitFor({ state: "visible" });
|
||||
assert.equal(await page.locator(".page-index").count(), 0);
|
||||
await page.locator('[data-action="settings.close-page"]').click();
|
||||
await page.locator("#set-plan-approval").waitFor({ state: "visible" });
|
||||
await page.locator('[data-action-change="settings.search"]').fill("session-ref");
|
||||
await page.locator('.prefs-found [data-target="session-ref"]').click();
|
||||
await page.locator("#set-session-ref").waitFor({ state: "visible" });
|
||||
await page.locator('[data-action-change="settings.search"]').fill("persona");
|
||||
await page.locator('.prefs-found [data-target="persona"]').click();
|
||||
await page.locator('.deep-page #set-persona[data-landed]').waitFor({ state: "visible" });
|
||||
assert.equal(await page.locator("#set-persona").count(), 1);
|
||||
console.log(`PASS agent settings: ${language} ${scheme} ${width}px`);
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -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 = query.has("signedin") ? { signedIn: true, user: { handle: "demo", email: "[email protected]", label: "demo" } } : await super.account();
|
||||
const state = await super.account();
|
||||
const label = query.get("accountLabel");
|
||||
return label && state.signedIn && state.user ? { ...state, user: { ...state.user, label } } : state;
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const base = process.env.PERF_URL ?? "http://127.0.0.1:4399/perf.html";
|
||||
try {
|
||||
for (const language of ["zh", "en"]) {
|
||||
for (const [width, height] of [[320, 640], [390, 844], [430, 932], [782, 390], [1044, 884]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height }, hasTouch: width < 900, reducedMotion: "reduce" });
|
||||
const url = new URL(base);
|
||||
url.searchParams.set("onboarding", "1");
|
||||
url.searchParams.set("pref", language);
|
||||
await page.goto(url.href);
|
||||
await page.locator('[data-action="onboarding.begin"]').click();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const check = async (step) => {
|
||||
const clipped = await page.locator(".onb").evaluate((root) => {
|
||||
const shell = root.parentElement.getBoundingClientRect();
|
||||
return [...root.querySelectorAll("input, button, h1, .onb-steps, .onb-field, .onb-model")]
|
||||
.filter((el) => el.checkVisibility()).flatMap((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.left < shell.left - 1 || r.right > shell.right + 1 || (el.tagName !== "INPUT" && el.scrollWidth > el.clientWidth + 2)
|
||||
? [{ tag: el.tagName, class: el.className, left: r.left, right: r.right, overflow: el.scrollWidth - el.clientWidth }] : [];
|
||||
});
|
||||
});
|
||||
assert.deepEqual(clipped, [], `${language} ${width}x${height} ${step}: ${JSON.stringify(clipped)}`);
|
||||
};
|
||||
await check("connect");
|
||||
await page.locator("#onb-key").fill("sk-layout-test");
|
||||
if (width < 600) {
|
||||
await page.setViewportSize({ width, height: 360 });
|
||||
await check("keyboard");
|
||||
}
|
||||
await page.locator(".onb-acts .onb-go").click();
|
||||
await page.locator('.onb-card[data-step="model"]').waitFor();
|
||||
await check("model");
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.locator('[data-action="onboarding.back"]').click();
|
||||
assert.equal(await page.locator("#onb-key").inputValue(), "sk-layout-test");
|
||||
await page.locator('[data-action="onboarding.service"]').last().click();
|
||||
await page.locator("#onb-url").fill("https://relay.example/a/long/service/address/v1");
|
||||
await check("custom service");
|
||||
await page.locator(".onb-acts .onb-go").click();
|
||||
await page.locator('.onb-card[data-step="model"]').waitFor();
|
||||
await page.locator('[data-action="onboarding.start"]').click();
|
||||
await page.locator('.onb-card[data-step="project"]').waitFor();
|
||||
await check("project");
|
||||
if (width < 900) {
|
||||
assert.equal(await page.locator('[data-action="onboarding.start"]').isEnabled(), true);
|
||||
assert.equal(await page.getByText(language === "zh" ? "把文件夹拖到这里" : "Drop a folder here").count(), 0);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
console.log("Onboarding: both languages, phone, keyboard, landscape and desktop passed.");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const url = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2";
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const width of [390, 1044]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 884 }, reducedMotion: "reduce" });
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
const sidebar = page.getByRole("toolbar", { name: "侧栏入口" });
|
||||
for (const name of [/^用量/, /^扩展$/, /^设置$/]) {
|
||||
const drawer = page.getByRole("button", { name: "会话列表", exact: true });
|
||||
const expand = page.getByRole("button", { name: "展开工作区栏", exact: true });
|
||||
if (await drawer.isVisible() && await drawer.getAttribute("aria-pressed") !== "true") await drawer.click();
|
||||
else if (await expand.isVisible()) await expand.click();
|
||||
await sidebar.getByRole("button", { name }).click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
const measure = () => page.evaluate(() => {
|
||||
const sheet = document.querySelector(".prefs-sheet");
|
||||
const bounds = (selector) => {
|
||||
const r = sheet.querySelector(selector).getBoundingClientRect();
|
||||
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
|
||||
};
|
||||
const button = bounds(".prefs-actions button");
|
||||
const hit = document.elementFromPoint((button.left + button.right) / 2, (button.top + button.bottom) / 2);
|
||||
return { solo: sheet.hasAttribute("data-solo"), title: bounds(".prefs-hd h1"), actions: bounds(".prefs-actions"), button, main: bounds(".prefs-main"), hitClose: !!hit?.closest('[data-action="settings.close"]') };
|
||||
});
|
||||
const check = (r) => {
|
||||
assert.ok(r.hitClose, "the close button stays visible and clickable above the content");
|
||||
assert.ok(r.actions.top >= r.title.top - 1 && r.actions.bottom <= r.title.bottom + 1, "the close area stays inside the title row");
|
||||
if (r.solo) {
|
||||
assert.ok(r.title.right >= r.button.right, "the title background covers the close area");
|
||||
assert.ok(Math.abs((r.button.top + r.button.bottom - r.title.top - r.title.bottom) / 2) <= 1, "the close button is centred in the title row");
|
||||
}
|
||||
assert.ok(r.button.bottom <= r.main.top || !r.solo, "the close button stays above the scrolling content");
|
||||
};
|
||||
const top = await measure();
|
||||
check(top);
|
||||
await page.locator(".prefs-main").evaluate((el) => { el.scrollTop = el.scrollHeight; });
|
||||
const scrolled = await measure();
|
||||
check(scrolled);
|
||||
assert.deepEqual(scrolled.button, top.button, "scrolling the body leaves the close button in place");
|
||||
await page.getByRole("button", { name: "关闭 Esc", exact: true }).click();
|
||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||
console.log(`PASS ${name}: header and close button at ${width}px`);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const url = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2";
|
||||
const browser = await chromium.launch();
|
||||
const failures = [];
|
||||
const check = async (name, run) => {
|
||||
try { await run(); console.log(`PASS ${name}`); }
|
||||
catch (error) { failures.push(`${name}: ${error.message}`); }
|
||||
};
|
||||
|
||||
async function contained(page, selector) {
|
||||
const boxes = await page.locator(selector).evaluateAll((elements) => elements.map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, width: r.width, height: r.height, vw: innerWidth, vh: innerHeight };
|
||||
}));
|
||||
assert.ok(boxes.length > 0, `${selector} exists`);
|
||||
for (const r of boxes) {
|
||||
assert.ok(r.width > 0 && r.height > 0, `${selector} is visible`);
|
||||
assert.ok(r.x >= 0 && r.right <= r.vw + 1, `${selector} fits horizontally: ${JSON.stringify(r)}`);
|
||||
assert.ok(r.y >= 0 && r.bottom <= r.vh + 1, `${selector} fits vertically: ${JSON.stringify(r)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function header(page) {
|
||||
await contained(page, '.prefs-sheet');
|
||||
await contained(page, '.prefs-actions button');
|
||||
assert.equal(await page.locator('.prefs-sheet').evaluate(el => el.scrollTop), 0, "focus only scrolls the page body");
|
||||
}
|
||||
|
||||
async function scopeRows(page) {
|
||||
const rows = await page.locator('.scope-pick .pk:has(.ct)').evaluateAll(elements => elements.map(el => {
|
||||
const name = el.querySelector('.nm').getBoundingClientRect();
|
||||
const path = el.querySelector('.pt').getBoundingClientRect();
|
||||
const badge = el.querySelector('.ct').getBoundingClientRect();
|
||||
return { name: { left: name.left, right: name.right, bottom: name.bottom }, path: { left: path.left, top: path.top }, badge: { left: badge.left } };
|
||||
}));
|
||||
assert.ok(rows.length > 0);
|
||||
for (const row of rows) {
|
||||
assert.ok(row.name.right <= row.badge.left && Math.abs(row.path.left - row.name.left) < 1 && row.path.top >= row.name.bottom, JSON.stringify(row));
|
||||
}
|
||||
}
|
||||
|
||||
async function open(page, name) {
|
||||
const drawer = page.getByRole("button", { name: "会话列表", exact: true });
|
||||
const expand = page.getByRole("button", { name: "展开工作区栏", exact: true });
|
||||
if (await drawer.isVisible() && await drawer.getAttribute("aria-pressed") !== "true") await drawer.click();
|
||||
else if (await expand.isVisible()) await expand.click();
|
||||
await page.getByRole("toolbar", { name: "侧栏入口" }).getByRole("button", { name }).click();
|
||||
await page.locator('.prefs-sheet').waitFor();
|
||||
}
|
||||
|
||||
async function deep(page, section, id) {
|
||||
await page.getByRole("tab", { name: new RegExp(`^${section}`) }).click();
|
||||
await page.locator(`.prefs-main [data-action="settings.open-page"][data-target="${id}"]`).first().click();
|
||||
await page.locator(`.deep-page[data-setting="${id}"]`).waitFor();
|
||||
}
|
||||
|
||||
try {
|
||||
for (const [width, height] of [[320, 740], [390, 844], [430, 932], [782, 390], [1044, 884], [1440, 900]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height }, reducedMotion: "reduce" });
|
||||
page.on("pageerror", error => failures.push(error.message));
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
const size = `${width}x${height}`;
|
||||
await open(page, /^扩展$/);
|
||||
await page.getByRole("button", { name: "Reasonix", exact: true }).click();
|
||||
await check(`${size}: standalone scope picker`, () => contained(page, '.scope-pick'));
|
||||
await check(`${size}: scope name, path and exception columns`, () => scopeRows(page));
|
||||
await page.getByRole("button", { name: "Reasonix", exact: true }).click();
|
||||
await page.getByRole("button", { name: "添加…", exact: true }).click();
|
||||
await check(`${size}: add menu`, () => contained(page, '.ext-add-menu'));
|
||||
await page.getByRole("menuitem", { name: /接入外部服务/ }).click();
|
||||
await page.getByRole("textbox", { name: "一段 JSON、一行命令,或一个 https 地址" }).fill('https://mcp.example.com/sse');
|
||||
await page.getByRole("button", { name: "查看内容", exact: true }).click();
|
||||
await page.locator('.addsrv[data-stage="confirm"]').waitFor();
|
||||
await check(`${size}: header after focusing MCP form`, () => header(page));
|
||||
await page.getByRole("button", { name: "返回", exact: true }).click();
|
||||
await page.getByRole("button", { name: "取消", exact: true }).click();
|
||||
await page.getByRole("tab", { name: "发现", exact: true }).click();
|
||||
await page.getByRole("button", { name: /^make-ui-not-ai @/ }).click();
|
||||
await check(`${size}: long marketplace source`, async () => {
|
||||
const r = await page.locator('.mkt-detail-meta .mono').evaluate(el => {
|
||||
const r = el.getBoundingClientRect(), parent = el.parentElement.getBoundingClientRect();
|
||||
return { right: r.right, parent: parent.right, scroll: el.scrollWidth, client: el.clientWidth };
|
||||
});
|
||||
assert.ok(r.right <= r.parent + 1 && r.scroll <= r.client + 1, JSON.stringify(r));
|
||||
});
|
||||
await page.getByRole("button", { name: "关闭 Esc", exact: true }).click();
|
||||
await open(page, /^设置$/);
|
||||
await page.getByRole("tab", { name: /^权限与安全/ }).click();
|
||||
await page.getByRole("button", { name: "Reasonix", exact: true }).click();
|
||||
await check(`${size}: settings scope picker`, () => contained(page, '.scope-pick'));
|
||||
await page.getByRole("button", { name: "Reasonix", exact: true }).click();
|
||||
await deep(page, "Agent 行为", "agent-presets");
|
||||
await page.getByRole("button", { name: "新建预设", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "预设名称", exact: true }).fill('Layout audit draft');
|
||||
await page.getByRole("button", { name: "返回预设列表", exact: true }).click();
|
||||
await check(`${size}: discard dialog`, () => contained(page, '.preset-confirm'));
|
||||
await page.getByRole("button", { name: "继续编辑", exact: true }).click();
|
||||
assert.equal(await page.getByRole("textbox", { name: "预设名称", exact: true }).inputValue(), 'Layout audit draft');
|
||||
await page.getByRole("button", { name: "返回预设列表", exact: true }).click();
|
||||
await page.getByRole("button", { name: "放弃更改", exact: true }).click();
|
||||
await deep(page, "数据", "storage");
|
||||
await page.getByRole("button", { name: "选择会话文件…", exact: true }).click();
|
||||
await check(`${size}: import dialog`, () => contained(page, '.ns-dlg'));
|
||||
await page.getByRole("dialog", { name: "导入原生会话", exact: true }).getByRole("button", { name: "取消", exact: true }).click();
|
||||
await check(`${size}: header after modal dismissal`, () => header(page));
|
||||
const signedIn = new URL(url);
|
||||
signedIn.searchParams.set("signedin", "1");
|
||||
await page.goto(signedIn.href, { waitUntil: "networkidle" });
|
||||
await open(page, /^设置$/);
|
||||
await deep(page, "数据", "backup");
|
||||
await page.getByRole("textbox", { name: "备注(可选)", exact: true }).fill('Layout audit backup');
|
||||
await page.getByRole("textbox", { name: "加密口令(至少 10 个字符)", exact: true }).fill('layout-audit-demo');
|
||||
await page.getByRole("textbox", { name: "再输一次", exact: true }).fill('layout-audit-demo');
|
||||
await page.getByRole("button", { name: "备份到账号", exact: true }).click();
|
||||
await page.getByRole("button", { name: "恢复…", exact: true }).click();
|
||||
await check(`${size}: backup passphrase dialog`, () => contained(page, '.bk-dlg'));
|
||||
await page.getByRole("textbox", { name: "加密口令", exact: true }).fill('layout-audit-demo');
|
||||
await page.getByRole("button", { name: "预览差异", exact: true }).click();
|
||||
await page.getByRole("button", { name: "恢复所选 1 项", exact: true }).waitFor();
|
||||
await check(`${size}: backup difference preview`, async () => {
|
||||
await contained(page, '.bk-dlg');
|
||||
assert.ok(await page.locator('.bk-dlg').evaluate(el => el.scrollWidth <= el.clientWidth + 1));
|
||||
});
|
||||
await page.getByRole("dialog", { name: "恢复备份", exact: true }).getByRole("button", { name: "取消", exact: true }).click();
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
assert.deepEqual(failures, [], failures.join('\n'));
|
||||
@@ -13,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"), col: left(".prefs-main .prefs-col") };
|
||||
return { title: left(".prefs-main .prefs-pagetitle"), index: left(".prefs-main .page-index"), col: left(".prefs-main .prefs-col") };
|
||||
});
|
||||
for (const name of ["模型", "连接", "常用"]) {
|
||||
await page.locator(".prefs-nav button", { hasText: new RegExp(`^${name}`) }).first().click();
|
||||
await page.waitForTimeout(400);
|
||||
const at = await lefts();
|
||||
assert.equal(await page.locator(".page-index").count(), 0);
|
||||
assert.equal(at.index, at.title, `${name}: the page index starts where the title does (${JSON.stringify(at)})`);
|
||||
assert.equal(at.col, at.title, `${name}: the cards start where the title does (${JSON.stringify(at)})`);
|
||||
console.log(`PASS ${name}: title and cards start at ${at.title}px without a duplicate index`);
|
||||
console.log(`PASS ${name}: title, index and cards all start at ${at.title}px`);
|
||||
}
|
||||
await page.locator(".prefs-nav button", { hasText: /^操作与性能/ }).first().click();
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
@@ -662,6 +662,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "reasoning.effort", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "reasoning.effort-preview", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "settings.sections-scroll", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "settings.page-index", kind: "navigation", target: "entity", proof: "interaction" },
|
||||
{ id: "files.open", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "palette.pick", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "palette.scope", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
.toast-actions .act[data-primary] { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
|
||||
.toast[data-stacked] .toast-actions { margin: 4px 0 0 26px; gap: 6px; }
|
||||
.preset-confirm-veil { background: color-mix(in oklab, var(--page) 80%, transparent); }
|
||||
.preset-confirm { width: 380px; max-width: 100%; max-height: 100%; padding: 0; overflow: auto; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); }
|
||||
.preset-confirm { width: 380px; max-width: 100%; padding: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); }
|
||||
.preset-confirm > header { display: flex; align-items: center; gap: 8px; padding: 14px 16px 0; }
|
||||
.preset-confirm > header h3 { flex: 1; margin: 0; font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); }
|
||||
.preset-confirm .pc-x { width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--muted); display: inline-grid; place-items: center; flex: none; cursor: pointer; }
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
.onb-shell { grid-template-columns: minmax(0, 1fr); }
|
||||
.onb, .onb-field, .onb-field > input { min-width: 0; }
|
||||
.onb-field > label { flex-wrap: wrap; }
|
||||
.onb-flow .onb-card { display: flex; flex-direction: column; gap: 14px; animation: onb-step-in var(--t-panel) var(--ease-out, ease-out) both; }
|
||||
.onb-flow .onb-card[data-back] { animation-name: onb-step-back; }
|
||||
.onb-flow .onb-card[data-step="welcome"] { animation: none; }
|
||||
@@ -119,9 +116,6 @@
|
||||
.onb-drop > .studio-icon { width: 22px; height: 22px; color: var(--faint); }
|
||||
.onb-drop[data-over] { border-color: var(--accent); background: var(--overlay); animation: onb-dash 1.2s linear infinite; background-image: repeating-linear-gradient(45deg, transparent 0 8px, color-mix(in srgb, var(--accent) 6%, transparent) 8px 16px); background-size: 22.6px 22.6px; }
|
||||
.onb-drop[data-chosen] { border-style: solid; border-color: var(--accent); background: var(--raised); }
|
||||
.onb-drop[data-touch] { border-style: solid; align-items: stretch; min-height: 0; padding: 16px; gap: 16px; }
|
||||
.onb-drop[data-touch] > button { min-height: 44px; border: 1px solid var(--border); border-radius: var(--r-md); }
|
||||
.onb-drop[data-touch] .onb-chosen > :is(b, small) { overflow-wrap: anywhere; white-space: normal; }
|
||||
.onb-chosen { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 2px 10px; max-width: 100%; text-align: start; animation: onb-land 260ms var(--ease-out) both; }
|
||||
.onb-chosen > .studio-icon { grid-row: 1 / span 2; width: 22px; height: 22px; color: var(--text); }
|
||||
.onb-chosen > b { color: var(--text); font: var(--w-semi) var(--fs-ui) var(--ui); }
|
||||
@@ -135,37 +129,3 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.onb-drop[data-over], .onb-chosen, .onb-stage[data-leaving] .onb-shell, .compose[data-rise] { animation: none !important; }
|
||||
}
|
||||
|
||||
@container onboarding (max-width: 600px) {
|
||||
.onb-brandbar { height: 56px; padding-inline: 24px; }
|
||||
.onb-shell { width: calc(100% - 24px); margin: 16px auto; border-radius: var(--r-lg); }
|
||||
.onb { padding: 20px 16px; }
|
||||
.onb-flow .onb-card { gap: 16px; }
|
||||
.onb-steps { gap: 8px; margin-bottom: 0; padding-bottom: 14px; border-bottom: 1px solid var(--hair); }
|
||||
.onb-steps ol { gap: 8px; }
|
||||
.onb-steps li { flex: none; gap: 6px; }
|
||||
.onb-steps li[data-state="current"] { flex: 1; white-space: normal; }
|
||||
.onb-steps li:not([data-state="current"]) span { display: none; }
|
||||
.onb-steps li + li::before { display: none; }
|
||||
.onb-steps .onb-skip { flex: none; min-height: 44px; }
|
||||
.onb-t { font-size: var(--fs-title); line-height: 1.35; overflow-wrap: anywhere; }
|
||||
.onb-s { margin: -8px 0 0; }
|
||||
.onb-services { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.onb-service { min-height: 60px; padding: 10px; justify-content: center; gap: 4px; }
|
||||
.onb-service b { flex-wrap: wrap; overflow-wrap: anywhere; }
|
||||
.onb-field { gap: 8px; }
|
||||
.onb-field > label { gap: 8px; }
|
||||
.onb-field > label code { display: none; }
|
||||
.onb-getkey { margin-left: 0; min-height: 32px; }
|
||||
.onb-field > input { min-height: 48px; font-size: 16px; }
|
||||
.onb-flow .onb-acts { display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; margin-top: 4px; }
|
||||
.onb-flow .onb-acts .onb-go { width: 100%; min-width: 0; min-height: 48px; }
|
||||
.onb-flow .onb-acts .onb-skip { min-height: 44px; }
|
||||
.onb-flow .onb-note, .onb-flow .onb-err { overflow-wrap: anywhere; }
|
||||
.onb-model { flex-wrap: wrap; min-width: 0; min-height: 52px; }
|
||||
.onb-model b { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.onb-model small { flex-basis: 100%; padding-left: 24px; margin: 0; }
|
||||
.onb-welcome { padding: 24px 0 8px; }
|
||||
.onb-welcome .onb-acts { width: 100%; }
|
||||
.onb-foot { margin-top: auto; padding: 8px 24px 20px; text-align: center; white-space: normal; }
|
||||
}
|
||||
@@ -1,7 +1,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 64px 8px 16px; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-actions { grid-column: 1; grid-row: 1; justify-self: end; align-self: center; margin: 0; padding: 8px 10px; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-hd h1 { grid-column: 1; grid-row: 1; padding: 14px 16px 8px; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-actions { grid-column: 1; grid-row: 1; justify-self: end; align-self: center; padding: 8px 10px; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-find { grid-column: 1; grid-row: 2; width: calc(100% - 24px); margin: 0 12px 8px; }
|
||||
[data-fold~="phone"] .prefs-sheet > .prefs-hd > .prefs-find { grid-column: 1; grid-row: 2; width: calc(100% - 24px); margin: 0 12px 8px; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-nav { grid-column: 1; grid-row: 3; display: flex; flex-direction: row; gap: 4px; padding: 4px 28px 8px 10px; mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--hair); scrollbar-width: none; }
|
||||
@@ -20,8 +20,6 @@
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-main .prefs-card > .grp > .grp-items { grid-column: 1; grid-row: auto; margin-top: 8px; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-main .srv > summary, [data-fold~="phone"] .prefs-sheet .prefs-main .srv > .srv-hd { flex-wrap: wrap; row-gap: 8px; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-main .srv .srv-sub { flex-wrap: wrap; white-space: normal; overflow: visible; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-main .srv .srv-sub > * { max-width: 100%; white-space: normal; overflow: visible; overflow-wrap: anywhere; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-main .srv .srv-sub > .meta { flex-basis: 100%; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-main .skrow { flex-wrap: wrap; row-gap: 6px; }
|
||||
[data-fold~="phone"] .prefs-sheet .prefs-main .skrow .sk-main { flex: 1 1 12em; min-width: min(12em, 100%); }
|
||||
[data-fold~="phone"] .prefs-sheet::before, [data-fold~="phone"] .prefs-sheet::after { display: none; }
|
||||
@@ -36,9 +36,8 @@
|
||||
.bk-chip:has(> input:disabled) { opacity: .55; cursor: default; }
|
||||
.bk .bk-warn { color: var(--warn-ink); }
|
||||
.bk .bk-wait { color: var(--warn-ink); }
|
||||
.bk-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 10px; }
|
||||
.bk-fields > label { display: block; min-width: 0; }
|
||||
.bk-fields > label.full { grid-column: 1 / -1; }
|
||||
.bk-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.bk-fields > label { display: block; }
|
||||
.bk-fields > label > span { display: block; margin: 10px 0 6px; font: var(--w-med) var(--fs-meta)/1.4 var(--ui); color: var(--muted); }
|
||||
.bk-fields input { width: 100%; height: 32px; box-sizing: border-box; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface); color: var(--text); font: var(--w-reg) var(--fs-ui) var(--ui); }
|
||||
.ns-done, .ns-fail { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding: 10px 12px; border-radius: var(--r-md); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); }
|
||||
@@ -47,11 +46,11 @@
|
||||
.ns-fail { background: color-mix(in srgb, var(--warn) 12%, transparent); }
|
||||
.ns-fail .studio-icon { color: var(--warn-ink); }
|
||||
.ns-done .studio-icon, .ns-fail .studio-icon { flex: none; width: 16px; height: 16px; }
|
||||
.ns-veil { position: fixed; inset: 0; z-index: 60; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); place-items: center; padding: 20px; background: color-mix(in oklab, var(--page) 80%, transparent); }
|
||||
.ns-dlg { width: 440px; max-width: 100%; max-height: 100%; overflow: auto; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); }
|
||||
.ns-veil { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 20px; background: color-mix(in oklab, var(--page) 80%, transparent); }
|
||||
.ns-dlg { width: 440px; max-width: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); }
|
||||
.ns-dlg > header { display: flex; align-items: center; gap: 8px; padding: 14px 16px 0; }
|
||||
.ns-dlg > header h3 { flex: 1; margin: 0; font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); }
|
||||
.ns-dlg .ns-x { flex: none; width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--muted); display: inline-grid; place-items: center; cursor: pointer; }
|
||||
.ns-dlg .ns-x { width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--muted); display: inline-grid; place-items: center; cursor: pointer; }
|
||||
.ns-dlg .ns-x:hover { background: var(--overlay); color: var(--text); }
|
||||
.ns-dlg > p { margin: 0; padding: 10px 16px 0; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.7 var(--ui); }
|
||||
.ns-dlg .ns-drop { display: flex; flex-direction: column; align-items: center; gap: 8px; margin: 12px 16px 16px; padding: 22px 16px; border: 1px dashed var(--border); border-radius: var(--r-md); color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); }
|
||||
|
||||
@@ -32,8 +32,8 @@ describe("the export chips follow the board", () => {
|
||||
expect(rule(".bk-chip[data-on] .box")).toMatch(/background:\s*var\(--accent\)/);
|
||||
});
|
||||
|
||||
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\)\)/);
|
||||
it("lays the two passphrase fields side by side", () => {
|
||||
expect(rule(".bk-fields")).toMatch(/grid-template-columns:\s*1fr 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('.prefs-actions')).toMatch(/z-index:\s*6/);
|
||||
expect(rule(':root:not([data-fold~="phone"]) .prefs-actions')).toMatch(/z-index:\s*6/);
|
||||
});
|
||||
});
|
||||
@@ -16,27 +16,30 @@
|
||||
.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 6px; }
|
||||
.prefs-card > header h3 { margin: 0; font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); }
|
||||
.prefs-card > header { padding: 0; margin: 0 0 4px; }
|
||||
.prefs-card > header h3 { margin: 0; font: var(--w-semi) var(--fs-ui)/1.4 var(--ui); }
|
||||
.prefs-card > header p { display: none; }
|
||||
.prefs-card > .grp { margin: 0; padding: 10px 0; border: 0; border-top: 1px solid var(--hair); border-radius: 0; background: none; box-shadow: none; column-gap: 16px; }
|
||||
.prefs-card > .grp .grp-hd 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(--muted); max-width: none; }
|
||||
.prefs-card > .grp .hint { margin: 2px 0 0; font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); color: var(--faint); max-width: none; }
|
||||
.prefs-card > .grp .apply { margin: 2px 0 0; font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); color: var(--faint); }
|
||||
.prefs-card .seg { padding: 2px; gap: 2px; border-radius: var(--r-sm); background: var(--overlay); }
|
||||
.prefs-card .seg button { height: 26px; min-height: 26px; padding: 0 10px; border-radius: var(--r-xs); font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted); }
|
||||
.prefs-card .seg button[aria-pressed="true"], .prefs-card .seg button[aria-checked="true"] { background: var(--raised); color: var(--text); font-weight: var(--w-med); box-shadow: 0 1px 2px rgb(0 0 0 / .07); }
|
||||
.prefs-card select { width: 140px; height: 30px; padding: 0 10px; border-radius: var(--r-sm); border: 1px solid var(--border); background-color: var(--surface); font: var(--w-reg) var(--fs-ui) var(--ui); }
|
||||
.prefs-card select.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; }
|
||||
.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; }
|
||||
@container prefsmain (min-width: 860px) {
|
||||
.prefs-col[data-cols="2"] { max-width: 1400px; column-count: 2; column-gap: 16px; }
|
||||
.prefs-col[data-cols="2"] > .prefs-card { break-inside: avoid; }
|
||||
}
|
||||
.prefs-card > .srow, .grp[data-setting="global-shortcut"] .srow { display: flex; align-items: center; gap: 16px; padding: 10px 0; border-top: 1px solid var(--hair); }
|
||||
.srow-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.srow-name { color: var(--text); font: var(--w-med) var(--fs-ui)/1.4 var(--ui); }
|
||||
.srow-desc { color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
|
||||
.srow-desc { color: var(--faint); 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; max-width: 100%; flex-wrap: wrap; }
|
||||
.srow-ctl { flex: none; display: flex; align-items: center; gap: 8px; }
|
||||
.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; }
|
||||
@@ -55,8 +58,8 @@
|
||||
.prefs-col .prefs-card .seg button { font-weight: var(--w-reg); }
|
||||
.prefs-col .prefs-card .seg button[aria-pressed="true"], .prefs-col .prefs-card .seg button[aria-checked="true"] { font-weight: var(--w-med); }
|
||||
.prefs-main { grid-row: 1 / span 3; }
|
||||
.prefs-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-actions { grid-row: 1; position: absolute; top: 10px; right: 14px; z-index: 6; padding: 0; }
|
||||
.prefs-pagetitle { padding-right: 150px; }
|
||||
.prefs-hd h1 { padding: 14px 18px 10px; }
|
||||
.prefs-sheet > .prefs-hd > .prefs-find { margin: 0 10px 8px; width: calc(100% - 20px); }
|
||||
.prefs-nav { padding: 0 10px 14px; gap: 2px; }
|
||||
@@ -75,7 +78,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; flex-wrap: wrap; align-items: center; gap: 10px 12px; margin-bottom: 14px; }
|
||||
.ext-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
|
||||
.ext-bar .mkt-tabs { margin-bottom: 0; }
|
||||
.ext-add { position: relative; margin-inline-start: auto; }
|
||||
.ext-add-btn {
|
||||
@@ -153,7 +156,6 @@
|
||||
.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); }
|
||||
@@ -274,8 +276,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 / -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-hd h1 { grid-column: 1; grid-row: 1; padding: 16px 22px 8px; font-size: var(--fs-title); }
|
||||
.prefs-sheet[data-solo] .prefs-actions { grid-column: 2; grid-row: 1; border-bottom: 1px solid var(--hair); background: none; }
|
||||
.prefs-sheet[data-solo] .prefs-main { grid-column: 1 / -1; grid-row: 2; }
|
||||
.prefs-sheet[data-solo] .prefs-col { max-width: none; }
|
||||
.ext-md { display: grid; grid-template-columns: minmax(300px, 400px) minmax(0, 1fr); gap: 16px; align-items: start; }
|
||||
@@ -434,48 +436,22 @@
|
||||
.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 > .prefs-pagetitle { max-width: 880px; margin-inline: auto; }
|
||||
.prefs-main > :is(.prefs-pagetitle, .page-index) { max-width: 880px; margin-inline: auto; }
|
||||
@container prefsmain (min-width: 860px) {
|
||||
.prefs-main:has(.prefs-col[data-cols="2"]) > :is(.prefs-pagetitle, .page-index) { max-width: 1400px; }
|
||||
}
|
||||
.apply[data-usual] { display: none; }
|
||||
.prefs-card > .grp > .apply:not([data-usual]) { display: block; grid-row: 3; }
|
||||
.prefs-card > .grp > .apply { display: none; }
|
||||
.scope-tag { display: inline-grid; place-items: center; height: 18px; padding: 0 7px; border-radius: var(--r-pill); background: var(--overlay); color: var(--muted); font: var(--w-reg) var(--fs-micro)/1 var(--ui); white-space: nowrap; }
|
||||
.prefs-card > .grp, .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 { padding-block: 6px; min-height: 36px; box-sizing: border-box; }
|
||||
.prefs-card > .srow { padding-block: 6px; min-height: 36px; box-sizing: border-box; }
|
||||
.prefs-card > .grp > .hint, .srow-desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.prefs-card > .grp > .hint { 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: 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; }
|
||||
}
|
||||
.srow-meta > .apply:not([data-usual]) { display: none; }
|
||||
.prefs-strip { position: fixed; inset: 0 0 auto; height: var(--chrome-h, 39px); z-index: 50; background: color-mix(in srgb, var(--page) 40%, transparent); -webkit-backdrop-filter: blur(14px) saturate(.92); backdrop-filter: blur(14px) saturate(.92); pointer-events: none; }
|
||||
:root[data-fold~="scene"] .prefs-strip { display: none; }
|
||||
.usage .ucharts-spend { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
|
||||
@@ -487,6 +463,8 @@
|
||||
.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; }
|
||||
@@ -494,5 +472,11 @@
|
||||
.scopeaway .act:hover { background: var(--overlay); }
|
||||
.ext-bar .ext-scope { margin-left: auto; }
|
||||
.ext-bar .ext-scope + .ext-add { margin-inline-start: 0; }
|
||||
.scopeanchor { display: inline-block; }
|
||||
.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; }
|
||||
: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,8 +85,6 @@
|
||||
.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,8 +2,6 @@ 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" },
|
||||
@@ -37,7 +35,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 device" },
|
||||
"onboarding.about_a_minute": { zh: "大约 1 分钟 · key 只存在这台电脑上", en: "About a minute · the key stays on this computer" },
|
||||
"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,12 +1,6 @@
|
||||
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,7 +6,6 @@ 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" },
|
||||
@@ -100,6 +99,7 @@ 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: "Applies immediately on this machine unless noted otherwise" },
|
||||
"settings.page_usual": { zh: "本页设置默认:本机,立即生效;例外的在名字旁标出", en: "Settings here apply on this machine at once; exceptions are marked beside the name" },
|
||||
"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: "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.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.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,9 +82,6 @@ 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
|
||||
@@ -206,7 +203,6 @@ 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>;
|
||||
@@ -300,9 +296,6 @@ 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";
|
||||
}
|
||||
@@ -367,7 +360,7 @@ class BrowserHost implements HostPort {
|
||||
inShell() {
|
||||
return false;
|
||||
}
|
||||
describe(): Promise<HostInfo> {
|
||||
describe() {
|
||||
return Promise.resolve({ shell: "browser" as const, platform: "", titleBar: false });
|
||||
}
|
||||
minimiseWindow() {}
|
||||
@@ -382,7 +375,7 @@ class BrowserHost implements HostPort {
|
||||
captureWindow() {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
syncTheme(_mode: string, _scheme: "light" | "dark") {}
|
||||
syncTheme() {}
|
||||
showTextContextMenu() {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
@@ -413,12 +406,9 @@ class BrowserHost implements HostPort {
|
||||
saveBytes() {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
pickFolder(_startIn: string): Promise<string | null> {
|
||||
pickFolder() {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
canScanPairingCode() { return false; }
|
||||
scanPairingCode(): Promise<string | null> { return Promise.resolve(null); }
|
||||
openInApp(_url: string) {}
|
||||
revealsFiles() {
|
||||
return false;
|
||||
}
|
||||
@@ -445,90 +435,28 @@ class BrowserHost implements HostPort {
|
||||
drawsBrowserViews() {
|
||||
return false;
|
||||
}
|
||||
showBrowserView(_target: string, _rect: ViewRect) {}
|
||||
showBrowserView() {}
|
||||
hideBrowserView() {}
|
||||
freezeBrowserView() {
|
||||
return Promise.resolve("");
|
||||
}
|
||||
controlBrowserView(_target: string, _action: BrowserControl) {}
|
||||
navigateBrowserView(_target: string, _address: string) {
|
||||
controlBrowserView() {}
|
||||
navigateBrowserView() {
|
||||
return Promise.resolve<BrowserRefusal>("scheme");
|
||||
}
|
||||
onBrowserLoadState(_listener: (state: BrowserLoadState) => void) {
|
||||
onBrowserLoadState() {
|
||||
return () => {};
|
||||
}
|
||||
onBrowserLogin(_listener: (ask: BrowserLogin) => void) {
|
||||
onBrowserLogin() {
|
||||
return () => {};
|
||||
}
|
||||
answerBrowserLogin(_id: string, _username: string, _password: string) {}
|
||||
trustBrowserCertificate(_target: string) {
|
||||
answerBrowserLogin() {}
|
||||
trustBrowserCertificate() {
|
||||
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();
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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,13 +17,6 @@ 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,32 +13,6 @@ 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
|
||||
@@ -1782,26 +1756,3 @@ A sub-agent row, by the list's own width:
|
||||
- Appearance → Conversation display offers “Show collapsed plan progress,” off by default and persisted on this machine. Changing it immediately updates mounted plan cards.
|
||||
- A nonempty collapsed plan draws the expanded plan's `.prog2` track below its summary, using completed steps over total steps from `stepDone`. Pending and running steps do not count as complete.
|
||||
- Opening the card replaces the optional track with the existing expanded progress. Goal-only, empty and hidden cards have no progress track; collapse, Escape, outside dismissal and focus return remain unchanged.
|
||||
## 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.
|
||||
@@ -2103,8 +2103,7 @@
|
||||
}
|
||||
.prefs-sheet {
|
||||
width: min(1120px, 100%); height: min(800px, 100%);
|
||||
min-width: 0; min-height: 0;
|
||||
display: flex; flex-direction: column; overflow: clip;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
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;
|
||||
@@ -2585,8 +2584,8 @@
|
||||
.scopebar .away { flex-basis: 100%; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--accent-ink) }
|
||||
|
||||
.scope-pick {
|
||||
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;
|
||||
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;
|
||||
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);
|
||||
@@ -2611,14 +2610,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 { 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 .nm { display: flex; align-items: baseline; gap: 6px; 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; grid-row: 2; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
grid-column: 1; 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-column: 2; grid-row: 1 / span 2; align-self: center; font-style: normal; white-space: nowrap;
|
||||
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);
|
||||
@@ -5181,7 +5180,9 @@
|
||||
}
|
||||
}
|
||||
.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; 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-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 { 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,30 +9,23 @@ import { SettingDeepContext } from "./SettingDeep";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function draw(port = new MockPort()) {
|
||||
function draw() {
|
||||
const go = vi.fn();
|
||||
render(
|
||||
<SettingDeepContext.Provider value={{ pages: true, open: null, section: "Agent 行为", go }}>
|
||||
<AgentSettings port={port as unknown as AgentPort} hub={{ tree: async () => [] }} persona={false} onChanged={() => {}} />
|
||||
<AgentSettings port={new MockPort() as unknown as AgentPort} hub={{ tree: async () => [] }} persona={false} onChanged={() => {}} />
|
||||
</SettingDeepContext.Provider>,
|
||||
);
|
||||
return go;
|
||||
}
|
||||
|
||||
it("shows every group directly without an extra navigation row", async () => {
|
||||
it("is four cards of single rows, plan mode and the display switches living elsewhere", async () => {
|
||||
draw();
|
||||
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();
|
||||
expect([...document.querySelectorAll(".prefs-card > header h3")].map((h) => h.textContent)).toEqual(["人设与预设", "计划与提问", "协作", "自动化"]);
|
||||
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", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -13,7 +14,6 @@ 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,20 +46,26 @@ 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} />
|
||||
</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>
|
||||
<ProgressWatch port={port} />
|
||||
</SectionCard>
|
||||
<SectionCard title={t("settings.collaboration")}>
|
||||
<SessionRef port={port} />
|
||||
@@ -72,9 +78,6 @@ 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,9 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useState } from "react";
|
||||
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
|
||||
@@ -26,39 +23,21 @@ 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 ref={trigger} type="button" className="spill who" data-action="ext.scope" aria-haspopup="listbox" aria-expanded={open} onClick={() => setOpen(!open)}>
|
||||
<button type="button" className="spill who" data-action="ext.scope" aria-expanded={open} onClick={() => setOpen(!open)}>
|
||||
{scope.label || scope.name}
|
||||
<i className="cv" aria-hidden>▾</i>
|
||||
</button>
|
||||
{open && createPortal(
|
||||
{open && (
|
||||
<ScopePicker
|
||||
panel={panel}
|
||||
scopes={scopes}
|
||||
at={scope.root}
|
||||
onPick={(root) => {
|
||||
close();
|
||||
setOpen(false);
|
||||
onPick(root);
|
||||
}}
|
||||
/>, document.body,
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
@@ -117,9 +96,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({
|
||||
panel, scopes, at, onPick,
|
||||
scopes, at, onPick,
|
||||
}: {
|
||||
panel: RefObject<HTMLDivElement | null>; scopes: CapabilityScope[]; at: string; onPick: (root: string) => void;
|
||||
scopes: CapabilityScope[]; at: string; onPick: (root: string) => void;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const [onlyChanged, setOnlyChanged] = useState(false);
|
||||
@@ -130,7 +109,7 @@ function ScopePicker({
|
||||
return !needle || (sc.label || sc.name).toLowerCase().includes(needle) || sc.root.toLowerCase().includes(needle);
|
||||
});
|
||||
return (
|
||||
<div ref={panel} className="scope-pick" role="listbox" aria-label={t("ext.manage_which_project")}>
|
||||
<div className="scope-pick" role="listbox">
|
||||
{searchable && (
|
||||
<input
|
||||
className="q"
|
||||
|
||||
@@ -19,24 +19,6 @@ 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();
|
||||
|
||||
@@ -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" && !matchMedia?.("(pointer: coarse)").matches) first.current?.focus(); }, [step, pick]);
|
||||
useEffect(() => { if (step === "connect") 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.
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// @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(touch ? project.current ?? "" : "");
|
||||
const [dir, setDir] = useState("");
|
||||
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 {
|
||||
if (dir !== project.current) await project.add(dir);
|
||||
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(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}>
|
||||
<p className="onb-s">{t("onboarding.drop_to_choose")}</p>
|
||||
<div className="onb-drop" ref={drop} data-over={over ? "" : undefined} data-chosen={dir ? "" : undefined}>
|
||||
{dir ? (
|
||||
<div className="onb-chosen">
|
||||
<StudioIcon name="folder" />
|
||||
<b>{touch && dir === project.current ? t("onboarding.current_workspace") : nameOf(dir)}</b>
|
||||
<b>{nameOf(dir)}</b>
|
||||
<small title={dir}>{dir}</small>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<StudioIcon name="folder" />
|
||||
<span>{t(touch ? "onboarding.choose_a_folder" : "onboarding.drop_a_folder_here")}</span>
|
||||
<span>{t("onboarding.drop_a_folder_here")}</span>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="onb-skip" data-action="onboarding.pick-folder" disabled={busy} onClick={() => void pick()}>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// @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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// @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");
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -8,17 +8,11 @@ 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. */
|
||||
function SharePop({ hub }: { hub: HubPort }) {
|
||||
export function PhonePop({ hub }: { hub: HubPort }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
@@ -9,27 +9,23 @@ 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("describes both choices and saves only a different selection", async () => {
|
||||
it("starts off, says what it releases and what it leaves alone, and saves the opposite of what it shows", 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(await never());
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
await userEvent.click(screen.getByRole("radio", { name: "等待确认" }));
|
||||
await waitFor(() => expect((screen.getByRole("radio", { name: "不需要" })).getAttribute("aria-checked")).toBe("true"));
|
||||
await userEvent.click(screen.getByRole("radio", { name: "每次" }));
|
||||
expect(save).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,12 +22,11 @@ export function PlanApproval({ port }: { port: AgentPort }) {
|
||||
|
||||
if (!state) return <div className="empty">{t("plan.could_not_read_the_plan")}</div>;
|
||||
|
||||
const pick = async (auto: boolean) => {
|
||||
if (auto === state.auto) return;
|
||||
const flip = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setState(await port.savePlanApproval(auto));
|
||||
setState(await port.savePlanApproval(!state.auto));
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
} finally {
|
||||
@@ -39,7 +38,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={(value) => void pick(value === "auto")} options={[["ask", t("settings.wait_for_confirmation")], ["auto", t("settings.start_automatically")]]} />
|
||||
onPick={() => void flip()} options={[["ask", t("settings.every_time")], ["auto", t("settings.not_needed")]]} />
|
||||
</SettingRow>
|
||||
{error && <div className="why">{error}</div>}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
<SettingDeepContext.Provider value={{ ...deep, pages: false }}>{children}</SettingDeepContext.Provider>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { useModalFocus } from "./useModalFocus";
|
||||
import { SettingsLeaveContext, useSettingsLeave } from "./SettingsLeave";
|
||||
@@ -6,7 +6,8 @@ import { t } from "../i18n";
|
||||
import { listenAction } from "./listen";
|
||||
import { useRuntimeReload } from "./RuntimeReload";
|
||||
import type { CapabilityScope, McpEntry, ModelEntry, PluginPackage, SkillEntry } from "../port/port";
|
||||
import { useSettingsSearch } from "./useSettingsSearch";
|
||||
import { usePageIndex, useSettingsSearch } from "./useSettingsSearch";
|
||||
import { PageIndex } from "./PageIndex";
|
||||
import { useSettingLanding } from "./useSettingLanding";
|
||||
import { bytes, tokens as fmtTokens } from "../i18n/format";
|
||||
import { STORAGE_TALLY_DELAY, cachedStorageTotal, storageTotal } from "./storageTally";
|
||||
@@ -61,6 +62,8 @@ 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);
|
||||
@@ -311,6 +314,7 @@ 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);
|
||||
@@ -329,10 +333,6 @@ 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,6 +415,7 @@ 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">
|
||||
@@ -426,7 +427,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-deep={deepAt ?? undefined}>
|
||||
<div className="prefs-col" key={`${at}:${deepAt ?? ""}`} data-cols={TWO_COLUMN.includes(at) && !deepAt && !solo ? 2 : undefined} 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
|
||||
|
||||
@@ -79,14 +79,16 @@ 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 && (
|
||||
{(desc || count > 0) && (
|
||||
<span className="desc">
|
||||
<span className="what">{t(desc)}</span>
|
||||
{desc && <span className="what">{t(desc)}</span>}
|
||||
{count > 0 && <span className="count">{t("settings.n_items", { n: count })}</span>}
|
||||
</span>
|
||||
)}
|
||||
<p className="usual">{t("settings.page_usual")}</p>
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
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>{`${blocks}{display:none}${wrappers}{display:none}`}</style>;
|
||||
return <style ref={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 and what it is for without repeating the navigation count", () => {
|
||||
it("names the page, how many settings it holds and what it is for", () => {
|
||||
const { container } = render(<SettingsTitle at="model" />);
|
||||
const bar = container.querySelector(".prefs-pagetitle")!;
|
||||
expect(bar.querySelector("h2")?.textContent).toBe("模型");
|
||||
expect(bar.querySelector(".count")).toBeNull();
|
||||
expect(bar.querySelector(".count")?.textContent).toMatch(/^\d+ 项$/);
|
||||
expect(bar.querySelector(".desc .what")?.textContent).toBe("接哪个服务、每种用途用哪个模型、上下文怎么维护");
|
||||
expect(bar.querySelector(".group")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -38,31 +38,6 @@ 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", () => {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
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 { NAV, SETTINGS, SETTING_AT, pageHome, type SettingScope } from "./prefsnav";
|
||||
import { SETTINGS, SETTING_AT, type SettingScope } from "./prefsnav";
|
||||
import { walk, type Node } from "./roots";
|
||||
|
||||
// The settings screen has to answer two questions about itself: where a
|
||||
@@ -84,13 +84,6 @@ 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, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { cleanup, render, 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, port = new MockPort()) {
|
||||
function draw(at?: string) {
|
||||
render(
|
||||
<Settings
|
||||
hub={new MockHub() as never}
|
||||
port={port}
|
||||
port={new MockPort()}
|
||||
at={at}
|
||||
status={{ preset: "balanced", toolApprovalMode: "ask" } as SessionStatus}
|
||||
theme="light" onTheme={() => {}} contrast="" onContrast={() => {}} weight="" onWeight={() => {}}
|
||||
@@ -29,106 +29,7 @@ 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,5 +1,6 @@
|
||||
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";
|
||||
@@ -26,3 +27,9 @@ 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]);
|
||||
}
|
||||
@@ -225,7 +225,7 @@ var English = Messages{
|
||||
MemoryApprovalArchiveFmt: "Archive memory %q",
|
||||
SandboxEscapeSubjectFallback: "run shell command unconfined once",
|
||||
SandboxEscapeSubjectPrefix: "run unconfined once: ",
|
||||
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.",
|
||||
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.",
|
||||
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?",
|
||||
|
||||
@@ -225,7 +225,7 @@ var Chinese = Messages{
|
||||
MemoryApprovalArchiveFmt: "归档记忆 %q",
|
||||
SandboxEscapeSubjectFallback: "仅本次不进沙箱运行 shell 命令",
|
||||
SandboxEscapeSubjectPrefix: "仅本次不进沙箱运行:",
|
||||
SandboxEscapeWrapReason: "当前设备无法提供这条命令所需的 OS 级沙箱。是否仅本次不受限运行?这只会对此命令绕过 OS 隔离。",
|
||||
SandboxEscapeWrapReason: "Windows 不提供这条命令所需的 OS 级 Bash 沙箱。是否仅本次不受限运行?这只会对此命令绕过 OS 隔离。",
|
||||
SandboxEscapeRuntimeReason: "OS 沙箱无法启动这条命令。是否仅本次不受限运行?这只会对此命令绕过 OS 隔离。",
|
||||
SandboxEscapeDeclined: "用户拒绝在没有 OS 沙箱的情况下运行这条命令;不要不进沙箱重试,请询问用户希望如何继续。",
|
||||
EgressApprovalReasonFmt: "一条 bash 命令要访问 %s,它不在 [sandbox] allowed_domains 中。是否允许?",
|
||||
|
||||
@@ -226,7 +226,7 @@ var ChineseTraditional = Messages{
|
||||
MemoryApprovalArchiveFmt: "封存記憶 %q",
|
||||
SandboxEscapeSubjectFallback: "僅本次不進沙箱執行 shell 命令",
|
||||
SandboxEscapeSubjectPrefix: "僅本次不進沙箱執行:",
|
||||
SandboxEscapeWrapReason: "目前裝置無法提供這條命令所需的 OS 級沙箱。是否僅本次不受限執行?這只會對此命令繞過 OS 隔離。",
|
||||
SandboxEscapeWrapReason: "Windows 不提供這條命令所需的 OS 級 Bash 沙箱。是否僅本次不受限執行?這只會對此命令繞過 OS 隔離。",
|
||||
SandboxEscapeRuntimeReason: "OS 沙箱無法啟動這條命令。是否僅本次不受限執行?這只會對此命令繞過 OS 隔離。",
|
||||
SandboxEscapeDeclined: "使用者拒絕在沒有 OS 沙箱的情況下執行這條命令;不要不進沙箱重試,請詢問使用者希望如何繼續。",
|
||||
EgressApprovalReasonFmt: "一條 bash 命令要存取 %s,它不在 [sandbox] allowed_domains 中。是否允許?",
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
@@ -1318,7 +1317,7 @@ func Default() *Config {
|
||||
UI: UIConfig{Theme: "auto", ShowTurnUsage: true, ShowChangedFiles: true},
|
||||
Desktop: DesktopConfig{ConversationWidth: "standard"},
|
||||
Billing: BillingConfig{},
|
||||
Tools: ToolsConfig{PresentFiles: true, Shell: defaultShell(runtime.GOOS)},
|
||||
Tools: ToolsConfig{PresentFiles: true},
|
||||
// Set here, not left to the zero value: an absent [secrets] still protects.
|
||||
Secrets: SecretsConfig{ProtectCredentialFiles: true},
|
||||
Notifications: NotificationsConfig{
|
||||
|
||||
@@ -23,13 +23,6 @@ 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).
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,10 @@ func mcpCommand(args []string) int {
|
||||
mcpUsage()
|
||||
return 2
|
||||
}
|
||||
if len(args) > 1 && (args[1] == "-h" || args[1] == "--help") {
|
||||
mcpUsage()
|
||||
return 0
|
||||
}
|
||||
switch args[0] {
|
||||
case "list", "ls":
|
||||
if len(args) > 1 {
|
||||
@@ -41,10 +45,6 @@ func mcpCommand(args []string) int {
|
||||
}
|
||||
return mcpList()
|
||||
case "add":
|
||||
if len(args) > 1 && (args[1] == "-h" || args[1] == "--help") {
|
||||
mcpUsage()
|
||||
return 0
|
||||
}
|
||||
return mcpAddCLI(args[1:])
|
||||
case "get":
|
||||
return mcpGetCLI(args[1:])
|
||||
@@ -64,6 +64,10 @@ func mcpCommand(args []string) int {
|
||||
case "update":
|
||||
return mcpUpdateCLI(args[1:])
|
||||
case "import":
|
||||
if len(args) > 1 {
|
||||
fmt.Fprintf(os.Stderr, "mcp import: unexpected argument %q\n", args[1])
|
||||
return 2
|
||||
}
|
||||
return mcpImportCLI()
|
||||
case "browse", "search":
|
||||
return mcpBrowseCLI(args[1:])
|
||||
|
||||
@@ -33,3 +33,35 @@ func TestMCPListAndGetRejectExtraArguments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHelpFlagPrintsUsageForEverySubcommandAndRunsNothing(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
for _, sub := range []string{"list", "ls", "add", "get", "remove", "rm", "enable", "disable", "retry", "connect", "trust", "auth", "authorize", "update", "import", "browse", "search", "install"} {
|
||||
for _, flag := range []string{"--help", "-h"} {
|
||||
var code int
|
||||
stdout, stderr := captureCLIOutput(t, func() {
|
||||
code = Run([]string{"mcp", sub, flag}, "test")
|
||||
})
|
||||
if code != 0 || !strings.Contains(stdout, "reasonix mcp") || stderr != "" || strings.Contains(stdout, "imported") {
|
||||
t.Errorf("mcp %s %s: exit %d, want 0 with usage only\nstdout:\n%s\nstderr:\n%s", sub, flag, code, stdout, stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPImportRejectsArgumentsInsteadOfImporting(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
var code int
|
||||
stdout, stderr := captureCLIOutput(t, func() {
|
||||
code = Run([]string{"mcp", "import", "--nope"}, "test")
|
||||
})
|
||||
if code != 2 || stdout != "" || stderr == "" {
|
||||
t.Fatalf("mcp import --nope exited %d, want 2 with an error\nstdout:\n%s\nstderr:\n%s", code, stdout, stderr)
|
||||
}
|
||||
}
|
||||
@@ -34,13 +34,9 @@ func init() { tool.RegisterBuiltin(bash{}) }
|
||||
|
||||
var (
|
||||
bashSandboxCommand = sandbox.Command
|
||||
bashSandboxEscapePromptEnabled = func() bool { return bashSandboxEscapePlatform(runtime.GOOS) }
|
||||
bashSandboxEscapePromptEnabled = func() bool { return runtime.GOOS == "windows" }
|
||||
)
|
||||
|
||||
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,45 +20,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
## 修复
|
||||
|
||||
- 修复命令行里 `reasonix mcp import --help`(以及 `mcp remove --help` 等其他子命令)不会显示用法,反而把 `--help` 当成参数真的去执行:`import` 会直接改写 MCP 配置。现在所有 `reasonix mcp` 子命令的 `--help` / `-h` 都只打印用法并退出,什么都不改;`mcp import` 带多余参数时会报错而不是照常导入(#15022)。
|
||||
|
||||
---
|
||||
<!-- lang:en -->
|
||||
|
||||
## Fixed
|
||||
|
||||
- `reasonix mcp import --help` (and `--help` on the other `mcp` subcommands) did not print usage and ran the command with `--help` as an argument, so `import` rewrote the MCP configuration. Every `reasonix mcp` subcommand now prints usage for `--help` / `-h` and changes nothing, and `mcp import` refuses extra arguments instead of importing (#15022).
|
||||
@@ -0,0 +1,10 @@
|
||||
## 修复
|
||||
|
||||
- 修复 Windows 上每次启动 Reasonix Studio 都会先闪一下黑色控制台窗口的问题:启动时用来确认"是不是同一个 Studio"的内核探测,以及启动内核本身,现在都隐藏控制台窗口(#15016)。
|
||||
|
||||
---
|
||||
<!-- lang:en -->
|
||||
|
||||
## Fixed
|
||||
|
||||
- On Windows, launching Reasonix Studio no longer flashes a console window first: the kernel probe that tells whether this is the same Studio, and the kernel launch itself, now run with their console hidden (#15016).
|
||||
Reference in new issue
Block a user