Initial commit

This commit is contained in:
2026-03-08 01:34:54 +08:00
commit 1f104f73c8
441 changed files with 64911 additions and 0 deletions

43
remote/public/js/app.js Normal file
View File

@@ -0,0 +1,43 @@
(function() {
const password = getCookie('auth') || '';
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = window.location.hostname;
const wsPort = window.location.port;
const wsUrlBase = wsPort ? `${wsProtocol}//${wsHost}:${wsPort}/ws` : `${wsProtocol}//${wsHost}/ws`;
const WS_URL = password ? `${wsUrlBase}?password=${encodeURIComponent(password)}` : wsUrlBase;
let videoPlayer = null;
let inputHandler = null;
function init() {
videoPlayer = new VideoPlayer('video-canvas', WS_URL);
videoPlayer.init();
inputHandler = new InputHandler(videoPlayer.getCanvas(), {
wsUrl: WS_URL
});
inputHandler.init();
console.log('xc-remote initialized');
}
function destroy() {
if (inputHandler) {
inputHandler.destroy();
inputHandler = null;
}
if (videoPlayer) {
videoPlayer.destroy();
videoPlayer = null;
}
}
window.addEventListener('beforeunload', destroy);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -0,0 +1,500 @@
class FilePanel {
constructor() {
this.isVisible = false;
this.localPath = '';
this.remotePath = '';
this.localFiles = [];
this.remoteFiles = [];
this.selectedLocal = null;
this.selectedRemote = null;
this.transfers = [];
this.chunkSize = 5 * 1024 * 1024;
this.init();
}
init() {
this.createPanel();
this.bindEvents();
}
createPanel() {
const panel = document.createElement('div');
panel.id = 'file-panel';
panel.innerHTML = `
<div class="file-panel-header">
<span>文件传输</span>
<button class="close-btn" id="close-panel">&times;</button>
</div>
<div class="file-panel-body">
<div class="file-browser" id="local-browser">
<div class="browser-header">
<span class="browser-title">本地文件</span>
<div class="path-nav">
<button class="nav-btn" id="local-up" title="上级目录">↑</button>
<input type="text" class="path-input" id="local-path" readonly>
</div>
</div>
<div class="file-list" id="local-files"></div>
<div class="browser-actions">
<button class="action-btn" id="select-local-file">选择文件</button>
<button class="action-btn primary" id="upload-btn" disabled>上传 →</button>
</div>
</div>
<div class="file-browser" id="remote-browser">
<div class="browser-header">
<span class="browser-title">远程文件</span>
<div class="path-nav">
<button class="nav-btn" id="remote-up" title="上级目录">↑</button>
<input type="text" class="path-input" id="remote-path" readonly>
</div>
</div>
<div class="file-list" id="remote-files"></div>
<div class="browser-actions">
<button class="action-btn primary" id="download-btn" disabled>← 下载</button>
<button class="action-btn danger" id="delete-btn" disabled>删除</button>
</div>
</div>
</div>
<div class="transfer-list" id="transfer-list"></div>
<input type="file" id="file-input" style="display:none" multiple>
`;
document.body.appendChild(panel);
this.panel = panel;
}
bindEvents() {
document.getElementById('close-panel').addEventListener('click', () => this.hide());
document.getElementById('local-up').addEventListener('click', () => this.navigateLocalUp());
document.getElementById('remote-up').addEventListener('click', () => this.navigateRemoteUp());
document.getElementById('select-local-file').addEventListener('click', () => {
document.getElementById('file-input').click();
});
document.getElementById('file-input').addEventListener('change', (e) => {
this.handleLocalFileSelect(e.target.files);
});
document.getElementById('upload-btn').addEventListener('click', () => this.uploadSelected());
document.getElementById('download-btn').addEventListener('click', () => this.downloadSelected());
document.getElementById('delete-btn').addEventListener('click', () => this.deleteSelected());
document.getElementById('remote-files').addEventListener('dblclick', (e) => {
const item = e.target.closest('.file-item');
if (item && item.dataset.isDirectory === 'true') {
this.navigateRemote(item.dataset.name);
}
});
document.getElementById('remote-files').addEventListener('click', (e) => {
const item = e.target.closest('.file-item');
if (item) {
this.selectRemote(item.dataset.name, item.dataset.isDirectory === 'true');
}
});
}
show() {
this.isVisible = true;
this.panel.classList.add('visible');
this.refreshRemote();
}
hide() {
this.isVisible = false;
this.panel.classList.remove('visible');
}
toggle() {
if (this.isVisible) {
this.hide();
} else {
this.show();
}
}
async refreshRemote() {
try {
const res = await fetch(`/api/files/browse?path=${encodeURIComponent(this.remotePath)}`);
const data = await res.json();
if (data.error) {
console.error('Browse error:', data.error);
return;
}
this.remoteFiles = data.items || [];
this.remotePath = data.currentPath || '';
document.getElementById('remote-path').value = this.remotePath || '/';
this.renderRemoteFiles();
} catch (error) {
console.error('Failed to refresh remote:', error);
}
}
renderRemoteFiles() {
const container = document.getElementById('remote-files');
if (this.remotePath) {
container.innerHTML = `<div class="file-item directory" data-name=".." data-is-directory="true">
<span class="file-icon">📁</span>
<span class="file-name">..</span>
</div>`;
} else {
container.innerHTML = '';
}
this.remoteFiles.forEach(item => {
const div = document.createElement('div');
div.className = `file-item ${item.isDirectory ? 'directory' : 'file'}`;
div.dataset.name = item.name;
div.dataset.isDirectory = item.isDirectory;
div.innerHTML = `
<span class="file-icon">${item.isDirectory ? '📁' : this.getFileIcon(item.name)}</span>
<span class="file-name">${item.name}</span>
<span class="file-size">${item.isDirectory ? '' : this.formatSize(item.size)}</span>
`;
container.appendChild(div);
});
this.selectedRemote = null;
this.updateRemoteActions();
}
selectRemote(name, isDirectory) {
const items = document.querySelectorAll('#remote-files .file-item');
items.forEach(item => item.classList.remove('selected'));
const selected = document.querySelector(`#remote-files .file-item[data-name="${name}"]`);
if (selected) {
selected.classList.add('selected');
this.selectedRemote = { name, isDirectory };
}
this.updateRemoteActions();
}
updateRemoteActions() {
const downloadBtn = document.getElementById('download-btn');
const deleteBtn = document.getElementById('delete-btn');
if (this.selectedRemote && !this.selectedRemote.isDirectory) {
downloadBtn.disabled = false;
deleteBtn.disabled = false;
} else {
downloadBtn.disabled = true;
deleteBtn.disabled = true;
}
}
navigateRemote(name) {
if (name === '..') {
this.remotePath = this.remotePath.split('/').slice(0, -1).join('/');
} else {
this.remotePath = this.remotePath ? `${this.remotePath}/${name}` : name;
}
this.refreshRemote();
}
navigateRemoteUp() {
if (this.remotePath) {
this.remotePath = this.remotePath.split('/').slice(0, -1).join('/');
this.refreshRemote();
}
}
handleLocalFileSelect(files) {
if (files.length === 0) return;
this.localFiles = Array.from(files).map(file => ({
name: file.name,
size: file.size,
file: file,
isDirectory: false
}));
this.renderLocalFiles();
}
renderLocalFiles() {
const container = document.getElementById('local-files');
container.innerHTML = '';
this.localFiles.forEach((item, index) => {
const div = document.createElement('div');
div.className = 'file-item file';
div.dataset.index = index;
div.innerHTML = `
<span class="file-icon">${this.getFileIcon(item.name)}</span>
<span class="file-name">${item.name}</span>
<span class="file-size">${this.formatSize(item.size)}</span>
<button class="remove-btn" data-index="${index}">&times;</button>
`;
container.appendChild(div);
});
container.querySelectorAll('.remove-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const index = parseInt(btn.dataset.index);
this.localFiles.splice(index, 1);
this.renderLocalFiles();
this.updateLocalActions();
});
});
container.querySelectorAll('.file-item').forEach(item => {
item.addEventListener('click', () => {
container.querySelectorAll('.file-item').forEach(i => i.classList.remove('selected'));
item.classList.add('selected');
this.selectedLocal = parseInt(item.dataset.index);
this.updateLocalActions();
});
});
this.updateLocalActions();
}
updateLocalActions() {
const uploadBtn = document.getElementById('upload-btn');
uploadBtn.disabled = this.localFiles.length === 0;
}
navigateLocalUp() {
this.localFiles = [];
this.selectedLocal = null;
this.renderLocalFiles();
this.updateLocalActions();
}
async uploadSelected() {
if (this.localFiles.length === 0) return;
for (const item of this.localFiles) {
await this.uploadFile(item);
}
this.localFiles = [];
this.selectedLocal = null;
this.renderLocalFiles();
this.updateLocalActions();
this.refreshRemote();
}
async uploadFile(item) {
const transferId = Date.now();
this.addTransfer(transferId, item.name, 'upload', item.size);
try {
const totalChunks = Math.ceil(item.size / this.chunkSize);
const startRes = await fetch('/api/files/upload/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: item.name,
totalChunks,
fileSize: item.size
})
});
const { fileId } = await startRes.json();
for (let i = 0; i < totalChunks; i++) {
const start = i * this.chunkSize;
const end = Math.min(start + this.chunkSize, item.size);
const chunk = item.file.slice(start, end);
const formData = new FormData();
formData.append('fileId', fileId);
formData.append('chunkIndex', i);
formData.append('chunk', chunk);
await fetch('/api/files/upload/chunk', {
method: 'POST',
body: formData
});
const progress = Math.round(((i + 1) / totalChunks) * 100);
this.updateTransfer(transferId, progress);
}
const remoteFilePath = this.remotePath ? `${this.remotePath}/${item.name}` : item.name;
await fetch('/api/files/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileId,
totalChunks,
filename: remoteFilePath
})
});
this.completeTransfer(transferId);
} catch (error) {
console.error('Upload failed:', error);
this.failTransfer(transferId, error.message);
}
}
async downloadSelected() {
if (!this.selectedRemote || this.selectedRemote.isDirectory) return;
const filename = this.selectedRemote.name;
const remoteFilePath = this.remotePath ? `${this.remotePath}/${filename}` : filename;
const transferId = Date.now();
this.addTransfer(transferId, filename, 'download', 0);
try {
const res = await fetch(`/api/files/${encodeURIComponent(remoteFilePath)}`);
if (!res.ok) {
throw new Error('Download failed');
}
const contentLength = parseInt(res.headers.get('Content-Length') || '0');
this.updateTransferSize(transferId, contentLength);
const reader = res.body.getReader();
const chunks = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
const progress = contentLength > 0 ? Math.round((received / contentLength) * 100) : 0;
this.updateTransfer(transferId, progress);
}
const blob = new Blob(chunks);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
this.completeTransfer(transferId);
} catch (error) {
console.error('Download failed:', error);
this.failTransfer(transferId, error.message);
}
}
async deleteSelected() {
if (!this.selectedRemote || this.selectedRemote.isDirectory) return;
if (!confirm(`确定要删除 "${this.selectedRemote.name}" 吗?`)) return;
const remoteFilePath = this.remotePath ? `${this.remotePath}/${this.selectedRemote.name}` : this.selectedRemote.name;
try {
const res = await fetch(`/api/files/${encodeURIComponent(remoteFilePath)}`, {
method: 'DELETE'
});
if (res.ok) {
this.refreshRemote();
}
} catch (error) {
console.error('Delete failed:', error);
}
}
addTransfer(id, name, type, size) {
const transfer = { id, name, type, size, progress: 0, status: 'transferring' };
this.transfers.push(transfer);
this.renderTransfers();
}
updateTransfer(id, progress) {
const transfer = this.transfers.find(t => t.id === id);
if (transfer) {
transfer.progress = progress;
this.renderTransfers();
}
}
updateTransferSize(id, size) {
const transfer = this.transfers.find(t => t.id === id);
if (transfer) {
transfer.size = size;
this.renderTransfers();
}
}
completeTransfer(id) {
const transfer = this.transfers.find(t => t.id === id);
if (transfer) {
transfer.status = 'completed';
transfer.progress = 100;
this.renderTransfers();
setTimeout(() => {
this.transfers = this.transfers.filter(t => t.id !== id);
this.renderTransfers();
}, 3000);
}
}
failTransfer(id, error) {
const transfer = this.transfers.find(t => t.id === id);
if (transfer) {
transfer.status = 'failed';
transfer.error = error;
this.renderTransfers();
}
}
renderTransfers() {
const container = document.getElementById('transfer-list');
if (this.transfers.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = this.transfers.map(t => `
<div class="transfer-item ${t.status}">
<span class="transfer-type">${t.type === 'upload' ? '↑' : '↓'}</span>
<span class="transfer-name">${t.name}</span>
<div class="transfer-progress">
<div class="progress-bar" style="width: ${t.progress}%"></div>
</div>
<span class="transfer-percent">${t.progress}%</span>
</div>
`).join('');
}
formatSize(bytes) {
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(1)) + ' ' + sizes[i];
}
getFileIcon(filename) {
const ext = filename.split('.').pop().toLowerCase();
const icons = {
'txt': '📄', 'doc': '📄', 'docx': '📄', 'pdf': '📄',
'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️',
'mp3': '🎵', 'wav': '🎵', 'mp4': '🎬', 'avi': '🎬', 'mkv': '🎬',
'zip': '📦', 'rar': '📦', '7z': '📦',
'exe': '⚙️', 'msi': '⚙️',
'js': '📜', 'ts': '📜', 'py': '📜', 'html': '📜', 'css': '📜'
};
return icons[ext] || '📄';
}
}
window.FilePanel = FilePanel;

