本次更新涵盖多个功能模块的优化与新增: 1. 新增Wi-Fi直连配对方式与协作画布模块 2. 完成设备管理重命名API与前端适配 3. 优化日签卡片空数据保护与UI细节 4. 新增剪贴板工具与每日运势会话 5. 修复应用锁恢复、语音消息录制等已知问题 6. 完善文件传输统计与配对逻辑 7. 更新安卓权限配置与iOS隐私描述 8. 新增自动化测试脚本与文档 9. 清理旧版审计报告与测试文件
995 lines
36 KiB
PHP
995 lines
36 KiB
PHP
<?php
|
|
// ============================================================
|
|
// 闲言APP — WebSocket信令服务主进程
|
|
// 创建时间: 2026-05-10
|
|
// 更新时间: 2026-05-12
|
|
// 作用: WebRTC信令中转+文本消息互发+设备管理+配对请求+回执/断点续传/语音/云暂存/画布/屏幕共享
|
|
// 兼容 LocalSend v2.1 协议 + 闲言 xianyan-v1 协议
|
|
// 上次更新: v11.0.0 新增14种信令消息类型路由转发
|
|
// ============================================================
|
|
|
|
require __DIR__ . '/vendor/autoload.php';
|
|
|
|
use Ratchet\Server\IoServer;
|
|
use Ratchet\Http\HttpServer;
|
|
use Ratchet\WebSocket\WsServer;
|
|
use Ratchet\ConnectionInterface;
|
|
use Ratchet\MessageComponentInterface;
|
|
|
|
$config = require __DIR__ . '/config.php';
|
|
|
|
// ─── 日志工具 ──────────────────────────────────────────────
|
|
|
|
class SignalLogger
|
|
{
|
|
private string $logFile;
|
|
private string $level;
|
|
private int $maxSize;
|
|
private array $levelMap = ['debug' => 0, 'info' => 1, 'warn' => 2, 'error' => 3];
|
|
|
|
public function __construct(array $cfg)
|
|
{
|
|
$this->level = $cfg['level'] ?? 'info';
|
|
$this->maxSize = $cfg['max_size'] ?? 10485760;
|
|
$this->logFile = $cfg['file'] ?? __DIR__ . '/logs/signaling.log';
|
|
$dir = dirname($this->logFile);
|
|
if (!is_dir($dir)) {
|
|
mkdir($dir, 0755, true);
|
|
}
|
|
}
|
|
|
|
public function debug(string $msg, array $ctx = []): void { $this->log('debug', $msg, $ctx); }
|
|
public function info(string $msg, array $ctx = []): void { $this->log('info', $msg, $ctx); }
|
|
public function warn(string $msg, array $ctx = []): void { $this->log('warn', $msg, $ctx); }
|
|
public function error(string $msg, array $ctx = []): void { $this->log('error', $msg, $ctx); }
|
|
|
|
private function log(string $level, string $msg, array $ctx): void
|
|
{
|
|
if (($this->levelMap[$level] ?? 0) < ($this->levelMap[$this->level] ?? 1)) {
|
|
return;
|
|
}
|
|
if (is_file($this->logFile) && filesize($this->logFile) > $this->maxSize) {
|
|
$bak = $this->logFile . '.1';
|
|
if (is_file($bak)) unlink($bak);
|
|
rename($this->logFile, $bak);
|
|
}
|
|
$ts = date('Y-m-d H:i:s');
|
|
$ctxStr = $ctx ? ' ' . json_encode($ctx, JSON_UNESCAPED_UNICODE) : '';
|
|
$line = "[{$ts}] [{$level}] {$msg}{$ctxStr}\n";
|
|
file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX);
|
|
}
|
|
}
|
|
|
|
// ─── 设备管理器 ────────────────────────────────────────────
|
|
|
|
class DeviceManager
|
|
{
|
|
private array $devices = [];
|
|
private array $connections = [];
|
|
private array $ipCount = [];
|
|
private SignalLogger $log;
|
|
private int $maxPerIp;
|
|
private int $heartbeatTimeout;
|
|
|
|
public function __construct(SignalLogger $log, int $maxPerIp = 10, int $heartbeatTimeout = 90)
|
|
{
|
|
$this->log = $log;
|
|
$this->maxPerIp = $maxPerIp;
|
|
$this->heartbeatTimeout = $heartbeatTimeout;
|
|
}
|
|
|
|
public function register(ConnectionInterface $conn, array $data): array
|
|
{
|
|
$deviceId = $data['deviceId'] ?? $data['fingerprint'] ?? '';
|
|
if (empty($deviceId)) {
|
|
return ['success' => false, 'error' => 'deviceId is required'];
|
|
}
|
|
|
|
$ip = $conn->remoteAddress;
|
|
$this->ipCount[$ip] = ($this->ipCount[$ip] ?? 0) + 1;
|
|
if ($this->ipCount[$ip] > $this->maxPerIp) {
|
|
$this->ipCount[$ip]--;
|
|
return ['success' => false, 'error' => 'max connections per IP exceeded'];
|
|
}
|
|
|
|
if (isset($this->connections[$deviceId]) && $this->connections[$deviceId] !== $conn) {
|
|
$oldConn = $this->connections[$deviceId];
|
|
try { $oldConn->close(); } catch (\Throwable $e) {}
|
|
unset($this->devices[$deviceId], $this->connections[$deviceId]);
|
|
}
|
|
|
|
$this->devices[$deviceId] = [
|
|
'deviceId' => $deviceId,
|
|
'alias' => $data['alias'] ?? 'Unknown',
|
|
'deviceModel' => $data['deviceModel'] ?? '',
|
|
'deviceType' => $data['deviceType'] ?? 'mobile',
|
|
'fingerprint' => $data['fingerprint'] ?? $deviceId,
|
|
'userId' => $data['userId'] ?? null,
|
|
'protocol' => $data['protocol'] ?? 'xianyan-v1',
|
|
'ip' => $ip,
|
|
'connectedAt' => time(),
|
|
'lastSeen' => time(),
|
|
'isOnline' => true,
|
|
];
|
|
$this->connections[$deviceId] = $conn;
|
|
$conn->deviceId = $deviceId;
|
|
|
|
$this->log->info("Device registered: {$deviceId}", [
|
|
'alias' => $data['alias'] ?? 'Unknown',
|
|
'type' => $data['deviceType'] ?? 'mobile',
|
|
'ip' => $ip,
|
|
]);
|
|
|
|
$onlineDevices = $this->getOnlineDevices($deviceId);
|
|
return ['success' => true, 'deviceId' => $deviceId, 'onlineDevices' => $onlineDevices];
|
|
}
|
|
|
|
public function unregister(ConnectionInterface $conn): void
|
|
{
|
|
$deviceId = $conn->deviceId ?? null;
|
|
if ($deviceId && isset($this->devices[$deviceId])) {
|
|
$ip = $this->devices[$deviceId]['ip'] ?? '';
|
|
if ($ip && isset($this->ipCount[$ip])) {
|
|
$this->ipCount[$ip]--;
|
|
if ($this->ipCount[$ip] <= 0) unset($this->ipCount[$ip]);
|
|
}
|
|
$this->log->info("Device unregistered: {$deviceId}", [
|
|
'alias' => $this->devices[$deviceId]['alias'] ?? '',
|
|
]);
|
|
unset($this->devices[$deviceId], $this->connections[$deviceId]);
|
|
}
|
|
}
|
|
|
|
public function heartbeat(string $deviceId): void
|
|
{
|
|
if (isset($this->devices[$deviceId])) {
|
|
$this->devices[$deviceId]['lastSeen'] = time();
|
|
}
|
|
}
|
|
|
|
public function getOnlineDevices(string $excludeId = ''): array
|
|
{
|
|
$now = time();
|
|
$result = [];
|
|
foreach ($this->devices as $id => $dev) {
|
|
if ($id === $excludeId) continue;
|
|
if ($now - $dev['lastSeen'] > $this->heartbeatTimeout) {
|
|
$dev['isOnline'] = false;
|
|
continue;
|
|
}
|
|
$result[] = [
|
|
'deviceId' => $dev['deviceId'],
|
|
'alias' => $dev['alias'],
|
|
'deviceModel' => $dev['deviceModel'],
|
|
'deviceType' => $dev['deviceType'],
|
|
'isOnline' => true,
|
|
'isPaired' => false,
|
|
'protocol' => $dev['protocol'],
|
|
'lastSeen' => $dev['lastSeen'] * 1000,
|
|
];
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
public function getConnection(string $deviceId): ?ConnectionInterface
|
|
{
|
|
return $this->connections[$deviceId] ?? null;
|
|
}
|
|
|
|
public function getDevice(string $deviceId): ?array
|
|
{
|
|
return $this->devices[$deviceId] ?? null;
|
|
}
|
|
|
|
public function getDeviceByConn(ConnectionInterface $conn): ?array
|
|
{
|
|
$deviceId = $conn->deviceId ?? null;
|
|
return $deviceId ? ($this->devices[$deviceId] ?? null) : null;
|
|
}
|
|
|
|
public function cleanupStale(): int
|
|
{
|
|
$now = time();
|
|
$cleaned = 0;
|
|
foreach ($this->devices as $id => $dev) {
|
|
if ($now - $dev['lastSeen'] > $this->heartbeatTimeout) {
|
|
$conn = $this->connections[$id] ?? null;
|
|
if ($conn) {
|
|
try { $conn->close(); } catch (\Throwable $e) {}
|
|
}
|
|
unset($this->devices[$id], $this->connections[$id]);
|
|
$cleaned++;
|
|
}
|
|
}
|
|
return $cleaned;
|
|
}
|
|
|
|
public function getStats(): array
|
|
{
|
|
return [
|
|
'totalDevices' => count($this->devices),
|
|
'totalConns' => count($this->connections),
|
|
'uniqueIps' => count($this->ipCount),
|
|
];
|
|
}
|
|
}
|
|
|
|
// ─── 配对记录管理 ──────────────────────────────────────────
|
|
|
|
class PairingManager
|
|
{
|
|
private string $dataFile;
|
|
private array $records = [];
|
|
private array $pendingRequests = [];
|
|
private SignalLogger $log;
|
|
private int $requestTtl;
|
|
|
|
public function __construct(SignalLogger $log, string $dataFile, int $requestTtl = 300)
|
|
{
|
|
$this->log = $log;
|
|
$this->dataFile = $dataFile;
|
|
$this->requestTtl = $requestTtl;
|
|
$this->loadRecords();
|
|
}
|
|
|
|
private function loadRecords(): void
|
|
{
|
|
$dir = dirname($this->dataFile);
|
|
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
|
if (is_file($this->dataFile)) {
|
|
$data = json_decode(file_get_contents($this->dataFile), true);
|
|
if (is_array($data)) {
|
|
$this->records = $data;
|
|
}
|
|
}
|
|
}
|
|
|
|
private function saveRecords(): void
|
|
{
|
|
file_put_contents(
|
|
$this->dataFile,
|
|
json_encode($this->records, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
|
|
LOCK_EX
|
|
);
|
|
}
|
|
|
|
public function createRequest(string $fromId, string $toId, string $fingerprint): array
|
|
{
|
|
$this->cleanupExpired();
|
|
$requestId = bin2hex(random_bytes(16));
|
|
$this->pendingRequests[$requestId] = [
|
|
'id' => $requestId,
|
|
'fromId' => $fromId,
|
|
'toId' => $toId,
|
|
'fingerprint' => $fingerprint,
|
|
'createdAt' => time(),
|
|
'expiresAt' => time() + $this->requestTtl,
|
|
];
|
|
$this->log->info("Pair request created: {$fromId} -> {$toId}");
|
|
return $this->pendingRequests[$requestId];
|
|
}
|
|
|
|
public function acceptRequest(string $requestId, string $fromId, string $toId): array
|
|
{
|
|
if (!isset($this->pendingRequests[$requestId])) {
|
|
return ['success' => false, 'error' => 'request not found or expired'];
|
|
}
|
|
$req = $this->pendingRequests[$requestId];
|
|
unset($this->pendingRequests[$requestId]);
|
|
|
|
$pairId1 = $fromId . ':' . $toId;
|
|
$pairId2 = $toId . ':' . $fromId;
|
|
$this->records[$pairId1] = [
|
|
'id' => $pairId1,
|
|
'deviceId' => $fromId,
|
|
'peerDeviceId' => $toId,
|
|
'peerFingerprint' => $req['fingerprint'],
|
|
'pairedAt' => time(),
|
|
'lastVerifiedAt' => time(),
|
|
'isTrusted' => true,
|
|
];
|
|
$this->records[$pairId2] = [
|
|
'id' => $pairId2,
|
|
'deviceId' => $toId,
|
|
'peerDeviceId' => $fromId,
|
|
'peerFingerprint' => $req['fingerprint'],
|
|
'pairedAt' => time(),
|
|
'lastVerifiedAt' => time(),
|
|
'isTrusted' => true,
|
|
];
|
|
$this->saveRecords();
|
|
$this->log->info("Pair accepted: {$fromId} <-> {$toId}");
|
|
return ['success' => true];
|
|
}
|
|
|
|
public function rejectRequest(string $requestId): array
|
|
{
|
|
if (isset($this->pendingRequests[$requestId])) {
|
|
unset($this->pendingRequests[$requestId]);
|
|
$this->log->info("Pair rejected: {$requestId}");
|
|
}
|
|
return ['success' => true];
|
|
}
|
|
|
|
public function getPairedDevices(string $deviceId): array
|
|
{
|
|
$result = [];
|
|
foreach ($this->records as $key => $rec) {
|
|
if ($rec['deviceId'] === $deviceId) {
|
|
$result[] = $rec;
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
public function removePairing(string $deviceId, string $peerDeviceId): array
|
|
{
|
|
$pairId1 = $deviceId . ':' . $peerDeviceId;
|
|
$pairId2 = $peerDeviceId . ':' . $deviceId;
|
|
unset($this->records[$pairId1], $this->records[$pairId2]);
|
|
$this->saveRecords();
|
|
$this->log->info("Pair removed: {$deviceId} <-> {$peerDeviceId}");
|
|
return ['success' => true];
|
|
}
|
|
|
|
public function isPaired(string $deviceId, string $peerDeviceId): bool
|
|
{
|
|
$pairId = $deviceId . ':' . $peerDeviceId;
|
|
return isset($this->records[$pairId]);
|
|
}
|
|
|
|
private function cleanupExpired(): void
|
|
{
|
|
$now = time();
|
|
foreach ($this->pendingRequests as $id => $req) {
|
|
if ($now > $req['expiresAt']) {
|
|
unset($this->pendingRequests[$id]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── 信令服务核心 ──────────────────────────────────────────
|
|
|
|
class SignalingServer implements MessageComponentInterface
|
|
{
|
|
private DeviceManager $deviceManager;
|
|
private PairingManager $pairingManager;
|
|
private SignalLogger $log;
|
|
private array $config;
|
|
private int $startTime;
|
|
|
|
public function __construct(array $config)
|
|
{
|
|
$this->config = $config;
|
|
$this->log = new SignalLogger($config['log'] ?? []);
|
|
$this->deviceManager = new DeviceManager(
|
|
$this->log,
|
|
$config['signaling']['max_devices_per_ip'] ?? 10,
|
|
$config['signaling']['heartbeat_timeout'] ?? 90
|
|
);
|
|
$this->pairingManager = new PairingManager(
|
|
$this->log,
|
|
$config['pairing']['data_file'] ?? __DIR__ . '/data/pairing_records.json',
|
|
$config['pairing']['request_ttl'] ?? 300
|
|
);
|
|
$this->startTime = time();
|
|
$this->log->info("SignalingServer initialized");
|
|
}
|
|
|
|
public function onOpen(ConnectionInterface $conn): void
|
|
{
|
|
$conn->deviceId = null;
|
|
$this->log->debug("Connection opened: {$conn->remoteAddress}");
|
|
}
|
|
|
|
public function onMessage(ConnectionInterface $from, $msg): void
|
|
{
|
|
$data = json_decode($msg, true);
|
|
if (!is_array($data)) {
|
|
$this->sendError($from, 'invalid JSON');
|
|
return;
|
|
}
|
|
|
|
$type = $data['type'] ?? '';
|
|
$this->log->debug("Message received: type={$type}", ['from' => $from->deviceId ?? 'unknown']);
|
|
|
|
switch ($type) {
|
|
case 'register':
|
|
$this->handleRegister($from, $data);
|
|
break;
|
|
case 'discover':
|
|
$this->handleDiscover($from);
|
|
break;
|
|
case 'discoverMyDevices':
|
|
$this->handleDiscoverMyDevices($from, $data);
|
|
break;
|
|
case 'offer':
|
|
$this->handleRelay($from, $data, 'offer');
|
|
break;
|
|
case 'answer':
|
|
$this->handleRelay($from, $data, 'answer');
|
|
break;
|
|
case 'iceCandidate':
|
|
case 'ice-candidate':
|
|
$this->handleRelay($from, $data, 'iceCandidate');
|
|
break;
|
|
case 'textMessage':
|
|
case 'text-message':
|
|
$this->handleTextMessage($from, $data);
|
|
break;
|
|
case 'fileMeta':
|
|
case 'file-meta':
|
|
$this->handleRelay($from, $data, 'fileMeta');
|
|
break;
|
|
case 'fileChunk':
|
|
case 'file-chunk':
|
|
$this->handleRelay($from, $data, 'fileChunk');
|
|
break;
|
|
case 'fileComplete':
|
|
case 'file-complete':
|
|
$this->handleRelay($from, $data, 'fileComplete');
|
|
break;
|
|
case 'progress':
|
|
$this->handleRelay($from, $data, 'progress');
|
|
break;
|
|
case 'transportNegotiate':
|
|
case 'transportNegotiate':
|
|
$this->handleRelay($from, $data, 'transportNegotiate');
|
|
break;
|
|
case 'transportNegotiateResponse':
|
|
case 'transport-negotiate-response':
|
|
$this->handleRelay($from, $data, 'transportNegotiateResponse');
|
|
break;
|
|
case 'wsRelay':
|
|
$this->handleRelay($from, $data, 'wsRelay');
|
|
break;
|
|
case 'pairRequest':
|
|
case 'pair-request':
|
|
$this->handlePairRequest($from, $data);
|
|
break;
|
|
case 'pairAccept':
|
|
case 'pair-accept':
|
|
$this->handlePairAccept($from, $data);
|
|
break;
|
|
case 'pairReject':
|
|
case 'pair-reject':
|
|
$this->handlePairReject($from, $data);
|
|
break;
|
|
case 'heartbeat':
|
|
$this->handleHeartbeat($from);
|
|
break;
|
|
case 'leave':
|
|
$this->handleLeave($from);
|
|
break;
|
|
case 'ping':
|
|
$this->send($from, ['type' => 'pong', 'ts' => time() * 1000]);
|
|
break;
|
|
case 'delivery-ack':
|
|
$this->handleRelay($from, $data, 'delivery-ack');
|
|
break;
|
|
case 'chunk-ack':
|
|
$this->handleRelay($from, $data, 'chunk-ack');
|
|
break;
|
|
case 'resume-request':
|
|
$this->handleRelay($from, $data, 'resume-request');
|
|
break;
|
|
case 'voice-meta':
|
|
$this->handleRelay($from, $data, 'voice-meta');
|
|
break;
|
|
case 'cloud-cache-notify':
|
|
$this->handleRelay($from, $data, 'cloud-cache-notify');
|
|
break;
|
|
case 'canvas-stroke':
|
|
$this->handleRelay($from, $data, 'canvas-stroke');
|
|
break;
|
|
case 'canvas-cursor':
|
|
$this->handleRelay($from, $data, 'canvas-cursor');
|
|
break;
|
|
case 'canvas-join':
|
|
$this->handleCanvasJoin($from, $data);
|
|
break;
|
|
case 'canvas-leave':
|
|
$this->handleCanvasLeave($from, $data);
|
|
break;
|
|
case 'canvas-snapshot':
|
|
$this->handleRelay($from, $data, 'canvas-snapshot');
|
|
break;
|
|
case 'screen-share-offer':
|
|
$this->handleRelay($from, $data, 'screen-share-offer');
|
|
break;
|
|
case 'screen-share-answer':
|
|
$this->handleRelay($from, $data, 'screen-share-answer');
|
|
break;
|
|
case 'screen-share-stop':
|
|
$this->handleRelay($from, $data, 'screen-share-stop');
|
|
break;
|
|
case 'remote-input':
|
|
$this->handleRelay($from, $data, 'remote-input');
|
|
break;
|
|
default:
|
|
$this->sendError($from, "unknown type: {$type}");
|
|
}
|
|
}
|
|
|
|
public function onClose(ConnectionInterface $conn): void
|
|
{
|
|
$deviceId = $conn->deviceId ?? null;
|
|
if ($deviceId) {
|
|
$this->broadcastDeviceLeave($deviceId);
|
|
}
|
|
$this->deviceManager->unregister($conn);
|
|
$this->log->info("Connection closed: {$conn->remoteAddress}", ['deviceId' => $deviceId]);
|
|
}
|
|
|
|
public function onError(ConnectionInterface $conn, \Exception $e): void
|
|
{
|
|
$this->log->error("Connection error: " . $e->getMessage(), [
|
|
'deviceId' => $conn->deviceId ?? 'unknown',
|
|
]);
|
|
$conn->close();
|
|
}
|
|
|
|
// ─── 消息处理方法 ─────────────────────────────────────
|
|
|
|
private function handleRegister(ConnectionInterface $conn, array $data): void
|
|
{
|
|
$payload = $data['data'] ?? $data;
|
|
$result = $this->deviceManager->register($conn, $payload);
|
|
if (!$result['success']) {
|
|
$this->sendError($conn, $result['error']);
|
|
return;
|
|
}
|
|
$this->send($conn, [
|
|
'type' => 'registered',
|
|
'data' => [
|
|
'deviceId' => $result['deviceId'],
|
|
'onlineDevices' => $result['onlineDevices'],
|
|
'protocol' => $payload['protocol'] ?? 'xianyan-v1',
|
|
'serverTime' => time() * 1000,
|
|
],
|
|
]);
|
|
$this->broadcastDeviceOnline($result['deviceId']);
|
|
}
|
|
|
|
private function handleDiscover(ConnectionInterface $conn): void
|
|
{
|
|
$deviceId = $conn->deviceId ?? null;
|
|
if (!$deviceId) {
|
|
$this->sendError($conn, 'not registered');
|
|
return;
|
|
}
|
|
$devices = $this->deviceManager->getOnlineDevices($deviceId);
|
|
$pairedDevices = $this->pairingManager->getPairedDevices($deviceId);
|
|
foreach ($devices as &$dev) {
|
|
$dev['isPaired'] = $this->pairingManager->isPaired($deviceId, $dev['deviceId']);
|
|
}
|
|
unset($dev);
|
|
$this->send($conn, [
|
|
'type' => 'discover_response',
|
|
'data' => [
|
|
'devices' => $devices,
|
|
'pairedDevices' => $pairedDevices,
|
|
],
|
|
]);
|
|
}
|
|
|
|
private function handleRelay(ConnectionInterface $from, array $data, string $type): void
|
|
{
|
|
$fromId = $from->deviceId ?? null;
|
|
$targetId = $data['targetId'] ?? $data['to'] ?? null;
|
|
if (!$fromId) {
|
|
$this->sendError($from, 'not registered');
|
|
return;
|
|
}
|
|
if (!$targetId) {
|
|
$this->sendError($from, 'targetId is required');
|
|
return;
|
|
}
|
|
$targetConn = $this->deviceManager->getConnection($targetId);
|
|
if (!$targetConn) {
|
|
$this->sendError($from, "target device not found: {$targetId}");
|
|
return;
|
|
}
|
|
$this->send($targetConn, [
|
|
'type' => $type,
|
|
'from' => $fromId,
|
|
'data' => $data['data'] ?? $data['payload'] ?? [],
|
|
]);
|
|
$this->log->debug("Relay {$type}: {$fromId} -> {$targetId}");
|
|
}
|
|
|
|
private function handleTextMessage(ConnectionInterface $from, array $data): void
|
|
{
|
|
$fromId = $from->deviceId ?? null;
|
|
$targetId = $data['targetId'] ?? $data['to'] ?? null;
|
|
if (!$fromId || !$targetId) {
|
|
$this->sendError($from, 'fromId and targetId required');
|
|
return;
|
|
}
|
|
$targetConn = $this->deviceManager->getConnection($targetId);
|
|
if (!$targetConn) {
|
|
$this->sendError($from, "target device not found: {$targetId}");
|
|
return;
|
|
}
|
|
$this->send($targetConn, [
|
|
'type' => 'textMessage',
|
|
'from' => $fromId,
|
|
'data' => [
|
|
'text' => $data['data']['text'] ?? '',
|
|
'envelope' => $data['data']['envelope'] ?? null,
|
|
'timestamp' => time() * 1000,
|
|
],
|
|
]);
|
|
$this->send($from, [
|
|
'type' => 'messageSent',
|
|
'data' => ['targetId' => $targetId, 'timestamp' => time() * 1000],
|
|
]);
|
|
$this->log->info("Text message: {$fromId} -> {$targetId}");
|
|
}
|
|
|
|
private function handlePairRequest(ConnectionInterface $from, array $data): void
|
|
{
|
|
$fromId = $from->deviceId ?? null;
|
|
$targetId = $data['targetId'] ?? $data['to'] ?? null;
|
|
$fingerprint = $data['data']['fingerprint'] ?? '';
|
|
if (!$fromId || !$targetId) {
|
|
$this->sendError($from, 'fromId and targetId required');
|
|
return;
|
|
}
|
|
$request = $this->pairingManager->createRequest($fromId, $targetId, $fingerprint);
|
|
$targetConn = $this->deviceManager->getConnection($targetId);
|
|
if ($targetConn) {
|
|
$fromDevice = $this->deviceManager->getDevice($fromId);
|
|
$this->send($targetConn, [
|
|
'type' => 'pairRequest',
|
|
'from' => $fromId,
|
|
'data' => [
|
|
'requestId' => $request['id'],
|
|
'fingerprint' => $fingerprint,
|
|
'alias' => $fromDevice['alias'] ?? 'Unknown',
|
|
'deviceModel' => $fromDevice['deviceModel'] ?? '',
|
|
'deviceType' => $fromDevice['deviceType'] ?? 'mobile',
|
|
'expiresAt' => $request['expiresAt'] * 1000,
|
|
],
|
|
]);
|
|
}
|
|
$this->send($from, [
|
|
'type' => 'pairRequestSent',
|
|
'data' => ['requestId' => $request['id'], 'targetId' => $targetId],
|
|
]);
|
|
}
|
|
|
|
private function handlePairAccept(ConnectionInterface $from, array $data): void
|
|
{
|
|
$fromId = $from->deviceId ?? null;
|
|
$requestId = $data['data']['requestId'] ?? '';
|
|
$targetId = $data['targetId'] ?? $data['to'] ?? '';
|
|
if (!$fromId || !$requestId) {
|
|
$this->sendError($from, 'fromId and requestId required');
|
|
return;
|
|
}
|
|
$result = $this->pairingManager->acceptRequest($requestId, $fromId, $targetId);
|
|
$targetConn = $this->deviceManager->getConnection($targetId);
|
|
if ($targetConn) {
|
|
$this->send($targetConn, [
|
|
'type' => 'pairAccepted',
|
|
'from' => $fromId,
|
|
'data' => ['requestId' => $requestId],
|
|
]);
|
|
}
|
|
$this->send($from, [
|
|
'type' => 'pairAcceptConfirmed',
|
|
'data' => ['requestId' => $requestId, 'success' => $result['success']],
|
|
]);
|
|
}
|
|
|
|
private function handlePairReject(ConnectionInterface $from, array $data): void
|
|
{
|
|
$fromId = $from->deviceId ?? null;
|
|
$requestId = $data['data']['requestId'] ?? '';
|
|
$targetId = $data['targetId'] ?? $data['to'] ?? '';
|
|
$this->pairingManager->rejectRequest($requestId);
|
|
$targetConn = $this->deviceManager->getConnection($targetId);
|
|
if ($targetConn) {
|
|
$this->send($targetConn, [
|
|
'type' => 'pairRejected',
|
|
'from' => $fromId,
|
|
'data' => ['requestId' => $requestId],
|
|
]);
|
|
}
|
|
$this->send($from, [
|
|
'type' => 'pairRejectConfirmed',
|
|
'data' => ['requestId' => $requestId],
|
|
]);
|
|
}
|
|
|
|
private function handleHeartbeat(ConnectionInterface $from): void
|
|
{
|
|
$deviceId = $from->deviceId ?? null;
|
|
if ($deviceId) {
|
|
$this->deviceManager->heartbeat($deviceId);
|
|
}
|
|
$this->send($from, [
|
|
'type' => 'heartbeat_ack',
|
|
'data' => ['timestamp' => time() * 1000],
|
|
]);
|
|
}
|
|
|
|
private function handleLeave(ConnectionInterface $from): void
|
|
{
|
|
$deviceId = $from->deviceId ?? null;
|
|
if ($deviceId) {
|
|
$this->broadcastDeviceLeave($deviceId);
|
|
}
|
|
}
|
|
|
|
private function handleDiscoverMyDevices(ConnectionInterface $from, array $data): void
|
|
{
|
|
$deviceId = $from->deviceId ?? null;
|
|
if (!$deviceId) {
|
|
$this->sendError($from, 'not registered');
|
|
return;
|
|
}
|
|
$userId = $data['data']['userId'] ?? $data['userId'] ?? null;
|
|
if (!$userId) {
|
|
$this->sendError($from, 'userId is required');
|
|
return;
|
|
}
|
|
$myDevices = [];
|
|
foreach ($this->deviceManager->getOnlineDevices() as $dev) {
|
|
$devInfo = $this->deviceManager->getDevice($dev['deviceId']);
|
|
if ($devInfo && ($devInfo['userId'] ?? null) === $userId) {
|
|
$myDevices[] = $dev;
|
|
}
|
|
}
|
|
$this->send($from, [
|
|
'type' => 'myDevicesResponse',
|
|
'data' => ['devices' => $myDevices],
|
|
]);
|
|
$this->log->info("discoverMyDevices: userId={$userId}, found=" . count($myDevices));
|
|
}
|
|
|
|
private function handleCanvasJoin(ConnectionInterface $from, array $data): void
|
|
{
|
|
$fromId = $from->deviceId ?? null;
|
|
$targetId = $data['targetId'] ?? $data['to'] ?? null;
|
|
$canvasId = $data['data']['canvasId'] ?? $data['canvasId'] ?? '';
|
|
if (!$fromId || !$targetId) {
|
|
$this->sendError($from, 'fromId and targetId required');
|
|
return;
|
|
}
|
|
$targetConn = $this->deviceManager->getConnection($targetId);
|
|
if ($targetConn) {
|
|
$this->send($targetConn, [
|
|
'type' => 'canvas-join',
|
|
'from' => $fromId,
|
|
'data' => [
|
|
'canvasId' => $canvasId,
|
|
'alias' => $this->deviceManager->getDevice($fromId)['alias'] ?? 'Unknown',
|
|
],
|
|
]);
|
|
}
|
|
$this->log->info("canvas-join: {$fromId} -> canvas={$canvasId}");
|
|
}
|
|
|
|
private function handleCanvasLeave(ConnectionInterface $from, array $data): void
|
|
{
|
|
$fromId = $from->deviceId ?? null;
|
|
$targetId = $data['targetId'] ?? $data['to'] ?? null;
|
|
$canvasId = $data['data']['canvasId'] ?? $data['canvasId'] ?? '';
|
|
if (!$fromId || !$targetId) {
|
|
$this->sendError($from, 'fromId and targetId required');
|
|
return;
|
|
}
|
|
$targetConn = $this->deviceManager->getConnection($targetId);
|
|
if ($targetConn) {
|
|
$this->send($targetConn, [
|
|
'type' => 'canvas-leave',
|
|
'from' => $fromId,
|
|
'data' => ['canvasId' => $canvasId],
|
|
]);
|
|
}
|
|
$this->log->info("canvas-leave: {$fromId} -> canvas={$canvasId}");
|
|
}
|
|
|
|
// ─── 广播方法 ─────────────────────────────────────────
|
|
|
|
private function broadcastDeviceOnline(string $deviceId): void
|
|
{
|
|
$device = $this->deviceManager->getDevice($deviceId);
|
|
if (!$device) return;
|
|
$msg = json_encode([
|
|
'type' => 'deviceOnline',
|
|
'data' => [
|
|
'deviceId' => $deviceId,
|
|
'alias' => $device['alias'],
|
|
'deviceModel' => $device['deviceModel'],
|
|
'deviceType' => $device['deviceType'],
|
|
'protocol' => $device['protocol'],
|
|
],
|
|
], JSON_UNESCAPED_UNICODE);
|
|
foreach ($this->deviceManager->getOnlineDevices($deviceId) as $dev) {
|
|
$conn = $this->deviceManager->getConnection($dev['deviceId']);
|
|
if ($conn) {
|
|
try { $conn->send($msg); } catch (\Throwable $e) {}
|
|
}
|
|
}
|
|
}
|
|
|
|
private function broadcastDeviceLeave(string $deviceId): void
|
|
{
|
|
$device = $this->deviceManager->getDevice($deviceId);
|
|
if (!$device) return;
|
|
$msg = json_encode([
|
|
'type' => 'deviceOffline',
|
|
'data' => ['deviceId' => $deviceId, 'alias' => $device['alias']],
|
|
], JSON_UNESCAPED_UNICODE);
|
|
foreach ($this->deviceManager->getOnlineDevices() as $dev) {
|
|
$conn = $this->deviceManager->getConnection($dev['deviceId']);
|
|
if ($conn) {
|
|
try { $conn->send($msg); } catch (\Throwable $e) {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── 工具方法 ─────────────────────────────────────────
|
|
|
|
private function send(ConnectionInterface $conn, array $data): void
|
|
{
|
|
try {
|
|
$conn->send(json_encode($data, JSON_UNESCAPED_UNICODE));
|
|
} catch (\Throwable $e) {
|
|
$this->log->error("Send failed: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
private function sendError(ConnectionInterface $conn, string $error): void
|
|
{
|
|
$this->send($conn, ['type' => 'error', 'data' => ['message' => $error]]);
|
|
}
|
|
|
|
public function getDeviceManager(): DeviceManager
|
|
{
|
|
return $this->deviceManager;
|
|
}
|
|
|
|
public function getPairingManager(): PairingManager
|
|
{
|
|
return $this->pairingManager;
|
|
}
|
|
|
|
public function getStats(): array
|
|
{
|
|
return array_merge($this->deviceManager->getStats(), [
|
|
'uptime' => time() - $this->startTime,
|
|
'startTime' => $this->startTime,
|
|
]);
|
|
}
|
|
}
|
|
|
|
// ─── HTTP健康检查端点 ──────────────────────────────────────
|
|
|
|
class HealthCheckHandler implements \Ratchet\Http\HttpServerInterface
|
|
{
|
|
private SignalingServer $signaling;
|
|
|
|
public function __construct(SignalingServer $signaling)
|
|
{
|
|
$this->signaling = $signaling;
|
|
}
|
|
|
|
public function onOpen(ConnectionInterface $conn, \Psr\Http\Message\RequestInterface $request = null): void
|
|
{
|
|
$path = $request ? $request->getUri()->getPath() : '/';
|
|
switch ($path) {
|
|
case '/health':
|
|
$stats = $this->signaling->getStats();
|
|
$body = json_encode([
|
|
'status' => 'ok',
|
|
'version' => '1.0.0',
|
|
'stats' => $stats,
|
|
'time' => date('c'),
|
|
]);
|
|
break;
|
|
case '/stats':
|
|
$body = json_encode($this->signaling->getStats());
|
|
break;
|
|
default:
|
|
$body = json_encode(['error' => 'not found']);
|
|
break;
|
|
}
|
|
$response = "HTTP/1.1 200 OK\r\n"
|
|
. "Content-Type: application/json; charset=utf-8\r\n"
|
|
. "Access-Control-Allow-Origin: *\r\n"
|
|
. "Content-Length: " . strlen($body) . "\r\n"
|
|
. "Connection: close\r\n"
|
|
. "\r\n"
|
|
. $body;
|
|
$conn->send($response);
|
|
$conn->close();
|
|
}
|
|
|
|
public function onMessage(ConnectionInterface $from, $msg): void {}
|
|
public function onClose(ConnectionInterface $conn): void {}
|
|
public function onError(ConnectionInterface $conn, \Exception $e): void { $conn->close(); }
|
|
}
|
|
|
|
// ─── 路由分发 ──────────────────────────────────────────────
|
|
|
|
class RouterHandler implements \Ratchet\Http\HttpServerInterface
|
|
{
|
|
private SignalingServer $signaling;
|
|
private HealthCheckHandler $healthCheck;
|
|
private WsServer $wsServer;
|
|
|
|
public function __construct(SignalingServer $signaling, WsServer $wsServer)
|
|
{
|
|
$this->signaling = $signaling;
|
|
$this->healthCheck = new HealthCheckHandler($signaling);
|
|
$this->wsServer = $wsServer;
|
|
}
|
|
|
|
public function onOpen(ConnectionInterface $conn, \Psr\Http\Message\RequestInterface $request = null): void
|
|
{
|
|
$path = $request ? $request->getUri()->getPath() : '/';
|
|
if ($path === '/health' || $path === '/stats') {
|
|
$this->healthCheck->onOpen($conn, $request);
|
|
} else {
|
|
$this->wsServer->onOpen($conn, $request);
|
|
}
|
|
}
|
|
|
|
public function onMessage(ConnectionInterface $from, $msg): void
|
|
{
|
|
$this->wsServer->onMessage($from, $msg);
|
|
}
|
|
|
|
public function onClose(ConnectionInterface $conn): void
|
|
{
|
|
$this->wsServer->onClose($conn);
|
|
}
|
|
|
|
public function onError(ConnectionInterface $conn, \Exception $e): void
|
|
{
|
|
$this->wsServer->onError($conn, $e);
|
|
}
|
|
}
|
|
|
|
// ─── 启动服务 ──────────────────────────────────────────────
|
|
|
|
$logger = new SignalLogger($config['log'] ?? []);
|
|
$signalingServer = new SignalingServer($config);
|
|
$wsServer = new WsServer($signalingServer);
|
|
$router = new RouterHandler($signalingServer, $wsServer);
|
|
$httpServer = new HttpServer($router);
|
|
|
|
$port = $config['signaling']['port'] ?? 9443;
|
|
$host = $config['signaling']['host'] ?? '0.0.0.0';
|
|
|
|
$logger->info("Starting XianYan Signaling Server on {$host}:{$port}");
|
|
$logger->info("Supported protocols: " . implode(', ', $config['protocol']['supported'] ?? ['xianyan-v1']));
|
|
|
|
$server = IoServer::factory($httpServer, $port, $host);
|
|
|
|
$server->loop->addPeriodicTimer(30, function () use ($signalingServer, $logger) {
|
|
$cleaned = $signalingServer->getDeviceManager()->cleanupStale();
|
|
if ($cleaned > 0) {
|
|
$logger->info("Cleaned up {$cleaned} stale connections");
|
|
}
|
|
});
|
|
|
|
$server->loop->addPeriodicTimer(60, function () use ($signalingServer, $logger) {
|
|
$stats = $signalingServer->getStats();
|
|
$logger->info("Stats: " . json_encode($stats));
|
|
});
|
|
|
|
echo "╔══════════════════════════════════════════════╗\n";
|
|
echo "║ 闲言APP 文件传输信令服务 v1.0.0 ║\n";
|
|
echo "║ WebSocket: ws://{$host}:{$port} ║\n";
|
|
echo "║ Health: http://{$host}:{$port}/health ║\n";
|
|
echo "║ Protocols: xianyan-v1, localsend-v2 ║\n";
|
|
echo "╚══════════════════════════════════════════════╝\n";
|
|
|
|
$server->run();
|