diff --git a/.github/workflows/android-prototype.yml b/.github/workflows/android-prototype.yml index cd0e6f1dcf..72f3f8f0d6 100644 --- a/.github/workflows/android-prototype.yml +++ b/.github/workflows/android-prototype.yml @@ -1,10 +1,12 @@ name: Android prototype checks and APK on: push: + # Product commits are checked by their draft PR with an exact head checkout. + # Keep the original prototype push route; avoid duplicate candidate runs. branches: [prototype/android-direct-ssh] 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 +16,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: @@ -73,7 +75,9 @@ jobs: run: npm run test:delivery --prefix mobile - name: Packaged WebView policy rejection tests if: always() && steps.install.outcome == 'success' - run: python3 -m unittest discover -s mobile/test -p test_android_web_security.py -v + run: | + python3 -m unittest discover -s mobile/test -p test_android_web_security.py -v + python3 -m unittest discover -s mobile/test -p test_android_manifest_policy.py -v - name: Rust safety and real SSH integration id: native if: always() && steps.install.outcome == 'success' @@ -94,7 +98,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 +140,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: @@ -237,6 +241,14 @@ jobs: if [ -x "$ANDROID_HOME/platform-tools/adb" ]; then "$ANDROID_HOME/platform-tools/adb" -s emulator-5554 emu kill || true fi + - name: Upload fixture-only runtime images on acceptance failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: Android-Runtime-Images-api${{ matrix.api }}-${{ matrix.form }}-${{ github.event.pull_request.head.sha || github.sha }} + path: mobile/artifacts/runtime-screenshots/*.png + if-no-files-found: ignore + retention-days: 14 - name: Build the ARM64 delivery APK working-directory: mobile/android run: ./gradlew --no-daemon -PtabbyAbis=arm64-v8a assembleDebug @@ -259,6 +271,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/README.md b/mobile/README.md index 62f41c19aa..fc84b00730 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -1,4 +1,4 @@ -# Android SSH and tmux prototype +# Android mobile SSH candidate This branch starts from desktop commit `fb869900e1ac6da0ff577a53ab7084778d6389bc`. Mobile code is isolated here; @@ -17,6 +17,46 @@ Mobile saved host-key identities use the native verified public-key blob; desktop retains its existing algorithm/fingerprint representation. The shared core does not define a cross-platform saved-identity import format. +## Candidate interaction and lifecycle + +This product batch is based on design SHA `667663dbeb6b8439dd26471a3fa0be64ab814d7f`. +It preserves the device-tested prototype `ded417ba852295f3ad76bcca4e6e557d1288ca12`. +Home returns to the connection workbench without disconnecting. Phones switch +adjacent sessions by dragging only the central title; larger windows show scrolling +Tabs. Terminal swipes remain local history gestures. A single scrolling auxiliary +key row has 48dp targets, one-shot Ctrl/Alt and an accessible all-keys panel. Dragged +or cancelled key gestures send no bytes. Short landscape hides this row and retains +the 48dp genuine editor. Font controls use Android's nonlinear sp conversion and +resize the actual terminal/PTY cells; each pane retains its own size. + +Background retention is off by default and starts only from the foreground with +an established connection. Android 13+ asks through the normal notification +permission dialog; denial keeps the foreground connection available. The product +requires visible notifications even though Android itself permits foreground +services without notification permission. The private `specialUse` service has a +generic notification and Stop All action, uses `START_NOT_STICKY`, and declares its +interactive SSH purpose. There is no boot receiver, battery-setting change, wake +lock or task rescheduling. This is a sideload candidate; specialUse distribution +review is outside this draft's scope. + +Activity recreation cannot preserve the old WebView parser. On reopening, old +native streams close and saved tmux identity is offered for manual recovery; +remote tmux sessions are never killed. Background transport loss requires a +foreground return. Foreground tmux restoration allows at most six backoffs +(1/2/4/8/16/30 seconds) and 120 seconds elapsed; auth, host-key, account/session +identity or occupied-session checks pause it. Plain SSH requires an explicit new +shell. Input queued before backgrounding or a Tab switch is not replayed. The +existing native and renderer output limits remain bounded; overflow closes with +an error rather than silently dropping terminal output. Process death never +promises connection survival or complete output history. + +Generated-key creation, encrypted persistent private keys and confirmed public-key +installation remain a later batch; this APK does not perform enrollment. Existing +plain/encrypted private-key imports are preserved. No real credentials are accepted +by this development task. Runtime images and tests use disposable test fixtures; +only emulator serials are accepted by the runner. Notification decisions use the +normal UI in fresh disposable emulators and never use `pm grant`. + ## Supported scope - Password, ordinary/encrypted OpenSSH private-key and keyboard-interactive @@ -26,8 +66,9 @@ core does not define a cross-platform saved-identity import format. - A real textarea for system IME composition; terminal rendering, touch scroll, long-press text selection, native clipboard and Ctrl/Esc/Tab/arrow buttons. - Viewport-driven PTY resize, connection cancellation, bounded output/input - queues and generation checks. Backgrounding closes all connections and - clears transient credentials. Each Tab owns its own native SSH transport. + queues and generation checks. Backgrounding clears transient credentials. + Connections close by default; an explicit visible foreground service can retain + established connections while switching apps. Each Tab owns its own native SSH transport. - Optional tmux discovery on the authenticated account and chosen socket, explicit session selection or atomic named creation, shared/read-only access, and explicit takeover. Ordinary SSH remains available; missing tmux is never @@ -44,7 +85,9 @@ core does not define a cross-platform saved-identity import format. takeover do not start automatic recovery. Occupied automatic recovery pauses without detaching another client. Private-key and keyboard-interactive sessions require explicit credentials/import on recovery; passwords are only kept in - foreground memory. No background SSH or hard fencing is promised. + foreground memory unless optional native password storage is selected. Background + retention is best effort; system termination and network changes can disconnect. + No hard fencing is promised. The configured minimum is Android 8 / API 26; compile/target SDK is 36. The default application build targets ARM64. The cloud emulator build explicitly @@ -55,7 +98,7 @@ bar/cutout or visible IME inset, including navigation space when the IME is visible with zero height. Child WebView system-bar/cutout insets are cleared after native padding to avoid applying them twice. Capacitor's automatic inset handler is disabled; the final WebView layout still drives terminal/PTY resize. -Jump hosts, SSH agents, X11, host certificates and background SSH remain +Jump hosts, SSH agents, X11, host certificates and generated-key enrollment remain outside this mobile prototype. Real exec control channels are separate from the PTY terminal. A framed response is accepted only after native complete channel close with an exit status; cancellation/time/output limits fail closed and do @@ -71,7 +114,13 @@ window transitions and keyboard-height changes while preserving the session and updating PTY dimensions; they do not establish a physical fold transition. Record the device's installed Android/WebView/IME versions during acceptance. -Credentials are neither logged nor saved to profiles, localStorage or files. +Credentials are never logged or saved to profiles or Web storage. Optional saved +passwords use Android Keystore AES-256-GCM and app-private `noBackupFilesDir`, +bound to normalized host/port/account and the verified host-key blob. They are +written only after successful authentication. Failed authentication leaves the +previous entry intact; deletion cancels connections to that target before removing +the entry. Secret bytes are cleared where practical; temporary JVM/JNI strings +cannot be guaranteed to be zeroized. Public host keys and explicitly saved tmux identities are persistent SSH data; saved identity JSON never contains passwords, private keys, key handles or output. Private keys are bounded to 64 KiB, RSA keys to 8192 bits and encrypted OpenSSH bcrypt cost to 64 rounds; @@ -162,12 +211,28 @@ input-event categories and focus booleans, never editor text. The history-render gate also retains its last bounded geometry and a fixed synthetic-marker visibility flag before the later swipe initializes its baseline. Its original readiness predicate and deadline remain; no terminal contents are reported. -When SystemUI's clipboard preview is observed, the harness sends one normal -native outside touch to the passive app status label, using the existing -app-window, focus, visibility and stable-geometry guards. It then still requires -350ms of observed preview absence within the original 10-second phase budget. -This changes no SystemUI settings, permissions or IME configuration and does -not touch a foreign window, retry the gesture or synthesize DOM activation. +When SystemUI's clipboard preview is observed, the harness selects the normal +close button only inside the exact SystemUI clipboard container. If that control +is absent, it uses one guarded native outside touch to the app's More button. +The read-only hierarchy lists system windows because a clipboard preview need +not own the active window; control selection remains confined to that container. +See [AOSP's window hierarchy command](https://android.googlesource.com/platform/prebuilts/fullsdk/sources/+/88c7ff1cd72d6305ec59f97aadc2198cc2dc3592/android-34/com/android/commands/uiautomator/DumpCommand.java). +It still requires 350ms of observed preview absence within the original +10-second phase budget. Notification Stop similarly belongs to this app's exact +notification card; a collapsed card is normally expanded once before clicking +Stop. These bounded actions change no SystemUI settings, permissions or IME +configuration and never dismiss an ANR, security dialog or foreign notification. +Independent cloud WebView phases finish their owned instrumentation and restart +only the disposable app process, retaining native public pins and encrypted +fixture records. They cannot reuse a cached debug page from a destroyed WebView. +The actual lifecycle and network checks run within each phase; focus emulation +is disabled and the native focus gates remain mandatory on every new attach. +Rebuilt connection forms must also have native foreground focus and stable +controls before synthetic fixture entry, then retain exact endpoint readback +for 350ms before the one native submission. These observations remain inside +the existing harness budget; they neither repair input values nor replay failed +submissions. Failure diagnostics contain only field-validity/match booleans and +fixed per-field validation categories, never host, account or credential values. See [Android's device-lock API](https://developer.android.com/reference/android/app/KeyguardManager#isDeviceLocked()) and [TrustManager's direct user dump](https://github.com/aosp-mirror/platform_frameworks_base/blob/master/services/core/java/com/android/server/trust/TrustManagerService.java). The 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/CloudWebViewHarness.kt b/mobile/android/app/src/androidTest/java/org/tabby/android/prototype/CloudWebViewHarness.kt index 1a58c7499e..6c34ebe69f 100644 --- a/mobile/android/app/src/androidTest/java/org/tabby/android/prototype/CloudWebViewHarness.kt +++ b/mobile/android/app/src/androidTest/java/org/tabby/android/prototype/CloudWebViewHarness.kt @@ -102,11 +102,14 @@ class CloudWebViewHarness { val context = InstrumentationRegistry.getInstrumentation().targetContext require(context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) val done = File(context.filesDir, "tabby-cloud-webview.done") + require(!args.containsKey("cloudReadyFile") || args.getString("cloudReadyFile") == "tabby-cloud-webview.ready") + val ready = args.getString("cloudReadyFile")?.let { File(context.filesDir, it) } val input = File(context.filesDir, "tabby-cloud-input.json") val result = File(context.filesDir, "tabby-cloud-input.result.json") val resultTemporary = File(context.filesDir, "tabby-cloud-input.result.tmp") val inputEnabled = args.getString("cloudInputFile") == "tabby-cloud-input.json" done.delete() + ready?.delete() input.delete() result.delete() resultTemporary.delete() @@ -119,6 +122,9 @@ class CloudWebViewHarness { WebView.setWebContentsDebuggingEnabled(true) } val deadline = System.nanoTime() + 180_000_000_000L + // Setup no longer erases an early command from the next owned + // harness. This signal grants no window or editor focus. + ready?.writeText("READY") while (!done.isFile && System.nanoTime() < deadline) { if (inputEnabled && input.isFile) { var ok = false @@ -210,6 +216,7 @@ class CloudWebViewHarness { WebView.setWebContentsDebuggingEnabled(false) } done.delete() + ready?.delete() input.delete() result.delete() resultTemporary.delete() 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..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 @@ -2,6 +2,11 @@ package org.tabby.android.prototype 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 @@ -13,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) @@ -52,6 +65,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..e8d16e543a --- /dev/null +++ b/mobile/android/app/src/main/java/org/tabby/android/prototype/SSHRuntime.kt @@ -0,0 +1,254 @@ +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 approvedHostKey: String? = null, + @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() + 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 + 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) + } + 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; 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) = synchronized(sessionLock) { + val bytes = session.pendingPassword ?: return@synchronized + session.pendingPassword = null + // Serialize against session removal during deletion. Once removal + // completes, a delayed authentication event cannot recreate the entry. + try { + if (!session.gate.isActive() || sessions[session.id] !== session) return@synchronized + 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) + } + session.approvedHostKey = key + 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" -> { + // 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) + } + "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 + if (sessions.isEmpty()) synchronized(sessionLock) { pollTask?.cancel(false); pollTask = null } + 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..e2f1e8837b 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,13 +43,14 @@ 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 + @Volatile private var notificationPermissionPending = false private val pickerPending = AtomicBoolean(false) private val selectionLock = Any() private val selection = AtomicReference() @@ -89,29 +80,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 +121,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 +136,8 @@ 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) { val session = allocatedSession @@ -173,11 +155,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 +171,8 @@ 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) + if (accepted) session.approvedHostKey = key output.put("requestId", requestId.toLong()).put("accept", accepted) } "authResponse" -> { @@ -202,6 +185,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 +247,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 +269,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,11 +278,22 @@ 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 && !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 || notificationPermissionPending) .put("reason", if (pickerPending.get() && selection.get()?.cancelled?.get() == false) "privateKeyPicker" else "background")) } + override fun handleOnStop() { + if (notificationPermissionPending && !ConnectionService.enabled) { + notificationPermissionPending = false + 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)) @@ -459,8 +308,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 +319,77 @@ 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); 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) { + // Activity Result can arrive before onResume. Permission is never + // treated as permission to start a foreground service from background. + val deadline = android.os.SystemClock.elapsedRealtime() + 5000 + val handler = android.os.Handler(android.os.Looper.getMainLooper()) + val observe = object : Runnable { + override fun run() { + if (!notificationPermissionPending || destroyed || !ConnectionService.notificationsAllowed(context) + || android.os.SystemClock.elapsedRealtime() >= deadline) { + notificationPermissionPending = false + notifyListeners("backgroundState", JSObject().put("enabled", false)) + call.reject("Return to the app with visible notifications to enable background connections", "BACKGROUND_UNAVAILABLE") + return + } + if (foreground) { notificationPermissionPending = false; enableBackground(call); return } + handler.postDelayed(this, 25) + } + } + handler.post(observe) + } + private fun enableBackground(call: PluginCall) { + try { + 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) { 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) { 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) { + 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/package.json b/mobile/package.json index 89239fe730..f16694c148 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -9,7 +9,7 @@ "scripts": { "dev": "npm run build && node scripts/serve-web.mjs --test-harness", "build": "ng build mobile --configuration production && node scripts/verify-web-build.mjs --fresh", - "check": "ngc --noEmit -p web/tsconfig.app.json && tsc --noEmit -p web/tsconfig.tests.json", + "check": "ngc --noEmit -p web/tsconfig.app.json && tsc --noEmit -p web/tsconfig.tests.json && node --test test/android-acceptance-static.test.mjs test/android-system-ui.test.mjs", "test:build": "node scripts/verify-web-build.mjs", "test:toolchain": "node scripts/verify-capacitor-toolchain.mjs", "test:web": "playwright test --config web/playwright.config.ts", diff --git a/mobile/scripts/android-system-ui.mjs b/mobile/scripts/android-system-ui.mjs new file mode 100644 index 0000000000..cff44ab8c9 --- /dev/null +++ b/mobile/scripts/android-system-ui.mjs @@ -0,0 +1,41 @@ +// Read-only selection of controls on this app's notification or test clipboard. +export function systemUIActionPoint (xml, action) { + if (typeof xml !== 'string' || xml.length > 2 * 1024 * 1024) return undefined + const stack = []; const nodes = [] + for (const token of xml.match(/]*>|<\/node>/g) || []) { + if (token === '') { if (!stack.length) return undefined; stack.pop(); continue } + const attrs = Object.fromEntries([...token.matchAll(/([\w-]+)="([^"<>]*)"/g)].map(match => [match[1], match[2]])) + const node = { attrs, parent: stack.at(-1), children: [] } + node.parent?.children.push(node); nodes.push(node) + if (!token.endsWith('/>')) stack.push(node) + } + if (stack.length) return undefined + const below = node => [node, ...node.children.flatMap(below)] + let controls + if (action === 'clipboardDismiss') { + const roots = nodes.filter(node => node.attrs['resource-id'] === 'com.android.systemui:id/clipboard_ui') + if (roots.length !== 1) return undefined + controls = below(roots[0]).filter(node => node.attrs['resource-id'] === 'com.android.systemui:id/dismiss_button') + } else { + const titles = nodes.filter(node => node.attrs.text === 'Tabby · 后台连接') + if (titles.length !== 1) return undefined + let card = titles[0].parent + while (card && !/\/status_bar_latest_event_content$/.test(card.attrs['resource-id'] || '')) card = card.parent + if (!card) return undefined + const descendants = below(card) + controls = action === 'notificationStop' ? descendants.filter(node => node.attrs.text === '停止全部') + : action === 'notificationExpand' ? descendants.filter(node => /\/expand_button(?:_touch_container)?$/.test(node.attrs['resource-id'] || '')) : [] + // Prefer the dedicated touch container over its child image. + if (action === 'notificationExpand' && controls.some(node => /_touch_container$/.test(node.attrs['resource-id']))) { + controls = controls.filter(node => /_touch_container$/.test(node.attrs['resource-id'])) + } + } + if (controls.length !== 1) return undefined + const item = controls[0].attrs + if (!['com.android.systemui', 'org.tabby.android.prototype', 'android'].includes(item.package) || item.enabled === 'false') return undefined + const bounds = /^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$/.exec(item.bounds || '') + if (!bounds) return undefined + const [left, top, right, bottom] = bounds.slice(1).map(Number) + if (right <= left || bottom <= top || right > 100000 || bottom > 100000) return undefined + return { x: (left + right) / 2, y: (top + bottom) / 2 } +} diff --git a/mobile/scripts/manifest_policy.py b/mobile/scripts/manifest_policy.py new file mode 100644 index 0000000000..2b48466bd6 --- /dev/null +++ b/mobile/scripts/manifest_policy.py @@ -0,0 +1,67 @@ +"""Packaged aapt2 component policy. Never authorize SSH boot/network receivers.""" +import re + +APP = 'org.tabby.android.prototype' +PROFILE_ACTIONS = {'androidx.profileinstaller.action.' + action for action in + ['INSTALL_PROFILE', 'SKIP_FILE', 'SAVE_PROFILE', 'BENCHMARK_OPERATION']} + + +class ManifestPolicyError(Exception): + pass + + +def require(condition, code): + if not condition: + raise ManifestPolicyError(code) + + +def components(manifest, kind): + lines = manifest.splitlines() + for index, line in enumerate(lines): + match = re.match(r'^( +)E: ' + re.escape(kind) + r'(?:\s|$)', line) + if not match: + continue + indent = len(match[1]) + block = [] + for child in lines[index + 1:]: + element = re.match(r'^( *)E:', child) + if element and len(element[1]) <= indent: + break + block.append(child) + yield indent, block + + +def attribute(component, name): + indent, lines = component + # SDK36 aapt2 prints the namespace URI, while older output uses its alias. + namespace = r'(?:android|http://schemas\.android\.com/apk/res/android):' + pattern = r'^ {' + str(indent + 2) + r'}A: ' + namespace + re.escape(name) + r'\([^)]*\)=(.*)$' + values = [match[1] for line in lines if (match := re.match(pattern, line))] + require(len(values) == 1, 'MISSING_OR_DUPLICATE_COMPONENT_ATTRIBUTE') + value = values[0] + return re.match(r'^"([^"\n]*)"', value)[1] if value.startswith('"') else value + + +def inspect_connection_components(manifest): + services = list(components(manifest, 'service')) + require(len(services) == 1, 'EXACTLY_ONE_CONNECTION_SERVICE_REQUIRED') + service = services[0] + require(attribute(service, 'name') in {APP + '.ConnectionService', '.ConnectionService'}, 'UNEXPECTED_SERVICE') + require(attribute(service, 'exported') == 'false', 'EXPORTED_CONNECTION_SERVICE') + require(attribute(service, 'foregroundServiceType') == '0x40000000', 'WRONG_FOREGROUND_SERVICE_TYPE') + properties = list(components('\n'.join(service[1]), 'property')) + require(any(attribute(prop, 'name') == 'android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE' + and 'User initiated interactive SSH' in attribute(prop, 'value') for prop in properties), 'MISSING_SSH_SERVICE_PURPOSE') + # AndroidX's existing profile receiver is DUMP protected and only handles + # explicit ART tooling requests. It does not start this connection service. + # https://github.com/androidx/androidx/blob/androidx-main/profileinstaller/profileinstaller/src/main/AndroidManifest.xml + receivers = list(components(manifest, 'receiver')) + require(len(receivers) <= 1, 'UNEXPECTED_RECEIVERS') + for receiver in receivers: + require(attribute(receiver, 'name') == 'androidx.profileinstaller.ProfileInstallReceiver', 'UNEXPECTED_RECEIVER') + require(attribute(receiver, 'permission') == 'android.permission.DUMP', 'UNPROTECTED_PROFILE_RECEIVER') + actions = list(components('\n'.join(receiver[1]), 'action')) + require({attribute(action, 'name') for action in actions} == PROFILE_ACTIONS and len(actions) == 4, 'UNEXPECTED_PROFILE_ACTIONS') + require('android.intent.action.BOOT_COMPLETED' not in manifest and 'android.intent.action.LOCKED_BOOT_COMPLETED' not in manifest, 'AUTOMATIC_BOOT_NOT_ALLOWED') + return {'connectionServiceExported': False, 'foregroundServiceType': 'specialUse', 'automaticBoot': False, + 'existingDumpProtectedProfileReceiver': bool(receivers)} diff --git a/mobile/scripts/test-android-tmux.mjs b/mobile/scripts/test-android-tmux.mjs index f8f3d084ae..21c94ba540 100644 --- a/mobile/scripts/test-android-tmux.mjs +++ b/mobile/scripts/test-android-tmux.mjs @@ -1,5 +1,5 @@ import { createRequire } from 'node:module' -import { APP, RUNNER, DONE, INPUT, check, until, pause, TestFailure, instrumentationResult, observeUntil, observeReadUntil } from './test-android-utils.mjs' +import { APP, RUNNER, DONE, READY, INPUT, check, until, pause, TestFailure, instrumentationResult, observeUntil, observeReadUntil } from './test-android-utils.mjs' import { TMUX_WEBVIEW_CASES } from './test-android-tmux-cases.mjs' import { javaScriptBootObservation } from './test-android-webview.mjs' import { connect as sshConnect, exec as sshExec, terminal as sshTerminal, quote } from '../test/ssh-fixture-client.mjs' @@ -156,8 +156,9 @@ export async function tmuxWebviewAcceptance (android, fixture) { } async function beginHarness (index) { await android.removeFile(DONE) + await android.removeFile(READY) harnessDeadline = Date.now() + 180000 - const command = `am instrument -w -r -e class ${APP}.CloudWebViewHarness -e fixtureMetadata ${METADATA} -e cloudDoneFile ${DONE} -e cloudInputFile ${INPUT} ${RUNNER}` + const command = `am instrument -w -r -e class ${APP}.CloudWebViewHarness -e fixtureMetadata ${METADATA} -e cloudDoneFile ${DONE} -e cloudReadyFile ${READY} -e cloudInputFile ${INPUT} ${RUNNER}` harness = android.launch(['shell', '-T', command], { timeout: 190000 }) harness.result.catch(() => {}) let view @@ -166,6 +167,10 @@ export async function tmuxWebviewAcceptance (android, fixture) { return !!view }, 'ANDROID_TMUX_HARNESS_WEBVIEW_NOT_AVAILABLE', 45000) harnessPID = view.pid() + const readyDeadline = Math.min(harnessDeadline, Date.now() + 45000) + await step('owned-input-loop-ready', () => until(async () => await observeReadUntil(() => android.readFile(READY, + { timeout: Math.max(1, Math.min(5000, readyDeadline - Date.now())) }), readyDeadline, + 'ANDROID_TMUX_HARNESS_INPUT_READY_TIMEOUT') === 'READY', 'ANDROID_TMUX_HARNESS_INPUT_READY_TIMEOUT', Math.max(1, readyDeadline - Date.now()))) const record = { harness: `tmux-${index + 1}`, actions: [] } harnesses.push(record) record.beforeCDP = await step('focus-before-cdp-attach', focusSample) @@ -236,6 +241,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 +326,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 +343,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 +408,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,8 +479,10 @@ 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') + await step('readonly-open-terminal-actions', () => nativeTouch(page.getByRole('button', { name: '更多终端操作', exact: true }))) check(await active().getByRole('button', { name: '粘贴', exact: true }).isDisabled(), 'ANDROID_TMUX_READONLY_INPUT_ENABLED') + await step('readonly-close-terminal-actions', () => nativeTouch(page.getByRole('button', { name: '更多终端操作', exact: true }))) 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') const readonly = await observe(() => page.evaluate(() => window.__tabbyTmuxObservation.events.slice().reverse() @@ -562,6 +574,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-utils.mjs b/mobile/scripts/test-android-utils.mjs index c0fd30ff01..6b4c7280e2 100644 --- a/mobile/scripts/test-android-utils.mjs +++ b/mobile/scripts/test-android-utils.mjs @@ -5,6 +5,7 @@ export const APP = 'org.tabby.android.prototype' export const RUNNER = `${APP}.test/androidx.test.runner.AndroidJUnitRunner` export const METADATA = 'tabby-ssh-test-fixture.json' export const DONE = 'tabby-cloud-webview.done' +export const READY = 'tabby-cloud-webview.ready' export const INPUT = 'tabby-cloud-input.json' export const INPUT_RESULT = 'tabby-cloud-input.result.json' export const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) diff --git a/mobile/scripts/test-android-webview.mjs b/mobile/scripts/test-android-webview.mjs index a7f72d1064..297cb15e2c 100644 --- a/mobile/scripts/test-android-webview.mjs +++ b/mobile/scripts/test-android-webview.mjs @@ -1,5 +1,8 @@ +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' +import { systemUIActionPoint } from './android-system-ui.mjs' +import { APP, RUNNER, DONE, READY, INPUT, METADATA, check, until, pause, TestFailure, instrumentationResult, observeUntil, observeReadUntil } from './test-android-utils.mjs' const require = createRequire(import.meta.url) const quote = value => `'${value.replace(/'/g, `'"'"'`)}'` @@ -114,6 +117,7 @@ export async function webviewAcceptance (android, fixture) { let harness let harnessDeadline let harnessPID + let retiredHarnessPID let page let stage = 'harness-start' let substage = 'initializing' @@ -121,6 +125,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 @@ -162,6 +175,9 @@ export async function webviewAcceptance (android, fixture) { ['认证中', 'AUTHENTICATING'], ['等待认证', 'WAITING_AUTH'], ['已连接', 'READY']]) const noticeNames = new Map([ ['请输入有效主机、端口和用户名。', 'INVALID_ENDPOINT'], + ['请输入有效主机地址。', 'INVALID_HOST'], + ['请输入 1–65535 范围内的整数端口。', 'INVALID_PORT'], + ['请输入用户名。', 'INVALID_USERNAME'], ['原生 SSH 插件不可用。此页面不能在普通浏览器中连接 SSH。', 'PLUGIN_UNAVAILABLE'], ['无法建立 SSH 连接。请检查地址和网络。', 'START_FAILED'], ['主机密钥已变化,连接已拒绝。请先通过可信渠道核实。', 'HOST_KEY_CHANGED'], @@ -185,22 +201,29 @@ export async function webviewAcceptance (android, fixture) { const geometry = values => Object.fromEntries(['sliderCount', 'sliderTop', 'legacyViewportCount', 'legacyScrollTop', 'x', 'y', 'width', 'height', 'fromX', 'fromY', 'toX', 'toY', 'terminalX', 'terminalY', 'terminalWidth', 'terminalHeight', 'screenWidth', 'screenHeight', 'rowCount', 'scrollbarHeight', 'sliderHeight', 'viewportWidth', 'viewportHeight', - 'visualHeight', 'devicePixelRatio', 'nativeViewportWidth', 'nativeViewportHeight', 'firstHistoryOrdinal'].filter(key => Number.isFinite(values?.[key])) + 'visualHeight', 'devicePixelRatio', 'nativeViewportWidth', 'nativeViewportHeight', 'nativeKeyboardHeight', 'firstHistoryOrdinal'].filter(key => Number.isFinite(values?.[key])) .map(key => [key, values[key]])) const notice = document.querySelector('.notice')?.textContent || '' + const endpointValue = name => document.querySelector(`.connect-panel input[name="${name}"]`)?.value || '' + const endpointPort = endpointValue('port') return { capabilities: { objectHasOwn: typeof Object.hasOwn === 'function', cryptoRandomUUID: typeof window.crypto?.randomUUID === 'function', 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, 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, portInputCount: document.querySelectorAll('input[name="port"]').length, usernameInputCount: document.querySelectorAll('input[name="username"]').length, authSelectCount: document.querySelectorAll('select[name="authMode"]').length, + formInputs: Object.fromEntries(['hostMatch', 'portMatch', 'usernameMatch', 'authModeMatch', 'portValid', 'documentFocused'] + .map(key => [key, window.__tabbyCloudObservation?.formInputs?.[key] === true])), + endpointValidity: { host: !!endpointValue('host').trim() && !/[\x00-\x20]/.test(endpointValue('host').trim()), + port: !!endpointPort && Number.isInteger(Number(endpointPort)) && Number(endpointPort) >= 1 && Number(endpointPort) <= 65535, + username: !!endpointValue('username').trim() }, passwordInputCount: document.querySelectorAll('input[name="password"]').length, submitVisible: visible('.connect-panel button[type="submit"]'), hostDialogVisible: visible('[role="dialog"][aria-label="确认主机密钥"]'), @@ -239,6 +262,8 @@ export async function webviewAcceptance (android, fixture) { nativeTouch: geometry(window.__tabbyCloudObservation?.lastNativeTouch), nativeTouchHitTarget: window.__tabbyCloudObservation?.nativeTouchHitTarget === true, nativeTouchDocumentFocused: window.__tabbyCloudObservation?.nativeTouchDocumentFocused === true, + nativeTouchPhase: ['VISIBILITY', 'SCROLL_VISIBILITY', 'GEOMETRY', 'DISPATCH'].includes(window.__tabbyCloudObservation?.nativeTouchPhase) + ? window.__tabbyCloudObservation.nativeTouchPhase : 'UNKNOWN', clipboardOverlay: { observed: window.__tabbyCloudObservation?.clipboardOverlay?.observed === true, cleared: window.__tabbyCloudObservation?.clipboardOverlay?.cleared === true, phase: ['command', 'selection'].includes(window.__tabbyCloudObservation?.clipboardOverlay?.phase) @@ -260,6 +285,12 @@ export async function webviewAcceptance (android, fixture) { remote: Object.fromEntries(['rows', 'cols'].filter(key => Number.isFinite(value.remote?.[key])).map(key => [key, value.remote[key]])), converged: value.converged === true }] })), + keyboardShow: { + visible: window.__tabbyCloudObservation.keyboardShow?.visible === true, + height: Number.isFinite(window.__tabbyCloudObservation.keyboardShow?.height) ? window.__tabbyCloudObservation.keyboardShow.height : null, + viewportHeight: Number.isFinite(window.__tabbyCloudObservation.keyboardShow?.viewportHeight) ? window.__tabbyCloudObservation.keyboardShow.viewportHeight : null, + baselineHeight: Number.isFinite(window.__tabbyCloudObservation.keyboardShow?.baselineHeight) ? window.__tabbyCloudObservation.keyboardShow.baselineHeight : null, + }, } }), deadline, 'ANDROID_DOM_DIAGNOSTICS_DEADLINE_EXCEEDED') } catch { result.domUnavailable = true } @@ -276,16 +307,24 @@ 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) { + await page.evaluate(() => { window.__tabbyCloudObservation.nativeTouchPhase = 'VISIBILITY' }) + 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(() => { window.__tabbyCloudObservation.nativeTouchHitTarget = false window.__tabbyCloudObservation.nativeTouchDocumentFocused = false + window.__tabbyCloudObservation.nativeTouchPhase = 'SCROLL_VISIBILITY' }) inTime() // Prepare visibility only. Activation remains the one real native // MotionEvent below; a viewport-inside box can still be panel-clipped. await locator.scrollIntoViewIfNeeded({ timeout: Math.max(1, Math.min(3000, deadline - Date.now())) }) + await page.evaluate(() => { window.__tabbyCloudObservation.nativeTouchPhase = 'GEOMETRY' }) inTime() let box let previous @@ -319,6 +358,7 @@ export async function webviewAcceptance (android, fixture) { return Date.now() - stableSince >= 350 }, 'ANDROID_TOUCH_TARGET_DID_NOT_STABILIZE', Math.max(1, deadline - Date.now())) inTime() + await page.evaluate(() => { window.__tabbyCloudObservation.nativeTouchPhase = 'DISPATCH' }) await android.input({ type: 'touch', x: box.x + box.width / 2, y: box.y + box.height / 2, durationMs }) } async function output (needle) { @@ -329,10 +369,9 @@ export async function webviewAcceptance (android, fixture) { return page.evaluate(async ({ method, options }) => window.Capacitor.Plugins.TabbySSH[method](options), { method, options }) } async function clipboardOverlayCleared (phase) { - // A normal outside touch dismisses SystemUI's clipboard preview. Touch - // only the passive app status label, through the existing native input - // guards, once within this phase's unchanged budget. Still observe real - // disappearance before activating any app command. + // One normal close touch inside SystemUI's exact clipboard container, + // or one guarded outside touch on More when the control is unavailable. + // Still observe real disappearance within the unchanged phase budget. let observed = false let dismissalSent = false let clearSince @@ -350,11 +389,35 @@ export async function webviewAcceptance (android, fixture) { if (windows.clipboardOverlayVisible) { observed = true; clearSince = undefined if (!dismissalSent) { - const target = page.locator('header .status') - check(await target.count() === 1, 'ANDROID_CLIPBOARD_DISMISS_TARGET_AMBIGUOUS') + let point + try { + // Clipboard previews need not own the active window. + // Read all windows, then select only the exact SystemUI + // clipboard container; no unrelated control is touched. + await android.shell('uiautomator dump --windows /data/local/tmp/tabby-owned-clipboard.xml', { timeout: Math.max(1, Math.min(3000, deadline - Date.now())) }) + point = systemUIActionPoint(await android.shell('cat /data/local/tmp/tabby-owned-clipboard.xml', + { timeout: Math.max(1, Math.min(1000, deadline - Date.now())) }), 'clipboardDismiss') + } catch (error) { + // uiautomator's idle wait can outlast a fading preview. + // An unavailable read supplies no touch coordinates; + // the normal outside gesture still has to pass every + // native guard and actual disappearance deadline. + if (!(error instanceof TestFailure) || !['ADB_COMMAND_FAILED', 'TEST_COMMAND_DEADLINE_EXCEEDED'].includes(error.code)) throw error + inTime() + } finally { + try { await android.shell('rm -f /data/local/tmp/tabby-owned-clipboard.xml', { timeout: Math.max(1, Math.min(500, deadline - Date.now())) }) } catch {} + } inTime() dismissalSent = true - await nativeTouch(target, 100, deadline) + if (point) { + const state = await android.input({ type: 'deviceState' }, { deadline }) + check(state.interactive && state.deviceLocked === false && state.keyguardShowing === false && state.secure === false, 'ANDROID_CLIPBOARD_DEVICE_NOT_READY') + await android.shell(`input tap ${point.x} ${point.y}`, { timeout: Math.max(1, deadline - Date.now()) }) + } else { + const target = page.getByRole('button', { name: '更多终端操作', exact: true }) + check(await target.count() === 1, 'ANDROID_CLIPBOARD_DISMISS_TARGET_AMBIGUOUS') + await nativeTouch(target, 100, deadline) + } inTime() } } @@ -462,17 +525,22 @@ export async function webviewAcceptance (android, fixture) { } async function beginHarness (previousPID) { await android.removeFile(DONE) - const command = `am instrument -w -r -e class ${APP}.CloudWebViewHarness -e fixtureMetadata ${METADATA} -e cloudDoneFile ${DONE} -e cloudInputFile ${INPUT} ${RUNNER}` + await android.removeFile(READY) + const command = `am instrument -w -r -e class ${APP}.CloudWebViewHarness -e fixtureMetadata ${METADATA} -e cloudDoneFile ${DONE} -e cloudReadyFile ${READY} -e cloudInputFile ${INPUT} ${RUNNER}` harnessDeadline = Date.now() + 180000 harness = android.launch(['shell', '-T', command], { timeout: 190000 }) // Keep the promise handled if the test-only harness fails during startup. harness.result.catch(() => {}) let view await until(() => { - view = device.webViews().find(view => view.pkg() === APP && view.pid() !== previousPID) + view = device.webViews().find(view => view.pkg() === APP && view.pid() !== previousPID && view.pid() !== retiredHarnessPID) return !!view }, 'ANDROID_TEST_HARNESS_WEBVIEW_NOT_AVAILABLE', 45000) harnessPID = view.pid() + const readyDeadline = Math.min(harnessDeadline, Date.now() + 45000) + await step('owned-input-loop-ready', () => until(async () => await observeReadUntil(() => android.readFile(READY, + { timeout: Math.max(1, Math.min(5000, readyDeadline - Date.now())) }), readyDeadline, + 'ANDROID_HARNESS_INPUT_READY_TIMEOUT') === 'READY', 'ANDROID_HARNESS_INPUT_READY_TIMEOUT', Math.max(1, readyDeadline - Date.now()))) const preparation = { harness: previousPID === undefined ? 'first' : 'fresh-process', actions: [] } deviceStates.push(preparation) preparation.beforeCDP = await step('focus-before-cdp-attach', () => focusSample()) @@ -521,8 +589,8 @@ export async function webviewAcceptance (android, fixture) { await prepareDevice(preparation, harnessDeadline) return view.pid() } - async function focusSample () { - const deadline = Math.min(harnessDeadline, Date.now() + 5000) + async function focusSample (enclosingDeadline = Infinity) { + const deadline = Math.min(harnessDeadline, Date.now() + 5000, enclosingDeadline) check(Date.now() < deadline, 'ANDROID_FOCUS_OBSERVATION_DEADLINE_EXCEEDED') const [deviceState, focusState] = await Promise.all([ android.input({ type: 'deviceState' }, { deadline }), android.focusState({ deadline }), @@ -581,9 +649,40 @@ export async function webviewAcceptance (android, fixture) { instrumentationResult(result, 1) harness = undefined await quiet() + // Playwright caches each process's debug page. An ActivityScenario + // closes its WebView, so the next independent phase needs a fresh + // disposable app process, as the existing tmux harness already does. + retiredHarnessPID = harnessPID + await android.shell(`am force-stop ${APP}`) + page = undefined } - async function connect (known, mode = 'password') { + async function connect (known, mode = 'password', credential = 'transient') { const authenticatedBefore = fixture.stats().authenticated + // A newly rebuilt form can exist before native foreground focus and + // Angular's queued form-control registration have settled. Observe it; + // never repair values, grant focus, or replay a failed submission. + const formDeadline = Math.min(harnessDeadline, Date.now() + 10000) + let formSince + let previousForm + await step('form-foreground-ready', () => until(async () => { + const [{ deviceState: state, focusState: focus }, form] = await Promise.all([ + focusSample(formDeadline), page.evaluate(() => ({ focused: document.hasFocus(), width: innerWidth, height: innerHeight, + controlsReady: ['host', 'port', 'username'].every(name => { + const nodes = document.querySelectorAll(`.connect-panel input[name="${name}"]`) + const node = nodes[0]; const box = node?.getBoundingClientRect() + return nodes.length === 1 && !node.disabled && !node.readOnly && box.width > 0 && box.height > 0 + }) })), + ]) + check(Date.now() < formDeadline, 'ANDROID_FORM_FOREGROUND_DID_NOT_STABILIZE') + check(state.secure === false && state.deviceLocked === false && state.keyguardShowing === false, 'ANDROID_FORM_DEVICE_NOT_READY') + const ready = state.interactive && state.windowFocused && state.scenarioState === 'RESUMED' && form.focused && form.controlsReady + && focus.appOnInputFocusedDisplay && focus.inputDispatchEnabled && !focus.inputDispatchFrozen && focus.inputFocusRequestResult === 'OK' + && ['wmsFocusedWindowCategory', 'wmsFocusedAppCategory', 'inputFocusedWindowCategory', 'inputFocusedApplicationCategory', + 'inputFocusRequestCategory', 'activityDisplayResumedCategory'].every(key => focus[key] === 'APP') + const current = JSON.stringify(form) + if (!ready || current !== previousForm) { formSince = Date.now(); previousForm = current } + return ready && Date.now() - formSince >= 350 + }, 'ANDROID_FORM_FOREGROUND_DID_NOT_STABILIZE', Math.max(1, formDeadline - Date.now()))) 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))) await step('form-username', () => page.getByLabel('用户名', { exact: true }).fill(fixture.metadata.username)) @@ -591,10 +690,34 @@ 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')) + const inputDeadline = Math.min(harnessDeadline, Date.now() + 5000) + let inputSince + await step('form-input-readback', () => until(async () => { + const state = await page.evaluate(({ port, username, mode }) => { + const value = name => document.querySelector(`.connect-panel [name="${name}"]`)?.value + const portValue = value('port') + const result = { hostMatch: value('host') === '127.0.0.1', portMatch: portValue === String(port), usernameMatch: value('username') === username, + authModeMatch: value('authMode') === mode, portValid: !!portValue && Number.isInteger(Number(portValue)) && Number(portValue) >= 1 && Number(portValue) <= 65535, + documentFocused: document.hasFocus() } + window.__tabbyCloudObservation.formInputs = result + return result + }, { port: fixture.metadata.port, username: fixture.metadata.username, mode }) + check(Date.now() < inputDeadline, 'ANDROID_FORM_FIXTURE_INPUTS_DID_NOT_STABILIZE') + const ready = Object.values(state).every(value => value === true) + if (!ready || inputSince === undefined) inputSince = Date.now() + return ready && Date.now() - inputSince >= 350 + }, 'ANDROID_FORM_FIXTURE_INPUTS_DID_NOT_STABILIZE', Math.max(1, inputDeadline - Date.now()))) await step('form-native-submit', () => nativeTouch(page.getByRole('button', { name: '连接', exact: true }))) if (!known) { await step('host-key-dialog', () => page.getByRole('dialog', { name: '确认主机密钥' }).waitFor()) @@ -605,7 +728,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 +738,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) { @@ -633,7 +756,7 @@ export async function webviewAcceptance (android, fixture) { page.evaluate(() => window.__tabbyCloudObservation.readScroll()), viewport(), ]) return { ...Object.fromEntries(keys.map(key => [key, scroll[key]])), nativeViewportWidth: native.viewportWidth, - nativeViewportHeight: native.viewportHeight, nativeKeyboardVisible: native.visible } + nativeViewportHeight: native.viewportHeight, nativeKeyboardVisible: native.visible, nativeKeyboardHeight: native.height } } const deadline = Date.now() + 10000 const inTime = () => check(Date.now() < deadline, 'ANDROID_REMOTE_PTY_SIZE_DID_NOT_CONVERGE') @@ -645,7 +768,9 @@ export async function webviewAcceptance (android, fixture) { const current = await geometry() inTime() const valid = keys.every(key => Number.isFinite(current[key])) && current.rowCount > 0 && current.screenHeight > 0 - && current.nativeKeyboardVisible === expectedIME && Number.isFinite(current.nativeViewportWidth) && Number.isFinite(current.nativeViewportHeight) + && current.nativeKeyboardVisible === expectedIME && Number.isFinite(current.nativeKeyboardHeight) + && (expectedIME ? current.nativeKeyboardHeight > 0 : current.nativeKeyboardHeight === 0) + && Number.isFinite(current.nativeViewportWidth) && Number.isFinite(current.nativeViewportHeight) && current.nativeViewportWidth > 0 && current.nativeViewportHeight > 0 const serialized = JSON.stringify(current) if (!valid || serialized !== previous) { previous = serialized; stableSince = Date.now(); return false } @@ -714,6 +839,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' @@ -884,6 +1010,15 @@ export async function webviewAcceptance (android, fixture) { await step('clipboard-exact-real-pty-bytes', () => output(`W_CLIP_HEX_${Buffer.from(selected).toString('hex')}`)) verify('native Android swipe/long-press selection → system clipboard → real PTY paste') + // Keep every owned instrumentation phase inside its existing 180s + // lifetime. Menu navigation adds real touches; it must not consume the + // later IME/lifecycle phase's input deadline on slower API37 engines. + stage = 'ime-phase-restart' + await step('ime-phase-disconnect', () => disconnect()) + await step('ime-phase-end-owned-harness', () => endHarness()) + await step('ime-phase-begin-owned-harness', () => beginHarness()) + await connect(true) + stage = 'system-keyboard-and-rotation-resize' await step('keyboard-enable-system-ime', () => setting('secure', 'show_ime_with_hard_keyboard', 1)) await step('keyboard-hide-for-baseline', () => plugin('hideKeyboard')) @@ -891,13 +1026,22 @@ export async function webviewAcceptance (android, fixture) { const sizeBefore = await size(false, 'hidden') const hidden = await viewport() await step('keyboard-show-native-touch', () => nativeTouch(page.getByRole('button', { name: '键盘', exact: true }))) - await step('keyboard-shown-native-state', () => until(async () => (await viewport()).visible, 'ANDROID_SYSTEM_IME_DID_NOT_SHOW')) + await step('keyboard-shown-native-state', () => until(async () => { + const shown = await viewport() + await page.evaluate(value => { window.__tabbyCloudObservation.keyboardShow = value }, + { visible: shown.visible === true, height: shown.height, viewportHeight: shown.viewportHeight, baselineHeight: hidden.viewportHeight }) + // Visibility can precede the first nonzero animation inset. + // Require the actual docked keyboard and native shrink before + // measuring rows; the later real PTY shrink assertion remains. + return shown.visible && shown.height > 0 && shown.viewportHeight < hidden.viewportHeight + }, 'ANDROID_SYSTEM_IME_DID_NOT_SHOW')) const sizeShown = await size(true, 'shown') const shown = await viewport() substage = 'keyboard-native-viewport-shrank' 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 +1086,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 +1121,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 +1141,104 @@ 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 beginHarness() + await connect(true) + const api = Number(await android.shell('getprop ro.build.version.sdk')) + async function systemHierarchy (filename, deadline) { + const remaining = () => Math.max(1, deadline - Date.now()) + check(Date.now() < deadline, 'ANDROID_SYSTEM_UI_OBSERVATION_TIMEOUT') + try { + // No stale snapshot can supply a touch after a failed read. + await android.shell(`rm -f /data/local/tmp/${filename}`, { timeout: Math.min(1000, remaining()) }) + await android.shell(`uiautomator dump /data/local/tmp/${filename}`, { timeout: Math.min(12000, remaining()) }) + return await android.shell(`cat /data/local/tmp/${filename}`, { timeout: Math.min(3000, remaining()) }) + } catch (error) { + if (!(error instanceof TestFailure) || !['ADB_COMMAND_FAILED', 'TEST_COMMAND_DEADLINE_EXCEEDED'].includes(error.code)) throw error + check(Date.now() < deadline, 'ANDROID_SYSTEM_UI_OBSERVATION_TIMEOUT') + return undefined + } + } + 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 + const deadline = Date.now() + 20000 + await until(async () => { + const xml = await systemHierarchy('tabby-owned-dialog.xml', deadline) + if (!xml) return false + 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, Math.max(1, deadline - Date.now())) + 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 step('notification-normal-permission-denial', () => 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 step('notification-normal-permission-allow', () => 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') + const notificationDeadline = Date.now() + 20000 + let stopPoint + let expanded = false + await step('notification-owned-stop-visible', () => until(async () => { + const xml = await systemHierarchy('tabby-owned-notification.xml', notificationDeadline) + if (!xml) return false + stopPoint = systemUIActionPoint(xml, 'notificationStop') + if (stopPoint) return true + if (!expanded) { + const point = systemUIActionPoint(xml, 'notificationExpand') + if (point) { expanded = true; await android.shell(`input tap ${point.x} ${point.y}`) } + } + return false + }, 'ANDROID_NOTIFICATION_STOP_ACTION_MISSING', Math.max(1, notificationDeadline - Date.now()))) + await capture('connection-notification') + await android.shell(`input tap ${stopPoint.x} ${stopPoint.y}`) + await android.shell('rm -f /data/local/tmp/tabby-owned-notification.xml') + 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 beginHarness() + 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)) @@ -1024,12 +1267,21 @@ export async function webviewAcceptance (android, fixture) { return { passed: true, cases: passed, skipped: 0, deviceStates, inputEvidence: 'Actual Android InputConnection; specific Chinese IME candidate UI unverified.', touchEvidence: 'Android instrumentation MotionEvent injection, not synthetic DOM touch.' } } catch (error) { + // Requested real images, never credential forms or unknown stages. + // A failed acceptance image is diagnostic evidence, not a passed APK. + if (page && ['native-touch-scroll-selection-clipboard', 'foreground-service-notification', 'native-encrypted-password'].includes(stage)) { + try { + if (await page.locator('.pane-status').textContent() === '已连接' && await page.getByRole('dialog').count() === 0 + && await page.locator('input[type="password"]').count() === 0) await capture('failure-' + stage) + } catch { /* Original test failure remains authoritative. */ } + } const failure = error instanceof TestFailure ? error : new TestFailure(`ANDROID_WEBVIEW_${stage.toUpperCase().replaceAll('-', '_')}_${substage.toUpperCase().replaceAll('-', '_')}_FAILED`) failure.diagnostics = await diagnostics(error) throw failure } finally { for (const observation of bootObservations) { observation.dispose() } + try { await android.shell('rm -f /data/local/tmp/tabby-owned-notification.xml /data/local/tmp/tabby-owned-clipboard.xml') } catch {} if (harness) { try { await android.privateFile(DONE, ''); await harness.result } catch { harness.terminate() } } 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/scripts/verify-android-apk.py b/mobile/scripts/verify-android-apk.py index 4b8627a7ba..188f5bc95a 100644 --- a/mobile/scripts/verify-android-apk.py +++ b/mobile/scripts/verify-android-apk.py @@ -10,6 +10,7 @@ import subprocess import tempfile import zipfile +from manifest_policy import inspect_connection_components, ManifestPolicyError from web_security import inspect_web_assets, bind_aot_assets, WebSecurityError APP = 'org.tabby.android.prototype' @@ -83,11 +84,15 @@ 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) + try: + component_policy = inspect_connection_components(manifest) + except ManifestPolicyError as error: + raise SystemExit(str(error)) from error 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 +114,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')) @@ -147,7 +152,7 @@ def main(): report = {'verified': True, 'artifact': apk.name, 'sha256': hashlib.file_digest(apk.open('rb'), 'sha256').hexdigest(), 'bytes': apk.stat().st_size, 'sourceCommit': source, 'sourceTree': tree, 'sourceDirty': dirty, 'applicationId': APP, 'minSdk': 26, 'targetSdk': 36, 'debuggable': True, - 'permissions': permissions, 'publicTestCertificateSHA256': certificate[1], 'signatureScheme': 'v2', + 'permissions': permissions, 'componentPolicy': component_policy, 'publicTestCertificateSHA256': certificate[1], 'signatureScheme': 'v2', 'zipAlignmentBytes': PAGE, 'expectedABIs': args.expected_abis, 'packagedABIs': packaged_abis, 'nativeLibraries': libraries, 'arm64RuntimeVerified': False, 'webSecurity': web_security, 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/android-acceptance-static.test.mjs b/mobile/test/android-acceptance-static.test.mjs new file mode 100644 index 0000000000..83dfa1763b --- /dev/null +++ b/mobile/test/android-acceptance-static.test.mjs @@ -0,0 +1,32 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' +import ts from 'typescript' + +const sources = ['test-android-webview.mjs', 'test-android-tmux.mjs'] + .map(name => fileURLToPath(new URL('../scripts/' + name, import.meta.url))) +const options = { allowJs: true, checkJs: true, noEmit: true, skipLibCheck: true, + target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.NodeNext, moduleResolution: ts.ModuleResolutionKind.NodeNext } + +function undefinedNames (replacement) { + const host = ts.createCompilerHost(options) + const original = host.getSourceFile.bind(host) + host.getSourceFile = (name, language, onError, fresh) => replacement && resolve(name) === sources[0] + ? ts.createSourceFile(name, replacement, language, true, ts.ScriptKind.JS) + : original(name, language, onError, fresh) + const program = ts.createProgram(sources, options, host) + return program.getSemanticDiagnostics().filter(item => item.code === 2304 + && item.file && sources.includes(resolve(item.file.fileName))) + .map(item => ts.flattenDiagnosticMessageText(item.messageText, ' ')) +} + +test('real Android acceptance scripts have no undeclared helper or global references', () => { + assert.deepEqual(undefinedNames(), []) +}) + +test('a misspelled harness call fails static checking before emulator execution', () => { + const source = readFileSync(sources[0], 'utf8').replace('await beginHarness()', 'await missingHarnessHelper()') + assert.ok(undefinedNames(source).some(message => message.includes('missingHarnessHelper'))) +}) diff --git a/mobile/test/android-system-ui.test.mjs b/mobile/test/android-system-ui.test.mjs new file mode 100644 index 0000000000..49fee2e4c9 --- /dev/null +++ b/mobile/test/android-system-ui.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { systemUIActionPoint } from '../scripts/android-system-ui.mjs' + +const own = (extra, title = 'Tabby · 后台连接') => `${extra}` +const button = (id, pkg = 'com.android.systemui', text = '') => `` +test('selects only this app notification and accepts its RemoteViews action package', () => { + assert.deepEqual(systemUIActionPoint(own(button('android:id/expand_button_touch_container')), 'notificationExpand'), { x: 30, y: 40 }) + assert.deepEqual(systemUIActionPoint(own(button('android:id/action0', 'org.tabby.android.prototype', '停止全部')), 'notificationStop'), { x: 30, y: 40 }) +}) +test('foreign and ambiguous notification cards never supply a touch point', () => { + const foreign = own(button('android:id/expand_button'), 'Other app') + assert.equal(systemUIActionPoint(own('') + foreign, 'notificationExpand'), undefined) + assert.equal(systemUIActionPoint(own(button('android:id/expand_button')) + own(button('android:id/expand_button')), 'notificationExpand'), undefined) +}) +test('clipboard close must belong to the exact SystemUI clipboard container', () => { + const close = button('com.android.systemui:id/dismiss_button') + assert.equal(systemUIActionPoint(close, 'clipboardDismiss'), undefined) + assert.deepEqual(systemUIActionPoint(`${close}`, 'clipboardDismiss'), { x: 30, y: 40 }) + const windows = `${close}${close}` + assert.deepEqual(systemUIActionPoint(windows, 'clipboardDismiss'), { x: 30, y: 40 }) +}) +test('malformed, empty or disabled bounds and foreign packages fail closed', () => { + for (const value of [button('android:id/expand_button', 'other.app'), button('android:id/expand_button').replace('[50,60]', '[10,20]'), button('android:id/expand_button').replace('enabled="true"', 'enabled="false"')]) { + assert.equal(systemUIActionPoint(own(value), 'notificationExpand'), undefined) + } + assert.equal(systemUIActionPoint(own(button('android:id/expand_button')).replace('', ''), 'notificationExpand'), undefined) +}) 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/test/test_android_manifest_policy.py b/mobile/test/test_android_manifest_policy.py new file mode 100644 index 0000000000..552b5f043d --- /dev/null +++ b/mobile/test/test_android_manifest_policy.py @@ -0,0 +1,54 @@ +import importlib.util +from pathlib import Path +import unittest + +path = Path(__file__).resolve().parents[1] / 'scripts' / 'manifest_policy.py' +spec = importlib.util.spec_from_file_location('manifest_policy', path) +policy = importlib.util.module_from_spec(spec) +spec.loader.exec_module(policy) +SERVICE = ''' E: application + E: service + A: android:name(0x1)="org.tabby.android.prototype.ConnectionService" + A: android:exported(0x2)=false + A: android:foregroundServiceType(0x3)=0x40000000 + E: property + A: android:name(0x1)="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" + A: android:value(0x2)="User initiated interactive SSH terminal connections" +''' +RECEIVER = ''' E: receiver + A: android:name(0x1)="androidx.profileinstaller.ProfileInstallReceiver" + A: android:permission(0x2)="android.permission.DUMP" +''' + ''.join(' E: intent-filter\n E: action\n A: android:name(0x1)="' + action + '"\n' for action in sorted(policy.PROFILE_ACTIONS)) + + +class ManifestPolicyTest(unittest.TestCase): + def test_sdk36_real_namespace_uri_and_untrusted_namespace(self): + manifest = (SERVICE + RECEIVER).replace('A: android:', 'A: http://schemas.android.com/apk/res/android:') + self.assertTrue(policy.inspect_connection_components(manifest)['existingDumpProtectedProfileReceiver']) + with self.assertRaises(policy.ManifestPolicyError): + policy.inspect_connection_components(manifest.replace('http://schemas.android.com/apk/res/android:', 'http://untrusted.invalid/android:')) + + def test_exact_service_and_existing_androidx_receiver(self): + self.assertFalse(policy.inspect_connection_components(SERVICE)['automaticBoot']) + self.assertTrue(policy.inspect_connection_components(SERVICE + RECEIVER)['existingDumpProtectedProfileReceiver']) + + def test_rejects_new_receivers_and_unprotected_profile_receiver(self): + for value in [RECEIVER.replace('androidx.profileinstaller.ProfileInstallReceiver', '.BootReceiver'), + RECEIVER.replace('android.permission.DUMP', 'android.permission.INTERNET'), RECEIVER + RECEIVER, + RECEIVER.replace('INSTALL_PROFILE', 'BOOT_COMPLETED')]: + with self.subTest(value=value): + with self.assertRaises(policy.ManifestPolicyError): + policy.inspect_connection_components(SERVICE + value) + + def test_rejects_export_type_purpose_and_extra_service(self): + for value in [SERVICE.replace('=false', '=true'), SERVICE.replace('0x40000000', '0x00000001'), + SERVICE.replace('User initiated interactive SSH', ''), SERVICE + SERVICE, + SERVICE.replace('.ConnectionService', '.OtherService')]: + with self.subTest(value=value): + with self.assertRaises(policy.ManifestPolicyError): + policy.inspect_connection_components(value) + + def test_nested_false_flag_cannot_hide_exported_service(self): + value = SERVICE.replace('=false', '=true') + ' A: android:exported(0x2)=false\n' + with self.assertRaises(policy.ManifestPolicyError): + policy.inspect_connection_components(value) diff --git a/mobile/test/tmux-controller.test.mjs b/mobile/test/tmux-controller.test.mjs index 6ef3a9fb05..afff7d8161 100644 --- a/mobile/test/tmux-controller.test.mjs +++ b/mobile/test/tmux-controller.test.mjs @@ -291,3 +291,49 @@ test('unreachable TCP during an established recovery series retries, while it ca recovery.cancel() context.mock.timers.reset() }) + +test('mobile recovery stops after six transport failures and does not abort a successful connection on reset', async context => { + context.mock.timers.enable({ apis: ['setTimeout'] }) + let starts = 0 + const delays = [], paused = [] + const recovery = new MobileTmuxRecovery({ connect: async () => { starts++; throw new MobileTmuxError('tcp_failed') }, + credentialsAvailable: () => true, foreground: () => true, hasBinding: () => true, + paused: reason => paused.push(reason), scheduled: delay => delays.push(delay) }) + recovery.transportLost({ ...endpoint, type: 'state', state: 'error', code: 'transport_lost', transportLost: true }) + for (const delay of [1000, 2000, 4000, 8000, 16000, 30000]) { + context.mock.timers.tick(delay) + for (let turn = 0; turn < 12; turn++) await Promise.resolve() + } + assert.equal(starts, 6) + assert.deepEqual(delays, [1000, 2000, 4000, 8000, 16000, 30000]) + assert.deepEqual(paused, ['recovery_exhausted']) + context.mock.timers.tick(120000) + assert.equal(starts, 6) + recovery.cancel() + let aborted = false + let successful + successful = new MobileTmuxRecovery({ connect: async signal => { + signal.addEventListener('abort', () => { aborted = true }) + successful.reset() + }, credentialsAvailable: () => true, foreground: () => true, hasBinding: () => true, paused: () => {}, scheduled: () => {} }) + await successful.run() + assert.equal(aborted, false) + successful.cancel() +}) + +test('elapsed recovery budget cancels a pending flight without another reconnect', async context => { + context.mock.timers.enable({ apis: ['setTimeout'] }) + let starts = 0 + const paused = [] + const recovery = new MobileTmuxRecovery({ connect: async () => { starts++; await new Promise(() => {}) }, + credentialsAvailable: () => true, foreground: () => true, hasBinding: () => true, + paused: reason => paused.push(reason), scheduled: () => {} }) + recovery.transportLost({ ...endpoint, type: 'state', state: 'error', code: 'transport_lost', transportLost: true }) + context.mock.timers.tick(1000) + for (let turn = 0; turn < 6; turn++) await Promise.resolve() + context.mock.timers.tick(120000) + for (let turn = 0; turn < 12; turn++) await Promise.resolve() + assert.equal(starts, 1) + assert.deepEqual(paused, ['recovery_exhausted']) + recovery.cancel() +}) diff --git a/mobile/web/README.md b/mobile/web/README.md index 9335a0082b..dac9d13347 100644 --- a/mobile/web/README.md +++ b/mobile/web/README.md @@ -63,7 +63,10 @@ and generation; owner IDs isolate events arriving before start returns its ID. H is required before any credential response. First-use confirmation shows the algorithm/fingerprint; a changed key closes the connection. Password/passphrase form values are cleared on start, authentication, cancellation and backgrounding; -there is no Web credential/profile persistence. Native private key import exposes +there is no Web credential/profile persistence. The optional native password vault +never returns decrypted passwords to Web code; saved authentication passes a +boolean selector after host verification. Native AES-GCM storage is excluded from +backup and can be updated or deleted from the connection form. Native private key import exposes only an in-memory key handle to this UI. The controlled system file-picker pause still closes SSH and clears previous credentials, but preserves its independent picker request token until the import @@ -89,7 +92,8 @@ Terminal logging is disabled, including upstream parser diagnostics. Backgrounding closes all SSH transports and clears prompts and transient passwords. Foreground tmux password sessions recover an unexpected transport loss with increasing backoff while the original complete identity is available. -Missing credentials, authentication/key/identity failures and occupied sessions +The mobile retry budget is six attempts and 120 seconds elapsed. Missing +credentials, authentication/key/identity failures and occupied sessions pause. Private-key/interactive sessions need explicit fresh authentication. Auto recovery never takes over or creates a session; manual share/read-only and explicit takeover remain distinct choices. Saved identity storage is opt-in and diff --git a/mobile/web/playwright.config.ts b/mobile/web/playwright.config.ts index bb52ffaaea..be6d5a40ae 100644 --- a/mobile/web/playwright.config.ts +++ b/mobile/web/playwright.config.ts @@ -3,7 +3,8 @@ import { defineConfig, devices } from '@playwright/test' export default defineConfig({ testDir: './tests', testMatch: '*.spec.ts', - timeout: 20_000, + // Menu navigation adds real UI steps; gesture and protocol deadlines remain separate. + timeout: 40_000, fullyParallel: true, workers: 2, reporter: [['list'], ['html', { outputFolder: '../test-results/web-report', open: 'never' }]], diff --git a/mobile/web/src/app.component.ts b/mobile/web/src/app.component.ts index 5e4a6e0a20..0fc2756e52 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,16 @@ 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 backHandle?: PluginListenerHandle 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 +80,57 @@ export class AppComponent implements OnDestroy { this.activeTabID = this.tabs[0].id } } catch { this.notice = '此设备无法读取保存的会话身份;仍可直接连接。' } - this.lease(this.activeTabID) + void this.bridge.addListener('backAction', () => { + if (this.activePane?.dismissOverlay()) return + if (this.homeOpen) { void this.bridge.leaveApp().catch(() => {}); return } + this.home() + }).then(handle => { if (this.destroyed) void handle.remove(); else this.backHandle = handle }).catch(() => {}) + 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 { + this.titleMove(event) + 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 { if (this.titlePointer) this.suppressTitleClick = true; this.titlePointer = undefined } + 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 } @@ -111,7 +169,7 @@ export class AppComponent implements OnDestroy { return tab.binding ? `${tab.binding.mode === 'readonly' ? '只读 · ' : ''}${tab.title || tab.binding.sessionID}` : pane?.host || tab.endpoint?.host || '新连接' } ngOnDestroy(): void { - this.destroyed = true; this.lease(''); void this.leaseHandle?.remove() + this.destroyed = true; void this.backHandle?.remove(); this.lease(''); void this.leaseHandle?.remove() document.removeEventListener('visibilitychange', this.visibilityListener) } } diff --git a/mobile/web/src/bridge.ts b/mobile/web/src/bridge.ts index 949b8bab48..e74a2d6365 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,10 @@ 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 + addListener(eventName: 'backAction', listener: (event: Record) => void): Promise + addListener(eventName: 'backgroundState', listener: (event: { enabled: boolean }) => void): Promise + leaveApp(): Promise writeClipboard(options: { text: string }): Promise readClipboard(): Promise<{ text: string }> selectPrivateKey(options?: { ownerId: string, requestId: string }): Promise<{ keyId: string, label: string }> @@ -73,7 +76,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..8023b2c29c 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,25 +91,20 @@ const nextInputOwner = () => ++inputOwnerGeneration
{{selectionSnapshot}}
- - +
+
+ + +
+
+ + +
+ [disabled]="!foreground || !connected || selectionMode || !!modal || readOnly">
{{notice}}