From 2c4f63f0f5e835ab3c42b2327b7c84463035884f Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 8 Oct 2026 15:43:08 +0000 Subject: [PATCH 01/16] feat(android): implement mobile navigation, opt-in background service and encrypted passwords --- .github/workflows/android-prototype.yml | 10 +- mobile/android/app/build.gradle | 4 +- .../prototype/EncryptedSecretStoreTest.kt | 38 +++ .../android/app/src/main/AndroidManifest.xml | 6 + .../android/prototype/ConnectionService.kt | 72 +++++ .../android/prototype/EncryptedSecretStore.kt | 68 ++++ .../tabby/android/prototype/MainActivity.kt | 4 + .../org/tabby/android/prototype/SSHRuntime.kt | 243 +++++++++++++++ .../tabby/android/prototype/TabbySSHPlugin.kt | 295 ++++++------------ mobile/scripts/test-android.mjs | 4 +- mobile/web/src/app.component.ts | 75 ++++- mobile/web/src/bridge.ts | 12 +- mobile/web/src/session-pane.component.ts | 187 ++++++++--- mobile/web/src/styles.css | 59 ++-- mobile/web/src/terminal-view.ts | 6 +- mobile/web/tests/harness.ts | 9 +- 16 files changed, 794 insertions(+), 298 deletions(-) create mode 100644 mobile/android/app/src/androidTest/java/org/tabby/android/prototype/EncryptedSecretStoreTest.kt create mode 100644 mobile/android/app/src/main/java/org/tabby/android/prototype/ConnectionService.kt create mode 100644 mobile/android/app/src/main/java/org/tabby/android/prototype/EncryptedSecretStore.kt create mode 100644 mobile/android/app/src/main/java/org/tabby/android/prototype/SSHRuntime.kt diff --git a/.github/workflows/android-prototype.yml b/.github/workflows/android-prototype.yml index cd0e6f1dcf..944036c001 100644 --- a/.github/workflows/android-prototype.yml +++ b/.github/workflows/android-prototype.yml @@ -1,10 +1,10 @@ name: Android prototype checks and APK on: push: - branches: [prototype/android-direct-ssh] + branches: [prototype/android-direct-ssh, candidate/android-mobile-product-20261008] paths: [mobile/**, tabby-ssh/src/session/tmux*, tabby-ssh/src/session/reconnect.ts, .github/workflows/android-prototype.yml] pull_request: - branches: [feature/ssh-tmux-recovery] + branches: [feature/ssh-tmux-recovery, candidate/android-mobile-ux-20261008] paths: [mobile/**, tabby-ssh/src/session/tmux*, tabby-ssh/src/session/reconnect.ts, .github/workflows/android-prototype.yml] workflow_dispatch: permissions: @@ -14,7 +14,7 @@ concurrency: cancel-in-progress: true jobs: checks: - if: github.event.repository.visibility == 'public' && github.repository == 'Mafei/tabby' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'Mafei/tabby' && github.event.pull_request.head.ref == 'prototype/android-direct-ssh') || (github.event_name != 'pull_request' && github.ref == 'refs/heads/prototype/android-direct-ssh')) + if: github.event.repository.visibility == 'public' && github.repository == 'Mafei/tabby' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'Mafei/tabby' && (github.event.pull_request.head.ref == 'prototype/android-direct-ssh' || github.event.pull_request.head.ref == 'candidate/android-mobile-product-20261008')) || (github.event_name != 'pull_request' && (github.ref == 'refs/heads/prototype/android-direct-ssh' || github.ref == 'refs/heads/candidate/android-mobile-product-20261008'))) runs-on: ubuntu-24.04 timeout-minutes: 25 steps: @@ -94,7 +94,7 @@ jobs: if: always() && steps.install.outcome == 'success' run: node mobile/scripts/test-jvm-policy.mjs arm64-core: - if: github.event.repository.visibility == 'public' && github.repository == 'Mafei/tabby' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'Mafei/tabby' && github.event.pull_request.head.ref == 'prototype/android-direct-ssh') || (github.event_name != 'pull_request' && github.ref == 'refs/heads/prototype/android-direct-ssh')) + if: github.event.repository.visibility == 'public' && github.repository == 'Mafei/tabby' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'Mafei/tabby' && (github.event.pull_request.head.ref == 'prototype/android-direct-ssh' || github.event.pull_request.head.ref == 'candidate/android-mobile-product-20261008')) || (github.event_name != 'pull_request' && (github.ref == 'refs/heads/prototype/android-direct-ssh' || github.ref == 'refs/heads/candidate/android-mobile-product-20261008'))) runs-on: ubuntu-24.04-arm timeout-minutes: 25 steps: @@ -136,7 +136,7 @@ jobs: TABBY_TEST_TMUX: /usr/bin/tmux run: node mobile/scripts/test-fixture-jni.mjs --deferred android: - if: github.event.repository.visibility == 'public' && github.repository == 'Mafei/tabby' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'Mafei/tabby' && github.event.pull_request.head.ref == 'prototype/android-direct-ssh') || (github.event_name != 'pull_request' && github.ref == 'refs/heads/prototype/android-direct-ssh')) + if: github.event.repository.visibility == 'public' && github.repository == 'Mafei/tabby' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'Mafei/tabby' && (github.event.pull_request.head.ref == 'prototype/android-direct-ssh' || github.event.pull_request.head.ref == 'candidate/android-mobile-product-20261008')) || (github.event_name != 'pull_request' && (github.ref == 'refs/heads/prototype/android-direct-ssh' || github.ref == 'refs/heads/candidate/android-mobile-product-20261008'))) runs-on: ubuntu-24.04 timeout-minutes: 45 strategy: diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 70317b3bf2..1a3fbf0c37 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -16,8 +16,8 @@ android { applicationId = 'org.tabby.android.prototype' minSdk = rootProject.ext.minSdkVersion targetSdk = rootProject.ext.targetSdkVersion - versionCode = 1 - versionName = '0.1.0-prototype' + versionCode = 2 + versionName = '0.2.0-mobile-candidate' testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner' ndk { abiFilters.addAll(selectedAbis) } } diff --git a/mobile/android/app/src/androidTest/java/org/tabby/android/prototype/EncryptedSecretStoreTest.kt b/mobile/android/app/src/androidTest/java/org/tabby/android/prototype/EncryptedSecretStoreTest.kt new file mode 100644 index 0000000000..9f14519980 --- /dev/null +++ b/mobile/android/app/src/androidTest/java/org/tabby/android/prototype/EncryptedSecretStoreTest.kt @@ -0,0 +1,38 @@ +package org.tabby.android.prototype + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import android.content.Context +import java.io.File + +@RunWith(AndroidJUnit4::class) +class EncryptedSecretStoreTest { + @Test fun encryptedUpdateDeleteAndTamperFailClosed() { + val context = ApplicationProvider.getApplicationContext() + val store = EncryptedSecretStore(context) + val host = "isolated-fixture.invalid"; val user = "synthetic-vault-test"; val pin = "fixture-public-pin" + val first = "SYNTHETIC_ONLY_FIRST_PASSWORD".toByteArray() + val second = "SYNTHETIC_ONLY_UPDATED_PASSWORD".toByteArray() + store.delete(host, 22, user) + try { + assertFalse(store.has(host, 22, user)) + store.put(host, 22, user, pin, first) + assertArrayEquals(first, store.get(host, 22, user, pin)) + assertNull(store.get(host, 23, user, pin)) + assertNull(store.get(host, 22, user + "other", pin)) + val files = File(context.noBackupFilesDir, "ssh-secrets-v1").listFiles()!!.filter { it.isFile } + assertTrue(files.isNotEmpty()) + files.forEach { assertFalse(it.readText().contains(String(first))); assertFalse(it.name.contains(host)) } + var rejected = false + try { store.get(host, 22, user, "changed-pin") } catch (_: Throwable) { rejected = true } + assertTrue("changed host identity must reject saved password", rejected) + store.put(host, 22, user, pin, second) + assertArrayEquals(second, store.get(host, 22, user, pin)) + store.delete(host, 22, user) + assertFalse(store.has(host, 22, user)); assertNull(store.get(host, 22, user, pin)) + } finally { first.fill(0); second.fill(0); store.delete(host, 22, user) } + } +} diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index c5cc3a3438..c9e03dc722 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + + + + = 34) startForeground(ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + else startForeground(ID, notification) + enabled = true + } catch (_: Throwable) { enabled = false; SSHRuntime.get(this).closeAll("background_service_failed"); stopSelf() } + return START_NOT_STICKY + } + private fun notification(count: Int) = makeNotification(this, count) + override fun onDestroy() { + enabled = false + if (!detaching) SSHRuntime.get(this).closeAll("background_service_stopped") + detaching = false + super.onDestroy() + } + companion object { + private var detaching = false + private fun makeNotification(context: Context, count: Int) = NotificationCompat.Builder(context, CHANNEL) + .setSmallIcon(android.R.drawable.stat_notify_sync).setContentTitle("Tabby · 后台连接") + .setContentText("$count 个连接 · 系统或网络仍可能中断") + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE).setOngoing(true).setOnlyAlertOnce(true) + .setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) + .addAction(0, "停止全部", PendingIntent.getService(context, 1, Intent(context, ConnectionService::class.java).setAction(STOP), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) + .build() + const val STOP = "org.tabby.android.prototype.STOP_CONNECTIONS" + private const val ID = 107; private const val CHANNEL = "ssh-background-v1" + @Volatile var enabled = false + private set + fun notificationsAllowed(context: Context) = + (Build.VERSION.SDK_INT < 33 || ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) && + context.getSystemService(NotificationManager::class.java).areNotificationsEnabled() + fun start(context: Context) { + require(notificationsAllowed(context)) + ContextCompat.startForegroundService(context, Intent(context, ConnectionService::class.java)) + } + fun disable(context: Context) { enabled = false; detaching = true; context.stopService(Intent(context, ConnectionService::class.java)) } + fun connectionsChanged(context: Context, count: Int) { + if (enabled) { + if (count == 0) { disable(context) } + else context.getSystemService(NotificationManager::class.java).notify(ID, makeNotification(context, count)) + } + } + } +} diff --git a/mobile/android/app/src/main/java/org/tabby/android/prototype/EncryptedSecretStore.kt b/mobile/android/app/src/main/java/org/tabby/android/prototype/EncryptedSecretStore.kt new file mode 100644 index 0000000000..b3cb62b718 --- /dev/null +++ b/mobile/android/app/src/main/java/org/tabby/android/prototype/EncryptedSecretStore.kt @@ -0,0 +1,68 @@ +package org.tabby.android.prototype + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.AtomicFile +import org.json.JSONObject +import java.io.File +import java.security.KeyStore +import java.security.MessageDigest +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import android.util.Base64 + +/** Ciphertext only, excluded from backup; no password or host/account in filenames. */ +class EncryptedSecretStore(context: Context) { + private val directory = File(context.noBackupFilesDir, "ssh-secrets-v1") + private val alias = "tabby.ssh.secrets.aes.v1" + private fun identity(host: String, port: Int, account: String) = JSONObject() + .put("host", host.lowercase(java.util.Locale.ROOT)).put("port", port).put("account", account).toString() + private fun file(host: String, port: Int, account: String): AtomicFile { + require(host.isNotEmpty() && port in 1..65535 && account.isNotEmpty()) + val hash = MessageDigest.getInstance("SHA-256").digest(identity(host, port, account).toByteArray()) + .joinToString("") { "%02x".format(it) } + return AtomicFile(File(directory, hash)) + } + private fun key(): SecretKey { + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (store.getKey(alias, null) as? SecretKey)?.let { return it } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { + init(KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256).setRandomizedEncryptionRequired(true).build()) + }.generateKey() + } + private fun aad(host: String, port: Int, account: String, hostKey: String) = + ("tabby-password-v1\u0000" + identity(host, port, account) + "\u0000" + hostKey).toByteArray() + @Synchronized fun has(host: String, port: Int, account: String) = file(host, port, account).baseFile.isFile + @Synchronized fun put(host: String, port: Int, account: String, hostKey: String, secret: ByteArray) { + require(hostKey.isNotEmpty() && secret.size in 1..65536) + require(directory.isDirectory || directory.mkdirs()) + val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { init(Cipher.ENCRYPT_MODE, key()); updateAAD(aad(host, port, account, hostKey)) } + val encrypted = cipher.doFinal(secret) + val data = JSONObject().put("version", 1).put("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP)) + .put("ciphertext", Base64.encodeToString(encrypted, Base64.NO_WRAP)).toString().toByteArray() + val target = file(host, port, account) + val stream = target.startWrite() + try { stream.write(data); target.finishWrite(stream) } catch (error: Throwable) { target.failWrite(stream); throw error } + finally { encrypted.fill(0); data.fill(0) } + } + @Synchronized fun get(host: String, port: Int, account: String, hostKey: String): ByteArray? { + val target = file(host, port, account) + if (!target.baseFile.isFile) return null + require(target.baseFile.length() <= 100000) + val data = target.readFully() + try { + val record = JSONObject(String(data, Charsets.UTF_8)); require(record.getInt("version") == 1) + val iv = Base64.decode(record.getString("iv"), Base64.NO_WRAP); require(iv.size == 12) + val encrypted = Base64.decode(record.getString("ciphertext"), Base64.NO_WRAP) + return try { Cipher.getInstance("AES/GCM/NoPadding").run { + init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, iv)); updateAAD(aad(host, port, account, hostKey)); doFinal(encrypted) + } } finally { encrypted.fill(0) } + } finally { data.fill(0) } + } + @Synchronized fun delete(host: String, port: Int, account: String) { file(host, port, account).delete() } +} diff --git a/mobile/android/app/src/main/java/org/tabby/android/prototype/MainActivity.kt b/mobile/android/app/src/main/java/org/tabby/android/prototype/MainActivity.kt index 75a5acc9d6..910c0cf41b 100644 --- a/mobile/android/app/src/main/java/org/tabby/android/prototype/MainActivity.kt +++ b/mobile/android/app/src/main/java/org/tabby/android/prototype/MainActivity.kt @@ -2,6 +2,8 @@ package org.tabby.android.prototype import android.content.res.Configuration import android.os.Bundle +import android.util.TypedValue +import android.view.ViewConfiguration import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat @@ -52,6 +54,8 @@ class MainActivity : BridgeActivity() { val ime = insets?.getInsets(WindowInsetsCompat.Type.ime()) val visible = insets?.isVisible(WindowInsetsCompat.Type.ime()) ?: false return JSObject().apply { + put("fontPixels", org.json.JSONObject((12..26).associate { it.toString() to TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, it.toFloat(), resources.displayMetrics) / density })) + put("touchSlop", ViewConfiguration.get(this@MainActivity).scaledTouchSlop / density) put("visible", visible) put("height", if (visible) (ime?.bottom ?: 0) / density else 0) put("viewportWidth", (webView?.width ?: 0) / density) diff --git a/mobile/android/app/src/main/java/org/tabby/android/prototype/SSHRuntime.kt b/mobile/android/app/src/main/java/org/tabby/android/prototype/SSHRuntime.kt new file mode 100644 index 0000000000..1cfa33dbb7 --- /dev/null +++ b/mobile/android/app/src/main/java/org/tabby/android/prototype/SSHRuntime.kt @@ -0,0 +1,243 @@ +package org.tabby.android.prototype + +import android.content.Context +import android.os.Handler +import android.os.Looper +import com.getcapacitor.JSObject +import org.json.JSONArray +import org.json.JSONObject +import org.tabby.android.ssh.NativeSSH +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** Process runtime; the foreground Service, rather than an Activity, owns its lifetime. */ +class SSHRuntime private constructor(val context: Context) { + data class Session( + val id: Long, + val endpoint: String, + val host: String, + val port: Int, + val username: String, + val ownerId: String?, + val gate: ConnectionGate, + val operations: SessionOperations, + val hostKeys: MutableMap = ConcurrentHashMap(), + val batchPending: AtomicBoolean = AtomicBoolean(false), + val outputWindow: OutputWindow = OutputWindow(), + @Volatile var pendingPassword: ByteArray? = null, + @Volatile var verifiedHostKey: String? = null, + ) + + + val sessions = ConcurrentHashMap() + val sessionLock = Any() + val privateKeys = PrivateKeyVault() + val secrets = EncryptedSecretStore(context) + private val main = Handler(Looper.getMainLooper()) + private val worker = Executors.newSingleThreadScheduledExecutor() + @Volatile private var sink: ((JSONObject) -> Unit)? = null + private lateinit var hostKeyStore: HostKeyStore + private lateinit var hostKeyPolicy: HostKeyPolicy + init { + val prefs = context.getSharedPreferences("ssh_public_host_keys_v1", Context.MODE_PRIVATE) + hostKeyStore = DurableHostKeyStore(object : PublicKeyPreferences { + private fun key(endpoint: String) = MessageDigest.getInstance("SHA-256") + .digest(endpoint.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } + override fun get(endpoint: String): String? = prefs.getString(key(endpoint), null) + override fun putAndCommit(endpoint: String, key: String): Boolean = + prefs.edit().putString(this.key(endpoint), key).commit() + override fun removeAndCommit(endpoint: String) { + prefs.edit().remove(key(endpoint)).commit() + } + }) + hostKeyPolicy = HostKeyPolicy(hostKeyStore) + worker.scheduleWithFixedDelay({ pollEvents() }, 0, 16, TimeUnit.MILLISECONDS) + } + fun knownHost(endpoint: String): String? = hostKeyStore.read(endpoint) + fun approve(endpoint: String, key: String) = hostKeyPolicy.approve(endpoint, key) + fun attach(listener: (JSONObject) -> Unit) { sink = listener } + fun detach() { sink = null } + private fun deliver(event: JSONObject) { sink?.invoke(event) } + private fun saveSuccessfulPassword(session: Session) { + val bytes = session.pendingPassword ?: return + session.pendingPassword = null + try { secrets.put(session.host, session.port, session.username, session.verifiedHostKey ?: error("unverified"), bytes) } + catch (_: Throwable) { deliver(JSONObject().put("type", "credentialStatus").put("code", "save_failed").put("connectionId", session.id.toString()).put("generation", session.gate.generation).put("ownerId", session.ownerId)) } + finally { bytes.fill(0) } + } + private fun pollEvents() { + if (sink == null) return + for (session in sessions.values) { + if (!session.gate.isActive() || !session.outputWindow.canPoll() || !session.batchPending.compareAndSet(false, true)) continue + try { + val batch = JSONArray(NativeSSH.poll(session.id)) + main.post { + try { + for (index in 0 until batch.length()) { + if (!session.gate.isActive()) break + val event = batch.getJSONObject(index) + if (event.optLong("generation", -1) != session.gate.generation) continue + event.put("connectionId", session.id.toString()) + session.ownerId?.let { event.put("ownerId", it) } + processEvent(session, event) + } + } catch (_: Throwable) { + if (session.gate.isActive()) { + emitFailure(session, "native_bridge_error") + closeSession(session, "native_bridge_error") + } + } finally { + session.batchPending.set(false) + } + } + } catch (_: Throwable) { + session.batchPending.set(false) + if (session.gate.isActive()) { + emitFailure(session, "native_bridge_error") + closeSession(session, "native_bridge_error") + } + } + } + } + + private fun processEvent(session: Session, event: JSONObject) { + when (event.optString("type")) { + "hostKey" -> { + val key = event.getString("keyBase64") + val decision = hostKeyPolicy.inspect(session.endpoint, key) + if (event.optString("status") == "known") { + // Rust emits this only after matching the handshake against + // the native store's expectedHostKey. It needs no response. + require(decision == HostKeyDecision.ACCEPT) + val previous = session.verifiedHostKey + require(previous == null || previous == key) + session.verifiedHostKey = key + emit(session, event) + return + } + val requestId = event.get("requestId").toString() + when (decision) { + HostKeyDecision.ACCEPT -> { + // Deferred authentication waits for Rust's accepted-key + // marker, rather than presenting a saved pin as proof. + if (!session.operations.deferredTerminal) { + event.put("status", "known") + emit(session, event) + } + NativeSSH.command(session.id, JSONObject() + .put("type", "hostKeyResponse").put("requestId", requestId.toLong()) + .put("generation", session.gate.generation).put("accept", true).toString()) + } + HostKeyDecision.REJECT_CHANGED -> { + emitFailure(session, "host_key_changed") + closeSession(session, "host_key_changed") + } + HostKeyDecision.ASK -> { + require(session.gate.register(requestId, "hostKey")) + session.hostKeys[requestId] = key + event.put("status", "unknown") + emit(session, event) + } + } + } + "auth" -> { + require(session.gate.register(event.get("requestId").toString(), "auth")) + emit(session, event) + } + "data", "execData" -> { + // Even cancelled/unknown exec output must be delivered and ACKed; + // dropping it here could stall this transport's shared queue. + event.put("sequence", session.outputWindow.reserve(event.getString("data").length)) + emit(session, event) + } + "execExit", "execError" -> { + val requestId = BridgeNumbers.integer(event.opt("requestId"), 1, BridgeNumbers.MAX_SAFE_INTEGER) + if (event.optString("type") == "execExit") { + require(event.opt("complete") == true) + BridgeNumbers.integer(event.opt("exitStatus"), 0, 4_294_967_295L) + } else require(event.opt("complete") == false) + session.operations.completeExec(requestId) + emit(session, event) + } + "terminalError" -> { + session.operations.terminalFailed(BridgeNumbers.integer(event.opt("requestId"), 1, BridgeNumbers.MAX_SAFE_INTEGER)) + emit(session, event) + } + else -> { + if (event.optString("type") == "state") { + when (event.optString("state")) { + "authenticating" -> Unit + "authenticated" -> { + val key = session.verifiedHostKey ?: error("unverified") + require(event.opt("deferredTerminal") == true) + session.operations.authenticated() + saveSuccessfulPassword(session) + event.put("verifiedHostKey", key).put("nativeEndpoint", JSONObject() + .put("host", session.host).put("port", session.port).put("username", session.username)) + } + "ready" -> { + if (session.operations.deferredTerminal) session.operations.ready( + BridgeNumbers.integer(event.opt("requestId"), 1, BridgeNumbers.MAX_SAFE_INTEGER), event.getString("terminalKind")) + else { session.operations.ready(); saveSuccessfulPassword(session) } + } + } + } + emit(session, event) + if (event.optString("type") == "state" && event.optString("state") in setOf("closed", "error")) { + closeSession(session, event.optString("code", "closed"), notify = false) + } + } + } + } + + private fun emit(session: Session, event: JSONObject) { + // Event batches are dispatched on the main thread, in native order. + // Final output is delivered before its closed event invalidates the gate. + if (session.gate.isActive()) deliver(event) + } + + private fun emitFailure(session: Session, code: String) { + val event = JSONObject().put("connectionId", session.id.toString()).put("generation", session.gate.generation) + .put("type", "state").put("state", "error").put("code", code).put("transportLost", false) + session.ownerId?.let { event.put("ownerId", it) } + main.post { deliver(event) } + } + + fun closeSession(session: Session, reason: String, notify: Boolean = true) { + if (!synchronized(sessionLock) { sessions.remove(session.id, session) }) return + session.gate.close() + session.operations.close() + session.hostKeys.clear() + session.outputWindow.clear() + session.pendingPassword?.fill(0); session.pendingPassword = null + ConnectionService.connectionsChanged(context, sessions.size) + try { NativeSSH.destroy(session.id) } catch (_: Throwable) { /* No secret-bearing exception logging. */ } + if (notify) { + val event = JSONObject().put("connectionId", session.id.toString()).put("generation", session.gate.generation) + .put("type", "state").put("state", "closed").put("code", reason).put("transportLost", false) + session.ownerId?.let { event.put("ownerId", it) } + main.post { deliver(event) } + } + } + + fun closeAll(reason: String) { + sessions.values.toList().forEach { closeSession(it, reason) } + clearPrivateKeys() + ConnectionService.connectionsChanged(context, sessions.size) + } + + private fun clearPrivateKeys() { + privateKeys.clear() + } + + + companion object { + @Volatile private var instance: SSHRuntime? = null + fun get(context: Context): SSHRuntime = instance ?: synchronized(this) { + instance ?: SSHRuntime(context.applicationContext).also { instance = it } + } + } +} diff --git a/mobile/android/app/src/main/java/org/tabby/android/prototype/TabbySSHPlugin.kt b/mobile/android/app/src/main/java/org/tabby/android/prototype/TabbySSHPlugin.kt index 6c6c19f344..f427e309ff 100644 --- a/mobile/android/app/src/main/java/org/tabby/android/prototype/TabbySSHPlugin.kt +++ b/mobile/android/app/src/main/java/org/tabby/android/prototype/TabbySSHPlugin.kt @@ -1,5 +1,10 @@ package org.tabby.android.prototype +import android.Manifest +import android.os.Build +import com.getcapacitor.PermissionState +import com.getcapacitor.annotation.Permission +import com.getcapacitor.annotation.PermissionCallback import android.app.Activity import android.content.ClipData import android.content.ClipboardManager @@ -29,23 +34,8 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference -@CapacitorPlugin(name = "TabbySSH") +@CapacitorPlugin(name = "TabbySSH", permissions = [Permission(alias = "notifications", strings = [Manifest.permission.POST_NOTIFICATIONS])]) class TabbySSHPlugin : Plugin() { - private data class Session( - val id: Long, - val endpoint: String, - val host: String, - val port: Int, - val username: String, - val ownerId: String?, - val gate: ConnectionGate, - val operations: SessionOperations, - val hostKeys: MutableMap = ConcurrentHashMap(), - val batchPending: AtomicBoolean = AtomicBoolean(false), - val outputWindow: OutputWindow = OutputWindow(), - @Volatile var verifiedHostKey: String? = null, - ) - private class Selection(val call: PluginCall, val scope: PickerScope) { val cancelled = AtomicBoolean(false) val settled = AtomicBoolean(false) @@ -53,11 +43,11 @@ class TabbySSHPlugin : Plugin() { } private data class SelectedDocument(val selection: Selection, val uri: Uri) - private val sessions = ConcurrentHashMap() - private val sessionLock = Any() + private lateinit var runtime: SSHRuntime + private val sessions get() = runtime.sessions + private val sessionLock get() = runtime.sessionLock private val keyboardLease = KeyboardLease() - private val privateKeys = PrivateKeyVault() - private val worker = Executors.newSingleThreadScheduledExecutor() + private val privateKeys get() = runtime.privateKeys @Volatile private var foreground = true @Volatile private var destroyed = false private val pickerPending = AtomicBoolean(false) @@ -89,29 +79,18 @@ class TabbySSHPlugin : Plugin() { ) } private var lastViewport = "" - private lateinit var hostKeyStore: HostKeyStore - private lateinit var hostKeyPolicy: HostKeyPolicy - override fun load() { - val prefs = context.getSharedPreferences("ssh_public_host_keys_v1", Context.MODE_PRIVATE) - hostKeyStore = DurableHostKeyStore(object : PublicKeyPreferences { - private fun key(endpoint: String) = MessageDigest.getInstance("SHA-256") - .digest(endpoint.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } - override fun get(endpoint: String): String? = prefs.getString(key(endpoint), null) - override fun putAndCommit(endpoint: String, key: String): Boolean = - prefs.edit().putString(this.key(endpoint), key).commit() - override fun removeAndCommit(endpoint: String) { - prefs.edit().remove(key(endpoint)).commit() - } - }) - hostKeyPolicy = HostKeyPolicy(hostKeyStore) - worker.scheduleWithFixedDelay({ pollEvents() }, 0, 16, TimeUnit.MILLISECONDS) + runtime = SSHRuntime.get(context) + // A recreated WebView has no matching parser buffer. Retain the remote tmux, + // but do not silently attach old byte streams to new DOM generations. + if (runtime.sessions.isNotEmpty()) runtime.closeAll("view_recreated") + runtime.attach { event -> notifyListeners("sshEvent", JSObject.fromJSONObject(event)) } } @PluginMethod fun start(call: PluginCall) { var allocatedId: Long? = null - var allocatedSession: Session? = null + var allocatedSession: SSHRuntime.Session? = null try { require(foreground && !destroyed) { "background" } val host = call.getString("host")?.trim() ?: error("host") @@ -141,12 +120,12 @@ class TabbySSHPlugin : Plugin() { .put("generation", generation).put("authMode", authMode) .put("cols", cols).put("rows", rows).put("term", "xterm-256color") .put("deferTerminal", deferTerminal) - hostKeyStore.read(endpoint)?.let { options.put("expectedHostKey", it) } + runtime.knownHost(endpoint)?.let { options.put("expectedHostKey", it) } val session = synchronized(sessionLock) { require(foreground && !destroyed && sessions.size < 4) val id = NativeSSH.start(options.toString()) allocatedId = id - Session(id, endpoint, host, port, username, ownerId, ConnectionGate(generation), SessionOperations(deferTerminal)).also { + SSHRuntime.Session(id, endpoint, host, port, username, ownerId, ConnectionGate(generation), SessionOperations(deferTerminal)).also { allocatedSession = it sessions[id] = it } @@ -156,6 +135,7 @@ class TabbySSHPlugin : Plugin() { call.reject("The app is in the background", "BACKGROUND") return } + ConnectionService.connectionsChanged(context, sessions.size) call.resolve(JSObject().put("connectionId", session.id.toString())) } catch (_: Throwable) { val session = allocatedSession @@ -173,11 +153,11 @@ class TabbySSHPlugin : Plugin() { @PluginMethod fun command(call: PluginCall) { val command = call.getObject("command") - var reserved: Pair? = null + var reserved: Pair? = null try { val id = call.getString("connectionId")?.toLongOrNull() ?: error("id") val session = sessions[id] ?: error("closed") - require(session.gate.isActive() && foreground && !destroyed) + require(session.gate.isActive() && !destroyed && (foreground || command?.optString("type") == "outputAck")) val input = command ?: error("command") val type = input.optString("type") if (type in setOf("exec", "execCancel", "openTerminal", "outputAck")) require(input.has("generation")) @@ -189,7 +169,7 @@ class TabbySSHPlugin : Plugin() { val requestId = input.get("requestId").toString() require(session.gate.take(requestId, "hostKey", generation)) val key = session.hostKeys.remove(requestId) ?: error("request") - val accepted = input.optBoolean("accept", false) && hostKeyPolicy.approve(session.endpoint, key) + val accepted = input.optBoolean("accept", false) && runtime.approve(session.endpoint, key) output.put("requestId", requestId.toLong()).put("accept", accepted) } "authResponse" -> { @@ -202,6 +182,17 @@ class TabbySSHPlugin : Plugin() { if (input.has("keyId")) { privateKeys.consumeText(input.getString("keyId") ?: error("key")) { output.put("privateKey", it) } } + if (input.optBoolean("useSavedPassword", false)) { + require(!input.has("password")) + val key = session.verifiedHostKey ?: error("unverified") + val bytes = runtime.secrets.get(session.host, session.port, session.username, key) ?: error("missing") + try { output.put("password", String(bytes, Charsets.UTF_8)) } finally { bytes.fill(0) } + } + if (input.optBoolean("savePassword", false)) { + require(output.has("password") && session.verifiedHostKey != null) + session.pendingPassword?.fill(0) + session.pendingPassword = output.getString("password").toByteArray(Charsets.UTF_8) + } // Do not accept raw key content from Web code; the SAF picker // owns import and the key remains in this process only. } @@ -253,7 +244,7 @@ class TabbySSHPlugin : Plugin() { } else -> error("unsupported") } - NativeSSH.command(id, output.toString()) + try { NativeSSH.command(id, output.toString()) } finally { for (field in listOf("password", "passphrase", "responses", "privateKey")) output.remove(field) } // Once native accepts a reservation, a response-delivery failure // must not erase it before its eventual completion event arrives. reserved = null @@ -275,164 +266,8 @@ class TabbySSHPlugin : Plugin() { call.resolve() } - private fun pollEvents() { - for (session in sessions.values) { - if (!session.gate.isActive() || !session.outputWindow.canPoll() || !session.batchPending.compareAndSet(false, true)) continue - try { - val batch = JSONArray(NativeSSH.poll(session.id)) - activity.runOnUiThread { - try { - for (index in 0 until batch.length()) { - if (!session.gate.isActive()) break - val event = batch.getJSONObject(index) - if (event.optLong("generation", -1) != session.gate.generation) continue - event.put("connectionId", session.id.toString()) - session.ownerId?.let { event.put("ownerId", it) } - processEvent(session, event) - } - } catch (_: Throwable) { - if (session.gate.isActive()) { - emitFailure(session, "native_bridge_error") - closeSession(session, "native_bridge_error") - } - } finally { - session.batchPending.set(false) - } - } - } catch (_: Throwable) { - session.batchPending.set(false) - if (session.gate.isActive()) { - emitFailure(session, "native_bridge_error") - closeSession(session, "native_bridge_error") - } - } - } - } - - private fun processEvent(session: Session, event: JSONObject) { - when (event.optString("type")) { - "hostKey" -> { - val key = event.getString("keyBase64") - val decision = hostKeyPolicy.inspect(session.endpoint, key) - if (event.optString("status") == "known") { - // Rust emits this only after matching the handshake against - // the native store's expectedHostKey. It needs no response. - require(decision == HostKeyDecision.ACCEPT) - val previous = session.verifiedHostKey - require(previous == null || previous == key) - session.verifiedHostKey = key - emit(session, event) - return - } - val requestId = event.get("requestId").toString() - when (decision) { - HostKeyDecision.ACCEPT -> { - // Deferred authentication waits for Rust's accepted-key - // marker, rather than presenting a saved pin as proof. - if (!session.operations.deferredTerminal) { - event.put("status", "known") - emit(session, event) - } - NativeSSH.command(session.id, JSONObject() - .put("type", "hostKeyResponse").put("requestId", requestId.toLong()) - .put("generation", session.gate.generation).put("accept", true).toString()) - } - HostKeyDecision.REJECT_CHANGED -> { - emitFailure(session, "host_key_changed") - closeSession(session, "host_key_changed") - } - HostKeyDecision.ASK -> { - require(session.gate.register(requestId, "hostKey")) - session.hostKeys[requestId] = key - event.put("status", "unknown") - emit(session, event) - } - } - } - "auth" -> { - require(session.gate.register(event.get("requestId").toString(), "auth")) - emit(session, event) - } - "data", "execData" -> { - // Even cancelled/unknown exec output must be delivered and ACKed; - // dropping it here could stall this transport's shared queue. - event.put("sequence", session.outputWindow.reserve(event.getString("data").length)) - emit(session, event) - } - "execExit", "execError" -> { - val requestId = BridgeNumbers.integer(event.opt("requestId"), 1, BridgeNumbers.MAX_SAFE_INTEGER) - if (event.optString("type") == "execExit") { - require(event.opt("complete") == true) - BridgeNumbers.integer(event.opt("exitStatus"), 0, 4_294_967_295L) - } else require(event.opt("complete") == false) - session.operations.completeExec(requestId) - emit(session, event) - } - "terminalError" -> { - session.operations.terminalFailed(BridgeNumbers.integer(event.opt("requestId"), 1, BridgeNumbers.MAX_SAFE_INTEGER)) - emit(session, event) - } - else -> { - if (event.optString("type") == "state") { - when (event.optString("state")) { - "authenticated" -> { - val key = session.verifiedHostKey ?: error("unverified") - require(event.opt("deferredTerminal") == true) - session.operations.authenticated() - event.put("verifiedHostKey", key).put("nativeEndpoint", JSONObject() - .put("host", session.host).put("port", session.port).put("username", session.username)) - } - "ready" -> { - if (session.operations.deferredTerminal) session.operations.ready( - BridgeNumbers.integer(event.opt("requestId"), 1, BridgeNumbers.MAX_SAFE_INTEGER), event.getString("terminalKind")) - else session.operations.ready() - } - } - } - emit(session, event) - if (event.optString("type") == "state" && event.optString("state") in setOf("closed", "error")) { - closeSession(session, event.optString("code", "closed"), notify = false) - } - } - } - } - - private fun emit(session: Session, event: JSONObject) { - // Event batches are dispatched on the main thread, in native order. - // Final output is delivered before its closed event invalidates the gate. - if (session.gate.isActive()) notifyListeners("sshEvent", JSObject.fromJSONObject(event)) - } - - private fun emitFailure(session: Session, code: String) { - val event = JSObject().put("connectionId", session.id.toString()).put("generation", session.gate.generation) - .put("type", "state").put("state", "error").put("code", code).put("transportLost", false) - session.ownerId?.let { event.put("ownerId", it) } - activity.runOnUiThread { notifyListeners("sshEvent", event) } - } - - private fun closeSession(session: Session, reason: String, notify: Boolean = true) { - if (!synchronized(sessionLock) { sessions.remove(session.id, session) }) return - session.gate.close() - session.operations.close() - session.hostKeys.clear() - session.outputWindow.clear() - try { NativeSSH.destroy(session.id) } catch (_: Throwable) { /* No secret-bearing exception logging. */ } - if (notify) { - val event = JSObject().put("connectionId", session.id.toString()).put("generation", session.gate.generation) - .put("type", "state").put("state", "closed").put("code", reason).put("transportLost", false) - session.ownerId?.let { event.put("ownerId", it) } - activity.runOnUiThread { notifyListeners("sshEvent", event) } - } - } - - private fun closeAll(reason: String) { - sessions.values.toList().forEach { closeSession(it, reason) } - clearPrivateKeys() - } - - private fun clearPrivateKeys() { - privateKeys.clear() - } + private fun closeSession(session: SSHRuntime.Session, reason: String, notify: Boolean = true) = runtime.closeSession(session, reason, notify) + private fun closeAll(reason: String) = runtime.closeAll(reason) override fun handleOnPause() { foreground = false @@ -440,8 +275,11 @@ class TabbySSHPlugin : Plugin() { // Opening SAF pauses this Activity. A selected document is different: // its import is cancelled by the next genuine foreground loss. if (selection.get()?.returned == true) cancelSelection() - closeAll("background") - notifyListeners("lifecycleState", JSObject().put("active", false) + if (!ConnectionService.enabled) closeAll("background") + else sessions.values.filter { !it.operations.isReady() }.forEach { closeSession(it, "background_auth_cancelled") } + sessions.values.forEach { it.pendingPassword?.fill(0); it.pendingPassword = null } + privateKeys.clear() + notifyListeners("lifecycleState", JSObject().put("active", false).put("retained", ConnectionService.enabled) .put("reason", if (pickerPending.get() && selection.get()?.cancelled?.get() == false) "privateKeyPicker" else "background")) } @@ -459,8 +297,8 @@ class TabbySSHPlugin : Plugin() { cancelSelection() selection.get()?.takeIf { !it.returned }?.let { finishSelection(it) } keyImports.close() - closeAll("destroyed") - worker.shutdownNow() + runtime.detach() + if (!ConnectionService.enabled) closeAll("destroyed") } fun emitViewport(value: JSObject) { @@ -470,6 +308,53 @@ class TabbySSHPlugin : Plugin() { notifyListeners("keyboardState", value) } + @PluginMethod + fun backgroundState(call: PluginCall) { call.resolve(JSObject().put("enabled", ConnectionService.enabled).put("notificationsAllowed", ConnectionService.notificationsAllowed(context))) } + + @PluginMethod + fun setBackground(call: PluginCall) { + if (!foreground || destroyed) { call.reject("Return to the app first", "BACKGROUND"); return } + if (call.getBoolean("enabled", false) != true) { ConnectionService.disable(context); call.resolve(JSObject().put("enabled", false)); return } + if (Build.VERSION.SDK_INT >= 33 && getPermissionState("notifications") != PermissionState.GRANTED) { + requestPermissionForAlias("notifications", call, "notificationPermissionResult"); return + } + enableBackground(call) + } + @PermissionCallback + private fun notificationPermissionResult(call: PluginCall) { enableBackground(call) } + private fun enableBackground(call: PluginCall) { + try { + require(foreground && !destroyed && ConnectionService.notificationsAllowed(context)) + ConnectionService.start(context) + val deadline = android.os.SystemClock.elapsedRealtime() + 5000 + val handler = android.os.Handler(android.os.Looper.getMainLooper()) + val observe = object : Runnable { + override fun run() { + if (!foreground || destroyed) { ConnectionService.disable(context); call.reject("Return to the app first", "BACKGROUND"); return } + if (ConnectionService.enabled) { call.resolve(JSObject().put("enabled", true)); return } + if (android.os.SystemClock.elapsedRealtime() >= deadline) { ConnectionService.disable(context); call.reject("Background service did not start", "BACKGROUND_UNAVAILABLE"); return } + handler.postDelayed(this, 25) + } + } + handler.post(observe) + } catch (_: Throwable) { call.reject("Visible notifications are required to enable background connections", "BACKGROUND_UNAVAILABLE") } + } + @PluginMethod + fun credentialStatus(call: PluginCall) { + try { call.resolve(JSObject().put("saved", runtime.secrets.has(call.getString("host")!!, call.getInt("port")!!, call.getString("username")!!))) } + catch (_: Throwable) { call.reject("Cannot read credential status", "CREDENTIAL_UNAVAILABLE") } + } + @PluginMethod + fun deletePassword(call: PluginCall) { + try { + require(foreground && !destroyed) + val host = call.getString("host")!!; val port = call.getInt("port")!!; val user = call.getString("username")!! + sessions.values.filter { it.host.equals(host, true) && it.port == port && it.username == user }.forEach { closeSession(it, "credential_deleted") } + runtime.secrets.delete(host, port, user) + call.resolve() + } catch (_: Throwable) { call.reject("Cannot delete credential", "CREDENTIAL_UNAVAILABLE") } + } + @PluginMethod fun getViewport(call: PluginCall) { activity.runOnUiThread { call.resolve((activity as MainActivity).viewportState()) } diff --git a/mobile/scripts/test-android.mjs b/mobile/scripts/test-android.mjs index 1420e2fa24..49d560ca37 100644 --- a/mobile/scripts/test-android.mjs +++ b/mobile/scripts/test-android.mjs @@ -91,10 +91,10 @@ try { privateKey: plainKey, encryptedPrivateKey: encryptedKey, privateKeyPassphrase: fixture.metadata.privateKeyPassphrase } await android.privateFile(METADATA, JSON.stringify(metadata)) if (!process.argv.includes('--webview-only')) { - const classes = ['RealSSHBridgeTest', 'AndroidHostKeyStoreTest', 'ViewportLifecycleTest'].map(name => `${APP}.${name}`).join(',') + const classes = ['RealSSHBridgeTest', 'AndroidHostKeyStoreTest', 'ViewportLifecycleTest', 'EncryptedSecretStoreTest'].map(name => `${APP}.${name}`).join(',') const command = `am instrument -w -r -e fixtureMetadata ${METADATA} -e class ${classes} ${RUNNER}` const result = await android.launch(['shell', '-T', command], { timeout: 180000 }).result - report.instrumentation = instrumentationResult(result, 7) + report.instrumentation = instrumentationResult(result, 8) console.log(`PASS Android instrumentation: ${report.instrumentation.tests} tests, no skips.`) // The native Activity tests already initialize the actual provider. // Capture only its bounded public identity before WebView boot checks, diff --git a/mobile/web/src/app.component.ts b/mobile/web/src/app.component.ts index 5e4a6e0a20..4b36588c8a 100644 --- a/mobile/web/src/app.component.ts +++ b/mobile/web/src/app.component.ts @@ -13,15 +13,28 @@ interface SessionTab { id: string, endpoint?: SessionEndpoint, binding?: TmuxBin imports: [CommonModule, SessionPaneComponent], template: `
- + +
{{notice}}
@@ -32,13 +45,15 @@ export class AppComponent implements OnDestroy { @ViewChildren(SessionPaneComponent) private panes!: QueryList tabs: SessionTab[] = [{ id: secureUUID() }] activeTabID = this.tabs[0].id - notice = '' + notice = ''; homeOpen = false; chooserOpen = false + private titlePointer?: { id: number, x: number, y: number, width: number, direction: number, moved: boolean, cancelled: boolean } + private suppressTitleClick = false private readonly registry = new MobileTmuxRegistry() private readonly bridge = inject(SSH_BRIDGE) private leaseEpoch = Date.now() * 1000 private leaseHandle?: PluginListenerHandle private destroyed = false - private readonly visibilityListener = () => { if (document.visibilityState === 'visible') { this.lease(this.activeTabID) } } + private readonly visibilityListener = () => { if (document.visibilityState === 'visible') { this.lease(this.homeOpen || this.chooserOpen ? '' : this.activeTabID) } } private store?: SavedTmuxStore readonly trackTab = (_index: number, tab: SessionTab) => tab.id get hasBoundTabs(): boolean { return this.tabs.some(tab => !!tab.binding) } @@ -64,15 +79,51 @@ export class AppComponent implements OnDestroy { this.activeTabID = this.tabs[0].id } } catch { this.notice = '此设备无法读取保存的会话身份;仍可直接连接。' } - this.lease(this.activeTabID) + this.lease(this.homeOpen || this.chooserOpen ? '' : this.activeTabID) document.addEventListener('visibilitychange', this.visibilityListener) void this.bridge.addListener('lifecycleState', event => { - if (event.active && !this.destroyed) { this.lease(this.activeTabID) } + if (event.active && !this.destroyed) { this.lease(this.homeOpen || this.chooserOpen ? '' : this.activeTabID) } }).then(handle => { if (this.destroyed) { void handle.remove() } else { this.leaseHandle = handle } }).catch(() => {}) } private lease(ownerId: string): void { void this.bridge.setActiveTab({ ownerId, epoch: ++this.leaseEpoch }).catch(() => {}) } - selectTab(id: string): void { this.lease(id); this.activeTabID = id; this.notice = '' } + selectTab(id: string): void { if (!this.tabs.some(tab => tab.id === id) || this.activePane?.modal) return; this.cancelTitle(); this.homeOpen = false; this.chooserOpen = false; this.lease(id); this.activeTabID = id; this.notice = '' } + + get activeTab(): SessionTab { return this.tabs.find(tab => tab.id === this.activeTabID)! } + get activeIndex(): number { return this.tabs.findIndex(tab => tab.id === this.activeTabID) } + get activePane(): SessionPaneComponent | undefined { return this.paneFor(this.activeTabID) } + paneFor(id: string): SessionPaneComponent | undefined { return this.panes?.find(pane => pane.tabID === id) } + home(): void { if (this.activePane?.modal) return; this.activePane?.returnHome(); this.cancelTitle(); this.lease(''); this.homeOpen = true; this.chooserOpen = false } + titleStart(event: PointerEvent): void { + if (!event.isPrimary || this.homeOpen || this.chooserOpen || this.activePane?.modal || innerWidth >= 760) { this.cancelTitle(); return } + this.titlePointer = { id: event.pointerId, x: event.clientX, y: event.clientY, width: (event.currentTarget as HTMLElement).clientWidth, direction: 0, moved: false, cancelled: false } + this.suppressTitleClick = false + } + titleMove(event: PointerEvent): void { + const point = this.titlePointer; if (!point || point.id !== event.pointerId) return + const dx = event.clientX - point.x; const dy = event.clientY - point.y + if (Math.hypot(dx, dy) <= (this.activePane?.touchSlop ?? 8)) return + point.moved = true; this.suppressTitleClick = true + if (Math.abs(dx) < 1.5 * Math.abs(dy)) { point.cancelled = true; return } + const direction = Math.sign(dx) + if (point.direction && direction !== point.direction) point.cancelled = true + point.direction ||= direction + } + titleEnd(event: PointerEvent): void { + const point = this.titlePointer; this.titlePointer = undefined + if (!point || point.id !== event.pointerId || !point.moved || point.cancelled || this.activePane?.modal) return + const dx = event.clientX - point.x + if (Math.abs(dx) < Math.max(48, point.width * .2)) return + const next = this.activeIndex + (dx < 0 ? 1 : -1) + if (next >= 0 && next < this.tabs.length) this.selectTab(this.tabs[next].id) + this.suppressTitleClick = true + } + cancelTitle(): void { this.titlePointer = undefined; this.suppressTitleClick = true } + titleClick(event: MouseEvent): void { + if (this.suppressTitleClick && event.detail) { this.suppressTitleClick = false; return } + if (this.homeOpen || this.activePane?.modal) return + this.activePane?.returnHome(); this.lease(''); this.chooserOpen = true + } newTab(sourceID?: string): void { if (this.tabs.length >= 4) { this.notice = '最多同时打开 4 个 SSH 标签页。请先关闭一个标签页。'; return } diff --git a/mobile/web/src/bridge.ts b/mobile/web/src/bridge.ts index 949b8bab48..f962f69343 100644 --- a/mobile/web/src/bridge.ts +++ b/mobile/web/src/bridge.ts @@ -6,7 +6,7 @@ export interface SSHEvent { connectionId: string generation: number ownerId?: string - type: 'state' | 'hostKey' | 'auth' | 'data' | 'exit' | 'execStarted' | 'execData' | 'execExit' | 'execError' | 'terminalError' + type: 'state' | 'hostKey' | 'auth' | 'data' | 'exit' | 'execStarted' | 'execData' | 'execExit' | 'execError' | 'terminalError' | 'credentialStatus' state?: 'connecting' | 'authenticating' | 'authenticated' | 'ready' | 'closed' | 'error' requestId?: number status?: 'unknown' | 'known' | 'changed' @@ -49,7 +49,7 @@ export interface SSHStart { export type SSHCommand = | { type: 'hostKeyResponse', requestId: number, accept: boolean } - | { type: 'authResponse', requestId: number, password?: string, keyId?: string, passphrase?: string, responses?: string[] } + | { type: 'authResponse', requestId: number, useSavedPassword?: boolean, savePassword?: boolean, password?: string, keyId?: string, passphrase?: string, responses?: string[] } | { type: 'write', data: string } | { type: 'resize', cols: number, rows: number } | { type: 'outputAck', sequence: number, generation: number } @@ -63,7 +63,7 @@ export interface SSHBridge { close(options: { connectionId: string }): Promise addListener(eventName: 'sshEvent', listener: (event: SSHEvent) => void): Promise addListener(eventName: 'keyboardState', listener: (event: { visible: boolean, height: number, viewportWidth: number, viewportHeight: number }) => void): Promise - addListener(eventName: 'lifecycleState', listener: (event: { active: boolean, reason?: 'privateKeyPicker' | 'background' }) => void): Promise + addListener(eventName: 'lifecycleState', listener: (event: { active: boolean, reason?: 'privateKeyPicker' | 'background', retained?: boolean }) => void): Promise writeClipboard(options: { text: string }): Promise readClipboard(): Promise<{ text: string }> selectPrivateKey(options?: { ownerId: string, requestId: string }): Promise<{ keyId: string, label: string }> @@ -73,7 +73,11 @@ export interface SSHBridge { setActiveTab(options: { ownerId: string, epoch: number }): Promise showKeyboard(options: { connectionId: string, generation: number }): Promise hideKeyboard(): Promise - getViewport(): Promise<{ visible: boolean, height: number, viewportWidth: number, viewportHeight: number }> + backgroundState(): Promise<{ enabled: boolean, notificationsAllowed: boolean }> + setBackground(options: { enabled: boolean }): Promise<{ enabled: boolean }> + credentialStatus(options: { host: string, port: number, username: string }): Promise<{ saved: boolean }> + deletePassword(options: { host: string, port: number, username: string }): Promise + getViewport(): Promise<{ visible: boolean, height: number, viewportWidth: number, viewportHeight: number, fontPixels?: Record, touchSlop?: number }> } export const SSH_BRIDGE = new InjectionToken('SSH bridge', { diff --git a/mobile/web/src/session-pane.component.ts b/mobile/web/src/session-pane.component.ts index ab0706d749..eea475d540 100644 --- a/mobile/web/src/session-pane.component.ts +++ b/mobile/web/src/session-pane.component.ts @@ -9,7 +9,7 @@ import { secureUUID } from './web-platform' import { MobileTmuxController, MobileTmuxError, MobileTmuxRecovery, type TmuxBinding, type TmuxSessionInfo, type TmuxSocket } from './tmux-controller' export interface SessionEndpoint { host: string, port: number, username: string, authMode: AuthMode, sessionMode: 'direct' | 'tmux' } -interface AuthFields { password: string, passphrase: string, keyId: string } +interface AuthFields { password: string, passphrase: string, keyId: string, saved?: boolean, save?: boolean } interface Prompt { text: string, echo: boolean, response: string } let paneGeneration = 0 const nextGeneration = () => ++paneGeneration @@ -21,19 +21,34 @@ const nextInputOwner = () => ++inputOwnerGeneration imports: [CommonModule, FormsModule], template: `
-
Tabby{{statusText}} - - -
+ {{statusText}} +
-

直接连接 SSH · 凭据只在当前设备内存中使用

+

连接你的服务器

- + + +
{{keyLabel || '未选择'}}

恢复 {{boundBinding.sessionID}} · {{boundBinding.selector.kind === 'default' ? '默认 socket' : boundBinding.selector.value}}。会话或服务器身份变化时停止,不会重新创建。

@@ -42,7 +57,7 @@ const nextInputOwner = () => ++inputOwnerGeneration
-

密码和私钥仅用于当前连接,不保存在 Web 存储中。进入后台会断开。

+

密码默认不保存;可选用 Android Keystore 加密保存在此设备,不参与备份。私钥文件仅用于当前连接。

选择 tmux 会话

{{username}}@{{host}}:{{port}} · 此账号下的所选 socket

@@ -64,7 +79,7 @@ const nextInputOwner = () => ++inputOwnerGeneration
-

同名创建会报错。共享和只读不会断开其他客户端;接管须单独确认。密码仅在前台内存中用于网络恢复,进入后台即清除。

+

同名创建会报错。共享和只读不会断开其他客户端;接管须单独确认。未保存的密码只在前台内存中用于网络恢复;进入后台清除。

@@ -76,22 +91,17 @@ const nextInputOwner = () => ++inputOwnerGeneration
{{selectionSnapshot}}
- - +
+
+ + +
+
+ + +
@@ -130,13 +140,15 @@ export class SessionPaneComponent implements AfterViewInit, OnDestroy { private isActive = true @Input() set active(value: boolean) { if (value === this.isActive) { return } + this.wantedKeyboard ||= document.activeElement === this.inputElement?.nativeElement this.isActive = value; ++this.interactionEpoch this.touchCancel(); this.input?.cancel(); this.inputElement?.nativeElement.blur() this.view?.cancelMouseGesture() this.inputEpochs = [nextInputOwner()] - this.ctrlHeld = false; this.selectionMode = false; this.selectionSnapshot = '' + this.clearModifiers(); this.selectionMode = false; this.selectionSnapshot = '' window.getSelection()?.removeAllRanges() - if (value) { this.updateViewport(); this.view?.fit() } + if (value) { this.updateViewport(); this.view?.fit(); if (this.wantedKeyboard) setTimeout(() => this.focusInput(), 0) } + this.actionsOpen = false; this.keysOpen = false; this.keyCancel() } get active(): boolean { return this.isActive } @Output() readonly newConnection = new EventEmitter() @@ -173,8 +185,22 @@ export class SessionPaneComponent implements AfterViewInit, OnDestroy { takeoverSession?: TmuxSessionInfo password = ''; passphrase = ''; keyId = ''; keyLabel = '' busy = false; connected = false; statusText = '未连接'; notice = '' - ctrlHeld = false; selectionMode = false; selectionSnapshot = ''; mouseMode = false + ctrlHeld = false; altHeld = false; selectionMode = false; selectionSnapshot = ''; mouseMode = false viewportHeight = 0 + fontSize = 16; private fontPixels: Record = {}; touchSlop = 8 + actionsOpen = false; keysOpen = false; hideKeys = false + savePassword = false; savedPassword = false; useSavedPassword = false + backgroundEnabled = false; backgroundPending = false; private foreground = true + readonly auxiliaryKeys = [ + { id: 'Escape', text: 'Esc', label: 'Esc' }, { id: 'Tab', text: 'Tab', label: 'Tab' }, + { id: 'Ctrl', text: 'Ctrl', label: 'Ctrl' }, { id: 'Alt', text: 'Alt', label: 'Alt' }, + { id: 'Left', text: '←', label: '向左' }, { id: 'Up', text: '↑', label: '向上' }, + { id: 'Down', text: '↓', label: '向下' }, { id: 'Right', text: '→', label: '向右' }, + ...['Home', 'End', 'PgUp', 'PgDn', 'Del', 'Ins'].map(id => ({ id, text: id, label: id })), + ] + private keyPointer?: { id: number, x: number, y: number, button: HTMLButtonElement, moved: boolean } + private suppressKeyClick = false + private wantedKeyboard = false modal?: 'hostKey' | 'auth' | 'takeover' | 'restoreTakeover' hostKeyFingerprint = ''; hostKeyAlgorithm = ''; prompts: Prompt[] = [] authInstructions = '' @@ -237,8 +263,14 @@ export class SessionPaneComponent implements AfterViewInit, OnDestroy { private pastePending?: string private readonly viewportListener = () => this.zone.run(() => this.updateViewport()) private readonly visibilityListener = () => { - if (document.visibilityState === 'hidden') { - this.zone.run(() => { this.disconnect(!this.pickerActive); this.notice = '应用进入后台,SSH 已关闭。返回后可重新连接。' }) + if (document.visibilityState === 'hidden') this.zone.run(() => this.suspend()) + } + private suspend(): void { + this.foreground = false; this.clearModifiers(); this.keyCancel(); this.touchCancel(); this.input?.cancel() + this.inputElement?.nativeElement.blur(); this.view?.cancelMouseGesture(); ++this.interactionEpoch + this.volatilePassword = undefined; this.auth = undefined; this.password = ''; this.passphrase = '' + if (!this.backgroundEnabled || !this.connected || this.modal) { + this.disconnect(!this.pickerActive); this.notice = '应用进入后台,SSH 已关闭。返回后可重新连接。' } } @@ -249,18 +281,18 @@ export class SessionPaneComponent implements AfterViewInit, OnDestroy { this.ready = this.bridge.addListener('sshEvent', event => this.zone.run(() => this.onEvent(event))) .then(handle => { if (this.destroyed) { void handle.remove() } else { this.handles.push(handle) } }) .catch(() => { this.notice = '原生 SSH 插件不可用。此页面不能在普通浏览器中连接 SSH。' }) - this.bridge.addListener('keyboardState', event => this.zone.run(() => this.updateViewport(event.viewportHeight))) + this.bridge.addListener('keyboardState', event => this.zone.run(() => { this.updateViewport(event.viewportHeight); void this.loadTypography() })) .then(handle => { if (this.destroyed) { void handle.remove() } else { this.handles.push(handle) } }) .catch(() => {}) this.bridge.addListener('lifecycleState', event => this.zone.run(() => { - if (!event.active) { this.disconnect(event.reason !== 'privateKeyPicker'); this.notice = '应用进入后台,SSH 已关闭。返回后可重新连接。' } - else { void this.bridge.getViewport().then(viewport => this.updateViewport(viewport.viewportHeight)).catch(() => {}) } + if (!event.active) { this.backgroundEnabled = event.retained === true; this.suspend() } + else { this.foreground = true; void this.loadTypography(); void this.refreshBackgroundState() } })).then(handle => { if (this.destroyed) { void handle.remove() } else { this.handles.push(handle) } }).catch(() => {}) window.visualViewport?.addEventListener('resize', this.viewportListener) window.visualViewport?.addEventListener('scroll', this.viewportListener) window.addEventListener('resize', this.viewportListener) document.addEventListener('visibilitychange', this.visibilityListener) - this.updateViewport() + this.updateViewport(); void this.loadTypography(); void this.refreshBackgroundState(); void this.refreshCredentialStatus() } async connect(automatic = false, takeover = false): Promise { @@ -282,10 +314,10 @@ export class SessionPaneComponent implements AfterViewInit, OnDestroy { this.requestedEndpoint = this.endpointValue const password = automatic ? this.volatilePassword ?? '' : this.password if (!automatic) { this.volatilePassword = this.sessionMode === 'tmux' && this.authMode === 'password' ? password : undefined } - this.auth = { password, passphrase: this.passphrase, keyId: this.keyId } + this.auth = { password, passphrase: this.passphrase, keyId: this.keyId, saved: this.useSavedPassword && !password, save: this.savePassword } this.password = ''; this.passphrase = '' this.busy = true; this.connected = false; this.statusText = '连接中'; this.notice = '' - this.events = []; this.ctrlHeld = false; this.selectionMode = false; this.hostVerified = false; this.hostKeyBlob = ''; this.lastSequence = -1 + this.events = []; this.clearModifiers(); this.selectionMode = false; this.hostVerified = false; this.hostKeyBlob = ''; this.lastSequence = -1 this.operation?.abort(); this.operation = new AbortController() this.input?.cancel(); this.inputElement?.nativeElement.blur(); this.inputEpochs = [nextInputOwner()]; this.createView() await this.ready @@ -312,6 +344,7 @@ export class SessionPaneComponent implements AfterViewInit, OnDestroy { } if (event.connectionId !== this.connectionId) { return } if (this.tmux?.onEvent(event)) { return } + if (event.type === 'credentialStatus') { this.notice = '密码保存失败。连接仍可使用,请手动重试保存。'; return } if (event.type === 'data' && event.data) { const connectionId = this.connectionId; const generation = this.generation if (!Number.isSafeInteger(event.sequence) || (event.sequence ?? -1) <= this.lastSequence) { this.fail('终端数据序列无效。'); return } @@ -347,7 +380,7 @@ export class SessionPaneComponent implements AfterViewInit, OnDestroy { const auth = this.auth; this.auth = undefined if (!auth) { this.fail('认证已取消或凭据已释放。请重新连接。'); return } void this.command({ type: 'authResponse', requestId: event.requestId!, - ...(event.mode === 'privateKey' ? { keyId: auth.keyId, passphrase: auth.passphrase } : { password: auth.password }) }) + ...(event.mode === 'privateKey' ? { keyId: auth.keyId, passphrase: auth.passphrase } : { ...(auth.saved ? { useSavedPassword: true } : { password: auth.password }), savePassword: auth.save }) }) } } else if (event.type === 'state') { if (event.state === 'authenticated') { @@ -355,7 +388,7 @@ export class SessionPaneComponent implements AfterViewInit, OnDestroy { } else if (event.state === 'ready') { if (!this.hostVerified) { this.fail('主机密钥尚未验证,连接已停止。'); return } this.auth = undefined; this.modal = undefined; this.prompts = []; this.requestId = undefined - this.connected = true; this.statusText = '已连接'; this.view?.fit() + this.connected = true; void this.refreshCredentialStatus(); this.statusText = '已连接'; this.view?.fit() // A visible-host fit can finish during authentication, when // resize commands are still gated. Synchronize those dimensions // now even if the next fit leaves the local geometry unchanged. @@ -600,13 +633,14 @@ export class SessionPaneComponent implements AfterViewInit, OnDestroy { } private sendText(text: string, applyControl = true): void { - if (!this.active || !this.connected || this.modal || this.selectionMode || this.readOnly) { return } + if (!this.active || !this.foreground || !this.connected || this.modal || this.selectionMode || this.readOnly) { return } if (this.ctrlHeld && applyControl) { const sequence = controlSequence(text) this.ctrlHeld = false if (!sequence) { this.notice = 'Ctrl 需要一个字母或符号键。'; return } text = sequence } + if (this.altHeld && applyControl) { text = '\x1b' + text; this.altHeld = false } this.writeBytes(new TextEncoder().encode(text)) } @@ -633,14 +667,15 @@ export class SessionPaneComponent implements AfterViewInit, OnDestroy { this.view = new TerminalView(this.terminalHost.nativeElement, bytes => { // TerminalView gates DOM mouse reports separately. Parser replies // keep working for inactive/readonly Tabs and never inherit Ctrl. - if (generation === this.generation && this.busy && this.hostVerified) { + if (this.foreground && generation === this.generation && this.busy && this.hostVerified) { if (this.connected) { this.writeBytes(bytes) } else if (this.protocolReplyBytes + bytes.byteLength <= 8192) { this.protocolReplies.push(bytes); this.protocolReplyBytes += bytes.byteLength } else { this.fail('SSH 初始化协议回复积压过多。') } } }, (cols, rows) => { if (generation === this.generation) { this.resize(cols, rows) } }, - () => this.active && this.connected && this.mouseMode && !this.readOnly && !this.modal && !this.selectionMode) + () => this.foreground && this.active && this.connected && this.mouseMode && !this.readOnly && !this.modal && !this.selectionMode) + this.view.setFontSize(this.fontPixels[String(this.fontSize)] ?? this.fontSize) } sendSpecial(text: string): void { @@ -654,12 +689,78 @@ export class SessionPaneComponent implements AfterViewInit, OnDestroy { } sendArrow(direction: 'A' | 'B' | 'C' | 'D'): void { - this.sendSpecial(this.ctrlHeld ? `\x1b[1;5${direction}` : arrowSequence(direction, this.view?.terminal.modes.applicationCursorKeysMode ?? false)) + const modifier = 1 + (this.ctrlHeld ? 4 : 0) + (this.altHeld ? 2 : 0) + this.sendSpecial(modifier > 1 ? `\x1b[1;${modifier}${direction}` : arrowSequence(direction, this.view?.terminal.modes.applicationCursorKeysMode ?? false)) } toggleCtrl(): void { if (this.input?.isComposing) { this.notice = '请先完成或取消当前组合输入。'; return } - this.ctrlHeld = !this.ctrlHeld + if (this.active && this.foreground && this.connected && !this.readOnly && !this.modal) this.ctrlHeld = !this.ctrlHeld + } + + clearModifiers(): void { this.ctrlHeld = false; this.altHeld = false } + toggleActions(): void { if (!this.modal) { this.actionsOpen = !this.actionsOpen; this.touchCancel(); this.keyCancel() } } + canNavigate(): boolean { return !this.modal } + returnHome(): void { this.wantedKeyboard = false; this.input?.cancel(); this.clearModifiers(); this.touchCancel(); this.keyCancel(); void this.bridge.hideKeyboard().catch(() => {}) } + activateKey(id: string): void { + if (!this.active || !this.foreground || !this.connected || this.readOnly || this.modal) return + if (this.input?.isComposing) { this.notice = '请先完成或取消当前组合输入。'; return } + if (id === 'Ctrl') { this.toggleCtrl(); return } + if (id === 'Alt') { this.altHeld = !this.altHeld; return } + const direction = ({ Left: 'D', Up: 'A', Down: 'B', Right: 'C' } as Record)[id] + if (direction) { this.sendArrow(direction); return } + const sequences: Record = { Escape: '\x1b', Tab: '\t', Home: '\x1b[H', End: '\x1b[F', PgUp: '\x1b[5~', PgDn: '\x1b[6~', Del: '\x1b[3~', Ins: '\x1b[2~' } + const text = sequences[id]; if (text) this.sendSpecial((this.altHeld ? '\x1b' : '') + text) + } + keyStart(event: PointerEvent): void { + const button = (event.target as HTMLElement).closest('button') + if (!(button instanceof HTMLButtonElement) || button.disabled || !event.isPrimary) { this.keyCancel(); return } + this.suppressKeyClick = true + this.keyPointer = { id: event.pointerId, x: event.clientX, y: event.clientY, button, moved: false } + // Preserve the genuine editor's focus while leaving horizontal touch scrolling native. + if (event.pointerType !== 'touch') event.preventDefault() + } + keyMove(event: PointerEvent): void { + const key = this.keyPointer; if (!key || key.id !== event.pointerId) return + key.moved ||= Math.hypot(event.clientX - key.x, event.clientY - key.y) > this.touchSlop + } + keyEnd(event: PointerEvent): void { + this.keyMove(event) + const key = this.keyPointer; this.keyPointer = undefined + if (!key || key.id !== event.pointerId || key.moved) return + const button = key.button.getBoundingClientRect(); const bar = key.button.parentElement!.getBoundingClientRect() + if (button.left < bar.left || button.right > bar.right || event.clientX < button.left || event.clientX > button.right || event.clientY < button.top || event.clientY > button.bottom) return + this.activateKey(key.button.dataset['key']!); this.focusInput() + } + keyCancel(): void { this.keyPointer = undefined; this.suppressKeyClick = true } + keyClick(event: MouseEvent): void { + if (this.suppressKeyClick && event.detail) { this.suppressKeyClick = false; return } + const id = (event.target as HTMLElement).closest('button')?.dataset['key']; if (id) this.activateKey(id) + } + changeFont(delta: number): void { this.fontSize = Math.max(12, Math.min(26, this.fontSize + delta)); this.view?.setFontSize(this.fontPixels[String(this.fontSize)] ?? this.fontSize) } + private async loadTypography(): Promise { + try { const viewport = await this.bridge.getViewport(); if (this.destroyed) return + this.zone.run(() => { this.fontPixels = viewport.fontPixels ?? {}; this.touchSlop = viewport.touchSlop ?? 8; this.updateViewport(viewport.viewportHeight); this.view?.setFontSize(this.fontPixels[String(this.fontSize)] ?? this.fontSize) }) + } catch {} + } + private async refreshBackgroundState(): Promise { try { const state = await this.bridge.backgroundState(); this.zone.run(() => { this.backgroundEnabled = state.enabled }) } catch {} } + async toggleBackground(): Promise { + if (this.backgroundPending) return + this.backgroundPending = true + try { const result = await this.bridge.setBackground({ enabled: !this.backgroundEnabled }); this.zone.run(() => { this.backgroundEnabled = result.enabled; this.notice = result.enabled ? '后台保持已开启,可从通知停止全部连接。' : '后台保持已关闭。' }) } + catch { this.zone.run(() => { this.backgroundEnabled = false; this.notice = '后台保持未开启。需要允许可见通知;你可以继续只在前台连接。' }) } + finally { this.zone.run(() => { this.backgroundPending = false }) } + } + async refreshCredentialStatus(): Promise { + const endpoint = this.endpointValue + if (!endpoint.host || !endpoint.username) return + try { const result = await this.bridge.credentialStatus(endpoint); if (this.host !== endpoint.host || this.username !== endpoint.username || this.port !== endpoint.port) return + this.zone.run(() => { this.savedPassword = result.saved; if (!result.saved) this.useSavedPassword = false }) } catch {} + } + async deleteSavedPassword(): Promise { + this.recovery.cancel(); this.volatilePassword = undefined; this.auth = undefined; this.password = ''; this.useSavedPassword = false + try { await this.bridge.deletePassword(this.endpointValue); this.zone.run(() => { this.savedPassword = false; this.notice = '已删除此目标的本地密码。' }) } + catch { this.zone.run(() => { this.notice = '无法删除本地密码。请重试。' }) } } keepInputFocus(event: PointerEvent): void { event.preventDefault() } diff --git a/mobile/web/src/styles.css b/mobile/web/src/styles.css index 44b21309ff..5530a79cf2 100644 --- a/mobile/web/src/styles.css +++ b/mobile/web/src/styles.css @@ -1,5 +1,5 @@ @import '@xterm/xterm/css/xterm.css'; -:root { color-scheme: dark; font: 15px system-ui, sans-serif; background: #171b24; color: #eee; } +:root { color-scheme: dark; font: 16px system-ui, sans-serif; background: #171b24; color: #eee; } * { box-sizing: border-box; } html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; } .session-workspace { height: 100vh; height: 100dvh; display: flex; flex-direction: column; min-width: 0; } @@ -11,7 +11,7 @@ html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; } .session-tab.selected > button[role=tab] { background: #165765; border-color: #8cffff; } .workspace-notice { flex: 0 0 auto; padding: .3rem .65rem; font-size: .78rem; background: #3c3422; } button, input, select, textarea { font: inherit; } -button { min-height: 44px; min-width: 44px; padding: .4rem .65rem; color: #eee; background: #293343; border: 1px solid #536076; border-radius: 7px; touch-action: manipulation; } +button { min-height: 48px; min-width: 48px; padding: .4rem .65rem; color: #eee; background: #293343; border: 1px solid #536076; border-radius: 7px; touch-action: manipulation; } button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid #8cffff; outline-offset: 2px; } button.primary, button.active { background: #165765; border-color: #8cffff; } .app-shell { height: 100vh; height: 100dvh; display: flex; flex-direction: column; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); } @@ -33,7 +33,7 @@ header strong { color: #8cffff; font-size: 1.15rem; } button:disabled { opacity: .5; } .connect-panel p { margin: .4rem 0; } form label { display: flex; flex-direction: column; gap: .3rem; margin: .5rem 0; } -input, select { min-height: 42px; padding: .45rem; width: 100%; background: #212936; color: #fff; border: 1px solid #607088; border-radius: 6px; } +input, select { min-height: 48px; padding: .45rem; width: 100%; background: #212936; color: #fff; border: 1px solid #607088; border-radius: 6px; } .endpoint-row { display: flex; gap: .7rem; } .endpoint-row label:first-child { flex: 1; min-width: 0; } .port { width: 90px; flex: 0 0 90px; } @@ -49,7 +49,7 @@ input, select { min-height: 42px; padding: .45rem; width: 100%; background: #212 .actions button { flex: 1 0 auto; font-size: .76rem; padding-inline: .25rem; white-space: nowrap; } .input-strip { display: flex; align-items: center; gap: 5px; padding: 3px 5px; flex: 0 0 auto; background: #202733; } .input-strip span { font-size: .75rem; color: #bcc9db; } -.input-strip textarea { flex: 1; min-width: 0; height: 44px; resize: none; padding: 10px; border: 1px solid #607088; border-radius: 5px; color: #eee; background: #171b24; } +.input-strip textarea { flex: 1; min-width: 0; height: 48px; resize: none; padding: 10px; border: 1px solid #607088; border-radius: 5px; color: #eee; background: #171b24; } /* Keep the static terminal host and generation-owned input in the DOM, but reserve their layout space only while connecting or connected. */ .terminal-area[hidden], .tools[hidden], .actions[hidden], .input-strip[hidden] { display: none; } @@ -60,25 +60,6 @@ input, select { min-height: 42px; padding: .45rem; width: 100%; background: #212 .modal-card code { display: block; overflow-wrap: anywhere; padding: .7rem; border: 1px solid #607088; border-radius: 6px; } .modal-card p { font-size: .9rem; line-height: 1.5; } .modal-card div { display: flex; gap: 10px; justify-content: flex-end; } -@media (max-height: 450px) { header { padding-block: 0; } .tools button, .actions button, .input-strip textarea { min-height: 36px; height: 36px; } .input-strip span { display: none; } } -/* Keep one terminal row and the input reachable when a keyboard or small - landscape window leaves too little height for both toolbar rows. */ -@media (max-height: 180px) { - .app-shell.terminal-active { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr) auto auto auto; } - .terminal-active > header { grid-column: 1 / -1; grid-row: 1; } - .terminal-active > .terminal-area { grid-column: 1 / -1; grid-row: 2; } - .terminal-active > .tools { grid-column: 1; grid-row: 3; } - .terminal-active > .actions { grid-column: 2; grid-row: 3; } - .terminal-active > .input-strip { grid-column: 1 / -1; grid-row: 4; } - .terminal-active > .notice { grid-column: 1 / -1; grid-row: 5; } -} -.app-shell.terminal-active.short-pane { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr) auto auto auto; } -.terminal-active.short-pane > header { grid-column: 1 / -1; grid-row: 1; } -.terminal-active.short-pane > .terminal-area { grid-column: 1 / -1; grid-row: 2; } -.terminal-active.short-pane > .tools { grid-column: 1; grid-row: 3; } -.terminal-active.short-pane > .actions { grid-column: 2; grid-row: 3; } -.terminal-active.short-pane > .input-strip { grid-column: 1 / -1; grid-row: 4; } -.terminal-active.short-pane > .notice { grid-column: 1 / -1; grid-row: 5; } @media (min-width: 760px) { .connect-panel form, .tmux-panel { max-width: 900px; width: 100%; margin-inline: auto; } .tmux-session-list li > div { flex-basis: 300px; } @@ -86,3 +67,35 @@ input, select { min-height: 42px; padding: .45rem; width: 100%; background: #212 .socket-form label { flex: 1; } .socket-form button { margin-bottom: .5rem; } } + +.workspace-header { height: 48px; padding: 0; gap: 0; background: #202733; } +.workspace-header > button { border: 0; border-radius: 0; height: 48px; background: transparent; } +.session-title { flex: 1; min-width: 0; text-align: left; touch-action: pan-y; overflow: hidden; } +.session-title strong, .session-title span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.session-title strong { color: #eee; font-size: 16px; } +.session-title span { font-size: 14px; color: #bcc9db; } +.session-tabs { display: none; padding: 0; } +.session-tabs button[aria-selected=true] { border-color: #8cffff; background: #165765; } +.pane-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } +.connection-workbench { padding: 16px; flex: 1; overflow-y: auto; } +.connection-workbench h1 { font-size: 20px; } +.workbench-session { display: flex; gap: 8px; margin-bottom: 12px; } +.workbench-session button:first-child { flex: 1; text-align: left; overflow-wrap: anywhere; } +.actions-panel { position: absolute; right: 8px; top: 52px; max-width: min(480px, calc(100% - 16px)); max-height: calc(100% - 64px); overflow-y: auto; z-index: 5; padding: 12px; display: flex; flex-wrap: wrap; gap: 8px; background: #212936; border: 1px solid #607088; border-radius: 12px; } +.actions-panel p { width: 100%; font-size: 14px; line-height: 1.5; } +.key-row { height: 48px; flex: 0 0 48px; display: flex; min-width: 0; background: #202733; } +.key-row[hidden] { display: none; } +.tools { padding: 0; gap: 0; flex: 1; } +.tools button { flex: 0 0 56px; border-radius: 0; border-width: 0 1px 0 0; min-height: 48px; } +.modifier-chips { display: flex; flex-shrink: 0; } +.modifier-chips button { padding-inline: 4px; background: #165765; } +.all-keys { flex: 0 0 48px; border-radius: 0; } +.all-keys-panel { position: absolute; bottom: 56px; left: 8px; right: 8px; z-index: 6; display: flex; flex-wrap: wrap; gap: 8px; padding: 12px; background: #212936; border: 1px solid #607088; border-radius: 12px; } +.all-keys-panel button { min-width: 56px; } +.input-strip { padding-block: 0; height: 48px; } +.input-strip span { display: none; } +.input-strip textarea { border: 0; border-radius: 0; padding: 10px; font-size: 16px; } +.input-strip button { height: 48px; border-radius: 0; } +.check-label { flex-direction: row; align-items: center; min-height: 48px; font-size: 14px; } +.check-label input { width: 24px; min-height: 24px; flex: 0 0 24px; } +@media (min-width: 760px) { .session-title { flex: 0 1 260px; } .session-tabs { display: flex; flex: 1; min-width: 0; } .session-tabs button { flex: 0 0 auto; } } diff --git a/mobile/web/src/terminal-view.ts b/mobile/web/src/terminal-view.ts index a46102cf02..789f4e3b2b 100644 --- a/mobile/web/src/terminal-view.ts +++ b/mobile/web/src/terminal-view.ts @@ -10,7 +10,7 @@ export class TerminalView { allowProposedApi: false, logLevel: 'off', disableStdin: false, - fontFamily: 'monospace', fontSize: 14, cursorBlink: true, + fontFamily: 'monospace', fontSize: 16, cursorBlink: true, scrollback: 2000, theme: { background: '#171b24', foreground: '#eeeeee', cursor: '#8cffff', black: colors[0], red: colors[1], green: colors[2], yellow: colors[3], blue: colors[4], @@ -66,6 +66,10 @@ export class TerminalView { this.fit() } + setFontSize(pixels: number): void { + if (Number.isFinite(pixels) && pixels > 0 && this.terminal.options.fontSize !== pixels) { this.terminal.options.fontSize = pixels; this.fit() } + } + fit(): void { if (this.disposed) { return } if (this.resizeTimer !== undefined) { clearTimeout(this.resizeTimer) } diff --git a/mobile/web/tests/harness.ts b/mobile/web/tests/harness.ts index ee92115fcd..f343f59752 100644 --- a/mobile/web/tests/harness.ts +++ b/mobile/web/tests/harness.ts @@ -137,6 +137,13 @@ export class TestBridge implements SSHBridge { async setActiveTab(options: { ownerId: string, epoch: number }): Promise { this.activeLeases.push(options) } async showKeyboard(options: { connectionId: string, generation: number }): Promise { this.keyboardRequests.push(options) } async hideKeyboard(): Promise {} + backgroundEnabled = false + savedPassword = false + deletedPasswords: unknown[] = [] + async backgroundState() { return { enabled: this.backgroundEnabled, notificationsAllowed: true } } + async setBackground(options: { enabled: boolean }) { this.backgroundEnabled = options.enabled; return { enabled: options.enabled } } + async credentialStatus() { return { saved: this.savedPassword } } + async deletePassword(options: unknown) { this.deletedPasswords.push(options); this.savedPassword = false } async getViewport() { return { visible: false, height: 0, viewportWidth: innerWidth, viewportHeight: innerHeight } } } @@ -146,7 +153,7 @@ window.attackMarker = [] const nativeListeners = new Map Promise }>() let callbackSequence = 0 const methods = ['start', 'command', 'close', 'writeClipboard', 'readClipboard', 'selectPrivateKey', - 'discardPrivateKey', 'cancelPrivateKeySelection', 'setActiveTab', 'showKeyboard', 'hideKeyboard', 'getViewport', 'removeListener'] + 'discardPrivateKey', 'cancelPrivateKeySelection', 'setActiveTab', 'showKeyboard', 'hideKeyboard', 'getViewport', 'backgroundState', 'setBackground', 'credentialStatus', 'deletePassword', 'removeListener'] ;(window as unknown as { Capacitor: unknown }).Capacitor = { PluginHeaders: [{ name: 'TabbySSH', methods: [ ...methods.map(name => ({ name, rtype: 'promise' })), { name: 'addListener', rtype: 'callback' }, From d641f1de124787c291487ba6bf775e0c2efc0c99 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 8 Oct 2026 16:01:07 +0000 Subject: [PATCH 02/16] fix(android): bind saved authentication and verify mobile lifecycle interactions --- .github/workflows/android-prototype.yml | 1 + .../android/prototype/ConnectionService.kt | 1 + .../tabby/android/prototype/MainActivity.kt | 11 ++ .../org/tabby/android/prototype/SSHRuntime.kt | 12 +- .../tabby/android/prototype/TabbySSHPlugin.kt | 29 +++-- mobile/scripts/test-android-tmux.mjs | 14 ++- mobile/scripts/test-android-webview.mjs | 109 ++++++++++++++-- mobile/scripts/verify-android-apk.py | 11 +- mobile/scripts/verify-android-delivery.py | 4 +- mobile/test/test_android_delivery.py | 2 +- mobile/web/src/app.component.ts | 15 ++- mobile/web/src/bridge.ts | 3 + mobile/web/src/session-pane.component.ts | 52 +++++--- mobile/web/src/styles.css | 7 ++ mobile/web/tests/harness.ts | 3 +- mobile/web/tests/input.spec.ts | 45 ++++++- mobile/web/tests/mobile-product.spec.ts | 117 ++++++++++++++++++ mobile/web/tests/tmux-ui.spec.ts | 22 +++- 18 files changed, 402 insertions(+), 56 deletions(-) create mode 100644 mobile/web/tests/mobile-product.spec.ts diff --git a/.github/workflows/android-prototype.yml b/.github/workflows/android-prototype.yml index 944036c001..aa114b4aeb 100644 --- a/.github/workflows/android-prototype.yml +++ b/.github/workflows/android-prototype.yml @@ -259,6 +259,7 @@ jobs: mobile/artifacts/Tabby-Android-Emulator.apk.sha256 mobile/artifacts/android-emulator-apk-verification.json mobile/artifacts/android-runtime-report.json + mobile/artifacts/runtime-screenshots/*.png mobile/artifacts/android-delivery-verification.json if-no-files-found: error retention-days: 14 diff --git a/mobile/android/app/src/main/java/org/tabby/android/prototype/ConnectionService.kt b/mobile/android/app/src/main/java/org/tabby/android/prototype/ConnectionService.kt index 336b83ff81..1160204265 100644 --- a/mobile/android/app/src/main/java/org/tabby/android/prototype/ConnectionService.kt +++ b/mobile/android/app/src/main/java/org/tabby/android/prototype/ConnectionService.kt @@ -25,6 +25,7 @@ class ConnectionService : Service() { return START_NOT_STICKY } try { + detaching = false val manager = getSystemService(NotificationManager::class.java) manager.createNotificationChannel(NotificationChannel(CHANNEL, "SSH 后台连接", NotificationManager.IMPORTANCE_LOW)) val notification = notification(SSHRuntime.get(this).sessions.size) diff --git a/mobile/android/app/src/main/java/org/tabby/android/prototype/MainActivity.kt b/mobile/android/app/src/main/java/org/tabby/android/prototype/MainActivity.kt index 910c0cf41b..2180bf143d 100644 --- a/mobile/android/app/src/main/java/org/tabby/android/prototype/MainActivity.kt +++ b/mobile/android/app/src/main/java/org/tabby/android/prototype/MainActivity.kt @@ -4,6 +4,9 @@ import android.content.res.Configuration import android.os.Bundle import android.util.TypedValue import android.view.ViewConfiguration +import androidx.activity.OnBackPressedCallback +import android.view.inputmethod.InputMethodManager +import android.content.Context import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat @@ -15,6 +18,14 @@ class MainActivity : BridgeActivity() { override fun onCreate(savedInstanceState: Bundle?) { registerPlugin(TabbySSHPlugin::class.java) super.onCreate(savedInstanceState) + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + val insets = ViewCompat.getRootWindowInsets(window.decorView) + if (insets?.isVisible(WindowInsetsCompat.Type.ime()) == true) { + (getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(bridge.webView.windowToken, 0) + } else (bridge?.getPlugin("TabbySSH")?.instance as? TabbySSHPlugin)?.emitBack() + } + }) // Also use explicit edge-to-edge on older supported Android versions, // so framework fitting cannot duplicate this Activity's safe-area padding. WindowCompat.setDecorFitsSystemWindows(window, false) diff --git a/mobile/android/app/src/main/java/org/tabby/android/prototype/SSHRuntime.kt b/mobile/android/app/src/main/java/org/tabby/android/prototype/SSHRuntime.kt index 1cfa33dbb7..8dd360eef3 100644 --- a/mobile/android/app/src/main/java/org/tabby/android/prototype/SSHRuntime.kt +++ b/mobile/android/app/src/main/java/org/tabby/android/prototype/SSHRuntime.kt @@ -27,6 +27,7 @@ class SSHRuntime private constructor(val context: Context) { val hostKeys: MutableMap = ConcurrentHashMap(), val batchPending: AtomicBoolean = AtomicBoolean(false), val outputWindow: OutputWindow = OutputWindow(), + @Volatile var approvedHostKey: String? = null, @Volatile var pendingPassword: ByteArray? = null, @Volatile var verifiedHostKey: String? = null, ) @@ -38,6 +39,7 @@ class SSHRuntime private constructor(val context: Context) { val secrets = EncryptedSecretStore(context) private val main = Handler(Looper.getMainLooper()) private val worker = Executors.newSingleThreadScheduledExecutor() + private var pollTask: java.util.concurrent.ScheduledFuture<*>? = null @Volatile private var sink: ((JSONObject) -> Unit)? = null private lateinit var hostKeyStore: HostKeyStore private lateinit var hostKeyPolicy: HostKeyPolicy @@ -54,12 +56,12 @@ class SSHRuntime private constructor(val context: Context) { } }) hostKeyPolicy = HostKeyPolicy(hostKeyStore) - worker.scheduleWithFixedDelay({ pollEvents() }, 0, 16, TimeUnit.MILLISECONDS) } fun knownHost(endpoint: String): String? = hostKeyStore.read(endpoint) fun approve(endpoint: String, key: String) = hostKeyPolicy.approve(endpoint, key) - fun attach(listener: (JSONObject) -> Unit) { sink = listener } - fun detach() { sink = null } + fun attach(listener: (JSONObject) -> Unit) { sink = listener; if (sessions.isNotEmpty()) connectionAdded() } + fun detach() { sink = null; synchronized(sessionLock) { pollTask?.cancel(false); pollTask = null } } + fun connectionAdded() { synchronized(sessionLock) { if (pollTask == null && sink != null) pollTask = worker.scheduleWithFixedDelay({ pollEvents() }, 0, 16, TimeUnit.MILLISECONDS) } } private fun deliver(event: JSONObject) { sink?.invoke(event) } private fun saveSuccessfulPassword(session: Session) { val bytes = session.pendingPassword ?: return @@ -127,6 +129,7 @@ class SSHRuntime private constructor(val context: Context) { event.put("status", "known") emit(session, event) } + session.approvedHostKey = key NativeSSH.command(session.id, JSONObject() .put("type", "hostKeyResponse").put("requestId", requestId.toLong()) .put("generation", session.gate.generation).put("accept", true).toString()) @@ -144,6 +147,8 @@ class SSHRuntime private constructor(val context: Context) { } } "auth" -> { + // Authentication challenges are emitted only after completed host KEX. + if (session.verifiedHostKey == null) session.verifiedHostKey = session.approvedHostKey ?: error("unverified") require(session.gate.register(event.get("requestId").toString(), "auth")) emit(session, event) } @@ -208,6 +213,7 @@ class SSHRuntime private constructor(val context: Context) { fun closeSession(session: Session, reason: String, notify: Boolean = true) { if (!synchronized(sessionLock) { sessions.remove(session.id, session) }) return + if (sessions.isEmpty()) synchronized(sessionLock) { pollTask?.cancel(false); pollTask = null } session.gate.close() session.operations.close() session.hostKeys.clear() diff --git a/mobile/android/app/src/main/java/org/tabby/android/prototype/TabbySSHPlugin.kt b/mobile/android/app/src/main/java/org/tabby/android/prototype/TabbySSHPlugin.kt index f427e309ff..98c868e5ba 100644 --- a/mobile/android/app/src/main/java/org/tabby/android/prototype/TabbySSHPlugin.kt +++ b/mobile/android/app/src/main/java/org/tabby/android/prototype/TabbySSHPlugin.kt @@ -50,6 +50,7 @@ class TabbySSHPlugin : Plugin() { private val privateKeys get() = runtime.privateKeys @Volatile private var foreground = true @Volatile private var destroyed = false + @Volatile private var notificationPermissionPending = false private val pickerPending = AtomicBoolean(false) private val selectionLock = Any() private val selection = AtomicReference() @@ -135,6 +136,7 @@ class TabbySSHPlugin : Plugin() { call.reject("The app is in the background", "BACKGROUND") return } + runtime.connectionAdded() ConnectionService.connectionsChanged(context, sessions.size) call.resolve(JSObject().put("connectionId", session.id.toString())) } catch (_: Throwable) { @@ -170,6 +172,7 @@ class TabbySSHPlugin : Plugin() { require(session.gate.take(requestId, "hostKey", generation)) val key = session.hostKeys.remove(requestId) ?: error("request") val accepted = input.optBoolean("accept", false) && runtime.approve(session.endpoint, key) + if (accepted) session.approvedHostKey = key output.put("requestId", requestId.toLong()).put("accept", accepted) } "authResponse" -> { @@ -275,14 +278,21 @@ class TabbySSHPlugin : Plugin() { // Opening SAF pauses this Activity. A selected document is different: // its import is cancelled by the next genuine foreground loss. if (selection.get()?.returned == true) cancelSelection() - if (!ConnectionService.enabled) closeAll("background") + if (!ConnectionService.enabled && !notificationPermissionPending) closeAll("background") else sessions.values.filter { !it.operations.isReady() }.forEach { closeSession(it, "background_auth_cancelled") } sessions.values.forEach { it.pendingPassword?.fill(0); it.pendingPassword = null } privateKeys.clear() - notifyListeners("lifecycleState", JSObject().put("active", false).put("retained", ConnectionService.enabled) + notifyListeners("lifecycleState", JSObject().put("active", false).put("retained", ConnectionService.enabled || notificationPermissionPending) .put("reason", if (pickerPending.get() && selection.get()?.cancelled?.get() == false) "privateKeyPicker" else "background")) } + override fun handleOnStop() { + if (notificationPermissionPending && !ConnectionService.enabled) { + closeAll("background") + notifyListeners("lifecycleState", JSObject().put("active", false).put("retained", false).put("reason", "background")) + } + } + override fun handleOnResume() { foreground = true notifyListeners("lifecycleState", JSObject().put("active", true)) @@ -308,36 +318,41 @@ class TabbySSHPlugin : Plugin() { notifyListeners("keyboardState", value) } + fun emitBack() { notifyListeners("backAction", JSObject()) } + @PluginMethod + fun leaveApp(call: PluginCall) { activity.runOnUiThread { activity.moveTaskToBack(true); call.resolve() } } + @PluginMethod fun backgroundState(call: PluginCall) { call.resolve(JSObject().put("enabled", ConnectionService.enabled).put("notificationsAllowed", ConnectionService.notificationsAllowed(context))) } @PluginMethod fun setBackground(call: PluginCall) { if (!foreground || destroyed) { call.reject("Return to the app first", "BACKGROUND"); return } - if (call.getBoolean("enabled", false) != true) { ConnectionService.disable(context); call.resolve(JSObject().put("enabled", false)); return } + if (call.getBoolean("enabled", false) != true) { ConnectionService.disable(context); notifyListeners("backgroundState", JSObject().put("enabled", false)); call.resolve(JSObject().put("enabled", false)); return } if (Build.VERSION.SDK_INT >= 33 && getPermissionState("notifications") != PermissionState.GRANTED) { + notificationPermissionPending = true requestPermissionForAlias("notifications", call, "notificationPermissionResult"); return } enableBackground(call) } @PermissionCallback - private fun notificationPermissionResult(call: PluginCall) { enableBackground(call) } + private fun notificationPermissionResult(call: PluginCall) { notificationPermissionPending = false; enableBackground(call) } private fun enableBackground(call: PluginCall) { try { - require(foreground && !destroyed && ConnectionService.notificationsAllowed(context)) + require(foreground && !destroyed && sessions.values.any { it.operations.isReady() } && ConnectionService.notificationsAllowed(context)) ConnectionService.start(context) val deadline = android.os.SystemClock.elapsedRealtime() + 5000 val handler = android.os.Handler(android.os.Looper.getMainLooper()) val observe = object : Runnable { override fun run() { if (!foreground || destroyed) { ConnectionService.disable(context); call.reject("Return to the app first", "BACKGROUND"); return } - if (ConnectionService.enabled) { call.resolve(JSObject().put("enabled", true)); return } + if (ConnectionService.enabled) { notifyListeners("backgroundState", JSObject().put("enabled", true)); call.resolve(JSObject().put("enabled", true)); return } if (android.os.SystemClock.elapsedRealtime() >= deadline) { ConnectionService.disable(context); call.reject("Background service did not start", "BACKGROUND_UNAVAILABLE"); return } handler.postDelayed(this, 25) } } handler.post(observe) - } catch (_: Throwable) { call.reject("Visible notifications are required to enable background connections", "BACKGROUND_UNAVAILABLE") } + } catch (_: Throwable) { notifyListeners("backgroundState", JSObject().put("enabled", false)); call.reject("Visible notifications and a ready connection are required to enable background connections", "BACKGROUND_UNAVAILABLE") } } @PluginMethod fun credentialStatus(call: PluginCall) { diff --git a/mobile/scripts/test-android-tmux.mjs b/mobile/scripts/test-android-tmux.mjs index f8f3d084ae..fc25bce01c 100644 --- a/mobile/scripts/test-android-tmux.mjs +++ b/mobile/scripts/test-android-tmux.mjs @@ -236,6 +236,11 @@ export async function tmuxWebviewAcceptance (android, fixture) { page = undefined } async function nativeTouch (locator) { + if (!await locator.isVisible()) { + const more = page.getByRole('button', { name: '更多终端操作', exact: true }) + if (await more.isVisible() && !await more.isDisabled() && !await page.locator('.actions-panel').isVisible()) await nativeTouch(more) + } + const deadline = deadlineFor(10000) await observeReadUntil(() => locator.scrollIntoViewIfNeeded({ timeout: Math.min(3000, deadline - Date.now()) }), deadline, 'ANDROID_TMUX_TOUCH_TARGET_DID_NOT_STABILIZE') let previous @@ -316,7 +321,7 @@ export async function tmuxWebviewAcceptance (android, fixture) { await refresh() } async function ready () { - await wait(async () => await active().locator('header .status').textContent() === '已连接' + await wait(async () => await active().locator('.pane-status').textContent() === '已连接' && await active().getByLabel('终端输入', { exact: true }).isEnabled(), 'ANDROID_TMUX_TERMINAL_NOT_READY') return observe(() => page.evaluate(() => { // Called immediately after this pane becomes ready. Switching back @@ -333,7 +338,7 @@ export async function tmuxWebviewAcceptance (android, fixture) { await active().getByLabel('访问方式', { exact: true }).selectOption(mode) await nativeTouch(active().getByRole('button', { name: `连接 ${name}`, exact: true })) if (mode === 'readonly') { - await wait(async () => await active().locator('header .status').textContent() === '已连接' + await wait(async () => await active().locator('.pane-status').textContent() === '已连接' && await active().getByLabel('终端输入', { exact: true }).isDisabled(), 'ANDROID_TMUX_READONLY_NOT_READY') } else { await ready() } } @@ -398,7 +403,7 @@ export async function tmuxWebviewAcceptance (android, fixture) { arrayAt: typeof Array.prototype.at === 'function', abortSignalAny: typeof window.AbortSignal?.any === 'function', cssDynamicViewport: typeof window.CSS?.supports === 'function' && window.CSS.supports('height', '100dvh') }, bootstrapFallback: document.body?.textContent?.includes('界面无法启动。请重新打开应用。') === true, - status: statusNames.get(pane?.querySelector('header .status')?.textContent) || 'UNRECOGNIZED', + status: statusNames.get(pane?.querySelector('.pane-status')?.textContent) || 'UNRECOGNIZED', notice: notice ? noticeNames.get(notice) || 'OTHER_FIXED_UI_NOTICE' : 'NONE', tmuxUnavailableHint: [...(pane?.querySelectorAll('.tmux-panel .hint') || [])].some(element => element.textContent === '服务器没有 tmux。可使用普通 SSH;应用不会安装软件。'), @@ -469,7 +474,7 @@ export async function tmuxWebviewAcceptance (android, fixture) { await disconnect() await active().getByLabel('恢复访问方式', { exact: true }).selectOption('readonly') await connect({ restore: true }) - await wait(async () => await active().locator('header .status').textContent() === '已连接' && await active().getByLabel('终端输入', { exact: true }).isDisabled(), 'ANDROID_TMUX_READONLY_NOT_READY') + await wait(async () => await active().locator('.pane-status').textContent() === '已连接' && await active().getByLabel('终端输入', { exact: true }).isDisabled(), 'ANDROID_TMUX_READONLY_NOT_READY') check(await active().getByRole('button', { name: '粘贴', exact: true }).isDisabled(), 'ANDROID_TMUX_READONLY_INPUT_ENABLED') const flags = (await run(`${base} list-clients -F ${quote('#{client_readonly}')}`)).trim().split('\n').sort() check(JSON.stringify(flags) === JSON.stringify(['0', '1']) && !other.closed(), 'ANDROID_TMUX_REAL_READONLY_FLAG_MISSING') @@ -562,6 +567,7 @@ export async function tmuxWebviewAcceptance (android, fixture) { } catch (error) { return error?.code === 'SSH_COMMAND_REJECTED' } }, old)) check(rejected, 'ANDROID_TMUX_CANCELLED_OLD_GENERATION_ACCEPTED') + if (!await page.getByRole('tab', { name: 'isolation_android', exact: true }).isVisible()) await nativeTouch(page.getByRole('button', { name: '选择会话', exact: true })) await nativeTouch(page.getByRole('tab', { name: 'isolation_android', exact: true })) await ready(); await marker('isolation_android', '__ANDROID_OTHER_TAB_ALIVE__') await wait(() => fixture.stats().timers === 0 && fixture.stats().execs === 0, 'ANDROID_TMUX_CANCELLED_EXEC_RESOURCES_REMAIN') diff --git a/mobile/scripts/test-android-webview.mjs b/mobile/scripts/test-android-webview.mjs index a7f72d1064..0fcd03f820 100644 --- a/mobile/scripts/test-android-webview.mjs +++ b/mobile/scripts/test-android-webview.mjs @@ -1,3 +1,5 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' import { createRequire } from 'node:module' import { APP, RUNNER, DONE, INPUT, METADATA, check, until, pause, TestFailure, instrumentationResult, observeUntil, observeReadUntil } from './test-android-utils.mjs' @@ -121,6 +123,15 @@ export async function webviewAcceptance (android, fixture) { let rotationGeometry let systemIME + async function capture (name) { + // Explicitly requested runtime images; synthetic fixture only, never auth forms. + const directory = fileURLToPath(new URL('../artifacts/runtime-screenshots/', import.meta.url)) + await mkdir(directory, { recursive: true }) + const encoded = await android.command(['exec-out', 'sh', '-c', 'screencap -p | base64'], { timeout: 15000 }) + const bytes = Buffer.from(encoded.replace(/\s/g, ''), 'base64') + check(bytes.length > 8 && bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10])), 'ANDROID_SCREENSHOT_INVALID') + await writeFile(directory + name + '.png', bytes) + } const verify = label => { passed.push(label); console.log(`PASS Android WebView: ${label}.`) } async function step (name, action) { substage = name @@ -194,7 +205,7 @@ export async function webviewAcceptance (android, fixture) { cssDynamicViewport: typeof window.CSS?.supports === 'function' && window.CSS.supports('height', '100dvh') }, bootstrapFallback: document.body?.textContent?.includes('界面无法启动。请重新打开应用。') === true, documentReady: ['loading', 'interactive', 'complete'].includes(document.readyState) ? document.readyState : 'unknown', - status: statusNames.get(document.querySelector('header .status')?.textContent) || 'UNRECOGNIZED', + status: statusNames.get(document.querySelector('.pane-status')?.textContent) || 'UNRECOGNIZED', notice: notice ? noticeNames.get(notice) || 'OTHER_FIXED_UI_NOTICE' : 'NONE', formVisible: visible('.connect-panel form'), hostInputCount: document.querySelectorAll('input[name="host"]').length, @@ -276,6 +287,11 @@ export async function webviewAcceptance (android, fixture) { await until(() => ['clients', 'sessions', 'ptys', 'timers', 'pendingAuth'].every(key => fixture.stats()[key] === 0), 'ANDROID_FIXTURE_RESOURCES_NOT_RELEASED') } async function nativeTouch (locator, durationMs = 100, enclosingDeadline = Infinity) { + if (!await locator.isVisible()) { + const more = page.getByRole('button', { name: '更多终端操作', exact: true }) + if (await more.isVisible() && !await more.isDisabled() && !await page.locator('.actions-panel').isVisible()) await nativeTouch(more) + } + const deadline = Math.min(Date.now() + 10000, enclosingDeadline) const inTime = () => check(Date.now() < deadline, 'ANDROID_TOUCH_TARGET_DID_NOT_STABILIZE') await page.evaluate(() => { @@ -350,11 +366,12 @@ export async function webviewAcceptance (android, fixture) { if (windows.clipboardOverlayVisible) { observed = true; clearSince = undefined if (!dismissalSent) { - const target = page.locator('header .status') + const target = page.getByRole('button', { name: '更多终端操作', exact: true }) check(await target.count() === 1, 'ANDROID_CLIPBOARD_DISMISS_TARGET_AMBIGUOUS') inTime() dismissalSent = true await nativeTouch(target, 100, deadline) + if (await page.locator('.actions-panel').isVisible()) await nativeTouch(target, 100, deadline) inTime() } } @@ -582,7 +599,7 @@ export async function webviewAcceptance (android, fixture) { harness = undefined await quiet() } - async function connect (known, mode = 'password') { + async function connect (known, mode = 'password', credential = 'transient') { const authenticatedBefore = fixture.stats().authenticated await step('form-host', () => page.getByLabel('主机', { exact: true }).fill('127.0.0.1')) await step('form-port', () => page.getByLabel('端口', { exact: true }).fill(String(fixture.metadata.port))) @@ -591,7 +608,14 @@ export async function webviewAcceptance (android, fixture) { // name. Match the actual form control instead of an exact short label. await step('form-auth-mode', () => page.locator('select[name="authMode"]').selectOption(mode)) if (mode === 'password') { - await step('form-password', () => page.getByLabel('密码', { exact: true }).fill(fixture.metadata.password)) + await step('form-password', () => page.getByLabel('密码', { exact: true }).fill(credential === 'saved' ? '' : fixture.metadata.password)) + if (credential === 'save') await nativeTouch(page.getByLabel('认证成功后保存 / 更新密码(默认不保存)')) + if (credential === 'saved') { + await page.getByLabel('密码', { exact: true }).focus() + await page.getByLabel('使用此设备已保存的密码(留空输入框)').waitFor() + await plugin('hideKeyboard') + await nativeTouch(page.getByLabel('使用此设备已保存的密码(留空输入框)')) + } } await step('form-hide-ime', () => plugin('hideKeyboard')) await step('form-ime-hidden', () => until(async () => !(await viewport()).visible, 'ANDROID_FORM_IME_DID_NOT_HIDE')) @@ -605,7 +629,7 @@ export async function webviewAcceptance (android, fixture) { await step('host-key-native-trust', () => nativeTouch(page.getByRole('button', { name: '核对后信任', exact: true }))) } if (mode === 'password') { - await step('ssh-ready', () => until(async () => await page.locator('header .status').textContent() === '已连接', 'ANDROID_WEBVIEW_SSH_NOT_READY')) + await step('ssh-ready', () => until(async () => await page.locator('.pane-status').textContent() === '已连接', 'ANDROID_WEBVIEW_SSH_NOT_READY')) substage = 'web-storage-password-absence' check(await page.evaluate(password => { const saved = [localStorage, sessionStorage].flatMap(storage => Object.keys(storage).map(key => storage.getItem(key) || '')) @@ -615,7 +639,7 @@ export async function webviewAcceptance (android, fixture) { } async function disconnect () { const button = page.getByRole('button', { name: '断开或取消连接', exact: true }) - if (await button.count()) { await nativeTouch(button) } + if (await page.locator('.connect-panel').count() === 0) { await nativeTouch(page.getByRole('button', { name: '更多终端操作', exact: true })); await nativeTouch(button) } await quiet() } async function setting (namespace, name, value) { @@ -714,6 +738,7 @@ export async function webviewAcceptance (android, fixture) { await sendLine('stty -echo') await sendLine("printf '%s%s\\n' 'W_DIRECT_' '中文🙂_OK'") await output('W_DIRECT_中文🙂_OK') + await capture('terminal') verify('actual Angular UI → Capacitor → native SSH → Unicode PTY') stage = 'native-composition-and-auxiliary-keys' @@ -898,6 +923,7 @@ export async function webviewAcceptance (android, fixture) { check(shown.height > 0 && shown.viewportHeight < hidden.viewportHeight, 'ANDROID_IME_VIEWPORT_DID_NOT_SHRINK') substage = 'keyboard-real-pty-rows-decreased' check(sizeShown.rows < sizeBefore.rows, 'ANDROID_IME_DID_NOT_RESIZE_REMOTE_PTY') + await capture('terminal-keyboard') await step('keyboard-hide-after-shown-size', () => plugin('hideKeyboard')) await step('keyboard-hidden-before-rotation', () => until(async () => !(await viewport()).visible, 'ANDROID_IME_DID_NOT_HIDE_AFTER_SHOW')) await step('rotation-disable-automatic', () => setting('system', 'accelerometer_rotation', 0)) @@ -942,6 +968,7 @@ export async function webviewAcceptance (android, fixture) { const sizeRotated = await size(false, 'rotated') substage = 'rotation-real-pty-cols-changed' check(sizeRotated.cols !== sizeBefore.cols, 'ANDROID_ROTATION_DID_NOT_RESIZE_REMOTE_PTY') + await capture('terminal-rotated') verify('actual AOSP system keyboard show/hide and rotation update WebView and SSH PTY dimensions') stage = 'background-and-auth-cancel' @@ -976,7 +1003,7 @@ export async function webviewAcceptance (android, fixture) { await fixture.command({ type: 'dropConnections' }) await step('network-loss-ui-closed', () => until(async () => await page.getByRole('button', { name: '连接', exact: true }).count() === 1 - && await page.locator('header .status').textContent() === '未连接' + && await page.locator('.pane-status').textContent() === '未连接' && await page.locator('textarea[aria-label="终端输入"]:enabled').count() === 0, 'ANDROID_NETWORK_LOSS_DID_NOT_FAIL_CLOSED')) await quiet() @@ -996,6 +1023,74 @@ export async function webviewAcceptance (android, fixture) { verify('actual Activity background closes resources; canceled auth rejects old responses and reconnects') await endHarness() + stage = 'foreground-service-notification' + await startHarness() + await connect(true) + const api = Number(await android.shell('getprop ro.build.version.sdk')) + async function systemButton (pattern, failure) { + // This runner only accepts emulator-* serials. Normal dialog/shade UI; + // no pm grant, appops changes, battery changes, or physical device access. + let point + await until(async () => { + await android.shell('uiautomator dump /data/local/tmp/tabby-owned-dialog.xml', { timeout: 12000 }) + const xml = await android.shell('cat /data/local/tmp/tabby-owned-dialog.xml') + const nodes = xml.match(/]*>/g) || [] + const node = nodes.find(value => pattern.test(value)) + const bounds = node && /bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/.exec(node) + if (!bounds) return false + point = { x: (Number(bounds[1]) + Number(bounds[3])) / 2, y: (Number(bounds[2]) + Number(bounds[4])) / 2 } + return true + }, failure, 20000) + await android.shell(`input tap ${point.x} ${point.y}`) + await android.shell('rm -f /data/local/tmp/tabby-owned-dialog.xml') + } + async function requestBackground () { + await nativeTouch(page.getByRole('button', { name: '更多终端操作', exact: true })) + await nativeTouch(page.getByRole('button', { name: '开启后台保持', exact: true })) + } + if (api >= 33) { + await requestBackground() + await systemButton(/resource-id="(?:com\.android|com\.google\.android)\.permissioncontroller:id\/permission_deny_button"/, 'ANDROID_NOTIFICATION_DENY_DIALOG_MISSING') + await until(async () => !(await plugin('backgroundState')).enabled && await page.getByRole('button', { name: '开启后台保持', exact: true }).isEnabled(), 'ANDROID_NOTIFICATION_DENIAL_NOT_RESOLVED') + check(await page.locator('.pane-status').textContent() === '已连接', 'ANDROID_NOTIFICATION_DIALOG_CLOSED_SSH') + // Menu remains open after this setting. Close it before opening for retry. + await nativeTouch(page.getByRole('button', { name: '更多终端操作', exact: true })) + } + await requestBackground() + if (api >= 33) await systemButton(/resource-id="(?:com\.android|com\.google\.android)\.permissioncontroller:id\/permission_allow_button"/, 'ANDROID_NOTIFICATION_ALLOW_DIALOG_MISSING') + await until(async () => (await plugin('backgroundState')).enabled === true, 'ANDROID_FOREGROUND_SERVICE_NOT_ENABLED') + await android.shell('input keyevent KEYCODE_HOME') + await pause(2000) + check(fixture.stats().clients === 1 && fixture.stats().ptys === 1, 'ANDROID_FOREGROUND_SERVICE_LOST_BACKGROUND_SSH') + await android.shell(`am start -n ${APP}/.MainActivity`) + await until(async () => await page.locator('.pane-status').textContent() === '已连接', 'ANDROID_FOREGROUND_SERVICE_LOST_FOREGROUND_SSH') + await nativeTouch(page.getByRole('button', { name: '更多终端操作', exact: true })) + await sendLine("printf '%s%s\\n' 'W_BACKGROUND_' 'RETAINED'") + await output('W_BACKGROUND_RETAINED') + await android.shell('cmd statusbar expand-notifications') + await capture('connection-notification') + await systemButton(/text="停止全部"[^>]*package="com\.android\.systemui"|package="com\.android\.systemui"[^>]*text="停止全部"/, 'ANDROID_NOTIFICATION_STOP_ACTION_MISSING') + await quiet() + await android.shell('cmd statusbar collapse') + await android.shell(`am start -n ${APP}/.MainActivity`) + await until(async () => !(await plugin('backgroundState')).enabled, 'ANDROID_USER_STOP_DID_NOT_DISABLE_SERVICE') + await endHarness() + verify('normal notification permission decisions, real background foreground retention and notification Stop All') + + stage = 'native-encrypted-password' + await startHarness() + await connect(true, 'password', 'save') + check((await plugin('credentialStatus', { host: '127.0.0.1', port: fixture.metadata.port, username: fixture.metadata.username })).saved === true, 'ANDROID_PASSWORD_NOT_SAVED') + await disconnect() + await connect(true, 'password', 'saved') + await sendLine("printf '%s%s\\n' 'W_VAULT_' 'NATIVE_LOGIN'") + await output('W_VAULT_NATIVE_LOGIN') + await disconnect() + await plugin('deletePassword', { host: '127.0.0.1', port: fixture.metadata.port, username: fixture.metadata.username }) + check((await plugin('credentialStatus', { host: '127.0.0.1', port: fixture.metadata.port, username: fixture.metadata.username })).saved === false, 'ANDROID_PASSWORD_NOT_DELETED') + await endHarness() + verify('optional native Keystore password save, secret-free saved login and deletion') + stage = 'durable-pin-fresh-process' await step('durable-first-process-force-stop', () => android.shell(`am force-stop ${APP}`)) const secondPID = await step('durable-start-fresh-harness', () => beginHarness(firstPID)) diff --git a/mobile/scripts/verify-android-apk.py b/mobile/scripts/verify-android-apk.py index 4b8627a7ba..38647b631f 100644 --- a/mobile/scripts/verify-android-apk.py +++ b/mobile/scripts/verify-android-apk.py @@ -83,11 +83,18 @@ def main(): require("minSdkVersion:'26'" in badging and "targetSdkVersion:'36'" in badging, 'Wrong Android SDK range') require('application-debuggable' in badging, 'This verifier only describes the prototype debug APK') permissions = re.findall(r"^uses-permission: name='([^']+)'", badging, re.M) - expected = {'android.permission.INTERNET', APP + '.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION'} + expected = {'android.permission.INTERNET', 'android.permission.FOREGROUND_SERVICE', 'android.permission.FOREGROUND_SERVICE_SPECIAL_USE', 'android.permission.POST_NOTIFICATIONS', APP + '.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION'} require(set(permissions) == expected, 'Unexpected packaged permission set') manifest = run(str(tools / 'aapt2'), 'dump', 'xmltree', '--file', 'AndroidManifest.xml', str(apk)) for flag in ['allowBackup', 'fullBackupContent', 'usesCleartextTraffic', 'extractNativeLibs']: require(re.search(r':' + flag + r'\([^\n]+\)=false', manifest), 'Unsafe or absent packaged flag: ' + flag) + service_blocks = re.findall(r'(?m)^( +)E: service[^\n]*\n((?:(?!\1E:)[^\n]*\n)*)', manifest) + require(len(service_blocks) == 1, 'Exactly one connection service is required') + service = service_blocks[0][1] + require('ConnectionService' in service and re.search(r':exported\([^\n]+\)=false', service), 'Connection service must be private') + require(re.search(r':foregroundServiceType\([^\n]+\)=0x40000000', service), 'Connection service must use specialUse only') + require('android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE' in service, 'Connection service purpose must be declared') + require(not re.search(r'E: receiver\b', manifest), 'Automatic receivers are not allowed') require('protectionLevel(0x01010009)=0x00000002' in manifest, 'Receiver permission must remain signature protected') run(str(tools / 'zipalign'), '-c', '-P', '16', '4', str(apk)) signature = run(str(tools / 'apksigner'), 'verify', '--verbose', '--print-certs', str(apk)) @@ -109,7 +116,7 @@ def main(): if name.endswith('.dex'): dex = archive.read(name) for test_class in ['CloudWebViewHarness', 'RealSSHBridgeTest', 'AndroidHostKeyStoreTest', - 'ViewportLifecycleTest', 'SecurityPolicyTest', 'PrivateKeyImportTest', + 'ViewportLifecycleTest', 'EncryptedSecretStoreTest', 'SecurityPolicyTest', 'PrivateKeyImportTest', 'DeferredSSHBridgeTest', 'NativeSessionIsolationTest', 'SessionOperationsTest']: require(('Lorg/tabby/android/prototype/' + test_class + ';').encode() not in dex, 'Instrumentation class packaged in main DEX: ' + test_class) config = json.loads(archive.read('assets/capacitor.config.json')) diff --git a/mobile/scripts/verify-android-delivery.py b/mobile/scripts/verify-android-delivery.py index fae543e536..5f7035e26c 100644 --- a/mobile/scripts/verify-android-delivery.py +++ b/mobile/scripts/verify-android-delivery.py @@ -30,6 +30,8 @@ 'actual AOSP system keyboard show/hide and rotation update WebView and SSH PTY dimensions', 'real TCP loss closes Android UI/resources, rejects old writes and permits explicit reconnect', 'actual Activity background closes resources; canceled auth rejects old responses and reconnects', + 'normal notification permission decisions, real background foreground retention and notification Stop All', + 'optional native Keystore password save, secret-free saved login and deletion', 'durable native host-key pin survives a fresh process and rejects same-endpoint replacement', ] TMUX_WEBVIEW_CASES = [ @@ -155,7 +157,7 @@ def verify_runtime(runtime, tested_sha): and 'failure' not in runtime, 'RUNTIME_NOT_PASSED') native = runtime.get('instrumentation') require(type(native) is dict and native.get('passed') is True - and type(native.get('tests')) is int and native['tests'] == 7 + and type(native.get('tests')) is int and native['tests'] == 8 and type(native.get('skipped')) is int and native['skipped'] == 0, 'NATIVE_TESTS_INCOMPLETE') web = runtime.get('webview') diff --git a/mobile/test/test_android_delivery.py b/mobile/test/test_android_delivery.py index 5cca5efcea..a5635ef053 100644 --- a/mobile/test/test_android_delivery.py +++ b/mobile/test/test_android_delivery.py @@ -54,7 +54,7 @@ def setUp(self): self.emulator_receipt = self.build(self.emulator, self.emulator_entries, ['arm64-v8a', 'x86_64']) self.runtime = { 'suite': 'real-android-emulator', 'passed': True, 'scope': 'native-and-webview', - 'instrumentation': {'passed': True, 'tests': 7, 'skipped': 0}, + 'instrumentation': {'passed': True, 'tests': 8, 'skipped': 0}, 'webview': {'passed': True, 'cases': delivery.WEBVIEW_CASES.copy(), 'skipped': 0}, 'tmux': {'passed': True, 'instrumentation': {'passed': True, 'tests': 4, 'skipped': 0}, 'webview': {'passed': True, 'cases': delivery.TMUX_WEBVIEW_CASES.copy(), 'skipped': 0}}, diff --git a/mobile/web/src/app.component.ts b/mobile/web/src/app.component.ts index 4b36588c8a..0fc2756e52 100644 --- a/mobile/web/src/app.component.ts +++ b/mobile/web/src/app.component.ts @@ -19,7 +19,7 @@ interface SessionTab { id: string, endpoint?: SessionEndpoint, binding?: TmuxBin {{homeOpen ? '连接工作台' : tabLabel(activeTab)}} · {{activeIndex + 1}}/{{tabs.length}} {{activePane?.username}}@{{activePane?.host}} · {{activePane?.statusText}} -