From 4dffee96ece576e264cfdb8e6c32b339ed021c4b Mon Sep 17 00:00:00 2001 From: F18-Ray Date: Mon, 7 Sep 2026 19:45:14 +0800 Subject: [PATCH 01/11] core-code: add the web applications for the servers and clients for easier to use --- .gitignore | 2 + PyFlow/flow_setup.py | 65 +++ PyFlow/transfer_web/setup_client.py | 28 + PyFlow/transfer_web/setup_server.py | 41 ++ PyFlow/transfer_web/static/common.css | 429 +++++++++++++++ PyFlow/transfer_web/static/common.js | 482 +++++++++++++++++ .../web_backend/server_backend.py | 501 ++++++++++++++++++ .../web_backend/templates/server_config.html | 99 ++++ .../web_backend/templates/server_status.html | 49 ++ .../transfer_web/web_front/client_backend.py | 445 ++++++++++++++++ .../web_front/templates/client_connect.html | 66 +++ .../web_front/templates/client_main.html | 49 ++ README.md | 38 +- pyproject.toml | 2 +- test/test_flow_setup.py | 75 ++- uv.lock | 51 ++ 16 files changed, 2409 insertions(+), 13 deletions(-) create mode 100755 PyFlow/transfer_web/setup_client.py create mode 100755 PyFlow/transfer_web/setup_server.py create mode 100644 PyFlow/transfer_web/static/common.css create mode 100644 PyFlow/transfer_web/static/common.js create mode 100644 PyFlow/transfer_web/web_backend/server_backend.py create mode 100644 PyFlow/transfer_web/web_backend/templates/server_config.html create mode 100644 PyFlow/transfer_web/web_backend/templates/server_status.html create mode 100644 PyFlow/transfer_web/web_front/client_backend.py create mode 100644 PyFlow/transfer_web/web_front/templates/client_connect.html create mode 100644 PyFlow/transfer_web/web_front/templates/client_main.html diff --git a/.gitignore b/.gitignore index 7800dbd..7284d1a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ docs/.sync *~ PyFlow/setup.json PyFlow/network_api/received_files +PyFlow/added_extensions.json +PyFlow/transfer_web/.Flow_Web/ pyflow_net.egg-info dist .coverage diff --git a/PyFlow/flow_setup.py b/PyFlow/flow_setup.py index 99d6f01..19256dd 100644 --- a/PyFlow/flow_setup.py +++ b/PyFlow/flow_setup.py @@ -179,6 +179,59 @@ def launch_instance(config, instance_type): pass +# ---- web tool launchers (transfer_web) -------------------------------------- + +def launch_web_tool(kind): + """Launch the transfer_web launcher (``kind`` = "server" or "client"). + + The web tool is a Flask app that opens a browser UI, so it runs in + its own process (a terminal window when one is available, otherwise + detached) and the launcher returns immediately. + """ + module = ( + "PyFlow.transfer_web.setup_server" if kind == "server" else "PyFlow.transfer_web.setup_client" + ) + python = sys.executable + system = platform.system() + try: + if system == "Windows": + cmd = f"start cmd /k {python} -m {module}" + subprocess.Popen(cmd, shell=True, cwd=project_root) + elif system == "Linux": + terminals = ["gnome-terminal", "xterm", "x-terminal-emulator"] + launched = False + for term in terminals: + if shutil.which(term): + cmd = f"{term} -- {python} -m {module}" + subprocess.Popen(cmd, shell=True, cwd=project_root) + launched = True + break + if not launched: + subprocess.Popen( + [python, "-m", module], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + start_new_session=True, + cwd=project_root, + ) + elif system == "Darwin": + cmd = f"open -a Terminal.app {python} -m {module}" + subprocess.Popen(cmd, shell=True, cwd=project_root) + else: + subprocess.Popen( + [python, "-m", module], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + start_new_session=True, + cwd=project_root, + ) + except Exception as e: + print(f"Failed to launch web {kind}: {e}") + traceback.print_exc() + + # ---- instance editor: reduce/change existing instances ---------------------- _MOUSE_WHEEL_UP = 64 @@ -1061,6 +1114,12 @@ def main(): # noqa: PLR0911, PLR0912, PLR0915 return parser = argparse.ArgumentParser(description="Flow Setup Launcher") parser.add_argument("--type", type=int, choices=[0, 1], help="0=Server, 1=Client") + parser.add_argument( + "--web_server", action="store_true", help="Launch the web TCP server tool (browser UI)" + ) + parser.add_argument( + "--web_client", action="store_true", help="Launch the web TCP client tool (browser UI)" + ) parser.add_argument("--setup_addr_port", type=str, help="Bind address and port (host:port)") parser.add_argument( "--connect_addr_port", type=str, help="Server address and port to connect (client required)" @@ -1071,6 +1130,12 @@ def main(): # noqa: PLR0911, PLR0912, PLR0915 "--setup_num", type=int, default=1, help="Number of instances to launch (only 1 is allowed)" ) args = parser.parse_args() + if args.web_server or args.web_client: + if args.web_server: + launch_web_tool("server") + if args.web_client: + launch_web_tool("client") + return if args.add is not None: try: add_extension.add_extension(args.add) diff --git a/PyFlow/transfer_web/setup_client.py b/PyFlow/transfer_web/setup_client.py new file mode 100755 index 0000000..fb498ee --- /dev/null +++ b/PyFlow/transfer_web/setup_client.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""PyFlow TCP client web launcher. + +Starts a lightweight Flask backend on 127.0.0.1 and opens the connect +UI in the browser. The user enters the server address (an http/https +domain or a bare IP); the backend asks the server's web backend for the +TCP server address/port, starts the TCP client, and keeps the backend +running to relay the user's frontend actions. +""" + +import os +import sys + +WEB_ROOT = os.path.dirname(os.path.abspath(__file__)) +PROJECT_ROOT = os.path.dirname(WEB_ROOT) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + +from transfer_web.web_front.client_backend import ClientWebApp # noqa: E402 + + +def main(): + app = ClientWebApp() + app.run() + + +if __name__ == "__main__": + main() diff --git a/PyFlow/transfer_web/setup_server.py b/PyFlow/transfer_web/setup_server.py new file mode 100755 index 0000000..68e5b4a --- /dev/null +++ b/PyFlow/transfer_web/setup_server.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""PyFlow TCP server web launcher. + +Checks ``transfer_web/.Flow_Web/setup_server.json``: + +- missing -> opens the server startup-configuration UI in the browser; + the UI saves the config (same shape as ``flow_setup``'s ``setup.json``) + and starts the TCP server class; +- present -> starts the TCP server class directly from the saved config. + +After the TCP server is up, the lightweight Flask backend serves the +status page and the client-facing API (``/api/server_info`` etc.) on +the server's address. +""" + +import os +import sys + +WEB_ROOT = os.path.dirname(os.path.abspath(__file__)) +PROJECT_ROOT = os.path.dirname(WEB_ROOT) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + +from transfer_web.web_backend.server_backend import ( # noqa: E402 + FLOW_WEB_DIR, + SERVER_CONFIG_FILE, + ServerWebApp, +) + + +def main(): + os.makedirs(FLOW_WEB_DIR, exist_ok=True) + app = ServerWebApp() + if os.path.exists(SERVER_CONFIG_FILE): + app.start_from_config() + # else: stays in config mode, the config UI is served at / + app.run() + + +if __name__ == "__main__": + main() diff --git a/PyFlow/transfer_web/static/common.css b/PyFlow/transfer_web/static/common.css new file mode 100644 index 0000000..d316775 --- /dev/null +++ b/PyFlow/transfer_web/static/common.css @@ -0,0 +1,429 @@ +/* PyFlow transfer_web shared styles */ +:root { + --bg: #0f1420; + --bg-2: #161d2e; + --bg-3: #1d2740; + --border: #2a3550; + --text: #dbe4f5; + --text-dim: #8b98b8; + --accent: #4f8cff; + --accent-2: #2f6ae0; + --ok: #3ecf8e; + --warn: #ffb454; + --err: #ff6b6b; + --radius: 10px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { height: 100%; } + +body { + background: var(--bg); + color: var(--text); + font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +a { color: var(--accent); } + +/* ---------- centered forms (config / connect) ---------- */ + +.center-wrap { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px 32px; + width: 100%; + max-width: 640px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); +} + +.card h1 { + font-size: 20px; + margin-bottom: 4px; +} + +.card .subtitle { + color: var(--text-dim); + margin-bottom: 20px; + font-size: 13px; +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 16px; +} + +.form-grid .full { grid-column: 1 / -1; } + +.field label { + display: block; + font-size: 12px; + color: var(--text-dim); + margin-bottom: 4px; +} + +.field input[type="text"], +.field input[type="number"], +.field input[type="password"] { + width: 100%; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + padding: 8px 10px; + font-size: 14px; + outline: none; +} + +.field input:focus { border-color: var(--accent); } + +.field .help { + font-size: 11px; + color: var(--text-dim); + margin-top: 3px; +} + +.checkbox-row { + display: flex; + align-items: center; + gap: 8px; + padding-top: 6px; +} + +.checkbox-row input { accent-color: var(--accent); width: 16px; height: 16px; } + +.btn { + background: var(--accent-2); + color: #fff; + border: none; + border-radius: 6px; + padding: 10px 18px; + font-size: 14px; + cursor: pointer; + transition: background 0.15s; +} + +.btn:hover { background: var(--accent); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.btn-ghost { + background: transparent; + border: 1px solid var(--border); + color: var(--text); +} + +.btn-ghost:hover { background: var(--bg-3); } + +.btn-danger { background: #a33; } +.btn-danger:hover { background: #c44; } + +.actions { margin-top: 20px; display: flex; gap: 10px; align-items: center; } + +.status-line { margin-top: 14px; font-size: 13px; min-height: 20px; } +.status-line.ok { color: var(--ok); } +.status-line.err { color: var(--err); } + +/* ---------- main app layout (server status / client main) ---------- */ + +.app { + display: flex; + height: 100vh; + overflow: hidden; +} + +.sidebar { + width: 260px; + flex-shrink: 0; + background: var(--bg-2); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; +} + +.sidebar-header { + padding: 14px 16px; + border-bottom: 1px solid var(--border); +} + +.sidebar-header h1 { font-size: 15px; } + +.sidebar-header .meta { font-size: 12px; color: var(--text-dim); margin-top: 2px; } + +.sidebar-actions { + display: flex; + gap: 6px; + margin-top: 10px; +} + +.sidebar-actions .btn { padding: 6px 10px; font-size: 12px; } + +.instance-list { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.instance { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 10px; + border-radius: 8px; + cursor: pointer; + border: 1px solid transparent; + margin-bottom: 2px; +} + +.instance:hover { background: var(--bg-3); } + +.instance.active { + background: var(--bg-3); + border-color: var(--accent); +} + +.instance .dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.instance .dot.server { background: var(--accent); } +.instance .dot.client { background: var(--ok); } +.instance .dot.self { background: var(--warn); } + +.instance .name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; +} + +.instance .tag { + font-size: 10px; + color: var(--text-dim); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 5px; + flex-shrink: 0; +} + +.sidebar-footer { + padding: 10px 16px; + border-top: 1px solid var(--border); + font-size: 12px; + color: var(--text-dim); +} + +/* ---------- main panel ---------- */ + +.main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.main-topbar { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 18px; + border-bottom: 1px solid var(--border); + background: var(--bg-2); +} + +.main-topbar .title { font-size: 14px; font-weight: 600; } +.main-topbar .spacer { flex: 1; } +.main-topbar .conn { font-size: 12px; color: var(--text-dim); } +.main-topbar .conn.on { color: var(--ok); } +.main-topbar .conn.off { color: var(--err); } + +.chat-area { + flex: 1; + overflow-y: auto; + padding: 18px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.msg { + max-width: 70%; + padding: 9px 13px; + border-radius: 10px; + font-size: 13px; + word-break: break-word; + white-space: pre-wrap; +} + +.msg.out { align-self: flex-end; background: var(--accent-2); } +.msg.in { align-self: flex-start; background: var(--bg-3); } +.msg.sys { align-self: center; background: transparent; color: var(--text-dim); font-size: 12px; } + +.empty-hint { + margin: auto; + color: var(--text-dim); + text-align: center; + font-size: 13px; +} + +/* ---------- input box ---------- */ + +.input-box { + border-top: 1px solid var(--border); + background: var(--bg-2); + padding: 10px 14px; +} + +.input-box .row { display: flex; align-items: flex-end; gap: 10px; } + +.input-box textarea { + flex: 1; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + padding: 10px 12px; + font-size: 14px; + font-family: inherit; + resize: none; + min-height: 44px; + max-height: 160px; + outline: none; +} + +.input-box textarea:focus { border-color: var(--accent); } + +.icon-bar { + display: flex; + gap: 6px; + margin-top: 8px; + align-items: center; +} + +.icon-btn { + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + width: 34px; + height: 34px; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, border-color 0.15s; +} + +.icon-btn:hover { background: var(--accent-2); border-color: var(--accent); } + +.icon-btn.ext { font-size: 15px; } + +.icon-bar .hint { font-size: 11px; color: var(--text-dim); margin-left: 4px; } + +/* ---------- modal ---------- */ + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; +} + +.modal { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 22px 26px; + width: 100%; + max-width: 520px; + max-height: 86vh; + overflow-y: auto; + box-shadow: 0 16px 50px rgba(0, 0, 0, 0.5); +} + +.modal h2 { font-size: 16px; margin-bottom: 14px; } + +.modal .field { margin-bottom: 12px; } + +.drop-zone { + border: 2px dashed var(--border); + border-radius: 8px; + padding: 26px; + text-align: center; + color: var(--text-dim); + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} + +.drop-zone:hover, .drop-zone.drag { border-color: var(--accent); color: var(--text); } + +.drop-zone input[type="file"] { display: none; } + +.file-list { margin-top: 10px; font-size: 12px; color: var(--text-dim); max-height: 120px; overflow-y: auto; } +.file-list div { padding: 2px 0; } + +.modal .actions { margin-top: 16px; } + +.ext-entry { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 6px; +} + +.ext-entry .icon { font-size: 18px; } +.ext-entry .info { flex: 1; } +.ext-entry .info .name { font-size: 13px; } +.ext-entry .info .cmd { font-size: 11px; color: var(--text-dim); } + +select { + width: 100%; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + padding: 8px 10px; + font-size: 14px; + outline: none; +} + +select:focus { border-color: var(--accent); } + +.toast { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 18px; + font-size: 13px; + z-index: 100; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.toast.err { border-color: var(--err); color: var(--err); } +.toast.ok { border-color: var(--ok); color: var(--ok); } diff --git a/PyFlow/transfer_web/static/common.js b/PyFlow/transfer_web/static/common.js new file mode 100644 index 0000000..c086c32 --- /dev/null +++ b/PyFlow/transfer_web/static/common.js @@ -0,0 +1,482 @@ +/* PyFlow transfer_web shared frontend logic. + * window.WEB_MODE is "server" or "client" (set by the template). + */ +(function () { + "use strict"; + + const MODE = window.WEB_MODE || "client"; + const $ = (id) => document.getElementById(id); + + const state = { + serverInfo: null, + clients: [], + ownAddress: null, + connected: false, + target: null, // "server" | [ip, port] + extensions: [], // [{name, icon, command}] + messages: [], // [{dir: "out"|"sys", text}] + }; + + /* ---------------- helpers ---------------- */ + + function toast(text, kind) { + const el = document.createElement("div"); + el.className = "toast" + (kind ? " " + kind : ""); + el.textContent = text; + document.body.appendChild(el); + setTimeout(() => el.remove(), 3500); + } + + async function api(path, options) { + const resp = await fetch(path, options); + let data = null; + try { + data = await resp.json(); + } catch (e) { + data = {}; + } + if (!resp.ok || data.ok === false) { + throw new Error(data.error || ("HTTP " + resp.status)); + } + return data; + } + + function esc(s) { + const div = document.createElement("div"); + div.textContent = s == null ? "" : String(s); + return div.innerHTML; + } + + function targetLabel(target) { + if (target === "server") { + return "Server " + (state.serverInfo ? state.serverInfo.host + ":" + state.serverInfo.port : ""); + } + return target[0] + ":" + target[1]; + } + + function isSelf(entry) { + return ( + state.ownAddress && + entry.ip === state.ownAddress.ip && + entry.port === state.ownAddress.port + ); + } + + /* ---------------- sidebar ---------------- */ + + function renderSidebar() { + const list = $("instance-list"); + list.innerHTML = ""; + const serverEntry = document.createElement("div"); + serverEntry.className = "instance" + (state.target === "server" ? " active" : ""); + serverEntry.innerHTML = + '' + + esc("Server " + (state.serverInfo ? state.serverInfo.host + ":" + state.serverInfo.port : "")) + + 'server'; + serverEntry.addEventListener("click", () => selectTarget("server")); + list.appendChild(serverEntry); + + if (!state.clients.length) { + const empty = document.createElement("div"); + empty.className = "empty-hint"; + empty.style.padding = "16px 8px"; + empty.textContent = "No clients connected"; + list.appendChild(empty); + } + state.clients.forEach((c) => { + const el = document.createElement("div"); + const key = c.ip + ":" + c.port; + const active = + state.target !== "server" && + state.target && + state.target[0] === c.ip && + state.target[1] === c.port; + el.className = "instance" + (active ? " active" : ""); + const self = isSelf(c); + el.innerHTML = + '' + + '' + esc(key) + (self ? " (me)" : "") + "" + + '' + (self ? "me" : "client") + ""; + el.addEventListener("click", () => selectTarget([c.ip, c.port])); + list.appendChild(el); + }); + } + + function selectTarget(target) { + state.target = target; + renderSidebar(); + const title = $("target-title"); + if (MODE === "server" && target === "server") { + title.textContent = "This is the server"; + $("empty-hint").style.display = "block"; + $("empty-hint").textContent = + "This is the server. Select a connected client on the left to send data."; + $("input").disabled = true; + $("send-btn").disabled = true; + $("icon-bar").style.opacity = "0.4"; + $("icon-bar").style.pointerEvents = "none"; + return; + } + title.textContent = "Sending to " + targetLabel(target); + $("empty-hint").style.display = "none"; + $("input").disabled = false; + $("send-btn").disabled = false; + $("icon-bar").style.opacity = "1"; + $("icon-bar").style.pointerEvents = "auto"; + $("input").focus(); + } + + /* ---------------- status polling ---------------- */ + + async function refreshStatus() { + try { + const data = await api("/api/status"); + state.serverInfo = data.server_info; + state.clients = data.clients || []; + state.ownAddress = data.own_address || null; + state.connected = MODE === "server" ? !!data.running : !!data.connected; + const conn = $("conn-indicator"); + conn.textContent = state.connected ? "connected" : "disconnected"; + conn.className = "conn " + (state.connected ? "on" : "off"); + const meta = $("server-meta"); + meta.textContent = state.serverInfo + ? state.serverInfo.host + ":" + state.serverInfo.port + : "no server info"; + renderSidebar(); + } catch (e) { + const conn = $("conn-indicator"); + conn.textContent = "offline"; + conn.className = "conn off"; + } + } + + /* ---------------- sending ---------------- */ + + function currentTarget() { + if (!state.target) { + toast("Select a target on the left first", "err"); + return null; + } + if (MODE === "server" && state.target === "server") { + toast("The server cannot send to itself", "err"); + return null; + } + return state.target; + } + + async function sendMessage() { + const target = currentTarget(); + if (!target) return; + const input = $("input"); + const text = input.value.trim(); + if (!text) return; + try { + await api("/api/send_msg", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target, message: text }), + }); + addMessage("out", text); + input.value = ""; + input.style.height = "auto"; + } catch (e) { + toast("Send failed: " + e.message, "err"); + } + } + + function addMessage(dir, text) { + const area = $("chat-area"); + const el = document.createElement("div"); + el.className = "msg " + dir; + el.textContent = text; + area.appendChild(el); + area.scrollTop = area.scrollHeight; + } + + /* ---------------- modals ---------------- */ + + function openModal(html) { + const backdrop = document.createElement("div"); + backdrop.className = "modal-backdrop"; + backdrop.innerHTML = '"; + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) backdrop.remove(); + }); + document.body.appendChild(backdrop); + return backdrop; + } + + function closeModal(backdrop) { + backdrop.remove(); + } + + /* file transfer modal */ + function openFileModal(folderMode) { + const backdrop = openModal( + '

' + (folderMode ? "Send folder" : "Send file(s)") + "

" + + '
' + + (folderMode + ? "Click to choose a folder, or drop a folder here" + : "Click to choose file(s), or drop them here") + + '
" + + '
' + + '
' + + '
' + ); + const zone = backdrop.querySelector("#drop-zone"); + const input = backdrop.querySelector("#file-input"); + const list = backdrop.querySelector("#file-list"); + let files = []; + + function showFiles() { + list.innerHTML = ""; + files.forEach((f) => { + const div = document.createElement("div"); + div.textContent = folderMode ? f.webkitRelativePath || f.name : f.name; + list.appendChild(div); + }); + } + + zone.addEventListener("click", () => input.click()); + zone.addEventListener("dragover", (e) => { + e.preventDefault(); + zone.classList.add("drag"); + }); + zone.addEventListener("dragleave", () => zone.classList.remove("drag")); + zone.addEventListener("drop", (e) => { + e.preventDefault(); + zone.classList.remove("drag"); + files = Array.from(e.dataTransfer.files); + showFiles(); + }); + input.addEventListener("change", () => { + files = Array.from(input.files); + showFiles(); + }); + backdrop.querySelector("#cancel-btn").addEventListener("click", () => closeModal(backdrop)); + backdrop.querySelector("#confirm-btn").addEventListener("click", async () => { + if (!files.length) { + toast("No files selected", "err"); + return; + } + const target = currentTarget(); + if (!target) return; + const fd = new FormData(); + fd.append("target", JSON.stringify(target)); + files.forEach((f) => { + fd.append("files", f, folderMode ? f.webkitRelativePath || f.name : f.name); + }); + try { + await api("/api/" + (folderMode ? "send_folder" : "send_file"), { method: "POST", body: fd }); + toast("Transfer started", "ok"); + closeModal(backdrop); + } catch (e) { + toast("Transfer failed: " + e.message, "err"); + } + }); + } + + /* extension protocol add modal (the "+" button) */ + async function openAddProtocolModal() { + let commands = []; + try { + commands = (await api("/api/available_commands")).commands || []; + } catch (e) { + toast("Cannot list commands: " + e.message, "err"); + return; + } + if (!commands.length) { + toast("No extension commands registered. Add an extension first.", "err"); + return; + } + const backdrop = openModal( + '

Add an extension protocol

' + + '
' + + '
' + + '
' + + '
" + + '
' + + '
' + ); + backdrop.querySelector("#ext-cancel-btn").addEventListener("click", () => closeModal(backdrop)); + backdrop.querySelector("#ext-add-btn").addEventListener("click", async () => { + const name = backdrop.querySelector("#ext-name").value.trim(); + const icon = backdrop.querySelector("#ext-icon").value.trim() || "⚙"; + const command = backdrop.querySelector("#ext-command").value; + if (!name) { + toast("Protocol name is required", "err"); + return; + } + state.extensions.push({ name, icon, command }); + try { + await api("/api/extensions_ui", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ extensions: state.extensions }), + }); + renderExtensionIcons(); + closeModal(backdrop); + toast("Protocol added", "ok"); + } catch (e) { + toast("Failed: " + e.message, "err"); + } + }); + } + + /* extension command execution modal */ + function openRunExtensionModal(entry) { + const backdrop = openModal( + "

" + esc(entry.name) + "

" + + '
" + + '
' + + '
' + + '
' + ); + backdrop.querySelector("#ext-run-cancel-btn").addEventListener("click", () => closeModal(backdrop)); + backdrop.querySelector("#ext-run-btn").addEventListener("click", async () => { + const cmd = backdrop.querySelector("#ext-cmd-input").value.trim(); + if (!cmd) { + toast("Command is empty", "err"); + return; + } + try { + await api("/api/run_extension", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ command: cmd }), + }); + addMessage("sys", "extension executed: " + cmd); + closeModal(backdrop); + toast("Command executed", "ok"); + } catch (e) { + toast("Failed: " + e.message, "err"); + } + }); + } + + function renderExtensionIcons() { + const bar = $("icon-bar"); + bar.querySelectorAll(".icon-btn.ext").forEach((el) => el.remove()); + state.extensions.forEach((entry) => { + const btn = document.createElement("button"); + btn.className = "icon-btn ext"; + btn.title = entry.name + " (" + entry.command + ")"; + btn.textContent = entry.icon; + btn.addEventListener("click", () => openRunExtensionModal(entry)); + bar.insertBefore(btn, $("plus-btn")); + }); + } + + /* add/remove extension modal (top-right "+ Extension") */ + async function openExtensionManager() { + let registered = []; + try { + registered = (await api("/api/registered_extensions")).extensions || []; + } catch (e) { + /* backend without the endpoint: ignore */ + } + const rows = registered + .map( + (p, i) => + '
' + esc(p) + + '
' + ) + .join(""); + const backdrop = openModal( + "

Extension files

