feat: 5.4.0版本大更新,新增多端桌面小组件与多项功能优化

- 重构「灵感」模块为「发现」模块,统一页面命名与文案
- 新增flutter_tts语音朗读依赖与鸿蒙Nearby配对方式
- 添加Android/iOS/鸿蒙全平台桌面小组件支持(7种类型)
- 完善文件传输模块,新增画布邀请消息与删除会话功能
- 优化协作画布光标广播节流逻辑,修复已知bug
- 更新应用英文名与隐私政策入口,新增翻译API抽象层
- 移除用户中心多余的加号按钮,完善空状态组件类型
This commit is contained in:
Developer
2026-05-19 05:39:50 +08:00
parent a60957cc0e
commit 6f5400ec4b
232 changed files with 43654 additions and 8566 deletions

View File

@@ -30,8 +30,48 @@ class SnapdropServer {
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);
}
@@ -158,9 +198,33 @@ class SnapdropServer {
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'];
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);
@@ -695,6 +759,228 @@ class SnapdropServer {
}
}
_handlePairingCodeCreate(sender, message) {
const payload = message.payload || message.data || {};
const CHARS = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
let code = '';
for (let i = 0; i < 6; i++) {
code += CHARS.charAt(Math.floor(Math.random() * CHARS.length));
}
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 || '').toUpperCase().trim();
if (!code) {
this._send(sender, { type: 'pairing-matched', success: false, error: '配对码不能为空' });
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] = {};
@@ -763,6 +1049,34 @@ class SnapdropServer {
}
}
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];