feat: 增加密码连接

This commit is contained in:
2026-07-22 20:32:52 +08:00
parent ef786b1529
commit bdde6ccd2e
19 changed files with 541 additions and 25 deletions

View File

@@ -32,6 +32,7 @@ import com.ttstd.controlled.base.BaseMvvmActivity;
import com.ttstd.controlled.databinding.ActivityMainBinding; import com.ttstd.controlled.databinding.ActivityMainBinding;
import com.ttstd.controlled.service.ScreenCaptureService; import com.ttstd.controlled.service.ScreenCaptureService;
import com.ttstd.controlled.accessibility.KeyboardAccessibilityService; import com.ttstd.controlled.accessibility.KeyboardAccessibilityService;
import com.ttstd.controlled.utils.AuthSettings;
import com.ttstd.controlled.utils.DeviceUtils; import com.ttstd.controlled.utils.DeviceUtils;
import com.ttstd.controlled.utils.SignatureUtils; import com.ttstd.controlled.utils.SignatureUtils;
import com.ttstd.controlled.webrtc.WebRtcClient; import com.ttstd.controlled.webrtc.WebRtcClient;
@@ -88,6 +89,31 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
binding.btnStart.setOnClickListener(v -> startScreenSharing()); binding.btnStart.setOnClickListener(v -> startScreenSharing());
binding.btnStop.setOnClickListener(v -> stopScreenSharing()); binding.btnStop.setOnClickListener(v -> stopScreenSharing());
// 安全验证:显示动态验证码,提供重新生成与固定密码设置
binding.tvDynamicCode.setText(AuthSettings.getDynamicCode(this));
binding.btnRegenerateCode.setOnClickListener(v -> {
String newCode = AuthSettings.generateDynamicCode();
AuthSettings.setDynamicCode(this, newCode);
binding.tvDynamicCode.setText(newCode);
binding.tvAuthHint.setText("动态验证码已重新生成");
});
binding.btnSavePassword.setOnClickListener(v -> {
String p1 = binding.etFixedPassword.getText().toString();
String p2 = binding.etConfirmPassword.getText().toString();
if (p1.isEmpty()) {
binding.tvAuthHint.setText("固定密码不能为空");
return;
}
if (!p1.equals(p2)) {
binding.tvAuthHint.setText("两次输入的密码不一致");
return;
}
AuthSettings.setFixedPassword(this, p1);
binding.etFixedPassword.setText("");
binding.etConfirmPassword.setText("");
binding.tvAuthHint.setText("固定密码已保存");
});
updateUI(false); updateUI(false);
} }

View File

@@ -37,6 +37,7 @@ import com.ttstd.controlled.input.ShellInputUtils;
import com.ttstd.controlled.input.SystemInputUtils; import com.ttstd.controlled.input.SystemInputUtils;
import com.ttstd.controlled.utils.SignatureUtils; import com.ttstd.controlled.utils.SignatureUtils;
import com.ttstd.controlled.signaling.SignalMessage; import com.ttstd.controlled.signaling.SignalMessage;
import com.ttstd.controlled.utils.AuthSettings;
import com.ttstd.controlled.signaling.WebSocketClient; import com.ttstd.controlled.signaling.WebSocketClient;
import com.ttstd.controlled.webrtc.WebRtcClient; import com.ttstd.controlled.webrtc.WebRtcClient;
@@ -457,18 +458,78 @@ public class ScreenCaptureService extends Service {
} }
String controllerId = message.getFromDeviceId(); String controllerId = message.getFromDeviceId();
// 暂存请求,弹出确认对话框,交由用户决定是否接受。 // 暂存请求信息
pendingControllerId = controllerId; pendingControllerId = controllerId;
pendingControllerName = (controllerName != null && !controllerName.isEmpty()) pendingControllerName = (controllerName != null && !controllerName.isEmpty())
? controllerName : controllerId; ? controllerName : controllerId;
pendingOfferSdp = offerSdp; pendingOfferSdp = offerSdp;
// 鉴权:动态验证码(CODE) / 固定密码(PASSWORD)。
// 服务器仅做转发,真实校验在此完成(密钥不离开被控端)。
String authType = message.getAuthType();
String authValue = message.getAuthValue();
if (authType != null && !authType.trim().isEmpty()) {
String failReason = verifyAuth(authType.trim(), authValue);
if (failReason == null) {
// 校验通过:自动接受,直接建立连接(无需手动确认)
Log.i(TAG, "Auth passed (" + authType + "), auto-accepting " + controllerId);
mainHandler.post(this::acceptConnection);
} else {
Log.w(TAG, "Auth failed (" + authType + "): " + failReason + " from " + controllerId);
sendAuthRejected(failReason);
}
return;
}
// 未携带鉴权信息:沿用原有手动确认弹窗
Intent dialogIntent = new Intent(this, ConnectionRequestActivity.class); Intent dialogIntent = new Intent(this, ConnectionRequestActivity.class);
dialogIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); dialogIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
dialogIntent.putExtra(ConnectionRequestActivity.EXTRA_CONTROLLER_ID, controllerId); dialogIntent.putExtra(ConnectionRequestActivity.EXTRA_CONTROLLER_ID, controllerId);
startActivity(dialogIntent); startActivity(dialogIntent);
} }
/**
* 校验控制端发来的鉴权信息。返回 null 表示通过,否则返回失败原因。
* CODE: 与被控端当前动态验证码比对;
* PASSWORD: 与被控端固定密码比对。
*/
private String verifyAuth(String authType, String authValue) {
if (authValue == null) authValue = "";
if ("CODE".equalsIgnoreCase(authType)) {
String expected = AuthSettings.getDynamicCode(this);
if (expected == null || expected.isEmpty()) {
return "被控端未生成动态验证码";
}
return expected.equals(authValue) ? null : "动态验证码错误";
} else if ("PASSWORD".equalsIgnoreCase(authType)) {
String expected = AuthSettings.getFixedPassword(this);
if (expected == null || expected.isEmpty()) {
return "被控端未设置固定密码";
}
return expected.equals(authValue) ? null : "固定密码错误";
}
return "未知的鉴权类型: " + authType;
}
/** 鉴权失败时,向控制端回送带原因的拒绝消息并清理待处理状态。 */
private void sendAuthRejected(String reason) {
if (pendingControllerId == null || wsClient == null) {
return;
}
String controllerId = pendingControllerId;
clearPendingRequest();
SignalMessage msg = new SignalMessage();
msg.setType("CONNECTION_REJECTED");
msg.setFromDeviceId(deviceId);
msg.setToDeviceId(controllerId);
msg.setDeviceType("CONTROLLED");
JsonObject payload = new JsonObject();
payload.addProperty("reason", reason);
msg.setPayload(payload.toString());
wsClient.sendMessage(msg);
Log.i(TAG, "Sent CONNECTION_REJECTED(" + reason + ") to " + controllerId);
}
/** /**
* 用户在弹窗中点击“接受”:创建 Answer 完成连接。 * 用户在弹窗中点击“接受”:创建 Answer 完成连接。
*/ */