" + + '
' + + '
' + + '
' + + '
' + + (rows ? '
Registered extensions (loaded on every start):
' + rows + "
" : "") + ); + backdrop.querySelector("#ext-mgr-close-btn").addEventListener("click", () => closeModal(backdrop)); + backdrop.querySelector("#ext-add-file-btn").addEventListener("click", async () => { + const raw = backdrop.querySelector("#ext-paths").value.trim(); + const paths = raw.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + if (!paths.length) { + toast("Enter at least one extension file path", "err"); + return; + } + try { + await api("/api/add_extension", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ paths }), + }); + toast("Extension added, restarting...", "ok"); + setTimeout(() => { location.href = "/"; }, 1500); + } catch (e) { + toast("Failed: " + e.message, "err"); + } + }); + backdrop.querySelectorAll("[data-remove]").forEach((btn) => { + btn.addEventListener("click", async () => { + const path = registered[Number(btn.dataset.remove)]; + try { + await api("/api/remove_extension", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ paths: [path] }), + }); + toast("Extension removed, restarting...", "ok"); + setTimeout(() => { location.href = "/"; }, 1500); + } catch (e) { + toast("Failed: " + e.message, "err"); + } + }); + }); + } + + /* ---------------- init ---------------- */ + + async function loadExtensions() { + try { + state.extensions = (await api("/api/extensions_ui")).extensions || []; + renderExtensionIcons(); + } catch (e) { + /* ignore */ + } + } + + function init() { + $("send-btn").addEventListener("click", sendMessage); + $("input").addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }); + $("input").addEventListener("input", () => { + $("input").style.height = "auto"; + $("input").style.height = Math.min($("input").scrollHeight, 160) + "px"; + }); + $("file-btn").addEventListener("click", () => openFileModal(false)); + $("folder-btn").addEventListener("click", () => openFileModal(true)); + $("plus-btn").addEventListener("click", openAddProtocolModal); + $("reload-btn").addEventListener("click", async () => { + try { + if (MODE === "client") { + await api("/api/sync_clients", { method: "POST" }); + } + await refreshStatus(); + toast("Instance list refreshed", "ok"); + } catch (e) { + toast("Reload failed: " + e.message, "err"); + } + }); + $("add-ext-btn").addEventListener("click", openExtensionManager); + selectTarget("server"); + loadExtensions(); + refreshStatus(); + setInterval(refreshStatus, 2000); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/PyFlow/transfer_web/web_backend/server_backend.py b/PyFlow/transfer_web/web_backend/server_backend.py new file mode 100644 index 0000000..bb498af --- /dev/null +++ b/PyFlow/transfer_web/web_backend/server_backend.py @@ -0,0 +1,501 @@ +"""Flask backend wrapping the PyFlow TCP server for the web tool. + +Two modes, one process: + +- ``config`` mode: serves the server startup-configuration UI. The UI + shows every ``TCP_Server_Base`` parameter with its default value; on + submit the config is written to ``.Flow_Web/setup_server.json`` (same + shape as ``flow_setup``'s ``setup.json``) and the TCP server class is + started. +- ``status`` mode: serves the minimal status page plus the same + sidebar/input UI as the client frontend (forwarding disabled; native + sends to connected clients allowed). Also exposes the HTTP API that + clients use to discover the TCP server address/port. + +The backend monitors ``server.clients``: whenever a client connects or +disconnects it broadcasts the current instance list to every connected +client (``/web_clients_update``), and it re-checks the list every +minute. +""" + +import json +import os +import shlex +import socket +import sys +import threading +import time +import traceback + +from flask import Flask, jsonify, render_template, request + +from PyFlow import add_extension +from PyFlow import forward_extension_tcp +from PyFlow.network_api.connect_tcp import TCP_Server_Base + +WEB_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FLOW_WEB_DIR = os.path.join(WEB_ROOT, ".Flow_Web") +SERVER_CONFIG_FILE = os.path.join(FLOW_WEB_DIR, "setup_server.json") +SERVER_EXTENSIONS_UI_FILE = os.path.join(FLOW_WEB_DIR, "server_extensions_ui.json") +UPLOAD_DIR = os.path.join(FLOW_WEB_DIR, "uploads") +TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") +STATIC_DIR = os.path.join(WEB_ROOT, "static") + +DEFAULT_WEB_PORT = 5000 + +# Ordered (key, label, type, default, help) for every TCP_Server_Base +# parameter shown in the startup-configuration UI. +SERVER_PARAM_FIELDS = [ + ("host", "Host", "text", "127.0.0.1", "IP address the TCP server binds to."), + ("port", "Port", "number", 65432, "TCP port the server listens on."), + ("max_clients", "Max clients", "number", 10, "Maximum number of concurrent clients."), + ("port_add_step", "Port add step", "number", 1, "Step size for port allocation."), + ("port_range_num", "Port range num", "number", 100, "Number of ports in the allocation range."), + ( + "max_file_transfer_thread_num", + "Max file transfer threads", + "number", + 10, + "Maximum concurrent file-transfer threads.", + ), + ("is_hand_alloc_port", "Hand-allocated ports", "bool", False, "Manually allocate transfer ports."), + ( + "is_input_command_in_console", + "Console input", + "bool", + False, + "Forced False by the web architecture (the web UI is the input).", + ), + ("max_custom_workers", "Max custom workers", "number", 10, "Maximum custom-command worker threads."), + ( + "is_extend_command", + "Extend command", + "bool", + True, + "Forced True by the web architecture (extensions are registered before start).", + ), + ("is_enable_encrypto", "Enable encryption", "bool", True, "RSA-encrypt the TCP channel."), + ("is_custom_keys", "Custom keys", "text", "", "Optional [pub_key_path, pvt_key_path] pair."), + ("max_mem_buff", "Max memory buffer (MB)", "number", 2048, "In-memory transfer buffer in MB."), +] + +# Web-only settings (not TCP_Server_Base parameters). +WEB_FIELDS = [ + ("web_port", "Web port", "number", DEFAULT_WEB_PORT, "Port of this web backend (clients query it)."), +] + + +def _public_host(host): + """Resolve a wildcard bind address to an address clients can reach.""" + if host not in ("0.0.0.0", "::", ""): + return host + try: + probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + probe.connect(("8.8.8.8", 80)) + return probe.getsockname()[0] + finally: + probe.close() + except Exception: + return "127.0.0.1" + + +def _find_free_port(base): + """Return ``base`` if free, otherwise the next free port.""" + port = base + while port < base + 100: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("127.0.0.1", port)) + return port + except OSError: + port += 1 + return base + + +def _load_json_list(path): + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, list) else [] + except Exception: + return [] + return [] + + +class ServerWebApp: + """Flask app + TCP_Server_Base wrapper for the web tool.""" + + def __init__(self, web_port=None): + self.web_port = web_port or DEFAULT_WEB_PORT + self.server = None + self.mode = "config" # "config" | "status" + self._bound_port = None + self._last_clients = set() + self._monitor_stop = threading.Event() + self._monitor_thread = None + self.app = Flask( + __name__, + template_folder=TEMPLATE_DIR, + static_folder=STATIC_DIR, + static_url_path="/static", + ) + self._register_routes() + + # ------------------------------------------------------------------ setup + + def start_from_config(self): + """Read ``.Flow_Web/setup_server.json`` and start the TCP server.""" + if not os.path.exists(SERVER_CONFIG_FILE): + self.mode = "config" + return + with open(SERVER_CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + servers = data.get("servers", []) + if not servers: + self.mode = "config" + return + web = data.get("web", {}) or {} + self.web_port = int(web.get("port", DEFAULT_WEB_PORT)) + self._start_server(servers[0]) + + def _start_server(self, config): + """Create, register and start the TCP_Server_Base instance.""" + params = dict(config) + # Web architecture constraints: extensions must be registered + # before start, and the web UI replaces the console input. + params["is_extend_command"] = True + params["is_input_command_in_console"] = False + if params.get("is_custom_keys") in (None, ""): + params["is_custom_keys"] = None + elif isinstance(params["is_custom_keys"], str): + try: + parsed = json.loads(params["is_custom_keys"]) + params["is_custom_keys"] = parsed if isinstance(parsed, list) else None + except Exception: + params["is_custom_keys"] = None + self.server = TCP_Server_Base(**params) + forward_extension_tcp.setup_server_commands(self.server) + self.server.register_command( + "/web_sync_clients", self._on_sync_clients, where_to_run="server", run_in_thread=True + ) + try: + add_extension.load_registered_extensions(self.server, "server") + except ImportError as e: + print(f"Failed to load registered extensions: {e}") + threading.Thread(target=self.server.start_TCP_Server, daemon=True).start() + self.mode = "status" + self._last_clients = set() + self._monitor_stop.clear() + self._monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True) + self._monitor_thread.start() + print(f"TCP server started: {self.server.host}:{self.server.port}") + + # ------------------------------------------------------------- monitoring + + def _monitor_loop(self): + """Broadcast the client list on connect/disconnect; re-check every minute.""" + last_check = time.time() + while not self._monitor_stop.is_set(): + time.sleep(1) + if self.server is None or not self.server.running: + continue + with self.server.client_lock: + current = set(self.server.clients.keys()) + if current != self._last_clients: + self._last_clients = current + self._broadcast_clients() + if time.time() - last_check >= 60: + last_check = time.time() + self._broadcast_clients() + + def _client_list(self): + if self.server is None: + return [] + with self.server.client_lock: + return [ + {"ip": addr[0], "port": addr[1], "id": info["id"]} + for addr, info in self.server.clients.items() + ] + + def _server_info_payload(self): + return { + "host": _public_host(self.server.host), + "port": self.server.port, + "is_enable_encrypto": self.server.is_enable_encrypto, + } + + def _broadcast_clients(self): + """Push the current instance list to every connected client.""" + if self.server is None or not self.server.running: + return + payload = json.dumps(self._client_list(), separators=(",", ":")) + message = f"/web_clients_update {payload}" + with self.server.client_lock: + for info in list(self.server.clients.values()): + try: + self.server.send_message(info["socket"], message) + except Exception: + pass + + def _on_sync_clients(self, sock, addr, cmd): + """A client asked for a fresh instance list: broadcast it.""" + self._broadcast_clients() + return None + + # ---------------------------------------------------------------- helpers + + def _require_server(self): + if self.server is None or not self.server.running: + return jsonify({"ok": False, "error": "TCP server is not running"}), 503 + return None + + def _target_info(self, target): + addr = (target[0], int(target[1])) + with self.server.client_lock: + return self.server.clients.get(addr) + + def _run_extension(self, handler, command): + try: + self.server._execute_custom_handler(handler, command) + except Exception: + traceback.print_exc() + + def _send_file_to_client(self, target, path): + try: + self.server.file_transfer_server_recv_client_start( + f"/file {shlex.quote(path)} {shlex.quote(str(target))}", None + ) + except Exception: + traceback.print_exc() + + def _send_folder_to_client(self, target, path): + try: + self.server.folder_file_transfer_server_recv_client_start( + f"/file_folder {shlex.quote(path)} {shlex.quote(str(target))}" + ) + except Exception: + traceback.print_exc() + + def _restart(self): + time.sleep(1) + os.execv(sys.executable, [sys.executable] + sys.argv) + + # ------------------------------------------------------------------ routes + + def _register_routes(self): + app = self.app + + @app.get("/") + def index(): + if self.mode == "status": + return render_template("server_status.html", mode="server") + return render_template( + "server_config.html", fields=SERVER_PARAM_FIELDS, web_fields=WEB_FIELDS + ) + + @app.get("/api/status") + def api_status(): + return jsonify( + { + "mode": self.mode, + "running": self.server is not None and self.server.running, + "server_info": self._server_info_payload() if self.server is not None else None, + "clients": self._client_list(), + } + ) + + @app.post("/api/save_config") + def api_save_config(): + data = request.get_json(force=True) + params = data.get("params", {}) + web_port = int(data.get("web_port", DEFAULT_WEB_PORT)) + os.makedirs(FLOW_WEB_DIR, exist_ok=True) + config = {"servers": [params], "clients": [], "web": {"port": web_port}} + with open(SERVER_CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(config, f, indent=4, ensure_ascii=False) + self.web_port = web_port + try: + self._start_server(params) + except Exception as e: + traceback.print_exc() + return jsonify({"ok": False, "error": f"failed to start TCP server: {e}"}), 500 + if self._bound_port is not None and web_port != self._bound_port: + # the web port only takes effect on the next start + threading.Thread(target=self._restart, daemon=True).start() + return jsonify({"ok": True, "restarting": True}) + return jsonify({"ok": True, "server_info": self._server_info_payload()}) + + @app.get("/api/server_info") + def api_server_info(): + """TCP server address/port discovery for web clients.""" + if self.server is None or not self.server.running: + return jsonify({"ok": False, "error": "TCP server is not running"}), 503 + return jsonify(self._server_info_payload()) + + @app.get("/api/clients") + def api_clients(): + return jsonify({"clients": self._client_list()}) + + @app.post("/api/send_msg") + def api_send_msg(): + err = self._require_server() + if err: + return err + data = request.get_json(force=True) + target = data.get("target") + message = data.get("message", "") + if target == "server": + return jsonify({"ok": False, "error": "the server cannot send to itself"}), 400 + info = self._target_info(target) + if info is None: + return jsonify({"ok": False, "error": "target client is not connected"}), 404 + try: + self.server.send_message(info["socket"], message) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + return jsonify({"ok": True}) + + @app.post("/api/send_file") + def api_send_file(): + err = self._require_server() + if err: + return err + target = json.loads(request.form.get("target", "null")) + files = request.files.getlist("files") + if not files: + return jsonify({"ok": False, "error": "no files uploaded"}), 400 + if target == "server": + return jsonify({"ok": False, "error": "the server cannot send to itself"}), 400 + info = self._target_info(target) + if info is None: + return jsonify({"ok": False, "error": "target client is not connected"}), 404 + os.makedirs(UPLOAD_DIR, exist_ok=True) + saved = [] + for f in files: + path = os.path.join(UPLOAD_DIR, os.path.basename(f.filename)) + f.save(path) + saved.append(path) + for path in saved: + threading.Thread( + target=self._send_file_to_client, args=(tuple(target), path), daemon=True + ).start() + return jsonify({"ok": True, "paths": saved}) + + @app.post("/api/send_folder") + def api_send_folder(): + err = self._require_server() + if err: + return err + target = json.loads(request.form.get("target", "null")) + files = request.files.getlist("files") + if not files: + return jsonify({"ok": False, "error": "no files uploaded"}), 400 + if target == "server": + return jsonify({"ok": False, "error": "the server cannot send to itself"}), 400 + info = self._target_info(target) + if info is None: + return jsonify({"ok": False, "error": "target client is not connected"}), 404 + os.makedirs(UPLOAD_DIR, exist_ok=True) + root = None + for f in files: + rel = f.filename # webkitRelativePath, e.g. "folder/sub/file.txt" + path = os.path.join(UPLOAD_DIR, rel) + os.makedirs(os.path.dirname(path), exist_ok=True) + f.save(path) + if root is None: + root = os.path.join(UPLOAD_DIR, rel.split("/")[0]) + if root is None or not os.path.isdir(root): + return jsonify({"ok": False, "error": "folder upload failed"}), 500 + threading.Thread( + target=self._send_folder_to_client, args=(tuple(target), root), daemon=True + ).start() + return jsonify({"ok": True, "path": root}) + + @app.post("/api/run_extension") + def api_run_extension(): + err = self._require_server() + if err: + return err + data = request.get_json(force=True) + command = data.get("command", "") + parts = shlex.split(command) + if not parts: + return jsonify({"ok": False, "error": "empty command"}), 400 + handler = self.server._custom_handlers[1].get(parts[0].lower()) + if handler is None: + return jsonify({"ok": False, "error": f"command {parts[0]} is not registered"}), 404 + threading.Thread(target=self._run_extension, args=(handler, command), daemon=True).start() + return jsonify({"ok": True}) + + @app.get("/api/available_commands") + def api_available_commands(): + err = self._require_server() + if err: + return err + return jsonify({"commands": sorted(self.server._custom_handlers[1].keys())}) + + @app.post("/api/sync_clients") + def api_sync_clients(): + self._broadcast_clients() + return jsonify({"ok": True}) + + @app.get("/api/extensions_ui") + def api_get_extensions_ui(): + return jsonify({"extensions": _load_json_list(SERVER_EXTENSIONS_UI_FILE)}) + + @app.get("/api/registered_extensions") + def api_registered_extensions(): + return jsonify({"extensions": _load_json_list(add_extension.added_extensions_log_file)}) + + @app.post("/api/extensions_ui") + def api_save_extensions_ui(): + data = request.get_json(force=True) + entries = data.get("extensions", []) + os.makedirs(FLOW_WEB_DIR, exist_ok=True) + with open(SERVER_EXTENSIONS_UI_FILE, "w", encoding="utf-8") as f: + json.dump(entries, f, indent=4, ensure_ascii=False) + return jsonify({"ok": True}) + + @app.post("/api/add_extension") + def api_add_extension(): + data = request.get_json(force=True) + paths = data.get("paths", []) + try: + add_extension.add_extension(paths) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 400 + threading.Thread(target=self._restart, daemon=True).start() + return jsonify({"ok": True, "restarting": True}) + + @app.post("/api/remove_extension") + def api_remove_extension(): + data = request.get_json(force=True) + paths = data.get("paths", []) + try: + add_extension.remove_extension(paths) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 400 + threading.Thread(target=self._restart, daemon=True).start() + return jsonify({"ok": True, "restarting": True}) + + # ------------------------------------------------------------------- run + + def run(self): + host = "127.0.0.1" if self.mode == "config" else self.server.host + port = _find_free_port(self.web_port) + if port != self.web_port: + print(f"Web port {self.web_port} busy, using {port}") + self._bound_port = port + threading.Thread(target=self._open_browser, args=(port,), daemon=True).start() + self.app.run(host=host, port=port, threaded=True, use_reloader=False) + + def _open_browser(self, port): + time.sleep(1.5) + try: + import webbrowser + + webbrowser.open(f"http://127.0.0.1:{port}/") + except Exception: + pass diff --git a/PyFlow/transfer_web/web_backend/templates/server_config.html b/PyFlow/transfer_web/web_backend/templates/server_config.html new file mode 100644 index 0000000..0014d77 --- /dev/null +++ b/PyFlow/transfer_web/web_backend/templates/server_config.html @@ -0,0 +1,99 @@ + + + + + + PyFlow TCP Server Setup + + + +
+
+

PyFlow TCP Server Setup

+

Configure the TCP server. Every parameter has a default value; change only what you need.

+
+ {% for key, label, ftype, default, help in fields %} +
+ + {% if ftype == "bool" %} +
+ + {{ help }} +
+ {% else %} + +
{{ help }}
+ {% endif %} +
+ {% endfor %} + {% for key, label, ftype, default, help in web_fields %} +
+ + +
{{ help }}
+
+ {% endfor %} +
+ + +
+
+
+
+ + + diff --git a/PyFlow/transfer_web/web_backend/templates/server_status.html b/PyFlow/transfer_web/web_backend/templates/server_status.html new file mode 100644 index 0000000..8f55771 --- /dev/null +++ b/PyFlow/transfer_web/web_backend/templates/server_status.html @@ -0,0 +1,49 @@ + + + + + + PyFlow TCP Server + + + +
+ +
+
+ Select a client + + starting... +
+
+
The PyFlow TCP Server is running! Connect it by the host address of the server.

Select a connected client on the left to send messages, files or folders.
+
+
+
+ + +
+
+ + + + +
+
+
+
+ + + + diff --git a/PyFlow/transfer_web/web_front/client_backend.py b/PyFlow/transfer_web/web_front/client_backend.py new file mode 100644 index 0000000..c966ca2 --- /dev/null +++ b/PyFlow/transfer_web/web_front/client_backend.py @@ -0,0 +1,445 @@ +"""Flask backend wrapping the PyFlow TCP client for the web tool. + +The launcher (``setup_client.py``) starts this backend and opens the +connect UI in the browser. The user enters the server address (an +``http``/``https`` domain or a bare IP); the backend queries the +server's web backend ``/api/server_info`` for the TCP server address +and port, then starts the ``TCP_Client_Base`` instance. The backend +stays up to relay the user's frontend actions: + +- messages/files/folders to the server use the native transfer methods; +- messages to other clients are forwarded through the built-in + ``forward_extension_tcp`` extension (string forwarding lives there); +- files/folders to other clients use the native forward methods. + +The sidebar instance list is kept fresh by the server's +``/web_clients_update`` broadcasts; a reload button re-requests the +list via ``/web_sync_clients``. +""" + +import json +import os +import shlex +import socket +import sys +import threading +import time +import traceback +import urllib.request +from urllib.parse import urlparse + +from flask import Flask, jsonify, render_template, request + +from PyFlow import add_extension +from PyFlow import forward_extension_tcp +from PyFlow.network_api.connect_tcp import TCP_Client_Base + +WEB_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FLOW_WEB_DIR = os.path.join(WEB_ROOT, ".Flow_Web") +CLIENT_EXTENSIONS_UI_FILE = os.path.join(FLOW_WEB_DIR, "client_extensions_ui.json") +CLIENT_LAST_SERVER_FILE = os.path.join(FLOW_WEB_DIR, "client_last_server.json") +UPLOAD_DIR = os.path.join(FLOW_WEB_DIR, "uploads") +TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") +STATIC_DIR = os.path.join(WEB_ROOT, "static") + +DEFAULT_CLIENT_WEB_PORT = 5001 +DEFAULT_SERVER_WEB_PORT = 5000 + + +def _find_free_port(base): + port = base + while port < base + 100: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("127.0.0.1", port)) + return port + except OSError: + port += 1 + return base + + +def _normalize_address(address): + """Turn user input into ``scheme://host:port`` for the server web backend.""" + address = address.strip() + if not address: + raise ValueError("empty server address") + if "://" not in address: + address = "http://" + address + parts = urlparse(address) + if not parts.hostname: + raise ValueError(f"invalid server address: {address}") + port = parts.port or (443 if parts.scheme == "https" else DEFAULT_SERVER_WEB_PORT) + return f"{parts.scheme}://{parts.hostname}:{port}" + + +def _load_json_list(path): + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, list) else [] + except Exception: + return [] + return [] + + +class ClientWebApp: + """Flask app + TCP_Client_Base wrapper for the web tool.""" + + def __init__(self, web_port=None): + self.web_port = web_port or DEFAULT_CLIENT_WEB_PORT + self.client = None + self.server_info = None + self.connected = False + self._last_address = "" + self._clients = [] + self._clients_lock = threading.Lock() + self.app = Flask( + __name__, + template_folder=TEMPLATE_DIR, + static_folder=STATIC_DIR, + static_url_path="/static", + ) + self._register_routes() + + # ---------------------------------------------------------------- helpers + + def _own_address(self): + if self.client is None or self.client.client_socket is None: + return None + try: + ip, port = self.client.client_socket.getsockname()[:2] + return {"ip": ip, "port": port, "id": f"{ip}:{port}"} + except Exception: + return None + + def _client_id(self): + own = self._own_address() + if own: + return own["id"] + return f"{self.client.client_host}:{self.client.client_port}" + + def _run_client_command(self, handler, command): + try: + self.client._execute_custom_handler( + handler, command, self.client.client_socket, self._client_id() + ) + except Exception: + traceback.print_exc() + + def _send_file_to_server(self, path): + try: + self.client.file_transfer_client_recv_client_start(f"/file {shlex.quote(path)}", None) + except Exception: + traceback.print_exc() + + def _send_folder_to_server(self, path): + try: + self.client.folder_file_transfer_client_recv_client_start( + f"/file_folder {shlex.quote(path)}" + ) + except Exception: + traceback.print_exc() + + def _forward_file(self, path, addr): + try: + self.client.forward_file_console( + f"/forward_file {shlex.quote(path)} {shlex.quote(str(addr))}" + ) + except Exception: + traceback.print_exc() + + def _forward_folder(self, path, addr): + try: + self.client.forward_folder_console( + f"/forward_folder {shlex.quote(path)} {shlex.quote(str(addr))}" + ) + except Exception: + traceback.print_exc() + + def _restart(self): + time.sleep(1) + os.execv(sys.executable, [sys.executable] + sys.argv) + + def _on_clients_update(self, sock, addr, cmd): + """Server broadcast: refresh the sidebar instance list.""" + payload = cmd[len("/web_clients_update") :].strip() + try: + clients = json.loads(payload) + except Exception: + return None + if isinstance(clients, list): + with self._clients_lock: + self._clients = clients + return None + + # ---------------------------------------------------------------- connect + + def _start_client(self, host, port, is_enable_encrypto): + self.client = TCP_Client_Base( + host=host, + port=port, + client_host="127.0.0.1", + client_port=None, + timeout=None, + port_add_step=1, + max_thread_num=10, + is_input_command_in_console=False, + is_wait_server=True, + max_custom_workers=10, + is_extend_command=True, + is_enable_encrypto=is_enable_encrypto, + is_custom_keys=None, + max_mem_buff=2048, + ) + forward_extension_tcp.setup_client_commands(self.client) + self.client.register_command( + "/web_clients_update", self._on_clients_update, where_to_run="server", run_in_thread=True + ) + try: + add_extension.load_registered_extensions(self.client, "client") + except ImportError as e: + print(f"Failed to load registered extensions: {e}") + threading.Thread(target=self.client.start_TCP_client, daemon=True).start() + self.connected = True + self.server_info = { + "host": host, + "port": port, + "is_enable_encrypto": is_enable_encrypto, + } + with self._clients_lock: + self._clients = [] + os.makedirs(FLOW_WEB_DIR, exist_ok=True) + with open(CLIENT_LAST_SERVER_FILE, "w", encoding="utf-8") as f: + json.dump({"address": self._last_address}, f, indent=4, ensure_ascii=False) + + # ------------------------------------------------------------------ routes + + def _register_routes(self): + app = self.app + + @app.get("/") + def index(): + if self.connected: + return render_template("client_main.html", mode="client") + last = "" + if os.path.exists(CLIENT_LAST_SERVER_FILE): + try: + with open(CLIENT_LAST_SERVER_FILE, "r", encoding="utf-8") as f: + last = json.load(f).get("address", "") + except Exception: + last = "" + return render_template("client_connect.html", last_address=last) + + @app.post("/api/connect") + def api_connect(): + data = request.get_json(force=True) + address = data.get("address", "").strip() + try: + base = _normalize_address(address) + except ValueError as e: + return jsonify({"ok": False, "error": str(e)}), 400 + try: + with urllib.request.urlopen(f"{base}/api/server_info", timeout=10) as resp: + info = json.loads(resp.read().decode("utf-8")) + except Exception as e: + return ( + jsonify( + { + "ok": False, + "error": f"cannot reach the server web backend at {base}: {e}", + } + ), + 502, + ) + if not info.get("ok", True): + return jsonify({"ok": False, "error": info.get("error", "server not ready")}), 503 + host = info.get("host") + port = int(info.get("port")) + is_enable_encrypto = bool(info.get("is_enable_encrypto", True)) + self._last_address = address + try: + self._start_client(host, port, is_enable_encrypto) + except Exception as e: + traceback.print_exc() + return jsonify({"ok": False, "error": f"failed to start TCP client: {e}"}), 500 + return jsonify({"ok": True, "server_info": self.server_info}) + + @app.get("/api/status") + def api_status(): + return jsonify( + { + "connected": self.connected + and self.client is not None + and self.client.running, + "server_info": self.server_info, + "clients": self._clients_snapshot(), + "own_address": self._own_address(), + } + ) + + @app.post("/api/send_msg") + def api_send_msg(): + if not self.connected or self.client is None: + return jsonify({"ok": False, "error": "not connected"}), 400 + data = request.get_json(force=True) + target = data.get("target") + message = data.get("message", "") + if target == "server": + ok = self.client.send_message(self.client.client_socket, message) + if not ok: + return jsonify({"ok": False, "error": "send failed"}), 500 + return jsonify({"ok": True}) + addr = (target[0], int(target[1])) + handler = self.client._custom_handlers[1].get("/send_msg_forward") + if handler is None: + return jsonify({"ok": False, "error": "forward extension is not loaded"}), 500 + command = f"/send_msg_forward {shlex.quote(message)} {shlex.quote(str(addr))}" + threading.Thread( + target=self._run_client_command, args=(handler, command), daemon=True + ).start() + return jsonify({"ok": True}) + + @app.post("/api/send_file") + def api_send_file(): + if not self.connected or self.client is None: + return jsonify({"ok": False, "error": "not connected"}), 400 + target = request.form.get("target") + files = request.files.getlist("files") + if not files: + return jsonify({"ok": False, "error": "no files uploaded"}), 400 + os.makedirs(UPLOAD_DIR, exist_ok=True) + saved = [] + for f in files: + path = os.path.join(UPLOAD_DIR, os.path.basename(f.filename)) + f.save(path) + saved.append(path) + if target == "server": + for path in saved: + threading.Thread( + target=self._send_file_to_server, args=(path,), daemon=True + ).start() + else: + addr = tuple(json.loads(target)) + for path in saved: + threading.Thread(target=self._forward_file, args=(path, addr), daemon=True).start() + return jsonify({"ok": True}) + + @app.post("/api/send_folder") + def api_send_folder(): + if not self.connected or self.client is None: + return jsonify({"ok": False, "error": "not connected"}), 400 + target = request.form.get("target") + files = request.files.getlist("files") + if not files: + return jsonify({"ok": False, "error": "no files uploaded"}), 400 + os.makedirs(UPLOAD_DIR, exist_ok=True) + root = None + for f in files: + rel = f.filename # webkitRelativePath, e.g. "folder/sub/file.txt" + path = os.path.join(UPLOAD_DIR, rel) + os.makedirs(os.path.dirname(path), exist_ok=True) + f.save(path) + if root is None: + root = os.path.join(UPLOAD_DIR, rel.split("/")[0]) + if root is None or not os.path.isdir(root): + return jsonify({"ok": False, "error": "folder upload failed"}), 500 + if target == "server": + threading.Thread( + target=self._send_folder_to_server, args=(root,), daemon=True + ).start() + else: + addr = tuple(json.loads(target)) + threading.Thread(target=self._forward_folder, args=(root, addr), daemon=True).start() + return jsonify({"ok": True}) + + @app.post("/api/run_extension") + def api_run_extension(): + if not self.connected or self.client is None: + return jsonify({"ok": False, "error": "not connected"}), 400 + data = request.get_json(force=True) + command = data.get("command", "") + parts = shlex.split(command) + if not parts: + return jsonify({"ok": False, "error": "empty command"}), 400 + handler = self.client._custom_handlers[1].get(parts[0].lower()) + if handler is None: + return jsonify({"ok": False, "error": f"command {parts[0]} is not registered"}), 404 + threading.Thread( + target=self._run_client_command, args=(handler, command), daemon=True + ).start() + return jsonify({"ok": True}) + + @app.get("/api/available_commands") + def api_available_commands(): + if not self.connected or self.client is None: + return jsonify({"ok": False, "error": "not connected"}), 400 + return jsonify({"commands": sorted(self.client._custom_handlers[1].keys())}) + + @app.post("/api/sync_clients") + def api_sync_clients(): + if not self.connected or self.client is None: + return jsonify({"ok": False, "error": "not connected"}), 400 + self.client.send_message(self.client.client_socket, "/web_sync_clients") + return jsonify({"ok": True}) + + @app.get("/api/extensions_ui") + def api_get_extensions_ui(): + return jsonify({"extensions": _load_json_list(CLIENT_EXTENSIONS_UI_FILE)}) + + @app.get("/api/registered_extensions") + def api_registered_extensions(): + return jsonify({"extensions": _load_json_list(add_extension.added_extensions_log_file)}) + + @app.post("/api/extensions_ui") + def api_save_extensions_ui(): + data = request.get_json(force=True) + entries = data.get("extensions", []) + os.makedirs(FLOW_WEB_DIR, exist_ok=True) + with open(CLIENT_EXTENSIONS_UI_FILE, "w", encoding="utf-8") as f: + json.dump(entries, f, indent=4, ensure_ascii=False) + return jsonify({"ok": True}) + + @app.post("/api/add_extension") + def api_add_extension(): + data = request.get_json(force=True) + paths = data.get("paths", []) + try: + add_extension.add_extension(paths) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 400 + threading.Thread(target=self._restart, daemon=True).start() + return jsonify({"ok": True, "restarting": True}) + + @app.post("/api/remove_extension") + def api_remove_extension(): + data = request.get_json(force=True) + paths = data.get("paths", []) + try: + add_extension.remove_extension(paths) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 400 + threading.Thread(target=self._restart, daemon=True).start() + return jsonify({"ok": True, "restarting": True}) + + # ------------------------------------------------------------------- run + + def _clients_snapshot(self): + with self._clients_lock: + return list(self._clients) + + def run(self): + port = _find_free_port(self.web_port) + if port != self.web_port: + print(f"Web port {self.web_port} busy, using {port}") + threading.Thread(target=self._open_browser, args=(port,), daemon=True).start() + self.app.run(host="127.0.0.1", port=port, threaded=True, use_reloader=False) + + def _open_browser(self, port): + time.sleep(1.5) + try: + import webbrowser + + webbrowser.open(f"http://127.0.0.1:{port}/") + except Exception: + pass diff --git a/PyFlow/transfer_web/web_front/templates/client_connect.html b/PyFlow/transfer_web/web_front/templates/client_connect.html new file mode 100644 index 0000000..d4dce96 --- /dev/null +++ b/PyFlow/transfer_web/web_front/templates/client_connect.html @@ -0,0 +1,66 @@ + + + + + + PyFlow TCP Client + + + +
+
+

