8 Commits
1.0.1 ... main

Author SHA1 Message Date
nnwang
cf0fdfa4d0 删除不必要的拓展名 2025-12-12 20:45:59 +08:00
nnwang
9a68952fac 将json解析改为外部库 2025-12-12 20:42:40 +08:00
nnwang
b661afed4c 修复2进制文件dfpwm被视为文本传输的问题 2025-12-12 20:11:48 +08:00
nnwang
33ff81a15d 修复缺少safe_decode_json 2025-12-12 19:18:40 +08:00
nnwang
c6d9d4f093 修复utf8编码问题,修改客户端长轮询为60秒 2025-12-12 18:57:17 +08:00
nnwang
d862467883 更新README 2025-12-06 01:08:40 +08:00
nnwang
83822ae165 将http轮询改为 长轮询请求挂起 2025-12-06 01:02:28 +08:00
nnwang
e5708e10fb 去除依赖库 2025-12-05 19:04:52 +08:00
6 changed files with 588 additions and 498 deletions

View File

@@ -4,6 +4,15 @@ local httpServer = args[1] or "http://192.168.2.200:8080"
local roomId = args[2] local roomId = args[2]
local pollInterval = 1 local pollInterval = 1
local computerID = tostring(os.computerID() or "unknown") local computerID = tostring(os.computerID() or "unknown")
-- ========== 加载 JSON ==========
local JsonUrl = "https://git.liulikeji.cn/GitHub/json.lua/raw/branch/master/json.lua"
local JsonResp = http.get(JsonUrl)
if not JsonResp then
error("无法下载 Json 框架")
end
local json = load(JsonResp.readAll())()
JsonResp.close()
-- ========== 加载 Basalt ========== -- ========== 加载 Basalt ==========
local basaltUrl = "https://git.liulikeji.cn/GitHub/Basalt/releases/download/v1.7/basalt.lua" local basaltUrl = "https://git.liulikeji.cn/GitHub/Basalt/releases/download/v1.7/basalt.lua"
@@ -21,134 +30,99 @@ local function log(msg)
--basalt.debug("[FileClient] " .. tostring(msg)) --basalt.debug("[FileClient] " .. tostring(msg))
end end
function table_to_json(t, indent)
indent = indent or 0 local function isBinaryFile(path)
local spaces = string.rep(" ", indent) local extension = string.lower(string.match(path, "%.([^%.%s]+)$") or "")
local result = {} local binaryExtensions = {
["dfpwm"] = true,
}
if type(t) ~= "table" then if binaryExtensions[extension] then
if type(t) == "string" then return true
-- 正确转义所有特殊字符
local escaped = t:gsub("[\\\"\b\f\n\r\t]", function(c)
local replacements = {
['\\'] = '\\\\',
['"'] = '\\"',
['\b'] = '\\b',
['\f'] = '\\f',
['\n'] = '\\n',
['\r'] = '\\r',
['\t'] = '\\t'
}
return replacements[c]
end)
return '"' .. escaped .. '"'
elseif type(t) == "number" or type(t) == "boolean" then
return tostring(t)
else
return '"' .. tostring(t) .. '"'
end
end end
-- 检查是否是数组 -- 对于没有扩展名的文件,检查内容
local is_array = true local absPath = path
local max_index = 0 if not fs.exists(absPath) then
local count = 0 return false
for k, v in pairs(t) do
count = count + 1
if type(k) ~= "number" or k <= 0 or math.floor(k) ~= k then
is_array = false
end
if type(k) == "number" and k > max_index then
max_index = k
end
end end
-- 空表当作对象处理 local ok, handle = pcall(fs.open, absPath, "rb")
if count == 0 then if not ok or not handle then
is_array = false return false
end end
if is_array then local data = handle.read(math.min(1024, fs.getSize(absPath)))
-- 处理数组 handle.close()
table.insert(result, "[")
local items = {} if not data then
for i = 1, max_index do return false
if t[i] ~= nil then end
table.insert(items, table_to_json(t[i], indent + 2))
else -- 检查是否存在控制字符(除常见的空白字符外)
table.insert(items, "null") for i = 1, #data do
local b = data:byte(i)
-- 控制字符范围是 0-8, 11-12, 14-31, 127
if (b >= 0 and b <= 8) or (b == 11) or (b == 12) or (b >= 14 and b <= 31) or (b == 127) then
-- 如果控制字符过多超过5%),则认为是二进制文件
local controlCount = 0
for j = 1, #data do
local byte = data:byte(j)
if (byte >= 0 and byte <= 8) or (byte == 11) or (byte == 12) or (byte >= 14 and byte <= 31) or (byte == 127) then
controlCount = controlCount + 1
end
end
if controlCount / #data > 0.05 then
return true
end end
end end
table.insert(result, table.concat(items, ", "))
table.insert(result, "]")
else
-- 处理对象
table.insert(result, "{")
local items = {}
for k, v in pairs(t) do
local key = '"' .. tostring(k) .. '"'
local value = table_to_json(v, indent + 2)
if indent > 0 then
table.insert(items, spaces .. " " .. key .. ": " .. value)
else
table.insert(items, key .. ":" .. value)
end
end
if indent > 0 then
table.insert(result, table.concat(items, ",\n"))
table.insert(result, "\n" .. spaces .. "}")
else
table.insert(result, table.concat(items, ","))
table.insert(result, "}")
end
end end
return table.concat(result, indent > 0 and "\n" .. spaces or "") return false
end end
local function cleanPath(path) local function cleanPath(path)
return (path:gsub("^computer/", ""):gsub("^computer\\", "")) return (path:gsub("^computer/", ""):gsub("^computer\\", ""))
end end
local function httpPost(path, data) local function httpPost(path, data)
local jsonData = table_to_json(data) local jsonData = json.encode(data)
local url = httpServer .. path local url = httpServer .. path
local response = http.post(url, jsonData, { -- 使用长轮询
["Content-Type"] = "application/json" local response,err = http.post({
url = url,
body = jsonData,
method = "POST",
headers = {
["Content-Type"] = "application/json"
},
timeout = 60
}) })
if not response then if not response then
return nil, "无法连接到服务器" return nil, "0 "..err
end end
local responseBody = response.readAll() local responseBody = response.readAll()
response.close() response.close()
local ok, result = pcall(textutils.unserialiseJSON, responseBody) local ok, result = pcall(json.decode, responseBody)
if ok then if ok then
return result return result
else else
return nil, "无效的JSON响应" return nil, "1无效的JSON响应: " .. responseBody
end end
end end
-- ========== 文件系统操作 ========== -- ========== 文件系统操作 ==========
local function isLikelyText(data)
for i = 1, math.min(#data, 1024) do
local b = data:byte(i)
if b < 32 and b ~= 9 and b ~= 10 and b ~= 13 then
return false
end
end
return true
end
local function getFiles(currentPath, result, prefix) local function getFiles(currentPath, result, prefix)
local computerPrefix = "computer_" .. computerID local computerPrefix = "computer_" .. computerID
local fullPrefix = currentPath == "" and prefix:sub(1, -2) or prefix .. currentPath local fullPrefix = currentPath == "" and prefix:sub(1, -2) or prefix .. currentPath
local absPath = "/" .. (currentPath == "" and "" or currentPath) local absPath = "/" .. (currentPath == "" and "" or currentPath)
if fs.isDir(absPath) then if fs.isDir(absPath) then
result[fullPrefix] = { isFolder = true } result[fullPrefix] = { isFolder = true }
for _, entry in ipairs(fs.list(absPath)) do for _, entry in ipairs(fs.list(absPath)) do
@@ -158,15 +132,16 @@ local function getFiles(currentPath, result, prefix)
end end
else else
local content = "[binary]" local content = "[binary]"
local ok, handle = pcall(fs.open, absPath, "rb") if not isBinaryFile(absPath) then
if ok and handle then local ok, handle = pcall(fs.open, absPath, "r")
local data = handle.readAll() if ok and handle then
handle.close() local data = handle.readAll()
if data and isLikelyText(data) then handle.close()
content = data content = data or ""
end end
end end
result[fullPrefix] = { isFile = true, content = content }
result[fullPrefix] = { isFile = true, content = content, isBinary = isBinaryFile(absPath) }
end end
end end
@@ -292,10 +267,13 @@ end
local function sendResponse(response) local function sendResponse(response)
if response and roomId then if response and roomId then
httpPost("/api/client/send", { local result, err = httpPost("/api/client/send", {
room_id = roomId, room_id = roomId,
message = response message = response
}) })
if not result then
log("3 发送响应失败: " .. tostring(err))
end
end end
end end
@@ -305,52 +283,56 @@ local function pollMessages()
sleep(pollInterval) sleep(pollInterval)
break break
end end
local result, err = httpPost("/api/client/receive", { local result, err = httpPost("/api/client/receive", {
room_id = roomId room_id = roomId
}) })
if result and result.success and result.message then if result and result.success then
local msg = result.message local msg = result.message
local msgType = msg.type log(msg)
if msg then
if msgType == "file_operation" or msgType == "file_operation_request" then local msgType = msg.type
local op = msg.operation_type or msg.type
local data = msg.data or {} if msgType == "file_operation" or msgType == "file_operation_request" then
local reqId = msg.requestId or msg.request_id local op = msg.operation_type or msg.type
local sender = msg.sender_id local data = msg.data or {}
local reqId = msg.requestId or msg.request_id
local response local sender = msg.sender_id
if op == "fetch_files" then local response
response = handleFetchFiles(reqId, sender)
elseif op == "create_or_save_file" then if op == "fetch_files" then
response = handleSaveFile(data, reqId, sender) response = handleFetchFiles(reqId, sender)
elseif op == "new_file" then elseif op == "create_or_save_file" then
response = handleCreateFile(data, reqId, sender) response = handleSaveFile(data, reqId, sender)
elseif op == "new_folder" then elseif op == "new_file" then
response = handleCreateFolder(data, reqId, sender) response = handleCreateFile(data, reqId, sender)
elseif op == "rename" then elseif op == "new_folder" then
response = handleRename(data, reqId, sender) response = handleCreateFolder(data, reqId, sender)
elseif op == "delete_file" then elseif op == "rename" then
response = handleDelete(data, reqId, sender) response = handleRename(data, reqId, sender)
else elseif op == "delete_file" then
response = { response = handleDelete(data, reqId, sender)
type = "file_operation_response", else
requestId = reqId, response = {
success = false, type = "file_operation_response",
error = "Unknown operation: " .. tostring(op), requestId = reqId,
target_client_id = sender success = false,
} error = "Unknown operation: " .. tostring(op),
target_client_id = sender
}
end
sendResponse(response)
end end
sendResponse(response)
end end
elseif err then elseif err then
log("轮询错误: " .. tostring(err)) log("2 轮询错误: " .. tostring(err))
-- 如果是连接错误,稍后再试
sleep(5)
end end
sleep(pollInterval)
end end
end end
@@ -369,7 +351,7 @@ local function main()
os.queueEvent("mouse_click",1,1,1) os.queueEvent("mouse_click",1,1,1)
-- 启动消息轮询 -- 启动消息轮询
mainFrame:addThread():start(pollMessages) mainFrame:addThread():start(pollMessages)
log("客户端已启动。房间ID: " .. roomId) log("客户端已启动。房间ID: " .. roomId)
log("计算机ID: " .. computerID) log("计算机ID: " .. computerID)
log("按 Q 退出") log("按 Q 退出")
@@ -385,4 +367,4 @@ local function main()
end end
-- 启动主逻辑和Basalt事件循环 -- 启动主逻辑和Basalt事件循环
parallel.waitForAll(basalt.autoUpdate, main) parallel.waitForAll(basalt.autoUpdate, main)

View File

@@ -1,13 +0,0 @@
import tomllib
# 您的 TOML 字符串
toml_str = '''{ a = "你好", "a-c-v" = "你好", b = "世界", }'''
# 反序列化
data = tomllib.loads(toml_str)
print(data)
# 输出: {'a': '你好', 'a-c-v': '你好', 'b': '世界'}
# 访问数据
print(data['a']) # 输出: 你好
print(data['a-c-v']) # 输出: 你好

View File

@@ -2,7 +2,7 @@ import type { Files } from 'monaco-tree-editor'
let roomId: string | null = null let roomId: string | null = null
let serverUrl: string | null = null let serverUrl: string | null = null
let pollIntervalMs = 1000 let pollIntervalMs = 100
let isPolling = false let isPolling = false
let pollingTimeout: number | null = null let pollingTimeout: number | null = null
@@ -32,29 +32,6 @@ function getParamsFromUrl(): { roomId: string | null } {
return { roomId } return { roomId }
} }
async function httpPost(path: string, data: any): Promise<any> {
const url = `${serverUrl}${path}`
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error(`HTTP错误: ${response.status} ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.error('HTTP请求失败:', error)
throw error
}
}
export async function initWebSocketConnection(): Promise<void> { export async function initWebSocketConnection(): Promise<void> {
const params = getParamsFromUrl() const params = getParamsFromUrl()
roomId = params.roomId roomId = params.roomId
@@ -67,6 +44,17 @@ export async function initWebSocketConnection(): Promise<void> {
console.log('HTTP连接已初始化服务器:', serverUrl) console.log('HTTP连接已初始化服务器:', serverUrl)
console.log('房间ID:', roomId) console.log('房间ID:', roomId)
// 处理挂起的初始请求
for (const request of pendingInitialRequests) {
try {
const result = await sendFileOperationInternal(request.operation, request.data)
request.resolve(result)
} catch (error) {
request.reject(error as Error)
}
}
pendingInitialRequests.length = 0
startPolling() startPolling()
return Promise.resolve() return Promise.resolve()
} }
@@ -86,18 +74,33 @@ function stopPolling() {
} }
async function pollForResponses() { async function pollForResponses() {
if (!isPolling || !roomId) return if (!isPolling || !roomId || !serverUrl) return
try { try {
const response = await httpPost('/api/frontend/receive', { const response = await fetch(`${serverUrl}/api/frontend/receive`, {
room_id: roomId, method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
room_id: roomId,
}),
}) })
if (response.success && response.message) { if (response.ok) {
handleMessage(response.message) const data = await response.json()
if (data.success && data.message) {
handleMessage(data.message)
}
} else {
console.error('轮询请求失败:', response.status)
// 短暂的延迟后重试
await new Promise((resolve) => setTimeout(resolve, 2000))
} }
} catch (error) { } catch (error) {
console.error('轮询消息失败:', error) console.error('轮询消息失败:', error)
// 网络错误,稍后重试
await new Promise((resolve) => setTimeout(resolve, 5000))
} }
if (isPolling) { if (isPolling) {
@@ -130,8 +133,13 @@ function handleFileOperationResponse(data: any): void {
} }
} }
function sendFileOperationInternal(operationType: string, data?: any, timeoutMs: number = 30000): Promise<any> { async function sendFileOperationInternal(operationType: string, data?: any, timeoutMs: number = 30000): Promise<any> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!serverUrl || !roomId) {
reject(new Error('未初始化连接'))
return
}
const requestId = generateRequestId() const requestId = generateRequestId()
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
@@ -143,21 +151,28 @@ function sendFileOperationInternal(operationType: string, data?: any, timeoutMs:
pendingRequests.set(requestId, { resolve, reject, timeout }) pendingRequests.set(requestId, { resolve, reject, timeout })
httpPost('/api/frontend/send', { fetch(`${serverUrl}/api/frontend/send`, {
room_id: roomId, method: 'POST',
message: { headers: {
type: 'file_operation', 'Content-Type': 'application/json',
requestId: requestId,
operation_type: operationType,
data: data,
room_id: roomId,
}, },
body: JSON.stringify({
room_id: roomId,
message: {
type: 'file_operation',
requestId: requestId,
operation_type: operationType,
data: data,
room_id: roomId,
},
}),
}) })
.then((response) => { .then(async (response) => {
if (!response.success) { if (!response.ok) {
const errorText = await response.text()
pendingRequests.delete(requestId) pendingRequests.delete(requestId)
clearTimeout(timeout) clearTimeout(timeout)
reject(new Error(response.message || '发送请求失败')) reject(new Error(errorText || `发送请求失败: ${response.status}`))
} }
}) })
.catch((error) => { .catch((error) => {

View File

@@ -3,9 +3,11 @@ import logging
import uuid import uuid
import time import time
import os import os
import asyncio
import aiohttp
from aiohttp import web
from datetime import datetime from datetime import datetime
from typing import Dict, Any, List from typing import Dict, Any, List
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
import mimetypes import mimetypes
import re import re
@@ -20,6 +22,8 @@ rooms = {}
frontend_to_client_queues = {} frontend_to_client_queues = {}
# 客户端到前端的消息队列 # 客户端到前端的消息队列
client_to_frontend_queues = {} client_to_frontend_queues = {}
# 挂起的请求管理
pending_requests = {}
# 静态文件目录 # 静态文件目录
STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static') STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
@@ -31,10 +35,10 @@ class Room:
def __init__(self, room_id: str, server_host: str): def __init__(self, room_id: str, server_host: str):
self.room_id = room_id self.room_id = room_id
self.created_at = datetime.now() self.created_at = datetime.now()
# 从host中移除端口号 # 从host中移除端口号
host_without_port = re.sub(r':\d+$', '', server_host) host_without_port = re.sub(r':\d+$', '', server_host)
self.frontend_url = f"http://{server_host}/?id={room_id}" self.frontend_url = f"http://{server_host}/?id={room_id}"
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
@@ -44,6 +48,18 @@ class Room:
'created_at': self.created_at.isoformat() 'created_at': self.created_at.isoformat()
} }
def safe_decode_json(raw_body: bytes) -> Dict[Any, Any]:
"""
尝试用 UTF-8 解码,失败则尝试 GB18030兼容 GBK/GB2312
"""
for encoding in ['utf-8', 'gb18030', 'latin1']:
try:
text = raw_body.decode(encoding)
return json.loads(text)
except (UnicodeDecodeError, json.JSONDecodeError):
continue
raise ValueError("无法解码请求体为有效 JSON")
def get_frontend_to_client_queue(room_id: str) -> List[Dict[str, Any]]: def get_frontend_to_client_queue(room_id: str) -> List[Dict[str, Any]]:
if room_id not in frontend_to_client_queues: if room_id not in frontend_to_client_queues:
frontend_to_client_queues[room_id] = [] frontend_to_client_queues[room_id] = []
@@ -54,326 +70,414 @@ def get_client_to_frontend_queue(room_id: str) -> List[Dict[str, Any]]:
client_to_frontend_queues[room_id] = [] client_to_frontend_queues[room_id] = []
return client_to_frontend_queues[room_id] return client_to_frontend_queues[room_id]
class HTTPHandler(BaseHTTPRequestHandler): class HTTPHandler:
def do_GET(self): async def handle_create_room(self, request):
"""处理HTTP GET请求""" """创建新房间"""
try: try:
parsed_path = urlparse(self.path) # 生成唯一房间ID
path = parsed_path.path room_id = str(uuid.uuid4())[:8]
query_params = parse_qs(parsed_path.query)
# 获取服务器主机地址并移除端口号
# API路由 host = request.headers.get('Host', 'localhost')
if path == '/api/room' and self.command == 'POST': host_without_port = re.sub(r':\d+$', '', host)
self.handle_create_room()
elif path.startswith('/api/room/') and self.command == 'GET': # 创建房间
room_id = path.split('/')[-1] room = Room(room_id, host_without_port)
self.handle_get_room(room_id) rooms[room_id] = room
elif path == '/api/rooms' and self.command == 'GET':
self.handle_list_rooms() logger.info(f"创建新房间: {room_id}")
# 根路径处理
elif path == '/': response = {
self.handle_root_path(query_params) 'success': True,
# 静态文件服务 'room_id': room_id,
else: 'frontend_url': room.frontend_url
self.handle_static_file(path) }
return web.json_response(response)
except Exception as e: except Exception as e:
logger.error(f"处理GET请求时发生错误: {e}") logger.error(f"创建房间失败: {e}")
self.send_error(500, f"Internal Server Error: {str(e)}") return web.json_response({'error': str(e)}, status=500)
def do_POST(self): async def handle_frontend_send_message(self, request):
"""处理HTTP POST请求"""
try: try:
content_length = int(self.headers.get('Content-Length', 0)) raw_body = await request.read()
post_data = self.rfile.read(content_length) if content_length > 0 else b'{}' data = safe_decode_json(raw_body)
room_id = data.get('room_id')
parsed_path = urlparse(self.path) message_data = data.get('message')
path = parsed_path.path
if not room_id or not message_data:
if path == '/api/room': return web.json_response({'error': '需要room_id和message参数'}, status=400)
self.handle_create_room(post_data)
elif path == '/api/frontend/send': queue = get_frontend_to_client_queue(room_id)
self.handle_frontend_send_message(post_data) queue.append(message_data)
elif path == '/api/frontend/receive': if room_id in pending_requests and 'client' in pending_requests[room_id]:
self.handle_frontend_receive_message(post_data) client_req_id = pending_requests[room_id]['client']
elif path == '/api/client/send': if client_req_id in pending_requests:
self.handle_client_send_message(post_data) pending_requests[client_req_id]['event'].set()
elif path == '/api/client/receive': logger.info(f"立即响应挂起的客户端请求: {client_req_id}")
self.handle_client_receive_message(post_data)
else: return web.json_response({
self.send_error(404, "Not Found") 'success': True,
'message': '消息已发送到客户端队列'
})
except ValueError as e:
logger.error(f"前端发送消息失败: 无效的JSON或编码错误 - {e}")
return web.json_response({'error': '无效的JSON数据或不支持的文本编码'}, status=400)
except Exception as e: except Exception as e:
logger.error(f"处理POST请求时发生错误: {e}") logger.error(f"前端发送消息失败: {e}")
self.send_error(500, f"Internal Server Error: {str(e)}") return web.json_response({'error': str(e)}, status=500)
def handle_root_path(self, query_params: Dict[str, Any]): async def handle_frontend_receive_message(self, request):
"""处理根路径请求""" """前端接收来自客户端的消息(长轮询)"""
room_id = query_params.get('id', [None])[0] try:
data = await request.json()
if room_id: room_id = data.get('room_id')
# 有房间ID参数直接返回前端页面
logger.info(f"请求根路径有房间ID: {room_id}") if not room_id:
self.serve_static_file('/index.html') return web.json_response({'error': '需要room_id参数'}, status=400)
else:
# 没有房间ID创建新房间并重定向 queue = get_client_to_frontend_queue(room_id)
# 立即检查是否有消息
if queue:
message = queue.pop(0)
response = {
'success': True,
'message': message
}
return web.json_response(response)
# 没有消息,设置长轮询
req_id = str(uuid.uuid4())
event = asyncio.Event()
# 存储挂起的请求
pending_requests[req_id] = {
'room_id': room_id,
'event': event,
'type': 'frontend',
'timestamp': time.time()
}
if room_id not in pending_requests:
pending_requests[room_id] = {}
pending_requests[room_id]['frontend'] = req_id
try: try:
# 生成唯一房间ID # 等待295秒或直到有消息
room_id = str(uuid.uuid4())[:8] await asyncio.wait_for(event.wait(), timeout=295)
# 获取服务器主机地址并移除端口号 # 检查队列中是否有消息
host = self.headers.get('Host', 'localhost') queue = get_client_to_frontend_queue(room_id)
host_without_port = re.sub(r':\d+$', '', host) if queue:
message = queue.pop(0)
response = {
'success': True,
'message': message
}
else:
# 超时返回空消息
response = {
'success': True,
'message': None
}
except asyncio.TimeoutError:
# 超时返回空消息
response = {
'success': True,
'message': None
}
# 清理挂起的请求
if req_id in pending_requests:
del pending_requests[req_id]
if room_id in pending_requests and 'frontend' in pending_requests[room_id]:
del pending_requests[room_id]['frontend']
# 创建房间 return web.json_response(response)
room = Room(room_id, host_without_port)
rooms[room_id] = room except json.JSONDecodeError:
logger.error("JSON解析失败")
return web.json_response({'error': '无效的JSON数据'}, status=400)
except Exception as e:
logger.error(f"前端接收消息失败: {e}")
# 清理挂起的请求
if 'req_id' in locals() and req_id in pending_requests:
del pending_requests[req_id]
if room_id in pending_requests and 'frontend' in pending_requests[room_id]:
del pending_requests[room_id]['frontend']
return web.json_response({'error': str(e)}, status=500)
async def handle_client_send_message(self, request):
try:
raw_body = await request.read() # 获取原始字节
data = safe_decode_json(raw_body)
room_id = data.get('room_id')
message_data = data.get('message')
if not room_id or not message_data:
return web.json_response({'error': '需要room_id和message参数'}, status=400)
queue = get_client_to_frontend_queue(room_id)
queue.append(message_data)
if room_id in pending_requests and 'frontend' in pending_requests[room_id]:
frontend_req_id = pending_requests[room_id]['frontend']
if frontend_req_id in pending_requests:
pending_requests[frontend_req_id]['event'].set()
logger.info(f"立即响应挂起的前端请求: {frontend_req_id}")
return web.json_response({
'success': True,
'message': '消息已发送到前端队列'
})
except ValueError as e:
logger.error(f"客户端发送消息失败: 无效的JSON或编码错误 - {e}")
return web.json_response({'error': '无效的JSON数据或不支持的文本编码'}, status=400)
except Exception as e:
logger.error(f"客户端发送消息失败: {e}")
return web.json_response({'error': str(e)}, status=500)
async def handle_client_receive_message(self, request):
"""客户端接收来自前端的消息(长轮询)"""
try:
raw_body = await request.read()
data = safe_decode_json(raw_body) # 使用你之前添加的 safe_decode_json
room_id = data.get('room_id')
if not room_id:
return web.json_response({'error': '需要room_id参数'}, status=400)
queue = get_frontend_to_client_queue(room_id)
# 立即检查是否有消息
if queue:
message = queue.pop(0)
response = {
'success': True,
'message': message
}
return web.json_response(response)
# 没有消息,设置长轮询(最多等待 58 秒)
req_id = str(uuid.uuid4())
event = asyncio.Event()
# 存储挂起的请求
pending_requests[req_id] = {
'room_id': room_id,
'event': event,
'type': 'client',
'timestamp': time.time()
}
if room_id not in pending_requests:
pending_requests[room_id] = {}
pending_requests[room_id]['client'] = req_id
try:
# ⏱️ 只等待 58 秒(略小于客户端或代理的 60 秒超时)
await asyncio.wait_for(event.wait(), timeout=58)
logger.info(f"通过根路径创建新房间: {room_id}") # 被唤醒后,检查队列
queue = get_frontend_to_client_queue(room_id)
if queue:
message = queue.pop(0)
response = {
'success': True,
'message': message
}
else:
response = {
'success': True,
'message': None
}
except asyncio.TimeoutError:
# 58秒超时返回空消息
response = {
'success': True,
'message': None
}
# 清理挂起的请求
pending_requests.pop(req_id, None)
if room_id in pending_requests:
pending_requests[room_id].pop('client', None)
# 如果 room_id 下已无其他引用,也可以清理整个 room 条目(可选)
# 重定向到带房间ID的URL return web.json_response(response)
redirect_url = f'/?id={room_id}'
self.send_response(302) except ValueError as e:
self.send_header('Location', redirect_url) logger.error(f"客户端接收消息失败: 无效JSON或编码 - {e}")
self.end_headers() return web.json_response({'error': '无效的JSON数据'}, status=400)
except Exception as e:
except Exception as e: logger.error(f"客户端接收消息失败: {e}")
logger.error(f"创建房间失败: {e}") # 清理挂起的请求
self.send_error(500, str(e)) if 'req_id' in locals():
pending_requests.pop(req_id, None)
def handle_static_file(self, path: str): if 'room_id' in locals() and room_id in pending_requests:
pending_requests[room_id].pop('client', None)
return web.json_response({'error': str(e)}, status=500)
async def handle_static_file(self, request):
"""处理静态文件请求""" """处理静态文件请求"""
path = request.match_info.get('path', '')
# 安全检查:防止路径遍历攻击 # 安全检查:防止路径遍历攻击
if '..' in path: if '..' in path:
self.send_error(403, "Forbidden: Path traversal not allowed") return web.Response(text="Forbidden: Path traversal not allowed", status=403)
return
# 规范化路径 # 规范化路径
if path == '/': if path == '' or path == '/':
path = '/index.html' path = 'index.html'
# 移除开头的斜杠
file_path = path.lstrip('/')
if not file_path:
file_path = 'index.html'
# 构建完整文件路径 # 构建完整文件路径
full_path = os.path.join(STATIC_DIR, file_path) full_path = os.path.join(STATIC_DIR, path)
# 如果是目录尝试查找index.html # 如果是目录尝试查找index.html
if os.path.isdir(full_path): if os.path.isdir(full_path):
index_path = os.path.join(full_path, 'index.html') index_path = os.path.join(full_path, 'index.html')
if os.path.exists(index_path): if os.path.exists(index_path):
full_path = index_path full_path = index_path
else: else:
self.send_error(404, "Directory index not found") return web.Response(text="Directory index not found", status=404)
return
# 检查文件是否存在且是普通文件 # 检查文件是否存在且是普通文件
if not os.path.exists(full_path) or not os.path.isfile(full_path): if not os.path.exists(full_path) or not os.path.isfile(full_path):
self.send_error(404, f"File not found: {path}") return web.Response(text=f"File not found: {path}", status=404)
return
try: try:
# 读取文件内容 # 读取文件内容
with open(full_path, 'rb') as f: with open(full_path, 'rb') as f:
content = f.read() content = f.read()
# 获取MIME类型 # 获取MIME类型
mime_type, encoding = mimetypes.guess_type(full_path) mime_type, encoding = mimetypes.guess_type(full_path)
if mime_type is None: if mime_type is None:
mime_type = 'application/octet-stream' mime_type = 'application/octet-stream'
# 发送响应 # 发送响应
self.send_response(200) return web.Response(
self.send_header('Content-Type', mime_type) body=content,
self.send_header('Content-Length', str(len(content))) content_type=mime_type,
self.send_header('Cache-Control', 'public, max-age=3600') headers={
self.end_headers() 'Cache-Control': 'public, max-age=3600'
}
# 发送文件内容 )
self.wfile.write(content)
except Exception as e: except Exception as e:
logger.error(f"读取或发送文件失败: {e}") logger.error(f"读取或发送文件失败: {e}")
self.send_error(500, f"Error reading file: {str(e)}") return web.Response(text=f"Error reading file: {str(e)}", status=500)
async def handle_root_path(self, request):
"""处理根路径请求"""
query_params = dict(request.query)
room_id = query_params.get('id')
if room_id:
# 有房间ID参数直接返回前端页面
logger.info(f"请求根路径有房间ID: {room_id}")
return await self.handle_static_file(request)
else:
# 没有房间ID创建新房间并重定向
try:
# 生成唯一房间ID
room_id = str(uuid.uuid4())[:8]
# 获取服务器主机地址并移除端口号
host = request.headers.get('Host', 'localhost')
host_without_port = re.sub(r':\d+$', '', host)
# 创建房间
room = Room(room_id, host_without_port)
rooms[room_id] = room
logger.info(f"通过根路径创建新房间: {room_id}")
# 重定向到带房间ID的URL
redirect_url = f'/?id={room_id}'
return web.HTTPFound(redirect_url)
except Exception as e:
logger.error(f"创建房间失败: {e}")
return web.Response(text=str(e), status=500)
async def create_app():
"""创建Web应用"""
handler = HTTPHandler()
def serve_static_file(self, path: str): app = web.Application()
"""服务静态文件(内部方法)"""
self.handle_static_file(path)
def handle_create_room(self, post_data=None): # API路由
"""创建新房间""" app.router.add_post('/api/room', handler.handle_create_room)
app.router.add_post('/api/frontend/send', handler.handle_frontend_send_message)
app.router.add_post('/api/frontend/receive', handler.handle_frontend_receive_message)
app.router.add_post('/api/client/send', handler.handle_client_send_message)
app.router.add_post('/api/client/receive', handler.handle_client_receive_message)
# 静态文件和根路径路由
app.router.add_get('/', handler.handle_root_path)
app.router.add_get('/{path:.*}', handler.handle_static_file)
return app
async def cleanup_pending_requests():
"""定期清理过期的挂起请求"""
while True:
await asyncio.sleep(60) # 每分钟清理一次
current_time = time.time()
expired_reqs = []
for req_id, req_info in pending_requests.items():
if current_time - req_info['timestamp'] > 300: # 超过5分钟
expired_reqs.append(req_id)
for req_id in expired_reqs:
if req_id in pending_requests:
# 尝试触发事件以释放挂起的请求
try:
pending_requests[req_id]['event'].set()
except:
pass
del pending_requests[req_id]
if expired_reqs:
logger.info(f"清理了 {len(expired_reqs)} 个过期的挂起请求")
async def start_background_tasks(app):
"""启动后台任务"""
app['cleanup_task'] = asyncio.create_task(cleanup_pending_requests())
async def cleanup_background_tasks(app):
"""清理后台任务"""
if 'cleanup_task' in app:
app['cleanup_task'].cancel()
try: try:
# 生成唯一房间ID await app['cleanup_task']
room_id = str(uuid.uuid4())[:8] except asyncio.CancelledError:
pass
# 获取服务器主机地址并移除端口号
host = self.headers.get('Host', 'localhost')
host_without_port = re.sub(r':\d+$', '', host)
# 创建房间
room = Room(room_id, host_without_port)
rooms[room_id] = room
logger.info(f"创建新房间: {room_id}")
response = {
'success': True,
'room_id': room_id,
'frontend_url': room.frontend_url
}
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(response).encode())
except Exception as e:
logger.error(f"创建房间失败: {e}")
self.send_error(500, str(e))
def handle_frontend_send_message(self, post_data):
"""前端发送消息到客户端"""
try:
data = json.loads(post_data.decode('utf-8'))
room_id = data.get('room_id')
message = data.get('message')
if not room_id or not message:
self.send_error(400, "需要room_id和message参数")
return
queue = get_frontend_to_client_queue(room_id)
queue.append(message)
response = {
'success': True,
'message': '消息已发送到客户端队列'
}
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(response).encode())
except Exception as e:
logger.error(f"前端发送消息失败: {e}")
self.send_error(500, str(e))
def handle_frontend_receive_message(self, post_data):
"""前端接收来自客户端的消息"""
try:
data = json.loads(post_data.decode('utf-8'))
room_id = data.get('room_id')
if not room_id:
self.send_error(400, "需要room_id参数")
return
queue = get_client_to_frontend_queue(room_id)
if queue:
# 返回队列中的第一个消息
message = queue.pop(0)
response = {
'success': True,
'message': message
}
else:
# 没有消息
response = {
'success': True,
'message': None
}
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(response).encode())
except Exception as e:
logger.error(f"前端接收消息失败: {e}")
self.send_error(500, str(e))
def handle_client_send_message(self, post_data):
"""客户端发送消息到前端"""
try:
data = json.loads(post_data.decode('utf-8'))
room_id = data.get('room_id')
message = data.get('message')
if not room_id or not message:
self.send_error(400, "需要room_id和message参数")
return
queue = get_client_to_frontend_queue(room_id)
queue.append(message)
response = {
'success': True,
'message': '消息已发送到前端队列'
}
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(response).encode())
except Exception as e:
logger.error(f"客户端发送消息失败: {e}")
self.send_error(500, str(e))
def handle_client_receive_message(self, post_data):
"""客户端接收来自前端的消息"""
try:
data = json.loads(post_data.decode('utf-8'))
room_id = data.get('room_id')
if not room_id:
self.send_error(400, "需要room_id参数")
return
queue = get_frontend_to_client_queue(room_id)
if queue:
# 返回队列中的第一个消息
message = queue.pop(0)
response = {
'success': True,
'message': message
}
else:
# 没有消息
response = {
'success': True,
'message': None
}
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(response).encode())
except Exception as e:
logger.error(f"客户端接收消息失败: {e}")
self.send_error(500, str(e))
def log_message(self, format, *args):
"""重写日志方法"""
logger.info("%s - - [%s] %s" % (self.client_address[0],
self.log_date_time_string(),
format % args))
def run_http_server(): def run_http_server():
"""运行HTTP服务器""" """运行HTTP服务器"""
try: try:
server = HTTPServer(('0.0.0.0', 80), HTTPHandler) # 创建应用
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
app = loop.run_until_complete(create_app())
# 注册启动和清理钩子
app.on_startup.append(start_background_tasks)
app.on_cleanup.append(cleanup_background_tasks)
# 启动服务器
logger.info("HTTP服务器启动在端口 80") logger.info("HTTP服务器启动在端口 80")
server.serve_forever() web.run_app(app, host='0.0.0.0', port=80)
except Exception as e: except Exception as e:
logger.error(f"HTTP服务器启动失败: {e}") logger.error(f"HTTP服务器启动失败: {e}")

View File

@@ -1,4 +1 @@
websockets aiohttp
aiohttp
requests
python-dateutil

View File

@@ -12,7 +12,7 @@ Demo: http://cc-web-edit.liulikeji.cn
- **远程文件管理**:实时浏览、编辑和管理 CC:Tweaked 计算机中的文件 - **远程文件管理**:实时浏览、编辑和管理 CC:Tweaked 计算机中的文件
- **Monaco 编辑器**:基于 VS Code 的 Monaco 编辑器,提供专业的代码编辑体验 - **Monaco 编辑器**:基于 VS Code 的 Monaco 编辑器,提供专业的代码编辑体验
- **HTTP 通信**:基于 HTTP 协议的可靠通信 - **低延迟通信**:基于 HTTP 长轮询 + 请求挂起机制,模拟 WebSocket 的低延迟传输
### 文件操作 ### 文件操作
@@ -27,7 +27,7 @@ Demo: http://cc-web-edit.liulikeji.cn
- **自动命令生成**:根据 URL 参数自动生成连接命令 - **自动命令生成**:根据 URL 参数自动生成连接命令
- **一键复制**:点击即可复制连接命令到剪贴板 - **一键复制**:点击即可复制连接命令到剪贴板
- **房间管理**:支持创建和加入房间 - **房间管理**:支持创建和加入房间
- **轮询机制**HTTP 轮询确保连接稳定性 - **实时通信**:高效的请求挂起机制确保接近实时的响应
## 🚀 快速开始 ## 🚀 快速开始
@@ -127,7 +127,7 @@ cp -r dist/* ../PyServer/static/
- **二进制文件**:非文本文件会显示为 `[binary]`,无法在线编辑 - **二进制文件**:非文本文件会显示为 `[binary]`,无法在线编辑
- **单客户端**:目前主要支持一个网页端和一个 CC 客户端的配对使用 - **单客户端**:目前主要支持一个网页端和一个 CC 客户端的配对使用
- **文件大小**:上传文件限制为 1MB - **文件大小**:上传文件限制为 1MB
- **轮询延迟**HTTP 轮询机制可能有轻微延迟(默认 2 秒) - **延迟**HTTP 轮询机制提供接近实时的响应体验
### 计划功能 ### 计划功能
@@ -140,9 +140,17 @@ cp -r dist/* ../PyServer/static/
- `POST /api/room` - 创建房间 - `POST /api/room` - 创建房间
- `POST /api/frontend/send` - 前端发送消息到客户端 - `POST /api/frontend/send` - 前端发送消息到客户端
- `POST /api/frontend/receive` - 前端接收来自客户端的消息 - `POST /api/frontend/receive` - 前端接收来自客户端的消息(长轮询)
- `POST /api/client/send` - 客户端发送消息到前端 - `POST /api/client/send` - 客户端发送消息到前端
- `POST /api/client/receive` - 客户端接收来自前端的消息 - `POST /api/client/receive` - 客户端接收来自前端的消息(长轮询)
### 通信机制
**长轮询 + 请求挂起**
- 客户端请求挂起最长达 295 秒,直到有消息到达
- 当有新消息时立即响应,实现低延迟传输
- 服务器端控制请求超时,避免不必要的轮询
### 消息类型 ### 消息类型
@@ -186,27 +194,24 @@ A: 确保 CC 客户端已成功连接,然后刷新文件列表
**Q: 文件上传失败** **Q: 文件上传失败**
A: 检查文件大小是否超过 1MB 限制 A: 检查文件大小是否超过 1MB 限制
**Q: 操作响应较慢** **Q: 消息传输延迟**
A: 默认轮询间隔为 1 秒,可通过调整代码中的轮询间隔改善 A: 服务器使用长轮询机制,响应通常在毫秒级别
## 📄 技术说明 ## 📄 技术说明
- **后端**Python + HTTP Server - **后端**Python + HTTP Server + 长轮询机制
- **前端**Vue 3 + TypeScript + Monaco Editor - **前端**Vue 3 + TypeScript + Monaco Editor
- **通信**HTTP 轮询机制实现双向通信 - **通信**HTTP 轮询 + 请求挂起实现低延迟通信
- **客户端**CC:Tweaked + HTTP - **客户端**CC:Tweaked + HTTP
## 🤝 开发说明 ## 🤝 开发说明
<<<<<<< HEAD 该项目使用创新的 HTTP 长轮询 + 请求挂起机制来模拟 WebSocket 的低延迟传输特性,为远程代码编辑提供了近乎实时的响应体验。
该项目目前主要支持远程代码编辑功能使用 HTTP 协议替代 WebSocket提高了兼容性和部署便利性远程控制台功能计划在后续版本中开发
======= 远程控制台功能计划在后续版本中开发。
该项目目前主要支持远程代码编辑功能远程控制台功能计划在后续版本中开发
## 贡献 ## 贡献
你可以制作适配不同平台的客户端然后共享其代码 你可以制作适配不同平台的客户端然后共享其代码
欢迎提交issues 欢迎提交 issues
>>>>>>> d3faa4b74bc0eeac9a272c4d8a348d98a48dad7e