View File

@@ -7,6 +7,8 @@ public class SignalMessage {
private String toDeviceId; private String toDeviceId;
private String deviceType; private String deviceType;
private String payload; private String payload;
private String authType;
private String authValue;
public SignalMessage() {} public SignalMessage() {}
@@ -31,4 +33,10 @@ public class SignalMessage {
public String getPayload() { return payload; } public String getPayload() { return payload; }
public void setPayload(String payload) { this.payload = payload; } public void setPayload(String payload) { this.payload = payload; }
public String getAuthType() { return authType; }
public void setAuthType(String authType) { this.authType = authType; }
public String getAuthValue() { return authValue; }
public void setAuthValue(String authValue) { this.authValue = authValue; }
} }

View File

@@ -0,0 +1,60 @@
package com.ttstd.controlled.utils;
import android.content.Context;
import android.content.SharedPreferences;
import java.security.SecureRandom;
/**
* 被控端安全验证配置:动态验证码与固定密码。
* 密钥仅保存在被控端本机,服务器只做转发,不接触任何密钥。
*/
public class AuthSettings {
private static final String PREF_NAME = "controlled_auth_prefs";
private static final String KEY_DYNAMIC_CODE = "dynamic_code";
private static final String KEY_FIXED_PASSWORD = "fixed_password";
private static final String CODE_CHARS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
private static final int CODE_LENGTH = 6;
private static final SecureRandom RANDOM = new SecureRandom();
/** 生成一个 6 位、由大小写字母与数字组成的随机验证码。 */
public static String generateDynamicCode() {
StringBuilder sb = new StringBuilder(CODE_LENGTH);
for (int i = 0; i < CODE_LENGTH; i++) {
sb.append(CODE_CHARS.charAt(RANDOM.nextInt(CODE_CHARS.length())));
}
return sb.toString();
}
/**
* 获取当前动态验证码;若不存在则随机生成并持久化。
*/
public static String getDynamicCode(Context context) {
SharedPreferences sp = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
String code = sp.getString(KEY_DYNAMIC_CODE, null);
if (code == null || code.isEmpty()) {
code = generateDynamicCode();
sp.edit().putString(KEY_DYNAMIC_CODE, code).apply();
}
return code;
}
public static void setDynamicCode(Context context, String code) {
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.edit().putString(KEY_DYNAMIC_CODE, code == null ? "" : code).apply();
}
/** 获取固定密码(未设置时为空字符串)。 */
public static String getFixedPassword(Context context) {
return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.getString(KEY_FIXED_PASSWORD, "");
}
public static void setFixedPassword(Context context, String password) {
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.edit().putString(KEY_FIXED_PASSWORD, password == null ? "" : password).apply();
}
}

View File