Connect to a PyFlow TCP Server

+

Enter the server address: an http:// or https:// domain, or a bare IP address (optionally with the web port, e.g. 192.168.1.10:5000).

+
+
+ + +
+
+ + +
+
+
+
+ + + diff --git a/PyFlow/transfer_web/web_front/templates/client_main.html b/PyFlow/transfer_web/web_front/templates/client_main.html new file mode 100644 index 0000000..7d7a230 --- /dev/null +++ b/PyFlow/transfer_web/web_front/templates/client_main.html @@ -0,0 +1,49 @@ + + + + + + PyFlow TCP Client + + + +
+ +
+
+ Select a target + + connecting... +
+
+
Connected. Select the server or a client on the left to send messages, files or folders.

Messages to other clients are forwarded through the server.
+
+
+
+ + +
+
+ + + + +
+
+
+
+ + + + diff --git a/README.md b/README.md index dc082aa..f310cd7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ PyFlow is a high-level network protocol with APIs for transferring messages, fil - **Encrypted TCP channel** — RSA-OAEP message encryption with a TOFU (trust-on-first-use) peer-key registry, session nonces and sequence numbers against replay, and a circuit breaker against re-exchange storms. See [docs/Crypto](docs/Crypto/Crypto.rst) and the encrypted-channel sections of the TCP API docs. - **C/OpenSSL cryptography library** — `libcrypto_api` provides RSA-OAEP, ECDH (P-256/384/521), HKDF-SHA256 and AES-256-GCM with a stable C API (`pf_*` prefix) usable from C, CMake or pkg-config. - **Multi-instance launcher** — `python -m PyFlow` (package entry point backed by `PyFlow/flow_setup.py`) starts one or more server/client instances from a CLI, an interactive prompt, or a `setup.json` configuration file. -- **Extension protocols** — `command_control_extension_tcp.py` (remote command execution with log collection) and `forward_extension_tcp.py` (forwarding messages/files/folders to multiple destinations) plug into any instance via `setup_*_commands()`; `flow_setup.py` loads them automatically for every instance whose `setup.json` config sets `is_extend_command=True`, and starts instances in a background thread when `is_input_command_in_console=False`. +- **Web tool** — `PyFlow/transfer_web/` wraps the TCP protocol in a browser UI for non-library use: `setup_server.py` opens a startup-configuration page (saved to `.Flow_Web/setup_server.json`, same shape as `setup.json`) and then serves a status page plus a client-facing API; `setup_client.py` connects to a server by address, and both pages offer a sidebar of connected instances, message/file/folder sending (with forwarding to other clients), and extension loading. Backed by Flask. ## Architecture @@ -24,8 +24,9 @@ PyFlow/ │ ├── connect_udp.py UDP communication │ ├── rsa_crypto.py ctypes binding to libcrypto_api + TOFU key registry │ └── decode_command_table.json wire-format table for the file-transfer protocol -├── command_control_extension_tcp.py command-control extension over TCP -├── forward_extension_tcp.py forward extension over TCP (messages/files/folders to multiple destinations) +├── transfer_web/ web tool: setup_server.py / setup_client.py launchers, +│ │ web_backend/ (Flask + TCP server wrapper), +│ │ web_front/ (Flask + TCP client wrapper), static/ (shared UI) ├── __init__.py / __main__.py package launcher entry (`python -m PyFlow`) ├── flow_setup.py launcher implementation └── setup.json default launcher configuration (generated) @@ -34,7 +35,7 @@ docs/ Sphinx documentation (multi-language) CMakeLists.txt top-level build for the C library and C tests ``` -The Python layer runs on the standard library only; the C library is loaded at runtime via `ctypes`. +The Python layer runs on the standard library plus Flask (used only by the `transfer_web` web tool); the C library is loaded at runtime via `ctypes`. ## Requirements @@ -101,6 +102,35 @@ Start a client that connects to that server (and binds its own local address/por uv run python -m PyFlow --type 1 --setup_addr_port 127.0.0.1:23456 --connect_addr_port 127.0.0.1:12345 ``` +### Web tool (browser UI) + +The web tool wraps the TCP protocol in a browser UI for non-library use +(requires Flask, installed by `uv sync`). Launch it through the package +launcher or directly: + +```bash +uv run python -m PyFlow --web_server # server: config UI -> status page + API +uv run python -m PyFlow --web_client # client: connect UI -> main UI +``` + +or directly: + +```bash +uv run python PyFlow/transfer_web/setup_server.py +uv run python PyFlow/transfer_web/setup_client.py +``` + +On first run the server launcher opens a startup-configuration page +showing every `TCP_Server_Base` parameter with its default; the saved +config lives in `PyFlow/transfer_web/.Flow_Web/setup_server.json` (same +shape as `setup.json`). Once the TCP server is up, the server's web +backend serves a status page and a client-facing API +(`/api/server_info` returns the TCP address/port). The client launcher +asks for the server address (an `http`/`https` domain or a bare IP) and +connects through the server's web backend. Both pages show a sidebar of +connected instances, message/file/folder sending (client-to-client +sends are forwarded through the server), and extension loading. + ### `setup.json` A pre-written `setup.json` is honoured by the launcher: diff --git a/pyproject.toml b/pyproject.toml index f17d348..de0ac56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyflow-net" version = "0.5.0" description = "PyFlow is a high-level network protocol with APIs for transferring messages, files, and folders, plus extensible interfaces etc.." requires-python = ">=3.10" -dependencies = [] +dependencies = ["flask>=3.0"] authors = [ { name="F18-Maverick", email="rayxu2333@outlook.com" }, ] diff --git a/test/test_flow_setup.py b/test/test_flow_setup.py index 2329517..d82644f 100644 --- a/test/test_flow_setup.py +++ b/test/test_flow_setup.py @@ -195,8 +195,46 @@ def test_launch_instance_os_branches( # noqa: PLR0913 assert mocked_popen.call_args.kwargs.get("cwd") == project_root -def test_launch_instance_popen_failure(monkeypatch, capsys): - """Exception in Popen is caught, error printed, temp file cleaned up.""" +@pytest.mark.parametrize( + "system,which_return,kind,expected_module", + [ + ("Windows", None, "server", "PyFlow.transfer_web.setup_server"), + ("Linux", "xterm", "client", "PyFlow.transfer_web.setup_client"), + ("Darwin", None, "server", "PyFlow.transfer_web.setup_server"), + ("Linux", None, "client", "PyFlow.transfer_web.setup_client"), + ("FreeBSD", None, "server", "PyFlow.transfer_web.setup_server"), + ], +) +def test_launch_web_tool_os_branches( # noqa: PLR0913 + monkeypatch, mocked_popen, system, which_return, kind, expected_module +): + monkeypatch.setattr(fs.platform, "system", lambda: system) + if which_return is not None: + monkeypatch.setattr(fs.shutil, "which", lambda t: which_return) + elif system == "Linux": + monkeypatch.setattr(fs.shutil, "which", lambda t: None) + + fs.launch_web_tool(kind) + mocked_popen.assert_called_once() + cmd = mocked_popen.call_args.args[0] + if system in ("Windows", "Darwin") or (system == "Linux" and which_return): + assert isinstance(cmd, str) + else: + assert isinstance(cmd, list) + cmd_repr = cmd if isinstance(cmd, str) else " ".join(cmd) + assert expected_module in cmd_repr + # the child must be started as a package (python -m), not as the + # script file, so the relative imports in the launcher resolve + assert "-m PyFlow.transfer_web.setup_" in cmd_repr + assert "setup_server.py" not in cmd_repr + assert "setup_client.py" not in cmd_repr + # and from the project root, so `PyFlow` is importable + project_root = os.path.dirname(os.path.dirname(os.path.abspath(fs.__file__))) + assert mocked_popen.call_args.kwargs.get("cwd") == project_root + + +def test_launch_web_tool_popen_failure(monkeypatch, capsys): + """Exception in Popen is caught and an error is printed.""" def failing_popen(*args, **kwargs): raise OSError("mock failure") @@ -204,10 +242,10 @@ def failing_popen(*args, **kwargs): monkeypatch.setattr(fs.subprocess, "Popen", failing_popen) monkeypatch.setattr(fs.platform, "system", lambda: "Windows") - fs.launch_instance({"host": "127.0.0.1", "port": 65000}, "server") + fs.launch_web_tool("server") captured = capsys.readouterr() - assert "Failed to launch instance" in captured.out + assert "Failed to launch web server" in captured.out assert "mock failure" in captured.out @@ -488,10 +526,31 @@ def test_main_type_client_missing_args_rejected(monkeypatch, cli_cleanup): fs.main() -def test_main_type_server_missing_addr_rejected(monkeypatch, cli_cleanup): - monkeypatch.setattr(sys, "argv", ["flow_setup", "--type", "0"]) - with pytest.raises(SystemExit): - fs.main() +def test_main_web_server_flag(monkeypatch, cli_cleanup): + """--web_server launches the web TCP server tool and returns.""" + monkeypatch.setattr(fs, "launch_web_tool", MagicMock()) + monkeypatch.setattr(sys, "argv", ["flow_setup", "--web_server"]) + fs.main() + fs.launch_web_tool.assert_called_once_with("server") + + +def test_main_web_client_flag(monkeypatch, cli_cleanup): + """--web_client launches the web TCP client tool and returns.""" + monkeypatch.setattr(fs, "launch_web_tool", MagicMock()) + monkeypatch.setattr(sys, "argv", ["flow_setup", "--web_client"]) + fs.main() + fs.launch_web_tool.assert_called_once_with("client") + + +def test_main_web_both_flags_launch_both(monkeypatch, cli_cleanup): + """Both web flags together launch both tools.""" + monkeypatch.setattr(fs, "launch_web_tool", MagicMock()) + monkeypatch.setattr(sys, "argv", ["flow_setup", "--web_server", "--web_client"]) + fs.main() + assert fs.launch_web_tool.call_args_list == [ + (("server",),), + (("client",),), + ] def test_main_existing_setup_json_keep_existing(monkeypatch, cli_cleanup, tmp_config): diff --git a/uv.lock b/uv.lock index 0751bcc..446766f 100644 --- a/uv.lock +++ b/uv.lock @@ -38,6 +38,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -414,6 +423,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + [[package]] name = "idna" version = "3.19" @@ -441,6 +467,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -569,6 +604,9 @@ wheels = [ name = "pyflow-net" version = "0.5.0" source = { editable = "." } +dependencies = [ + { name = "flask" }, +] [package.dev-dependencies] dev = [ @@ -583,6 +621,7 @@ dev = [ ] [package.metadata] +requires-dist = [{ name = "flask", specifier = ">=3.0" }] [package.metadata.requires-dev] dev = [ @@ -911,3 +950,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] From aca837707510b4bc268ff32951dc4f2410169dbc Mon Sep 17 00:00:00 2001 From: F18-Ray Date: Mon, 7 Sep 2026 19:53:49 +0800 Subject: [PATCH 02/11] Docs: fix the deleted container in readme --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index f310cd7..2890584 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ PyFlow is a high-level network protocol with APIs for transferring messages, fil - **Encrypted TCP channel** — RSA-OAEP message encryption with a TOFU (trust-on-first-use) peer-key registry, session nonces and sequence numbers against replay, and a circuit breaker against re-exchange storms. See [docs/Crypto](docs/Crypto/Crypto.rst) and the encrypted-channel sections of the TCP API docs. - **C/OpenSSL cryptography library** — `libcrypto_api` provides RSA-OAEP, ECDH (P-256/384/521), HKDF-SHA256 and AES-256-GCM with a stable C API (`pf_*` prefix) usable from C, CMake or pkg-config. - **Multi-instance launcher** — `python -m PyFlow` (package entry point backed by `PyFlow/flow_setup.py`) starts one or more server/client instances from a CLI, an interactive prompt, or a `setup.json` configuration file. +- **Extension protocols** — `command_control_extension_tcp.py` (remote command execution with log collection) and `forward_extension_tcp.py` (forwarding messages/files/folders to multiple destinations) plug into any instance via `setup_*_commands()`; `flow_setup.py` loads them automatically for every instance whose `setup.json` config sets `is_extend_command=True`, and starts instances in a background thread when `is_input_command_in_console=False`. - **Web tool** — `PyFlow/transfer_web/` wraps the TCP protocol in a browser UI for non-library use: `setup_server.py` opens a startup-configuration page (saved to `.Flow_Web/setup_server.json`, same shape as `setup.json`) and then serves a status page plus a client-facing API; `setup_client.py` connects to a server by address, and both pages offer a sidebar of connected instances, message/file/folder sending (with forwarding to other clients), and extension loading. Backed by Flask. ## Architecture @@ -24,6 +25,8 @@ PyFlow/ │ ├── connect_udp.py UDP communication │ ├── rsa_crypto.py ctypes binding to libcrypto_api + TOFU key registry │ └── decode_command_table.json wire-format table for the file-transfer protocol +├── command_control_extension_tcp.py command-control extension over TCP +├── forward_extension_tcp.py forward extension over TCP (messages/files/folders to multiple destinations) ├── transfer_web/ web tool: setup_server.py / setup_client.py launchers, │ │ web_backend/ (Flask + TCP server wrapper), │ │ web_front/ (Flask + TCP client wrapper), static/ (shared UI) From 1d575cfa8cd67786a07b8d421d688f3e48a6c870 Mon Sep 17 00:00:00 2001 From: F18-Ray Date: Tue, 8 Sep 2026 10:00:44 +0800 Subject: [PATCH 03/11] core-code: fix the clients and servers can't get messages on the front also add the functions for users can get messages in the code while import the pyflow --- PyFlow/network_api/connect_tcp.py | 134 ++++++++++++++++++ PyFlow/transfer_web/static/common.js | 69 +++++++-- .../web_backend/server_backend.py | 66 +++++++++ .../transfer_web/web_front/client_backend.py | 80 +++++++++++ 4 files changed, 338 insertions(+), 11 deletions(-) diff --git a/PyFlow/network_api/connect_tcp.py b/PyFlow/network_api/connect_tcp.py index 3e3b867..6086a73 100644 --- a/PyFlow/network_api/connect_tcp.py +++ b/PyFlow/network_api/connect_tcp.py @@ -146,6 +146,12 @@ def __init__( self._custom_handler_threaded = [{}, {}] self._custom_executor = ThreadPoolExecutor(max_workers=max_custom_workers) self._task_semaphore = threading.Semaphore(max_custom_workers) + # Inbound-event listeners (see add_message_listener/add_file_listener). + # They run on the receive thread, so a listener must not block and must + # never raise (exceptions are swallowed by the notify helpers). + self._message_listeners = [] + self._file_listeners = [] + self._event_listeners_lock = threading.Lock() self.is_extend_command = is_extend_command self.is_enable_encrypto = is_enable_encrypto self.is_custom_keys = is_custom_keys @@ -361,6 +367,65 @@ def register_command(self, command_name, handler, where_to_run, run_in_thread=Fa self._custom_handlers[registe_index][command_name] = handler self._custom_handler_threaded[registe_index][command_name] = run_in_thread + def add_message_listener(self, listener): + """Register ``listener(client_id, message)`` for every inbound plain-text message. + + Plain messages are the chat/data lines received from a client that do + not start with ``/``. ``client_id`` is the sender's ``"ip:port"``. + Commands are not reported here; they go through the registered + command handlers. + """ + with self._event_listeners_lock: + if listener not in self._message_listeners: + self._message_listeners.append(listener) + + def remove_message_listener(self, listener): + """Unregister a listener previously added by ``add_message_listener``.""" + with self._event_listeners_lock: + try: + self._message_listeners.remove(listener) + except ValueError: + pass + + def add_file_listener(self, listener): + """Register ``listener(client_id, full_path, name, size, command)`` for each saved inbound file. + + Fired after a file uploaded by a client (a direct send or a forwarded + file/folder item staged on the server) has been fully written to + ``file_transfer_dir``. ``client_id`` is the uploader's ``"ip:port"`` + and ``command`` is the wire command that triggered the transfer, so a + listener can recognise protocol pushes such as ``/crypto_pub_key``. + """ + with self._event_listeners_lock: + if listener not in self._file_listeners: + self._file_listeners.append(listener) + + def remove_file_listener(self, listener): + """Unregister a listener previously added by ``add_file_listener``.""" + with self._event_listeners_lock: + try: + self._file_listeners.remove(listener) + except ValueError: + pass + + def _notify_message_received(self, client_id, message): + with self._event_listeners_lock: + listeners = list(self._message_listeners) + for listener in listeners: + try: + listener(client_id, message) + except Exception: + traceback.print_exc() + + def _notify_file_received(self, client_id, full_path, name, size, command): + with self._event_listeners_lock: + listeners = list(self._file_listeners) + for listener in listeners: + try: + listener(client_id, full_path, name, size, command) + except Exception: + traceback.print_exc() + def submit_task(self, func, *args, **kwargs): self._task_semaphore.acquire() future = self._custom_executor.submit(func, *args, **kwargs) @@ -910,6 +975,7 @@ def handle_client(self, client_socket, client_address): # deal with each client if message.startswith("/"): # deal with special command response = self.handle_command(client_socket, client_address, message) else: + self._notify_message_received(client_id, message) timestamp = datetime.now().strftime("%H:%M:%S") # deal with normal message log_msg = f"[{timestamp}] {client_id}: {message}" print(log_msg) @@ -1327,6 +1393,7 @@ def file_transfer_client_recv(client_id): # TOFU first: the ack is only a notification, and a failed # ack send must not skip the key registration (the server # would never announce readiness and the handshake hangs) + self._notify_file_received(client_id, full_path, final_filename, file_size, command) print(f"file {filename} received from {client_id}, size {file_size} bytes") if command_part[0] == "/crypto_pub_key": with self._crypto_lock: @@ -2413,6 +2480,12 @@ def __init__( self._custom_handler_threaded = [{}, {}] self._custom_executor = ThreadPoolExecutor(max_workers=max_custom_workers) self._task_semaphore = threading.Semaphore(max_custom_workers) + # Inbound-event listeners (see add_message_listener/add_file_listener). + # They run on the receive thread, so a listener must not block and must + # never raise (exceptions are swallowed by the notify helpers). + self._message_listeners = [] + self._file_listeners = [] + self._event_listeners_lock = threading.Lock() self.is_extend_command = is_extend_command self.is_enable_encrypto = is_enable_encrypto self.is_custom_keys = is_custom_keys @@ -2464,6 +2537,64 @@ def register_command(self, command_name, handler, where_to_run, run_in_thread=Fa self._custom_handlers[registe_index][command_name] = handler self._custom_handler_threaded[registe_index][command_name] = run_in_thread + def add_message_listener(self, listener): + """Register ``listener(message: str)`` for every inbound plain-text message. + + Plain messages are the chat/data lines received from the server that + do not start with ``/`` (direct sends from the server, messages + forwarded from other clients, protocol replies). Commands are not + reported here; they go through the registered command handlers. + """ + with self._event_listeners_lock: + if listener not in self._message_listeners: + self._message_listeners.append(listener) + + def remove_message_listener(self, listener): + """Unregister a listener previously added by ``add_message_listener``.""" + with self._event_listeners_lock: + try: + self._message_listeners.remove(listener) + except ValueError: + pass + + def add_file_listener(self, listener): + """Register ``listener(full_path, name, size, command)`` for each saved inbound file. + + Fired after a file pushed by the server (a direct send, a forwarded + file or folder item) has been fully written to ``file_transfer_dir``. + ``command`` is the wire command that triggered the transfer, so a + listener can recognise protocol pushes such as ``/crypto_pub_key``. + """ + with self._event_listeners_lock: + if listener not in self._file_listeners: + self._file_listeners.append(listener) + + def remove_file_listener(self, listener): + """Unregister a listener previously added by ``add_file_listener``.""" + with self._event_listeners_lock: + try: + self._file_listeners.remove(listener) + except ValueError: + pass + + def _notify_message_received(self, message): + with self._event_listeners_lock: + listeners = list(self._message_listeners) + for listener in listeners: + try: + listener(message) + except Exception: + traceback.print_exc() + + def _notify_file_received(self, full_path, name, size, command): + with self._event_listeners_lock: + listeners = list(self._file_listeners) + for listener in listeners: + try: + listener(full_path, name, size, command) + except Exception: + traceback.print_exc() + def submit_task(self, func, *args, **kwargs): self._task_semaphore.acquire() future = self._custom_executor.submit(func, *args, **kwargs) @@ -2804,6 +2935,8 @@ def receive_messages(self): # get server msg if message.startswith("/"): self.handle_server_command(message) if message: + if not message.startswith("/"): + self._notify_message_received(message) print(f"\n[server] {message}") except socket.timeout: continue @@ -4093,6 +4226,7 @@ def file_transfer_client_recv(client_id): # TOFU first: the ack is only a notification, and a failed # ack send must not skip the key registration (readiness # would never be announced and the handshake hangs) + self._notify_file_received(full_path, final_filename, file_size, command) print(f"file {filename} received from {client_id}, size {file_size} bytes") if command_part[0] == "/crypto_pub_key": self._crypto_store_received_pub(full_path, "server", (self.host, self.port)) diff --git a/PyFlow/transfer_web/static/common.js b/PyFlow/transfer_web/static/common.js index c086c32..92e1f28 100644 --- a/PyFlow/transfer_web/static/common.js +++ b/PyFlow/transfer_web/static/common.js @@ -15,6 +15,7 @@ target: null, // "server" | [ip, port] extensions: [], // [{name, icon, command}] messages: [], // [{dir: "out"|"sys", text}] + eventId: 0, // last inbound event id consumed from /api/events }; /* ---------------- helpers ---------------- */ @@ -54,6 +55,18 @@ return target[0] + ":" + target[1]; } + function fmtSize(n) { + if (n == null || isNaN(n)) return "?"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let v = Number(n); + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return (v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)) + " " + units[i]; + } + function isSelf(entry) { return ( state.ownAddress && @@ -76,14 +89,11 @@ serverEntry.addEventListener("click", () => selectTarget("server")); list.appendChild(serverEntry); - if (!state.clients.length) { - const empty = document.createElement("div"); - empty.className = "empty-hint"; - empty.style.padding = "16px 8px"; - empty.textContent = "No clients connected"; - list.appendChild(empty); - } + let shown = 0; state.clients.forEach((c) => { + // a client never lists itself; the server is never in the client list + if (MODE === "client" && isSelf(c)) return; + shown++; const el = document.createElement("div"); const key = c.ip + ":" + c.port; const active = @@ -92,14 +102,20 @@ state.target[0] === c.ip && state.target[1] === c.port; el.className = "instance" + (active ? " active" : ""); - const self = isSelf(c); el.innerHTML = - '' + - '' + esc(key) + (self ? " (me)" : "") + "" + - '' + (self ? "me" : "client") + ""; + '' + + '' + esc(key) + "" + + 'client'; el.addEventListener("click", () => selectTarget([c.ip, c.port])); list.appendChild(el); }); + if (!shown) { + const empty = document.createElement("div"); + empty.className = "empty-hint"; + empty.style.padding = "16px 8px"; + empty.textContent = "No clients connected"; + list.appendChild(empty); + } } function selectTarget(target) { @@ -150,6 +166,35 @@ } } + /* ---------------- inbound events ---------------- */ + + async function refreshEvents() { + let data; + try { + data = await api("/api/events?since=" + state.eventId); + } catch (e) { + return; // offline or a backend without the endpoint + } + let rendered = 0; + (data.events || []).forEach((ev) => { + if (ev.id) state.eventId = Math.max(state.eventId, ev.id); + const from = ev.from ? ev.from + ": " : ""; + if (ev.type === "msg") { + addMessage("in", from + ev.text); + rendered++; + } else if (ev.type === "file") { + const size = fmtSize(ev.size); + addMessage("in", from + "file received: " + ev.name + " (" + size + ") -> " + ev.path); + toast("File received: " + ev.name + " (" + size + ")", "ok"); + rendered++; + } + }); + if (rendered && MODE === "server") { + const hint = $("empty-hint"); + if (hint) hint.style.display = "none"; + } + } + /* ---------------- sending ---------------- */ function currentTarget() { @@ -471,7 +516,9 @@ selectTarget("server"); loadExtensions(); refreshStatus(); + refreshEvents(); setInterval(refreshStatus, 2000); + setInterval(refreshEvents, 2000); } if (document.readyState === "loading") { diff --git a/PyFlow/transfer_web/web_backend/server_backend.py b/PyFlow/transfer_web/web_backend/server_backend.py index bb498af..5b745fb 100644 --- a/PyFlow/transfer_web/web_backend/server_backend.py +++ b/PyFlow/transfer_web/web_backend/server_backend.py @@ -16,6 +16,11 @@ disconnects it broadcasts the current instance list to every connected client (``/web_clients_update``), and it re-checks the list every minute. + +Inbound events (plain-text messages and file uploads arriving from +clients) are captured on the TCP server's receive threads through +``TCP_Server_Base``'s ``add_message_listener``/``add_file_listener`` +APIs, queued here, and polled by the frontend via ``/api/events``. """ import json @@ -135,6 +140,9 @@ def __init__(self, web_port=None): self._last_clients = set() self._monitor_stop = threading.Event() self._monitor_thread = None + self._events = [] # inbound events surfaced to the frontend (/api/events) + self._events_lock = threading.Lock() + self._event_seq = 0 self.app = Flask( __name__, template_folder=TEMPLATE_DIR, @@ -180,6 +188,8 @@ def _start_server(self, config): self.server.register_command( "/web_sync_clients", self._on_sync_clients, where_to_run="server", run_in_thread=True ) + self.server.add_message_listener(self._on_incoming_message) + self.server.add_file_listener(self._on_incoming_file) try: add_extension.load_registered_extensions(self.server, "server") except ImportError as e: @@ -244,6 +254,54 @@ def _on_sync_clients(self, sock, addr, cmd): self._broadcast_clients() return None + # ------------------------------------------------ inbound event handling + + def _push_event(self, event): + """Record an inbound event with a monotonically increasing id.""" + with self._events_lock: + self._event_seq += 1 + event["id"] = self._event_seq + self._events.append(event) + if len(self._events) > 1000: + del self._events[: len(self._events) - 1000] + return self._event_seq + + def _on_incoming_message(self, client_id, text): + """Server receive thread: a plain-text message arrived from a client.""" + text = (text or "").strip() + if not text: + return + self._push_event( + {"type": "msg", "from": client_id, "text": text, "at": time.strftime("%H:%M:%S")} + ) + + def _on_incoming_file(self, client_id, full_path, name, size, command): + """Server receive thread: a file uploaded by a client was saved.""" + try: + cmd_name = (command or "").strip().split(" ", 1)[0].lower() + except Exception: + cmd_name = "" + if cmd_name == "/crypto_pub_key": # handshake keys are not user data + return + rel = full_path + if self.server is not None: + try: + candidate = os.path.relpath(full_path, self.server.file_transfer_dir) + if not candidate.startswith(".."): + rel = candidate + except Exception: + pass + self._push_event( + { + "type": "file", + "name": name, + "path": rel, + "size": size, + "from": client_id, + "at": time.strftime("%H:%M:%S"), + } + ) + # ---------------------------------------------------------------- helpers def _require_server(self): @@ -338,6 +396,14 @@ def api_server_info(): def api_clients(): return jsonify({"clients": self._client_list()}) + @app.get("/api/events") + def api_events(): + since = request.args.get("since", 0, type=int) + with self._events_lock: + events = [e for e in self._events if e["id"] > since] + latest = events[-1]["id"] if events else since + return jsonify({"events": events, "latest": latest}) + @app.post("/api/send_msg") def api_send_msg(): err = self._require_server() diff --git a/PyFlow/transfer_web/web_front/client_backend.py b/PyFlow/transfer_web/web_front/client_backend.py index c966ca2..e7321f8 100644 --- a/PyFlow/transfer_web/web_front/client_backend.py +++ b/PyFlow/transfer_web/web_front/client_backend.py @@ -15,6 +15,12 @@ The sidebar instance list is kept fresh by the server's ``/web_clients_update`` broadcasts; a reload button re-requests the list via ``/web_sync_clients``. + +Inbound events (plain-text messages and files pushed by the server, +whether direct sends or client forwards) are captured on the TCP +client's receive threads through ``TCP_Client_Base``'s +``add_message_listener``/``add_file_listener`` APIs, queued here, and +polled by the frontend via ``/api/events``. """ import json @@ -94,6 +100,11 @@ def __init__(self, web_port=None): self._last_address = "" self._clients = [] self._clients_lock = threading.Lock() + self._events = [] # inbound events surfaced to the frontend (/api/events) + self._events_lock = threading.Lock() + self._event_seq = 0 + self._echo_expect = None # plain text last sent to the server (echo suppression) + self._echo_expect_at = 0.0 self.app = Flask( __name__, template_folder=TEMPLATE_DIR, @@ -173,6 +184,62 @@ def _on_clients_update(self, sock, addr, cmd): self._clients = clients return None + # ------------------------------------------------ inbound event handling + + def _push_event(self, event): + """Record an inbound event with a monotonically increasing id.""" + with self._events_lock: + self._event_seq += 1 + event["id"] = self._event_seq + self._events.append(event) + if len(self._events) > 1000: + del self._events[: len(self._events) - 1000] + return self._event_seq + + def _on_incoming_message(self, text): + """Client receive thread: a plain-text message arrived from the server.""" + text = (text or "").strip() + if not text: + return + if text.startswith("Welcome!:"): # connection greeting, not chat + return + if text == "Command received, processing in background.": # server ack, not chat + return + if text.startswith("Unknown command"): # server rejection notice, not chat + return + if text.startswith("msg send: "): # echo of our own plain send to the server + with self._events_lock: + expect, at = self._echo_expect, self._echo_expect_at + if expect is not None and time.time() - at <= 3 and text == "msg send: " + expect: + return + self._push_event({"type": "msg", "text": text, "at": time.strftime("%H:%M:%S")}) + + def _on_incoming_file(self, full_path, name, size, command): + """Client receive thread: a file pushed by the server was saved.""" + try: + cmd_name = (command or "").strip().split(" ", 1)[0].lower() + except Exception: + cmd_name = "" + if cmd_name == "/crypto_pub_key": # handshake keys are not user data + return + rel = full_path + if self.client is not None: + try: + candidate = os.path.relpath(full_path, self.client.file_transfer_dir) + if not candidate.startswith(".."): + rel = candidate + except Exception: + pass + self._push_event( + { + "type": "file", + "name": name, + "path": rel, + "size": size, + "at": time.strftime("%H:%M:%S"), + } + ) + # ---------------------------------------------------------------- connect def _start_client(self, host, port, is_enable_encrypto): @@ -196,6 +263,8 @@ def _start_client(self, host, port, is_enable_encrypto): self.client.register_command( "/web_clients_update", self._on_clients_update, where_to_run="server", run_in_thread=True ) + self.client.add_message_listener(self._on_incoming_message) + self.client.add_file_listener(self._on_incoming_file) try: add_extension.load_registered_extensions(self.client, "client") except ImportError as e: @@ -278,6 +347,14 @@ def api_status(): } ) + @app.get("/api/events") + def api_events(): + since = request.args.get("since", 0, type=int) + with self._events_lock: + events = [e for e in self._events if e["id"] > since] + latest = events[-1]["id"] if events else since + return jsonify({"events": events, "latest": latest}) + @app.post("/api/send_msg") def api_send_msg(): if not self.connected or self.client is None: @@ -289,6 +366,9 @@ def api_send_msg(): ok = self.client.send_message(self.client.client_socket, message) if not ok: return jsonify({"ok": False, "error": "send failed"}), 500 + with self._events_lock: + self._echo_expect = message + self._echo_expect_at = time.time() return jsonify({"ok": True}) addr = (target[0], int(target[1])) handler = self.client._custom_handlers[1].get("/send_msg_forward") From c1cb13ba8798b8e16a5b5906523cd1bf3f68b549 Mon Sep 17 00:00:00 2001 From: F18-Ray Date: Tue, 8 Sep 2026 15:50:17 +0800 Subject: [PATCH 04/11] core-code: add the internal messages recorder function and apis --- PyFlow/network_api/connect_tcp.py | 282 +++++++++++++++++++++++++++++- test/test_message_event_store.py | 211 ++++++++++++++++++++++ 2 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 test/test_message_event_store.py diff --git a/PyFlow/network_api/connect_tcp.py b/PyFlow/network_api/connect_tcp.py index 6086a73..df5a115 100644 --- a/PyFlow/network_api/connect_tcp.py +++ b/PyFlow/network_api/connect_tcp.py @@ -11,6 +11,7 @@ import uuid import errno import queue +import json from . import rsa_crypto from datetime import datetime from concurrent.futures import ThreadPoolExecutor @@ -71,7 +72,6 @@ def _parse_destination_path(command_part): return None - class TCP_Server_Base: # TCP server class def __init__( self, @@ -152,6 +152,20 @@ def __init__( self._message_listeners = [] self._file_listeners = [] self._event_listeners_lock = threading.Lock() + # Inbound message/event stores: external code reads these instead of + # registering listeners. Keyed by the sender's socket; each value is + # a list of [content, timestamp] pairs. When a store's total size + # reaches max_dict_size (64 KiB) it is flushed to its JSON log and + # cleared (see _record_message/_record_event/_flush_*_dict). + self.messages_dict = {} + self.events_dict = {} + self._messages_dict_lock = threading.Lock() + self._events_dict_lock = threading.Lock() + self._messages_dict_size = 0 + self._events_dict_size = 0 + self.max_dict_size = 64 * 1024 + self.messages_log_file = os.path.join(self.project_info_dir, "messages_log.json") + self.events_log_file = os.path.join(self.project_info_dir, "events_log.json") self.is_extend_command = is_extend_command self.is_enable_encrypto = is_enable_encrypto self.is_custom_keys = is_custom_keys @@ -426,6 +440,123 @@ def _notify_file_received(self, client_id, full_path, name, size, command): except Exception: traceback.print_exc() + def _socket_key(self, sock): + """Serializable key for a sender socket (its peer address).""" + try: + ip, port = sock.getpeername()[:2] + return f"{ip}:{port}" + except Exception: + return str(sock) + + def _record_message(self, sock, content): + """Store one inbound plain-text message under the sender's socket. + + External code reads ``messages_dict`` (or the JSON log) instead of + registering a message listener. The entry is ``[content, timestamp]`` + with the timestamp of arrival. + """ + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with self._messages_dict_lock: + self.messages_dict.setdefault(sock, []).append([content, timestamp]) + self._messages_dict_size += len(content.encode("utf-8", "replace")) + len(timestamp) + if self._messages_dict_size >= self.max_dict_size: + self._flush_dict_locked( + self.messages_dict, "_messages_dict_size", self.messages_log_file + ) + + def _record_event(self, sock, command): + """Store one inbound command as an event under the sender's socket. + + The event content is the original wire command from the peer (file + transfers, folder transfers, extension commands, ...). External code + reads ``events_dict`` (or the JSON log) instead of registering a + listener. File-transfer events get their completion timestamp via + ``_update_event_timestamp``. + """ + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with self._events_dict_lock: + self.events_dict.setdefault(sock, []).append([command, timestamp]) + self._events_dict_size += len(command.encode("utf-8", "replace")) + len(timestamp) + if self._events_dict_size >= self.max_dict_size: + self._flush_dict_locked( + self.events_dict, "_events_dict_size", self.events_log_file + ) + + def _update_event_timestamp(self, sock, command, timestamp): + """Stamp the completion time onto the recorded event for ``command``. + + File transfers finish on a worker thread after the receive thread + recorded the command, so the event's timestamp is refreshed here with + the moment the transfer actually completed. + """ + with self._events_dict_lock: + entries = self.events_dict.get(sock) + if entries: + for entry in reversed(entries): + if entry[0] == command: + entry[1] = timestamp + return + + def _splice_event_command(self, command, **parts): + """Return the command to record as an event. + + The wire command is recorded verbatim whenever it is available. When + this end cannot see the original command (a transfer relayed by the + server, or a protocol-internal control line), splice a readable + command from the available parts so the event still identifies the + transfer. + """ + if command: + return command + kind = parts.get("kind") + fname = parts.get("fname") + rel_dir = parts.get("rel_dir") + if kind == "folder" and fname: + if rel_dir: + return "/file_folder {} {}".format(shlex.quote(rel_dir), shlex.quote(fname)) + return "/file_folder {}".format(shlex.quote(fname)) + if fname: + return "/file {}".format(shlex.quote(fname)) + return parts.get("fallback") or "/unknown" + + def _flush_dict_locked(self, d, size_attr, path): + """Flush ``d`` (socket -> [[content, ts], ...]) into its JSON log and + clear it. The caller must hold the dict's lock.""" + if not d: + return + snapshot = dict(d) + d.clear() + setattr(self, size_attr, 0) + self._merge_json_log(path, snapshot) + + def _flush_messages_dict(self): + with self._messages_dict_lock: + self._flush_dict_locked( + self.messages_dict, "_messages_dict_size", self.messages_log_file + ) + + def _flush_events_dict(self): + with self._events_dict_lock: + self._flush_dict_locked( + self.events_dict, "_events_dict_size", self.events_log_file + ) + + def _merge_json_log(self, path, snapshot): + """Merge ``snapshot`` into the JSON log at ``path`` (append per socket).""" + try: + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + existing = json.load(f) + else: + existing = {} + for sock, entries in snapshot.items(): + key = self._socket_key(sock) + existing.setdefault(key, []).extend(entries) + with open(path, "w", encoding="utf-8") as f: + json.dump(existing, f, ensure_ascii=False, indent=2) + except Exception: + traceback.print_exc() + def submit_task(self, func, *args, **kwargs): self._task_semaphore.acquire() future = self._custom_executor.submit(func, *args, **kwargs) @@ -973,9 +1104,11 @@ def handle_client(self, client_socket, client_address): # deal with each client message = plain.strip() print(message) if message.startswith("/"): # deal with special command + self._record_event(client_socket, message) response = self.handle_command(client_socket, client_address, message) else: self._notify_message_received(client_id, message) + self._record_message(client_socket, message) timestamp = datetime.now().strftime("%H:%M:%S") # deal with normal message log_msg = f"[{timestamp}] {client_id}: {message}" print(log_msg) @@ -1394,6 +1527,11 @@ def file_transfer_client_recv(client_id): # ack send must not skip the key registration (the server # would never announce readiness and the handshake hangs) self._notify_file_received(client_id, full_path, final_filename, file_size, command) + self._update_event_timestamp( + client_socket, + self._splice_event_command(command, fname=final_filename), + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + ) print(f"file {filename} received from {client_id}, size {file_size} bytes") if command_part[0] == "/crypto_pub_key": with self._crypto_lock: @@ -2381,6 +2519,8 @@ def console_input(self): # deal consule input def stop(self): # shutting down the server self.running = False self.free_port() + self._flush_messages_dict() + self._flush_events_dict() with self.client_lock: # close all clients connections for client_info in self.clients.values(): try: @@ -2486,6 +2626,20 @@ def __init__( self._message_listeners = [] self._file_listeners = [] self._event_listeners_lock = threading.Lock() + # Inbound message/event stores: external code reads these instead of + # registering listeners. Keyed by the sender's socket; each value is + # a list of [content, timestamp] pairs. When a store's total size + # reaches max_dict_size (64 KiB) it is flushed to its JSON log and + # cleared (see _record_message/_record_event/_flush_*_dict). + self.messages_dict = {} + self.events_dict = {} + self._messages_dict_lock = threading.Lock() + self._events_dict_lock = threading.Lock() + self._messages_dict_size = 0 + self._events_dict_size = 0 + self.max_dict_size = 64 * 1024 + self.messages_log_file = os.path.join(self.project_info_dir, "messages_log.json") + self.events_log_file = os.path.join(self.project_info_dir, "events_log.json") self.is_extend_command = is_extend_command self.is_enable_encrypto = is_enable_encrypto self.is_custom_keys = is_custom_keys @@ -2595,6 +2749,123 @@ def _notify_file_received(self, full_path, name, size, command): except Exception: traceback.print_exc() + def _socket_key(self, sock): + """Serializable key for a sender socket (its peer address).""" + try: + ip, port = sock.getpeername()[:2] + return f"{ip}:{port}" + except Exception: + return str(sock) + + def _record_message(self, sock, content): + """Store one inbound plain-text message under the sender's socket. + + External code reads ``messages_dict`` (or the JSON log) instead of + registering a message listener. The entry is ``[content, timestamp]`` + with the timestamp of arrival. + """ + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with self._messages_dict_lock: + self.messages_dict.setdefault(sock, []).append([content, timestamp]) + self._messages_dict_size += len(content.encode("utf-8", "replace")) + len(timestamp) + if self._messages_dict_size >= self.max_dict_size: + self._flush_dict_locked( + self.messages_dict, "_messages_dict_size", self.messages_log_file + ) + + def _record_event(self, sock, command): + """Store one inbound command as an event under the sender's socket. + + The event content is the original wire command from the peer (file + transfers, folder transfers, extension commands, ...). External code + reads ``events_dict`` (or the JSON log) instead of registering a + listener. File-transfer events get their completion timestamp via + ``_update_event_timestamp``. + """ + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with self._events_dict_lock: + self.events_dict.setdefault(sock, []).append([command, timestamp]) + self._events_dict_size += len(command.encode("utf-8", "replace")) + len(timestamp) + if self._events_dict_size >= self.max_dict_size: + self._flush_dict_locked( + self.events_dict, "_events_dict_size", self.events_log_file + ) + + def _update_event_timestamp(self, sock, command, timestamp): + """Stamp the completion time onto the recorded event for ``command``. + + File transfers finish on a worker thread after the receive thread + recorded the command, so the event's timestamp is refreshed here with + the moment the transfer actually completed. + """ + with self._events_dict_lock: + entries = self.events_dict.get(sock) + if entries: + for entry in reversed(entries): + if entry[0] == command: + entry[1] = timestamp + return + + def _splice_event_command(self, command, **parts): + """Return the command to record as an event. + + The wire command is recorded verbatim whenever it is available. When + this end cannot see the original command (a transfer relayed by the + server, or a protocol-internal control line), splice a readable + command from the available parts so the event still identifies the + transfer. + """ + if command: + return command + kind = parts.get("kind") + fname = parts.get("fname") + rel_dir = parts.get("rel_dir") + if kind == "folder" and fname: + if rel_dir: + return "/file_folder {} {}".format(shlex.quote(rel_dir), shlex.quote(fname)) + return "/file_folder {}".format(shlex.quote(fname)) + if fname: + return "/file {}".format(shlex.quote(fname)) + return parts.get("fallback") or "/unknown" + + def _flush_dict_locked(self, d, size_attr, path): + """Flush ``d`` (socket -> [[content, ts], ...]) into its JSON log and + clear it. The caller must hold the dict's lock.""" + if not d: + return + snapshot = dict(d) + d.clear() + setattr(self, size_attr, 0) + self._merge_json_log(path, snapshot) + + def _flush_messages_dict(self): + with self._messages_dict_lock: + self._flush_dict_locked( + self.messages_dict, "_messages_dict_size", self.messages_log_file + ) + + def _flush_events_dict(self): + with self._events_dict_lock: + self._flush_dict_locked( + self.events_dict, "_events_dict_size", self.events_log_file + ) + + def _merge_json_log(self, path, snapshot): + """Merge ``snapshot`` into the JSON log at ``path`` (append per socket).""" + try: + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + existing = json.load(f) + else: + existing = {} + for sock, entries in snapshot.items(): + key = self._socket_key(sock) + existing.setdefault(key, []).extend(entries) + with open(path, "w", encoding="utf-8") as f: + json.dump(existing, f, ensure_ascii=False, indent=2) + except Exception: + traceback.print_exc() + def submit_task(self, func, *args, **kwargs): self._task_semaphore.acquire() future = self._custom_executor.submit(func, *args, **kwargs) @@ -2933,10 +3204,12 @@ def receive_messages(self): # get server msg if ok: message = plain.strip() if message.startswith("/"): + self._record_event(self.client_socket, message) self.handle_server_command(message) if message: if not message.startswith("/"): self._notify_message_received(message) + self._record_message(self.client_socket, message) print(f"\n[server] {message}") except socket.timeout: continue @@ -4227,6 +4500,11 @@ def file_transfer_client_recv(client_id): # ack send must not skip the key registration (readiness # would never be announced and the handshake hangs) self._notify_file_received(full_path, final_filename, file_size, command) + self._update_event_timestamp( + client_socket, + self._splice_event_command(command, fname=final_filename), + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + ) print(f"file {filename} received from {client_id}, size {file_size} bytes") if command_part[0] == "/crypto_pub_key": self._crypto_store_received_pub(full_path, "server", (self.host, self.port)) @@ -4285,6 +4563,8 @@ def file_transfer_client_recv(client_id): def close(self): # close connection self.running = False self.free_port() + self._flush_messages_dict() + self._flush_events_dict() if self.client_socket: self.client_socket.close() print("connection closed") diff --git a/test/test_message_event_store.py b/test/test_message_event_store.py new file mode 100644 index 0000000..0c25a94 --- /dev/null +++ b/test/test_message_event_store.py @@ -0,0 +1,211 @@ +"""Tests for the inbound message/event stores (messages_dict / events_dict). + +External code reads these dicts (or the JSON logs they flush into) instead +of registering message/file listeners. The dicts are keyed by the sender's +socket; each value is a list of [content, timestamp] pairs. When a store's +total size reaches max_dict_size (64 KiB) it is flushed to its JSON log and +cleared. +""" + +import json +import os +import threading + +import pytest + +from test_util import server_ready, wait_until + +from PyFlow.network_api.connect_tcp import TCP_Client_Base, TCP_Server_Base + +_PORT_COUNTER = 65510 + + +def _next_port(): + global _PORT_COUNTER + _PORT_COUNTER += 1 + return _PORT_COUNTER + + +@pytest.fixture +def pair(tmp_path): + port = _next_port() + server = TCP_Server_Base( + host="127.0.0.1", + port=port, + is_extend_command=True, + is_input_command_in_console=False, + is_enable_encrypto=False, + ) + server.messages_log_file = str(tmp_path / "server_messages_log.json") + server.events_log_file = str(tmp_path / "server_events_log.json") + threading.Thread(target=server.start_TCP_Server, daemon=True).start() + assert server_ready(server), "server did not start" + client = TCP_Client_Base( + host="127.0.0.1", + port=port, + client_host="127.0.0.1", + is_extend_command=True, + is_input_command_in_console=False, + is_enable_encrypto=False, + ) + client.messages_log_file = str(tmp_path / "client_messages_log.json") + client.events_log_file = str(tmp_path / "client_events_log.json") + assert client.connect() + yield server, client + client.close() + server.stop() + + +def _server_sock(server, client): + """The server-side socket for a connected client.""" + return server.clients[client.client_socket.getsockname()]["socket"] + + +def _client_addr_key(client): + """The JSON-log key for the server's store (the client's address).""" + ip, port = client.client_socket.getsockname() + return f"{ip}:{port}" + + +def test_server_records_client_message(pair): + server, client = pair + client.send_message(client.client_socket, "hello from client") + assert wait_until( + lambda: any( + e[0] == "hello from client" + for e in server.messages_dict.get(_server_sock(server, client), []) + ) + ), "message not recorded" + entries = server.messages_dict[_server_sock(server, client)] + assert any(e[0] == "hello from client" for e in entries) + assert all(e[1] for e in entries) # every entry carries a timestamp + + +def test_server_records_client_event(pair): + server, client = pair + client.send_message(client.client_socket, "/time") + assert wait_until( + lambda: any( + e[0] == "/time" for e in server.events_dict.get(_server_sock(server, client), []) + ) + ), "event not recorded" + entries = server.events_dict[_server_sock(server, client)] + assert any(e[0] == "/time" for e in entries) + assert all(e[1] for e in entries) + + +def test_client_records_server_message(pair): + server, client = pair + server.send_message(_server_sock(server, client), "hello from server") + assert wait_until( + lambda: any( + e[0] == "hello from server" + for e in client.messages_dict.get(client.client_socket, []) + ) + ), "message not recorded" + entries = client.messages_dict[client.client_socket] + assert any(e[0] == "hello from server" for e in entries) + + +def test_client_records_server_event(pair): + server, client = pair + server.send_message(_server_sock(server, client), "/custom_cmd arg1") + assert wait_until( + lambda: any( + e[0] == "/custom_cmd arg1" + for e in client.events_dict.get(client.client_socket, []) + ) + ), "event not recorded" + entries = client.events_dict[client.client_socket] + assert any(e[0] == "/custom_cmd arg1" for e in entries) + + +def test_server_records_file_transfer_event(pair, tmp_path): + """A /file command over the wire is recorded as an event with the + transfer's completion timestamp.""" + server, client = pair + server_recv = tmp_path / "server_recv" + server_recv.mkdir() + server.file_transfer_dir = str(server_recv) + src = tmp_path / "upload.bin" + payload = os.urandom(8192) + src.write_bytes(payload) + + client.file_transfer_client_recv_client_start_thread(f"/file {src}", None) + assert wait_until(lambda: any(server_recv.iterdir()), timeout=10), "file not received" + assert list(server_recv.iterdir())[0].read_bytes() == payload + + server_sock = _server_sock(server, client) + assert wait_until( + lambda: any(e[0].startswith("/file ") for e in server.events_dict.get(server_sock, [])) + ), f"no /file event recorded: {server.events_dict.get(server_sock)}" + file_events = [e for e in server.events_dict[server_sock] if e[0].startswith("/file ")] + assert file_events[-1][1] # completion timestamp present + + +def test_update_event_timestamp_refreshes_entry(pair): + """The completion-time refresh replaces the receipt timestamp of the + matching event entry.""" + server, client = pair + server_sock = _server_sock(server, client) + server._record_event(server_sock, "/file a.txt 0") + server._update_event_timestamp(server_sock, "/file a.txt 0", "2026-01-01 00:00:00") + assert server.events_dict[server_sock][-1] == ["/file a.txt 0", "2026-01-01 00:00:00"] + + +def test_flush_writes_json_and_clears_dict(pair, tmp_path): + """When a store reaches max_dict_size it is written to its JSON log and + cleared; the log accumulates across flushes.""" + server, client = pair + server.max_dict_size = 200 + key = _client_addr_key(client) + for i in range(30): + client.send_message(client.client_socket, f"msg {i} " + "x" * 40) + + def all_flushed(): + if not os.path.exists(server.messages_log_file): + return False + with open(server.messages_log_file, "r", encoding="utf-8") as f: + data = json.load(f) + in_file = len(data.get(key, [])) + in_dict = len(server.messages_dict.get(_server_sock(server, client), [])) + return in_file + in_dict >= 30 + + assert wait_until(all_flushed, timeout=10), "messages were not flushed to the JSON log" + with open(server.messages_log_file, "r", encoding="utf-8") as f: + data = json.load(f) + in_dict = len(server.messages_dict.get(_server_sock(server, client), [])) + assert len(data[key]) + in_dict == 30 + assert all(len(e) == 2 and e[1] for e in data[key]) + assert server._messages_dict_size < server.max_dict_size + + +def test_flush_on_close_persists_remaining(pair, tmp_path): + """close()/stop() flush whatever is still buffered so nothing is lost.""" + server, client = pair + client.send_message(client.client_socket, "persist me") + assert wait_until( + lambda: any( + e[0] == "persist me" + for e in server.messages_dict.get(_server_sock(server, client), []) + ) + ), "message not recorded" + server.stop() + with open(server.messages_log_file, "r", encoding="utf-8") as f: + data = json.load(f) + key = _client_addr_key(client) + assert any(e[0] == "persist me" for e in data.get(key, [])) + + +def test_splice_event_command(pair): + """The wire command is kept verbatim; a missing command is spliced from + the available parts (forwarded transfers).""" + server, client = pair + assert server._splice_event_command("/file a.txt 1") == "/file a.txt 1" + assert server._splice_event_command("", fname="a.txt") == "/file a.txt" + assert ( + server._splice_event_command("", kind="folder", rel_dir="d", fname="a.txt") + == "/file_folder d a.txt" + ) + assert server._splice_event_command("", kind="folder", fname="a.txt") == "/file_folder a.txt" + assert server._splice_event_command("") == "/unknown" From 886e1257c51320a6f6c57aad64714a1dc65421db Mon Sep 17 00:00:00 2001 From: F18-Ray Date: Tue, 8 Sep 2026 16:08:48 +0800 Subject: [PATCH 05/11] tests: fix the merge json log function in connect_tcp --- PyFlow/network_api/connect_tcp.py | 32 +++++++++++++++++++++++++++---- test/test_message_event_store.py | 9 ++++++--- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/PyFlow/network_api/connect_tcp.py b/PyFlow/network_api/connect_tcp.py index df5a115..6f75879 100644 --- a/PyFlow/network_api/connect_tcp.py +++ b/PyFlow/network_api/connect_tcp.py @@ -542,7 +542,11 @@ def _flush_events_dict(self): ) def _merge_json_log(self, path, snapshot): - """Merge ``snapshot`` into the JSON log at ``path`` (append per socket).""" + """Merge ``snapshot`` into the JSON log at ``path`` (append per socket). + + The file is replaced atomically (temp file + os.replace) so a + concurrent reader never sees a truncated/partial log. + """ try: if os.path.exists(path): with open(path, "r", encoding="utf-8") as f: @@ -552,8 +556,16 @@ def _merge_json_log(self, path, snapshot): for sock, entries in snapshot.items(): key = self._socket_key(sock) existing.setdefault(key, []).extend(entries) - with open(path, "w", encoding="utf-8") as f: + tmp_path = path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: json.dump(existing, f, ensure_ascii=False, indent=2) + for _ in range(5): # Windows: the log may be briefly open for reading + try: + os.replace(tmp_path, path) + return + except PermissionError: + time.sleep(0.05) + os.replace(tmp_path, path) except Exception: traceback.print_exc() @@ -2851,7 +2863,11 @@ def _flush_events_dict(self): ) def _merge_json_log(self, path, snapshot): - """Merge ``snapshot`` into the JSON log at ``path`` (append per socket).""" + """Merge ``snapshot`` into the JSON log at ``path`` (append per socket). + + The file is replaced atomically (temp file + os.replace) so a + concurrent reader never sees a truncated/partial log. + """ try: if os.path.exists(path): with open(path, "r", encoding="utf-8") as f: @@ -2861,8 +2877,16 @@ def _merge_json_log(self, path, snapshot): for sock, entries in snapshot.items(): key = self._socket_key(sock) existing.setdefault(key, []).extend(entries) - with open(path, "w", encoding="utf-8") as f: + tmp_path = path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: json.dump(existing, f, ensure_ascii=False, indent=2) + for _ in range(5): # Windows: the log may be briefly open for reading + try: + os.replace(tmp_path, path) + return + except PermissionError: + time.sleep(0.05) + os.replace(tmp_path, path) except Exception: traceback.print_exc() diff --git a/test/test_message_event_store.py b/test/test_message_event_store.py index 0c25a94..85839f3 100644 --- a/test/test_message_event_store.py +++ b/test/test_message_event_store.py @@ -131,7 +131,7 @@ def test_server_records_file_transfer_event(pair, tmp_path): payload = os.urandom(8192) src.write_bytes(payload) - client.file_transfer_client_recv_client_start_thread(f"/file {src}", None) + client.file_transfer_client_recv_client_start_thread(f'/file "{src}"', None) assert wait_until(lambda: any(server_recv.iterdir()), timeout=10), "file not received" assert list(server_recv.iterdir())[0].read_bytes() == payload @@ -165,8 +165,11 @@ def test_flush_writes_json_and_clears_dict(pair, tmp_path): def all_flushed(): if not os.path.exists(server.messages_log_file): return False - with open(server.messages_log_file, "r", encoding="utf-8") as f: - data = json.load(f) + try: + with open(server.messages_log_file, "r", encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return False # log mid-replace: retry in_file = len(data.get(key, [])) in_dict = len(server.messages_dict.get(_server_sock(server, client), [])) return in_file + in_dict >= 30 From fe3fb0585931d28bb5e73c82e4cf3b23688466ba Mon Sep 17 00:00:00 2001 From: F18-Ray Date: Tue, 8 Sep 2026 20:03:04 +0800 Subject: [PATCH 06/11] core-code: add the self-defining destination path input function in file or folder transfer windows --- PyFlow/transfer_web/static/common.js | 4 ++ .../web_backend/server_backend.py | 28 ++++++--- .../transfer_web/web_front/client_backend.py | 62 ++++++++++++------- 3 files changed, 62 insertions(+), 32 deletions(-) diff --git a/PyFlow/transfer_web/static/common.js b/PyFlow/transfer_web/static/common.js index 92e1f28..9b86ca1 100644 --- a/PyFlow/transfer_web/static/common.js +++ b/PyFlow/transfer_web/static/common.js @@ -266,6 +266,8 @@ '" + '
' + + '
' + + '
' + '
' + '
' ); @@ -309,6 +311,8 @@ if (!target) return; const fd = new FormData(); fd.append("target", JSON.stringify(target)); + const dest = backdrop.querySelector("#dest-input").value.trim(); + if (dest) fd.append("destination", dest); files.forEach((f) => { fd.append("files", f, folderMode ? f.webkitRelativePath || f.name : f.name); }); diff --git a/PyFlow/transfer_web/web_backend/server_backend.py b/PyFlow/transfer_web/web_backend/server_backend.py index 5b745fb..7c95dbd 100644 --- a/PyFlow/transfer_web/web_backend/server_backend.py +++ b/PyFlow/transfer_web/web_backend/server_backend.py @@ -320,19 +320,21 @@ def _run_extension(self, handler, command): except Exception: traceback.print_exc() - def _send_file_to_client(self, target, path): + def _send_file_to_client(self, target, path, destination=None): try: - self.server.file_transfer_server_recv_client_start( - f"/file {shlex.quote(path)} {shlex.quote(str(target))}", None - ) + message = f"/file {shlex.quote(path)} {shlex.quote(str(target))}" + if destination: + message += f" {shlex.quote(destination)}" + self.server.file_transfer_server_recv_client_start(message, None) except Exception: traceback.print_exc() - def _send_folder_to_client(self, target, path): + def _send_folder_to_client(self, target, path, destination=None): try: - self.server.folder_file_transfer_server_recv_client_start( - f"/file_folder {shlex.quote(path)} {shlex.quote(str(target))}" - ) + message = f"/file_folder {shlex.quote(path)} {shlex.quote(str(target))}" + if destination: + message += f" {shlex.quote(destination)}" + self.server.folder_file_transfer_server_recv_client_start(message) except Exception: traceback.print_exc() @@ -437,6 +439,7 @@ def api_send_file(): info = self._target_info(target) if info is None: return jsonify({"ok": False, "error": "target client is not connected"}), 404 + destination = request.form.get("destination") or None os.makedirs(UPLOAD_DIR, exist_ok=True) saved = [] for f in files: @@ -445,7 +448,9 @@ def api_send_file(): saved.append(path) for path in saved: threading.Thread( - target=self._send_file_to_client, args=(tuple(target), path), daemon=True + target=self._send_file_to_client, + args=(tuple(target), path, destination), + daemon=True, ).start() return jsonify({"ok": True, "paths": saved}) @@ -463,6 +468,7 @@ def api_send_folder(): info = self._target_info(target) if info is None: return jsonify({"ok": False, "error": "target client is not connected"}), 404 + destination = request.form.get("destination") or None os.makedirs(UPLOAD_DIR, exist_ok=True) root = None for f in files: @@ -475,7 +481,9 @@ def api_send_folder(): if root is None or not os.path.isdir(root): return jsonify({"ok": False, "error": "folder upload failed"}), 500 threading.Thread( - target=self._send_folder_to_client, args=(tuple(target), root), daemon=True + target=self._send_folder_to_client, + args=(tuple(target), root, destination), + daemon=True, ).start() return jsonify({"ok": True, "path": root}) diff --git a/PyFlow/transfer_web/web_front/client_backend.py b/PyFlow/transfer_web/web_front/client_backend.py index e7321f8..a3066f3 100644 --- a/PyFlow/transfer_web/web_front/client_backend.py +++ b/PyFlow/transfer_web/web_front/client_backend.py @@ -138,33 +138,39 @@ def _run_client_command(self, handler, command): except Exception: traceback.print_exc() - def _send_file_to_server(self, path): + def _send_file_to_server(self, path, destination=None): try: - self.client.file_transfer_client_recv_client_start(f"/file {shlex.quote(path)}", None) + message = f"/file {shlex.quote(path)}" + if destination: + message += f" {shlex.quote(destination)}" + self.client.file_transfer_client_recv_client_start(message, None) except Exception: traceback.print_exc() - def _send_folder_to_server(self, path): + def _send_folder_to_server(self, path, destination=None): try: - self.client.folder_file_transfer_client_recv_client_start( - f"/file_folder {shlex.quote(path)}" - ) + message = f"/file_folder {shlex.quote(path)}" + if destination: + message += f" {shlex.quote(destination)}" + self.client.folder_file_transfer_client_recv_client_start(message) except Exception: traceback.print_exc() - def _forward_file(self, path, addr): + def _forward_file(self, path, addr, destination=None): try: - self.client.forward_file_console( - f"/forward_file {shlex.quote(path)} {shlex.quote(str(addr))}" - ) + message = f"/forward_file {shlex.quote(path)} {shlex.quote(str(addr))}" + if destination: + message += f" {shlex.quote(destination)}" + self.client.forward_file_console(message) except Exception: traceback.print_exc() - def _forward_folder(self, path, addr): + def _forward_folder(self, path, addr, destination=None): try: - self.client.forward_folder_console( - f"/forward_folder {shlex.quote(path)} {shlex.quote(str(addr))}" - ) + message = f"/forward_folder {shlex.quote(path)} {shlex.quote(str(addr))}" + if destination: + message += f" {shlex.quote(destination)}" + self.client.forward_folder_console(message) except Exception: traceback.print_exc() @@ -384,10 +390,14 @@ def api_send_msg(): def api_send_file(): if not self.connected or self.client is None: return jsonify({"ok": False, "error": "not connected"}), 400 - target = request.form.get("target") + try: + target = json.loads(request.form.get("target")) + except Exception: + return jsonify({"ok": False, "error": "invalid target"}), 400 files = request.files.getlist("files") if not files: return jsonify({"ok": False, "error": "no files uploaded"}), 400 + destination = request.form.get("destination") or None os.makedirs(UPLOAD_DIR, exist_ok=True) saved = [] for f in files: @@ -397,22 +407,28 @@ def api_send_file(): if target == "server": for path in saved: threading.Thread( - target=self._send_file_to_server, args=(path,), daemon=True + target=self._send_file_to_server, args=(path, destination), daemon=True ).start() else: - addr = tuple(json.loads(target)) + addr = tuple(target) for path in saved: - threading.Thread(target=self._forward_file, args=(path, addr), daemon=True).start() + threading.Thread( + target=self._forward_file, args=(path, addr, destination), daemon=True + ).start() return jsonify({"ok": True}) @app.post("/api/send_folder") def api_send_folder(): if not self.connected or self.client is None: return jsonify({"ok": False, "error": "not connected"}), 400 - target = request.form.get("target") + try: + target = json.loads(request.form.get("target")) + except Exception: + return jsonify({"ok": False, "error": "invalid target"}), 400 files = request.files.getlist("files") if not files: return jsonify({"ok": False, "error": "no files uploaded"}), 400 + destination = request.form.get("destination") or None os.makedirs(UPLOAD_DIR, exist_ok=True) root = None for f in files: @@ -426,11 +442,13 @@ def api_send_folder(): return jsonify({"ok": False, "error": "folder upload failed"}), 500 if target == "server": threading.Thread( - target=self._send_folder_to_server, args=(root,), daemon=True + target=self._send_folder_to_server, args=(root, destination), daemon=True ).start() else: - addr = tuple(json.loads(target)) - threading.Thread(target=self._forward_folder, args=(root, addr), daemon=True).start() + addr = tuple(target) + threading.Thread( + target=self._forward_folder, args=(root, addr, destination), daemon=True + ).start() return jsonify({"ok": True}) @app.post("/api/run_extension") From bd8bd8532c4aa99761036ff39e3d01a9d615d6b6 Mon Sep 17 00:00:00 2001 From: F18-Ray Date: Wed, 9 Sep 2026 19:10:09 +0800 Subject: [PATCH 07/11] core-code: add the internal forward message functions --- PyFlow/forward_extension_tcp.py | 95 ++----- PyFlow/network_api/connect_tcp.py | 176 ++++++++++++- PyFlow/transfer_web/static/common.js | 70 ++++-- .../transfer_web/web_front/client_backend.py | 28 ++- docs/Instance_Setup/Instance_Setup.rst | 15 +- test/test_forward_extension.py | 103 +++++--- test/test_forward_msg_attribution.py | 234 ++++++++++++++++++ 7 files changed, 574 insertions(+), 147 deletions(-) create mode 100644 test/test_forward_msg_attribution.py diff --git a/PyFlow/forward_extension_tcp.py b/PyFlow/forward_extension_tcp.py index 43873b5..0394d91 100644 --- a/PyFlow/forward_extension_tcp.py +++ b/PyFlow/forward_extension_tcp.py @@ -1,12 +1,18 @@ """Forward extension for the TCP protocol. -Lets a client forward the data it would normally send to the server -(strings, files, multiple files, folders, multiple folders) to a list of -destination clients instead. Every transfer family of the main TCP -protocol gets a matching ``/xxx_forward`` command: +Disk-based, upload-then-push forwarding of files and folders to a list of +destination clients. This is deliberately a second implementation of file +forwarding: the native TCP protocol already streams files and folders in +memory (``/forward_file`` / ``/forward_folder`` on a client console, +relayed by the server as ``/forward_item`` with no disk I/O on the +server), while this extension uploads the data to the server's transfer +directory first and then asks the server to push the stored copies. +Plain-message forwarding is native as well (the client-only command +``/forward_send_msg``, relayed by the server), so no string forwarding +lives here. + +Transfer families added by this extension: - /send_msg_forward ... <(ip, port)> ... - forward the messages to every listed destination /file_forward <(ip, port)> ... forward one file to every listed destination /multiple_file_forward ... <(ip, port)> ... @@ -32,59 +38,34 @@ served. """ -import ast import functools import os import shlex import threading from .network_api import connect_tcp +from .network_api.connect_tcp import ( + forward_skip_message as _server_skip_message, + parse_forward_items_and_addrs as _parse_items_and_addrs, +) server_instance = None client_instance = None _FORWARD_COMMANDS = ( - "/send_msg_forward", "/file_forward", "/multiple_file_forward", "/folder_forward", "/multiple_folder_forward", ) -_ADDRESS_LEN = 2 # (host, port) tuple shape - # client command kind -> the relay command the server receives _RELAY_FOR_KIND = { - "send_msg": "/forward_send_msg", "file": "/forward_file", "folder": "/forward_folder", } -def _parse_items_and_addrs(tokens): - """Split command tokens into (items, addresses). - - A token of the form ``('ip', port)`` is a destination; anything else - is a forwarded item (message text or a path). - """ - items = [] - addrs = [] - for token in tokens: - if token.startswith("(") and token.endswith(")"): - try: - addr = ast.literal_eval(token) - except (ValueError, SyntaxError): - items.append(token) - continue - if isinstance(addr, tuple) and len(addr) == _ADDRESS_LEN and isinstance(addr[0], str): - addrs.append(addr) - else: - items.append(token) - else: - items.append(token) - return items, addrs - - def _server_send(message): """Send one protocol request to the server through the client socket.""" if client_instance is None or client_instance.client_socket is None: @@ -94,7 +75,7 @@ def _server_send(message): def _forward_request(command, names, addrs): - """Ask the server to push ``names`` (messages or stored paths) to ``addrs``.""" + """Ask the server to push ``names`` (stored paths) to ``addrs``.""" request = command + " " + " ".join(shlex.quote(n) for n in names) request += " " + " ".join(shlex.quote(str(a)) for a in addrs) return _server_send(request) @@ -153,8 +134,8 @@ def _upload_folders_sync(paths): def _client_forward_handler(kind, allow_multiple, sock, addr, cmd): """Console entry point for the /xxx_forward commands (client only). - The command name never matters here: ``kind`` ("send_msg", "file" or - "folder") and ``allow_multiple`` are bound at registration time with + The command name never matters here: ``kind`` ("file" or "folder") and + ``allow_multiple`` are bound at registration time with functools.partial. Registered with where_to_run="client", so it only fires from console input (interactive_mode), never from messages sent by other instances. @@ -164,16 +145,14 @@ def _client_forward_handler(kind, allow_multiple, sock, addr, cmd): if not items or not addrs: print( f"{kind}: need at least one item and one destination, " - 'e.g. /send_msg_forward "msg" "(\'127.0.0.1\', 3000)"' + 'e.g. /file_forward "file.txt" "(\'127.0.0.1\', 3000)"' ) return None if not allow_multiple and len(items) != 1: print(f"{kind}: expects exactly one item; use the multiple variant") return None relay = _RELAY_FOR_KIND[kind] - if kind == "send_msg": - _forward_request(relay, items, addrs) - elif kind == "file": + if kind == "file": _upload_files_sync(items) _forward_request(relay, [os.path.basename(p) for p in items], addrs) elif kind == "folder": @@ -182,28 +161,6 @@ def _client_forward_handler(kind, allow_multiple, sock, addr, cmd): return None -def _server_skip_message(target): - return f"forward: destination {target} is unreachable or is the server, skipped" - - -def _forward_send_msg_handler(sock, addr, cmd): - """Server-side relay: push the messages to every reachable destination.""" - if server_instance is None: - print("forward: server instance is not set up") - return None - parts = shlex.split(cmd) - items, addrs = _parse_items_and_addrs(parts[1:]) - for target in addrs: - client_info = server_instance.clients.get(target) - if client_info is None: - print(_server_skip_message(target)) - continue - target_socket = client_info["socket"] - for msg in items: - server_instance.send_message(target_socket, msg) - return None - - def _forward_files_handler(sock, addr, cmd): """Server-side relay: push server-side files to every reachable destination.""" if server_instance is None: @@ -251,8 +208,9 @@ def _forward_folders_handler(sock, addr, cmd): def setup_client_commands(client): # noqa: PLW0603 - """Register the forward commands on a client instance (console use). + """Register the file/folder forward commands on a client instance. + Message forwarding (``/forward_send_msg``) is native and needs no setup. Each command binds its transfer kind and single/multiple policy into the shared handler via functools.partial; where_to_run="client" makes them fire from console input only. @@ -260,7 +218,6 @@ def setup_client_commands(client): # noqa: PLW0603 global client_instance # noqa: PLW0603 client_instance = client command_specs = [ - ("/send_msg_forward", "send_msg", True), ("/file_forward", "file", False), ("/multiple_file_forward", "file", True), ("/folder_forward", "folder", False), @@ -276,8 +233,9 @@ def setup_client_commands(client): # noqa: PLW0603 def setup_server_commands(server): # noqa: PLW0603 - """Register the internal forward relays on a server instance. + """Register the file/folder forward relays on a server instance. + The message relay (``/forward_send_msg``) is native and needs no setup. These handlers are triggered by relay requests sent by clients, i.e. they live in the "server" group: messages coming in from other instances are dispatched there. The /xxx_forward commands themselves @@ -286,9 +244,6 @@ def setup_server_commands(server): # noqa: PLW0603 """ global server_instance # noqa: PLW0603 server_instance = server - server.register_command( - "/forward_send_msg", _forward_send_msg_handler, where_to_run="server", run_in_thread=True - ) server.register_command( "/forward_file", _forward_files_handler, where_to_run="server", run_in_thread=True ) diff --git a/PyFlow/network_api/connect_tcp.py b/PyFlow/network_api/connect_tcp.py index 6f75879..68be0b6 100644 --- a/PyFlow/network_api/connect_tcp.py +++ b/PyFlow/network_api/connect_tcp.py @@ -72,6 +72,62 @@ def _parse_destination_path(command_part): return None +def parse_forwarded_message(command): + """Split a ``/send_msg_from `` relay envelope. + + The forward extension's server relay wraps every forwarded message + with the sender's address so the receiving client can attribute it + to the sending instance (the web tool shows it in the sender's + conversation). Returns ``(sender_id, payload)`` where ``sender_id`` + is the sender's ``"ip:port"``, or ``None`` when the command is not a + well-formed envelope. + """ + try: + parts = shlex.split(command) + except ValueError: + return None + if len(parts) < 3 or parts[0].lower() != "/send_msg_from": + return None + try: + sender = ast.literal_eval(parts[1]) + except (ValueError, SyntaxError): + return None + if not (isinstance(sender, tuple) and len(sender) == 2 and isinstance(sender[0], str)): + return None + return f"{sender[0]}:{sender[1]}", " ".join(parts[2:]) + + +def parse_forward_items_and_addrs(tokens): + """Split forward-command tokens into (items, destination addresses). + + A token of the form ``('ip', port)`` is a destination; anything else is + a forwarded item (message text or a path). Shared by the native + message forwarding (``/forward_send_msg``) and the file/folder forward + extension. + """ + items = [] + addrs = [] + for token in tokens: + if token.startswith("(") and token.endswith(")"): + try: + addr = ast.literal_eval(token) + except (ValueError, SyntaxError): + items.append(token) + continue + if isinstance(addr, tuple) and len(addr) == 2 and isinstance(addr[0], str): + addrs.append(addr) + else: + items.append(token) + else: + items.append(token) + return items, addrs + + +def forward_skip_message(target): + """Console notice for a forward destination that cannot be served.""" + return f"forward: destination {target} is unreachable or is the server, skipped" + + class TCP_Server_Base: # TCP server class def __init__( self, @@ -1222,6 +1278,16 @@ def handle_command( daemon=True, ).start() return None + elif shlex.split(command.lower())[0] == "/forward_send_msg": + # Internal relay request typed on a client console (client-only + # command, same name on the wire): push the messages to the + # listed destinations. + threading.Thread( + target=self._handle_forward_send_msg, + args=(client_socket, client_address, command), + daemon=True, + ).start() + return "Command received, processing in background.\n" elif shlex.split(command.lower())[0] == "/pause_trans": self._forward_pause_target(client_address, command) return None @@ -1332,6 +1398,38 @@ def handle_command( else: print(f"Unknown command: {command}") + def _handle_forward_send_msg(self, sock, addr, cmd): + """Relay plain messages to every reachable destination client. + + Server side of the client-only ``/forward_send_msg`` command (typed on + a client console and relayed here over the wire). Every message is + wrapped in a ``/send_msg_from `` envelope carrying the + originator's address so receivers can attribute it. The messages are + recorded under the originator's socket (``sock``), exactly as if the + originator had sent them to the server. Destinations that are + unreachable -- or the server itself, which is never in the client + table -- are skipped and the rest are still served. + """ + parts = shlex.split(cmd) + items, addrs = parse_forward_items_and_addrs(parts[1:]) + reached = False + for target in addrs: + client_info = self.clients.get(target) + if client_info is None: + print(forward_skip_message(target)) + continue + reached = True + target_socket = client_info["socket"] + for msg in items: + self.send_message( + target_socket, + f"/send_msg_from {shlex.quote(repr(addr))} {shlex.quote(msg)}", + ) + if reached: + for msg in items: + self._record_message(sock, msg) + return None + def _execute_custom_handler(self, handler, command, client_socket=None, client_address=None): try: result = handler(client_socket, client_address, command) @@ -2467,7 +2565,11 @@ def console_input(self): # deal consule input self.diff_multiple_file_diff_multiple_client_transfer_server_recv_client_start( deal_cmd ) - elif shlex.split(deal_cmd)[0].lower() in ("/forward_file", "/forward_folder"): + elif shlex.split(deal_cmd)[0].lower() in ( + "/forward_send_msg", + "/forward_file", + "/forward_folder", + ): print( "forward commands are client-only; " "run them on a client console, not on the server" @@ -2704,12 +2806,14 @@ def register_command(self, command_name, handler, where_to_run, run_in_thread=Fa self._custom_handler_threaded[registe_index][command_name] = run_in_thread def add_message_listener(self, listener): - """Register ``listener(message: str)`` for every inbound plain-text message. - - Plain messages are the chat/data lines received from the server that - do not start with ``/`` (direct sends from the server, messages - forwarded from other clients, protocol replies). Commands are not - reported here; they go through the registered command handlers. + """Register ``listener(sender_id, message)`` for every inbound message. + + ``sender_id`` is the author's ``"ip:port"``: the forwarding client for + messages another client forwarded to this one (``/send_msg_from`` + envelopes), or ``None`` for direct pushes from the server, which do + not identify a client author. Commands are not reported here; they go + through the registered command handlers. Mirrors the server-side + contract (``listener(client_id, message)``). """ with self._event_listeners_lock: if listener not in self._message_listeners: @@ -2743,12 +2847,12 @@ def remove_file_listener(self, listener): except ValueError: pass - def _notify_message_received(self, message): + def _notify_message_received(self, sender, message): with self._event_listeners_lock: listeners = list(self._message_listeners) for listener in listeners: try: - listener(message) + listener(sender, message) except Exception: traceback.print_exc() @@ -3227,13 +3331,26 @@ def receive_messages(self): # get server msg ok, plain = self._crypto_process_line(self.client_socket, message) if ok: message = plain.strip() + sender = None # the author's "ip:port"; None for direct server pushes if message.startswith("/"): - self._record_event(self.client_socket, message) - self.handle_server_command(message) + # A /send_msg_from envelope is a message that another + # client forwarded to us: its records belong to the + # originator, whose socket exists only on the server, so + # the originator's address is the store key here. The + # payload then flows through the single plain-message + # path below (notify + store + print). + unwrapped = parse_forwarded_message(message) + if unwrapped is not None: + raw = message + sender, message = unwrapped + self._record_event(sender, raw) + else: + self._record_event(self.client_socket, message) + self.handle_server_command(message) if message: if not message.startswith("/"): - self._notify_message_received(message) - self._record_message(self.client_socket, message) + self._notify_message_received(sender, message) + self._record_message(sender or self.client_socket, message) print(f"\n[server] {message}") except socket.timeout: continue @@ -3787,6 +3904,36 @@ def handle_server_command(self, command): # deal with special command from serv else: print(f"Unknown server command: {command}") + def _console_forward_send_msg(self, command): + """Client console entry point for ``/forward_send_msg`` (client-only). + + The forwarding command is only meaningful on a client: it parses the + typed request and asks the server (which runs the same-named relay) + to push each message to every listed destination. + """ + parts = shlex.split(command) + items, addrs = parse_forward_items_and_addrs(parts[1:]) + if not items or not addrs: + print( + "forward_send_msg: need at least one message and one destination, " + 'e.g. /forward_send_msg "msg" "(\'127.0.0.1\', 3000)"' + ) + return None + return self.forward_messages(items, addrs) + + def forward_messages(self, messages, addrs): + """Forward plain messages to other connected clients through the server. + + Internal protocol feature (the client console command + ``/forward_send_msg``): this client must be connected. Each message is + sent to every destination over the server, wrapped there in a + ``/send_msg_from`` envelope so the receiver can attribute it back to + this client. ``addrs`` is a list of ``(ip, port)`` tuples. + """ + request = "/forward_send_msg " + " ".join(shlex.quote(m) for m in messages) + request += " " + " ".join(shlex.quote(str(a)) for a in addrs) + return self.send_message(self.client_socket, request) + def _execute_custom_handler(self, handler, command, client_socket=None, client_address=None): try: result = handler(client_socket, client_address, command) @@ -3835,6 +3982,9 @@ def interactive_mode(self): # Interactive mode self.forward_file_console(message) elif shlex.split(message.lower())[0] == "/forward_folder": self.forward_folder_console(message) + elif shlex.split(message.lower())[0] == "/forward_send_msg": + # client-only message forwarding: relayed by the server + self._console_forward_send_msg(message) else: cmd_name = message[0].lower() if cmd_name in self._custom_handlers[1]: diff --git a/PyFlow/transfer_web/static/common.js b/PyFlow/transfer_web/static/common.js index 9b86ca1..64817cd 100644 --- a/PyFlow/transfer_web/static/common.js +++ b/PyFlow/transfer_web/static/common.js @@ -14,7 +14,7 @@ connected: false, target: null, // "server" | [ip, port] extensions: [], // [{name, icon, command}] - messages: [], // [{dir: "out"|"sys", text}] + conversations: {}, // targetKey -> [{dir: "in"|"out"|"sys", text}] eventId: 0, // last inbound event id consumed from /api/events }; @@ -55,6 +55,12 @@ return target[0] + ":" + target[1]; } + function targetKey(target) { + if (target === "server") return "server"; + if (typeof target === "string") return target; // already an "ip:port" (event sender) + return target[0] + ":" + target[1]; + } + function fmtSize(n) { if (n == null || isNaN(n)) return "?"; const units = ["B", "KB", "MB", "GB", "TB"]; @@ -124,21 +130,19 @@ const title = $("target-title"); if (MODE === "server" && target === "server") { title.textContent = "This is the server"; - $("empty-hint").style.display = "block"; - $("empty-hint").textContent = - "This is the server. Select a connected client on the left to send data."; $("input").disabled = true; $("send-btn").disabled = true; $("icon-bar").style.opacity = "0.4"; $("icon-bar").style.pointerEvents = "none"; + renderConversation(); return; } title.textContent = "Sending to " + targetLabel(target); - $("empty-hint").style.display = "none"; $("input").disabled = false; $("send-btn").disabled = false; $("icon-bar").style.opacity = "1"; $("icon-bar").style.pointerEvents = "auto"; + renderConversation(); $("input").focus(); } @@ -175,24 +179,21 @@ } catch (e) { return; // offline or a backend without the endpoint } - let rendered = 0; (data.events || []).forEach((ev) => { if (ev.id) state.eventId = Math.max(state.eventId, ev.id); const from = ev.from ? ev.from + ": " : ""; + // Events come from a specific peer (client-to-client forwards carry the + // sender's address) or, when the sender is unknown (direct pushes by the + // server), from the server itself. + const target = ev.from || "server"; if (ev.type === "msg") { - addMessage("in", from + ev.text); - rendered++; + addMessage("in", from + ev.text, target); } else if (ev.type === "file") { const size = fmtSize(ev.size); - addMessage("in", from + "file received: " + ev.name + " (" + size + ") -> " + ev.path); + addMessage("in", from + "file received: " + ev.name + " (" + size + ") -> " + ev.path, target); toast("File received: " + ev.name + " (" + size + ")", "ok"); - rendered++; } }); - if (rendered && MODE === "server") { - const hint = $("empty-hint"); - if (hint) hint.style.display = "none"; - } } /* ---------------- sending ---------------- */ @@ -229,12 +230,39 @@ } } - function addMessage(dir, text) { + function addMessage(dir, text, target) { + const key = targetKey(target || state.target); + if (!state.conversations[key]) state.conversations[key] = []; + state.conversations[key].push({ dir, text }); + if (key === targetKey(state.target)) renderConversation(); + } + + function renderConversation() { const area = $("chat-area"); - const el = document.createElement("div"); - el.className = "msg " + dir; - el.textContent = text; - area.appendChild(el); + const hint = $("empty-hint"); + const key = targetKey(state.target); + const msgs = state.conversations[key] || []; + area.querySelectorAll(".msg").forEach((el) => el.remove()); + if (!msgs.length) { + hint.style.display = "block"; + if (MODE === "server" && state.target === "server") { + hint.textContent = + "This is the server. Select a connected client on the left to send data."; + } else if (key === "server") { + hint.textContent = + "Connected. Select the server or a client on the left to send messages, files or folders."; + } else { + hint.textContent = "No messages with this target yet."; + } + } else { + hint.style.display = "none"; + } + msgs.forEach((m) => { + const el = document.createElement("div"); + el.className = "msg " + m.dir; + el.textContent = m.text; + area.appendChild(el); + }); area.scrollTop = area.scrollHeight; } @@ -318,6 +346,10 @@ }); try { await api("/api/" + (folderMode ? "send_folder" : "send_file"), { method: "POST", body: fd }); + const label = folderMode + ? (files[0].webkitRelativePath || files[0].name).split("/")[0] + : files.map((f) => f.name).join(", "); + addMessage("out", (folderMode ? "folder sent: " : "file sent: ") + label, target); toast("Transfer started", "ok"); closeModal(backdrop); } catch (e) { diff --git a/PyFlow/transfer_web/web_front/client_backend.py b/PyFlow/transfer_web/web_front/client_backend.py index a3066f3..02ab0af 100644 --- a/PyFlow/transfer_web/web_front/client_backend.py +++ b/PyFlow/transfer_web/web_front/client_backend.py @@ -8,9 +8,10 @@ stays up to relay the user's frontend actions: - messages/files/folders to the server use the native transfer methods; -- messages to other clients are forwarded through the built-in - ``forward_extension_tcp`` extension (string forwarding lives there); -- files/folders to other clients use the native forward methods. +- messages to other clients use the native ``/forward_send_msg`` forwarding + (a client-only command relayed by the server); +- files/folders to other clients are forwarded through the built-in + ``forward_extension_tcp`` extension. The sidebar instance list is kept fresh by the server's ``/web_clients_update`` broadcasts; a reload button re-requests the @@ -202,8 +203,14 @@ def _push_event(self, event): del self._events[: len(self._events) - 1000] return self._event_seq - def _on_incoming_message(self, text): - """Client receive thread: a plain-text message arrived from the server.""" + def _on_incoming_message(self, sender, text): + """Client receive thread: an inbound plain-text message. + + ``sender`` is the author's ``"ip:port"`` when another client forwarded + the message to us (the server relay envelope carries it), or ``None`` + for a direct push from the server. Direct pushes surface under the + server entry; forwarded ones under the sender's own conversation. + """ text = (text or "").strip() if not text: return @@ -218,7 +225,10 @@ def _on_incoming_message(self, text): expect, at = self._echo_expect, self._echo_expect_at if expect is not None and time.time() - at <= 3 and text == "msg send: " + expect: return - self._push_event({"type": "msg", "text": text, "at": time.strftime("%H:%M:%S")}) + event = {"type": "msg", "text": text, "at": time.strftime("%H:%M:%S")} + if sender: + event["from"] = sender + self._push_event(event) def _on_incoming_file(self, full_path, name, size, command): """Client receive thread: a file pushed by the server was saved.""" @@ -377,12 +387,8 @@ def api_send_msg(): self._echo_expect_at = time.time() return jsonify({"ok": True}) addr = (target[0], int(target[1])) - handler = self.client._custom_handlers[1].get("/send_msg_forward") - if handler is None: - return jsonify({"ok": False, "error": "forward extension is not loaded"}), 500 - command = f"/send_msg_forward {shlex.quote(message)} {shlex.quote(str(addr))}" threading.Thread( - target=self._run_client_command, args=(handler, command), daemon=True + target=self.client.forward_messages, args=([message], [addr]), daemon=True ).start() return jsonify({"ok": True}) diff --git a/docs/Instance_Setup/Instance_Setup.rst b/docs/Instance_Setup/Instance_Setup.rst index eee3c1f..40ea353 100644 --- a/docs/Instance_Setup/Instance_Setup.rst +++ b/docs/Instance_Setup/Instance_Setup.rst @@ -155,11 +155,16 @@ entry sets ``is_extend_command=True``: - ``command_control_extension_tcp.py`` – remote command execution with per-client log collection (``/command``). -- ``forward_extension_tcp.py`` – forwarding messages, -files, multiple files, folders and multiple folders to -any number of destination clients (``/send_msg_forward``, -``/file_forward``, ``/multiple_file_forward``, -``/folder_forward``, ``/multiple_folder_forward``). +- ``forward_extension_tcp.py`` – forwarding files, +multiple files, folders and multiple folders to any +number of destination clients (``/file_forward``, +``/multiple_file_forward``, ``/folder_forward``, +``/multiple_folder_forward``). + +Plain-message forwarding is native to the TCP protocol +(no extension needed): the client-only command +``/forward_send_msg`` relays messages to the listed +destination clients through the server. With ``is_extend_command=False`` (the default) only the raw TCP protocol is started. diff --git a/test/test_forward_extension.py b/test/test_forward_extension.py index fe67ccb..22c95cf 100644 --- a/test/test_forward_extension.py +++ b/test/test_forward_extension.py @@ -1,4 +1,10 @@ -"""Tests for the TCP forward extension (forward_extension_tcp.py).""" +"""Tests for the file/folder forward extension (forward_extension_tcp.py). + +Plain-message forwarding (the client-only command ``/forward_send_msg``) +is native to the TCP protocol and covered by +``test_forward_msg_attribution.py``; this file covers the extension's +file and folder transfers plus the internal message command's placement. +""" import os import socket @@ -42,21 +48,16 @@ def test_parse_items_and_addrs_rejects_malformed_tuple(): assert addrs == [] -def test_send_msg_forward_relays_to_reachable_only(server, dummy_client_socket, capsys): - fwd.server_instance = server - server.running = True - reachable = ("127.0.0.1", 12345) - server.clients[reachable] = {"socket": dummy_client_socket} - fwd._forward_send_msg_handler( - None, - None, - "/forward_send_msg \"111\" \"222\" \"('127.0.0.1', 12345)\" \"('127.0.0.1', 99999)\"", - ) - sent = dummy_client_socket.data.decode("utf-8") - assert "111" in sent - assert "222" in sent - assert "99999" not in sent - assert "skipped" in capsys.readouterr().out +def test_message_forward_command_is_internal(client, server): + """The single ``/forward_send_msg`` command is internal, not an extension: + not registered in the handler registry on either side, and the deleted + ``/send_msg_forward`` name is gone.""" + assert "/forward_send_msg" not in server._custom_handlers[0] + assert "/forward_send_msg" not in server._custom_handlers[1] + assert "/forward_send_msg" not in client._custom_handlers[0] + assert "/forward_send_msg" not in client._custom_handlers[1] + assert "/send_msg_forward" not in client._custom_handlers[1] + assert "/send_msg_forward" not in client._custom_handlers[0] def test_file_forward_relay_skips_unreachable(server, monkeypatch, capsys, tmp_path): @@ -114,17 +115,17 @@ def test_forward_commands_are_client_only(client, server): assert cmd in client._custom_handlers[1] # client console triggers it assert cmd not in server._custom_handlers[1] # server console rejects it assert cmd not in client._custom_handlers[0] - for relay in ("/forward_send_msg", "/forward_file", "/forward_folder"): + for relay in ("/forward_file", "/forward_folder"): assert relay in server._custom_handlers[0] # client requests reach it + assert "/send_msg_forward" not in fwd._FORWARD_COMMANDS -def test_send_msg_forward_asks_server(client, monkeypatch): - fwd.setup_client_commands(client) +def test_forward_messages_public_api(client, monkeypatch): + """forward_messages() builds the /forward_send_msg relay request.""" client.client_socket = DummySocket() sent = [] monkeypatch.setattr(client, "send_message", lambda sock, msg: sent.append(msg) or True) - handler = client._custom_handlers[1]["/send_msg_forward"] - handler(None, None, '/send_msg_forward "111" "222" "(\'127.0.0.1\', 3000)"') + client.forward_messages(["111", "222"], [("127.0.0.1", 3000)]) assert len(sent) == 1 request = sent[0] assert request.startswith("/forward_send_msg") @@ -132,6 +133,27 @@ def test_send_msg_forward_asks_server(client, monkeypatch): assert "127.0.0.1" in request and "3000" in request +def test_forward_send_msg_relays_to_reachable_only(server, dummy_client_socket, capsys): + """The native /forward_send_msg relay wraps each message with the + originator's address and records it under the originator's socket.""" + server.running = True + origin_sock = DummySocket() + origin_addr = ("127.0.0.1", 54321) + reachable = ("127.0.0.1", 12345) + server.clients[reachable] = {"socket": dummy_client_socket} + server._handle_forward_send_msg( + origin_sock, + origin_addr, + "/forward_send_msg \"111\" \"222\" \"('127.0.0.1', 12345)\" \"('127.0.0.1', 99999)\"", + ) + sent = dummy_client_socket.data.decode("utf-8") + assert "111" in sent and "222" in sent + assert "99999" not in sent + assert "skipped" in capsys.readouterr().out + recorded = [e[0] for e in server.messages_dict.get(origin_sock, [])] + assert "111" in recorded and "222" in recorded + + def test_file_forward_uploads_then_asks_server(client, monkeypatch, tmp_path): fwd.setup_client_commands(client) client.client_socket = DummySocket() @@ -180,15 +202,13 @@ def test_folder_forward_uploads_sync_then_asks_server(client, monkeypatch, tmp_p assert "data_folder" in sent[-1] -def test_forward_without_items_or_addrs_prints_usage(client, monkeypatch, capsys): - fwd.setup_client_commands(client) +def test_console_forward_without_items_or_addrs_prints_usage(client, monkeypatch, capsys): client.client_socket = DummySocket() sent = [] monkeypatch.setattr(client, "send_message", lambda sock, msg: sent.append(msg) or True) - handler = client._custom_handlers[1]["/send_msg_forward"] - handler(None, None, "/send_msg_forward") + client._console_forward_send_msg("/forward_send_msg") assert sent == [] - assert "need at least one item" in capsys.readouterr().out + assert "need at least one message" in capsys.readouterr().out def test_single_variant_rejects_multiple_items(client, monkeypatch, capsys): @@ -217,8 +237,33 @@ def fake_server(**kwargs): fwd.server_setup() assert created and created[0]["is_extend_command"] is True assert fwd.server_instance is not None - for relay in ("/forward_send_msg", "/forward_file", "/forward_folder"): + for relay in ("/forward_file", "/forward_folder"): assert relay in fwd.server_instance._custom_handlers[0] + # the message relay is internal and needs no extension setup + assert "/forward_send_msg" not in fwd.server_instance._custom_handlers[0] + + +def test_server_dispatches_forward_send_msg_internally(server, capsys): + """/forward_send_msg arriving over the wire is routed by handle_command's + built-in chain (no extension registration involved).""" + from test_util import wait_until + + server.running = True + dest = DummySocket() + reachable = ("127.0.0.1", 12345) + server.clients[reachable] = {"socket": dest} + origin_sock = DummySocket() + try: + ack = server.handle_command( + origin_sock, + ("127.0.0.1", 54321), + "/forward_send_msg \"111\" \"('127.0.0.1', 12345)\" \"('127.0.0.1', 99999)\"", + ) + assert ack == "Command received, processing in background.\n" + assert wait_until(lambda: b"111" in dest.data), f"never relayed: {dest.data!r}" + assert "skipped" in capsys.readouterr().out + finally: + server.running = False def test_server_setup_with_existing_instance_threaded(monkeypatch): @@ -238,7 +283,7 @@ def test_server_setup_with_existing_instance_threaded(monkeypatch): try: fwd.server_setup(instance=s, is_input_command_in_console=False) assert fwd.server_instance is s - for relay in ("/forward_send_msg", "/forward_file", "/forward_folder"): + for relay in ("/forward_file", "/forward_folder"): assert relay in s._custom_handlers[0] for _ in range(50): if s.running: @@ -250,4 +295,4 @@ def test_server_setup_with_existing_instance_threaded(monkeypatch): probe.connect(("127.0.0.1", port)) probe.close() finally: - s.stop() \ No newline at end of file + s.stop() diff --git a/test/test_forward_msg_attribution.py b/test/test_forward_msg_attribution.py new file mode 100644 index 0000000..ed4f503 --- /dev/null +++ b/test/test_forward_msg_attribution.py @@ -0,0 +1,234 @@ +"""Tests for native message forwarding and sender attribution. + +Plain-message forwarding is native to the TCP protocol: the client-only +console command ``/forward_send_msg`` asks the server (which runs the +same-named relay) to push messages to other connected clients. The server +sends each message wrapped in a ``/send_msg_from `` +envelope, so the receiving client can attribute it to the sender's +conversation instead of surfacing under every sidebar entry. + +Records of a forwarded message belong to the originator on both ends: + +- the server records the payload under the originator's socket, as if the + originator had sent it directly; +- the receiving client cannot hold the originator's socket (it only has + its own link to the server), so it records the payload and the envelope + event under the originator's ``"ip:port"`` address string. + +Message listeners on both classes share one contract, +``listener(sender_id, message)``; on the client ``sender_id`` is ``None`` +for direct pushes from the server. +""" + +import shlex +import threading +import time + +import pytest + +from test_util import server_ready, wait_until + +from PyFlow.network_api.connect_tcp import ( + TCP_Client_Base, + TCP_Server_Base, + parse_forwarded_message, +) + +_PORT_COUNTER = 65530 + + +def _next_port(): + global _PORT_COUNTER + _PORT_COUNTER += 1 + return _PORT_COUNTER + + +@pytest.fixture +def trio(): + """Server + two clients. Message forwarding is native, no extension setup.""" + port = _next_port() + server = TCP_Server_Base( + host="127.0.0.1", + port=port, + is_extend_command=True, + is_input_command_in_console=False, + is_enable_encrypto=False, + ) + threading.Thread(target=server.start_TCP_Server, daemon=True).start() + assert server_ready(server), "server did not start" + + clients = [] + for _ in range(2): + c = TCP_Client_Base( + host="127.0.0.1", + port=port, + client_host="127.0.0.1", + is_extend_command=True, + is_input_command_in_console=False, + is_enable_encrypto=False, + ) + assert c.connect() + clients.append(c) + + yield server, *clients + for c in clients: + c.close() + server.stop() + + +def _addr(client): + return client.client_socket.getsockname() + + +def _client_key(client): + ip, port = client.client_socket.getsockname() + return f"{ip}:{port}" + + +def _server_sock(server, client): + """The server-side socket object for a connected client.""" + return server.clients[client.client_socket.getsockname()]["socket"] + + +def _forward(sender, payload, dest): + """Mimic the web backend: forward via the native public API.""" + sender.forward_messages([payload], [_addr(dest)]) + + +def _envelope(addr, payload): + # the exact wire form produced by the server relay + return f"/send_msg_from {shlex.quote(repr(addr))} {shlex.quote(payload)}" + + +def test_parse_forwarded_message(): + assert parse_forwarded_message(_envelope(("127.0.0.1", 3000), "hi")) == ( + "127.0.0.1:3000", + "hi", + ) + assert parse_forwarded_message(_envelope(("127.0.0.1", 3000), "hello world")) == ( + "127.0.0.1:3000", + "hello world", + ) + assert parse_forwarded_message(_envelope(("::1", 3000), "x")) == ("::1:3000", "x") + assert parse_forwarded_message(_envelope(("127.0.0.1", 3000), "it's fine")) == ( + "127.0.0.1:3000", + "it's fine", + ) + # malformed or unrelated lines are not envelopes + assert parse_forwarded_message("/send_msg_from nope hi") is None + assert parse_forwarded_message("/send_msg_from") is None + assert parse_forwarded_message("/other ('127.0.0.1', 3000) hi") is None + assert parse_forwarded_message("plain message") is None + + +def test_forward_commands_are_internal(): + """Message forwarding is internal, not an extension. The single command + ``/forward_send_msg`` is NOT registered in the extension handler registry + on either side, and the deleted ``/send_msg_forward`` name is gone.""" + server = TCP_Server_Base( + host="127.0.0.1", port=_next_port(), is_extend_command=True, is_enable_encrypto=False + ) + client = TCP_Client_Base( + host="127.0.0.1", + port=server.port, + client_host="127.0.0.1", + is_extend_command=True, + is_enable_encrypto=False, + ) + try: + assert "/forward_send_msg" not in server._custom_handlers[0] + assert "/forward_send_msg" not in server._custom_handlers[1] + assert "/forward_send_msg" not in client._custom_handlers[0] + assert "/forward_send_msg" not in client._custom_handlers[1] + assert "/send_msg_forward" not in server._custom_handlers[0] + assert "/send_msg_forward" not in server._custom_handlers[1] + assert "/send_msg_forward" not in client._custom_handlers[0] + assert "/send_msg_forward" not in client._custom_handlers[1] + finally: + client.close() + server.stop() + + +def test_listener_gets_sender_for_forward_and_none_for_direct_push(trio): + """The merged message listener receives (sender_id, message): the + forwarding client's id for forwards, None for direct server pushes.""" + server, a, b = trio + received = [] + b.add_message_listener(lambda sender, text: received.append((sender, text))) + _forward(a, "hello", b) + server.send_message(_server_sock(server, b), "direct from server") + assert wait_until(lambda: len(received) == 2), f"messages never arrived {received=}" + assert (_client_key(a), "hello") in received + assert (None, "direct from server") in received + + +def test_forwarded_message_reaches_destination_as_plain(trio): + """The payload reaches the destination's listener and message store, + exactly as a plain inbound message would.""" + server, a, b = trio + received = [] + b.add_message_listener(lambda sender, text: received.append((sender, text))) + _forward(a, "hello from a", b) + assert wait_until(lambda: any(t == "hello from a" for _, t in received)), ( + f"payload never reached {received=}" + ) + assert received[0][0] == _client_key(a) + assert wait_until( + lambda: any( + e[0] == "hello from a" for e in b.messages_dict.get(_client_key(a), []) + ) + ), "payload not recorded under the originator in the destination store" + + +def test_forward_records_keyed_by_originator(trio): + """Part of the store contract: forwarded content is recorded under the + ORIGINAL client on both ends. + + - server.messages_dict[origin_socket] gains the payload (as if the + originator had sent it directly); + - the receiving client records the payload and the envelope event under + the originator's "ip:port" (its socket exists only on the server); + - the receiving client's own server-link socket key stays clean. + """ + server, a, b = trio + origin_key = _client_key(a) + origin_sock = _server_sock(server, a) + _forward(a, "rec me", b) + assert wait_until( + lambda: any( + e[0] == "rec me" for e in server.messages_dict.get(origin_sock, []) + ) + ), "server did not record the forwarded message under the originator's socket" + assert wait_until( + lambda: any(e[0] == "rec me" for e in b.messages_dict.get(origin_key, [])) + ), "receiver did not record the payload under the originator's address" + assert wait_until( + lambda: any( + e[0].startswith("/send_msg_from") + for e in b.events_dict.get(origin_key, []) + ) + ), "receiver did not record the envelope event under the originator's address" + # the client's own link to the server must not hold forwarded content + assert all( + e[0] != "rec me" for e in b.messages_dict.get(b.client_socket, []) + ) + + +def test_extension_command_cannot_hijack_envelope(trio): + """/send_msg_from is unwrapped before command dispatch: an extension + registering a command with that name must not swallow forwarded messages.""" + server, a, b = trio + hijacked = [] + b.register_command( + "/send_msg_from", + lambda sock, addr, cmd: hijacked.append(cmd), + where_to_run="server", + run_in_thread=False, + ) + received = [] + b.add_message_listener(lambda sender, text: received.append((sender, text))) + _forward(a, "hi", b) + assert wait_until(lambda: len(received) == 1), f"payload never reached {received=}" + assert received[0] == (_client_key(a), "hi") + time.sleep(0.3) + assert hijacked == [] From 685f2d09392b37f362d502c684389bf7eddbfd07 Mon Sep 17 00:00:00 2001 From: F18-Ray Date: Thu, 10 Sep 2026 09:52:42 +0800 Subject: [PATCH 08/11] core-code: fix the original sender tracing functions in connect tcp and the web --- PyFlow/network_api/connect_tcp.py | 147 +++++++++++++----- .../transfer_web/web_front/client_backend.py | 26 ++-- test/test_forward_msg_attribution.py | 18 ++- test/test_tcp_file_transfer.py | 62 +++++++- 4 files changed, 204 insertions(+), 49 deletions(-) diff --git a/PyFlow/network_api/connect_tcp.py b/PyFlow/network_api/connect_tcp.py index 68be0b6..fd35d91 100644 --- a/PyFlow/network_api/connect_tcp.py +++ b/PyFlow/network_api/connect_tcp.py @@ -128,6 +128,35 @@ def forward_skip_message(target): return f"forward: destination {target} is unreachable or is the server, skipped" +def parse_forward_originator(command, own_address=None): + """Extract the originator's ``"ip:port"`` from a received transfer command. + The server's forward relay tags every pushed ``/file`` and ``/file_folder`` + command with the forwarding client's address tuple (the tuple token before + the trailing transfer id). Direct sends carry the receiver's own address + instead, which is filtered out when ``own_address`` is given. Returns the + originator's ``"ip:port"``, or ``None`` when the command carries no + originator (a direct send or a non-transfer command). + """ + try: + parts = shlex.split(command) + except ValueError: + return None + if len(parts) < 4: + return None + for token in parts[1:-1]: # skip the command name and the trailing id + if token.startswith("(") and token.endswith(")"): + try: + addr = ast.literal_eval(token) + except (ValueError, SyntaxError): + continue + if isinstance(addr, tuple) and len(addr) == 2 and isinstance(addr[0], str): + originator = f"{addr[0]}:{addr[1]}" + if own_address and originator == own_address: + return None # direct send: the tuple is the receiver itself + return originator + return None + + class TCP_Server_Base: # TCP server class def __init__( self, @@ -1414,22 +1443,81 @@ def _handle_forward_send_msg(self, sock, addr, cmd): items, addrs = parse_forward_items_and_addrs(parts[1:]) reached = False for target in addrs: - client_info = self.clients.get(target) - if client_info is None: - print(forward_skip_message(target)) - continue - reached = True - target_socket = client_info["socket"] for msg in items: - self.send_message( - target_socket, - f"/send_msg_from {shlex.quote(repr(addr))} {shlex.quote(msg)}", - ) + if self.forward_message_to(target, msg, addr): + reached = True if reached: for msg in items: self._record_message(sock, msg) return None + def forward_message_to(self, target, message, originator_addr): + """Send one plain message to ``target``, tagged with the originator's + address (public API for forward extensions). + + The message is wrapped in a ``/send_msg_from `` + envelope so the receiver can attribute it to the originator (see + ``parse_forwarded_message`` on the receiving side). Returns False when + the target is not connected. + """ + client_info = self.clients.get(target) + if client_info is None: + print(forward_skip_message(target)) + return False + self.send_message( + client_info["socket"], + f"/send_msg_from {shlex.quote(repr(originator_addr))} {shlex.quote(message)}", + ) + return True + + def forward_target_command( + self, kind, rel_dir, fname, originator_addr, tfid, destination_path=None + ): + """Build the wire command that pushes one forwarded file/folder item to + a target, tagged with the originator's address (public API for forward + extensions). + + The originator tuple sits before the trailing transfer id: the + receiver's existing parsers treat it as the address slot and ignore + it, while ``parse_forward_originator`` recovers it for attribution. + """ + originator = shlex.quote(repr(originator_addr)) + if kind == "file": + if destination_path: + return ( + f"/file {shlex.quote(fname)} {originator} " + f"{shlex.quote(destination_path)} {tfid}" + ) + return f"/file {shlex.quote(fname)} {originator} {tfid}" + if destination_path: + return ( + f"/file_folder {shlex.quote(rel_dir)} {shlex.quote(fname)} " + f"{originator} {shlex.quote(destination_path)} {tfid}" + ) + return f"/file_folder {shlex.quote(rel_dir)} {shlex.quote(fname)} {originator} {tfid}" + + def forward_item_to( + self, target, kind, rel_dir, fname, originator_addr, tfid, destination_path=None + ): + """Push one forwarded file/folder item to ``target``, tagged with the + originator's address (public API for forward extensions). + + Sends the command built by ``forward_target_command``; the receiver + recovers the originator with ``parse_forward_originator``. Returns + False when the target is not connected. + """ + client_info = self.clients.get(target) + if client_info is None: + print(forward_skip_message(target)) + return False + self.send_message( + client_info["socket"], + self.forward_target_command( + kind, rel_dir, fname, originator_addr, tfid, destination_path + ), + ) + return True + def _execute_custom_handler(self, handler, command, client_socket=None, client_address=None): try: result = handler(client_socket, client_address, command) @@ -2244,7 +2332,7 @@ def _forward_item_handler(self, sock, addr, cmd): return threading.Thread( target=self._forward_relay, - args=(sock, kind, rel_dir, fname, valid_targets, destination_path), + args=(sock, addr, kind, rel_dir, fname, valid_targets, destination_path), daemon=True, ).start() @@ -2304,9 +2392,15 @@ def _forward_resume_target(self, client_address, command): relay["writer_pause"][client_address] = False relay["cond"].notify_all() - def _forward_relay(self, forwarder_sock, kind, rel_dir, fname, targets, destination_path=None): + def _forward_relay( + self, forwarder_sock, originator_addr, kind, rel_dir, fname, targets, destination_path=None + ): """Relay one file/folder item to every target, streaming from the - uploader's transfer connection with bounded in-memory buffering.""" + uploader's transfer connection with bounded in-memory buffering. + + Every pushed command is tagged with the originator's address (see + ``forward_item_to``) so the receivers can attribute the transfer. + """ fid = self._forward_alloc_fid() tfids = [self._forward_alloc_fid() for _ in targets] relay = { @@ -2321,30 +2415,9 @@ def _forward_relay(self, forwarder_sock, kind, rel_dir, fname, targets, destinat # destination directory (if any) goes before the target id so # the receiver's own destination parsing sees it for target, tfid in zip(targets, tfids): - try: - t_sock = self.clients[target]["socket"] - except Exception as e: - print(f"forward: target {target} missing, skipped: {e}") - continue - if kind == "file": - if destination_path: - self.send_message( - t_sock, f"/file {shlex.quote(fname)} {shlex.quote(destination_path)} {tfid}" - ) - else: - self.send_message(t_sock, f"/file {shlex.quote(fname)} {tfid}") - else: - if destination_path: - self.send_message( - t_sock, - f"/file_folder {shlex.quote(rel_dir)} {shlex.quote(fname)} " - f"{shlex.quote(destination_path)} {tfid}", - ) - else: - self.send_message( - t_sock, - f"/file_folder {shlex.quote(rel_dir)} {shlex.quote(fname)} {tfid}", - ) + self.forward_item_to( + target, kind, rel_dir, fname, originator_addr, tfid, destination_path + ) # collect every target's advertised transfer port ports = {} deadline = time.time() + 20 diff --git a/PyFlow/transfer_web/web_front/client_backend.py b/PyFlow/transfer_web/web_front/client_backend.py index 02ab0af..3a26b67 100644 --- a/PyFlow/transfer_web/web_front/client_backend.py +++ b/PyFlow/transfer_web/web_front/client_backend.py @@ -39,7 +39,7 @@ from PyFlow import add_extension from PyFlow import forward_extension_tcp -from PyFlow.network_api.connect_tcp import TCP_Client_Base +from PyFlow.network_api.connect_tcp import TCP_Client_Base, parse_forward_originator WEB_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) FLOW_WEB_DIR = os.path.join(WEB_ROOT, ".Flow_Web") @@ -246,15 +246,21 @@ def _on_incoming_file(self, full_path, name, size, command): rel = candidate except Exception: pass - self._push_event( - { - "type": "file", - "name": name, - "path": rel, - "size": size, - "at": time.strftime("%H:%M:%S"), - } - ) + event = { + "type": "file", + "name": name, + "path": rel, + "size": size, + "at": time.strftime("%H:%M:%S"), + } + # A forwarded file/folder carries the originator's address in the wire + # command; direct pushes carry the receiver's own address and are + # filtered out, so they keep surfacing under the server entry. + own = self._own_address() + originator = parse_forward_originator(command, own_address=own["id"] if own else None) + if originator: + event["from"] = originator + self._push_event(event) # ---------------------------------------------------------------- connect diff --git a/test/test_forward_msg_attribution.py b/test/test_forward_msg_attribution.py index ed4f503..e8f3e88 100644 --- a/test/test_forward_msg_attribution.py +++ b/test/test_forward_msg_attribution.py @@ -31,10 +31,11 @@ from PyFlow.network_api.connect_tcp import ( TCP_Client_Base, TCP_Server_Base, + parse_forward_originator, parse_forwarded_message, ) -_PORT_COUNTER = 65530 +_PORT_COUNTER = 65500 def _next_port(): @@ -214,6 +215,21 @@ def test_forward_records_keyed_by_originator(trio): ) +def test_public_forward_api(trio): + """The packaged forward API for extension authors: forward_message_to + tags the envelope, forward_target_command builds the tagged transfer + command, and unreachable targets are reported.""" + server, a, b = trio + received = [] + b.add_message_listener(lambda sender, text: received.append((sender, text))) + assert server.forward_message_to(_addr(b), "via api", _addr(a)) + assert wait_until(lambda: len(received) == 1), f"never arrived {received=}" + assert received[0] == (_client_key(a), "via api") + cmd = server.forward_target_command("file", "", "a.txt", _addr(a), 7) + assert parse_forward_originator(cmd) == _client_key(a) + assert server.forward_message_to(("127.0.0.1", 1), "x", _addr(a)) is False # unreachable + + def test_extension_command_cannot_hijack_envelope(trio): """/send_msg_from is unwrapped before command dispatch: an extension registering a command with that name must not swallow forwarded messages.""" diff --git a/test/test_tcp_file_transfer.py b/test/test_tcp_file_transfer.py index 47525ca..612708f 100644 --- a/test/test_tcp_file_transfer.py +++ b/test/test_tcp_file_transfer.py @@ -10,7 +10,11 @@ from test_util import server_ready, wait_until -from PyFlow.network_api.connect_tcp import TCP_Client_Base, TCP_Server_Base +from PyFlow.network_api.connect_tcp import ( + TCP_Client_Base, + TCP_Server_Base, + parse_forward_originator, +) from unittest.mock import MagicMock _PORT_COUNTER = 65420 @@ -390,6 +394,62 @@ def _forward_client(server, recv_dir): return client +def test_parse_forward_originator(): + """The originator tuple in a pushed /file or /file_folder command is + recovered; a direct send (the receiver's own address) is filtered out.""" + # the wire form produced by forward_target_command (tuple shlex-quoted) + cmd = lambda *a: TCP_Server_Base.forward_target_command(None, *a) + assert parse_forward_originator(cmd("file", "", "a.txt", ("127.0.0.1", 3000), 7)) == ( + "127.0.0.1:3000" + ) + assert parse_forward_originator( + cmd("file", "", "a.txt", ("127.0.0.1", 3000), 7, "dest") + ) == "127.0.0.1:3000" + assert parse_forward_originator( + cmd("folder", "d", "a.txt", ("127.0.0.1", 3000), 7) + ) == "127.0.0.1:3000" + assert parse_forward_originator( + cmd("folder", "d", "a.txt", ("127.0.0.1", 3000), 7, "dest") + ) == "127.0.0.1:3000" + # direct send: the tuple is the receiver's own address -> filtered + assert ( + parse_forward_originator( + cmd("file", "", "a.txt", ("127.0.0.1", 3000), 7), + own_address="127.0.0.1:3000", + ) + is None + ) + # no tuple: plain direct send or a non-transfer command + assert parse_forward_originator("/file 'a.txt' 7") is None + assert parse_forward_originator("/pause_trans 3") is None + + +def test_forwarded_file_carries_originator(pair, tmp_path): + """A streamed forward tags the pushed /file command with the originator's + address: the receiver's file listener can attribute the transfer.""" + server, target, recv_dir = pair + src = tmp_path / "payload.bin" + payload = os.urandom(4096) + src.write_bytes(payload) + forwarder = _forward_client(server, tmp_path / "fwd") + commands = [] + target.add_file_listener( + lambda full_path, name, size, command: commands.append(command) + ) + t = target.client_socket.getsockname() + cmd = '/forward_file "{}" "({}, {})"'.format(src, repr(t[0]), t[1]) + forwarder.forward_file_console(cmd) + assert wait_until(lambda: (recv_dir / "payload.bin").exists(), timeout=15), ( + "forwarded file not received" + ) + assert wait_until(lambda: len(commands) >= 1), f"no file event {commands=}" + a = forwarder.client_socket.getsockname() + own = target.client_socket.getsockname() + originator = parse_forward_originator(commands[0], own_address=f"{own[0]}:{own[1]}") + assert originator == f"{a[0]}:{a[1]}" + forwarder.close() + + def test_forward_file_to_multiple_clients(pair, tmp_path): """/forward_file relays one file from a client to several clients, with the server acting as an in-memory relay (no server-side disk write).""" From 2c7844e8745aad69ae7a8c600ec0ada6ae43a0fe Mon Sep 17 00:00:00 2001 From: F18-Ray Date: Thu, 10 Sep 2026 11:45:52 +0800 Subject: [PATCH 09/11] core-code: fix the error of lossing the ip host who sent the messages when logs writing to the log files --- PyFlow/network_api/connect_tcp.py | 86 +++++++++++++++++++++++++++++-- test/test_message_event_store.py | 22 +++++++- 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/PyFlow/network_api/connect_tcp.py b/PyFlow/network_api/connect_tcp.py index fd35d91..1da0862 100644 --- a/PyFlow/network_api/connect_tcp.py +++ b/PyFlow/network_api/connect_tcp.py @@ -246,6 +246,8 @@ def __init__( self.events_dict = {} self._messages_dict_lock = threading.Lock() self._events_dict_lock = threading.Lock() + self._socket_keys = {} # record key -> "ip:port", captured while it was alive + self._socket_keys_lock = threading.Lock() self._messages_dict_size = 0 self._events_dict_size = 0 self.max_dict_size = 64 * 1024 @@ -526,13 +528,43 @@ def _notify_file_received(self, client_id, full_path, name, size, command): traceback.print_exc() def _socket_key(self, sock): - """Serializable key for a sender socket (its peer address).""" + """Serializable key for a sender socket (its peer address). + + Forwarded records already carry the originator's address as a + string and are used as-is; a socket that cannot answer + ``getpeername`` (already closed) falls back to its repr. + """ + if isinstance(sock, str): + return sock try: ip, port = sock.getpeername()[:2] return f"{ip}:{port}" except Exception: return str(sock) + def _remember_socket_key(self, sock): + """Capture the log key of ``sock`` while it is known to be alive. + + A record stays buffered after its connection goes away, and the + flush that persists it must still file it under the sender's + address: by then the closed socket no longer answers + ``getpeername`` and would be logged as ``str(sock)``. The key is + forgotten once nothing is buffered under it any more (see + ``_flush_dict_locked``). + """ + with self._socket_keys_lock: + if sock not in self._socket_keys: + self._socket_keys[sock] = self._socket_key(sock) + + def _record_key(self, sock): + """The JSON-log key for ``sock``: the address captured when it was + recorded, or the live peer address for a socket inserted without a + record.""" + key = self._socket_keys.get(sock) + if key is not None: + return key + return self._socket_key(sock) + def _record_message(self, sock, content): """Store one inbound plain-text message under the sender's socket. @@ -542,6 +574,7 @@ def _record_message(self, sock, content): """ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") with self._messages_dict_lock: + self._remember_socket_key(sock) self.messages_dict.setdefault(sock, []).append([content, timestamp]) self._messages_dict_size += len(content.encode("utf-8", "replace")) + len(timestamp) if self._messages_dict_size >= self.max_dict_size: @@ -560,6 +593,7 @@ def _record_event(self, sock, command): """ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") with self._events_dict_lock: + self._remember_socket_key(sock) self.events_dict.setdefault(sock, []).append([command, timestamp]) self._events_dict_size += len(command.encode("utf-8", "replace")) + len(timestamp) if self._events_dict_size >= self.max_dict_size: @@ -613,6 +647,11 @@ def _flush_dict_locked(self, d, size_attr, path): d.clear() setattr(self, size_attr, 0) self._merge_json_log(path, snapshot) + other = self.events_dict if d is self.messages_dict else self.messages_dict + with self._socket_keys_lock: # a key is only kept while it is buffered + for sock in snapshot: + if sock not in d and sock not in other: + self._socket_keys.pop(sock, None) def _flush_messages_dict(self): with self._messages_dict_lock: @@ -639,7 +678,7 @@ def _merge_json_log(self, path, snapshot): else: existing = {} for sock, entries in snapshot.items(): - key = self._socket_key(sock) + key = self._record_key(sock) existing.setdefault(key, []).extend(entries) tmp_path = path + ".tmp" with open(tmp_path, "w", encoding="utf-8") as f: @@ -2822,6 +2861,8 @@ def __init__( self.events_dict = {} self._messages_dict_lock = threading.Lock() self._events_dict_lock = threading.Lock() + self._socket_keys = {} # record key -> "ip:port", captured while it was alive + self._socket_keys_lock = threading.Lock() self._messages_dict_size = 0 self._events_dict_size = 0 self.max_dict_size = 64 * 1024 @@ -2939,13 +2980,43 @@ def _notify_file_received(self, full_path, name, size, command): traceback.print_exc() def _socket_key(self, sock): - """Serializable key for a sender socket (its peer address).""" + """Serializable key for a sender socket (its peer address). + + Forwarded records already carry the originator's address as a + string and are used as-is; a socket that cannot answer + ``getpeername`` (already closed) falls back to its repr. + """ + if isinstance(sock, str): + return sock try: ip, port = sock.getpeername()[:2] return f"{ip}:{port}" except Exception: return str(sock) + def _remember_socket_key(self, sock): + """Capture the log key of ``sock`` while it is known to be alive. + + A record stays buffered after its connection goes away, and the + flush that persists it must still file it under the sender's + address: by then the closed socket no longer answers + ``getpeername`` and would be logged as ``str(sock)``. The key is + forgotten once nothing is buffered under it any more (see + ``_flush_dict_locked``). + """ + with self._socket_keys_lock: + if sock not in self._socket_keys: + self._socket_keys[sock] = self._socket_key(sock) + + def _record_key(self, sock): + """The JSON-log key for ``sock``: the address captured when it was + recorded, or the live peer address for a socket inserted without a + record.""" + key = self._socket_keys.get(sock) + if key is not None: + return key + return self._socket_key(sock) + def _record_message(self, sock, content): """Store one inbound plain-text message under the sender's socket. @@ -2955,6 +3026,7 @@ def _record_message(self, sock, content): """ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") with self._messages_dict_lock: + self._remember_socket_key(sock) self.messages_dict.setdefault(sock, []).append([content, timestamp]) self._messages_dict_size += len(content.encode("utf-8", "replace")) + len(timestamp) if self._messages_dict_size >= self.max_dict_size: @@ -2973,6 +3045,7 @@ def _record_event(self, sock, command): """ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") with self._events_dict_lock: + self._remember_socket_key(sock) self.events_dict.setdefault(sock, []).append([command, timestamp]) self._events_dict_size += len(command.encode("utf-8", "replace")) + len(timestamp) if self._events_dict_size >= self.max_dict_size: @@ -3026,6 +3099,11 @@ def _flush_dict_locked(self, d, size_attr, path): d.clear() setattr(self, size_attr, 0) self._merge_json_log(path, snapshot) + other = self.events_dict if d is self.messages_dict else self.messages_dict + with self._socket_keys_lock: # a key is only kept while it is buffered + for sock in snapshot: + if sock not in d and sock not in other: + self._socket_keys.pop(sock, None) def _flush_messages_dict(self): with self._messages_dict_lock: @@ -3052,7 +3130,7 @@ def _merge_json_log(self, path, snapshot): else: existing = {} for sock, entries in snapshot.items(): - key = self._socket_key(sock) + key = self._record_key(sock) existing.setdefault(key, []).extend(entries) tmp_path = path + ".tmp" with open(tmp_path, "w", encoding="utf-8") as f: diff --git a/test/test_message_event_store.py b/test/test_message_event_store.py index 85839f3..7afed24 100644 --- a/test/test_message_event_store.py +++ b/test/test_message_event_store.py @@ -193,11 +193,31 @@ def test_flush_on_close_persists_remaining(pair, tmp_path): for e in server.messages_dict.get(_server_sock(server, client), []) ) ), "message not recorded" + key = _client_addr_key(client) # the live socket's address, before stop() server.stop() with open(server.messages_log_file, "r", encoding="utf-8") as f: data = json.load(f) + assert any(e[0] == "persist me" for e in data.get(key, [])), ( + f"no entry under {key}; log keys: {list(data)}" + ) + + +def test_flush_keeps_sender_key_after_connection_closed(pair): + """A record buffered when its connection goes away is still flushed under + the sender's address, not under a dead socket repr.""" + server, client = pair + server_sock = _server_sock(server, client) key = _client_addr_key(client) - assert any(e[0] == "persist me" for e in data.get(key, [])) + server._record_message(server_sock, "persist me") + server_sock.close() # the connection is gone before the store is flushed + server._flush_messages_dict() + with open(server.messages_log_file, "r", encoding="utf-8") as f: + data = json.load(f) + assert any(e[0] == "persist me" for e in data.get(key, [])), ( + f"no entry under {key}; log keys: {list(data)}" + ) + server._flush_events_dict() # nothing buffered under the socket any more + assert server_sock not in server._socket_keys def test_splice_event_command(pair): From 724a27e81e4a6488c082668bdee291268d9ef265 Mon Sep 17 00:00:00 2001 From: F18-Ray Date: Thu, 10 Sep 2026 19:58:26 +0800 Subject: [PATCH 10/11] core-code: add the config fixing functions in server and client web page --- PyFlow/transfer_web/setup_client.py | 1 + PyFlow/transfer_web/static/common.css | 1 + .../web_backend/server_backend.py | 82 +++++++- .../web_backend/templates/server_config.html | 34 +++- .../web_backend/templates/server_status.html | 1 + .../transfer_web/web_front/client_backend.py | 188 ++++++++++++++++-- .../web_front/templates/client_config.html | 118 +++++++++++ .../web_front/templates/client_main.html | 1 + 8 files changed, 404 insertions(+), 22 deletions(-) create mode 100644 PyFlow/transfer_web/web_front/templates/client_config.html diff --git a/PyFlow/transfer_web/setup_client.py b/PyFlow/transfer_web/setup_client.py index fb498ee..b75ad10 100755 --- a/PyFlow/transfer_web/setup_client.py +++ b/PyFlow/transfer_web/setup_client.py @@ -21,6 +21,7 @@ def main(): app = ClientWebApp() + app.start_from_config() app.run() diff --git a/PyFlow/transfer_web/static/common.css b/PyFlow/transfer_web/static/common.css index d316775..3cbbad6 100644 --- a/PyFlow/transfer_web/static/common.css +++ b/PyFlow/transfer_web/static/common.css @@ -163,6 +163,7 @@ a { color: var(--accent); } .sidebar-actions { display: flex; + flex-wrap: wrap; gap: 6px; margin-top: 10px; } diff --git a/PyFlow/transfer_web/web_backend/server_backend.py b/PyFlow/transfer_web/web_backend/server_backend.py index 7c95dbd..89c4292 100644 --- a/PyFlow/transfer_web/web_backend/server_backend.py +++ b/PyFlow/transfer_web/web_backend/server_backend.py @@ -27,6 +27,7 @@ import os import shlex import socket +import subprocess import sys import threading import time @@ -129,6 +130,15 @@ def _load_json_list(path): return [] +def _config_display_value(key, value): + """Render a saved config value for the config form input.""" + if value is None: + return "" + if key == "is_custom_keys" and isinstance(value, list): + return json.dumps(value) + return value + + class ServerWebApp: """Flask app + TCP_Server_Base wrapper for the web tool.""" @@ -340,7 +350,35 @@ def _send_folder_to_client(self, target, path, destination=None): def _restart(self): time.sleep(1) - os.execv(sys.executable, [sys.executable] + sys.argv) + # Spawn a fresh process and exit: ``os.execv`` would keep the Flask + # dev-server socket (no FD_CLOEXEC) alive and strand the old web port. + try: + subprocess.Popen( + [sys.executable] + sys.argv, + close_fds=True, + start_new_session=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: + traceback.print_exc() + os._exit(0) + + def _stop_server(self): + """Stop the running TCP server and release its port.""" + if self.server is None: + return + try: + # Unblock the accept thread so the port is released before the + # new server binds (``stop()`` alone leaves it held). + self.server.server_socket.shutdown(socket.SHUT_RDWR) + except Exception: + pass + try: + self.server.stop() + except Exception: + traceback.print_exc() # ------------------------------------------------------------------ routes @@ -355,6 +393,32 @@ def index(): "server_config.html", fields=SERVER_PARAM_FIELDS, web_fields=WEB_FIELDS ) + @app.get("/config") + def config(): + """Startup-configuration page, reachable from the status page too.""" + current = {} + web_port = self.web_port + if os.path.exists(SERVER_CONFIG_FILE): + try: + with open(SERVER_CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + servers = data.get("servers", []) + if servers: + current = servers[0] + web = data.get("web", {}) or {} + web_port = int(web.get("port", web_port)) + except Exception: + pass + fields = [ + (key, label, ftype, _config_display_value(key, current.get(key, default)), help) + for key, label, ftype, default, help in SERVER_PARAM_FIELDS + ] + web_fields = [ + (key, label, ftype, web_port, help) + for key, label, ftype, default, help in WEB_FIELDS + ] + return render_template("server_config.html", fields=fields, web_fields=web_fields) + @app.get("/api/status") def api_status(): return jsonify( @@ -363,6 +427,7 @@ def api_status(): "running": self.server is not None and self.server.running, "server_info": self._server_info_payload() if self.server is not None else None, "clients": self._client_list(), + "pid": os.getpid(), } ) @@ -376,6 +441,21 @@ def api_save_config(): with open(SERVER_CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(config, f, indent=4, ensure_ascii=False) self.web_port = web_port + if self.server is not None and web_port == self._bound_port: + # Same web port: restart the TCP server in place; the Flask + # app stays up, so there is no dead window. + self._stop_server() + try: + self._start_server(params) + except Exception as e: + traceback.print_exc() + return jsonify({"ok": False, "error": f"failed to start TCP server: {e}"}), 500 + return jsonify({"ok": True, "server_info": self._server_info_payload()}) + if self.server is not None: + # Web port changed: the Flask app cannot rebind, so restart + # the whole process for the new port to take effect. + threading.Thread(target=self._restart, daemon=True).start() + return jsonify({"ok": True, "restarting": True}) try: self._start_server(params) except Exception as e: diff --git a/PyFlow/transfer_web/web_backend/templates/server_config.html b/PyFlow/transfer_web/web_backend/templates/server_config.html index 0014d77..2798e11 100644 --- a/PyFlow/transfer_web/web_backend/templates/server_config.html +++ b/PyFlow/transfer_web/web_backend/templates/server_config.html @@ -48,11 +48,37 @@

