mirror of
https://github.com/imputnet/cobalt.git
synced 2025-07-03 03:48:28 +00:00
文件传输1
This commit is contained in:
parent
cddf6867de
commit
7b83692845
@ -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"
|
||||
},
|
||||
|
@ -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
180
api/src/core/signaling.js
Normal 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;
|
||||
};
|
183
pnpm-lock.yaml
183
pnpm-lock.yaml
@ -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:
|
||||
|
33
web/i18n/en/clipboard.json
Normal file
33
web/i18n/en/clipboard.json
Normal 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"
|
||||
}
|
||||
}
|
@ -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",
|
||||
|
@ -1,5 +1,6 @@
|
||||
{
|
||||
"save": "index",
|
||||
"clipboard": "clipboard",
|
||||
"settings": "settings",
|
||||
"updates": "updates",
|
||||
"donate": "donate",
|
||||
|
33
web/i18n/zh/clipboard.json
Normal file
33
web/i18n/zh/clipboard.json
Normal 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": "加密失败"
|
||||
}
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
{
|
||||
"save": "首页",
|
||||
"clipboard": "剪贴板",
|
||||
"settings": "设置",
|
||||
"updates": "更新",
|
||||
"donate": "捐赠",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
978
web/src/routes/clipboard/+page.svelte
Normal file
978
web/src/routes/clipboard/+page.svelte
Normal 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>
|
1112
web/src/routes/clipboard/+page.svelte.backup
Normal file
1112
web/src/routes/clipboard/+page.svelte.backup
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user