229
remote/public/js/input.js Normal file
View File

@@ -0,0 +1,229 @@
class InputHandler {
constructor(canvas, options = {}) {
this.canvas = canvas;
this.wsUrl = options.wsUrl;
this.screenWidth = options.screenWidth || 1280;
this.screenHeight = options.screenHeight || 720;
this.ws = null;
this.wsReady = false;
this.lastMoveTime = 0;
this.MOVE_THROTTLE_MS = 33;
this.pendingMove = null;
this.moveThrottleTimer = null;
this.pressedKeys = new Set();
this.codeToKey = {
'Enter': 'enter', 'Backspace': 'backspace', 'Tab': 'tab',
'Escape': 'escape', 'Delete': 'delete', 'Home': 'home',
'End': 'end', 'PageUp': 'pageup', 'PageDown': 'pagedown',
'ArrowUp': 'up', 'ArrowDown': 'down', 'ArrowLeft': 'left', 'ArrowRight': 'right',
'F1': 'f1', 'F2': 'f2', 'F3': 'f3', 'F4': 'f4',
'F5': 'f5', 'F6': 'f6', 'F7': 'f7', 'F8': 'f8',
'F9': 'f9', 'F10': 'f10', 'F11': 'f11', 'F12': 'f12',
'ControlLeft': 'ctrl', 'ControlRight': 'ctrl',
'AltLeft': 'alt', 'AltRight': 'alt',
'ShiftLeft': 'shift', 'ShiftRight': 'shift',
'MetaLeft': 'win', 'MetaRight': 'win',
'Space': 'space',
'Digit0': '0', 'Digit1': '1', 'Digit2': '2', 'Digit3': '3', 'Digit4': '4',
'Digit5': '5', 'Digit6': '6', 'Digit7': '7', 'Digit8': '8', 'Digit9': '9',
'KeyA': 'a', 'KeyB': 'b', 'KeyC': 'c', 'KeyD': 'd', 'KeyE': 'e',
'KeyF': 'f', 'KeyG': 'g', 'KeyH': 'h', 'KeyI': 'i', 'KeyJ': 'j',
'KeyK': 'k', 'KeyL': 'l', 'KeyM': 'm', 'KeyN': 'n', 'KeyO': 'o',
'KeyP': 'p', 'KeyQ': 'q', 'KeyR': 'r', 'KeyS': 's', 'KeyT': 't',
'KeyU': 'u', 'KeyV': 'v', 'KeyW': 'w', 'KeyX': 'x', 'KeyY': 'y', 'KeyZ': 'z',
'Comma': ',', 'Period': '.', 'Slash': '/', 'Semicolon': ';',
'Quote': "'", 'BracketLeft': '[', 'BracketRight': ']',
'Backslash': '\\', 'Minus': '-', 'Equal': '=', 'Backquote': '`'
};
this._boundHandlers = {
mousemove: this._handleMouseMove.bind(this),
mousedown: this._handleMouseDown.bind(this),
mouseup: this._handleMouseUp.bind(this),
contextmenu: this._handleContextMenu.bind(this),
wheel: this._handleWheel.bind(this),
keydown: this._handleKeyDown.bind(this),
keyup: this._handleKeyUp.bind(this),
blur: this._handleBlur.bind(this)
};
}
init() {
this._initWebSocket();
this._bindEvents();
return this;
}
_initWebSocket() {
this.ws = createReconnectingWebSocket(this.wsUrl, {
onOpen: () => {
this.wsReady = true;
console.log('Input WebSocket connected');
},
onClose: () => {
this.wsReady = false;
console.log('Input WebSocket disconnected');
},
onMessage: (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'screenInfo') {
this.screenWidth = msg.width;
this.screenHeight = msg.height;
console.log('Screen resolution:', this.screenWidth, 'x', this.screenHeight);
}
} catch (err) {}
},
onError: (err) => {
console.error('Input WebSocket error:', err);
}
});
}
_bindEvents() {
this.canvas.addEventListener('mousemove', this._boundHandlers.mousemove);
this.canvas.addEventListener('mousedown', this._boundHandlers.mousedown);
this.canvas.addEventListener('mouseup', this._boundHandlers.mouseup);
this.canvas.addEventListener('contextmenu', this._boundHandlers.contextmenu);
this.canvas.addEventListener('wheel', this._boundHandlers.wheel);
document.addEventListener('keydown', this._boundHandlers.keydown);
document.addEventListener('keyup', this._boundHandlers.keyup);
window.addEventListener('blur', this._boundHandlers.blur);
}
_unbindEvents() {
this.canvas.removeEventListener('mousemove', this._boundHandlers.mousemove);
this.canvas.removeEventListener('mousedown', this._boundHandlers.mousedown);
this.canvas.removeEventListener('mouseup', this._boundHandlers.mouseup);
this.canvas.removeEventListener('contextmenu', this._boundHandlers.contextmenu);
this.canvas.removeEventListener('wheel', this._boundHandlers.wheel);
document.removeEventListener('keydown', this._boundHandlers.keydown);
document.removeEventListener('keyup', this._boundHandlers.keyup);
window.removeEventListener('blur', this._boundHandlers.blur);
}
sendInputEvent(event) {
if (this.wsReady && this.ws && this.ws.isReady()) {
this.ws.getWebSocket().send(JSON.stringify(event));
}
}
_sendMouseMove(x, y) {
this.pendingMove = { x, y };
const now = Date.now();
if (now - this.lastMoveTime >= this.MOVE_THROTTLE_MS) {
this._flushMouseMove();
} else if (!this.moveThrottleTimer) {
this.moveThrottleTimer = setTimeout(() => {
this._flushMouseMove();
this.moveThrottleTimer = null;
}, this.MOVE_THROTTLE_MS - (now - this.lastMoveTime));
}
}
_flushMouseMove() {
if (this.pendingMove) {
this.sendInputEvent({
type: 'mouseMove',
x: this.pendingMove.x,
y: this.pendingMove.y
});
this.lastMoveTime = Date.now();
this.pendingMove = null;
}
}
_handleMouseMove(e) {
const { x, y } = getScreenCoordinates(e.clientX, e.clientY, this.canvas, this.screenWidth, this.screenHeight);
this._sendMouseMove(x, y);
}
_handleMouseDown(e) {
const button = e.button === 2 ? 'right' : 'left';
this.sendInputEvent({
type: 'mouseDown',
button: button
});
}
_handleMouseUp(e) {
const button = e.button === 2 ? 'right' : 'left';
this.sendInputEvent({
type: 'mouseUp',
button: button
});
}
_handleContextMenu(e) {
e.preventDefault();
}
_handleWheel(e) {
e.preventDefault();
const delta = -Math.sign(e.deltaY) * 120;
this.sendInputEvent({
type: 'mouseWheel',
delta: delta
});
}
_getKeyFromEvent(e) {
if (this.codeToKey[e.code]) {
return this.codeToKey[e.code];
}
return e.key;
}
_handleKeyDown(e) {
const key = this._getKeyFromEvent(e);
const keyId = key.toLowerCase();
if (!this.pressedKeys.has(keyId)) {
this.pressedKeys.add(keyId);
this.sendInputEvent({
type: 'keyDown',
key: key
});
}
if (e.key === 'Tab' || e.key === ' ' || e.key.startsWith('Arrow')) {
e.preventDefault();
}
}
_handleKeyUp(e) {
const key = this._getKeyFromEvent(e);
const keyId = key.toLowerCase();
this.pressedKeys.delete(keyId);
this.sendInputEvent({
type: 'keyUp',
key: key
});
}
_handleBlur() {
this.pressedKeys.forEach(keyId => {
this.sendInputEvent({
type: 'keyUp',
key: keyId
});
});
this.pressedKeys.clear();
}
destroy() {
this._unbindEvents();
if (this.moveThrottleTimer) {
clearTimeout(this.moveThrottleTimer);
}
if (this.ws) {
this.ws.close();
}
}
}