PyFlow TCP Server Setup

const status = document.getElementById("status"); const saveBtn = document.getElementById("save-btn"); + async function currentPid() { + try { + const r = await fetch("/api/status"); + return (await r.json()).pid; + } catch (e) { + return null; + } + } + + async function waitForRestart(oldPid) { + // Poll until a new process (different pid) answers, then navigate. + for (let i = 0; i < 40; i++) { + try { + const r = await fetch("/api/status"); + const d = await r.json(); + if (d.pid && d.pid !== oldPid) { + location.href = "/"; + return; + } + } catch (e) { /* old process gone or not up yet */ } + await new Promise((res) => setTimeout(res, 500)); + } + location.href = "/"; + } + form.addEventListener("submit", async (e) => { e.preventDefault(); saveBtn.disabled = true; status.className = "status-line"; status.textContent = "Saving configuration and starting the TCP server..."; + const oldPid = await currentPid(); const params = {}; let webPort = 5000; form.querySelectorAll("[data-key]").forEach((el) => { @@ -85,9 +111,15 @@

PyFlow TCP Server Setup

saveBtn.disabled = false; return; } + if (data.restarting) { + status.className = "status-line ok"; + status.textContent = "Configuration saved, restarting..."; + await waitForRestart(oldPid); + return; + } status.className = "status-line ok"; status.textContent = "TCP server started at " + data.server_info.host + ":" + data.server_info.port; - setTimeout(() => { location.reload(); }, 1200); + setTimeout(() => { location.href = "/"; }, 1200); } catch (err) { status.className = "status-line err"; status.textContent = "Failed: " + err.message; diff --git a/PyFlow/transfer_web/web_backend/templates/server_status.html b/PyFlow/transfer_web/web_backend/templates/server_status.html index 8f55771..08b3491 100644 --- a/PyFlow/transfer_web/web_backend/templates/server_status.html +++ b/PyFlow/transfer_web/web_backend/templates/server_status.html @@ -15,6 +15,7 @@

PyFlow TCP Server

diff --git a/PyFlow/transfer_web/web_front/client_backend.py b/PyFlow/transfer_web/web_front/client_backend.py index 3a26b67..7e435f1 100644 --- a/PyFlow/transfer_web/web_front/client_backend.py +++ b/PyFlow/transfer_web/web_front/client_backend.py @@ -28,6 +28,7 @@ import os import shlex import socket +import subprocess import sys import threading import time @@ -45,6 +46,7 @@ FLOW_WEB_DIR = os.path.join(WEB_ROOT, ".Flow_Web") CLIENT_EXTENSIONS_UI_FILE = os.path.join(FLOW_WEB_DIR, "client_extensions_ui.json") CLIENT_LAST_SERVER_FILE = os.path.join(FLOW_WEB_DIR, "client_last_server.json") +CLIENT_CONFIG_FILE = os.path.join(FLOW_WEB_DIR, "setup_client.json") UPLOAD_DIR = os.path.join(FLOW_WEB_DIR, "uploads") TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") STATIC_DIR = os.path.join(WEB_ROOT, "static") @@ -52,6 +54,43 @@ DEFAULT_CLIENT_WEB_PORT = 5001 DEFAULT_SERVER_WEB_PORT = 5000 +# Ordered (key, label, type, default, help) for every TCP_Client_Base +# parameter shown in the startup-configuration UI. +CLIENT_PARAM_FIELDS = [ + ("host", "Server host", "text", "", "TCP server IP/host the client connects to."), + ("port", "Server port", "number", 65432, "TCP port of the server."), + ("client_host", "Client host", "text", "127.0.0.1", "Local address the client binds to."), + ("client_port", "Client port", "number", "", "Local port (empty = auto-allocated)."), + ("timeout", "Timeout (s)", "number", "", "Connection timeout in seconds (empty = none)."), + ("port_add_step", "Port add step", "number", 1, "Step size for port allocation."), + ("max_thread_num", "Max threads", "number", 10, "Maximum concurrent transfer threads."), + ( + "is_input_command_in_console", + "Console input", + "bool", + False, + "Forced False by the web architecture (the web UI is the input).", + ), + ( + "is_wait_server", + "Wait for server", + "bool", + True, + "Wait for the server to be reachable before starting.", + ), + ("max_custom_workers", "Max custom workers", "number", 10, "Maximum custom-command worker threads."), + ( + "is_extend_command", + "Extend command", + "bool", + True, + "Forced True by the web architecture (extensions are registered before start).", + ), + ("is_enable_encrypto", "Enable encryption", "bool", True, "RSA-encrypt the TCP channel."), + ("is_custom_keys", "Custom keys", "text", "", "Optional [pub_key_path, pvt_key_path] pair."), + ("max_mem_buff", "Max memory buffer (MB)", "number", 2048, "In-memory transfer buffer in MB."), +] + def _find_free_port(base): port = base @@ -90,6 +129,15 @@ def _load_json_list(path): return [] +def _config_display_value(key, value): + """Render a saved config value for the config form input.""" + if value is None: + return "" + if key == "is_custom_keys" and isinstance(value, list): + return json.dumps(value) + return value + + class ClientWebApp: """Flask app + TCP_Client_Base wrapper for the web tool.""" @@ -177,7 +225,20 @@ def _forward_folder(self, path, addr, destination=None): def _restart(self): time.sleep(1) - os.execv(sys.executable, [sys.executable] + sys.argv) + # Spawn a fresh process and exit: ``os.execv`` would keep the Flask + # dev-server socket (no FD_CLOEXEC) alive and strand the old web port. + try: + subprocess.Popen( + [sys.executable] + sys.argv, + close_fds=True, + start_new_session=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: + traceback.print_exc() + os._exit(0) def _on_clients_update(self, sock, addr, cmd): """Server broadcast: refresh the sidebar instance list.""" @@ -265,22 +326,55 @@ def _on_incoming_file(self, full_path, name, size, command): # ---------------------------------------------------------------- connect def _start_client(self, host, port, is_enable_encrypto): - self.client = TCP_Client_Base( - host=host, - port=port, - client_host="127.0.0.1", - client_port=None, - timeout=None, - port_add_step=1, - max_thread_num=10, - is_input_command_in_console=False, - is_wait_server=True, - max_custom_workers=10, - is_extend_command=True, - is_enable_encrypto=is_enable_encrypto, - is_custom_keys=None, - max_mem_buff=2048, - ) + params = self._load_client_params() + params["host"] = host + params["port"] = port + params["is_enable_encrypto"] = is_enable_encrypto + self._start_client_from_params(params) + + def _load_client_params(self): + """Return the saved client startup params (``setup_client.json``), if any.""" + if os.path.exists(CLIENT_CONFIG_FILE): + try: + with open(CLIENT_CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except Exception: + return {} + return {} + + def start_from_config(self): + """Read ``.Flow_Web/setup_client.json`` and start the TCP client.""" + params = self._load_client_params() + if not params: + return + self._start_client_from_params(params) + + def _normalize_client_params(self, params): + """Normalize form values into TCP_Client_Base constructor arguments.""" + params = dict(params) + # Web architecture constraints: extensions must be registered + # before start, and the web UI replaces the console input. + params["is_extend_command"] = True + params["is_input_command_in_console"] = False + if params.get("client_port") in (None, ""): + params["client_port"] = None + if params.get("timeout") in (None, ""): + params["timeout"] = None + if params.get("is_custom_keys") in (None, ""): + params["is_custom_keys"] = None + elif isinstance(params["is_custom_keys"], str): + try: + parsed = json.loads(params["is_custom_keys"]) + params["is_custom_keys"] = parsed if isinstance(parsed, list) else None + except Exception: + params["is_custom_keys"] = None + return params + + def _start_client_from_params(self, params): + """Create, register and start the TCP_Client_Base instance.""" + params = self._normalize_client_params(params) + self.client = TCP_Client_Base(**params) forward_extension_tcp.setup_client_commands(self.client) self.client.register_command( "/web_clients_update", self._on_clients_update, where_to_run="server", run_in_thread=True @@ -294,9 +388,9 @@ def _start_client(self, host, port, is_enable_encrypto): threading.Thread(target=self.client.start_TCP_client, daemon=True).start() self.connected = True self.server_info = { - "host": host, - "port": port, - "is_enable_encrypto": is_enable_encrypto, + "host": params["host"], + "port": params["port"], + "is_enable_encrypto": params.get("is_enable_encrypto", True), } with self._clients_lock: self._clients = [] @@ -322,6 +416,33 @@ def index(): last = "" return render_template("client_connect.html", last_address=last) + @app.get("/config") + def config(): + """Startup-configuration page, reachable from the main page too.""" + current = self._load_client_params() + if not current and self.client is not None: + c = self.client + current = { + "host": c.host, + "port": c.port, + "client_host": c.client_host, + "client_port": c.client_port, + "timeout": c.timeout, + "port_add_step": c.port_add_step, + "max_thread_num": c.max_thread_num, + "is_input_command_in_console": c.is_input_command_in_console, + "is_wait_server": c.is_wait_server, + "is_extend_command": c.is_extend_command, + "is_enable_encrypto": c.is_enable_encrypto, + "is_custom_keys": c.is_custom_keys, + "max_mem_buff": c.max_mem_buff // (1024 * 1024), + } + fields = [ + (key, label, ftype, _config_display_value(key, current.get(key, default)), help) + for key, label, ftype, default, help in CLIENT_PARAM_FIELDS + ] + return render_template("client_config.html", fields=fields) + @app.post("/api/connect") def api_connect(): data = request.get_json(force=True) @@ -356,6 +477,32 @@ def api_connect(): return jsonify({"ok": False, "error": f"failed to start TCP client: {e}"}), 500 return jsonify({"ok": True, "server_info": self.server_info}) + @app.post("/api/save_config") + def api_save_config(): + data = request.get_json(force=True) + params = data.get("params", {}) + # Validate the params by constructing the client class before saving. + try: + TCP_Client_Base(**self._normalize_client_params(params)) + except Exception as e: + return jsonify({"ok": False, "error": f"invalid configuration: {e}"}), 400 + os.makedirs(FLOW_WEB_DIR, exist_ok=True) + with open(CLIENT_CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(params, f, indent=4, ensure_ascii=False) + # Restart the TCP client in place; the Flask app stays up, so + # there is no dead window and no dependency on process spawning. + if self.client is not None: + try: + self.client.close() + except Exception: + traceback.print_exc() + try: + self._start_client_from_params(params) + except Exception as e: + traceback.print_exc() + return jsonify({"ok": False, "error": f"failed to start TCP client: {e}"}), 500 + return jsonify({"ok": True, "server_info": self.server_info}) + @app.get("/api/status") def api_status(): return jsonify( @@ -366,6 +513,7 @@ def api_status(): "server_info": self.server_info, "clients": self._clients_snapshot(), "own_address": self._own_address(), + "pid": os.getpid(), } ) diff --git a/PyFlow/transfer_web/web_front/templates/client_config.html b/PyFlow/transfer_web/web_front/templates/client_config.html new file mode 100644 index 0000000..5483a9b --- /dev/null +++ b/PyFlow/transfer_web/web_front/templates/client_config.html @@ -0,0 +1,118 @@ + + + + + + PyFlow TCP Client Setup + + + +
+
+

PyFlow TCP Client Setup

+

Configure the TCP client. Every parameter has a default value; change only what you need.

+
+ {% for key, label, ftype, default, help in fields %} +
+ + {% if ftype == "bool" %} +
+ + {{ help }} +
+ {% else %} + +
{{ help }}
+ {% endif %} +
+ {% endfor %} +
+ + +
+
+
+
+ + + diff --git a/PyFlow/transfer_web/web_front/templates/client_main.html b/PyFlow/transfer_web/web_front/templates/client_main.html index 7d7a230..68a40a1 100644 --- a/PyFlow/transfer_web/web_front/templates/client_main.html +++ b/PyFlow/transfer_web/web_front/templates/client_main.html @@ -15,6 +15,7 @@

PyFlow TCP Client

From d7cf822351fbefe219c2e5c5ba9e00a5bc5c72c7 Mon Sep 17 00:00:00 2001 From: F18-Ray Date: Thu, 10 Sep 2026 20:31:53 +0800 Subject: [PATCH 11/11] core-code: fix the warning and traceback errors in tests but which not influence the result of the tests --- PyFlow/network_api/connect_tcp.py | 121 ++++++++++++++---------------- 1 file changed, 56 insertions(+), 65 deletions(-) diff --git a/PyFlow/network_api/connect_tcp.py b/PyFlow/network_api/connect_tcp.py index 1da0862..49a5c2f 100644 --- a/PyFlow/network_api/connect_tcp.py +++ b/PyFlow/network_api/connect_tcp.py @@ -1212,7 +1212,14 @@ def handle_client(self, client_socket, client_address): # deal with each client print(f"error while welcoming client {client_id} : {e}") return # announce our encryption mode; a mismatched peer is disconnected in handle_command - self._send_raw(client_socket, f"/crypto_mode {1 if self.is_enable_encrypto else 0}") + try: + self._send_raw(client_socket, f"/crypto_mode {1 if self.is_enable_encrypto else 0}") + except Exception as e: + # the peer vanished right after the welcome: the finally block + # below cleans up; never let this escape the thread + if not _is_closed_socket_error(e): + print(f"error while announcing crypto mode to {client_id} : {e}") + return if self.is_hand_alloc_port == True: broadcast_clients_port_alloc_range_msg = "/client_alloc_port_range {}".format( self.each_client_port_range @@ -1678,9 +1685,8 @@ def file_transfer_client_recv(client_id): if not chunk: try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() raise ConnectionError( "ErrorWhileReceivingFileNameLength: client disconnected" @@ -1700,9 +1706,8 @@ def file_transfer_client_recv(client_id): if not chunk: try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() raise ConnectionError("ErrorWhileReceivingFileName: client disconnected") file_name_encoded += chunk @@ -1720,9 +1725,8 @@ def file_transfer_client_recv(client_id): if not chunk: try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() raise ConnectionError("ErrorWhileReceivingFileSize: client disconnected") size_bytes += chunk @@ -1796,9 +1800,8 @@ def file_transfer_client_recv(client_id): pass try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() print(f"ErrorWhileReceiveFile: {e}") return False @@ -2170,9 +2173,8 @@ def receive_file_transfer_messages(): print("\nbreak the file transfer connection from server") try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() break file_receive_data_from_server = data.decode("utf-8").strip() @@ -2182,12 +2184,12 @@ def receive_file_transfer_messages(): break except Exception as e: print(f"\nget file transfer msg error: {e}") - traceback.print_exc() + if not _is_closed_socket_error(e): + traceback.print_exc() try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() break @@ -2210,9 +2212,8 @@ def receive_file_transfer_messages(): if waiting_time >= 10: try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected print( f"ErrorWhileSendFile: \ Wait file transfer function start sign timeout, \ @@ -2254,9 +2255,8 @@ def receive_file_transfer_messages(): if waiting_time >= timeout: try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() print( f"ErrorWhileSendFileData: \ @@ -2271,19 +2271,18 @@ def receive_file_transfer_messages(): traceback.print_exc() try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() print(f"file {filename} not exist") return False except Exception as e: - traceback.print_exc() + if not _is_closed_socket_error(e): + traceback.print_exc() try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() print(f"send error: {e}") return False @@ -4381,9 +4380,8 @@ def receive_file_transfer_messages(): print("\nbreak the file transfer connection from server") try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() break file_receive_data_from_server = data.decode("utf-8").strip() @@ -4393,12 +4391,12 @@ def receive_file_transfer_messages(): break except Exception as e: print(f"\nget file transfer msg error: {e}") - traceback.print_exc() + if not _is_closed_socket_error(e): + traceback.print_exc() try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() break @@ -4421,9 +4419,8 @@ def receive_file_transfer_messages(): if waiting_time >= 10: try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected print( f"ErrorWhileSendFile: \ Wait file transfer function start sign timeout, \ @@ -4465,9 +4462,8 @@ def receive_file_transfer_messages(): if waiting_time >= timeout: try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() print( f"ErrorWhileSendFileData: \ @@ -4482,19 +4478,18 @@ def receive_file_transfer_messages(): traceback.print_exc() try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() print(f"file {filename} not exist") return False except Exception as e: - traceback.print_exc() + if not _is_closed_socket_error(e): + traceback.print_exc() try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() print(f"send error: {e}") return False @@ -4739,9 +4734,8 @@ def file_transfer_client_recv(client_id): if not chunk: try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() raise ConnectionError( "ErrorWhileReceivingFileNameLength: client disconnected" @@ -4761,9 +4755,8 @@ def file_transfer_client_recv(client_id): if not chunk: try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() raise ConnectionError("ErrorWhileReceivingFileName: client disconnected") file_name_encoded += chunk @@ -4781,9 +4774,8 @@ def file_transfer_client_recv(client_id): if not chunk: try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() raise ConnectionError("ErrorWhileReceivingFileSize: client disconnected") size_bytes += chunk @@ -4847,9 +4839,8 @@ def file_transfer_client_recv(client_id): pass try: self.send_message(client_file_socket, self.error_sign) - except: - traceback.print_exc() - pass + except Exception: + pass # send_message already logged real errors; a dead peer is expected close_socket() print(f"ErrorWhileReceiveFile: {e}") return False