Files
xianyan/server/signaling_server.php
Developer 283950ea07 chore: 批量代码优化与功能迭代更新
本次提交包含大量代码优化、功能新增与服务端配置更新:
1. 修复分析报告统计数据,调整CMake策略设置
2. 优化APP权限配置、编辑器与聊天界面组件
3. 更新依赖库版本与pubspec配置
4. 新增文件传输服务端、信令服务器相关配置与脚本
5. 完善用户注销功能与数据库迁移脚本
6. 优化多处动画效果、代码风格与日志输出
7. 新增多种调试与部署脚本,修复已知BUG
2026-05-12 06:28:04 +08:00

994 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'] ?? '',
'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();