1
remote/public/js/jsmpeg.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,36 @@
class VideoPlayer {
constructor(canvasId, wsUrl, options = {}) {
this.canvas = document.getElementById(canvasId);
this.wsUrl = wsUrl;
this.options = {
autoplay: true,
audio: false,
videoBufferSize: 512 * 1024,
...options
};
this.player = null;
}
init() {
this.player = new JSMpeg.Player(this.wsUrl, {
canvas: this.canvas,
...this.options
});
return this;
}
getCanvas() {
return this.canvas;
}
getPlayer() {
return this.player;
}
destroy() {
if (this.player) {
this.player.destroy();
this.player = null;
}
}
}

88
remote/public/js/utils.js Normal file
View File

@@ -0,0 +1,88 @@
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return '';
}
function getScreenCoordinates(clientX, clientY, canvas, screenWidth, screenHeight) {
const rect = canvas.getBoundingClientRect();
const canvasX = clientX - rect.left;
const canvasY = clientY - rect.top;
const scaleX = screenWidth / rect.width;
const scaleY = screenHeight / rect.height;
const screenX = Math.floor(canvasX * scaleX);
const screenY = Math.floor(canvasY * scaleY);
return { x: screenX, y: screenY };
}
function createReconnectingWebSocket(url, options = {}) {
const {
onOpen = () => {},
onClose = () => {},
onMessage = () => {},
onError = () => {},
maxDelay = 30000
} = options;
let ws = null;
let reconnectDelay = 1000;
let reconnectTimer = null;
let isManualClose = false;
function connect() {
ws = new WebSocket(url);
ws.onopen = () => {
reconnectDelay = 1000;
onOpen(ws);
};
ws.onmessage = (e) => {
onMessage(e, ws);
};
ws.onclose = () => {
onClose();
if (!isManualClose) {
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, maxDelay);
connect();
}, reconnectDelay);
}
};
ws.onerror = (err) => {
onError(err);
};
}
function close() {
isManualClose = true;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
}
if (ws) {
ws.close();
}
}
function getWebSocket() {
return ws;
}
function isReady() {
return ws && ws.readyState === WebSocket.OPEN;
}
connect();
return {
getWebSocket,
isReady,
close
};
}