初始版本

This commit is contained in:
hitmant 2025-02-28 15:11:02 +08:00
commit 9de9186977
16 changed files with 1289 additions and 0 deletions

60
.gitignore vendored Normal file
View File

@ -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

45
client/NotifyWin.spec Normal file
View File

@ -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'],
)

58
client/build.bat Normal file
View File

@ -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

36
client/build.py Normal file
View File

@ -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')

16
client/config.yml Normal file
View File

@ -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

13
client/create_icon.py Normal file
View File

@ -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')

BIN
client/icons/notify.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,693 @@
import asyncio
import websockets
import json
import pystray
from PIL import Image
import sys
import yaml
import logging
from datetime import datetime
import os
import argparse
import logging.handlers
from collections import deque, defaultdict, Counter
from winotify import Notification # 添加到文件开头的导入部分
import tkinter as tk
from tkinter import ttk
import threading
try:
from win10toast import ToastNotifier
USE_WIN10TOAST = True
toaster = ToastNotifier()
except ImportError:
USE_WIN10TOAST = False
# 设置日志
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
# 控制台处理器
console_handler = logging.StreamHandler()
console_handler.setFormatter(logging.Formatter(
'%(asctime)s - %(levelname)s - %(message)s'
))
logger.addHandler(console_handler)
# 文件处理器
file_handler = logging.handlers.RotatingFileHandler(
'notification_client.log',
maxBytes=1024*1024, # 1MB
backupCount=5,
encoding='utf-8'
)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s - %(levelname)s - %(message)s'
))
logger.addHandler(file_handler)
# 添加自定义通知框类
class CustomNotificationWindow:
def __init__(self, title, message, duration=3000, position=None, client=None):
# 保存客户端引用和位置信息
self.client = client
self.position = position
# 确保在主线程中运行 tkinter
if threading.current_thread() is not threading.main_thread():
return
self.window = tk.Tk()
self.window.withdraw() # 先隐藏窗口
# 设置窗口样式
self.window.overrideredirect(True)
self.window.attributes('-topmost', True)
self.window.configure(bg='#f0f0f0')
# 创建主框架
self.frame = tk.Frame(self.window, bg='#f0f0f0')
self.frame.pack(fill='both', expand=True)
# 创建标题栏框架
self.title_frame = tk.Frame(self.frame, bg='#f0f0f0', cursor='hand2')
self.title_frame.pack(fill='x', expand=True, padx=10, pady=(10,0))
# 标题
self.title_label = tk.Label(self.title_frame,
text=title,
font=('Arial', 10, 'bold'),
bg='#f0f0f0',
fg='#333333')
self.title_label.pack(side='left', anchor='w')
# 关闭按钮
self.close_button = tk.Label(self.title_frame,
text='×',
font=('Arial', 12, 'bold'),
bg='#f0f0f0',
fg='#666666',
cursor='hand2',
padx=5)
self.close_button.pack(side='right', anchor='e')
# 消息内容框架
self.message_frame = tk.Frame(self.frame, bg='#f0f0f0')
self.message_frame.pack(fill='both', expand=True, padx=10, pady=10)
# 消息内容
self.message_label = tk.Label(self.message_frame,
text=message,
wraplength=300,
justify='left',
bg='#f0f0f0',
fg='#666666')
self.message_label.pack(anchor='w')
# 获取屏幕尺寸
screen_width = self.window.winfo_screenwidth()
screen_height = self.window.winfo_screenheight()
# 设置窗口大小和位置
self.window.update_idletasks()
self.width = self.window.winfo_width()
self.height = self.window.winfo_height()
# 如果有指定位置就使用指定位置,否则使用默认位置
if self.position:
x, y = self.position
else:
x = screen_width - self.width - 20
y = screen_height - self.height - 40
# 调整Y坐标以避免重叠
if self.client:
y = self.client.get_next_notification_y(self.height)
self.window.geometry(f'+{x}+{y}')
# 将自己添加到客户端的通知窗口列表
if self.client:
self.client.notification_windows.append(self)
# 绑定事件
self._bind_events()
# 显示窗口
self.window.deiconify()
# 运行主循环
self.window.mainloop()
def _bind_events(self):
"""绑定所有事件"""
# 关闭按钮事件
self.close_button.bind('<Button-1>', lambda e: self.close())
self.close_button.bind('<Enter>',
lambda e: self.close_button.configure(fg='#ff0000'))
self.close_button.bind('<Leave>',
lambda e: self.close_button.configure(fg='#666666'))
# 拖动事件
self.title_frame.bind('<Button-1>', self.start_move)
self.title_frame.bind('<B1-Motion>', self.on_move)
self.title_label.bind('<Button-1>', self.start_move)
self.title_label.bind('<B1-Motion>', self.on_move)
# 窗口事件
self.window.bind('<Escape>', 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()

View File

@ -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

21
server/.dockerignore Normal file
View File

@ -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/

30
server/Dockerfile Normal file
View File

@ -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"]

12
server/config.yml Normal file
View File

@ -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"

13
server/docker-compose.yml Normal file
View File

@ -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 # 设置时区

View File

@ -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"
}
}

3
server/requirements.txt Normal file
View File

@ -0,0 +1,3 @@
websockets==12.0
aiohttp==3.9.3
PyYAML==6.0.1

274
server/server.py Normal file
View File

@ -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())