主要变更: 1. 新增多风格音效资源与管理文档 2. 修复翻译服务空响应处理与Dio日志异常捕获 3. 完善Web端平台适配与路径获取Stub 4. 优化设备配对与文件传输功能 5. 新增角色命名常量与摇一摇检测器 6. 修复Riverpod dispose与鸿蒙导航路由 7. 新增每日通知服务与流体着色器 8. 优化备份服务与数据管理页面 9. 新增隐私设置附近设备发现选项 10. 重构诗词提供者支持历史记录 11. 完善桌面端构建配置与开发脚本 12. 清理旧版工具部署脚本
1254 lines
44 KiB
JavaScript
1254 lines
44 KiB
JavaScript
var process = require('process')
|
|
var fs = require('fs')
|
|
var path = require('path')
|
|
// Handle SIGINT
|
|
process.on('SIGINT', () => {
|
|
console.info("SIGINT Received, exiting...")
|
|
process.exit(0)
|
|
})
|
|
|
|
// Handle SIGTERM
|
|
process.on('SIGTERM', () => {
|
|
console.info("SIGTERM Received, exiting...")
|
|
process.exit(0)
|
|
})
|
|
|
|
const parser = require('ua-parser-js');
|
|
const { uniqueNamesGenerator, animals, colors } = require('unique-names-generator');
|
|
|
|
class SnapdropServer {
|
|
|
|
constructor(port) {
|
|
const WebSocket = require('ws');
|
|
this._wss = new WebSocket.Server({ port: port });
|
|
this._wss.on('connection', (socket, request) => this._onConnection(new Peer(socket, request)));
|
|
this._wss.on('headers', (headers, response) => this._onHeaders(headers, response));
|
|
|
|
this._rooms = {};
|
|
this._userIdIndex = {};
|
|
this._fingerprintIndex = {};
|
|
this._pairingRecords = {};
|
|
this._pendingPairRequests = {};
|
|
this._canvasRooms = {};
|
|
this._pairingCodes = {};
|
|
this._radarPool = {};
|
|
this._activeScreenShares = {};
|
|
|
|
this._pairingCodeCleanup = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const code in this._pairingCodes) {
|
|
if (now > this._pairingCodes[code].expiresAt) {
|
|
delete this._pairingCodes[code];
|
|
}
|
|
}
|
|
}, 60000);
|
|
|
|
this._radarPoolCleanup = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const deviceId in this._radarPool) {
|
|
if (now - this._radarPool[deviceId].lastBroadcast > 30000) {
|
|
delete this._radarPool[deviceId];
|
|
}
|
|
}
|
|
}, 10000);
|
|
|
|
this._loadPairingRecords();
|
|
|
|
if (this._wss._server) {
|
|
this._wss._server.on('request', (req, res) => {
|
|
if (req.url === '/health') {
|
|
const totalPeers = Object.values(this._rooms).reduce((sum, room) => sum + Object.keys(room).length, 0);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
status: 'healthy',
|
|
peers: totalPeers,
|
|
pairingCodes: Object.keys(this._pairingCodes).length,
|
|
radarDevices: Object.keys(this._radarPool).length,
|
|
activeScreenShares: Object.keys(this._activeScreenShares).length,
|
|
canvasRooms: Object.keys(this._canvasRooms).length,
|
|
uptime: process.uptime()
|
|
}));
|
|
}
|
|
});
|
|
}
|
|
|
|
console.log('Snapdrop is running on port', port);
|
|
}
|
|
|
|
_loadPairingRecords() {
|
|
const dataFile = path.join(__dirname, 'data', 'pairing_records.json');
|
|
try {
|
|
if (fs.existsSync(dataFile)) {
|
|
this._pairingRecords = JSON.parse(fs.readFileSync(dataFile, 'utf8'));
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load pairing records:', e.message);
|
|
}
|
|
}
|
|
|
|
_savePairingRecords() {
|
|
const dataDir = path.join(__dirname, 'data');
|
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
const dataFile = path.join(dataDir, 'pairing_records.json');
|
|
try {
|
|
fs.writeFileSync(dataFile, JSON.stringify(this._pairingRecords, null, 2));
|
|
} catch (e) {
|
|
console.error('Failed to save pairing records:', e.message);
|
|
}
|
|
}
|
|
|
|
_onConnection(peer) {
|
|
this._joinRoom(peer);
|
|
peer.socket.on('message', message => this._onMessage(peer, message));
|
|
peer.socket.on('error', console.error);
|
|
this._keepAlive(peer);
|
|
|
|
this._send(peer, {
|
|
type: 'registered',
|
|
id: peer.id,
|
|
fingerprint: peer.fingerprint || '',
|
|
userId: peer.userId || ''
|
|
});
|
|
|
|
this._send(peer, {
|
|
type: 'display-name',
|
|
message: {
|
|
displayName: peer.name.displayName,
|
|
deviceName: peer.name.deviceName
|
|
}
|
|
});
|
|
}
|
|
|
|
_onHeaders(headers, response) {
|
|
if (response.headers.cookie && response.headers.cookie.indexOf('peerid=') > -1) return;
|
|
response.peerId = Peer.uuid();
|
|
headers.push('Set-Cookie: peerid=' + response.peerId + "; SameSite=Strict; Secure");
|
|
}
|
|
|
|
_onMessage(sender, message) {
|
|
try {
|
|
message = JSON.parse(message);
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
|
|
switch (message.type) {
|
|
case 'disconnect':
|
|
this._leaveRoom(sender);
|
|
break;
|
|
case 'pong':
|
|
sender.lastBeat = Date.now();
|
|
break;
|
|
case 'register':
|
|
this._handleRegister(sender, message);
|
|
break;
|
|
case 'discoverMyDevices':
|
|
this._handleDiscoverMyDevices(sender, message);
|
|
break;
|
|
case 'transportNegotiate':
|
|
this._handleTransportNegotiate(sender, message);
|
|
break;
|
|
case 'wsRelay':
|
|
this._handleWsRelay(sender, message);
|
|
break;
|
|
case 'pair-request':
|
|
case 'pairRequest':
|
|
this._handlePairRequest(sender, message);
|
|
break;
|
|
case 'pair-accept':
|
|
case 'pairAccept':
|
|
this._handlePairAccept(sender, message);
|
|
break;
|
|
case 'pair-reject':
|
|
case 'pairReject':
|
|
this._handlePairReject(sender, message);
|
|
break;
|
|
case 'heartbeat':
|
|
sender.lastBeat = Date.now();
|
|
this._send(sender, {
|
|
type: 'heartbeat_ack',
|
|
timestamp: Date.now()
|
|
});
|
|
break;
|
|
case 'discover':
|
|
this._handleDiscover(sender, message);
|
|
break;
|
|
case 'ping':
|
|
sender.lastBeat = Date.now();
|
|
this._send(sender, { type: 'pong', timestamp: Date.now() });
|
|
break;
|
|
case 'textMessage':
|
|
this._handleTextMessage(sender, message);
|
|
break;
|
|
case 'fileMeta':
|
|
this._handleFileMeta(sender, message);
|
|
break;
|
|
case 'canvas-stroke':
|
|
this._handleCanvasStroke(sender, message);
|
|
break;
|
|
case 'canvas-cursor':
|
|
this._handleCanvasCursor(sender, message);
|
|
break;
|
|
case 'canvas-join':
|
|
this._handleCanvasJoin(sender, message);
|
|
break;
|
|
case 'canvas-leave':
|
|
this._handleCanvasLeave(sender, message);
|
|
break;
|
|
case 'canvas-snapshot':
|
|
this._handleCanvasSnapshot(sender, message);
|
|
break;
|
|
case 'pairing-code-create':
|
|
this._handlePairingCodeCreate(sender, message);
|
|
break;
|
|
case 'pairing-code-join':
|
|
this._handlePairingCodeJoin(sender, message);
|
|
break;
|
|
case 'radar-broadcast':
|
|
this._handleRadarBroadcast(sender, message);
|
|
break;
|
|
case 'radar-scan':
|
|
this._handleRadarScan(sender, message);
|
|
break;
|
|
case 'screen-share-request':
|
|
this._handleScreenShareRequest(sender, message);
|
|
break;
|
|
case 'screen-share-accept':
|
|
this._handleScreenShareAccept(sender, message);
|
|
break;
|
|
case 'screen-share-reject':
|
|
this._handleScreenShareReject(sender, message);
|
|
break;
|
|
case 'screen-share-stop':
|
|
this._handleScreenShareStop(sender, message);
|
|
break;
|
|
}
|
|
|
|
const handledTypes = ['disconnect', 'pong', 'register', 'discoverMyDevices', 'discover', 'transportNegotiate', 'wsRelay', 'pair-request', 'pairRequest', 'pair-accept', 'pairAccept', 'pair-reject', 'pairReject', 'heartbeat', 'ping', 'textMessage', 'fileMeta', 'canvas-stroke', 'canvas-cursor', 'canvas-join', 'canvas-leave', 'canvas-snapshot', 'pairing-code-create', 'pairing-code-join', 'radar-broadcast', 'radar-scan', 'screen-share-request', 'screen-share-accept', 'screen-share-reject', 'screen-share-stop'];
|
|
if (message.to && !handledTypes.includes(message.type)) {
|
|
let recipientId = message.to;
|
|
let recipient = this._resolveTarget(sender, recipientId);
|
|
|
|
if (recipient) {
|
|
delete message.to;
|
|
message.sender = sender.id;
|
|
message.from = sender.fingerprint || sender.id;
|
|
message.fromPeerId = sender.id;
|
|
this._send(recipient, message);
|
|
} else {
|
|
console.log('Message routing failed: target not found for', recipientId, 'from', sender.id);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
_handleRegister(sender, message) {
|
|
const data = message.payload || message.data || {};
|
|
if (data.userId) {
|
|
const oldUserId = sender.userId;
|
|
sender.userId = data.userId;
|
|
if (oldUserId && this._userIdIndex[oldUserId]) {
|
|
this._userIdIndex[oldUserId].delete(sender.id);
|
|
if (this._userIdIndex[oldUserId].size === 0) {
|
|
delete this._userIdIndex[oldUserId];
|
|
}
|
|
}
|
|
if (!this._userIdIndex[sender.userId]) {
|
|
this._userIdIndex[sender.userId] = new Set();
|
|
}
|
|
this._userIdIndex[sender.userId].add(sender.id);
|
|
}
|
|
if (data.fingerprint) {
|
|
const oldFingerprint = sender.fingerprint;
|
|
sender.fingerprint = data.fingerprint;
|
|
if (oldFingerprint && this._fingerprintIndex[oldFingerprint] === sender.id) {
|
|
delete this._fingerprintIndex[oldFingerprint];
|
|
}
|
|
const existingPeerId = this._fingerprintIndex[sender.fingerprint];
|
|
if (existingPeerId && existingPeerId !== sender.id) {
|
|
const existingPeer = this._findPeer(existingPeerId);
|
|
if (existingPeer) {
|
|
console.log('Replacing old connection for fingerprint:', sender.fingerprint, 'old peer:', existingPeerId, 'new peer:', sender.id);
|
|
this._leaveRoom(existingPeer);
|
|
}
|
|
}
|
|
this._fingerprintIndex[sender.fingerprint] = sender.id;
|
|
console.log('Register fingerprint mapping:', sender.fingerprint, '->', sender.id);
|
|
}
|
|
if (data.ipCity) sender.ipCity = data.ipCity;
|
|
if (data.ipRange) sender.ipRange = data.ipRange;
|
|
if (data.deviceModel) sender.deviceModel = data.deviceModel;
|
|
if (data.platform) sender.platform = data.platform;
|
|
if (data.alias) sender.name.displayName = data.alias;
|
|
if (data.deviceType) sender.name.type = data.deviceType;
|
|
|
|
this._send(sender, {
|
|
type: 'registered',
|
|
id: sender.id,
|
|
fingerprint: sender.fingerprint || '',
|
|
userId: sender.userId
|
|
});
|
|
}
|
|
|
|
_handleDiscover(sender, message) {
|
|
const allPeers = [];
|
|
for (const roomId in this._rooms) {
|
|
for (const peerId in this._rooms[roomId]) {
|
|
const peer = this._rooms[roomId][peerId];
|
|
if (peer.id !== sender.id) {
|
|
allPeers.push(peer.getInfo());
|
|
}
|
|
}
|
|
}
|
|
this._send(sender, {
|
|
type: 'discover_response',
|
|
devices: allPeers
|
|
});
|
|
}
|
|
|
|
_handleDiscoverMyDevices(sender, message) {
|
|
const payload = message.payload || message.data || {};
|
|
const userId = payload.userId || message.userId || sender.userId;
|
|
if (!userId) {
|
|
this._send(sender, {
|
|
type: 'myDevicesResponse',
|
|
devices: [],
|
|
error: 'userId required'
|
|
});
|
|
return;
|
|
}
|
|
|
|
const seenFingerprints = new Map();
|
|
for (const roomId in this._rooms) {
|
|
for (const peerId in this._rooms[roomId]) {
|
|
const peer = this._rooms[roomId][peerId];
|
|
if (peer.id === sender.id) continue;
|
|
if (peer.userId === userId) {
|
|
const fp = peer.fingerprint || peer.id;
|
|
if (!seenFingerprints.has(fp)) {
|
|
seenFingerprints.set(fp, {
|
|
id: peer.id,
|
|
fingerprint: peer.fingerprint || '',
|
|
alias: peer.name.displayName,
|
|
deviceName: peer.name.deviceName,
|
|
deviceModel: peer.deviceModel || '',
|
|
platform: peer.platform || '',
|
|
ip: peer.ip,
|
|
ipCity: peer.ipCity || '',
|
|
isOnline: true,
|
|
rtcSupported: peer.rtcSupported
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
this._send(sender, {
|
|
type: 'myDevicesResponse',
|
|
devices: Array.from(seenFingerprints.values())
|
|
});
|
|
}
|
|
|
|
_handleTransportNegotiate(sender, message) {
|
|
const targetId = message.to;
|
|
if (!targetId) return;
|
|
|
|
let targetPeer = this._findPeer(targetId);
|
|
|
|
if (!targetPeer && this._fingerprintIndex[targetId]) {
|
|
targetPeer = this._findPeer(this._fingerprintIndex[targetId]);
|
|
}
|
|
|
|
if (!targetPeer) {
|
|
this._send(sender, {
|
|
type: 'transportNegotiateResponse',
|
|
success: false,
|
|
error: 'Target device not found'
|
|
});
|
|
return;
|
|
}
|
|
|
|
this._send(targetPeer, {
|
|
type: 'transportNegotiate',
|
|
from: sender.fingerprint || sender.id,
|
|
transport: message.transport,
|
|
payload: message.payload || {}
|
|
});
|
|
}
|
|
|
|
_handlePairRequest(sender, message) {
|
|
const targetId = message.to;
|
|
if (!targetId) return;
|
|
|
|
let targetPeer = this._findPeer(targetId);
|
|
|
|
if (!targetPeer && this._fingerprintIndex[targetId]) {
|
|
targetPeer = this._findPeer(this._fingerprintIndex[targetId]);
|
|
}
|
|
|
|
if (!targetPeer) {
|
|
this._send(sender, {
|
|
type: 'pair-response',
|
|
from: sender.id,
|
|
success: false,
|
|
error: 'Target device not found'
|
|
});
|
|
return;
|
|
}
|
|
|
|
const requestId = Date.now().toString(36) + Math.random().toString(36).substr(2);
|
|
this._pendingPairRequests[requestId] = {
|
|
id: requestId,
|
|
fromId: sender.id,
|
|
toId: targetId,
|
|
fingerprint: message.fingerprint || message.payload?.fingerprint || '',
|
|
createdAt: Date.now(),
|
|
expiresAt: Date.now() + 300000
|
|
};
|
|
|
|
this._send(targetPeer, {
|
|
type: 'pair-request',
|
|
from: sender.fingerprint || sender.id,
|
|
requestId: requestId,
|
|
fingerprint: this._pendingPairRequests[requestId].fingerprint,
|
|
payload: message.payload || {}
|
|
});
|
|
}
|
|
|
|
_handlePairAccept(sender, message) {
|
|
const requestId = message.requestId || message.payload?.requestId;
|
|
if (!requestId || !this._pendingPairRequests[requestId]) {
|
|
this._send(sender, {
|
|
type: 'pair-response',
|
|
success: false,
|
|
error: 'Request not found or expired'
|
|
});
|
|
return;
|
|
}
|
|
|
|
const req = this._pendingPairRequests[requestId];
|
|
delete this._pendingPairRequests[requestId];
|
|
|
|
const pairKey1 = req.fromId + ':' + sender.id;
|
|
const pairKey2 = sender.id + ':' + req.fromId;
|
|
this._pairingRecords[pairKey1] = {
|
|
deviceId: req.fromId,
|
|
peerDeviceId: sender.id,
|
|
peerFingerprint: req.fingerprint,
|
|
pairedAt: Date.now(),
|
|
isTrusted: true
|
|
};
|
|
this._pairingRecords[pairKey2] = {
|
|
deviceId: sender.id,
|
|
peerDeviceId: req.fromId,
|
|
peerFingerprint: req.fingerprint,
|
|
pairedAt: Date.now(),
|
|
isTrusted: true
|
|
};
|
|
this._savePairingRecords();
|
|
|
|
const fromPeer = this._findPeer(req.fromId);
|
|
if (fromPeer) {
|
|
this._send(fromPeer, {
|
|
type: 'pair-response',
|
|
from: sender.fingerprint || sender.id,
|
|
success: true,
|
|
requestId: requestId
|
|
});
|
|
}
|
|
|
|
this._send(sender, {
|
|
type: 'pair-response',
|
|
from: fromPeer ? (fromPeer.fingerprint || fromPeer.id) : req.fromId,
|
|
success: true,
|
|
requestId: requestId
|
|
});
|
|
}
|
|
|
|
_handlePairReject(sender, message) {
|
|
const requestId = message.requestId || message.payload?.requestId;
|
|
if (!requestId || !this._pendingPairRequests[requestId]) return;
|
|
|
|
const req = this._pendingPairRequests[requestId];
|
|
delete this._pendingPairRequests[requestId];
|
|
|
|
const fromPeer = this._findPeer(req.fromId);
|
|
if (fromPeer) {
|
|
this._send(fromPeer, {
|
|
type: 'pair-response',
|
|
from: sender.fingerprint || sender.id,
|
|
success: false,
|
|
requestId: requestId,
|
|
error: 'Rejected'
|
|
});
|
|
}
|
|
}
|
|
|
|
_findPeer(peerId) {
|
|
for (const roomId in this._rooms) {
|
|
if (this._rooms[roomId][peerId]) {
|
|
return this._rooms[roomId][peerId];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
_resolveTarget(sender, targetId) {
|
|
if (this._rooms[sender.ip] && this._rooms[sender.ip][targetId]) {
|
|
return this._rooms[sender.ip][targetId];
|
|
}
|
|
|
|
let recipient = this._findPeer(targetId);
|
|
if (recipient) return recipient;
|
|
|
|
if (this._fingerprintIndex[targetId]) {
|
|
const mappedPeerId = this._fingerprintIndex[targetId];
|
|
console.log('Fingerprint mapping:', targetId, '->', mappedPeerId);
|
|
recipient = this._findPeer(mappedPeerId);
|
|
if (recipient) return recipient;
|
|
}
|
|
|
|
if (sender.userId) {
|
|
const sameUserPeerIds = this._userIdIndex[sender.userId];
|
|
if (sameUserPeerIds) {
|
|
for (const peerId of sameUserPeerIds) {
|
|
if (peerId !== sender.id) {
|
|
recipient = this._findPeer(peerId);
|
|
if (recipient) return recipient;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this._userIdIndex[targetId]) {
|
|
for (const peerId of this._userIdIndex[targetId]) {
|
|
recipient = this._findPeer(peerId);
|
|
if (recipient) return recipient;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
_handleTextMessage(sender, message) {
|
|
const targetId = message.to;
|
|
if (!targetId) return;
|
|
|
|
const recipient = this._resolveTarget(sender, targetId);
|
|
if (recipient) {
|
|
this._send(recipient, {
|
|
type: 'textMessage',
|
|
from: sender.fingerprint || sender.id,
|
|
fromPeerId: sender.id,
|
|
to: recipient.id,
|
|
message: message.message || message.payload || {},
|
|
timestamp: message.timestamp || Date.now()
|
|
});
|
|
} else {
|
|
console.log('textMessage routing failed: target not found for', targetId, 'from', sender.id);
|
|
}
|
|
}
|
|
|
|
_handleFileMeta(sender, message) {
|
|
const targetId = message.to;
|
|
if (!targetId) return;
|
|
|
|
const recipient = this._resolveTarget(sender, targetId);
|
|
if (recipient) {
|
|
this._send(recipient, {
|
|
type: 'fileMeta',
|
|
from: sender.fingerprint || sender.id,
|
|
fromPeerId: sender.id,
|
|
to: recipient.id,
|
|
file: message.file || message.payload || {},
|
|
timestamp: message.timestamp || Date.now()
|
|
});
|
|
} else {
|
|
console.log('fileMeta routing failed: target not found for', targetId, 'from', sender.id);
|
|
}
|
|
}
|
|
|
|
_handleWsRelay(sender, message) {
|
|
const targetId = message.to;
|
|
if (!targetId) return;
|
|
|
|
let targetPeer = null;
|
|
for (const roomId in this._rooms) {
|
|
if (this._rooms[roomId][targetId]) {
|
|
targetPeer = this._rooms[roomId][targetId];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!targetPeer && this._fingerprintIndex[targetId]) {
|
|
const mappedPeerId = this._fingerprintIndex[targetId];
|
|
for (const roomId in this._rooms) {
|
|
if (this._rooms[roomId][mappedPeerId]) {
|
|
targetPeer = this._rooms[roomId][mappedPeerId];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!targetPeer && sender.userId) {
|
|
const sameUserPeerIds = this._userIdIndex[sender.userId];
|
|
if (sameUserPeerIds) {
|
|
for (const peerId of sameUserPeerIds) {
|
|
if (peerId !== sender.id) {
|
|
for (const roomId in this._rooms) {
|
|
if (this._rooms[roomId][peerId]) {
|
|
targetPeer = this._rooms[roomId][peerId];
|
|
break;
|
|
}
|
|
}
|
|
if (targetPeer) break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!targetPeer) {
|
|
this._send(sender, {
|
|
type: 'wsRelay',
|
|
from: sender.fingerprint || sender.id,
|
|
payload: {
|
|
relayType: 'error',
|
|
data: { error: 'Target peer not found', targetId: targetId }
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
const relayType = message.relayType || (message.payload && message.payload.relayType) || 'data';
|
|
const payload = message.payload || {};
|
|
if (!payload.relayType) {
|
|
payload.relayType = relayType;
|
|
}
|
|
|
|
this._send(targetPeer, {
|
|
type: 'wsRelay',
|
|
from: sender.fingerprint || sender.id,
|
|
payload: payload
|
|
});
|
|
}
|
|
|
|
_handleCanvasJoin(sender, message) {
|
|
const payload = message.payload || {};
|
|
const canvasId = payload.canvasId;
|
|
if (!canvasId) return;
|
|
|
|
if (!this._canvasRooms[canvasId]) {
|
|
this._canvasRooms[canvasId] = new Set();
|
|
}
|
|
this._canvasRooms[canvasId].add(sender.id);
|
|
sender.canvasRoomId = canvasId;
|
|
|
|
const members = Array.from(this._canvasRooms[canvasId]);
|
|
for (const peerId of this._canvasRooms[canvasId]) {
|
|
if (peerId === sender.id) continue;
|
|
const peer = this._findPeer(peerId);
|
|
if (peer) {
|
|
this._send(peer, {
|
|
type: 'canvas-join',
|
|
from: sender.fingerprint || sender.id,
|
|
payload: { canvasId: canvasId, members: members }
|
|
});
|
|
}
|
|
}
|
|
|
|
this._send(sender, {
|
|
type: 'canvas-join',
|
|
from: 'server',
|
|
payload: { canvasId: canvasId, members: members }
|
|
});
|
|
console.log('Canvas join:', sender.id, 'joined canvas', canvasId, 'members:', members.length);
|
|
}
|
|
|
|
_handleCanvasLeave(sender, message) {
|
|
const payload = message.payload || {};
|
|
const canvasId = payload.canvasId || sender.canvasRoomId;
|
|
if (!canvasId || !this._canvasRooms[canvasId]) return;
|
|
|
|
this._canvasRooms[canvasId].delete(sender.id);
|
|
sender.canvasRoomId = null;
|
|
|
|
for (const peerId of this._canvasRooms[canvasId]) {
|
|
const peer = this._findPeer(peerId);
|
|
if (peer) {
|
|
this._send(peer, {
|
|
type: 'canvas-leave',
|
|
from: sender.fingerprint || sender.id,
|
|
payload: { canvasId: canvasId }
|
|
});
|
|
}
|
|
}
|
|
|
|
if (this._canvasRooms[canvasId].size === 0) {
|
|
delete this._canvasRooms[canvasId];
|
|
}
|
|
console.log('Canvas leave:', sender.id, 'left canvas', canvasId);
|
|
}
|
|
|
|
_handleCanvasStroke(sender, message) {
|
|
const payload = message.payload || {};
|
|
const canvasId = payload.canvasId || sender.canvasRoomId;
|
|
if (!canvasId || !this._canvasRooms[canvasId]) return;
|
|
|
|
for (const peerId of this._canvasRooms[canvasId]) {
|
|
if (peerId === sender.id) continue;
|
|
const peer = this._findPeer(peerId);
|
|
if (peer) {
|
|
this._send(peer, {
|
|
type: 'canvas-stroke',
|
|
from: sender.fingerprint || sender.id,
|
|
payload: payload
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
_handleCanvasCursor(sender, message) {
|
|
const payload = message.payload || {};
|
|
const canvasId = payload.canvasId || sender.canvasRoomId;
|
|
if (!canvasId || !this._canvasRooms[canvasId]) return;
|
|
|
|
for (const peerId of this._canvasRooms[canvasId]) {
|
|
if (peerId === sender.id) continue;
|
|
const peer = this._findPeer(peerId);
|
|
if (peer) {
|
|
this._send(peer, {
|
|
type: 'canvas-cursor',
|
|
from: sender.fingerprint || sender.id,
|
|
payload: payload
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
_handleCanvasSnapshot(sender, message) {
|
|
const payload = message.payload || {};
|
|
const canvasId = payload.canvasId || sender.canvasRoomId;
|
|
const action = payload.action || 'request';
|
|
if (!canvasId || !this._canvasRooms[canvasId]) return;
|
|
|
|
if (action === 'request') {
|
|
const members = Array.from(this._canvasRooms[canvasId]);
|
|
const otherMember = members.find(id => id !== sender.id);
|
|
if (otherMember) {
|
|
const peer = this._findPeer(otherMember);
|
|
if (peer) {
|
|
this._send(peer, {
|
|
type: 'canvas-snapshot',
|
|
from: sender.fingerprint || sender.id,
|
|
payload: { canvasId: canvasId, action: 'request' }
|
|
});
|
|
}
|
|
}
|
|
} else if (action === 'response') {
|
|
const targetId = message.to;
|
|
if (targetId) {
|
|
const targetPeer = this._findPeer(targetId);
|
|
if (targetPeer) {
|
|
this._send(targetPeer, {
|
|
type: 'canvas-snapshot',
|
|
from: sender.fingerprint || sender.id,
|
|
payload: payload
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_handlePairingCodeCreate(sender, message) {
|
|
const payload = message.payload || message.data || {};
|
|
let code = '';
|
|
for (let i = 0; i < 4; i++) {
|
|
code += Math.floor(Math.random() * 10).toString();
|
|
}
|
|
for (const existingCode in this._pairingCodes) {
|
|
if (this._pairingCodes[existingCode].creatorId === sender.id) {
|
|
delete this._pairingCodes[existingCode];
|
|
}
|
|
}
|
|
this._pairingCodes[code] = {
|
|
creatorId: sender.id,
|
|
creatorFingerprint: sender.fingerprint || '',
|
|
creatorAlias: payload.alias || sender.name.displayName,
|
|
creatorDeviceModel: payload.deviceModel || sender.deviceModel || '',
|
|
creatorDeviceType: payload.deviceType || sender.name.type || '',
|
|
createdAt: Date.now(),
|
|
expiresAt: Date.now() + 300000,
|
|
used: false
|
|
};
|
|
this._send(sender, {
|
|
type: 'pairing-code-created',
|
|
pairingCode: code,
|
|
expiresAt: this._pairingCodes[code].expiresAt
|
|
});
|
|
console.log('Pairing code created:', code, 'by', sender.id, '(' + (sender.fingerprint || '') + ')');
|
|
}
|
|
|
|
_handlePairingCodeJoin(sender, message) {
|
|
const payload = message.payload || message.data || {};
|
|
const code = (payload.pairingCode || '').trim();
|
|
if (!code || !/^\d{4}$/.test(code)) {
|
|
this._send(sender, { type: 'pairing-matched', success: false, error: '请输入4位数字配对码' });
|
|
return;
|
|
}
|
|
const entry = this._pairingCodes[code];
|
|
if (!entry) {
|
|
this._send(sender, { type: 'pairing-matched', success: false, error: '配对码不存在或已过期' });
|
|
return;
|
|
}
|
|
if (Date.now() > entry.expiresAt) {
|
|
delete this._pairingCodes[code];
|
|
this._send(sender, { type: 'pairing-matched', success: false, error: '配对码已过期' });
|
|
return;
|
|
}
|
|
if (entry.used) {
|
|
this._send(sender, { type: 'pairing-matched', success: false, error: '配对码已被使用' });
|
|
return;
|
|
}
|
|
if (entry.creatorId === sender.id) {
|
|
this._send(sender, { type: 'pairing-matched', success: false, error: '不能使用自己创建的配对码' });
|
|
return;
|
|
}
|
|
entry.used = true;
|
|
const creatorPeer = this._findPeer(entry.creatorId);
|
|
this._send(sender, {
|
|
type: 'pairing-matched',
|
|
success: true,
|
|
pairingCode: code,
|
|
peer: {
|
|
id: entry.creatorId,
|
|
fingerprint: entry.creatorFingerprint,
|
|
alias: entry.creatorAlias,
|
|
deviceModel: entry.creatorDeviceModel,
|
|
deviceType: entry.creatorDeviceType
|
|
}
|
|
});
|
|
if (creatorPeer) {
|
|
this._send(creatorPeer, {
|
|
type: 'pairing-matched',
|
|
success: true,
|
|
pairingCode: code,
|
|
peer: {
|
|
id: sender.id,
|
|
fingerprint: sender.fingerprint || '',
|
|
alias: sender.name.displayName,
|
|
deviceModel: sender.deviceModel || '',
|
|
deviceType: sender.name.type || ''
|
|
}
|
|
});
|
|
}
|
|
delete this._pairingCodes[code];
|
|
console.log('Pairing matched via code:', code, 'creator:', entry.creatorId, 'joiner:', sender.id);
|
|
}
|
|
|
|
_handleRadarBroadcast(sender, message) {
|
|
const payload = message.payload || message.data || {};
|
|
this._radarPool[sender.id] = {
|
|
deviceId: sender.id,
|
|
fingerprint: sender.fingerprint || '',
|
|
alias: payload.alias || sender.name.displayName,
|
|
deviceModel: payload.deviceModel || sender.deviceModel || '',
|
|
deviceType: payload.deviceType || sender.name.type || '',
|
|
ipRange: payload.ipRange || sender.ipRange || '',
|
|
ipCity: payload.ipCity || sender.ipCity || '',
|
|
ip: sender.ip,
|
|
userId: sender.userId || '',
|
|
lastBroadcast: Date.now()
|
|
};
|
|
this._send(sender, { type: 'radar-broadcast', success: true });
|
|
}
|
|
|
|
_handleRadarScan(sender, message) {
|
|
const payload = message.payload || message.data || {};
|
|
const myIpRange = payload.ipRange || sender.ipRange || '';
|
|
const myIpCity = payload.ipCity || sender.ipCity || '';
|
|
const devices = [];
|
|
for (const deviceId in this._radarPool) {
|
|
const entry = this._radarPool[deviceId];
|
|
if (deviceId === sender.id) continue;
|
|
if (Date.now() - entry.lastBroadcast > 30000) continue;
|
|
let matchType = 'remote';
|
|
if (sender.userId && entry.userId && sender.userId === entry.userId) {
|
|
matchType = 'my-device';
|
|
} else if (myIpRange && entry.ipRange && myIpRange === entry.ipRange) {
|
|
matchType = 'same-network';
|
|
} else if (myIpCity && entry.ipCity && myIpCity === entry.ipCity) {
|
|
matchType = 'same-city';
|
|
}
|
|
devices.push({
|
|
id: entry.deviceId,
|
|
fingerprint: entry.fingerprint,
|
|
alias: entry.alias,
|
|
deviceModel: entry.deviceModel,
|
|
deviceType: entry.deviceType,
|
|
ipCity: entry.ipCity,
|
|
matchType: matchType
|
|
});
|
|
}
|
|
devices.sort((a, b) => {
|
|
const order = { 'my-device': 0, 'same-network': 1, 'same-city': 2, 'remote': 3 };
|
|
return (order[a.matchType] || 4) - (order[b.matchType] || 4);
|
|
});
|
|
this._send(sender, { type: 'radar-devices', devices: devices });
|
|
}
|
|
|
|
_handleScreenShareRequest(sender, message) {
|
|
const targetId = message.to;
|
|
if (!targetId) return;
|
|
const targetPeer = this._resolveTarget(sender, targetId);
|
|
if (!targetPeer) {
|
|
this._send(sender, {
|
|
type: 'screen-share-response',
|
|
from: targetId,
|
|
success: false,
|
|
error: 'Target device not found or offline'
|
|
});
|
|
return;
|
|
}
|
|
const payload = message.payload || message.data || {};
|
|
const direction = payload.direction || 'view';
|
|
this._send(targetPeer, {
|
|
type: 'screen-share-request',
|
|
from: sender.fingerprint || sender.id,
|
|
fromPeerId: sender.id,
|
|
direction: direction,
|
|
requestId: Date.now().toString(36) + Math.random().toString(36).substr(2),
|
|
timestamp: Date.now()
|
|
});
|
|
console.log('Screen share request:', sender.id, '->', targetId, 'direction:', direction);
|
|
}
|
|
|
|
_handleScreenShareAccept(sender, message) {
|
|
const targetId = message.to;
|
|
if (!targetId) return;
|
|
const targetPeer = this._resolveTarget(sender, targetId);
|
|
if (!targetPeer) {
|
|
this._send(sender, { type: 'screen-share-response', success: false, error: 'Target device not found' });
|
|
return;
|
|
}
|
|
const payload = message.payload || message.data || {};
|
|
const shareKey = [sender.id, targetId].sort().join(':');
|
|
this._activeScreenShares[shareKey] = {
|
|
sharer: payload.direction === 'share' ? sender.id : targetId,
|
|
viewer: payload.direction === 'share' ? targetId : sender.id,
|
|
direction: payload.direction || 'view',
|
|
startedAt: Date.now()
|
|
};
|
|
this._send(targetPeer, {
|
|
type: 'screen-share-accept',
|
|
from: sender.fingerprint || sender.id,
|
|
fromPeerId: sender.id,
|
|
direction: payload.direction || 'view',
|
|
timestamp: Date.now()
|
|
});
|
|
console.log('Screen share accepted:', sender.id, '<->', targetId);
|
|
}
|
|
|
|
_handleScreenShareReject(sender, message) {
|
|
const targetId = message.to;
|
|
if (!targetId) return;
|
|
const targetPeer = this._resolveTarget(sender, targetId);
|
|
if (targetPeer) {
|
|
this._send(targetPeer, {
|
|
type: 'screen-share-reject',
|
|
from: sender.fingerprint || sender.id,
|
|
fromPeerId: sender.id,
|
|
timestamp: Date.now()
|
|
});
|
|
}
|
|
console.log('Screen share rejected:', sender.id, 'rejected request from', targetId);
|
|
}
|
|
|
|
_handleScreenShareStop(sender, message) {
|
|
const targetId = message.to;
|
|
if (!targetId) return;
|
|
const shareKey = [sender.id, targetId].sort().join(':');
|
|
delete this._activeScreenShares[shareKey];
|
|
const targetPeer = this._resolveTarget(sender, targetId);
|
|
if (targetPeer) {
|
|
this._send(targetPeer, {
|
|
type: 'screen-share-stop',
|
|
from: sender.fingerprint || sender.id,
|
|
fromPeerId: sender.id,
|
|
timestamp: Date.now()
|
|
});
|
|
}
|
|
console.log('Screen share stopped:', sender.id, '<->', targetId);
|
|
}
|
|
|
|
_joinRoom(peer) {
|
|
if (!this._rooms[peer.ip]) {
|
|
this._rooms[peer.ip] = {};
|
|
}
|
|
|
|
for (const otherPeerId in this._rooms[peer.ip]) {
|
|
const otherPeer = this._rooms[peer.ip][otherPeerId];
|
|
this._send(otherPeer, {
|
|
type: 'peer-joined',
|
|
peer: peer.getInfo()
|
|
});
|
|
}
|
|
|
|
const otherPeers = [];
|
|
for (const otherPeerId in this._rooms[peer.ip]) {
|
|
otherPeers.push(this._rooms[peer.ip][otherPeerId].getInfo());
|
|
}
|
|
|
|
this._send(peer, {
|
|
type: 'peers',
|
|
peers: otherPeers
|
|
});
|
|
|
|
this._rooms[peer.ip][peer.id] = peer;
|
|
|
|
if (peer.userId) {
|
|
if (!this._userIdIndex[peer.userId]) {
|
|
this._userIdIndex[peer.userId] = new Set();
|
|
}
|
|
this._userIdIndex[peer.userId].add(peer.id);
|
|
}
|
|
}
|
|
|
|
_leaveRoom(peer) {
|
|
if (!this._rooms[peer.ip] || !this._rooms[peer.ip][peer.id]) return;
|
|
this._cancelKeepAlive(this._rooms[peer.ip][peer.id]);
|
|
|
|
if (peer.canvasRoomId && this._canvasRooms[peer.canvasRoomId]) {
|
|
this._canvasRooms[peer.canvasRoomId].delete(peer.id);
|
|
for (const peerId of this._canvasRooms[peer.canvasRoomId]) {
|
|
const otherPeer = this._findPeer(peerId);
|
|
if (otherPeer) {
|
|
this._send(otherPeer, {
|
|
type: 'canvas-leave',
|
|
from: peer.fingerprint || peer.id,
|
|
payload: { canvasId: peer.canvasRoomId }
|
|
});
|
|
}
|
|
}
|
|
if (this._canvasRooms[peer.canvasRoomId].size === 0) {
|
|
delete this._canvasRooms[peer.canvasRoomId];
|
|
}
|
|
peer.canvasRoomId = null;
|
|
}
|
|
|
|
if (peer.fingerprint && this._fingerprintIndex[peer.fingerprint] === peer.id) {
|
|
delete this._fingerprintIndex[peer.fingerprint];
|
|
}
|
|
|
|
delete this._rooms[peer.ip][peer.id];
|
|
|
|
if (peer.userId && this._userIdIndex[peer.userId]) {
|
|
this._userIdIndex[peer.userId].delete(peer.id);
|
|
if (this._userIdIndex[peer.userId].size === 0) {
|
|
delete this._userIdIndex[peer.userId];
|
|
}
|
|
}
|
|
|
|
if (this._radarPool[peer.id]) {
|
|
delete this._radarPool[peer.id];
|
|
}
|
|
|
|
for (const shareKey in this._activeScreenShares) {
|
|
const share = this._activeScreenShares[shareKey];
|
|
if (share.sharer === peer.id || share.viewer === peer.id) {
|
|
const otherPeerId = share.sharer === peer.id ? share.viewer : share.sharer;
|
|
const otherPeer = this._findPeer(otherPeerId);
|
|
if (otherPeer) {
|
|
this._send(otherPeer, {
|
|
type: 'screen-share-stop',
|
|
from: peer.fingerprint || peer.id,
|
|
fromPeerId: peer.id,
|
|
reason: 'peer-disconnected',
|
|
timestamp: Date.now()
|
|
});
|
|
}
|
|
delete this._activeScreenShares[shareKey];
|
|
}
|
|
}
|
|
|
|
for (const code in this._pairingCodes) {
|
|
if (this._pairingCodes[code].creatorId === peer.id) {
|
|
delete this._pairingCodes[code];
|
|
}
|
|
}
|
|
|
|
peer.socket.terminate();
|
|
if (!Object.keys(this._rooms[peer.ip]).length) {
|
|
delete this._rooms[peer.ip];
|
|
} else {
|
|
for (const otherPeerId in this._rooms[peer.ip]) {
|
|
const otherPeer = this._rooms[peer.ip][otherPeerId];
|
|
this._send(otherPeer, { type: 'peer-left', peerId: peer.id });
|
|
}
|
|
}
|
|
}
|
|
|
|
_send(peer, message) {
|
|
if (!peer) return;
|
|
if (this._wss.readyState !== this._wss.OPEN) return;
|
|
message = JSON.stringify(message);
|
|
peer.socket.send(message, error => '');
|
|
}
|
|
|
|
_keepAlive(peer) {
|
|
this._cancelKeepAlive(peer);
|
|
var timeout = 30000;
|
|
if (!peer.lastBeat) {
|
|
peer.lastBeat = Date.now();
|
|
}
|
|
if (Date.now() - peer.lastBeat > 2 * timeout) {
|
|
this._leaveRoom(peer);
|
|
return;
|
|
}
|
|
|
|
this._send(peer, { type: 'ping' });
|
|
|
|
peer.timerId = setTimeout(() => this._keepAlive(peer), timeout);
|
|
}
|
|
|
|
_cancelKeepAlive(peer) {
|
|
if (peer && peer.timerId) {
|
|
clearTimeout(peer.timerId);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
class Peer {
|
|
|
|
constructor(socket, request) {
|
|
this.socket = socket;
|
|
|
|
this._setIP(request);
|
|
this._setPeerId(request);
|
|
this.rtcSupported = request.url.indexOf('webrtc') > -1;
|
|
this._setName(request);
|
|
|
|
this.userId = '';
|
|
this.fingerprint = '';
|
|
this.ipCity = '';
|
|
this.ipRange = '';
|
|
this.deviceModel = '';
|
|
this.platform = '';
|
|
this.canvasRoomId = null;
|
|
|
|
this.timerId = 0;
|
|
this.lastBeat = Date.now();
|
|
}
|
|
|
|
_setIP(request) {
|
|
if (request.headers['x-forwarded-for']) {
|
|
this.ip = request.headers['x-forwarded-for'].split(/\s*,\s*/)[0];
|
|
} else {
|
|
this.ip = request.connection.remoteAddress;
|
|
}
|
|
if (this.ip == '::1' || this.ip == '::ffff:127.0.0.1') {
|
|
this.ip = '127.0.0.1';
|
|
}
|
|
}
|
|
|
|
_setPeerId(request) {
|
|
if (request.peerId) {
|
|
this.id = request.peerId;
|
|
} else {
|
|
this.id = request.headers.cookie.replace('peerid=', '');
|
|
}
|
|
}
|
|
|
|
toString() {
|
|
return `<Peer id=${this.id} ip=${this.ip} userId=${this.userId} rtcSupported=${this.rtcSupported}>`
|
|
}
|
|
|
|
_setName(req) {
|
|
let ua = parser(req.headers['user-agent']);
|
|
|
|
let deviceName = '';
|
|
|
|
if (ua.os && ua.os.name) {
|
|
deviceName = ua.os.name.replace('Mac OS', 'Mac') + ' ';
|
|
}
|
|
|
|
if (ua.device.model) {
|
|
deviceName += ua.device.model;
|
|
} else {
|
|
deviceName += ua.browser.name;
|
|
}
|
|
|
|
if(!deviceName)
|
|
deviceName = 'Unknown Device';
|
|
|
|
const displayName = uniqueNamesGenerator({
|
|
length: 2,
|
|
separator: ' ',
|
|
dictionaries: [colors, animals],
|
|
style: 'capital',
|
|
seed: this.id.hashCode()
|
|
})
|
|
|
|
this.name = {
|
|
model: ua.device.model,
|
|
os: ua.os.name,
|
|
browser: ua.browser.name,
|
|
type: ua.device.type,
|
|
deviceName,
|
|
displayName
|
|
};
|
|
}
|
|
|
|
getInfo() {
|
|
return {
|
|
id: this.id,
|
|
name: this.name,
|
|
rtcSupported: this.rtcSupported,
|
|
userId: this.userId,
|
|
ipCity: this.ipCity,
|
|
deviceModel: this.deviceModel,
|
|
platform: this.platform
|
|
}
|
|
}
|
|
|
|
static uuid() {
|
|
let uuid = '',
|
|
ii;
|
|
for (ii = 0; ii < 32; ii += 1) {
|
|
switch (ii) {
|
|
case 8:
|
|
case 20:
|
|
uuid += '-';
|
|
uuid += (Math.random() * 16 | 0).toString(16);
|
|
break;
|
|
case 12:
|
|
uuid += '-';
|
|
uuid += '4';
|
|
break;
|
|
case 16:
|
|
uuid += '-';
|
|
uuid += (Math.random() * 4 | 8).toString(16);
|
|
break;
|
|
default:
|
|
uuid += (Math.random() * 16 | 0).toString(16);
|
|
}
|
|
}
|
|
return uuid;
|
|
};
|
|
}
|
|
|
|
Object.defineProperty(String.prototype, 'hashCode', {
|
|
value: function() {
|
|
var hash = 0, i, chr;
|
|
for (i = 0; i < this.length; i++) {
|
|
chr = this.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + chr;
|
|
hash |= 0;
|
|
}
|
|
return hash;
|
|
}
|
|
});
|
|
|
|
const server = new SnapdropServer(process.env.PORT || 3000);
|