From 9de9186977913ec9e1c73d41185f559cc2cd286b Mon Sep 17 00:00:00 2001 From: hitmant Date: Fri, 28 Feb 2025 15:11:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 60 +++ client/NotifyWin.spec | 45 +++ client/build.bat | 58 +++ client/build.py | 36 ++ client/config.yml | 16 + client/create_icon.py | 13 + client/icons/notify.ico | Bin 0 -> 2149 bytes client/notification_client.py | 693 ++++++++++++++++++++++++++++++++++ client/requirements-dev.txt | 8 + server/.dockerignore | 21 ++ server/Dockerfile | 30 ++ server/config.yml | 12 + server/docker-compose.yml | 13 + server/registered_keys.json | 7 + server/requirements.txt | 3 + server/server.py | 274 ++++++++++++++ 16 files changed, 1289 insertions(+) create mode 100644 .gitignore create mode 100644 client/NotifyWin.spec create mode 100644 client/build.bat create mode 100644 client/build.py create mode 100644 client/config.yml create mode 100644 client/create_icon.py create mode 100644 client/icons/notify.ico create mode 100644 client/notification_client.py create mode 100644 client/requirements-dev.txt create mode 100644 server/.dockerignore create mode 100644 server/Dockerfile create mode 100644 server/config.yml create mode 100644 server/docker-compose.yml create mode 100644 server/registered_keys.json create mode 100644 server/requirements.txt create mode 100644 server/server.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23ac501 --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# 虚拟环境 +venv/ +ENV/ +env/ +wowfish/ + +# 日志文件 +*.log +*.log.* +notification_client.log* + +# 客户端特定文件 +client_key.txt +notification_history.json + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +.project +.pydevproject +.settings/ + +# 操作系统 +.DS_Store +Thumbs.db +desktop.ini + +# 配置文件备份 +*.yml.bak +*.yaml.bak + +# 临时文件 +*.tmp +*.temp +*.bak \ No newline at end of file diff --git a/client/NotifyWin.spec b/client/NotifyWin.spec new file mode 100644 index 0000000..d7523db --- /dev/null +++ b/client/NotifyWin.spec @@ -0,0 +1,45 @@ +# -*- mode: python ; coding: utf-8 -*- + + +block_cipher = None + + +a = Analysis( + ['notification_client.py'], + pathex=[], + binaries=[], + datas=[('config.yml', '.')], + hiddenimports=['websockets', 'winotify', 'yaml', 'PIL', 'pystray', 'asyncio', 'logging', 'logging.handlers', 'pyperclip'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='NotifyWin', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['icons\\notify.ico'], +) diff --git a/client/build.bat b/client/build.bat new file mode 100644 index 0000000..312651c --- /dev/null +++ b/client/build.bat @@ -0,0 +1,58 @@ +@echo off +echo ʼ NotifyWin... + +:: ͼĿ¼ +if not exist icons mkdir icons + +:: ͼ +echo ͼ... +python create_icon.py + +:: ɵĹļ +if exist build rmdir /s /q build +if exist dist rmdir /s /q dist + +:: ʱɾ typing +echo ɾ typing ... +pip uninstall -y typing + +:: +echo ʼ... +pyinstaller ^ + --name=NotifyWin ^ + --noconsole ^ + --onefile ^ + --icon=icons/notify.ico ^ + --add-data="config.yml;." ^ + --hidden-import=websockets ^ + --hidden-import=winotify ^ + --hidden-import=yaml ^ + --hidden-import=PIL ^ + --hidden-import=pystray ^ + --hidden-import=asyncio ^ + --hidden-import=logging ^ + --hidden-import=logging.handlers ^ + --hidden-import=pyperclip ^ + notification_client.py + +:: ƱҪļ +echo ļ... +if not exist dist mkdir dist +copy config.yml dist\config.yml + +:: ͼĿ¼ +if exist icons ( + echo ͼ... + xcopy /s /i icons dist\icons +) + +:: Ŀ¼ +if exist sounds ( + echo ... + xcopy /s /i sounds dist\sounds +) + +echo ɣ +echo ɵļ dist Ŀ¼ + +pause \ No newline at end of file diff --git a/client/build.py b/client/build.py new file mode 100644 index 0000000..dd8bfcd --- /dev/null +++ b/client/build.py @@ -0,0 +1,36 @@ +import PyInstaller.__main__ +import os +import shutil + +# 清理旧的构建文件 +if os.path.exists('build'): + shutil.rmtree('build') +if os.path.exists('dist'): + shutil.rmtree('dist') + +# 打包配置 +PyInstaller.__main__.run([ + 'notification_client.py', # 主程序 + '--name=NotifyWin', # 生成的exe名称 + '--noconsole', # 不显示控制台 + '--onefile', # 打包成单个文件 + '--icon=icons/notify.ico', # 图标文件 + '--add-data=config.yml;.', # 添加配置文件 + '--hidden-import=websockets', + '--hidden-import=winotify', + '--hidden-import=yaml', + '--hidden-import=PIL', + '--hidden-import=pystray', + '--hidden-import=typing_extensions', + '--hidden-import=asyncio', + '--hidden-import=logging', + '--hidden-import=logging.handlers', + '--hidden-import=pyperclip', +]) + +# 复制必要的文件到dist目录 +shutil.copy('config.yml', 'dist/config.yml') +if os.path.exists('icons'): + shutil.copytree('icons', 'dist/icons') +if os.path.exists('sounds'): + shutil.copytree('sounds', 'dist/sounds') \ No newline at end of file diff --git a/client/config.yml b/client/config.yml new file mode 100644 index 0000000..4d49f7a --- /dev/null +++ b/client/config.yml @@ -0,0 +1,16 @@ +client: + app_id: Windows App + device_name: 我的电脑 + icon_path: null +notification: + default_duration: short + mode: system # 可选值: system/custom + duration: + long: 10 + short: 3 + sound: default +security: + use_ssl: true +server: + host: notify.xj.rs + port: 443 diff --git a/client/create_icon.py b/client/create_icon.py new file mode 100644 index 0000000..68f0f09 --- /dev/null +++ b/client/create_icon.py @@ -0,0 +1,13 @@ +from PIL import Image + +# 创建一个渐变色图标 +icon = Image.new('RGBA', (256, 256)) +for y in range(256): + for x in range(256): + r = int(x/256 * 255) + g = int(y/256 * 255) + b = 255 + icon.putpixel((x, y), (r, g, b, 255)) + +# 保存为 ICO 文件 +icon.save('icons/notify.ico') \ No newline at end of file diff --git a/client/icons/notify.ico b/client/icons/notify.ico new file mode 100644 index 0000000000000000000000000000000000000000..eeb383c4a214a34916e45a32e36d89508ad297c7 GIT binary patch literal 2149 zcmZQzU}Rut5D;Jh0tJQwAXx^)5)u%86_9!c#0m-!em#&%U}Ru0Fo5tk0jc{yzJmjV ze-%hgWMW`wXn^n+0p-$|L2M9EV2A~>IanDOIs^RNdAX#xfP%c99xg#Z8sq{24mKbu z|M`C+kTUmlaSW-Llbn+N|Ns2?jg5{IfVjbfC;0sN0|yQqI&j`WFd(^&q&K2G*%(A=j>kVVomGjs3Zx4-<(e0?J?g8~opL3ah!H}(c0YaF_2RIc0Y zJU!#{90oH1hq_a*qgj@2zj3wU6KhZd10xd)hk$|uh;fT~*Z(&0$#+=_x>=t#6R;i> zbp~MT!nAyK9Z(QB;ui4#e{ywZ`~~L%kA%qnwzCqC=S)pek9>dU!@qB5 zPF+2d^Nahx#*PDDnbeL1DI_@Qu#&}yYk0f+lQd5d@ZkZMVOMk+me{;U-y0AmVW!^iJ$ks-zzJ7e|fI$*OqOY zgZ(pr>CeG-AurI3uPtUk@`~^+AQ?cp#6Q0JfA2pWk3YbFSdHb&K zuRY58ZBxp_Z2U0vN8Ne|VQ?w$Dfq_{C5km(I7!DgOfAL>`d3uQeXT|p=oA2(< qJ$|OBnk{9ob$vX;KP3hMwNW%00wXL0zA+x', lambda e: self.close()) + self.close_button.bind('', + lambda e: self.close_button.configure(fg='#ff0000')) + self.close_button.bind('', + lambda e: self.close_button.configure(fg='#666666')) + + # 拖动事件 + self.title_frame.bind('', self.start_move) + self.title_frame.bind('', self.on_move) + self.title_label.bind('', self.start_move) + self.title_label.bind('', self.on_move) + + # 窗口事件 + self.window.bind('', lambda e: self.close()) + + def start_move(self, event): + """开始拖动窗口""" + self._drag_start_x = event.x_root - self.window.winfo_x() + self._drag_start_y = event.y_root - self.window.winfo_y() + + def on_move(self, event): + """处理窗口拖动""" + x = event.x_root - self._drag_start_x + y = event.y_root - self._drag_start_y + self.window.geometry(f'+{x}+{y}') + # 更新客户端中记录的位置 + if self.client: + self.client.last_notification_pos = (x, y) + + def close(self, event=None): + """关闭窗口""" + try: + if self.window: + # 保存最后的位置 + if self.client: + self.client.last_notification_pos = ( + self.window.winfo_x(), + self.window.winfo_y() + ) + # 从通知窗口列表中移除自己 + if self in self.client.notification_windows: + self.client.notification_windows.remove(self) + # 重新排列其他通知窗口 + self.client.rearrange_notifications() + + self.window.destroy() + self.window = None + except Exception as e: + print(f"关闭窗口时出错: {e}") + +class NotificationClient: + def __init__(self, config_path='config.yml'): + self.logger = logger # 先初始化logger + self.config_path = config_path + self.config_mtime = 0 + self.load_config() + self.running = True + self.key_file = 'client_key.txt' + self.key = self.load_key() + self.connected = False + self.status_text = "未连接" # 添加状态文本变量 + self.status_item = None + self.history_file = 'notification_history.json' + self.history = deque(maxlen=100) # 最多保存100条记录 + self.load_history() + self.notification_filters = [] + self.notification_groups = defaultdict(list) + self.notification_stats = { + 'total': 0, + 'by_type': Counter(), + 'by_hour': Counter(), + 'filtered': 0 + } + self.notification_mode = 'system' # 默认使用系统通知 + self.notification_windows = [] # 存储当前显示的通知窗口 + self.last_notification_pos = None # 记录最后一个通知的位置 + self.notification_spacing = 10 # 通知窗口之间的间距 + + def load_config(self): + """加载配置文件""" + try: + if os.path.exists(self.config_path): + mtime = os.path.getmtime(self.config_path) + if mtime > self.config_mtime: + with open(self.config_path, 'r', encoding='utf-8') as f: + self.config = yaml.safe_load(f) + self.config_mtime = mtime + self.logger.info("配置已重新加载") + return True + else: + # 如果配置文件不存在,创建默认配置 + self.config = { + 'server': { + 'host': '127.0.0.1', + 'port': 8080 + }, + 'client': { + 'device_name': '我的设备', + 'app_id': 'Python.Notification' + }, + 'security': { + 'use_ssl': False + }, + 'notification': { + 'duration': { + 'info': "short", + 'warning': "long", + 'error': "long" + }, + 'sounds': { + 'enabled': True, + 'info': "default", + 'warning': "warning.wav", + 'error': "error.wav" + } + } + } + # 保存默认配置 + with open(self.config_path, 'w', encoding='utf-8') as f: + yaml.safe_dump(self.config, f, allow_unicode=True) + self.logger.info("已创建默认配置文件") + return True + except Exception as e: + self.logger.error(f"加载配置失败: {e}") + return False + + def load_key(self): + """从文件加载密钥""" + try: + if os.path.exists(self.key_file): + with open(self.key_file, 'r', encoding='utf-8') as f: + return f.read().strip() + except Exception as e: + self.logger.error(f"加载密钥失败: {e}") + return None + + def save_key(self, key): + """保存密钥到文件""" + try: + with open(self.key_file, 'w', encoding='utf-8') as f: + f.write(key) + except Exception as e: + self.logger.error(f"保存密钥失败: {e}") + + def load_history(self): + """加载通知历史""" + try: + if os.path.exists(self.history_file): + with open(self.history_file, 'r', encoding='utf-8') as f: + self.history = deque(json.load(f), maxlen=100) + except Exception as e: + self.logger.error(f"加载历史记录失败: {e}") + + def save_history(self): + """保存通知历史""" + try: + with open(self.history_file, 'w', encoding='utf-8') as f: + json.dump(list(self.history), f, ensure_ascii=False, indent=2) + except Exception as e: + self.logger.error(f"保存历史记录失败: {e}") + + def add_filter(self, filter_func): + """添加通知过滤器""" + self.notification_filters.append(filter_func) + + def should_show_notification(self, title, message, notification_type): + """检查是否应该显示通知""" + for filter_func in self.notification_filters: + if not filter_func(title, message, notification_type): + return False + return True + + def group_notification(self, title, message, notification_type): + """对通知进行分组""" + group_key = title # 可以根据需要修改分组规则 + self.notification_groups[group_key].append({ + 'message': message, + 'type': notification_type, + 'time': datetime.now() + }) + + # 如果同一组有多条消息,合并显示 + if len(self.notification_groups[group_key]) > 1: + messages = [n['message'] for n in self.notification_groups[group_key][-5:]] + return f"{message}\n\n还有 {len(messages)-1} 条相似消息" + return message + + def show_custom_notification(self, title, message, duration): + """在主线程中显示自定义通知""" + if hasattr(self, 'icon') and self.icon: + # 如果在主线程中,直接创建通知窗口 + CustomNotificationWindow(title, message, duration, + position=self.last_notification_pos, + client=self) + else: + # 如果不在主线程中,使用新线程创建通知窗口 + self.logger.info("在主线程中创建通知窗口") + threading.Thread(target=lambda: CustomNotificationWindow( + title, message, duration, + position=self.last_notification_pos, + client=self + ), daemon=True).start() + + def show_notification(self, title, message, notification_type='info'): + try: + self.logger.info(f"准备显示通知: {title} - {message} (类型: {notification_type})") + + # 更新统计和历史记录 + self.update_stats(notification_type) + self.history.append({ + 'timestamp': datetime.now().isoformat(), + 'title': title, + 'message': message, + 'type': notification_type + }) + self.save_history() + + if self.notification_mode == 'system': + # 使用系统通知 + duration_type = self.config['notification']['default_duration'] + duration = "long" if duration_type == "long" else "short" + + notification = Notification( + app_id="Windows App", + title=title, + msg=message, + duration=duration + ) + notification.show() + self.logger.info(f"系统通知已发送 (持续时间: {duration})") + else: + # 使用自定义通知框 + duration_ms = 10000 if self.config['notification']['default_duration'] == 'long' else 3000 + self.show_custom_notification(title, message, duration_ms) + self.logger.info("自定义通知已发送") + + except Exception as e: + self.logger.error(f"通知处理失败: {e}") + self.logger.error(f"错误类型: {type(e)}") + import traceback + self.logger.error(f"详细错误: {traceback.format_exc()}") + + async def connect_websocket(self): + retry_delays = [5, 10, 30, 60] # 重试间隔(秒) + retry_count = 0 + + while self.running: + try: + self.update_status("正在连接...", False) + ws_url = f"{'wss' if self.config['security']['use_ssl'] else 'ws'}://{self.config['server']['host']}:{self.config['server']['port']}/ws" + self.logger.info(f"正在连接到服务器: {ws_url}") + + async with websockets.connect(ws_url, ping_interval=20, ping_timeout=10) as websocket: + if self.key: + self.logger.info("使用已有密钥连接") + await websocket.send(json.dumps({ + 'action': 'connect', + 'key': self.key + })) + else: + self.logger.info("注册新客户端") + await websocket.send(json.dumps({ + 'action': 'register', + 'device_name': self.config['client']['device_name'] + })) + + response = await websocket.recv() + data = json.loads(response) + self.logger.info(f"收到服务器响应: {data}") + + if data.get('status') == 'success': + self.update_status("已连接", True) + if 'key' in data: + self.key = data['key'] + self.save_key(self.key) + self.logger.info(f"获取到新密钥: {self.key[:8]}...") + + self.show_notification( + "设备注册成功", + f"你的推送密钥: {self.key}\n请保管好此密钥" + ) + + self.logger.info("已连接到服务器,等待消息...") + retry_count = 0 # 连接成功后重置重试计数 + + # 处理消息 + while self.running: + try: + message = await websocket.recv() + if message == "ping": + await websocket.send("pong") + continue + elif message == "pong": + continue + + data = json.loads(message) + self.logger.info(f"收到新消息: {data}") + self.show_notification( + data["title"], + data["message"], + data.get("type", "info") + ) + except websockets.exceptions.ConnectionClosed: + break + else: + self.logger.error(f"连接失败: {data.get('message')}") + if self.key: + self.key = None + if os.path.exists(self.key_file): + os.remove(self.key_file) + + except Exception as e: + self.update_status(f"连接错误: {str(e)}", False) + + # 计算重试延迟 + delay = retry_delays[min(retry_count, len(retry_delays)-1)] + self.logger.info(f"{delay} 秒后重试连接...") + await asyncio.sleep(delay) + retry_count += 1 + + def update_status(self, status, connected=None): + """更新托盘图标状态""" + if connected is not None: + self.connected = connected + self.status_text = f"状态: {'已连接' if self.connected else '未连接'} - {status}" + # 重建菜单 + if hasattr(self, 'icon') and self.icon: + self.icon.menu = self.create_menu() + + def create_menu(self): + """创建托盘菜单""" + duration_menu = pystray.Menu( + pystray.MenuItem("短 (3秒)", + lambda: self.set_notification_duration("short")), + pystray.MenuItem("长 (10秒)", + lambda: self.set_notification_duration("long")) + ) + + notification_mode_text = "切换到自定义通知" if self.notification_mode == 'system' else "切换到系统通知" + + return pystray.Menu( + pystray.MenuItem(self.status_text, lambda: None, enabled=False), + pystray.MenuItem("复制密钥", self.copy_key_to_clipboard), + pystray.MenuItem("查看历史", self.show_history), + pystray.MenuItem("查看统计", self.show_stats), + pystray.MenuItem("通知持续时间", duration_menu), + pystray.MenuItem(notification_mode_text, self.toggle_notification_mode), + pystray.Menu.SEPARATOR, + pystray.MenuItem("重新连接", self.reconnect), + pystray.MenuItem("退出", self.exit_app) + ) + + def create_tray_icon(self): + try: + # 尝试加载自定义图标 + icon_path = self.config['client'].get('icon_path') + if icon_path and os.path.exists(icon_path): + image = Image.open(icon_path) + else: + # 创建默认图标(使用渐变色而不是纯白) + image = Image.new('RGB', (64, 64)) + for y in range(64): + for x in range(64): + image.putpixel((x, y), (x*4, y*4, 255)) + + self.icon = pystray.Icon( + "notification_client", + image, + "通知客户端", + self.create_menu() + ) + return self.icon + except Exception as e: + self.logger.error(f"创建托盘图标失败: {e}") + # 创建一个简单的白色图标作为后备 + image = Image.new('RGB', (64, 64), color='white') + menu = pystray.Menu(pystray.MenuItem("退出", self.exit_app)) + self.icon = pystray.Icon("notification_client", image, "通知客户端", menu) + return self.icon + + def copy_key_to_clipboard(self): + """复制密钥到剪贴板""" + if self.key: + import pyperclip + pyperclip.copy(self.key) + self.show_notification("复制成功", "密钥已复制到剪贴板") + + async def cleanup(self): + """清理资源""" + self.running = False + self.save_history() + # 等待其他任务完成 + await asyncio.sleep(1) + + def exit_app(self, icon): + """优雅退出""" + self.logger.info("正在关闭应用...") + # 运行清理 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(self.cleanup()) + # 停止图标 + icon.stop() + sys.exit(0) + + def reconnect(self): + """手动触发重连""" + if not self.connected: + self.logger.info("手动触发重连...") + # 创建新的事件循环来处理重连 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(self.connect_websocket()) + + async def config_watch(self): + """监控配置文件变化""" + while self.running: + if self.load_config(): + # 如果配置改变,可以在这里处理 + pass + await asyncio.sleep(5) # 每5秒检查一次 + + def show_history(self): + """显示通知历史""" + if self.history: + latest = list(self.history)[-5:] # 显示最近5条 + message = "\n".join( + f"{item['timestamp'][:19]} - {item['title']}: {item['message']}" + for item in latest + ) + self.show_notification("最近通知记录", message) + else: + self.show_notification("通知历史", "暂无通知记录") + + def update_stats(self, notification_type, shown=True): + """更新通知统计""" + if shown: + self.notification_stats['total'] += 1 + self.notification_stats['by_type'][notification_type] += 1 + hour = datetime.now().hour + self.notification_stats['by_hour'][hour] += 1 + else: + self.notification_stats['filtered'] += 1 + + def show_stats(self): + """显示通知统计""" + stats = ( + f"总通知数: {self.notification_stats['total']}\n" + f"已过滤: {self.notification_stats['filtered']}\n\n" + f"类型统计:\n" + + "\n".join(f"- {t}: {c}" for t, c in self.notification_stats['by_type'].most_common()) + ) + self.show_notification("通知统计", stats) + + def set_notification_duration(self, duration_type): + """设置通知持续时间""" + self.config['notification']['default_duration'] = duration_type + self.show_notification( + "设置已更新", + f"通知持续时间已设置为: {duration_type}" + ) + # 保存配置 + with open(self.config_path, 'w', encoding='utf-8') as f: + yaml.safe_dump(self.config, f, allow_unicode=True) + + def toggle_notification_mode(self): + """切换通知模式""" + self.notification_mode = 'system' if self.notification_mode == 'custom' else 'custom' + mode_text = "系统通知" if self.notification_mode == 'system' else "自定义通知" + self.show_notification("通知模式已更改", f"已切换为{mode_text}") + + def get_next_notification_y(self, window_height): + """计算下一个通知窗口的Y坐标""" + # 创建一个临时的 Tk 实例来获取屏幕高度 + root = tk.Tk() + root.withdraw() # 隐藏窗口 + screen_height = root.winfo_screenheight() + root.destroy() + + if not self.notification_windows: + # 如果没有其他通知,使用默认位置或最后记录的位置 + if self.last_notification_pos: + return self.last_notification_pos[1] + return screen_height - window_height - 40 + + # 获取最下方通知窗口的位置 + last_window = max(self.notification_windows, + key=lambda w: w.window.winfo_y()) + return last_window.window.winfo_y() + last_window.height + self.notification_spacing + + def rearrange_notifications(self): + """重新排列所有通知窗口""" + if not self.notification_windows: + return + + # 按Y坐标排序 + windows = sorted(self.notification_windows, + key=lambda w: w.window.winfo_y()) + + # 重新设置位置 + base_y = windows[0].window.winfo_y() + for i, win in enumerate(windows): + if i == 0: + continue # 保持第一个窗口位置不变 + new_y = base_y + sum(w.height + self.notification_spacing + for w in windows[:i]) + x = win.window.winfo_x() + win.window.geometry(f'+{x}+{new_y}') + + def run(self): + # 创建并运行系统托盘图标 + icon = self.create_tray_icon() + + # 创建事件循环 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + # 启动WebSocket客户端和配置监控 + loop.create_task(self.connect_websocket()) + loop.create_task(self.config_watch()) + + # 在新线程中运行事件循环 + import threading + threading.Thread(target=lambda: loop.run_forever(), daemon=True).start() + + # 运行系统托盘 + icon.run() + +def parse_args(): + parser = argparse.ArgumentParser(description='Windows 通知客户端') + parser.add_argument('--config', default='config.yml', help='配置文件路径') + parser.add_argument('--server', help='服务器地址 (例如: localhost:8080)') + parser.add_argument('--device', help='设备名称') + return parser.parse_args() + +if __name__ == "__main__": + args = parse_args() + + client = NotificationClient(config_path=args.config) + + # 命令行参数覆盖配置文件 + if args.server: + host, port = args.server.split(':') + client.config['server']['host'] = host + client.config['server']['port'] = int(port) + if args.device: + client.config['client']['device_name'] = args.device + + client.run() \ No newline at end of file diff --git a/client/requirements-dev.txt b/client/requirements-dev.txt new file mode 100644 index 0000000..1d555cc --- /dev/null +++ b/client/requirements-dev.txt @@ -0,0 +1,8 @@ +websockets==12.0 +winotify==1.1.0 +PyYAML==6.0.1 +Pillow==10.2.0 +pystray==0.19.5 +pyperclip==1.8.2 +pyinstaller==6.3.0 +typing-extensions>=4.0.0 \ No newline at end of file diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..e8ec4ca --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,21 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +env +pip-log.txt +pip-delete-this-directory.txt +.tox +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.pytest_cache +.env +.venv +.DS_Store +data/ \ No newline at end of file diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..a0511bb --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,30 @@ +# 使用多架构基础镜像 +FROM --platform=$TARGETPLATFORM python:3.9-slim + +# 设置环境变量 +ENV PYTHONIOENCODING=utf-8 +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 + +# 设置工作目录 +WORKDIR /app + +# 复制依赖文件 +COPY requirements.txt . + +# 安装依赖 +RUN pip install --no-cache-dir -r requirements.txt + +# 复制服务器代码和配置 +COPY server.py . +COPY config.yml . + +# 创建数据目录 +RUN mkdir -p /app/data + +# 暴露端口 +EXPOSE 8080 +EXPOSE 8081 + +# 设置启动命令 +CMD ["python", "server.py"] \ No newline at end of file diff --git a/server/config.yml b/server/config.yml new file mode 100644 index 0000000..a63863a --- /dev/null +++ b/server/config.yml @@ -0,0 +1,12 @@ +websocket: + host: "0.0.0.0" + port: 8080 + +security: + use_ssl: false + ssl_cert: "path/to/cert.pem" + ssl_key: "path/to/key.pem" + +# 数据存储路径 +data: + keys_file: "/app/data/registered_keys.json" \ No newline at end of file diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..6dcd599 --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3' + +services: + notify-server: + image: your-username/notify-server:latest # 替换为你的镜像名 + container_name: notify-server + ports: + - "31182:8080" # 统一使用一个端口 + volumes: + - ./data:/app/data # 数据持久化 + restart: unless-stopped + environment: + - TZ=Asia/Shanghai # 设置时区 \ No newline at end of file diff --git a/server/registered_keys.json b/server/registered_keys.json new file mode 100644 index 0000000..7320f1d --- /dev/null +++ b/server/registered_keys.json @@ -0,0 +1,7 @@ +{ + "oxvHH-PdW8tdT6lnigYw_YKRjwWzyl9tn3qhnTLGYXA": { + "device_name": "\u6211\u7684\u7535\u8111", + "created_at": "2025-02-21T10:52:59.871115", + "last_seen": "2025-02-21T10:52:59.872385" + } +} \ No newline at end of file diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..882b688 --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,3 @@ +websockets==12.0 +aiohttp==3.9.3 +PyYAML==6.0.1 \ No newline at end of file diff --git a/server/server.py b/server/server.py new file mode 100644 index 0000000..b5de592 --- /dev/null +++ b/server/server.py @@ -0,0 +1,274 @@ +import asyncio +import websockets +import json +import ssl +import secrets +import aiohttp +from aiohttp import web +import yaml +import logging +from datetime import datetime +from collections import defaultdict +import os + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +class NotificationServer: + def __init__(self, config_path='config.yml'): + try: + with open(config_path, 'r', encoding='utf-8') as f: + self.config = yaml.safe_load(f) + except FileNotFoundError: + # 如果配置文件不存在,创建默认配置 + self.config = { + 'websocket': { + 'host': '0.0.0.0', + 'port': 8080 + }, + 'security': { + 'use_ssl': False, + 'ssl_cert': 'path/to/cert.pem', + 'ssl_key': 'path/to/key.pem' + }, + 'data': { + 'keys_file': '/app/data/registered_keys.json' + } + } + # 保存默认配置 + with open(config_path, 'w', encoding='utf-8') as f: + yaml.safe_dump(self.config, f, allow_unicode=True) + + # 存储客户端连接 + self.client_connections = defaultdict(set) + # 从配置文件获取数据存储路径 + self.keys_file = self.config['data']['keys_file'] + self.registered_keys = {} + self.load_registered_keys() + + def load_registered_keys(self): + """从文件加载已注册的密钥""" + try: + if os.path.exists(self.keys_file): + with open(self.keys_file, 'r', encoding='utf-8') as f: + data = json.load(f) + self.registered_keys = data + logger.info(f"已加载 {len(self.registered_keys)} 个注册密钥") + except Exception as e: + logger.error(f"加载密钥文件失败: {e}") + + def save_registered_keys(self): + """保存密钥到文件""" + try: + with open(self.keys_file, 'w', encoding='utf-8') as f: + json.dump(self.registered_keys, f, indent=2) + except Exception as e: + logger.error(f"保存密钥文件失败: {e}") + + def generate_key(self): + """生成唯一的客户端密钥""" + return secrets.token_urlsafe(32) + + async def register_client(self, websocket): + """注册新客户端并生成密钥""" + try: + # 等待客户端发送注册请求 + msg = await websocket.recv() + data = json.loads(msg) + + if data.get('action') == 'register': + device_name = data.get('device_name', 'unknown') + key = self.generate_key() + + client_info = { + 'device_name': device_name, + 'created_at': datetime.now().isoformat(), + 'last_seen': datetime.now().isoformat() + } + + self.registered_keys[key] = client_info + self.client_connections[key].add(websocket) + + # 发送密钥给客户端 + await websocket.send(json.dumps({ + 'status': 'success', + 'key': key, + 'message': '注册成功' + })) + + logger.info(f"新客户端注册: {device_name}, key: {key[:8]}...") + self.save_registered_keys() # 保存新注册的密钥 + return key + + elif data.get('action') == 'connect': + key = data.get('key') + if key in self.registered_keys: + self.client_connections[key].add(websocket) + self.registered_keys[key]['last_seen'] = datetime.now().isoformat() + await websocket.send(json.dumps({ + 'status': 'success', + 'message': '连接成功' + })) + logger.info(f"客户端重连成功: {key[:8]}...") + return key + else: + await websocket.send(json.dumps({ + 'status': 'error', + 'message': '无效的密钥' + })) + return None + + except Exception as e: + logger.error(f"注册失败: {e}") + await websocket.close() + return None + + async def handle_websocket(self, websocket): + key = await self.register_client(websocket) + if not key: + return + + try: + # 添加心跳检测 + while True: + try: + message = await asyncio.wait_for(websocket.recv(), timeout=30) + if message == "ping": + await websocket.send("pong") + except asyncio.TimeoutError: + # 30秒没有收到消息,发送 ping + try: + await websocket.send("ping") + await asyncio.wait_for(websocket.recv(), timeout=10) + except: + # ping 失败,关闭连接 + break + except websockets.exceptions.ConnectionClosed: + break + finally: + self.client_connections[key].discard(websocket) + if not self.client_connections[key]: + logger.info(f"客户端完全断开: {key[:8]}...") + + async def send_notification(self, key, notification): + """向指定key的所有连接发送通知""" + if key not in self.client_connections: + return False + + connections = self.client_connections[key].copy() + if not connections: + return False + + for websocket in connections: + try: + await websocket.send(json.dumps(notification)) + except Exception as e: + logger.error(f"发送通知失败: {e}") + self.client_connections[key].discard(websocket) + + return True + + async def handle_http_push(self, request): + try: + # 从URL路径中获取key + key = request.match_info['key'] + + if key not in self.registered_keys: + return web.Response(status=404, text='Invalid key') + + data = await request.json() + if not data.get('title') or not data.get('message'): + return web.Response(status=400, text='Missing title or message') + + notification = { + 'title': data['title'], + 'message': data['message'], + 'type': data.get('type', 'info'), + 'timestamp': datetime.now().isoformat() + } + + success = await self.send_notification(key, notification) + if success: + return web.Response(text='Notification sent') + else: + return web.Response(status=404, text='No active connections') + + except Exception as e: + logger.error(f"处理HTTP请求失败: {e}") + return web.Response(status=500, text='Internal server error') + + async def start(self): + # 设置SSL + ssl_context = None + if self.config['security']['use_ssl']: + ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_context.load_cert_chain( + self.config['security']['ssl_cert'], + self.config['security']['ssl_key'] + ) + + # 创建 aiohttp 应用 + app = web.Application() + + # 添加 WebSocket 处理 + async def websocket_handler(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + + # 包装 aiohttp WebSocketResponse 为 websockets 兼容的接口 + class WebSocketWrapper: + async def send(self, message): + await ws.send_str(message) + + async def recv(self): + msg = await ws.receive() + if msg.type == web.WSMsgType.TEXT: + return msg.data + elif msg.type == web.WSMsgType.CLOSE: + raise websockets.exceptions.ConnectionClosed(None, None) + + async def close(self): + await ws.close() + + wrapped_ws = WebSocketWrapper() + await self.handle_websocket(wrapped_ws) + return ws + + # 注册路由 + app.router.add_get('/ws', websocket_handler) # WebSocket 端点 + app.router.add_post('/push/{key}', self.handle_http_push) # HTTP 推送端点 + + # 启动服务器 + runner = web.AppRunner(app) + await runner.setup() + + # 根据是否使用 SSL 创建不同的站点 + if ssl_context: + site = web.SSLSite( + runner, + self.config['websocket']['host'], + self.config['websocket']['port'], + ssl_context=ssl_context + ) + else: + site = web.TCPSite( + runner, + self.config['websocket']['host'], + self.config['websocket']['port'] + ) + + await site.start() + + logger.info(f"服务器运行在 {self.config['websocket']['host']}:{self.config['websocket']['port']}") + logger.info("WebSocket 路径: /ws") + logger.info("HTTP 推送路径: /push/{key}") + + await asyncio.Future() # 持续运行 + +if __name__ == "__main__": + server = NotificationServer() + asyncio.run(server.start()) \ No newline at end of file