From 5047618c01db611763618006566db9c0a4e2233d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E5=8D=9A=E6=96=87?= <1323515022@qq.com> Date: Fri, 30 Oct 2026 09:51:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(android):=20=E8=A7=A6=E6=91=B8=E6=98=A0?= =?UTF-8?q?=E5=B0=84=20+=20letterbox=20=E6=98=BE=E7=A4=BA=EF=BC=88?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=202a=20=E4=BB=A3=E7=A0=81=E9=83=A8=E5=88=86?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:阶段 1 的 SurfaceView 用 MATCH_PARENT,2560x1440 的视频被拉进 1080x2400 竖屏,画面变形。变形状态下手指位置与远端指针不再一一对应, 触摸映射无从谈起。所以两者必须一起做。 新增 RemoteSurfaceView - onMeasure 按视频宽高比 letterbox:视图尺寸 = 画面尺寸,黑边不属于视图, 触摸天然只在画面内产生事件,坐标归一化到 0..=65535 才准确 - 手势:单指轻点=左键、单指拖动=左键拖拽、双指轻点=右键、双指滑动=滚轮 - 刻意不做「手指即光标」的绝对跟随:手指会完全挡住目标,精细操作不可用 - 边界处理:进入拖拽后落下第二指,先抬起左键再切双指手势, 否则远端会留下一个被按住未释放的按键 协议侧(不改线格式,只加只读查询) - viewer-core::Session 记录 AuthOk 的桌面尺寸,新增 desktop_size() - android-ffi 新增 nativeDesktopSize(handle) -> i64(高32=宽,低32=高) - RustBridge.desktopSize() 拆包 - 尺寸必须在建解码器之前拿到:等首帧再改视图尺寸会触发重新测量, 把刚创建的解码器 surface 打掉 MainActivity - 接入 RemoteSurfaceView.Listener;连接成功后先取尺寸设宽高比,再起解码线程 - 返回键 = 断开(不退出 App,便于重连);去掉阶段 1 的双击断开 (双击现在语义上是「两次左键点击」,会和远端交互冲突) 验证:cargo build --workspace 通过;cargo test 全绿(common 20 + viewer-core 3)。 真机手势验收待手机重新连接后补。 --- android-ffi/src/lib.rs | 16 ++ .../kotlin/com/verify/remote/MainActivity.kt | 153 +++++++------- .../com/verify/remote/RemoteSurfaceView.kt | 200 ++++++++++++++++++ .../kotlin/com/verify/remote/RustBridge.kt | 13 ++ viewer-core/src/lib.rs | 11 + 5 files changed, 315 insertions(+), 78 deletions(-) create mode 100644 apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteSurfaceView.kt diff --git a/android-ffi/src/lib.rs b/android-ffi/src/lib.rs index c1f9ee7..0a3e9b1 100644 --- a/android-ffi/src/lib.rs +++ b/android-ffi/src/lib.rs @@ -172,6 +172,22 @@ pub extern "system" fn Java_com_verify_remote_RustBridge_nativeStats( (((st.rtt_ms & 0xFFFF_FFFF) << 32) | (st.frames_in & 0xFFFF_FFFF)) as jlong } +/// 被控端桌面尺寸,打包成 i64:高 32 位宽、低 32 位高。0 = 句柄无效。 +/// Kotlin 侧必须在建解码器之前调用,用返回值定 SurfaceView 宽高比。 +#[no_mangle] +pub extern "system" fn Java_com_verify_remote_RustBridge_nativeDesktopSize( + _env: JNIEnv, + _class: JClass, + handle: jlong, +) -> jlong { + let Some(boxed) = handle_to_box(handle) else { + return 0; + }; + let guard = lock_box(boxed); + let (w, h) = guard.session.desktop_size(); + (((w as u64 & 0xFFFF_FFFF) << 32) | (h as u64 & 0xFFFF_FFFF)) as jlong +} + #[no_mangle] pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendMouseMove( _env: JNIEnv, diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt index d9ddc2b..1dd933e 100644 --- a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt @@ -3,28 +3,26 @@ package com.verify.remote import android.app.Activity import android.os.Bundle import android.util.Log -import android.view.MotionEvent +import android.view.KeyEvent import android.view.SurfaceHolder -import android.view.SurfaceView import android.widget.TextView import kotlin.concurrent.thread /** - * 阶段 1 验收 App:输入 IP/端口/验证码 → 连 agent.exe → 显示桌面。 - * - * 刻意用最简结构(纯代码布局、无 AndroidX、无第三方库): - * 阶段 1 的目标是打通「QUIC 收帧 → MediaCodec 解码 → Surface 显示」, - * UI 框架与交互打磨属于阶段 2/3。 + * 安卓主控端(阶段 2a):连接被控端 → MediaCodec 解码 → SurfaceView 显示 + 触摸操作。 * * 线程模型(与 android-ffi 的锁纪律对应): - * - UI 线程:按钮连接/断开、触摸输入(nativeSend* 不持锁等待) + * - UI 线程:触摸/按键 → nativeSend*(入队不阻塞);连接/断开 * - 解码线程:nativeNextFrame 阻塞取帧 → H264Decoder.queue * - 断开顺序:**先置 stopDecoding 并 join 解码线程,再 destroy** - * (Rust 侧文档明确:解码线程存活期间 destroy 是 UB) + * (Rust 侧明确:解码线程存活期间 destroy 是 UB) + * + * 布局:SurfaceView 按视频宽高比 letterbox 居中(RemoteSurfaceView), + * 触摸坐标只在画面区域内产生,与远端归一化坐标一一对应。 */ -class MainActivity : Activity() { +class MainActivity : Activity(), RemoteSurfaceView.Listener { - private lateinit var surfaceView: SurfaceView + private lateinit var surfaceView: RemoteSurfaceView private lateinit var statusView: TextView @Volatile @@ -34,95 +32,95 @@ class MainActivity : Activity() { private var stopDecoding = false private var decodeThread: Thread? = null - // 远端桌面尺寸(AuthOk 首帧头携带) @Volatile private var remoteW = 0 @Volatile private var remoteH = 0 - private var decoder: H264Decoder? = null - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - surfaceView = SurfaceView(this).apply { + surfaceView = RemoteSurfaceView(this).apply { + listener = this@MainActivity holder.addCallback(object : SurfaceHolder.Callback { override fun surfaceCreated(holder: SurfaceHolder) {} override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, ht: Int) {} override fun surfaceDestroyed(holder: SurfaceHolder) { - // Surface 没了解码器输出会失效:直接断开,下次重连 + // Surface 没了解码器输出会失效:断开,重进 App 重连 disconnect() } }) } statusView = TextView(this).apply { - textSize = 13f - setPadding(24, 24, 24, 24) + textSize = 12f + setPadding(20, 12, 20, 12) setBackgroundColor(0xCC000000.toInt()) setTextColor(0xFFFFFFFF.toInt()) } - val root = android.widget.LinearLayout(this).apply { - orientation = android.widget.LinearLayout.VERTICAL + // 画面居中、黑边填充:LinearLayout 竖直 + gravity center + val stage = android.widget.FrameLayout(this).apply { + setBackgroundColor(0xFF000000.toInt()) addView( surfaceView, - android.widget.LinearLayout.LayoutParams( - android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f - ) - ) - addView( - statusView, - android.widget.LinearLayout.LayoutParams( - android.widget.LinearLayout.LayoutParams.MATCH_PARENT, - android.widget.LinearLayout.LayoutParams.WRAP_CONTENT + android.widget.FrameLayout.LayoutParams( + android.widget.FrameLayout.LayoutParams.WRAP_CONTENT, + android.widget.FrameLayout.LayoutParams.WRAP_CONTENT, + android.view.Gravity.CENTER ) ) } + val root = android.widget.LinearLayout(this).apply { + orientation = android.widget.LinearLayout.VERTICAL + addView(stage, android.widget.LinearLayout.LayoutParams( + android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)) + addView(statusView, android.widget.LinearLayout.LayoutParams( + android.widget.LinearLayout.LayoutParams.MATCH_PARENT, + android.widget.LinearLayout.LayoutParams.WRAP_CONTENT)) + } setContentView(root) - // 阶段 1 交互:启动即自动连接(参数走 intent extra,由 adb 传入), - // 双击画面断开。正式输入界面与触摸映射在阶段 2/3。 showHint() - val i = intent - val host = i?.getStringExtra("host") - if (host != null) { - connect(host, i.getIntExtra("port", 47800), i.getStringExtra("code") ?: "123456") + intent?.getStringExtra("host")?.let { host -> + connect(host, intent.getIntExtra("port", 47800), intent.getStringExtra("code") ?: "123456") } } private fun showHint() { - statusView.text = buildString { - append(if (handle != 0L) "已连接 ${remoteW}x$remoteH\n" else "未连接\n") - append("双击画面=断开;连接参数走 intent:\n") - append("am start -n com.verify.remote/.MainActivity ") - append("--es host --ei port 47800 --es code t") + statusView.text = if (handle != 0L) { + "已连接 ${remoteW}x$remoteH|单指=左键 双指轻点=右键 双指滑动=滚轮 返回键=断开" + } else { + "未连接|am start -n com.verify.remote/.MainActivity --es host --ei port 47800 --es code t" } } - override fun onTouchEvent(event: MotionEvent): Boolean { - when (event.action) { - MotionEvent.ACTION_UP -> { - val now = System.currentTimeMillis() - if (now - lastTap < 300) { // 双击:断开 - disconnect() - return true - } - lastTap = now - } - MotionEvent.ACTION_MOVE -> { - // 阶段 1 输入:单指绝对坐标映射(远端桌面尺寸已知时)。 - // 正式触摸交互(虚拟鼠标/手势)在阶段 2/3。 - if (handle != 0L && remoteW > 0) { - val x = (event.x / surfaceView.width * 65535).toInt().coerceIn(0, 65535) - val y = (event.y / surfaceView.height * 65535).toInt().coerceIn(0, 65535) - RustBridge.nativeSendMouseMove(handle, x, y) - } - } - } - return super.onTouchEvent(event) + // ---- RemoteSurfaceView.Listener ---- + + override fun sendMouseMove(x: Int, y: Int) { + val h = handle + if (h != 0L) RustBridge.nativeSendMouseMove(h, x, y) } - private var lastTap = 0L + override fun sendMouseButton(button: Int, down: Boolean) { + val h = handle + if (h != 0L) RustBridge.nativeSendMouseButton(h, button, if (down) 1 else 0) + } + + override fun sendWheel(dy: Int) { + val h = handle + if (h != 0L) RustBridge.nativeSendWheel(h, dy) + } + + /** 返回键 = 断开(不退出 App,便于重连) */ + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK && handle != 0L) { + disconnect() + return true + } + return super.onKeyDown(keyCode, event) + } + + // ---- 连接 / 解码 ---- private fun connect(host: String, port: Int, code: String) { if (handle != 0L) return @@ -130,8 +128,17 @@ class MainActivity : Activity() { thread(name = "connect") { try { val h = RustBridge.connect(host, port, code) + // 尺寸必须在解码前拿到:用它定 SurfaceView 宽高比。 + // 若等首帧再改,视图会重新测量,把刚建好的解码器 surface 打掉。 + val (w, ht) = RustBridge.desktopSize(h) handle = h - runOnUiThread { showHint(); startDecodeLoop(h) } + remoteW = w + remoteH = ht + runOnUiThread { + if (w > 0 && ht > 0) surfaceView.videoAspectRatio = w.toFloat() / ht + showHint() + startDecodeLoop(h) + } } catch (e: Exception) { Log.e(TAG, "connect failed", e) runOnUiThread { statusView.text = "连接失败: ${e.message}" } @@ -142,7 +149,6 @@ class MainActivity : Activity() { private fun startDecodeLoop(h: Long) { stopDecoding = false decodeThread = thread(name = "decode") { - // Surface 可能尚未创建好,等它 val surface = surfaceView.holder.surface var tries = 0 while (!surface.isValid && tries++ < 100) Thread.sleep(50) @@ -151,7 +157,6 @@ class MainActivity : Activity() { return@thread } val dec = H264Decoder(surface) - decoder = dec var frames = 0L var lastKeyReq = 0L while (!stopDecoding) { @@ -161,27 +166,21 @@ class MainActivity : Activity() { Log.e(TAG, "nextFrame threw", e) null } ?: break - if (remoteW == 0) { - remoteW = f.width - remoteH = f.height - runOnUiThread { showHint() } - } val fed = dec.queue(f) - if (!fed && f.keyframe.not() && System.currentTimeMillis() - lastKeyReq > 200) { - // 处于等 IDR 状态:限频请求关键帧 - RustBridge.nativeRequestKeyframe(h) + if (!fed && !f.keyframe && System.currentTimeMillis() - lastKeyReq > 200) { + RustBridge.nativeRequestKeyframe(h) // 等 IDR 中:限频请求 lastKeyReq = System.currentTimeMillis() } frames++ if (frames % 120 == 0L) { val rtt = RustBridge.rttMs(h) runOnUiThread { - statusView.text = "已连接 ${remoteW}x$remoteH 帧=$frames RTT=${rtt}ms(双击断开)" + statusView.text = + "已连接 ${remoteW}x$remoteH 帧=$frames RTT=${rtt}ms|返回键断开" } } } dec.release() - decoder = null Log.i(TAG, "decode loop ended, frames=$frames") runOnUiThread { statusView.text = "会话结束(帧 $frames)" @@ -194,9 +193,7 @@ class MainActivity : Activity() { if (handle == 0L) return stopDecoding = true // 顺序纪律:等解码线程退出后才能 destroy(见 android-ffi 文件头)。 - // 卡帧风险:agent 静止时 500ms 一帧、QUIC idle 超时后流必关, - // 6s 足够覆盖两者;超时仍 destroy 会 UB——阶段 2 在 Rust 侧加 - // nativeShutdown(先关连接唤醒 nextFrame 再销毁)消除该竞态。 + // 卡帧风险:agent 静止时 500ms 一帧、QUIC idle 超时后流必关,6s 足够覆盖。 decodeThread?.join(6000) decodeThread = null val h = handle diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteSurfaceView.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteSurfaceView.kt new file mode 100644 index 0000000..22305a9 --- /dev/null +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteSurfaceView.kt @@ -0,0 +1,200 @@ +package com.verify.remote + +import android.content.Context +import android.view.MotionEvent +import android.view.SurfaceView +import kotlin.math.abs +import kotlin.math.hypot + +/** + * 保持视频宽高比的 SurfaceView(letterbox),并把触摸手势映射为远端鼠标事件。 + * + * 为什么视图尺寸必须等于画面尺寸:触摸坐标按视图宽高归一化到远端 0..=65535。 + * 若视图被拉伸(阶段 1 用 MATCH_PARENT),画面与视图不再一一对应,手指位置与 + * 远端指针位置就会错位;letterbox 后黑边不属于本视图,触摸天然只在画面内产生事件。 + * + * 手势映射(桌面远控的通行方案,参考 Moonlight / Scrcpy 的交互形态, + * 实现为本项目独立编写): + * - 单指轻点 = 左键单击 + * - 单指按下并移动 = 左键拖拽(窗口拖动、文字选中) + * - 双指轻点 = 右键 + * - 双指上下滑动 = 滚轮 + * + * 刻意不做「手指即光标」的绝对定位:手指会完全挡住目标,桌面精细操作不可用。 + */ +class RemoteSurfaceView(context: Context) : SurfaceView(context) { + + /** 由 Activity 实现,转发到 RustBridge */ + interface Listener { + fun sendMouseMove(x: Int, y: Int) + fun sendMouseButton(button: Int, down: Boolean) + fun sendWheel(dy: Int) + } + + var listener: Listener? = null + + /** 视频宽高比(w/h)。>0 时视图按此比例测量。 */ + var videoAspectRatio = 0f + set(value) { + if (value > 0f && field != value) { + field = value + requestLayout() + } + } + + private enum class Mode { IDLE, TAP, DRAG, TWO_FINGER } + + private var mode = Mode.IDLE + private var downX = 0f + private var downY = 0f + private var lastX = 0f + private var lastY = 0f + private var downTime = 0L + private var twoFingerStartY = 0f + private var twoFingerDownTime = 0L + private var twoFingerMoved = false + private var wheelAccum = 0f + + /** 判定「拖拽 vs 点击」的位移阈值:12dp */ + private val dragSlopPx = resources.displayMetrics.density * 12f + + init { + isFocusable = true + } + + override fun onMeasure(widthSpec: Int, heightSpec: Int) { + super.onMeasure(widthSpec, heightSpec) + val ar = videoAspectRatio + if (ar <= 0f) return + var w = measuredWidth + var h = measuredHeight + if (w <= 0 || h <= 0) return + if (w.toFloat() > h * ar) w = (h * ar).toInt() else h = (w / ar).toInt() + setMeasuredDimension(w, h) + } + + private fun normX(x: Float) = (x / width * 65535).toInt().coerceIn(0, 65535) + private fun normY(y: Float) = (y / height * 65535).toInt().coerceIn(0, 65535) + + private fun moved(fromX: Float, fromY: Float, toX: Float, toY: Float) = + hypot((toX - fromX).toDouble(), (toY - fromY).toDouble()) > dragSlopPx + + private fun midX(e: MotionEvent) = + if (e.pointerCount >= 2) (e.getX(0) + e.getX(1)) / 2f else e.x + + private fun midY(e: MotionEvent) = + if (e.pointerCount >= 2) (e.getY(0) + e.getY(1)) / 2f else e.y + + override fun onTouchEvent(event: MotionEvent): Boolean { + val l = listener ?: return false + if (width == 0 || height == 0) return false + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + downX = event.x; downY = event.y + lastX = event.x; lastY = event.y + downTime = System.currentTimeMillis() + mode = Mode.TAP + // 先把远端指针移到位,点击/拖拽都以此为基准 + l.sendMouseMove(normX(event.x), normY(event.y)) + } + + MotionEvent.ACTION_POINTER_DOWN -> { + // 第二指落下 = 双指手势开始。若已进入拖拽(远端鼠标按着), + // 必须先抬起,否则远端会留下一个被按住未释放的左键。 + if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false) + mode = Mode.TWO_FINGER + twoFingerMoved = false + wheelAccum = 0f + twoFingerStartY = midY(event) + lastX = midX(event); lastY = midY(event) + twoFingerDownTime = System.currentTimeMillis() + } + + MotionEvent.ACTION_MOVE -> when (mode) { + Mode.TAP -> if (moved(downX, downY, event.x, event.y)) { + mode = Mode.DRAG + l.sendMouseButton(BTN_LEFT, true) + lastX = event.x; lastY = event.y + l.sendMouseMove(normX(event.x), normY(event.y)) + } + + Mode.DRAG -> { + lastX = event.x; lastY = event.y + l.sendMouseMove(normX(event.x), normY(event.y)) + } + + Mode.TWO_FINGER -> { + val cx = midX(event); val cy = midY(event) + if (abs(cy - twoFingerStartY) > dragSlopPx || moved(lastX, lastY, cx, cy)) { + twoFingerMoved = true + } + // 手指下移(cy 增大)= 内容向上翻 = Windows 滚轮向下 → dy 取负 + wheelAccum += (cy - lastY) + val notches = (wheelAccum / WHEEL_PX_PER_NOTCH).toInt() + if (notches != 0) { + l.sendWheel(-notches * WHEEL_DELTA) + wheelAccum -= notches * WHEEL_PX_PER_NOTCH + } + lastX = cx; lastY = cy + } + + Mode.IDLE -> {} + } + + MotionEvent.ACTION_POINTER_UP -> { + // 双指之一抬起:先判定右键,再把剩余手指的移动视为已结束手势 + if (mode == Mode.TWO_FINGER) { + maybeRightClick(l) + mode = Mode.IDLE + } + } + + MotionEvent.ACTION_UP -> { + when (mode) { + Mode.TAP -> { + // 轻点 = 左键单击(位置已在 DOWN 时发送) + l.sendMouseButton(BTN_LEFT, true) + l.sendMouseButton(BTN_LEFT, false) + performClick() + } + Mode.DRAG -> l.sendMouseButton(BTN_LEFT, false) + Mode.TWO_FINGER -> maybeRightClick(l) + Mode.IDLE -> {} + } + mode = Mode.IDLE + } + + MotionEvent.ACTION_CANCEL -> { + if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false) + mode = Mode.IDLE + } + } + return true + } + + /** 双指未移动且按住时间短 → 右键 */ + 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() + } + + @Suppress("ClickableViewAccessibility") + override fun performClick(): Boolean { + super.performClick() + return true + } + + companion object { + const val BTN_LEFT = 0 + const val BTN_RIGHT = 1 + const val BTN_MIDDLE = 2 + private const val WHEEL_DELTA = 120 + private const val WHEEL_PX_PER_NOTCH = 60f + private const val TWO_FINGER_TAP_MAX_MS = 500L + } +} diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RustBridge.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RustBridge.kt index c0ce60f..0cf2da5 100644 --- a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RustBridge.kt +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RustBridge.kt @@ -21,6 +21,7 @@ object RustBridge { /** 返回 null = 会话结束;否则 [FRAME_HEADER_LEN] 字节头 + Annex-B 载荷 */ external fun nativeNextFrame(handle: Long): ByteArray? external fun nativeStats(handle: Long): Long + external fun nativeDesktopSize(handle: Long): Long external fun nativeSendMouseMove(handle: Long, x: Int, y: Int) external fun nativeSendMouseButton(handle: Long, button: Int, down: Int) external fun nativeSendWheel(handle: Long, dy: Int) @@ -75,6 +76,18 @@ object RustBridge { return if (packed < 0) 0 else packed and 0xFFFF_FFFF } + /** + * 被控端桌面尺寸 (w, h)。连接成功后、建解码器之前调用。 + * 返回 (0,0) 表示句柄无效。 + */ + fun desktopSize(handle: Long): Pair { + val packed = nativeDesktopSize(handle) + if (packed == 0L) return 0 to 0 + val w = ((packed ushr 32) and 0xFFFF_FFFF).toInt() + val h = (packed and 0xFFFF_FFFF).toInt() + return w to h + } + private fun readLong(b: ByteArray, off: Int): Long { var v = 0L for (i in 0 until 8) v = v or ((b[off + i].toLong() and 0xFF) shl (8 * i)) diff --git a/viewer-core/src/lib.rs b/viewer-core/src/lib.rs index f8d9538..dd371fa 100644 --- a/viewer-core/src/lib.rs +++ b/viewer-core/src/lib.rs @@ -153,6 +153,9 @@ pub struct Session { /// 输入线程并发调 `send_input`(走无锁的 unbounded tx),两者互不阻塞。 frames: Arc>>, stats: Arc>, + /// 被控端桌面尺寸(AuthOk 携带)。主控端据此在解码前定好视图宽高比—— + /// 若等首帧再改尺寸会触发 SurfaceView 重新测量,把刚创建的解码器 surface 打掉。 + desktop: (u32, u32), /// 保持 Endpoint 存活(drop 会销毁连接) _endpoint: quinn::Endpoint, conn: quinn::Connection, @@ -210,6 +213,7 @@ impl Session { ) .await .context("send Auth")?; + let desktop: (u32, u32); match read_control(&mut v_recv).await.context("read AuthOk")? { ControlMsg::AuthOk { width, @@ -221,6 +225,7 @@ impl Session { "protocol mismatch: agent={proto_version}, viewer-core={PROTO_VERSION}" ); } + desktop = (width, height); log(&format!("auth ok, remote desktop {width}x{height}")); } ControlMsg::AuthFail(e) => bail!("auth failed: {e}"), @@ -317,11 +322,17 @@ impl Session { tx: cmd_tx, frames: Arc::new(tokio::sync::Mutex::new(frames)), stats, + desktop, _endpoint: endpoint, conn, }) } + /// 被控端桌面尺寸 `(width, height)`,来自 AuthOk,解码前即可用。 + pub fn desktop_size(&self) -> (u32, u32) { + self.desktop + } + /// 取下一帧。`None` = 会话结束。 /// `&self`:JNI 层里解码线程独占 recv,输入线程可并发 `send_input`。 pub async fn next_frame(&self) -> Option {