@@ -61,6 +61,85 @@
android:textSize="16sp" android:textSize="16sp"
android:textStyle="bold" /> android:textStyle="bold" />
<!-- 安全验证:动态验证码 + 固定密码 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginBottom="6dp"
android:text="安全验证"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="动态验证码(连接时由控制端输入):"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="8dp">
<TextView
android:id="@+id/tv_dynamic_code"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="------"
android:textSize="22sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:letterSpacing="0.1" />
<Button
android:id="@+id/btn_regenerate_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="重新生成" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="固定密码(手动设置并确认):"
android:textSize="14sp" />
<EditText
android:id="@+id/et_fixed_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="输入固定密码"
android:inputType="textPassword" />
<EditText
android:id="@+id/et_confirm_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="再次输入固定密码"
android:inputType="textPassword" />
<Button
android:id="@+id/btn_save_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="保存固定密码" />
<TextView
android:id="@+id/tv_auth_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:textSize="13sp"
android:textColor="#888888" />
<Button <Button
android:id="@+id/btn_start" android:id="@+id/btn_start"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -15,6 +15,8 @@ import android.view.View;
import android.widget.AdapterView; import android.widget.AdapterView;
import android.widget.ArrayAdapter; import android.widget.ArrayAdapter;
import android.widget.Spinner; import android.widget.Spinner;
import android.widget.RadioGroup;
import androidx.appcompat.app.AlertDialog;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -62,6 +64,8 @@ public class MainActivity extends AppCompatActivity {
private final Gson gson = new Gson(); private final Gson gson = new Gson();
private String myDeviceId; private String myDeviceId;
private String targetDeviceId; private String targetDeviceId;
private String pendingAuthType;
private String pendingAuthValue;
private final Handler statsHandler = new Handler(Looper.getMainLooper()); private final Handler statsHandler = new Handler(Looper.getMainLooper());
private final Runnable statsRunnable = new Runnable() { private final Runnable statsRunnable = new Runnable() {
@@ -105,7 +109,7 @@ public class MainActivity extends AppCompatActivity {
myDeviceId = DeviceUtils.getSerialNumber(this); myDeviceId = DeviceUtils.getSerialNumber(this);
etDeviceId.setText(myDeviceId); etDeviceId.setText(myDeviceId);
btnConnect.setOnClickListener(v -> connectToControlled()); btnConnect.setOnClickListener(v -> showAuthDialog());
btnDisconnect.setOnClickListener(v -> disconnect()); btnDisconnect.setOnClickListener(v -> disconnect());
// 初始化 EGL 和远端视频渲染 // 初始化 EGL 和远端视频渲染
@@ -210,6 +214,56 @@ public class MainActivity extends AppCompatActivity {
}); });
} }
/** 弹出鉴权输入对话框:选择动态验证码或固定密码,输入后发起连接。 */
private void showAuthDialog() {
String serverUrl = etServerUrl.getText().toString().trim();
String target = etTargetDeviceId.getText().toString().trim();
String myId = etDeviceId.getText().toString().trim();
if (serverUrl.isEmpty() || target.isEmpty() || myId.isEmpty()) {
Toast.makeText(this, "请先填写服务器地址、设备ID和目标设备ID", Toast.LENGTH_SHORT).show();
return;
}
View view = getLayoutInflater().inflate(R.layout.dialog_auth_input, null);
RadioGroup rg = view.findViewById(R.id.rg_auth_type);
EditText et = view.findViewById(R.id.et_auth_value);
TextView tip = view.findViewById(R.id.tv_auth_tip);
rg.setOnCheckedChangeListener((group, checkedId) -> {
if (checkedId == R.id.rb_password) {
et.setHint("请输入固定密码");
tip.setText("固定密码为被控端设置的固定密码");
} else {
et.setHint("请输入动态验证码");
tip.setText("动态验证码为被控端界面显示的 6 位大小写字母与数字组合");
}
});
AlertDialog dialog = new AlertDialog.Builder(this)
.setTitle("连接鉴权")
.setView(view)
.setNegativeButton("取消", null)
.setPositiveButton("连接", null)
.create();
// 校验失败时不关闭对话框,引导用户重新输入
dialog.setOnShowListener(d -> dialog.getButton(AlertDialog.BUTTON_POSITIVE)
.setOnClickListener(v -> {
boolean isPassword = (rg.getCheckedRadioButtonId() == R.id.rb_password);
String authType = isPassword ? "PASSWORD" : "CODE";
String authValue = et.getText().toString().trim();
if (authValue.isEmpty()) {
Toast.makeText(MainActivity.this, "请输入验证码或密码", Toast.LENGTH_SHORT).show();
return;
}
pendingAuthType = authType;
pendingAuthValue = authValue;
dialog.dismiss();
connectToControlled();
}));
dialog.show();
}
private void connectToControlled() { private void connectToControlled() {
String serverUrl = etServerUrl.getText().toString().trim(); String serverUrl = etServerUrl.getText().toString().trim();
targetDeviceId = etTargetDeviceId.getText().toString().trim(); targetDeviceId = etTargetDeviceId.getText().toString().trim();
@@ -283,7 +337,7 @@ public class MainActivity extends AppCompatActivity {
}); });
} }
}); });
webRtcClient.createOffer(targetDeviceId, remoteVideoView); webRtcClient.createOffer(targetDeviceId, remoteVideoView, pendingAuthType, pendingAuthValue);
} }
private void handleSignalMessage(SignalMessage message) { private void handleSignalMessage(SignalMessage message) {
@@ -330,17 +384,29 @@ public class MainActivity extends AppCompatActivity {
*/ */
private void handleConnectionRejected(SignalMessage message) { private void handleConnectionRejected(SignalMessage message) {
String targetId = message.getToDeviceId(); String targetId = message.getToDeviceId();
String payload = message.getPayload(); String reason = parseReason(message.getPayload());
String text = (payload != null && !payload.isEmpty()) String finalText = reason;
? payload
: "目标被控端拒绝了连接请求";
String finalText = text;
runOnUiThread(() -> { runOnUiThread(() -> {
tvStatus.setText("状态: " + finalText); tvStatus.setText("状态: " + finalText);
Toast.makeText(MainActivity.this, finalText, Toast.LENGTH_LONG).show(); Toast.makeText(MainActivity.this, finalText, Toast.LENGTH_LONG).show();
updateUI(false); updateUI(false);
}); });
Log.w(TAG, "Connection request to " + targetId + " was rejected by controlled device"); Log.w(TAG, "Connection request to " + targetId + " was rejected: " + reason);
}
/** 解析拒绝原因:优先读取 JSON 中的 reason 字段,否则直接返回原文。 */
private String parseReason(String payload) {
if (payload == null || payload.isEmpty()) {
return "目标被控端拒绝了连接请求";
}
try {
JsonObject obj = gson.fromJson(payload, JsonObject.class);
if (obj != null && obj.has("reason") && !obj.get("reason").isJsonNull()) {
return obj.get("reason").getAsString();
}
} catch (Exception ignored) {
}
return payload;
} }
/** /**

View File

@@ -7,6 +7,8 @@ public class SignalMessage {
private String toDeviceId; private String toDeviceId;
private String deviceType; private String deviceType;
private String payload; private String payload;
private String authType;
private String authValue;
public SignalMessage() {} public SignalMessage() {}
@@ -31,4 +33,10 @@ public class SignalMessage {
public String getPayload() { return payload; } public String getPayload() { return payload; }
public void setPayload(String payload) { this.payload = payload; } public void setPayload(String payload) { this.payload = payload; }
public String getAuthType() { return authType; }
public void setAuthType(String authType) { this.authType = authType; }
public String getAuthValue() { return authValue; }
public void setAuthValue(String authValue) { this.authValue = authValue; }
} }

View File

@@ -41,6 +41,8 @@ public class WebRtcClient {
private final WebSocketClient wsClient; private final WebSocketClient wsClient;
private final String deviceId; private final String deviceId;
private final Gson gson = new Gson(); private final Gson gson = new Gson();
private String pendingAuthType;
private String pendingAuthValue;
private PeerConnectionFactory peerConnectionFactory; private PeerConnectionFactory peerConnectionFactory;
private PeerConnection peerConnection; private PeerConnection peerConnection;
@@ -89,8 +91,11 @@ public class WebRtcClient {
.createPeerConnectionFactory(); .createPeerConnectionFactory();
} }
public void createOffer(String controlledId, SurfaceViewRenderer remoteRenderer) { public void createOffer(String controlledId, SurfaceViewRenderer remoteRenderer,
String authType, String authValue) {
this.currentControlledId = controlledId; this.currentControlledId = controlledId;
this.pendingAuthType = authType;
this.pendingAuthValue = authValue;
List<PeerConnection.IceServer> iceServers = new ArrayList<>(); List<PeerConnection.IceServer> iceServers = new ArrayList<>();
// 添加 STUN 和 TURN 服务器 // 添加 STUN 和 TURN 服务器
@@ -309,6 +314,8 @@ public class WebRtcClient {
msg.setFromDeviceId(deviceId); msg.setFromDeviceId(deviceId);
msg.setToDeviceId(controlledId); msg.setToDeviceId(controlledId);
msg.setDeviceType("CONTROLLER"); msg.setDeviceType("CONTROLLER");
if (pendingAuthType != null) msg.setAuthType(pendingAuthType);
if (pendingAuthValue != null) msg.setAuthValue(pendingAuthValue);
JsonObject payload = new JsonObject(); JsonObject payload = new JsonObject();
payload.addProperty("sdp", sdp); payload.addProperty("sdp", sdp);

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<RadioGroup
android:id="@+id/rg_auth_type"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<RadioButton
android:id="@+id/rb_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true"
android:text="动态验证码" />
<RadioButton
android:id="@+id/rb_password"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="固定密码" />
</RadioGroup>
<EditText
android:id="@+id/et_auth_value"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="请输入动态验证码"
android:inputType="textVisiblePassword" />
<TextView
android:id="@+id/tv_auth_tip"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="动态验证码为被控端界面显示的 6 位大小写字母与数字组合"
android:textSize="12sp"
android:textColor="#888888" />
</LinearLayout>

View File

@@ -3,9 +3,19 @@ import { ref } from 'vue';
import { store, connectSignaling, disconnectSignaling, refreshDevices, connectToDevice, disconnectDevice } from '../store/controllerStore'; import { store, connectSignaling, disconnectSignaling, refreshDevices, connectToDevice, disconnectDevice } from '../store/controllerStore';
const selected = ref(''); const selected = ref('');
const authType = ref('CODE');
const authValue = ref('');
function onConnectDevice() { function onConnectDevice() {
connectToDevice(selected.value); if (!selected.value) {
store.error = '请选择要连接的设备';
return;
}
if (!authValue.value.trim()) {
store.error = '请输入验证码或密码';
return;
}
connectToDevice(selected.value, authType.value, authValue.value.trim());
} }
</script> </script>
@@ -51,6 +61,15 @@ function onConnectDevice() {
</select> </select>
</div> </div>
<div class="section-title" style="margin-top:14px">连接鉴权</div>
<div class="auth-row">
<label class="auth-radio"><input type="radio" value="CODE" v-model="authType" /> 动态验证码</label>
<label class="auth-radio"><input type="radio" value="PASSWORD" v-model="authType" /> 固定密码</label>
</div>
<div class="field">
<input class="input" v-model="authValue" :placeholder="authType === 'PASSWORD' ? '请输入固定密码' : '请输入动态验证码'" />
</div>
<button class="btn" @click="onConnectDevice" :disabled="!selected || store.rtcConnected">发起远程控制</button> <button class="btn" @click="onConnectDevice" :disabled="!selected || store.rtcConnected">发起远程控制</button>
<button class="btn danger" style="margin-top:12px" v-if="store.rtcConnected" @click="disconnectDevice">结束控制</button> <button class="btn danger" style="margin-top:12px" v-if="store.rtcConnected" @click="disconnectDevice">结束控制</button>
@@ -64,3 +83,8 @@ function onConnectDevice() {
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.auth-row { display: flex; gap: 16px; margin-bottom: 8px; align-items: center; }
.auth-radio { display: inline-flex; align-items: center; gap: 4px; }
</style>

View File

@@ -81,14 +81,17 @@ export class SignalingClient {
this.send({ type: 'DEVICE_LIST', fromDeviceId: this.deviceId }); this.send({ type: 'DEVICE_LIST', fromDeviceId: this.deviceId });
} }
sendOffer(sdp, toDeviceId) { sendOffer(sdp, toDeviceId, authType = null, authValue = null) {
this.send({ const msg = {
type: 'OFFER', type: 'OFFER',
fromDeviceId: this.deviceId, fromDeviceId: this.deviceId,
toDeviceId, toDeviceId,
deviceType: 'CONTROLLER', deviceType: 'CONTROLLER',
payload: JSON.stringify({ sdp }), payload: JSON.stringify({ sdp }),
}); };
if (authType) msg.authType = authType;
if (authValue != null) msg.authValue = authValue;
this.send(msg);
} }
sendIceCandidate(candidate, toDeviceId) { sendIceCandidate(candidate, toDeviceId) {

View File

@@ -35,7 +35,7 @@ export class WebRtcController {
this._pendingCandidates = []; this._pendingCandidates = [];
} }
async createOffer() { async createOffer(authType = null, authValue = null) {
this.pc = new RTCPeerConnection({ iceServers: this.iceServers }); this.pc = new RTCPeerConnection({ iceServers: this.iceServers });
this.pc.onicecandidate = (e) => { this.pc.onicecandidate = (e) => {
@@ -88,7 +88,7 @@ export class WebRtcController {
const offer = await this.pc.createOffer(); const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer); await this.pc.setLocalDescription(offer);
this.signaling.sendOffer(this.pc.localDescription.sdp, this.targetDeviceId); this.signaling.sendOffer(this.pc.localDescription.sdp, this.targetDeviceId, authType, authValue);
this._startStats(); this._startStats();
} }

View File

@@ -120,7 +120,7 @@ export function refreshDevices() {
signaling && signaling.requestDeviceList(); signaling && signaling.requestDeviceList();
} }
export async function connectToDevice(targetId) { export async function connectToDevice(targetId, authType = null, authValue = null) {
if (!signaling || !store.signalingConnected) { store.error = '请先连接信令服务器'; return; } if (!signaling || !store.signalingConnected) { store.error = '请先连接信令服务器'; return; }
if (!targetId) { store.error = '请选择要控制的被控端设备'; return; } if (!targetId) { store.error = '请选择要控制的被控端设备'; return; }
@@ -147,7 +147,7 @@ export async function connectToDevice(targetId) {
// 被控端上报当前实际采集分辨率,同步到 store 供 UI 展示/匹配预设。 // 被控端上报当前实际采集分辨率,同步到 store 供 UI 展示/匹配预设。
onResolutionReport: (res) => { store.currentResolution = res; }, onResolutionReport: (res) => { store.currentResolution = res; },
}); });
await webrtc.createOffer(); await webrtc.createOffer(authType, authValue);
} catch (e) { } catch (e) {
// 捕获 createOffer 阶段的所有异常,避免未处理的 Promise 拒绝导致浏览器中断脚本执行、 // 捕获 createOffer 阶段的所有异常,避免未处理的 Promise 拒绝导致浏览器中断脚本执行、
// 间接影响 WebSocket 信令连接的存活。 // 间接影响 WebSocket 信令连接的存活。

View File

@@ -189,6 +189,21 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
return; return;
} }
// 鉴权类型分类:动态验证码(CODE) / 固定密码(PASSWORD)。
// 服务器只做分类与转发,真实校验在被控端完成(服务器不保存任何密钥)。
String authType = message.getAuthType();
if (authType != null && !authType.trim().isEmpty()) {
if ("CODE".equalsIgnoreCase(authType)) {
logger.info("连接请求 {} -> {} 使用【动态验证码】鉴权", fromDeviceId, toDeviceId);
} else if ("PASSWORD".equalsIgnoreCase(authType)) {
logger.info("连接请求 {} -> {} 使用【固定密码】鉴权", fromDeviceId, toDeviceId);
} else {
logger.warn("连接请求 {} -> {} 携带未知鉴权类型 authType={}", fromDeviceId, toDeviceId, authType);
}
} else {
logger.info("连接请求 {} -> {} 未携带鉴权(走被控端手动确认)", fromDeviceId, toDeviceId);
}
// 2. 去重:短时间内重复 OFFER 直接忽略,避免被控端反复弹窗 // 2. 去重:短时间内重复 OFFER 直接忽略,避免被控端反复弹窗
if (connectionRequestManager.isDuplicateOffer(fromDeviceId, toDeviceId)) { if (connectionRequestManager.isDuplicateOffer(fromDeviceId, toDeviceId)) {
logger.info("Duplicate connection request {} -> {} ignored", fromDeviceId, toDeviceId); logger.info("Duplicate connection request {} -> {} ignored", fromDeviceId, toDeviceId);

View File

@@ -10,6 +10,8 @@ public class SignalMessage {
private String toDeviceId; // 目标设备ID private String toDeviceId; // 目标设备ID
private String deviceType; // CONTROLLER 或 CONTROLLED private String deviceType; // CONTROLLER 或 CONTROLLED
private String payload; // JSON格式的信令数据SDP/ICE/控制指令) private String payload; // JSON格式的信令数据SDP/ICE/控制指令)
private String authType; // 鉴权类型CODE动态验证码/ PASSWORD固定密码
private String authValue; // 鉴权值:动态验证码或固定密码
public SignalMessage() {} public SignalMessage() {}
@@ -27,4 +29,10 @@ public class SignalMessage {
public String getPayload() { return payload; } public String getPayload() { return payload; }
public void setPayload(String payload) { this.payload = payload; } public void setPayload(String payload) { this.payload = payload; }
public String getAuthType() { return authType; }
public void setAuthType(String authType) { this.authType = authType; }
public String getAuthValue() { return authValue; }
public void setAuthValue(String authValue) { this.authValue = authValue; }
} }

View File

@@ -14,6 +14,8 @@ class RemoteController {
final String serverUrl; final String serverUrl;
final String deviceId; final String deviceId;
final String targetDeviceId; final String targetDeviceId;
final String? authType;
final String? authValue;
late final SignalingClient _signaling; late final SignalingClient _signaling;
WebRtcController? _webRtc; WebRtcController? _webRtc;
@@ -47,10 +49,14 @@ class RemoteController {
required this.serverUrl, required this.serverUrl,
required this.deviceId, required this.deviceId,
required this.targetDeviceId, required this.targetDeviceId,
this.authType,
this.authValue,
}); });
/// 发起连接:先连接信令服务器,成功后建立 WebRTC 并创建 Offer。 /// 发起连接:先连接信令服务器,成功后建立 WebRTC 并创建 Offer。
void connect() { void connect({String? authType, String? authValue}) {
this.authType = authType;
this.authValue = authValue;
onStatusChanged?.call('状态: 正在连接信令服务器...'); onStatusChanged?.call('状态: 正在连接信令服务器...');
_signaling = SignalingClient(serverUrl: serverUrl, deviceId: deviceId); _signaling = SignalingClient(serverUrl: serverUrl, deviceId: deviceId);
@@ -74,6 +80,8 @@ class RemoteController {
signaling: _signaling, signaling: _signaling,
deviceId: deviceId, deviceId: deviceId,
targetDeviceId: targetDeviceId, targetDeviceId: targetDeviceId,
authType: authType,
authValue: authValue,
); );
_webRtc!.onConnectionEstablished = () { _webRtc!.onConnectionEstablished = () {
onConnectionEstablished?.call(); onConnectionEstablished?.call();
@@ -109,13 +117,24 @@ class RemoteController {
case 'CONNECTION_REJECTED': case 'CONNECTION_REJECTED':
case 'REQUEST_ERROR': case 'REQUEST_ERROR':
case 'REQUEST_TIMEOUT': case 'REQUEST_TIMEOUT':
final text = message.payload ?? '连接请求失败'; final text = _parseRejectReason(message.payload);
onStatusChanged?.call('状态: $text'); onStatusChanged?.call('状态: $text');
onConnectionRejected?.call(text); onConnectionRejected?.call(text);
break; break;
} }
} }
/// 解析拒绝原因:优先读取 JSON 中的 reason 字段,否则直接返回原文。
String _parseRejectReason(String? payload) {
if (payload == null || payload.isEmpty) return '连接请求失败';
try {
final map = jsonDecode(payload) as Map<String, dynamic>;
final r = map['reason'];
if (r is String && r.isNotEmpty) return r;
} catch (_) {}
return payload;
}
void _startStats() { void _startStats() {
_statsTimer?.cancel(); _statsTimer?.cancel();
_statsTimer = Timer.periodic(const Duration(seconds: 1), (_) async { _statsTimer = Timer.periodic(const Duration(seconds: 1), (_) async {

View File

@@ -99,7 +99,73 @@ class _ControllerHomeState extends State<ControllerHome> {
); );
} }
Future<void> _connect() async { Future<void> _showAuthDialog() async {
final serverUrl = _serverUrlController.text.trim();
final deviceId = _deviceIdController.text.trim();
final target = _targetController.text.trim();
if (serverUrl.isEmpty || deviceId.isEmpty || target.isEmpty) {
_setStatus('请填写所有字段');
return;
}
String selectedType = 'CODE';
final valueController = TextEditingController();
await showCupertinoDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => CupertinoAlertDialog(
title: const Text('连接鉴权'),
content: Column(
children: [
const SizedBox(height: 12),
CupertinoSegmentedControl<String>(
children: const {
'CODE': Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('动态验证码'),
),
'PASSWORD': Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('固定密码'),
),
},
groupValue: selectedType,
onValueChanged: (v) => setDialogState(() => selectedType = v),
),
const SizedBox(height: 12),
CupertinoTextField(
controller: valueController,
placeholder: '请输入验证码或密码',
obscureText: true,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
],
),
actions: [
CupertinoDialogAction(
child: const Text('取消'),
onPressed: () => Navigator.of(ctx).pop(),
),
CupertinoDialogAction(
child: const Text('连接'),
onPressed: () {
final val = valueController.text.trim();
if (val.isEmpty) {
_showAlert('请输入验证码或密码');
return;
}
Navigator.of(ctx).pop();
_connect(authType: selectedType, authValue: val);
},
),
],
),
),
);
}
Future<void> _connect({required String authType, required String authValue}) async {
final serverUrl = _serverUrlController.text.trim(); final serverUrl = _serverUrlController.text.trim();
final deviceId = _deviceIdController.text.trim(); final deviceId = _deviceIdController.text.trim();
final target = _targetController.text.trim(); final target = _targetController.text.trim();
@@ -115,6 +181,8 @@ class _ControllerHomeState extends State<ControllerHome> {
serverUrl: serverUrl, serverUrl: serverUrl,
deviceId: deviceId, deviceId: deviceId,
targetDeviceId: target, targetDeviceId: target,
authType: authType,
authValue: authValue,
); );
_controller!.onStatusChanged = _setStatus; _controller!.onStatusChanged = _setStatus;
_controller!.onConnectionEstablished = () { _controller!.onConnectionEstablished = () {
@@ -142,7 +210,7 @@ class _ControllerHomeState extends State<ControllerHome> {
}; };
_controller!.onStats = (stats) => setState(() => _stats = stats); _controller!.onStats = (stats) => setState(() => _stats = stats);
_controller!.connect(); _controller!.connect(authType: authType, authValue: authValue);
} }
void _onRendererUpdate() { void _onRendererUpdate() {
@@ -241,7 +309,7 @@ class _ControllerHomeState extends State<ControllerHome> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: CupertinoButton.filled( child: CupertinoButton.filled(
onPressed: _connect, onPressed: _showAuthDialog,
child: const Text('连接被控设备'), child: const Text('连接被控设备'),
), ),
), ),

View File

@@ -8,12 +8,16 @@ import 'dart:convert';
/// - [toDeviceId] 接收方设备 ID /// - [toDeviceId] 接收方设备 ID
/// - [deviceType] 设备类型CONTROLLER / CONTROLLED /// - [deviceType] 设备类型CONTROLLER / CONTROLLED
/// - [payload] JSON 字符串形式的负载SDP / ICE 候选等) /// - [payload] JSON 字符串形式的负载SDP / ICE 候选等)
/// - [authType] 鉴权类型CODE动态验证码/ PASSWORD固定密码
/// - [authValue] 鉴权值:动态验证码或固定密码
class SignalMessage { class SignalMessage {
final String? type; final String? type;
final String? fromDeviceId; final String? fromDeviceId;
final String? toDeviceId; final String? toDeviceId;
final String? deviceType; final String? deviceType;
final String? payload; final String? payload;
final String? authType;
final String? authValue;
const SignalMessage({ const SignalMessage({
this.type, this.type,
@@ -21,6 +25,8 @@ class SignalMessage {
this.toDeviceId, this.toDeviceId,
this.deviceType, this.deviceType,
this.payload, this.payload,
this.authType,
this.authValue,
}); });
factory SignalMessage.fromJson(Map<String, dynamic> json) => SignalMessage( factory SignalMessage.fromJson(Map<String, dynamic> json) => SignalMessage(
@@ -29,6 +35,8 @@ class SignalMessage {
toDeviceId: json['toDeviceId'] as String?, toDeviceId: json['toDeviceId'] as String?,
deviceType: json['deviceType'] as String?, deviceType: json['deviceType'] as String?,
payload: json['payload'] as String?, payload: json['payload'] as String?,
authType: json['authType'] as String?,
authValue: json['authValue'] as String?,
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@@ -37,6 +45,8 @@ class SignalMessage {
if (toDeviceId != null) 'toDeviceId': toDeviceId, if (toDeviceId != null) 'toDeviceId': toDeviceId,
if (deviceType != null) 'deviceType': deviceType, if (deviceType != null) 'deviceType': deviceType,
if (payload != null) 'payload': payload, if (payload != null) 'payload': payload,
if (authType != null) 'authType': authType,
if (authValue != null) 'authValue': authValue,
}; };
/// 便捷构造方法payload 为任意 Map会自动序列化为 JSON 字符串。 /// 便捷构造方法payload 为任意 Map会自动序列化为 JSON 字符串。
@@ -46,6 +56,8 @@ class SignalMessage {
required String toDeviceId, required String toDeviceId,
required String deviceType, required String deviceType,
required Map<String, dynamic> payload, required Map<String, dynamic> payload,
String? authType,
String? authValue,
}) { }) {
return SignalMessage( return SignalMessage(
type: type, type: type,
@@ -53,6 +65,8 @@ class SignalMessage {
toDeviceId: toDeviceId, toDeviceId: toDeviceId,
deviceType: deviceType, deviceType: deviceType,
payload: jsonEncode(payload), payload: jsonEncode(payload),
authType: authType,
authValue: authValue,
); );
} }

View File

@@ -21,6 +21,8 @@ class WebRtcController {
final SignalingClient signaling; final SignalingClient signaling;
final String deviceId; final String deviceId;
final String targetDeviceId; final String targetDeviceId;
final String? authType;
final String? authValue;
RTCPeerConnection? _pc; RTCPeerConnection? _pc;
RTCDataChannel? _dataChannel; RTCDataChannel? _dataChannel;
@@ -42,6 +44,8 @@ class WebRtcController {
required this.signaling, required this.signaling,
required this.deviceId, required this.deviceId,
required this.targetDeviceId, required this.targetDeviceId,
this.authType,
this.authValue,
}); });
/// 初始化渲染器、PeerConnection并创建 Offer。 /// 初始化渲染器、PeerConnection并创建 Offer。
@@ -89,7 +93,7 @@ class WebRtcController {
}; };
final offer = await _pc!.createOffer(constraints); final offer = await _pc!.createOffer(constraints);
await _pc!.setLocalDescription(offer); await _pc!.setLocalDescription(offer);
_sendOffer(offer.sdp!); _sendOffer(offer.sdp!, authType: authType, authValue: authValue);
} }
void _onTrack(RTCTrackEvent event) { void _onTrack(RTCTrackEvent event) {
@@ -157,7 +161,7 @@ class WebRtcController {
} }
} }
void _sendOffer(String sdp) { void _sendOffer(String sdp, {String? authType, String? authValue}) {
final payload = {'sdp': sdp}; final payload = {'sdp': sdp};
final msg = SignalMessage.withPayload( final msg = SignalMessage.withPayload(
type: 'OFFER', type: 'OFFER',
@@ -165,6 +169,8 @@ class WebRtcController {
toDeviceId: targetDeviceId, toDeviceId: targetDeviceId,
deviceType: 'CONTROLLER', deviceType: 'CONTROLLER',
payload: payload, payload: payload,
authType: authType,
authValue: authValue,
); );
signaling.send(msg); signaling.send(msg);
} }