文件传输1

This commit is contained in:
celebrateyang 2025-06-03 15:32:00 +08:00
parent cddf6867de
commit 7b83692845
16 changed files with 2548 additions and 13 deletions

View File

@ -39,6 +39,7 @@
"set-cookie-parser": "2.6.0",
"undici": "^5.19.1",
"url-pattern": "1.0.3",
"ws": "^8.18.2",
"youtubei.js": "^13.4.0",
"zod": "^3.23.8"
},

View File

@ -19,6 +19,7 @@ import { friendlyServiceName } from "../processing/service-alias.js";
import { verifyStream } from "../stream/manage.js";
import { createResponse, normalizeRequest, getIP } from "../processing/request.js";
import { setupTunnelHandler } from "./itunnel.js";
import { setupSignalingServer } from "./signaling.js";
import * as APIKeys from "../security/api-keys.js";
import * as Cookies from "../processing/cookie/manager.js";
@ -309,8 +310,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
streamInfo.range = req.headers['range'];
}
return stream(res, streamInfo);
});
return stream(res, streamInfo); });
app.get('/', (_, res) => {
res.type('json');
@ -337,7 +337,12 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
setGlobalDispatcher(new ProxyAgent(env.externalProxy))
}
http.createServer(app).listen({
const server = http.createServer(app);
// 设置WebSocket信令服务器
setupSignalingServer(server);
server.listen({
port: env.apiPort,
host: env.listenAddress,
reusePort: env.instanceCount > 1 || undefined

180
api/src/core/signaling.js Normal file
View File

@ -0,0 +1,180 @@
import { WebSocketServer } from 'ws';
import { Green } from '../misc/console-text.js';
export const setupSignalingServer = (httpServer) => {
const wss = new WebSocketServer({
server: httpServer,
path: '/ws'
});
const sessions = new Map(); // sessionId -> { creator, joiner, createdAt }
// 清理过期会话
setInterval(() => {
const now = Date.now();
for (const [sessionId, session] of sessions.entries()) {
if (now - session.createdAt > 30 * 60 * 1000) { // 30分钟过期
sessions.delete(sessionId);
console.log(`清理过期会话: ${sessionId}`);
}
}
}, 5 * 60 * 1000); // 每5分钟检查一次
wss.on('connection', (ws, req) => {
console.log('WebSocket连接建立:', req.socket.remoteAddress);
let sessionId = null;
let userRole = null; // 'creator' | 'joiner'
ws.on('message', (data) => {
try {
const message = JSON.parse(data);
switch (message.type) {
case 'create_session':
handleCreateSession(ws, message);
break;
case 'join_session':
handleJoinSession(ws, message);
break;
case 'offer':
case 'answer':
case 'ice_candidate':
handleSignaling(ws, message, sessionId, userRole);
break;
case 'disconnect':
handleDisconnect(sessionId, userRole);
break;
default:
ws.send(JSON.stringify({
type: 'error',
message: '未知消息类型'
}));
}
} catch (error) {
console.error('WebSocket消息处理错误:', error);
ws.send(JSON.stringify({
type: 'error',
message: '消息格式错误'
}));
}
});
ws.on('close', () => {
console.log('WebSocket连接关闭');
if (sessionId && userRole) {
handleDisconnect(sessionId, userRole);
}
});
ws.on('error', (error) => {
console.error('WebSocket错误:', error);
});
function handleCreateSession(ws, message) {
sessionId = Math.random().toString(36).substring(2, 10);
userRole = 'creator';
sessions.set(sessionId, {
creator: { ws, publicKey: message.publicKey },
joiner: null,
createdAt: Date.now()
});
ws.send(JSON.stringify({
type: 'session_created',
sessionId: sessionId
}));
console.log(`会话创建: ${sessionId}`);
}
function handleJoinSession(ws, message) {
const session = sessions.get(message.sessionId);
if (!session) {
ws.send(JSON.stringify({
type: 'error',
message: '会话不存在或已过期'
}));
return;
}
if (session.joiner) {
ws.send(JSON.stringify({
type: 'error',
message: '会话已满'
}));
return;
}
sessionId = message.sessionId;
userRole = 'joiner';
session.joiner = { ws, publicKey: message.publicKey };
// 通知创建者有人加入,并交换公钥
if (session.creator && session.creator.ws.readyState === ws.OPEN) {
session.creator.ws.send(JSON.stringify({
type: 'peer_joined',
publicKey: message.publicKey
}));
}
// 回复加入者创建者的公钥
ws.send(JSON.stringify({
type: 'session_joined',
publicKey: session.creator.publicKey
}));
console.log(`用户加入会话: ${sessionId}`);
}
function handleSignaling(ws, message, sessionId, userRole) {
if (!sessionId || !userRole) {
ws.send(JSON.stringify({
type: 'error',
message: '未加入会话'
}));
return;
}
const session = sessions.get(sessionId);
if (!session) {
ws.send(JSON.stringify({
type: 'error',
message: '会话不存在'
}));
return;
}
// 转发信令消息给对端
const peer = userRole === 'creator' ? session.joiner : session.creator;
if (peer && peer.ws.readyState === ws.OPEN) {
peer.ws.send(JSON.stringify(message));
}
}
function handleDisconnect(sessionId, userRole) {
if (!sessionId) return;
const session = sessions.get(sessionId);
if (!session) return;
// 通知对端连接断开
const peer = userRole === 'creator' ? session.joiner : session.creator;
if (peer && peer.ws.readyState === ws.OPEN) {
peer.ws.send(JSON.stringify({
type: 'peer_disconnected'
}));
}
// 清理会话
sessions.delete(sessionId);
console.log(`会话结束: ${sessionId}`);
}
});
console.log(`${Green('[✓]')} WebSocket信令服务器启动成功`);
return wss;
};

View File

@ -58,6 +58,9 @@ importers:
url-pattern:
specifier: 1.0.3
version: 1.0.3
ws:
specifier: ^8.18.2
version: 8.18.2
youtubei.js:
specifier: ^13.4.0
version: 13.4.0
@ -90,6 +93,10 @@ importers:
packages/version-info: {}
web:
dependencies:
qrcode:
specifier: ^1.5.4
version: 1.5.4
devDependencies:
'@eslint/js':
specifier: ^9.5.0
@ -130,6 +137,9 @@ importers:
'@types/node':
specifier: ^20.14.10
version: 20.14.14
'@types/qrcode':
specifier: ^1.5.5
version: 1.5.5
'@vitejs/plugin-basic-ssl':
specifier: ^1.1.0
version: 1.1.0(vite@5.3.5(@types/node@20.14.14))
@ -759,6 +769,9 @@ packages:
'@types/pug@2.0.10':
resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==}
'@types/qrcode@1.5.5':
resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==}
'@types/unist@2.0.10':
resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==}
@ -940,6 +953,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caseless@0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
@ -951,6 +968,9 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cluster-key-slot@1.1.2:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
@ -1034,6 +1054,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@ -1064,6 +1088,9 @@ packages:
devalue@5.0.0:
resolution: {integrity: sha512-gO+/OMXF7488D+u3ue+G7Y4AA3ZmUnB3eHJXmBTgNHvr4ZNzl36A0ZtG+XCRNYCkYx/bFmw4qtkoFLa+wSrwAA==}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@ -1139,6 +1166,7 @@ packages:
eslint@8.57.0:
resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
esm-env@1.0.0:
@ -1217,6 +1245,10 @@ packages:
resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==}
engines: {node: '>= 0.8'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@ -1258,6 +1290,10 @@ packages:
resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==}
engines: {node: '>= 4'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.2.4:
resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==}
engines: {node: '>= 0.4'}
@ -1462,6 +1498,10 @@ packages:
locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@ -1620,14 +1660,26 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-json-from-dist@1.0.0:
resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==}
@ -1679,6 +1731,10 @@ packages:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
postcss-load-config@6.0.1:
resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
engines: {node: '>= 18'}
@ -1729,6 +1785,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.13.0:
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
engines: {node: '>=0.6'}
@ -1761,6 +1822,13 @@ packages:
redis@4.7.0:
resolution: {integrity: sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -1817,6 +1885,9 @@ packages:
resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==}
engines: {node: '>= 0.8.0'}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-cookie-parser@2.6.0:
resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==}
@ -2185,6 +2256,9 @@ packages:
whatwg-url@7.1.0:
resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@ -2194,6 +2268,10 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@ -2205,9 +2283,32 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.18.2:
resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@ -2640,6 +2741,10 @@ snapshots:
'@types/pug@2.0.10': {}
'@types/qrcode@1.5.5':
dependencies:
'@types/node': 20.14.14
'@types/unist@2.0.10': {}
'@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)':
@ -2839,6 +2944,8 @@ snapshots:
callsites@3.1.0: {}
camelcase@5.3.1: {}
caseless@0.12.0: {}
chalk@4.1.2:
@ -2858,6 +2965,12 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
cluster-key-slot@1.1.2:
optional: true
@ -2928,6 +3041,8 @@ snapshots:
dependencies:
ms: 2.1.2
decamelize@1.2.0: {}
deep-is@0.1.4: {}
deepmerge@4.3.1: {}
@ -2948,6 +3063,8 @@ snapshots:
devalue@5.0.0: {}
dijkstrajs@1.0.3: {}
dir-glob@3.0.1:
dependencies:
path-type: 4.0.0
@ -3212,6 +3329,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@ -3251,6 +3373,8 @@ snapshots:
generic-pool@3.9.0:
optional: true
get-caller-file@2.0.5: {}
get-intrinsic@1.2.4:
dependencies:
es-errors: 1.3.0
@ -3443,6 +3567,10 @@ snapshots:
locate-character@3.0.0: {}
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@ -3567,14 +3695,24 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
p-try@2.2.0: {}
package-json-from-dist@1.0.0: {}
parent-module@1.0.1:
@ -3612,6 +3750,8 @@ snapshots:
pirates@4.0.6: {}
pngjs@5.0.0: {}
postcss-load-config@6.0.1(postcss@8.4.40):
dependencies:
lilconfig: 3.1.2
@ -3641,6 +3781,12 @@ snapshots:
punycode@2.3.1: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
qs@6.13.0:
dependencies:
side-channel: 1.0.6
@ -3681,6 +3827,10 @@ snapshots:
'@redis/time-series': 1.1.0(@redis/client@1.6.0)
optional: true
require-directory@2.1.1: {}
require-main-filename@2.0.0: {}
resolve-from@4.0.0: {}
resolve-from@5.0.0: {}
@ -3765,6 +3915,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
set-blocking@2.0.0: {}
set-cookie-parser@2.6.0: {}
set-function-length@1.2.2:
@ -4077,12 +4229,20 @@ snapshots:
tr46: 1.0.1
webidl-conversions: 4.0.2
which-module@2.0.1: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
word-wrap@1.2.5: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@ -4097,9 +4257,32 @@ snapshots:
wrappy@1.0.2: {}
ws@8.18.2: {}
y18n@4.0.3: {}
yallist@4.0.0:
optional: true
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yocto-queue@0.1.0: {}
youtubei.js@13.4.0:

View File

@ -0,0 +1,33 @@
{
"title": "Clipboard Share",
"description": "Share text and files securely between devices using end-to-end encryption",
"create_session": "Create Session",
"create_description": "Create a new sharing session and get a code",
"create": "Create",
"creating": "Creating...",
"join_session": "Join Session",
"join_description": "Enter a session code to join an existing session",
"join": "Join",
"joining": "Joining...",
"enter_code": "Enter session code",
"session_active": "Session Active",
"session_id": "Session ID",
"copy_link": "Copy sharing link",
"scan_qr": "Scan QR code to join from another device",
"peer_connected": "Device connected",
"waiting_peer": "Waiting for device to connect",
"send_text": "Send Text",
"enter_text": "Enter text to share",
"send": "Send",
"sending": "Sending...",
"download": "Download",
"receiving": "Receiving",
"received_files": "Received Files",
"disconnect": "Disconnect",
"error": {
"session_not_found": "Session not found or expired",
"session_full": "Session is full",
"connection_failed": "Connection failed",
"encryption_failed": "Encryption failed"
}
}

View File

@ -2,6 +2,7 @@
"cobalt": "bamboo download, Download Videos for Free - YouTube, TikTok, Bilibili, Instagram, Facebook, Twitter",
"meowbalt": "bamboo download",
"beta": "beta",
"or": "or",
"embed.description": "Download videos for free from YouTube, TikTok, Bilibili, Instagram, Facebook, and Twitter. No registration needed. Easy and fast video downloads.",
"guide": {
"title": "Freesavevideo.online Online Video Downloader Guide",

View File

@ -1,5 +1,6 @@
{
"save": "index",
"clipboard": "clipboard",
"settings": "settings",
"updates": "updates",
"donate": "donate",

View File

@ -0,0 +1,33 @@
{
"title": "剪贴板分享",
"description": "使用端到端加密在设备间安全分享文本和文件",
"create_session": "创建会话",
"create_description": "创建新的分享会话并获取分享码",
"create": "创建",
"creating": "创建中...",
"join_session": "加入会话",
"join_description": "输入会话码加入现有会话",
"join": "加入",
"joining": "加入中...",
"enter_code": "输入会话码",
"session_active": "会话已激活",
"session_id": "会话ID",
"copy_link": "复制分享链接",
"scan_qr": "扫描二维码从其他设备加入",
"peer_connected": "设备已连接",
"waiting_peer": "等待设备连接",
"send_text": "发送文本",
"enter_text": "输入要分享的文本",
"send": "发送",
"sending": "发送中...",
"download": "下载",
"receiving": "接收中",
"received_files": "已接收文件",
"disconnect": "断开连接",
"error": {
"session_not_found": "会话不存在或已过期",
"session_full": "会话已满",
"connection_failed": "连接失败",
"encryption_failed": "加密失败"
}
}

View File

@ -1,5 +1,6 @@
{
"save": "首页",
"clipboard": "剪贴板",
"settings": "设置",
"updates": "更新",
"donate": "捐赠",

View File

@ -37,6 +37,7 @@
"@types/eslint__js": "^8.42.3",
"@types/fluent-ffmpeg": "^2.1.25",
"@types/node": "^20.14.10",
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-basic-ssl": "^1.1.0",
"compare-versions": "^6.1.0",
"dotenv": "^16.0.1",
@ -54,5 +55,8 @@
"typescript": "^5.4.5",
"typescript-eslint": "^7.13.1",
"vite": "^5.0.3"
},
"dependencies": {
"qrcode": "^1.5.4"
}
}

View File

@ -3,8 +3,9 @@
export let click = () => {
alert("no function assigned");
};
export let disabled: boolean = false;
</script>
<button id="button-{id}" class="button" on:click={click}>
<button id="button-{id}" class="button" {disabled} on:click={click}>
<slot></slot>
</button>

View File

@ -3,10 +3,9 @@
import { defaultNavPage } from "$lib/subnav";
import CobaltLogo from "$components/sidebar/CobaltLogo.svelte";
import SidebarTab from "$components/sidebar/SidebarTab.svelte";
import IconDownload from "@tabler/icons-svelte/IconDownload.svelte";
import SidebarTab from "$components/sidebar/SidebarTab.svelte"; import IconDownload from "@tabler/icons-svelte/IconDownload.svelte";
import IconSettings from "@tabler/icons-svelte/IconSettings.svelte";
import IconClipboard from "$components/icons/Clipboard.svelte";
import IconRepeat from "@tabler/icons-svelte/IconRepeat.svelte";
@ -27,11 +26,13 @@
<nav id="sidebar" aria-label={$t("a11y.tabs.tab_panel")}>
<CobaltLogo />
<div id="sidebar-tabs" role="tablist">
<div id="sidebar-actions" class="sidebar-inner-container">
<div id="sidebar-tabs" role="tablist"> <div id="sidebar-actions" class="sidebar-inner-container">
<SidebarTab tabName="save" tabLink="/">
<IconDownload />
</SidebarTab>
<SidebarTab tabName="clipboard" tabLink="/clipboard">
<IconClipboard />
</SidebarTab>
<SidebarTab tabName="remux" tabLink="/remux" beta>
<IconRepeat />
</SidebarTab>

View File

@ -55,8 +55,8 @@ const docs = {
apiLicense: "https://github.com/imputnet/cobalt/blob/main/api/LICENSE",
};
const apiURL = "https://api.freesavevideo.online/";
//const apiURL = "http://localhost:9000";
//const apiURL = "https://api.freesavevideo.online/";
const apiURL = "http://localhost:9000";
export { donate, apiURL, contacts, partners, siriShortcuts, docs };
export default variables;

View File

@ -0,0 +1,978 @@
<script lang="ts"> import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n/translations';
import SettingsCategory from '$components/settings/SettingsCategory.svelte';
import ActionButton from '$components/buttons/ActionButton.svelte';
import QRCode from 'qrcode';
import { currentApiURL } from '$lib/api/api-url';
// Types
interface FileItem {
name: string;
size: number;
type: string;
blob: Blob;
}
interface ReceivingFile {
name: string;
size: number;
type: string;
chunks: Uint8Array[];
receivedSize: number;
}
// State variables
let sessionId = '';
let joinCode = '';
let isConnected = false;
let isCreating = false;
let isJoining = false;
let isCreator = false;
let peerConnected = false;
let qrCodeUrl = '';
// WebSocket and WebRTC
let ws: WebSocket | null = null;
let peerConnection: RTCPeerConnection | null = null;
let dataChannel: RTCDataChannel | null = null;
// Encryption
let keyPair: CryptoKeyPair | null = null;
let remotePublicKey: CryptoKey | null = null;
let sharedKey: CryptoKey | null = null;
// File transfer
let files: File[] = [];
let receivedFiles: FileItem[] = [];
let textContent = '';
let dragover = false;
let sendingFiles = false;
let receivingFiles = false;
let transferProgress = 0;
let currentReceivingFile: ReceivingFile | null = null;
// Constants
const CHUNK_SIZE = 64 * 1024; // 64KB chunks
onMount(async () => {
// Check for session parameter in URL
if (typeof window !== 'undefined') {
const urlParams = new URLSearchParams(window.location.search);
const sessionParam = urlParams.get('session');
if (sessionParam) {
joinCode = sessionParam;
await joinSession();
}
}
});
onDestroy(() => {
cleanup();
}); function getWebSocketURL(): string {
if (typeof window === 'undefined') return 'ws://localhost:9000/ws';
const apiUrl = currentApiURL();
const url = new URL(apiUrl);
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${url.host}/ws`;
}
async function generateKeyPair(): Promise<void> {
keyPair = await window.crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
false,
['deriveKey']
);
}
async function exportPublicKey(): Promise<ArrayBuffer> {
if (!keyPair) throw new Error('Key pair not generated');
return await window.crypto.subtle.exportKey('raw', keyPair.publicKey);
} async function importRemotePublicKey(publicKeyArray: number[]): Promise<void> {
const publicKeyBuffer = new Uint8Array(publicKeyArray).buffer;
remotePublicKey = await window.crypto.subtle.importKey(
'raw',
publicKeyBuffer,
{ name: 'ECDH', namedCurve: 'P-256' },
false,
[]
);
}
async function deriveSharedKey(): Promise<void> {
if (!keyPair || !remotePublicKey) throw new Error('Keys not available');
sharedKey = await window.crypto.subtle.deriveKey(
{ name: 'ECDH', public: remotePublicKey },
keyPair.privateKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
async function encryptData(data: string): Promise<ArrayBuffer> {
if (!sharedKey) throw new Error('Shared key not available');
const encoder = new TextEncoder();
const dataBuffer = encoder.encode(data);
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const encrypted = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
sharedKey,
dataBuffer
);
// Combine IV and encrypted data
const result = new Uint8Array(iv.length + encrypted.byteLength);
result.set(iv);
result.set(new Uint8Array(encrypted), iv.length);
return result.buffer;
}
async function decryptData(encryptedBuffer: ArrayBuffer): Promise<string> {
if (!sharedKey) throw new Error('Shared key not available');
const encryptedArray = new Uint8Array(encryptedBuffer);
const iv = encryptedArray.slice(0, 12);
const encrypted = encryptedArray.slice(12);
const decrypted = await window.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv },
sharedKey,
encrypted
);
const decoder = new TextDecoder();
return decoder.decode(decrypted);
}
async function connectWebSocket(): Promise<void> {
return new Promise((resolve, reject) => {
try {
const wsUrl = getWebSocketURL();
console.log('Connecting to WebSocket:', wsUrl);
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected');
isConnected = true;
resolve();
};
ws.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);
console.log('WebSocket message:', message);
await handleWebSocketMessage(message);
} catch (error) {
console.error('Error handling WebSocket message:', error);
}
};
ws.onclose = () => {
console.log('WebSocket disconnected');
isConnected = false;
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
isConnected = false;
reject(error);
};
} catch (error) {
reject(error);
}
});
} async function handleWebSocketMessage(message: any): Promise<void> {
switch (message.type) {
case 'session_created':
sessionId = message.sessionId;
isCreating = false;
isCreator = true;
console.log('Session created:', sessionId);
await generateQRCode();
break;
case 'session_joined':
await importRemotePublicKey(message.publicKey);
await deriveSharedKey();
isJoining = false;
await setupWebRTC(false);
break;
case 'peer_joined':
await importRemotePublicKey(message.publicKey);
await deriveSharedKey();
if (isCreator) {
await setupWebRTC(true);
}
break;
case 'offer':
await handleOffer(message.offer);
break;
case 'answer':
await handleAnswer(message.answer);
break;
case 'ice_candidate':
await handleIceCandidate(message.candidate);
break;
case 'error':
console.error('Server error:', message.error);
isCreating = false;
isJoining = false;
break;
}
} async function createSession(): Promise<void> {
try {
isCreating = true;
await generateKeyPair();
await connectWebSocket();
const publicKeyBuffer = await exportPublicKey();
const publicKeyArray = Array.from(new Uint8Array(publicKeyBuffer));
if (ws) {
ws.send(JSON.stringify({
type: 'create_session',
publicKey: publicKeyArray
}));
}
} catch (error) {
console.error('Error creating session:', error);
isCreating = false;
}
} async function joinSession(): Promise<void> {
try {
isJoining = true;
await generateKeyPair();
await connectWebSocket();
const publicKeyBuffer = await exportPublicKey();
const publicKeyArray = Array.from(new Uint8Array(publicKeyBuffer));
if (ws) {
ws.send(JSON.stringify({
type: 'join_session',
sessionId: joinCode,
publicKey: publicKeyArray
}));
}
} catch (error) {
console.error('Error joining session:', error);
isJoining = false;
}
}
async function setupWebRTC(shouldCreateOffer: boolean): Promise<void> {
peerConnection = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
peerConnection.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
if (event.candidate && ws) {
ws.send(JSON.stringify({
type: 'ice_candidate',
candidate: event.candidate
}));
}
};
if (shouldCreateOffer) {
dataChannel = peerConnection.createDataChannel('fileTransfer', { ordered: true });
setupDataChannel(dataChannel);
peerConnection.createOffer().then((offer: RTCSessionDescriptionInit) => {
if (peerConnection) {
peerConnection.setLocalDescription(offer);
if (ws) {
ws.send(JSON.stringify({ type: 'offer', offer: offer }));
}
}
});
} else {
peerConnection.ondatachannel = (event: RTCDataChannelEvent) => {
dataChannel = event.channel;
setupDataChannel(dataChannel);
};
}
}
function setupDataChannel(channel: RTCDataChannel): void {
channel.onopen = () => {
console.log('Data channel opened');
peerConnected = true;
};
channel.onclose = () => {
console.log('Data channel closed');
peerConnected = false;
};
channel.onmessage = async (event) => {
try {
const encryptedData = event.data;
const decryptedMessage = await decryptData(encryptedData);
const message = JSON.parse(decryptedMessage);
if (message.type === 'text') {
textContent = message.data;
} else if (message.type === 'file_info') {
currentReceivingFile = {
name: message.name,
size: message.size,
type: message.type,
chunks: [],
receivedSize: 0
};
receivingFiles = true;
} else if (message.type === 'file_chunk' && currentReceivingFile) {
const chunkData = new Uint8Array(message.data);
currentReceivingFile.chunks.push(chunkData);
currentReceivingFile.receivedSize += chunkData.length;
transferProgress = (currentReceivingFile.receivedSize / currentReceivingFile.size) * 100;
if (currentReceivingFile.receivedSize >= currentReceivingFile.size) {
const completeFile = new Blob(currentReceivingFile.chunks, { type: currentReceivingFile.type });
receivedFiles = [...receivedFiles, {
name: currentReceivingFile.name,
size: currentReceivingFile.size,
type: currentReceivingFile.type,
blob: completeFile
}];
currentReceivingFile = null;
receivingFiles = false;
transferProgress = 0;
}
}
} catch (error) {
console.error('Error handling data channel message:', error);
}
};
}
async function handleOffer(offer: RTCSessionDescriptionInit): Promise<void> {
if (!peerConnection) return;
await peerConnection.setRemoteDescription(offer);
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
if (ws) {
ws.send(JSON.stringify({ type: 'answer', answer: answer }));
}
}
async function handleAnswer(answer: RTCSessionDescriptionInit): Promise<void> {
if (!peerConnection) return;
await peerConnection.setRemoteDescription(answer);
}
async function handleIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
if (!peerConnection) return;
await peerConnection.addIceCandidate(candidate);
}
async function sendText(): Promise<void> {
if (!dataChannel || !peerConnected || !textContent.trim()) return;
try {
const message = { type: 'text', data: textContent.trim() };
const encryptedData = await encryptData(JSON.stringify(message));
if (dataChannel) {
dataChannel.send(encryptedData);
textContent = '';
}
} catch (error) {
console.error('Error sending text:', error);
}
}
async function sendFiles(): Promise<void> {
if (!dataChannel || !peerConnected || files.length === 0) return;
sendingFiles = true;
transferProgress = 0;
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Send file info
const fileInfo = { type: 'file_info', name: file.name, size: file.size, mimeType: file.type };
const encryptedInfo = await encryptData(JSON.stringify(fileInfo));
dataChannel.send(encryptedInfo);
// Send file chunks
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const chunkArray = new Uint8Array(await chunk.arrayBuffer());
const chunkMessage = { type: 'file_chunk', data: Array.from(chunkArray) };
const encryptedChunk = await encryptData(JSON.stringify(chunkMessage));
dataChannel.send(encryptedChunk);
const fileProgress = (chunkIndex + 1) / totalChunks;
const totalProgress = ((i + fileProgress) / files.length) * 100;
transferProgress = totalProgress;
// Small delay to prevent overwhelming the connection
await new Promise(resolve => setTimeout(resolve, 10));
}
}
files = [];
transferProgress = 0;
sendingFiles = false;
} catch (error) {
console.error('Error sending files:', error);
sendingFiles = false;
}
}
function handleFileSelect(event: Event): void {
const target = event.target as HTMLInputElement;
if (target.files) {
files = [...files, ...Array.from(target.files)];
}
}
function handleDragOver(event: DragEvent): void {
event.preventDefault();
dragover = true;
}
function handleDragLeave(): void {
dragover = false;
}
function handleDrop(event: DragEvent): void {
event.preventDefault();
dragover = false;
if (event.dataTransfer?.files) {
files = [...files, ...Array.from(event.dataTransfer.files)];
}
}
function removeFile(index: number): void {
files = files.filter((_, i) => i !== index);
}
function downloadReceivedFile(file: FileItem): void {
const url = URL.createObjectURL(file.blob);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function removeReceivedFile(index: number): void {
const file = receivedFiles[index];
URL.revokeObjectURL(URL.createObjectURL(file.blob));
receivedFiles = receivedFiles.filter((_, i) => i !== index);
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
async function generateQRCode(): Promise<void> {
try {
if (typeof window !== 'undefined' && sessionId) {
const url = `${window.location.origin}/clipboard?session=${sessionId}`;
qrCodeUrl = await QRCode.toDataURL(url, {
width: 200,
margin: 2,
color: { dark: '#000000', light: '#ffffff' }
});
console.log('QR Code generated:', { sessionId, url, qrCodeUrl: 'data URL created' });
}
} catch (error) {
console.error('QR Code generation failed:', error);
console.log('QR Code generation failed:', { hasWindow: typeof window !== 'undefined', sessionId });
}
}
function shareSession(): void {
if (typeof window !== 'undefined' && sessionId) {
const url = `${window.location.origin}/clipboard?session=${sessionId}`;
navigator.clipboard.writeText(url);
}
}
function cleanup(): void {
if (dataChannel) {
dataChannel.close();
dataChannel = null;
}
if (peerConnection) {
peerConnection.close();
peerConnection = null;
}
if (ws) {
ws.close();
ws = null;
}
sessionId = '';
isConnected = false;
peerConnected = false;
sharedKey = null;
remotePublicKey = null;
qrCodeUrl = '';
}
</script>
<svelte:head>
<title>cobalt | {$t("clipboard.title")}</title>
<meta property="og:title" content="cobalt | {$t("clipboard.title")}" />
<meta property="og:description" content={$t("clipboard.description")} />
<meta property="description" content={$t("clipboard.description")} />
</svelte:head>
<div class="clipboard-container">
<div class="clipboard-header">
<h1>{$t("clipboard.title")}</h1>
<p>{$t("clipboard.description")}</p>
</div>
{#if !isConnected}
<SettingsCategory title={$t("clipboard.title")} sectionId="connection-setup">
<div class="connection-setup">
<div class="setup-option">
<h3>{$t("clipboard.create_session")}</h3>
<p>{$t("clipboard.create_description")}</p> <ActionButton
id="create-session"
click={createSession} disabled={isCreating}
>
{isCreating ? $t("clipboard.creating") : $t("clipboard.create")}
</ActionButton>
</div>
<div class="divider">
<span>{$t("general.or")}</span>
</div>
<div class="setup-option">
<h3>{$t("clipboard.join_session")}</h3>
<p>{$t("clipboard.join_description")}</p>
<div class="join-form">
<input
type="text"
bind:value={joinCode}
placeholder={$t("clipboard.enter_code")}
disabled={isJoining}
/> <ActionButton
id="join-session"
click={joinSession}
disabled={isJoining || !joinCode.trim()}
>
{isJoining ? $t("clipboard.joining") : $t("clipboard.join")}
</ActionButton>
</div>
</div>
</div>
</SettingsCategory>
{:else}
<SettingsCategory title={$t("clipboard.session_active")} sectionId="session-info">
<div class="session-info">
<h3>{$t("clipboard.session_active")}</h3>
<div class="session-details">
<div class="session-id">
<strong>{$t("clipboard.session_id")}:</strong>
<code>{sessionId}</code>
</div>
{#if isCreator && sessionId && !peerConnected && qrCodeUrl}
<div class="qr-code">
<p>{$t("clipboard.scan_qr")}</p>
<img src={qrCodeUrl} alt="QR Code" />
</div>
{/if}
<div class="connection-status">
<span class="status-indicator" class:connected={peerConnected}></span>
{peerConnected ? $t("clipboard.peer_connected") : $t("clipboard.waiting_peer")}
</div>
</div>
</div>
</SettingsCategory>
{#if peerConnected}
<SettingsCategory title={$t("clipboard.send_text")} sectionId="text-transfer">
<div class="text-transfer">
<h3>{$t("clipboard.send_text")}</h3>
<textarea
bind:value={textContent}
placeholder={$t("clipboard.enter_text")}
rows="4"
></textarea> <ActionButton id="send-text" click={sendText} disabled={!textContent.trim()}>
{$t("clipboard.send")}
</ActionButton>
</div>
</SettingsCategory>
<SettingsCategory title={$t("clipboard.send_files")} sectionId="file-transfer">
<div class="file-transfer">
<h3>{$t("clipboard.send_files")}</h3>
<div
class="drop-zone"
on:dragover|preventDefault={handleDragOver}
on:dragleave={handleDragLeave}
on:drop={handleDrop}
class:dragover
>
<div class="drop-content">
<p>{$t("clipboard.drop_files")}</p>
<input
type="file"
multiple
on:change={handleFileSelect}
id="file-input"
style="display: none;"
/> <ActionButton id="select-files" click={() => document.getElementById('file-input')?.click()}>
{$t("clipboard.select_files")}
</ActionButton>
</div>
</div>
{#if files.length > 0}
<div class="file-list">
{#each files as file, index}
<div class="file-item">
<span class="file-name">{file.name}</span>
<span class="file-size">({formatFileSize(file.size)})</span>
<button class="remove-btn" on:click={() => removeFile(index)}>×</button>
</div>
{/each} <ActionButton id="send-files" click={sendFiles} disabled={sendingFiles}>
{sendingFiles ? $t("clipboard.sending") : $t("clipboard.send")}
</ActionButton>
{#if sendingFiles || transferProgress > 0}
<div class="progress-bar">
<div class="progress-fill" style="width: {transferProgress}%"></div>
</div>
<span class="progress-text">{Math.round(transferProgress)}%</span>
{/if}
</div>
{/if}
</div>
</SettingsCategory>
{#if receivedFiles.length > 0}
<SettingsCategory title={$t("clipboard.received_files")} sectionId="received-files">
<div class="received-files">
<h3>{$t("clipboard.received_files")}</h3>
<div class="file-list">
{#each receivedFiles as file, index}
<div class="file-item">
<span class="file-name">{file.name}</span>
<span class="file-size">({formatFileSize(file.size)})</span>
<div class="file-actions">
<button class="download-btn" on:click={() => downloadReceivedFile(file)}>
{$t("clipboard.download")}
</button>
<button class="remove-btn" on:click={() => removeReceivedFile(index)}>×</button>
</div>
</div>
{/each}
</div>
</div>
</SettingsCategory>
{/if}
{#if receivingFiles && currentReceivingFile}
<SettingsCategory title={$t("clipboard.receiving")} sectionId="receiving-progress">
<div class="receiving-progress">
<h3>{$t("clipboard.receiving")}: {currentReceivingFile.name}</h3>
<div class="progress-bar">
<div class="progress-fill" style="width: {transferProgress}%"></div>
</div>
<span class="progress-text">{Math.round(transferProgress)}% ({formatFileSize(currentReceivingFile.receivedSize)} / {formatFileSize(currentReceivingFile.size)})</span>
</div>
</SettingsCategory>
{/if}
{/if} <div class="disconnect-section">
<ActionButton id="cleanup" click={cleanup}>
{$t("clipboard.disconnect")}
</ActionButton>
</div>
{/if}
</div>
<style>
.clipboard-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.clipboard-header {
text-align: center;
margin-bottom: 2rem;
}
.clipboard-header h1 {
margin-bottom: 0.5rem;
}
.connection-setup {
display: flex;
flex-direction: column;
gap: 2rem;
}
.setup-option {
text-align: center;
}
.setup-option h3 {
margin-bottom: 0.5rem;
}
.divider {
text-align: center;
position: relative;
margin: 1rem 0;
}
.divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background-color: var(--border);
}
.divider span {
background-color: var(--background);
padding: 0 1rem;
color: var(--secondary);
}
.join-form {
display: flex;
gap: 1rem;
max-width: 400px;
margin: 0 auto;
}
.join-form input {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.25rem;
background-color: var(--input-background);
color: var(--text);
}
.session-info {
text-align: center;
}
.session-details {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
.session-id {
display: flex;
align-items: center;
gap: 0.5rem;
}
.session-id code {
background-color: var(--border);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-family: monospace;
}
.qr-code {
text-align: center;
}
.qr-code img {
max-width: 200px;
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.connection-status {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: var(--red);
}
.status-indicator.connected {
background-color: var(--green);
}
.text-transfer textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.25rem;
background-color: var(--input-background);
color: var(--text);
resize: vertical;
margin-bottom: 1rem;
}
.drop-zone {
border: 2px dashed var(--border);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
transition: border-color 0.2s;
margin-bottom: 1rem;
}
.drop-zone.dragover {
border-color: var(--accent);
background-color: var(--accent-background);
}
.file-list {
margin-top: 1rem;
}
.file-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.25rem;
margin-bottom: 0.5rem;
}
.file-name {
flex: 1;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.file-size {
color: var(--secondary);
font-size: 0.9rem;
}
.file-actions {
display: flex;
gap: 0.5rem;
}
.download-btn, .remove-btn {
background: none;
border: 1px solid var(--border);
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
cursor: pointer;
color: var(--text);
transition: background-color 0.2s;
}
.download-btn:hover {
background-color: var(--accent-background);
}
.remove-btn:hover {
background-color: var(--red-background);
color: var(--red);
}
.progress-bar {
width: 100%;
height: 8px;
background-color: var(--border);
border-radius: 4px;
overflow: hidden;
margin: 0.5rem 0;
}
.progress-fill {
height: 100%;
background-color: var(--accent);
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.9rem;
color: var(--secondary);
}
.disconnect-section {
text-align: center;
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--border);
}
@media (min-width: 768px) {
.connection-setup {
flex-direction: row;
align-items: flex-start;
}
.setup-option {
flex: 1;
}
.divider {
align-self: center;
margin: 0 2rem;
width: 60px;
}
.divider::before {
top: 0;
bottom: 0;
left: 50%;
right: auto;
width: 1px;
height: auto;
}
.session-details {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.qr-code {
order: -1;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -80,8 +80,9 @@ export default defineConfig({
}
}
}
},
server: {
}, server: {
host: '0.0.0.0', // 允许外部访问
port: 5173,
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp"