Compare commits

..

No commits in common. "bb3cf6b09b2b2a3c267989f96bbff90b701d7b04" and "99ffffa393f4ba1bb59e7ded38a7b74585254fa0" have entirely different histories.

11 changed files with 115 additions and 978 deletions

View File

@ -34,8 +34,7 @@ struct SessionBox {
runtime: tokio::runtime::Runtime,
}
/// 连接被控端。成功返回句柄(>0),失败返回 0;错误消息经 `nativeLastError()` 取,
/// **错误分类**经 `nativeLastErrorKind()` 取(3a:界面按分类显示中文文案,原始英文作第二行)。
/// 连接被控端。成功返回句柄(>0),失败返回 0;错误消息经 `nativeLastError()` 取。
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeConnect<'local>(
mut env: JNIEnv<'local>,
@ -47,14 +46,14 @@ pub extern "system" fn Java_com_verify_remote_RustBridge_nativeConnect<'local>(
let host_s = match env.get_string(&host) {
Ok(s) => s.to_string_lossy().into_owned(),
Err(_) => {
set_last_error(-1, "host 参数不是合法字符串");
set_last_error("host 参数不是合法字符串");
return 0;
}
};
let code_s = match env.get_string(&code) {
Ok(s) => s.to_string_lossy().into_owned(),
Err(_) => {
set_last_error(-1, "code 参数不是合法字符串");
set_last_error("code 参数不是合法字符串");
return 0;
}
};
@ -65,7 +64,7 @@ pub extern "system" fn Java_com_verify_remote_RustBridge_nativeConnect<'local>(
.build()
{
Ok(r) => r,
Err(e) => return Err((-1i32, format!("runtime init: {e}"))),
Err(e) => return Err(format!("runtime init: {e}")),
};
let cfg = ConnectConfig::direct(host_s, port as u16, code_s);
match runtime.block_on(Session::connect(cfg)) {
@ -73,73 +72,53 @@ pub extern "system" fn Java_com_verify_remote_RustBridge_nativeConnect<'local>(
session: Arc::new(session),
runtime,
}),
// ConnectError 的 Display 即原始 detail;kind.code() 与
// viewer_core::ConnectErrorKind::code 的映射表一致(FFI 契约)。
Err(e) => Err((e.kind.code(), e.detail)),
Err(e) => Err(format!("{e:#}")),
}
});
match built {
Ok(Ok(boxed)) => {
set_last_error(0, "");
set_last_error("");
Box::into_raw(Box::new(Mutex::new(boxed))) as jlong
}
Ok(Err((kind, msg))) => {
set_last_error(kind, &msg);
Ok(Err(msg)) => {
set_last_error(&msg);
0
}
Err(_) => {
set_last_error(-1, "panic in nativeConnect");
set_last_error("panic in nativeConnect");
0
}
}
}
/// kind = 0 表示清除;-1 表示 FFI 层自身错误(无 viewer-core 分类,Kotlin 走 Other 文案)。
fn set_last_error(kind: i32, msg: &str) {
fn set_last_error(msg: &str) {
let mut slot = LAST_ERROR.lock().unwrap();
if msg.is_empty() {
*slot = None;
} else {
*slot = Some((kind, msg.to_string()));
*slot = Some(msg.to_string());
}
}
static LAST_ERROR: Mutex<Option<(i32, String)>> = Mutex::new(None);
static LAST_ERROR: Mutex<Option<String>> = Mutex::new(None);
/// 取最近一次 nativeConnect 的错误消息(无错误返回 null)。
/// FFI 契约:调用方必须先 `nativeLastErrorKind()` 再 `nativeLastError()`——
/// 本函数读走消息时**清空整个槽位**(kind 一起失效),颠倒顺序会拿到 kind=0。
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeLastError<'local>(
env: JNIEnv<'local>,
_class: JClass<'local>,
) -> jstring {
let mut slot = LAST_ERROR.lock().unwrap();
let msg = slot.as_ref().map(|(_, m)| m.clone());
let msg = LAST_ERROR.lock().unwrap().take();
match msg {
Some(m) => {
*slot = None;
env.new_string(m)
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut())
}
Some(m) => env
.new_string(m)
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut()),
None => std::ptr::null_mut(),
}
}
/// 取最近一次 nativeConnect 的错误分类码(viewer_core::ConnectErrorKind::code)。
/// 只读不清空;0 = 无错误,-1 = FFI 层自身错误(参数非法/panic/runtime 初始化失败,
/// 无 viewer-core 分类,Kotlin 按 Other 文案 + 原始消息显示)。
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeLastErrorKind(
_env: JNIEnv,
_class: JClass,
) -> jint {
let slot = LAST_ERROR.lock().unwrap();
slot.as_ref().map(|(k, _)| *k).unwrap_or(0)
}
/// 取下一帧(阻塞直到有帧或会话结束)。返回 null = 会话结束。
/// 仅允许解码线程调用;destroy 前必须先停止本线程(见文件头锁纪律)。
#[no_mangle]

View File

@ -11,8 +11,8 @@ android {
applicationId = "com.verify.remote"
minSdk = 26
targetSdk = 34
versionCode = 2
versionName = "0.1-phase3a"
versionCode = 1
versionName = "0.1-phase1"
// 阶段 1 只测真机 arm64;x86_64 留给模拟器回归
ndk {
abiFilters += listOf("arm64-v8a")
@ -25,15 +25,6 @@ android {
}
}
// 3a/U7:安卓测试基础设施(Q3=A,用户 2026-10-31 批准)。
// **仅测试作用域**:junit 不进 APK,runtime dependencies 保持为空。
testOptions {
unitTests {
// android.jar 桩方法抛 "not mocked"——本工程的单测刻意只测纯逻辑
// (不构造 Android 对象),此开关只兜常量引用类的类加载副作用。
isReturnDefaultValues = true
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
@ -62,6 +53,4 @@ android {
dependencies {
// 阶段 1 刻意零第三方 UI 依赖:纯 View 代码布局,减少变量。
// AndroidX 在阶段 2 做正式 UI 时再引入。
// 3a/U7(Q3=A 批准):仅 JUnit 测试作用域,不进 APK。
testImplementation("junit:junit:4.13.2")
}

View File

@ -1,38 +0,0 @@
package com.verify.remote
/**
* 连接失败分类 → 用户可执行的中文提示(阶段 3a,U8/N6 闭环)。
*
* 分类在 Rust 侧做(`viewer_core::ConnectErrorKind`),这里只做编号→文案映射。
* 编号契约与 Rust 的 `code()` 一一对应(`android-ffi/src/lib.rs` 文件头);
* Kotlin 不 import Rust 类型,跨 FFI 只传整数,所以本对象是纯逻辑、可 JVM 单测。
*
* 文案纪律:每条都必须**告诉用户下一步做什么**(方案 §3.4:不能停在「连接中」),
* 原始英文错误由 UI 作第二行展示(排障用,两者不互相替代)。
*/
object ConnectErrorText {
/** FFI 编号。与 viewer_core::ConnectErrorKind::code 同步,改动必须两侧一起改 */
const val KIND_NONE = 0
const val KIND_FFI_SELF = -1 // FFI 层自身错误(参数/panic/runtime),无业务分类
const val KIND_RESOLVE = 1
const val KIND_TIMEOUT = 2
const val KIND_REFUSED = 3
const val KIND_AUTH = 4
const val KIND_VERSION = 5
const val KIND_OTHER = 9
/** kind 未知(编号漂移)时返回 null,调用方必须落到带原始错误的兜底文案,不得静默 */
fun summary(kind: Int): String? = when (kind) {
KIND_RESOLVE -> "无法解析这个地址:检查 IP 是否写对"
KIND_TIMEOUT -> "连接超时:被控端不在线或入站被拦(检查 agent 是否在跑;本机联调需防火墙放行 47800-47810)"
KIND_REFUSED -> "被控端应答异常:确认这个端口上跑的是 agent"
KIND_AUTH -> "验证码错误:请向被控端核对验证码"
KIND_VERSION -> "协议版本不一致:两端都要更新到同一版本"
KIND_FFI_SELF, KIND_OTHER -> "连接失败"
else -> null
}
/** 完整首行:有分类文案用分类,未知编号不猜语义 */
fun firstLine(kind: Int): String =
summary(kind) ?: "连接失败(未知错误类型 $kind)"
}

View File

@ -57,15 +57,6 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
@Volatile
private var connecting = false
/**
* 会话代际(3a):startConnect/cancel/disconnect/destroy 全部递增。
* connect 线程完成时若代际已变(用户取消、断开、退出),结果**作废**——
* 成功的连接立即在本线程 destroy(此时解码线程还不存在,满足
* 「destroy 前必须没有解码线程」的锁纪律),失败的错误也不再上屏。
*/
@Volatile
private var sessionGeneration = 0
@Volatile
private var remoteW = 0
@Volatile
@ -137,9 +128,8 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
// 验证码刻意不从 prefs 读(不落盘)
codeInput = editText("验证码", InputType.TYPE_CLASS_TEXT, "")
connectButton = Button(this).apply {
// 三态复用同一按钮:连接 / 取消(等待中)/ 重新连接(失败或断开后)
text = "连接"
setOnClickListener { onConnectButtonClicked() }
setOnClickListener { startConnect() }
}
connectStatus = TextView(this).apply {
textSize = 13f
@ -247,22 +237,13 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
* 显式唤起软键盘。不做自动弹:自动弹会挡画面,且 vivo ROM 行为不一致。
* SurfaceView 已在触摸模式下可聚焦(见 RemoteSurfaceView.init),
* 否则系统不会建立 InputConnection。
*
* 键盘可见时再点 = 收起(否则用户没有任何收起入口,BACK 语义已归会话管理)。
*/
private fun showKeyboard() {
if (handle == 0L) return
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (keyboardVisible) {
imm.hideSoftInputFromWindow(surfaceView.windowToken, 0)
keyboardVisible = false
lastTouchDbg = "键盘已收起"
refreshStatus()
return
}
surfaceView.requestFocus()
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(surfaceView, InputMethodManager.SHOW_IMPLICIT)
lastTouchDbg = "键盘已请求"
lastTouchDbg = "showSoftInput 已请求(焦点=${surfaceView.hasFocus()})"
refreshStatus()
}
@ -362,14 +343,6 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
keyboardVisible = false
return true
}
if (connecting) {
// 连接等待中(handle 还没回填):BACK = 取消并回表单,**不结束 Activity**。
// 3a/V-A4:修复「连接中 BACK 让 Activity 可能被 super 关掉,
// 而 connect 线程随后仍回调界面」的悬挂路径。再按一次 BACK(表单态)才退出。
cancelConnect()
remotePanelVisible(false)
return true
}
if (handle != 0L) {
disconnect()
return true
@ -378,23 +351,23 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
}
val h = handle
if (h == 0L || event == null) return super.onKeyDown(keyCode, event)
// 只报事件去向,不报字符内容(诊断通道会显示在屏幕上);坐标类验证在 PC 侧闭环
// 只报 keyCode 与路由结果,不报字符内容(诊断通道会显示在屏幕上)
val cp = event.unicodeChar
if (cp >= 0x20 && cp != 0x7F) {
if (event.repeatCount == 0) RustBridge.nativeSendText(h, charArrayOf(cp.toChar()))
lastTouchDbg = "键→文本 1u"
lastTouchDbg = "key code=$keyCode -> text 1u"
runOnUiThread { refreshStatus() }
return true
}
val key = WinScancode.of(keyCode) ?: run {
lastTouchDbg = "键 keyCode=$keyCode 未映射"
lastTouchDbg = "key code=$keyCode 未映射"
runOnUiThread { refreshStatus() }
return super.onKeyDown(keyCode, event)
}
if (event.repeatCount == 0) {
RustBridge.nativeSendKey(h, key.scancode, if (key.extended) 1 else 0, 1)
}
lastTouchDbg = "键→scan 0x${key.scancode.toString(16)}"
lastTouchDbg = "key code=$keyCode -> scan ${key.scancode} ext=${key.extended}"
runOnUiThread { refreshStatus() }
return true
}
@ -416,26 +389,6 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
// ---- 连接 / 解码 ----
/** 按钮三态复用:等待中→取消;空闲/失败→发起连接 */
private fun onConnectButtonClicked() {
if (connecting) cancelConnect() else startConnect()
}
/**
* 取消等待中的连接(3a/V-A1)。不做真正的中断(JNI block_on 不可打断),
* 而是代际作废 + 立即回表单:connect 线程最多再等分级预算自灭
* (握手 5s;已握手后排队认证 11s),成功返回的孤儿句柄由该线程就地
* destroy(无解码线程,锁纪律成立)。
*/
private fun cancelConnect() {
if (!connecting) return
sessionGeneration++
connecting = false
connectButton.isEnabled = true
connectButton.text = "重新连接"
showConnectState("连接已取消(后台最多再跑 ~14s 自动作废,不影响再次连接)")
}
private fun startConnect() {
if (connecting || handle != 0L) return
val host = hostInput.text.toString().trim()
@ -456,36 +409,21 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
}
}
connecting = true
val gen = ++sessionGeneration
connectButton.text = "取消"
connectButton.isEnabled = false
showRemoteState()
statusView.text = "连接 $host:$port …"
thread(name = "connect") {
try {
// 分阶段预算(viewer-core):握手 5s(不可达快速失败),
// 认证成功排队最长 11s(agent 串行 accept,前会话 ~10s 释放)。
val h = RustBridge.connect(host, port, code)
// 尺寸必须在解码前拿到:用它定 SurfaceView 宽高比。
// 若等首帧再改,视图会重新测量,把刚建好的解码器 surface 打掉。
if (gen != sessionGeneration) {
// 已被取消/断开:这里没有解码线程在用 h,本线程 destroy 安全
RustBridge.destroy(h)
return@thread
}
val (w, ht) = RustBridge.desktopSize(h)
// 状态写入全部收敛到 UI 线程:connecting/handle/按钮的读写与
// 取消/BACK/disconnect 同线程串行,杜绝「cancel 竞态留下孤儿 handle」
handle = h
remoteW = w
remoteH = ht
connecting = false
runOnUiThread {
if (gen != sessionGeneration) {
// UI 线程里 cancel 的原子复查;h 尚无解码线程,直接 destroy
RustBridge.destroy(h)
return@runOnUiThread
}
connecting = false
handle = h
remoteW = w
remoteH = ht
connectButton.text = "断开"
connectButton.isEnabled = true
// 只有认证成功的会话才值得记住;验证码不落盘(用户裁决)
getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
@ -496,157 +434,73 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
surfaceView.videoAspectRatio = w.toFloat() / ht
}
refreshStatus()
startDecodeLoop(h, gen)
}
} catch (e: ConnectFailure) {
Log.e(TAG, "connect failed kind=${e.kind}", e)
if (gen != sessionGeneration) return@thread // 已取消:错误不再上屏
val first = ConnectErrorText.firstLine(e.kind)
runOnUiThread {
if (gen != sessionGeneration) return@runOnUiThread
connecting = false
handle = 0L
connectButton.isEnabled = true
connectButton.text = "重新连接"
// 第一行=分类中文提示,第二行=原始错误(V-A2 判据:两者都在)
showConnectState("$first\n detail: ${e.detail}")
startDecodeLoop(h)
}
} catch (e: Exception) {
Log.e(TAG, "connect failed (unexpected)", e)
if (gen != sessionGeneration) return@thread
Log.e(TAG, "connect failed", e)
val msg = e.message ?: "连接失败"
runOnUiThread {
if (gen != sessionGeneration) return@runOnUiThread
connecting = false
handle = 0L
connectButton.isEnabled = true
connectButton.text = "重新连接"
showConnectState("连接失败\n detail: $msg")
handle = 0L
showConnectState("连接失败:$msg")
}
}
}
}
/**
* 解码循环。所有权模型与 2b 一致:**destroy 只在 UI 线程**(置 stopDecoding →
* join → destroy);本线程只负责取帧/解码,检测到会话断时把收尾 post 回 UI,
* 由代际(gen)与 handle 双重复核后执行。本线程绝不自等 join、绝不并发 destroy。
*/
private fun startDecodeLoop(h: Long, gen: Int) {
private fun startDecodeLoop(h: Long) {
stopDecoding = false
decodeThread = thread(name = "decode") {
val surface = surfaceView.holder.surface
var tries = 0
while (!surface.isValid && tries++ < 100 && !stopDecoding) Thread.sleep(50)
while (!surface.isValid && tries++ < 100) Thread.sleep(50)
if (!surface.isValid) {
Log.e(TAG, "surface never became valid")
if (!stopDecoding) {
// 可见后果:画面永远起不来也要回表单报错,不能静默停在「已连接」
runOnUiThread {
if (gen == sessionGeneration && handle == h) {
cleanupSession(h, "画面启动失败:Surface 不可用,请重新连接")
}
}
}
return@thread
}
val dec = H264Decoder(surface)
var frames = 0L
var lastKeyReq = 0L
var windowFrames = 0L
var windowStart = System.currentTimeMillis()
var lostReason: String? = null
while (!stopDecoding) {
val f = try {
RustBridge.nextFrame(h)
} catch (e: Exception) {
Log.e(TAG, "nextFrame threw", e)
null
}
if (f == null) {
if (!stopDecoding) lostReason = "被控端不可达或会话已结束"
break
}
} ?: break
val fed = dec.queue(f)
if (!fed && !f.keyframe && System.currentTimeMillis() - lastKeyReq > 200) {
RustBridge.nativeRequestKeyframe(h) // 等 IDR 中:限频请求
lastKeyReq = System.currentTimeMillis()
}
frames++
windowFrames++
// HUD 每 1s 窗口刷一次:fps 是窗口内帧数(3a/V-A5)
val now = System.currentTimeMillis()
if (now - windowStart >= 1000) {
lastFps = windowFrames * 1000 / (now - windowStart)
windowFrames = 0
windowStart = now
if (frames % 120 == 0L) {
lastFrames = frames
lastRtt = RustBridge.rttMs(h)
runOnUiThread { refreshStatus() }
}
}
dec.release()
val reason = lostReason
Log.i(TAG, "decode loop ended, frames=$frames lost=${reason != null}")
if (reason != null) {
// 断线可见(3a/V-A3,修复「冻结在已连接」):收尾回 UI 线程执行。
// 本线程 post 后继续走到退出——UI 侧 cleanupSession 的 join 等的
// 正是「本线程退出」,不会自等(自等死锁只发生在线程 join 自己)。
runOnUiThread {
if (gen == sessionGeneration && handle == h) {
cleanupSession(h, "连接已断开:$reason\n 点「重新连接」再次发起(验证码需重输)")
}
}
}
Log.i(TAG, "decode loop ended, frames=$frames")
runOnUiThread { refreshStatus() }
}
}
/**
* 会话收尾的唯一入口(**只允许 UI 线程调用**)。顺序纪律不变(2b 真机验证过的模型):
* 置 stopDecoding → join 解码线程 → destroy。代际由调用点的 `gen == sessionGeneration`
* 复核负责;这里先把 handle 清零,第二次进入(surfaceDestroyed→disconnect 幂等路径)
* 看到 0 直接跳过 destroy。
*/
private fun cleanupSession(h: Long, statusMsg: String) {
private fun disconnect() {
if (handle == 0L) return
stopDecoding = true
// 顺序纪律:等解码线程退出后才能 destroy(见 android-ffi 文件头)。
// 卡帧风险:agent 静止时 500ms 一帧、QUIC idle 超时后流必关,6s 足够覆盖。
decodeThread?.join(6000)
decodeThread = null
if (handle == h) handle = 0L
val h = handle
handle = 0L
remoteW = 0
remoteH = 0
lastFrames = 0
lastRtt = 0
lastFps = 0
RustBridge.destroy(h)
connecting = false
connectButton.isEnabled = true
connectButton.text = "重新连接"
showConnectState(statusMsg)
}
private fun disconnect() {
val h = handle
if (!connecting && h == 0L) return // 幂等:surfaceDestroyed→disconnect 二次进入
sessionGeneration++ // 作废一切在途回调(connect 线程孤儿、断线提示)
if (connecting) connecting = false
if (h != 0L) {
handle = 0L
stopDecoding = true
// 顺序纪律:等解码线程退出后才能 destroy(见 android-ffi 文件头)。
// 卡帧风险:agent 静止时 500ms 一帧、QUIC idle 超时后流必关,6s 足够覆盖。
decodeThread?.join(6000)
decodeThread = null
remoteW = 0
remoteH = 0
lastFrames = 0
lastRtt = 0
lastFps = 0
RustBridge.destroy(h)
}
connectButton.isEnabled = true
connectButton.text = "连接"
showConnectState(if (h != 0L) "已断开" else "连接已取消")
showConnectState("已断开")
}
override fun onDestroy() {
@ -654,13 +508,11 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
disconnect()
}
/** 状态栏统一刷新:连接态 + fps/帧数/RTT + 最近输入诊断(logcat 被限流,这是唯一通道) */
/** 状态栏统一刷新:连接态 + 帧数/RTT + 最近输入诊断(logcat 被限流,这是唯一通道) */
@Volatile
private var lastFrames = 0L
@Volatile
private var lastRtt = 0L
@Volatile
private var lastFps = 0L
private fun refreshStatus() {
if (handle == 0L) {
@ -668,7 +520,7 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
return
}
statusView.text = buildString {
append("已连接 v$PROTO_LABEL ${remoteW}x$remoteH fps=$lastFps RTT=${lastRtt}ms 帧=$lastFrames\n")
append("已连接 v$PROTO_LABEL ${remoteW}x$remoteH 帧=$lastFrames RTT=${lastRtt}ms\n")
append("输入: $lastTouchDbg")
}
}

View File

@ -5,40 +5,6 @@ import android.view.View
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
/** 单次退格请求上限:防 IME 异常大值刷爆消息队列(与 agent 侧 WHEEL_MAX_CLICKS 同思路) */
const val MAX_BACKSPACE = 64
/**
* 退格决策(纯函数 [planBackspaces] 的输出)。类型刻意放在类外:
* `RemoteInputConnection` 构造链到 `BaseInputConnection`(android.jar 桩,JVM 单测
* 里抛 "not mocked"),决策本身无 Android 依赖——抽出来才能被 3a 的 JVM 单测覆盖(U7)。
*/
sealed class BackspaceAction {
/** (0,0):IME 没有要删的东西 */
object Noop : BackspaceAction()
/** 退格 [count] 次(已截断到 [MAX_BACKSPACE]) */
data class Backspaces(val count: Int) : BackspaceAction()
/** `after>0` / 双向删除:本地无远端真值,不可执行(U3) */
data class Unsupported(val before: Int, val after: Int) : BackspaceAction()
}
/**
* `deleteSurroundingText(before, after)` 的决策逻辑,从 [RemoteInputConnection] 抽出。
* 语义与原实现一致(3a 只提取不改动):
* - `(0,0)` → Noop;
* - `after>0` → Unsupported(forward-delete 形态本地无法执行,U3);
* - `before>0 && after==0` → 退格 min(before, [MAX_BACKSPACE]) 次
* (上限防 IME 异常大值刷爆消息队列,与 agent 侧 WHEEL_MAX_CLICKS 同思路)。
*/
fun planBackspaces(before: Int, after: Int, maxBackspace: Int = MAX_BACKSPACE): BackspaceAction =
when {
before == 0 && after == 0 -> BackspaceAction.Noop
after != 0 -> BackspaceAction.Unsupported(before, after)
else -> BackspaceAction.Backspaces(minOf(before, maxBackspace))
}
/**
* P1 IME 通道(loop-2b-plan §5,用户已裁决):**本地没有编辑缓冲,远端才是权威**。
*
@ -46,8 +12,7 @@ fun planBackspaces(before: Int, after: Int, maxBackspace: Int = MAX_BACKSPACE):
* 1. `commitText` = 用户确认的整段文本 → 直接走协议 v3 `Text` 通道(布局无关、支持中文);
* 2. `sendKeyEvent` = 硬键盘/控制键 → 既有扫描码通道(Enter/Backspace/方向键…);
* 3. `deleteSurroundingText`:本地不知道远端内容,`before>0 && after==0`
* 是 IME 退格最常见形态 → 发对应次数的 Backspace;其余形态记录后放弃(U3,
* 决策逻辑抽到纯函数 [planBackspaces],3a 起可 JVM 单测)。
* 是 IME 退格最常见形态 → 发对应次数的 Backspace;其余形态记录后放弃(U3)。
*
* composing(拼音未确认串)不显示到远端(U4):`setComposingText` 吞掉但返回 true,
* 让 IME 维持本地组合状态,候选确认后仍会走 `commitText`。
@ -101,28 +66,25 @@ class RemoteInputConnection(
}
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean =
applyBackspaces(planBackspaces(beforeLength, afterLength))
deleteBackspaces(beforeLength, afterLength)
override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean =
applyBackspaces(planBackspaces(beforeLength, afterLength))
deleteBackspaces(beforeLength, afterLength)
/** 决策 → 注入。日志纪律:只报数量与形态,不报内容。 */
private fun applyBackspaces(action: BackspaceAction): Boolean = when (action) {
is BackspaceAction.Noop -> true
is BackspaceAction.Unsupported -> {
private fun deleteBackspaces(before: Int, after: Int): Boolean {
if (before == 0 && after == 0) return true
if (after != 0) {
// U3:forward-delete 与「先删 after 再删 before」的形态本地无远端真值可依
debug("delete(${action.before},${action.after}):after>0 形态未支持")
false
debug("delete($before,$after):after>0 形态未支持")
return false
}
is BackspaceAction.Backspaces -> {
val bk = WinScancode.of(KeyEvent.KEYCODE_DEL) ?: return false
repeat(action.count) {
sendKey(bk, true)
sendKey(bk, false)
}
debug("backspace x${action.count}")
true
val bk = WinScancode.of(KeyEvent.KEYCODE_DEL) ?: return false
repeat(before.coerceAtMost(MAX_BACKSPACE)) {
sendKey(bk, true)
sendKey(bk, false)
}
debug("backspace x$before")
return true
}
/** 吞掉 composing 但返回 true:IME 保持本地组合流程,候选确认仍会 commitText。 */
@ -151,4 +113,9 @@ class RemoteInputConnection(
debug("editorAction $actionCode 忽略")
return true
}
companion object {
/** 单次退格请求上限:防 IME 异常大值刷爆消息队列(与 agent 侧 WHEEL_MAX_CLICKS 同思路) */
private const val MAX_BACKSPACE = 64
}
}

View File

@ -144,21 +144,28 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
private fun midY(e: MotionEvent) =
if (e.pointerCount >= 2) (e.getY(0) + e.getY(1)) / 2f else e.y
/**
* 诊断回调:把触摸情况显示到状态栏(本机 logcat 被系统限流,日志不可靠)。
*
* 3a 降噪(V-A5):只显示**事件语义**(单击/拖拽/右键/滚轮),不再显示
* 坐标与归一化值——那 20 列噪声挤满状态栏,且对操作者没有决策价值
* (坐标类验证由 proof 脚本在 PC 侧读光标闭环,不经屏幕)。
*/
/** 诊断回调:把触摸情况显示到状态栏(本机 logcat 被系统限流,日志不可靠) */
var onDebug: ((String) -> Unit)? = null
override fun onTouchEvent(event: MotionEvent): Boolean {
val l = listener ?: run {
onDebug?.invoke("触摸(未接线)")
onDebug?.invoke("touch: listener=null")
return false
}
if (width == 0 || height == 0) return false
val act = when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> "DOWN"
MotionEvent.ACTION_MOVE -> "MOVE"
MotionEvent.ACTION_UP -> "UP"
MotionEvent.ACTION_POINTER_DOWN -> "P2DOWN"
MotionEvent.ACTION_POINTER_UP -> "P2UP"
MotionEvent.ACTION_CANCEL -> "CANCEL"
else -> event.actionMasked.toString()
}
onDebug?.invoke(
"$act view=(${event.x.toInt()},${event.y.toInt()}) " +
"norm=(${normX(event.x)},${normY(event.y)}) ${width}x$height"
)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
@ -168,7 +175,6 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
mode = Mode.TAP
// 先把远端指针移到位,点击/拖拽都以此为基准
l.sendMouseMove(normX(event.x), normY(event.y))
onDebug?.invoke("按下")
}
MotionEvent.ACTION_POINTER_DOWN -> {
@ -181,7 +187,6 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
twoFingerStartY = midY(event)
lastX = midX(event); lastY = midY(event)
twoFingerDownTime = System.currentTimeMillis()
onDebug?.invoke("双指按下")
}
MotionEvent.ACTION_MOVE -> when (mode) {
@ -190,7 +195,6 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
l.sendMouseButton(BTN_LEFT, true)
lastX = event.x; lastY = event.y
l.sendMouseMove(normX(event.x), normY(event.y))
onDebug?.invoke("拖拽中")
}
Mode.DRAG -> {
@ -209,7 +213,6 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
if (notches != 0) {
l.sendWheel(-notches * WHEEL_DELTA)
wheelAccum -= notches * WHEEL_PX_PER_NOTCH
onDebug?.invoke(if (notches > 0) "滚轮↓" else "滚轮↑")
}
lastX = cx; lastY = cy
}
@ -220,7 +223,7 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
MotionEvent.ACTION_POINTER_UP -> {
// 双指之一抬起:先判定右键,再把剩余手指的移动视为已结束手势
if (mode == Mode.TWO_FINGER) {
if (maybeRightClick(l)) onDebug?.invoke("右键")
maybeRightClick(l)
mode = Mode.IDLE
}
}
@ -232,15 +235,9 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
l.sendMouseButton(BTN_LEFT, true)
l.sendMouseButton(BTN_LEFT, false)
performClick()
onDebug?.invoke("单击")
}
Mode.DRAG -> {
l.sendMouseButton(BTN_LEFT, false)
onDebug?.invoke("拖拽结束")
}
Mode.TWO_FINGER -> {
if (maybeRightClick(l)) onDebug?.invoke("右键")
}
Mode.DRAG -> l.sendMouseButton(BTN_LEFT, false)
Mode.TWO_FINGER -> maybeRightClick(l)
Mode.IDLE -> {}
}
mode = Mode.IDLE
@ -249,21 +246,19 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
MotionEvent.ACTION_CANCEL -> {
if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false)
mode = Mode.IDLE
onDebug?.invoke("手势取消")
}
}
return true
}
/** 双指未移动且按住时间短 → 右键。返回是否真的注入了右键(供诊断显示)。 */
private fun maybeRightClick(l: Listener): Boolean {
if (twoFingerMoved) return false
if (System.currentTimeMillis() - twoFingerDownTime > TWO_FINGER_TAP_MAX_MS) return false
/** 双指未移动且按住时间短 → 右键 */
private fun maybeRightClick(l: Listener) {
if (twoFingerMoved) return
if (System.currentTimeMillis() - twoFingerDownTime > TWO_FINGER_TAP_MAX_MS) return
l.sendMouseMove(normX(lastX), normY(lastY))
l.sendMouseButton(BTN_RIGHT, true)
l.sendMouseButton(BTN_RIGHT, false)
performClick()
return true
}
@Suppress("ClickableViewAccessibility")

View File

@ -1,11 +1,5 @@
package com.verify.remote
/**
* 连接失败:Rust 侧分类([kind],`ConnectErrorText.KIND_*`)+ 原始错误文本([detail])。
* 界面第一行按分类给中文可执行提示,第二行显示 detail(排障用,见 3a 错误显示纪律)。
*/
class ConnectFailure(val kind: Int, val detail: String) : Exception(detail)
/**
* viewer-core(Rust)的 JNI 声明。符号约定:Java_com_verify_remote_RustBridge_*,
* 与 android-ffi/src/lib.rs 一一对应。
@ -24,13 +18,6 @@ object RustBridge {
private external fun nativeConnect(host: String, port: Int, code: String): Long
external fun nativeLastError(): String?
/**
* 最近一次 nativeConnect 失败的业务分类(`ConnectErrorText.KIND_*` 编号)。
* FFI 契约(android-ffi/src/lib.rs):**必须在 nativeLastError() 之前调用**——
* 消息读走时槽位整体清空,颠倒顺序拿到 kind=0。
*/
external fun nativeLastErrorKind(): Int
/** 返回 null = 会话结束;否则 [FRAME_HEADER_LEN] 字节头 + Annex-B 载荷 */
external fun nativeNextFrame(handle: Long): ByteArray?
external fun nativeStats(handle: Long): Long
@ -48,16 +35,11 @@ object RustBridge {
external fun nativeRequestKeyframe(handle: Long)
external fun nativeDestroy(handle: Long)
/**
* 连接。失败抛 [ConnectFailure](分类 + Rust 侧原始消息)。
* 取序遵守 FFI 契约:先 kind 后 message。
*/
/** 连接。失败抛 IllegalStateException(带 Rust 侧错误消息)。 */
fun connect(host: String, port: Int, code: String): Long {
val h = nativeConnect(host, port, code)
if (h == 0L) {
val kind = nativeLastErrorKind()
val msg = nativeLastError() ?: "connect failed"
throw ConnectFailure(kind, msg)
throw IllegalStateException(nativeLastError() ?: "connect failed")
}
return h
}

View File

@ -1,80 +0,0 @@
package com.verify.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 3a/U7:连接错误编号→文案映射。
* 编号契约的另一端是 Rust(`viewer_core::ConnectErrorKind::code`,
* 由 `cargo test -p viewer-core ffi_error_codes_are_stable_and_unique` 锁死),
* 这里锁 Kotlin 侧的完整覆盖与文案质量——两端一致才算契约成立。
*/
class ConnectErrorTextTest {
@Test
fun every_known_kind_has_actionable_chinese_text() {
val kinds = listOf(
ConnectErrorText.KIND_RESOLVE,
ConnectErrorText.KIND_TIMEOUT,
ConnectErrorText.KIND_REFUSED,
ConnectErrorText.KIND_AUTH,
ConnectErrorText.KIND_VERSION,
ConnectErrorText.KIND_OTHER,
ConnectErrorText.KIND_FFI_SELF,
)
for (k in kinds) {
val s = ConnectErrorText.summary(k)
assertTrue("kind=$k 必须有文案", !s.isNullOrEmpty())
// 项目纪律:用户可见输出中文(文案里至少含一个 CJK 字符)
assertTrue("kind=$k 文案必须中文", s!!.any { c -> c.code in 0x4E00..0x9FFF })
}
}
@Test
fun codes_match_ffi_contract() {
// 期望值 = android-ffi / viewer-core 的 code() 表(改动必须三处同步)
assertEquals(1, ConnectErrorText.KIND_RESOLVE)
assertEquals(2, ConnectErrorText.KIND_TIMEOUT)
assertEquals(3, ConnectErrorText.KIND_REFUSED)
assertEquals(4, ConnectErrorText.KIND_AUTH)
assertEquals(5, ConnectErrorText.KIND_VERSION)
assertEquals(9, ConnectErrorText.KIND_OTHER)
assertEquals(-1, ConnectErrorText.KIND_FFI_SELF)
assertEquals(0, ConnectErrorText.KIND_NONE)
}
@Test
fun texts_are_distinct_per_failure_mode() {
// 五种可区分失败不能给同一句文案(V-A2 的映射前提)
val texts = listOf(
ConnectErrorText.KIND_RESOLVE,
ConnectErrorText.KIND_TIMEOUT,
ConnectErrorText.KIND_REFUSED,
ConnectErrorText.KIND_AUTH,
ConnectErrorText.KIND_VERSION,
).map { ConnectErrorText.summary(it)!! }
assertEquals(texts.size, texts.distinct().size)
for (t in texts) {
assertNotEquals("连接失败", t) // 可分类的不得落兜底文案
}
}
@Test
fun unknown_kind_falls_back_without_claiming_a_cause() {
// 编号漂移时不猜语义:兜底文案 + 带未知编号(R-8:宁可少说不可说错)
assertNull(ConnectErrorText.summary(42))
val line = ConnectErrorText.firstLine(42)
assertTrue("实际: $line", line.contains("42"))
assertTrue("实际: $line", line.startsWith("连接失败"))
}
@Test
fun firstLine_uses_summary_for_known_kinds() {
for (k in listOf(1, 2, 3, 4, 5, 9, -1)) {
assertEquals(ConnectErrorText.summary(k), ConnectErrorText.firstLine(k))
}
}
}

View File

@ -1,46 +0,0 @@
package com.verify.remote
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* 3a/U7:退格决策纯函数。原逻辑在 `RemoteInputConnection.deleteBackspaces`
* (构造链依赖 android.jar 桩,JVM 里不可测),3a 提取为 [planBackspaces] 后
* 首次可单测。期望值对照 2b 真机行为(语义不变,见 RemoteInputConnection 注释)。
*/
class PlanBackspacesTest {
@Test
fun zero_zero_is_noop() {
assertEquals(BackspaceAction.Noop, planBackspaces(0, 0))
}
@Test
fun after_positive_is_unsupported_and_reports_both_counts() {
// U3:forward-delete 形态本地无远端真值,必须显式 Unsupported(可见后果),
// 不得静默当退格执行——before 值要随形态带出去供 debug 通道显示
assertEquals(BackspaceAction.Unsupported(2, 1), planBackspaces(2, 1))
assertEquals(BackspaceAction.Unsupported(0, 3), planBackspaces(0, 3))
}
@Test
fun before_only_maps_to_backspace_count() {
// IME 退格最常见形态(R-9 实测)
assertEquals(BackspaceAction.Backspaces(3), planBackspaces(3, 0))
assertEquals(BackspaceAction.Backspaces(1), planBackspaces(1, 0))
}
@Test
fun count_is_capped_at_limit() {
// 防 IME 异常大值刷爆消息队列;截断语义必须发生在计划层(执行层无感知上限)
assertEquals(BackspaceAction.Backspaces(64), planBackspaces(64, 0))
assertEquals(BackspaceAction.Backspaces(64), planBackspaces(999, 0))
assertEquals(BackspaceAction.Backspaces(MAX_BACKSPACE), planBackspaces(1_000_000, 0))
}
@Test
fun MAX_BACKSPACE_constant_is_pinned() {
// 与 RemoteInputConnection 的 companion 原值一致(提取时不得改数)
assertEquals(64, MAX_BACKSPACE)
}
}

View File

@ -1,82 +0,0 @@
package com.verify.remote
import android.view.KeyEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* 3a/U7 首批 JVM 单测:扫描码映射表(此前只能真机点验)。
*
* `KeyEvent.KEYCODE_*` 是 Java 编译期常量(static final int),引用会被内联进
* 测试字节码——JVM 单测不触发 android.jar 桩类加载,所以这里不需要 Robolectric。
* 期望值来自项目内部权威源 `viewer/src/keymap.rs`(同 R-1:不信记忆,对照项目已有用法)。
*/
class WinScancodeTest {
@Test
fun enter_is_make_0x1C_not_extended() {
assertEquals(WinKey(0x1C, false), WinScancode.of(KeyEvent.KEYCODE_ENTER))
}
@Test
fun backspace_is_0x0E() {
assertEquals(WinKey(0x0E, false), WinScancode.of(KeyEvent.KEYCODE_DEL))
}
@Test
fun forward_delete_is_0x53_extended() {
assertEquals(WinKey(0x53, true), WinScancode.of(KeyEvent.KEYCODE_FORWARD_DEL))
}
@Test
fun arrows_are_extended() {
assertEquals(WinKey(0x48, true), WinScancode.of(KeyEvent.KEYCODE_DPAD_UP))
assertEquals(WinKey(0x50, true), WinScancode.of(KeyEvent.KEYCODE_DPAD_DOWN))
assertEquals(WinKey(0x4B, true), WinScancode.of(KeyEvent.KEYCODE_DPAD_LEFT))
assertEquals(WinKey(0x4D, true), WinScancode.of(KeyEvent.KEYCODE_DPAD_RIGHT))
}
@Test
fun right_shift_is_NOT_extended_with_its_own_scancode() {
// 微软 Keyboard Input Overview:右 Shift 不是扩展键,它有独立扫描码 0x36
// (2b 的 D4 偏差修正,回归锁死)
assertEquals(WinKey(0x36, false), WinScancode.of(KeyEvent.KEYCODE_SHIFT_RIGHT))
assertEquals(WinKey(0x2A, false), WinScancode.of(KeyEvent.KEYCODE_SHIFT_LEFT))
}
@Test
fun right_ctrl_and_alt_are_extended_shared_scancodes() {
assertEquals(WinKey(0x1D, true), WinScancode.of(KeyEvent.KEYCODE_CTRL_RIGHT))
assertEquals(WinKey(0x1D, false), WinScancode.of(KeyEvent.KEYCODE_CTRL_LEFT))
assertEquals(WinKey(0x38, true), WinScancode.of(KeyEvent.KEYCODE_ALT_RIGHT))
}
@Test
fun letters_and_digits_match_keymap_rs() {
// 抽查与 keymap.rs 交集的字母/数字(Set-1 make code)
assertEquals(WinKey(0x1E, false), WinScancode.of(KeyEvent.KEYCODE_A))
assertEquals(WinKey(0x2C, false), WinScancode.of(KeyEvent.KEYCODE_Z))
assertEquals(WinKey(0x02, false), WinScancode.of(KeyEvent.KEYCODE_1))
assertEquals(WinKey(0x0B, false), WinScancode.of(KeyEvent.KEYCODE_0))
assertEquals(WinKey(0x39, false), WinScancode.of(KeyEvent.KEYCODE_SPACE))
}
@Test
fun unmapped_and_unknown_keycodes_return_null() {
// KEYCODE_UNKNOWN=0:软键盘可打印字符的形态,必须落 null 让调用方走 Text 通道,
// 不能兜底成任意扫描码(2b D13 的根因之一)
assertNull(WinScancode.of(KeyEvent.KEYCODE_UNKNOWN))
assertNull(WinScancode.of(0))
assertNull(WinScancode.of(-1))
assertNull(WinScancode.of(9999))
}
@Test
fun f_keys_pinned_until_u6_physical_keyboard_verification() {
// N5/U6:F1-F10 来自 Set-1 常识与 keymap.rs 交集,未在 Windows 逐一对账;
// 这里锁「当前值不变」,防止无声漂移——真机接物理键盘验证后(U6)才允许改期望值
assertEquals(WinKey(0x3B, false), WinScancode.of(KeyEvent.KEYCODE_F1))
assertEquals(WinKey(0x44, false), WinScancode.of(KeyEvent.KEYCODE_F10))
}
}

View File

@ -15,6 +15,7 @@
//! 仅限局域网测试)。产品化前必须换成信令下发的证书指纹钉扎(`signaling.rs`
//! 已有 `CertFingerprint`),该项登记在方案阶段 4。
use anyhow::{bail, Context, Result};
use common::{
read_control, read_video_frame, write_control, ControlMsg, InputEvent, VideoFrameHeader,
PROTO_VERSION,
@ -29,30 +30,6 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
/// QUIC 握手单次尝试预算(第一拨):不可达/被拦路径的失败上限(阶段 3a U8)。
/// 与被控端 `agent-core` 的 max_idle_timeout(10s) 同思路——超时策略必须显式、短、可见。
pub const CONNECT_BUDGET: Duration = Duration::from_secs(5);
/// 第二拨握手预算(重拨专用,8s > 第一拨)。
/// 存在理由:agent 串行 accept,僵尸释放实测在「杀端后 ~10.2s + 残骸握手再排
/// 5s 档」≈13s 窗口内;第二拨 8s 让握手段天然长于僵尸窗口,一次重拨接住。
/// 真·不可达不受此影响——它由总预算封顶报 Timeout。
pub const CONNECT_RETRY_BUDGET: Duration = Duration::from_secs(8);
/// 认证应答预算:握手成功后等 `AuthOk` 的上限。agent 串行 accept(R-5),
/// 正常应答是毫秒级;11s 只是异常场景(agent 卡在旧会话收尾)的兜底。
pub const AUTH_BUDGET: Duration = Duration::from_secs(11);
/// connect() 全程硬上限(含重拨)。14s = 5 + 8 两拨握手预算 + 1s 余量:
/// 正常路径由**内层**各拨预算先触发(错误 detail 精确到段),外层只兜
/// 「重试组合出无界等待」这一种失控。真不可达 → ≈13s 报 Timeout
/// (2b 的 32s → 13s 且全程可取消,判据更新见 V-A1)。
pub const CONNECT_TOTAL_BUDGET: Duration = Duration::from_secs(14);
/// 可重拨失败(Timeout/Refused)的最大尝试次数。Resolve/Auth/Version 是
/// 确定性结果,重拨只会拖慢反馈,不重试。
pub const MAX_CONNECT_ATTEMPTS: usize = 2;
/// 连接目标。当前只有局域网直连;设备码 + 打洞在后续阶段替换 `connect()` 内部实现。
#[derive(Debug, Clone)]
pub enum PeerTarget {
@ -85,110 +62,6 @@ impl ConnectConfig {
}
}
/// 连接建立失败的分类(阶段 3a)。
///
/// 为什么分类在 Rust 侧做而不是 Kotlin:错误来源(DNS / QUIC 握手 / 认证 / 版本)
/// 只有这一层看得全;Kotlin 只做 `code → 中文文案` 映射。
/// [`ConnectErrorKind::code`] 是 FFI 编号(android-ffi `nativeLastErrorKind` 与 Kotlin
/// `ConnectErrorText` 都按它映射),**改动必须三处同步**——FFI 只传整数不传 Rust 类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectErrorKind {
/// 地址解析失败:host 写错或 DNS 不可达
Resolve,
/// 连接超时:被控端不在线、IP/端口不对、入站被拦
Timeout,
/// 被控端异常应答/拒绝:该端口上没有 QUIC 服务、握手被重置
Refused,
/// 认证失败:验证码错误
Auth,
/// 协议版本不一致:两端必须重编到同一版本
Version,
/// 未分类。**detail 原文必须展示**(第二行),不得静默归进某类文案
Other,
}
impl ConnectErrorKind {
/// FFI 稳定编号。新增项只追加,不复用旧编号(Kotlin `else ->` 兜住未知码)。
pub fn code(self) -> i32 {
match self {
ConnectErrorKind::Resolve => 1,
ConnectErrorKind::Timeout => 2,
ConnectErrorKind::Refused => 3,
ConnectErrorKind::Auth => 4,
ConnectErrorKind::Version => 5,
ConnectErrorKind::Other => 9,
}
}
}
/// 连接失败 = 分类 + 原始错误文本。
#[derive(Debug)]
pub struct ConnectError {
pub kind: ConnectErrorKind,
pub detail: String,
}
impl ConnectError {
fn new(kind: ConnectErrorKind, detail: impl Into<String>) -> Self {
Self { kind, detail: detail.into() }
}
}
impl std::fmt::Display for ConnectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.detail)
}
}
impl std::error::Error for ConnectError {}
/// 未分类错误的统一包装:保留原文(Other 的 detail 会显示到界面第二行,不静默)。
fn other_err(e: impl std::fmt::Display) -> ConnectError {
ConnectError::new(ConnectErrorKind::Other, e.to_string())
}
/// 认证失败消息 → 分类。判据是 agent 侧的稳定前缀(`agent/src/main.rs` 与
/// `agent-core/src/lib.rs` 都以 `protocol version mismatch` 开头);
/// 命中不了的一律 `Auth`——宁可提示「验证码错误」也不把内部错误文案当分类依据。
fn classify_auth_fail(msg: &str) -> ConnectErrorKind {
if msg.contains("protocol version mismatch") {
ConnectErrorKind::Version
} else {
ConnectErrorKind::Auth
}
}
/// 同步 connect() 调用(本地配置面)的分类:全是 Other(endpoint 停摆/地址非法等),
/// 保留原文。单列函数是为了让分类面在测试里可枚举、改动可见。
fn classify_connect_error(_e: &quinn::ConnectError) -> ConnectErrorKind {
ConnectErrorKind::Other
}
/// 哪些失败值得重拨(3a 方案①)。
/// - Timeout/Refused:agent 串行 accept 的排队/残骸窗口是**暂时**的
/// (实测僵尸释放 ~10.2s、残骸阻塞握手 5~8.5s),第二拨能接住;
/// - Resolve/Auth/Version:确定性结果,重拨只拖慢反馈、不改变答案;
/// - Other:语义不明(含钉扎未实现、首消息异常),不猜、不重试。
fn is_retryable(kind: ConnectErrorKind) -> bool {
matches!(kind, ConnectErrorKind::Timeout | ConnectErrorKind::Refused)
}
/// 握手/连接期错误 → 分类。逐个变体对照 quinn 0.11.12 re-export 的
/// `quinn::ConnectionError`(quinn-proto 0.11.19 `connection/mod.rs:3906`,本会话实读)。
fn classify_connection_error(e: &quinn::ConnectionError) -> ConnectErrorKind {
use quinn::ConnectionError as E;
match e {
E::TimedOut => ConnectErrorKind::Timeout,
// 端口上没有 QUIC 服务(回收包/非法头)、被重置、握手后被关:
// 用户语境统一是「对端不应答」→ Refused
E::TransportError(_) | E::Reset | E::ConnectionClosed(_) | E::ApplicationClosed(_) => {
ConnectErrorKind::Refused
}
// QUIC 传输版本协商失败 ≠ 应用协议版本不一致,不要误导成 Version
E::VersionMismatch | E::LocallyClosed | E::CidsExhausted => ConnectErrorKind::Other,
}
}
/// 交付给消费方的一帧视频。字段语义与 `common::VideoFrameHeader` 一致,
/// payload 为标准 Annex-B H.264(可直接喂 MediaCodec)。
#[derive(Debug, Clone)]
@ -290,66 +163,7 @@ pub struct Session {
impl Session {
/// 建立连接并完成认证握手 + 首帧关键帧请求。
///
/// **短预算 × 有限重拨**(3a/U8,2026-10-31 用户裁决方案①;演进证据链见
/// loop-3-plan 偏差 D2/D3:单次 5s 预算在 agent 串行 accept 架构下自相矛盾——
/// 不可达要快失败、僵尸排队要耐心等,同一数字满足不了;拆握手/认证两段也不够,
/// 实测阻塞点在握手本身):
/// - 每次尝试:握手第一拨 [`CONNECT_BUDGET`](5s)、重拨拨
/// [`CONNECT_RETRY_BUDGET`](8s)、认证 [`AUTH_BUDGET`](11s);
/// - [`is_retryable`] 的失败(Timeout/Refused)**自动重拨**,共
/// [`MAX_CONNECT_ATTEMPTS`](2) 次。存在理由(2026-10-31 两组对照实验):
/// agent 串行 accept 期间不泵 socket,新握手被残骸/僵尸阻塞实测 5~8.5s
/// (杀会话后必现、干净连发 8 次亦有 1 次);第一拨 5s 被切是预期成本,
/// 第二拨的窗口天然落在释放点之后。包级机制未逐行核实(R-15),此处只
/// 陈述实测:**第一拨 5s 超时 → 第二拨成功**(kill 场景 total ≈5-11s 内接住)。
/// - 全程 [`CONNECT_TOTAL_BUDGET`](13s) 硬上限 = 5+8 完整两拨,
/// 防重试组合出无界等待;
/// - 真不可达 ≈13s 报 Timeout(修复前 32s),**全程 UI 可取消**——
/// U8 判据形态由此更新(plan V-A1)。
///
/// 已知边界:DNS 解析走同步 `to_socket_addrs`,current-thread runtime 里阻塞调用
/// 不能被 timeout 打断;本机实测解析失败是亚秒级。极端 DNS 挂起属阶段 4 网络治理面。
pub async fn connect(cfg: ConnectConfig) -> Result<Self, ConnectError> {
let attempts = async {
let mut attempt = 0usize;
loop {
attempt += 1;
// 第一拨短握手(快速判不可达),重拨拨长握手(接住僵尸释放)
let handshake = if attempt == 1 {
CONNECT_BUDGET
} else {
CONNECT_RETRY_BUDGET
};
match Self::connect_once(cfg.clone(), handshake).await {
Ok(s) => return Ok(s),
Err(e) => {
if attempt >= MAX_CONNECT_ATTEMPTS || !is_retryable(e.kind) {
return Err(e);
}
log(&format!(
"connect 第 {attempt}/{MAX_CONNECT_ATTEMPTS} 拨失败: {}(重拨)",
e.detail
));
}
}
}
};
// 外层硬上限。Timeout::poll 先轮询内层——已成功返回的会话不会被
// 「刚好到点」的外层吞掉(D2 的教训:预算必须给合法路径留出完成空间)
match tokio::time::timeout(CONNECT_TOTAL_BUDGET, attempts).await {
Ok(r) => r,
Err(_) => Err(ConnectError::new(
ConnectErrorKind::Timeout,
format!(
"connect exceeded {}s total budget within {MAX_CONNECT_ATTEMPTS} attempts",
CONNECT_TOTAL_BUDGET.as_secs()
),
)),
}
}
async fn connect_once(cfg: ConnectConfig, handshake_budget: Duration) -> Result<Self, ConnectError> {
pub async fn connect(cfg: ConnectConfig) -> Result<Self> {
rustls::crypto::ring::default_provider()
.install_default()
.ok();
@ -361,14 +175,11 @@ impl Session {
.with_no_client_auth()
} else {
// 证书钉扎尚未接入(方案阶段 4)。这里明确报错而不是静默放行。
return Err(ConnectError::new(
ConnectErrorKind::Other,
"insecure_skip_verify=false 需要证书钉扎,尚未实现(见方案阶段 4)",
));
bail!("insecure_skip_verify=false 需要证书钉扎,尚未实现(见方案阶段 4)");
};
let qc: quinn::crypto::rustls::QuicClientConfig = Arc::new(tls)
.try_into()
.map_err(other_err)?;
.context("build QUIC client config")?;
// 接收窗口放大:视频下行大流量,quinn 默认 512KB 流窗口会卡吞吐
// (与桌面 viewer / agent 的实测调优一致)
@ -378,54 +189,21 @@ impl Session {
let mut client_config = quinn::ClientConfig::new(Arc::new(qc));
client_config.transport_config(Arc::new(transport));
let mut endpoint =
quinn::Endpoint::client("0.0.0.0:0".parse().map_err(other_err)?).map_err(other_err)?;
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse()?)?;
endpoint.set_default_client_config(client_config);
let PeerTarget::Direct { host, port } = &cfg.target;
let addr = (host.as_str(), *port)
.to_socket_addrs()
.map_err(|e| {
ConnectError::new(
ConnectErrorKind::Resolve,
format!("resolve {host}:{port}: {e}"),
)
})?
.to_socket_addrs()?
.next()
.ok_or_else(|| {
ConnectError::new(
ConnectErrorKind::Resolve,
format!("resolve {host}:{port}: no record"),
)
})?;
let conn = {
let connect_fut = endpoint.connect(addr, "localhost").map_err(|e| {
ConnectError::new(classify_connect_error(&e), e.to_string())
})?;
// 单次尝试的握手预算(第一拨 5s、重拨拨 8s,见 connect() 注释)。
// 超时归类 Timeout(可重拨);真不可达由重拨打满总预算后报出。
match tokio::time::timeout(handshake_budget, connect_fut).await {
Ok(Ok(c)) => c,
Ok(Err(e)) => {
return Err(ConnectError::new(
classify_connection_error(&e),
e.to_string(),
))
}
Err(_) => {
return Err(ConnectError::new(
ConnectErrorKind::Timeout,
format!(
"QUIC handshake exceeded {}s budget",
handshake_budget.as_secs()
),
))
}
}
};
.with_context(|| format!("resolve {host}:{port}"))?;
let conn = endpoint
.connect(addr, "localhost")?
.await
.with_context(|| format!("QUIC connect {addr}"))?;
// ---- 流 1:认证 + 视频下行 ----
let (mut v_send, mut v_recv) = conn.open_bi().await.map_err(other_err)?;
let (mut v_send, mut v_recv) = conn.open_bi().await?;
write_control(
&mut v_send,
&ControlMsg::Auth {
@ -434,59 +212,30 @@ impl Session {
},
)
.await
.map_err(other_err)?;
.context("send Auth")?;
let desktop: (u32, u32);
// 认证应答预算 11s:agent 串行 accept,僵尸会话最长 ~10s(R-5)才轮到本请求。
// 这一段超时的语义是「连上了但对端一直不应答」——同样归类 Timeout,detail 说清。
let first_msg = match tokio::time::timeout(AUTH_BUDGET, read_control(&mut v_recv)).await {
Ok(r) => r.map_err(other_err)?,
Err(_) => {
return Err(ConnectError::new(
ConnectErrorKind::Timeout,
format!(
"connected but auth reply exceeded {}s (agent busy?)",
AUTH_BUDGET.as_secs()
),
))
}
};
match first_msg {
match read_control(&mut v_recv).await.context("read AuthOk")? {
ControlMsg::AuthOk {
width,
height,
proto_version,
} => {
if proto_version != PROTO_VERSION {
return Err(ConnectError::new(
ConnectErrorKind::Version,
format!(
"protocol mismatch: agent={proto_version}, viewer-core={PROTO_VERSION}"
),
));
bail!(
"protocol mismatch: agent={proto_version}, viewer-core={PROTO_VERSION}"
);
}
desktop = (width, height);
log(&format!("auth ok, remote desktop {width}x{height}"));
}
ControlMsg::AuthFail(e) => {
return Err(ConnectError::new(
classify_auth_fail(&e),
format!("auth failed: {e}"),
))
}
m => {
return Err(ConnectError::new(
ConnectErrorKind::Other,
format!("unexpected first message: {m:?}"),
))
}
ControlMsg::AuthFail(e) => bail!("auth failed: {e}"),
m => bail!("unexpected first message: {m:?}"),
}
// ---- 流 2:输入 + 控制 + Ping/Pong ----
let (mut ctl_send, mut ctl_recv) = conn.open_bi().await.map_err(other_err)?;
let (mut ctl_send, mut ctl_recv) = conn.open_bi().await?;
// 入会即请求 IDR:从 GOP 中间接入时裸 P 帧解不出来(桌面端同款教训)
write_control(&mut ctl_send, &ControlMsg::KeyFrameReq)
.await
.map_err(other_err)?;
write_control(&mut ctl_send, &ControlMsg::KeyFrameReq).await?;
let stats = Arc::new(std::sync::Mutex::new(SessionStats::default()));
@ -698,138 +447,8 @@ mod tests {
insecure_skip_verify: false,
};
match Session::connect(cfg).await {
Err(e) => {
assert_eq!(e.kind, ConnectErrorKind::Other);
assert!(e.detail.contains("证书钉扎"), "实际错误: {e}");
}
Err(e) => assert!(e.to_string().contains("证书钉扎"), "实际错误: {e}"),
Ok(_) => panic!("insecure_skip_verify=false 必须明确报错,不得静默放行"),
}
}
#[test]
fn auth_fail_classification_uses_agent_message_prefix() {
// 判据来自 agent 侧的真实文案(agent/src/main.rs:111),不是自造样本
assert_eq!(
classify_auth_fail("protocol version mismatch: client=2, server=3"),
ConnectErrorKind::Version
);
assert_eq!(classify_auth_fail("bad code"), ConnectErrorKind::Auth);
// 未知形态宁可归 Auth 也不静默
assert_eq!(
classify_auth_fail("expected Auth, got Ping(0)"),
ConnectErrorKind::Auth
);
}
#[test]
fn ffi_error_codes_are_stable_and_unique() {
// FFI 编号是 Kotlin 映射表的契约面:改动=公共 API 变更。
// 期望值写死(R-8:判据是等于期望值,不是"没重复")。
let expect = [
(ConnectErrorKind::Resolve, 1),
(ConnectErrorKind::Timeout, 2),
(ConnectErrorKind::Refused, 3),
(ConnectErrorKind::Auth, 4),
(ConnectErrorKind::Version, 5),
(ConnectErrorKind::Other, 9),
];
for (kind, code) in expect {
assert_eq!(kind.code(), code);
}
}
#[tokio::test]
async fn unresolvable_host_is_classified_as_resolve() {
// 本机环境事实(2026-10-31 实测):这台办公机的 DNS 拦截层(Meta/198.18.0.1)
// 把 `.invalid` 直接**解析成功**并指向 198.18.0.x 假地址——RFC 6761 的
// NXDOMAIN 保证在这条链路上不成立,`.invalid` 样本会走成 Timeout。
// 含空格的 name 在任何 resolver 里都是非法输入、快速失败(实测 26ms),
// 判据不依赖本机 DNS 行为。
let cfg = ConnectConfig::direct("a b", 47800, "t");
let e = match Session::connect(cfg).await {
Ok(_) => panic!("非法主机名必须解析失败"),
Err(e) => e,
};
assert_eq!(e.kind, ConnectErrorKind::Resolve, "实际: {e:?}");
assert!(e.detail.contains("a b"), "detail 必须含原输入: {e}");
}
#[tokio::test]
async fn unreachable_peer_redials_then_reports_timeout() {
// TEST-NET-1(RFC 5737):无可达主机 → 每拨握手都无应答。
// 方案①(用户裁决):第一拨 5s 超时 → 重拨 → 第二拨 8s 超时 → 报 Timeout。
// 期望落点 [13s-1s, 14s+2s):
// - 下界 ≈ 两拨握手预算之和,证明**第二拨真的跑完**(重拨丢失就是回归);
// - 上界证明总预算封顶(不会滚向第三拨)。
// 修复前(quinn 默认)这里要 32s;U8 判据形态更新为「≤13s 且全程可取消」。
let cfg = ConnectConfig::direct("192.0.2.1", 47800, "t");
let t0 = Instant::now();
let e = match Session::connect(cfg).await {
Ok(_) => panic!("TEST-NET-1 无主机,必须失败"),
Err(e) => e,
};
let elapsed = t0.elapsed();
assert_eq!(e.kind, ConnectErrorKind::Timeout, "实际: {e:?}");
let two_dials = CONNECT_BUDGET + CONNECT_RETRY_BUDGET;
assert!(
elapsed >= two_dials - Duration::from_secs(1)
&& elapsed < CONNECT_TOTAL_BUDGET + Duration::from_secs(2),
"重拨后的等待 {elapsed:?} 不在 [{two_dials:?}-1s, {CONNECT_TOTAL_BUDGET:?}+2s)"
);
}
#[test]
fn budget_constants_match_redial_design() {
// 方案①常量关系固化(2026-10-31 用户裁决):
// - 第一拨握手 5s:不可达的快速探测
// - 第二拨握手 8s:第二拨起点在 ~5s,需盖住实测僵尸释放点 ~10.2s
// (R-5 idle 超时)+ 余量——比第一拨长是这个物理原因,不是随手加大
// - 总预算 14s:两拨之和 + 1s,让内层各拨 Timeout 先触发(错误 detail
// 精确到段),外层只封顶失控组合
// - 认证 11s:> 僵尸释放 10.2s(实测),握手成功后排队不被压断
assert_eq!(CONNECT_BUDGET, Duration::from_secs(5));
assert_eq!(CONNECT_RETRY_BUDGET, Duration::from_secs(8));
assert_eq!(AUTH_BUDGET, Duration::from_secs(11));
assert_eq!(CONNECT_TOTAL_BUDGET, Duration::from_secs(14));
assert_eq!(MAX_CONNECT_ATTEMPTS, 2);
assert!(CONNECT_RETRY_BUDGET > CONNECT_BUDGET, "第二拨必须更长才盖得住释放点");
assert!(CONNECT_TOTAL_BUDGET > CONNECT_BUDGET + CONNECT_RETRY_BUDGET);
assert!(AUTH_BUDGET > Duration::from_secs(10));
// 重拨名单是策略面:只有暂时性失败可重试,改动必须过这条测试
assert!(is_retryable(ConnectErrorKind::Timeout));
assert!(is_retryable(ConnectErrorKind::Refused));
for k in [
ConnectErrorKind::Resolve,
ConnectErrorKind::Auth,
ConnectErrorKind::Version,
ConnectErrorKind::Other,
] {
assert!(!is_retryable(k), "{k:?} 是确定性失败,不得重拨");
}
}
#[test]
fn connection_error_variants_map_to_declared_kinds() {
// 只测**可安全构造**的单元变体(unit variants):TransportError /
// ConnectionClosed / ApplicationClose 的字段是 `bytes::Bytes`,测试构造
// 需要把 bytes 提为直接依赖——为分类表测试引依赖不值当(偏差 D-R1)。
// 这三个结构变体的映射由 match 的穷尽性 + 上面的注释钉住;
// 真实归类靠 V-A2 的端到端(端口上跑非 QUIC 服务)覆盖。
assert_eq!(
classify_connection_error(&quinn::ConnectionError::TimedOut),
ConnectErrorKind::Timeout
);
assert_eq!(
classify_connection_error(&quinn::ConnectionError::Reset),
ConnectErrorKind::Refused
);
assert_eq!(
classify_connection_error(&quinn::ConnectionError::VersionMismatch),
ConnectErrorKind::Other
);
assert_eq!(
classify_connection_error(&quinn::ConnectionError::LocallyClosed),
ConnectErrorKind::Other
);
}
}