From f2af33b5652eb66f07597526b2ce39ebd7289540 Mon Sep 17 00:00:00 2001 From: dvsxt Date: Mon, 1 Jun 2026 17:39:33 +0800 Subject: [PATCH] =?UTF-8?q?new:=20=E6=96=B0=E5=BB=BA=E6=96=87=E4=BB=B6=20m?= =?UTF-8?q?ain=5Fpyqt5.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: dvsxt --- main_pyqt5.py | 2533 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2533 insertions(+) create mode 100644 main_pyqt5.py diff --git a/main_pyqt5.py b/main_pyqt5.py new file mode 100644 index 0000000..afb9bd2 --- /dev/null +++ b/main_pyqt5.py @@ -0,0 +1,2533 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +云笔记应用程序 +支持本地和云端文件管理,支持文本文件(.txt)和加密笔记(.bjb) +完整修复版 - 支持端到端加密,云端下载的单独密钥文件需用户提供.key文件 +""" + +import sys +import os +import json +import tempfile +import traceback +from datetime import datetime + +from PyQt5.QtWidgets import * +from PyQt5.QtCore import * +from PyQt5.QtGui import * +import requests + +# 导入加密模块 +from DVT_RFSA import * + +# ==================== 调试日志配置 ==================== +DEBUG = True # True=记录日志, False=不记录日志 + +def debug_log(msg, level="INFO"): + """调试日志函数""" + if DEBUG: + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + print(f"[{timestamp}] [{level}] {msg}") + sys.stdout.flush() + +def debug_error(msg): + """错误日志(带堆栈)""" + if DEBUG: + debug_log(msg, "ERROR") + traceback.print_exc() + +# ==================== 配置 ==================== +API_BASE_URL = "https://noteapi.dvssvc.site/api" +APP_NAME = "CloudNote" + +def get_app_dir(): + """获取程序所在目录""" + if getattr(sys, 'frozen', False): + return os.path.dirname(sys.executable) + else: + return os.path.dirname(os.path.abspath(__file__)) + +APP_DIR = get_app_dir() +USER_KEY_DIR = os.path.expanduser(f"~/.{APP_NAME.lower()}/keys") +APP_KEY_DIR = os.path.join(APP_DIR, ".keys") + +if DEBUG: + debug_log(f"程序目录: {APP_DIR}") + debug_log(f"用户密钥目录: {USER_KEY_DIR}") + debug_log(f"程序密钥目录: {APP_KEY_DIR}") + +KEY_FILES = { + 'rsa_private': "global_rsa_private.pem", + 'rsa_public': "global_rsa_public.pem", + 'x25519_private': "global_x25519_private.bin", + 'x25519_public': "global_x25519_public.bin" +} + +def get_key_path(key_name, prefer_user=True): + """获取密钥文件路径,优先使用用户目录""" + if prefer_user: + search_dirs = [USER_KEY_DIR, APP_KEY_DIR] + dir_names = ['user', 'app'] + else: + search_dirs = [APP_KEY_DIR, USER_KEY_DIR] + dir_names = ['app', 'user'] + + for dir_path, dir_type in zip(search_dirs, dir_names): + file_path = os.path.join(dir_path, key_name) + if os.path.exists(file_path): + if DEBUG: + debug_log(f"找到密钥 {key_name} 在 {dir_type} 目录: {file_path}") + return file_path, dir_type + + if DEBUG: + debug_log(f"密钥 {key_name} 未找到,将使用用户目录路径") + return os.path.join(USER_KEY_DIR, key_name), 'user' + +def ensure_directory(dir_path): + """确保目录存在""" + try: + os.makedirs(dir_path, exist_ok=True) + if DEBUG: + debug_log(f"目录已确保: {dir_path}") + return True + except Exception as e: + debug_error(f"创建目录失败 {dir_path}: {e}") + return False + +# ==================== 全局密钥管理(双路径) ==================== +def ensure_global_keys(): + """确保全局密钥对存在,支持双路径存储""" + if DEBUG: + debug_log("开始检查/生成全局密钥(双路径模式)...") + + ensure_directory(USER_KEY_DIR) + ensure_directory(APP_KEY_DIR) + + key_pairs = [ + ('rsa', KEY_FILES['rsa_private'], KEY_FILES['rsa_public'], generate_rsa_key, 4096), + ('x25519', KEY_FILES['x25519_private'], KEY_FILES['x25519_public'], generate_x25519_keys, None) + ] + + for key_type, priv_file, pub_file, gen_func, key_size in key_pairs: + user_priv = os.path.join(USER_KEY_DIR, priv_file) + user_pub = os.path.join(USER_KEY_DIR, pub_file) + user_exists = os.path.exists(user_priv) and os.path.exists(user_pub) + + app_priv = os.path.join(APP_KEY_DIR, priv_file) + app_pub = os.path.join(APP_KEY_DIR, pub_file) + app_exists = os.path.exists(app_priv) and os.path.exists(app_pub) + + if DEBUG: + debug_log(f"{key_type.upper()}密钥对 - 用户目录存在: {user_exists}, 程序目录存在: {app_exists}") + + if not user_exists: + if DEBUG: + debug_log(f"开始生成{key_type.upper()}密钥对到用户目录...") + try: + if key_size: + priv, pub = gen_func(key_size) + else: + priv, pub = gen_func() + + with open(user_priv, 'wb') as f: + f.write(priv) + with open(user_pub, 'wb') as f: + f.write(pub) + + try: + with open(app_priv, 'wb') as f: + f.write(priv) + with open(app_pub, 'wb') as f: + f.write(pub) + if DEBUG: + debug_log(f"{key_type.upper()}密钥备份到程序目录成功") + except Exception as e: + if DEBUG: + debug_log(f"备份到程序目录失败: {e}") + except Exception as e: + debug_error(f"{key_type.upper()}密钥生成失败: {e}") + raise + + elif user_exists and not app_exists: + try: + with open(user_priv, 'rb') as f: + priv = f.read() + with open(user_pub, 'rb') as f: + pub = f.read() + with open(app_priv, 'wb') as f: + f.write(priv) + with open(app_pub, 'wb') as f: + f.write(pub) + if DEBUG: + debug_log(f"{key_type.upper()}密钥备份到程序目录成功") + except Exception as e: + if DEBUG: + debug_log(f"备份失败: {e}") + + if DEBUG: + debug_log("全局密钥检查/生成完成") + +def get_global_rsa_private(): + """获取全局RSA私钥""" + key_path, _ = get_key_path(KEY_FILES['rsa_private']) + if not os.path.exists(key_path): + ensure_global_keys() + key_path, _ = get_key_path(KEY_FILES['rsa_private']) + with open(key_path, 'rb') as f: + return f.read() + +def get_global_rsa_public(): + """获取全局RSA公钥""" + key_path, _ = get_key_path(KEY_FILES['rsa_public']) + if not os.path.exists(key_path): + ensure_global_keys() + key_path, _ = get_key_path(KEY_FILES['rsa_public']) + with open(key_path, 'rb') as f: + return f.read() + +def get_global_x25519_private(): + """获取全局X25519私钥""" + key_path, _ = get_key_path(KEY_FILES['x25519_private']) + if not os.path.exists(key_path): + ensure_global_keys() + key_path, _ = get_key_path(KEY_FILES['x25519_private']) + with open(key_path, 'rb') as f: + return f.read() + +def get_global_x25519_public(): + """获取全局X25519公钥""" + key_path, _ = get_key_path(KEY_FILES['x25519_public']) + if not os.path.exists(key_path): + ensure_global_keys() + key_path, _ = get_key_path(KEY_FILES['x25519_public']) + with open(key_path, 'rb') as f: + return f.read() + +# ==================== 加密笔记处理 ==================== +class EncryptedNoteHandler: + """加密笔记处理器 - 核心加密解密逻辑""" + + @staticmethod + def check_file_has_password(file_path): + """检查加密文件是否有密码保护""" + try: + with open(file_path, 'rb') as f: + data = f.read() + clean_data = remove_security_info(data) + json_str = clean_data.decode('utf-8') + info = json.loads(json_str) + return info.get('has_password', False), info.get('mode', 'aes_rsa'), info.get('single_key', False) + except Exception as e: + debug_error(f"检查文件密码状态失败: {e}") + return False, 'unknown', False + + @staticmethod + def get_file_metadata(file_path): + """获取加密文件的元数据(不进行解密)""" + try: + with open(file_path, 'rb') as f: + data = f.read() + clean_data = remove_security_info(data) + json_str = clean_data.decode('utf-8') + info = json.loads(json_str) + return { + 'has_password': info.get('has_password', False), + 'mode': info.get('mode', 'aes_rsa'), + 'single_key': info.get('single_key', False), + 'algorithm': info.get('algorithm', 'unknown'), + 'timestamp': info.get('timestamp', 'unknown') + } + except Exception as e: + debug_error(f"获取文件元数据失败: {e}") + return None + + @staticmethod + def encrypt_content(content: str, algorithm: str, key_mode: str, password: str = None, + single_private=None, single_public=None, strict_password=True): + """ + 加密内容 + content: 要加密的文本内容 + algorithm: 'rsa' 或 'x25519' + key_mode: 'global' 或 'single' + password: 用户密码(可选) + single_private/single_public: 单独密钥对 + strict_password: 是否使用高强度密码要求 + """ + if DEBUG: + debug_log(f"开始加密 - 算法: {algorithm}, 密钥模式: {key_mode}, 密码保护: {password is not None}, 严格模式: {strict_password}") + debug_log(f"内容长度: {len(content)} 字符") + + data = content.encode('utf-8') + private_key = None + public_key = None + + try: + if algorithm == 'rsa': + if key_mode == 'global': + pub_pem = get_global_rsa_public() + if password: + encrypted = encrypt_text_aes_rsa_with_password(data, pub_pem, password, add_header=True, strict_password=strict_password) + else: + encrypted = encrypt_text_aes_rsa(data, pub_pem, add_header=True, strict_password=strict_password) + private_key = None + public_key = None + else: + if single_private is None or single_public is None: + private_key, public_key = generate_rsa_key(4096) + else: + private_key = single_private + public_key = single_public + + if password: + encrypted = encrypt_text_aes_rsa_with_password(data, public_key, password, add_header=True, strict_password=strict_password) + else: + encrypted = encrypt_text_aes_rsa(data, public_key, add_header=True, strict_password=strict_password) + + # 添加single_key标记 + json_str = remove_security_info(encrypted).decode('utf-8') + info = json.loads(json_str) + info['single_key'] = True + encrypted = add_security_info(json.dumps(info).encode('utf-8'), 'aes_rsa', bool(password)) + return encrypted, private_key, public_key + else: # x25519 + if key_mode == 'global': + pub_raw = get_global_x25519_public() + if password: + encrypted = encrypt_text_aes_x25519(data, pub_raw, password, add_header=True, strict_password=strict_password) + else: + encrypted = encrypt_text_aes_x25519(data, pub_raw, add_header=True, strict_password=strict_password) + private_key = None + public_key = None + else: + if single_private is None or single_public is None: + private_key, public_key = generate_x25519_keys() + else: + private_key = single_private + public_key = single_public + + if password: + encrypted = encrypt_text_aes_x25519(data, public_key, password, add_header=True, strict_password=strict_password) + else: + encrypted = encrypt_text_aes_x25519(data, public_key, add_header=True, strict_password=strict_password) + + # 添加single_key标记 + json_str = remove_security_info(encrypted).decode('utf-8') + info = json.loads(json_str) + info['single_key'] = True + encrypted = add_security_info(json.dumps(info).encode('utf-8'), 'aes_x25519', bool(password)) + return encrypted, private_key, public_key + + return encrypted, private_key, public_key + except Exception as e: + debug_error(f"加密过程异常: {e}") + raise Exception(f"加密失败: {str(e)}") + + @staticmethod + def decrypt_file(file_path, password: str = None, single_private: bytes = None): + """ + 解密文件 + file_path: 文件路径 + password: 用户密码(可选) + single_private: 单独私钥(可选) + """ + if DEBUG: + debug_log(f"开始解密文件: {file_path}") + debug_log(f"密码提供: {password is not None}, 单独私钥提供: {single_private is not None}") + if password: + debug_log(f"密码长度: {len(password)}") + + try: + with open(file_path, 'rb') as f: + data = f.read() + + clean_data = remove_security_info(data) + json_str = clean_data.decode('utf-8') + info = json.loads(json_str) + mode = info.get('mode', 'aes_rsa') + has_password = info.get('has_password', False) + single_key = info.get('single_key', False) + + if DEBUG: + debug_log(f"加密信息 - 模式: {mode}, 有密码: {has_password}, 单文件密钥: {single_key}") + + if mode in ['aes_rsa', 'aes_rsa_password_protected']: + if single_key: + if single_private is None: + raise ValueError("单独密钥_需要选择密钥文件") + priv = single_private + else: + priv = get_global_rsa_private() + + if has_password: + if password is None: + raise ValueError("密码保护_需要输入密码") + decrypted = decrypt_text_aes_rsa_with_password(clean_data, priv, password, has_header=False) + else: + decrypted = decrypt_text_aes_rsa(clean_data, priv, has_header=False) + elif mode in ['aes_x25519', 'aes_x25519_password_protected']: + if single_key: + if single_private is None: + raise ValueError("单独密钥_需要选择密钥文件") + priv = single_private + else: + priv = get_global_x25519_private() + + if has_password: + if password is None: + raise ValueError("密码保护_需要输入密码") + decrypted = decrypt_text_aes_x25519(clean_data, priv, password, has_header=False) + else: + decrypted = decrypt_text_aes_x25519(clean_data, priv, has_header=False) + else: + raise ValueError(f"未知加密模式: {mode}") + + # 解密结果可能是 bytes 或 str,统一转为 bytes + if isinstance(decrypted, str): + decrypted = decrypted.encode('utf-8') + + if DEBUG: + debug_log(f"解密成功,解密后数据大小: {len(decrypted)}字节") + return decrypted + except Exception as e: + debug_error(f"解密过程异常: {e}") + raise + +# ==================== 通用解密函数(修复版:支持单独密钥文件选择) ==================== +def decrypt_bjb_file(file_path, parent_window, is_cloud_file=False, cloud_filename=None): + """ + 解密.bjb文件的通用函数 + file_path: 文件路径 + parent_window: 父窗口(用于弹窗) + is_cloud_file: 是否为云端下载的文件 + cloud_filename: 云端原始文件名(用于提示) + 返回: (content, success) + """ + if DEBUG: + debug_log(f"通用解密函数: {file_path}, 云端文件: {is_cloud_file}") + + display_name = cloud_filename if cloud_filename else os.path.basename(file_path) + + try: + # 首先获取文件元数据,判断是否需要单独密钥 + metadata = EncryptedNoteHandler.get_file_metadata(file_path) + if metadata is None: + QMessageBox.critical(parent_window, "文件错误", "无法读取文件元数据,文件可能已损坏") + return None, False + + has_password = metadata['has_password'] + single_key = metadata['single_key'] + mode = metadata['mode'] + + if DEBUG: + debug_log(f"文件元数据 - has_password: {has_password}, single_key: {single_key}, mode: {mode}") + + single_private = None + + # ========== 处理单独密钥模式 ========== + if single_key: + if DEBUG: + debug_log("检测到单独密钥模式,需要查找或选择.key文件") + + # 首先尝试在相同目录下查找对应的.key文件 + key_file_path = file_path + '.key' + if os.path.exists(key_file_path): + if DEBUG: + debug_log(f"找到本地密钥文件: {key_file_path}") + try: + with open(key_file_path, 'rb') as f: + single_private = f.read() + if DEBUG: + debug_log(f"密钥文件读取成功,长度: {len(single_private)}字节") + except Exception as e: + debug_error(f"读取密钥文件失败: {e}") + QMessageBox.critical(parent_window, "读取失败", f"无法读取密钥文件: {str(e)}") + return None, False + else: + # 没有找到,弹出文件选择对话框 + if DEBUG: + debug_log("未找到本地密钥文件,弹出选择对话框") + + # 构建提示信息 + msg = f"文件「{display_name}」使用单独密钥加密(端到端加密)。\n\n" + msg += "请选择对应的私钥文件(.key):\n" + msg += "提示:此文件加密时会在同目录生成同名.key文件。" + + if is_cloud_file: + msg = f"云端文件「{display_name}」使用单独密钥加密(端到端加密)。\n\n" + msg += "服务器不存储您的私钥,请选择您本地保存的对应.key文件:\n" + msg += "提示:创建此文件时会在本地生成同名的.key文件。" + + key_file_path, ok = QFileDialog.getOpenFileName( + parent_window, + "选择密钥文件 - " + display_name, + "", + "密钥文件 (*.key);;所有文件 (*.*)" + ) + + if not ok or not key_file_path: + QMessageBox.warning(parent_window, "密钥缺失", + f"无法解密「{display_name}」:缺少对应的私钥文件\n\n" + "此文件是使用【安全加密】模式创建的,\n" + "需要当初保存时生成的.key文件才能解密。\n" + "请找到对应的.key文件后重试。") + return None, False + + try: + with open(key_file_path, 'rb') as f: + single_private = f.read() + if DEBUG: + debug_log(f"用户选择密钥文件: {key_file_path}, 长度: {len(single_private)}字节") + except Exception as e: + debug_error(f"读取用户选择的密钥文件失败: {e}") + QMessageBox.critical(parent_window, "读取失败", f"无法读取密钥文件: {str(e)}") + return None, False + + # ========== 处理密码保护模式 ========== + if has_password: + if DEBUG: + debug_log("检测到密码保护,弹出密码输入框") + + max_attempts = 3 + attempts = 0 + success = False + + while attempts < max_attempts and not success: + pwd, ok = QInputDialog.getText( + parent_window, "输入密码 - " + display_name, + f"文件「{display_name}」受密码保护\n请输入密码 (尝试 {attempts + 1}/{max_attempts}):", + QLineEdit.Password + ) + if not ok: + if DEBUG: + debug_log("用户取消输入密码") + return None, False + + if pwd: + try: + if DEBUG: + debug_log(f"尝试密码解密 (长度: {len(pwd)})") + decrypted = EncryptedNoteHandler.decrypt_file( + file_path, password=pwd, single_private=single_private + ) + if isinstance(decrypted, bytes): + content = decrypted.decode('utf-8') + else: + content = str(decrypted) + if DEBUG: + debug_log("密码解密成功") + return content, True + except ValueError as e: + error_msg = str(e) + if DEBUG: + debug_log(f"密码解密失败: {error_msg}") + + if "单独密钥" in error_msg: + QMessageBox.warning(parent_window, "密钥错误", + "解密失败:密钥文件不匹配\n请选择正确的.key文件") + return None, False + + attempts += 1 + if attempts < max_attempts: + QMessageBox.warning(parent_window, "密码错误", + f"密码错误,还剩 {max_attempts - attempts} 次尝试") + else: + QMessageBox.warning(parent_window, "解密失败", + "多次密码错误,无法解密文件") + return None, False + except Exception as e: + error_msg = str(e) + if DEBUG: + debug_log(f"解密异常: {error_msg}") + attempts += 1 + if attempts < max_attempts: + QMessageBox.warning(parent_window, "解密失败", + f"解密失败: {error_msg}\n还剩 {max_attempts - attempts} 次尝试") + else: + QMessageBox.warning(parent_window, "解密失败", + f"多次尝试失败,无法解密文件\n错误: {error_msg}") + return None, False + else: + attempts += 1 + QMessageBox.warning(parent_window, "密码为空", "密码不能为空") + + return None, False + else: + # ========== 无密码保护,直接解密 ========== + if DEBUG: + debug_log("无密码保护,直接解密") + try: + decrypted = EncryptedNoteHandler.decrypt_file( + file_path, password=None, single_private=single_private + ) + if isinstance(decrypted, bytes): + content = decrypted.decode('utf-8') + else: + content = str(decrypted) + return content, True + except ValueError as e: + error_msg = str(e) + if "单独密钥" in error_msg: + QMessageBox.warning(parent_window, "密钥错误", + "解密失败:需要对应的.key密钥文件\n请选择正确的.key文件") + else: + QMessageBox.warning(parent_window, "解密失败", str(e)) + return None, False + except Exception as e: + debug_error(f"解密失败: {e}") + QMessageBox.critical(parent_window, "解密失败", str(e)) + return None, False + + except Exception as e: + debug_error(f"解密过程异常: {e}") + QMessageBox.critical(parent_window, "解密失败", str(e)) + return None, False + +# ==================== 云端API客户端 ==================== +class CloudClient: + """云端API客户端,处理所有网络请求""" + + def __init__(self): + if DEBUG: + debug_log("初始化CloudClient") + self.token = None + self.user_id = None + self.username = None + + def set_auth(self, token, user_id, username): + """设置认证信息""" + if DEBUG: + debug_log(f"设置认证信息 - 用户: {username}, ID: {user_id}") + self.token = token + self.user_id = user_id + self.username = username + + def clear_auth(self): + """清除认证信息""" + if DEBUG: + debug_log("清除认证信息") + self.token = None + self.user_id = None + self.username = None + + def _headers(self): + """获取请求头""" + if self.token: + return {'x-access-token': self.token} + return {} + + def _request(self, method, endpoint, **kwargs): + """发送HTTP请求""" + url = f"{API_BASE_URL}{endpoint}" + headers = self._headers() + if 'headers' in kwargs: + headers.update(kwargs.pop('headers')) + + try: + resp = requests.request(method, url, headers=headers, timeout=30, **kwargs) + if resp.status_code == 401: + return None + return resp + except requests.exceptions.ConnectionError: + QMessageBox.critical(None, "网络错误", "无法连接到服务器,请检查网络连接") + return None + except Exception as e: + QMessageBox.critical(None, "网络错误", f"请求失败: {str(e)}") + return None + + def register(self, username, email, password): + """用户注册""" + resp = self._request('POST', '/register', json={'username': username, 'email': email, 'password': password}) + if resp and resp.status_code == 201: + return resp.json() + return None + + def login(self, email, password): + """用户登录""" + resp = self._request('POST', '/login', json={'email': email, 'password': password}) + if resp and resp.status_code == 200: + data = resp.json() + self.set_auth(data['token'], data['user_id'], data['username']) + return data + return None + + def get_filetree(self, folder='/'): + """获取文件树""" + resp = self._request('GET', '/filetree', params={'folder': folder}) + if resp and resp.status_code == 200: + return resp.json() + return None + + def create_folder(self, name, parent='/'): + """创建文件夹""" + resp = self._request('POST', '/create_folder', json={'name': name, 'parent': parent}) + if resp and resp.status_code == 201: + return resp.json() + return None + + def upload_file(self, file_path, parent_folder='/'): + """上传文件""" + ext = os.path.splitext(file_path)[1].lower() + if ext not in ['.txt', '.bjb']: + QMessageBox.warning(None, "错误", "只支持上传 .txt 和 .bjb 文件") + return None + + with open(file_path, 'rb') as f: + files = {'file': (os.path.basename(file_path), f)} + data = {'parent_folder': parent_folder} + resp = self._request('POST', '/upload', files=files, data=data) + if resp and resp.status_code == 201: + return resp.json() + return None + + def download_file(self, file_id, target_path): + """下载文件""" + resp = self._request('GET', f'/download/{file_id}') + if resp and resp.status_code == 200: + with open(target_path, 'wb') as f: + f.write(resp.content) + return True + return False + + def delete_item(self, item_id): + """删除文件或文件夹""" + resp = self._request('DELETE', f'/delete/{item_id}') + if resp: + return resp.status_code == 200 + return False + + def rename_item(self, item_id, new_name): + """重命名文件或文件夹""" + resp = self._request('PUT', f'/rename/{item_id}', json={'new_name': new_name}) + if resp: + return resp.status_code == 200 + return False + + def move_item(self, item_id, target_folder): + """移动文件或文件夹""" + resp = self._request('POST', '/move', json={'item_id': item_id, 'target_folder': target_folder}) + if resp: + return resp.status_code == 200 + return False + + def search_files(self, keyword): + """搜索文件""" + resp = self._request('GET', '/search', params={'q': keyword}) + if resp and resp.status_code == 200: + return resp.json() + return None + + def get_storage_info(self): + """获取存储信息""" + resp = self._request('GET', '/storage_info') + if resp and resp.status_code == 200: + return resp.json() + return None + + def get_user_info(self): + """获取用户信息""" + resp = self._request('GET', '/user_info') + if resp and resp.status_code == 200: + return resp.json() + return None + +# ==================== 登录对话框 ==================== +class LoginDialog(QDialog): + """登录对话框""" + + def __init__(self, client, parent=None): + super().__init__(parent) + if DEBUG: + debug_log("初始化登录对话框") + self.client = client + self.setWindowTitle("云笔记 - 登录") + self.setFixedSize(400, 350) + self.setWindowFlags(Qt.FramelessWindowHint | Qt.Dialog) + self.setAttribute(Qt.WA_TranslucentBackground) + self.setup_ui() + + def setup_ui(self): + """设置UI""" + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + card = QWidget() + card.setObjectName("card") + card.setStyleSheet(""" + #card { background-color: white; border-radius: 16px; } + QLineEdit { border: 1px solid #e1e4e8; border-radius: 10px; padding: 12px; font-size: 14px; } + QPushButton { border-radius: 10px; padding: 12px; font-size: 14px; font-weight: 500; } + """) + + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(40, 40, 40, 40) + card_layout.setSpacing(20) + + title = QLabel("☁️ 云笔记") + title.setStyleSheet("font-size: 28px; font-weight: bold;") + title.setAlignment(Qt.AlignCenter) + card_layout.addWidget(title) + + self.email_input = QLineEdit() + self.email_input.setPlaceholderText("电子邮箱") + self.email_input.setMinimumHeight(45) + card_layout.addWidget(self.email_input) + + self.password_input = QLineEdit() + self.password_input.setPlaceholderText("密码") + self.password_input.setEchoMode(QLineEdit.Password) + self.password_input.setMinimumHeight(45) + card_layout.addWidget(self.password_input) + + self.remember_check = QCheckBox("记住我") + card_layout.addWidget(self.remember_check) + + self.login_btn = QPushButton("登 录") + self.login_btn.setMinimumHeight(45) + self.login_btn.setStyleSheet("background-color: #0366d6; color: white; border: none;") + self.login_btn.clicked.connect(self.do_login) + card_layout.addWidget(self.login_btn) + + register_btn = QPushButton("还没有账号?立即注册") + register_btn.setStyleSheet("background: transparent; color: #0366d6; border: none;") + register_btn.clicked.connect(self.show_register) + card_layout.addWidget(register_btn, alignment=Qt.AlignCenter) + + close_btn = QPushButton("×") + close_btn.setFixedSize(30, 30) + close_btn.setStyleSheet("background: transparent; border: none; font-size: 20px;") + close_btn.clicked.connect(self.reject) + + layout.addWidget(card) + close_btn.setParent(self) + close_btn.move(self.width() - 40, 15) + + def do_login(self): + """执行登录""" + email = self.email_input.text().strip() + password = self.password_input.text().strip() + + if not email or not password: + QMessageBox.warning(self, "提示", "请填写邮箱和密码") + return + + data = self.client.login(email, password) + if data: + if self.remember_check.isChecked(): + settings = QSettings("CloudNote", "User") + settings.setValue("token", self.client.token) + settings.setValue("user_id", self.client.user_id) + settings.setValue("username", self.client.username) + self.accept() + else: + QMessageBox.warning(self, "错误", "邮箱或密码错误") + + def show_register(self): + """显示注册对话框""" + dialog = RegisterDialog(self.client, self) + dialog.exec_() + +# ==================== 注册对话框 ==================== +class RegisterDialog(QDialog): + """注册对话框""" + + def __init__(self, client, parent=None): + super().__init__(parent) + if DEBUG: + debug_log("初始化注册对话框") + self.client = client + self.setWindowTitle("注册账号") + self.setFixedSize(400, 450) + self.setWindowFlags(Qt.FramelessWindowHint | Qt.Dialog) + self.setAttribute(Qt.WA_TranslucentBackground) + self.setup_ui() + + def setup_ui(self): + """设置UI""" + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + card = QWidget() + card.setObjectName("card") + card.setStyleSheet(""" + #card { background-color: white; border-radius: 16px; } + QLineEdit { border: 1px solid #e1e4e8; border-radius: 10px; padding: 12px; font-size: 14px; } + QPushButton { border-radius: 10px; padding: 12px; font-size: 14px; font-weight: 500; } + """) + + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(40, 40, 40, 40) + card_layout.setSpacing(15) + + title = QLabel("📝 创建新账号") + title.setStyleSheet("font-size: 24px; font-weight: bold;") + title.setAlignment(Qt.AlignCenter) + card_layout.addWidget(title) + + self.username_input = QLineEdit() + self.username_input.setPlaceholderText("用户名") + self.username_input.setMinimumHeight(45) + card_layout.addWidget(self.username_input) + + self.email_input = QLineEdit() + self.email_input.setPlaceholderText("电子邮箱") + self.email_input.setMinimumHeight(45) + card_layout.addWidget(self.email_input) + + self.password_input = QLineEdit() + self.password_input.setPlaceholderText("密码(至少6位)") + self.password_input.setEchoMode(QLineEdit.Password) + self.password_input.setMinimumHeight(45) + card_layout.addWidget(self.password_input) + + self.confirm_input = QLineEdit() + self.confirm_input.setPlaceholderText("确认密码") + self.confirm_input.setEchoMode(QLineEdit.Password) + self.confirm_input.setMinimumHeight(45) + card_layout.addWidget(self.confirm_input) + + self.register_btn = QPushButton("注 册") + self.register_btn.setMinimumHeight(45) + self.register_btn.setStyleSheet("background-color: #0366d6; color: white; border: none;") + self.register_btn.clicked.connect(self.do_register) + card_layout.addWidget(self.register_btn) + + close_btn = QPushButton("×") + close_btn.setFixedSize(30, 30) + close_btn.setStyleSheet("background: transparent; border: none; font-size: 20px;") + close_btn.clicked.connect(self.reject) + + layout.addWidget(card) + close_btn.setParent(self) + close_btn.move(self.width() - 40, 15) + + def do_register(self): + """执行注册""" + username = self.username_input.text().strip() + email = self.email_input.text().strip() + password = self.password_input.text().strip() + confirm = self.confirm_input.text().strip() + + if not all([username, email, password]): + QMessageBox.warning(self, "提示", "请填写所有字段") + return + if password != confirm: + QMessageBox.warning(self, "错误", "两次密码不一致") + return + if len(password) < 6: + QMessageBox.warning(self, "错误", "密码至少6位") + return + + res = self.client.register(username, email, password) + if res: + QMessageBox.information(self, "成功", "注册成功,请登录") + self.accept() + else: + QMessageBox.warning(self, "错误", "注册失败,用户名或邮箱已存在") + +# ==================== 新建加密文件对话框 ==================== +class NewEncryptedFileDialog(QDialog): + """新建加密文件配置对话框""" + + def __init__(self, parent=None): + super().__init__(parent) + if DEBUG: + debug_log("初始化新建加密文件对话框") + self.setWindowTitle("新建加密笔记") + self.setFixedSize(600, 700) + self.selected_algorithm = 'rsa' + self.selected_key_mode = 'global' + self.use_password = False + self.strict_password = True + self.custom_password = None + self.single_private = None + self.single_public = None + self.setup_ui() + + def setup_ui(self): + """设置UI""" + layout = QVBoxLayout(self) + layout.setSpacing(15) + + title = QLabel("🔐 加密配置") + title.setStyleSheet("font-size: 18px; font-weight: bold;") + layout.addWidget(title) + + group1 = QGroupBox("1. 选择加密算法") + group1_layout = QVBoxLayout() + self.radio_rsa = QRadioButton("RSA-4096 + AES-256-GCM") + self.radio_x25519 = QRadioButton("X25519 + AES-256-GCM") + self.radio_rsa.setChecked(True) + group1_layout.addWidget(self.radio_rsa) + group1_layout.addWidget(self.radio_x25519) + group1.setLayout(group1_layout) + layout.addWidget(group1) + + group2 = QGroupBox("2. 选择密钥方式") + group2_layout = QVBoxLayout() + self.radio_global = QRadioButton("默认加密(使用全局密钥对)") + self.radio_single = QRadioButton("安全加密(为当前文件单独生成密钥对)") + self.radio_global.setChecked(True) + group2_layout.addWidget(self.radio_global) + group2_layout.addWidget(self.radio_single) + group2.setLayout(group2_layout) + layout.addWidget(group2) + + group3 = QGroupBox("3. 额外密码保护") + group3_layout = QVBoxLayout() + + strength_layout = QHBoxLayout() + strength_layout.addWidget(QLabel("密码强度要求:")) + self.radio_strict = QRadioButton("高强度 (推荐)") + self.radio_weak = QRadioButton("低强度 (不推荐)") + self.radio_strict.setChecked(True) + strength_layout.addWidget(self.radio_strict) + strength_layout.addWidget(self.radio_weak) + group3_layout.addLayout(strength_layout) + + self.checkbox_password = QCheckBox("启用双层加密") + self.checkbox_password.toggled.connect(self.on_password_toggled) + group3_layout.addWidget(self.checkbox_password) + + self.password_input = QLineEdit() + self.password_input.setPlaceholderText("请输入密码(至少16位,包含大小写字母、数字和特殊字符)") + self.password_input.setEchoMode(QLineEdit.Password) + self.password_input.setEnabled(False) + group3_layout.addWidget(self.password_input) + + self.confirm_input = QLineEdit() + self.confirm_input.setPlaceholderText("确认密码") + self.confirm_input.setEchoMode(QLineEdit.Password) + self.confirm_input.setEnabled(False) + group3_layout.addWidget(self.confirm_input) + + self.password_strength_label = QLabel("") + self.password_strength_label.setStyleSheet("font-size: 12px;") + group3_layout.addWidget(self.password_strength_label) + + self.password_input.textChanged.connect(self.check_password_strength) + self.radio_strict.toggled.connect(self.on_strength_mode_changed) + self.radio_weak.toggled.connect(self.on_strength_mode_changed) + + group3.setLayout(group3_layout) + layout.addWidget(group3) + + info_label = QLabel("说明:\n• 默认加密:使用全局密钥对,所有文件共用同一密钥\n• 安全加密:每个文件独立生成密钥对,私钥保存为 .key 文件\n• 密钥文件请妥善保管,丢失后将无法解密文件!\n• 高强度密码要求≥16位含大小写+数字+特殊字符\n• 低强度模式仅用于测试,不推荐用于重要数据") + info_label.setStyleSheet("color: #586069; font-size: 12px; background-color: #f6f8fa; padding: 10px; border-radius: 8px;") + info_label.setWordWrap(True) + layout.addWidget(info_label) + + btn_layout = QHBoxLayout() + self.ok_btn = QPushButton("确定") + self.cancel_btn = QPushButton("取消") + self.ok_btn.clicked.connect(self.accept_config) + self.cancel_btn.clicked.connect(self.reject) + btn_layout.addWidget(self.ok_btn) + btn_layout.addWidget(self.cancel_btn) + layout.addLayout(btn_layout) + + def on_strength_mode_changed(self): + """密码强度模式改变""" + self.strict_password = self.radio_strict.isChecked() + if self.radio_strict.isChecked(): + self.password_input.setPlaceholderText("请输入密码(至少16位,包含大小写字母、数字和特殊字符)") + else: + self.password_input.setPlaceholderText("请输入密码(无强度限制,但强烈建议使用强密码)") + self.check_password_strength() + + def on_password_toggled(self, checked): + """密码开关切换""" + self.password_input.setEnabled(checked) + self.confirm_input.setEnabled(checked) + self.use_password = checked + if checked: + self.check_password_strength() + else: + self.password_strength_label.setText("") + + def check_password_strength(self): + """检查密码强度""" + if not self.use_password: + self.password_strength_label.setText("") + return + + pwd = self.password_input.text() + if self.radio_strict.isChecked(): + if len(pwd) >= 16 and any(c.isupper() for c in pwd) and any(c.islower() for c in pwd) and any(c.isdigit() for c in pwd) and any(not c.isalnum() for c in pwd): + self.password_strength_label.setText("✓ 密码强度:强") + self.password_strength_label.setStyleSheet("color: green; font-size: 12px;") + elif len(pwd) >= 12: + self.password_strength_label.setText("⚠ 密码强度:中(建议至少16位,包含大小写+数字+特殊字符)") + self.password_strength_label.setStyleSheet("color: orange; font-size: 12px;") + else: + self.password_strength_label.setText("✗ 密码强度:弱(需要至少16位,包含大小写+数字+特殊字符)") + self.password_strength_label.setStyleSheet("color: red; font-size: 12px;") + else: + if len(pwd) >= 8: + self.password_strength_label.setText("✓ 密码已输入(低强度模式,建议使用高强度)") + self.password_strength_label.setStyleSheet("color: orange; font-size: 12px;") + elif len(pwd) > 0: + self.password_strength_label.setText("⚠ 密码太短(建议至少8位)") + self.password_strength_label.setStyleSheet("color: orange; font-size: 12px;") + else: + self.password_strength_label.setText("请输入密码") + self.password_strength_label.setStyleSheet("color: gray; font-size: 12px;") + + def accept_config(self): + """接受配置""" + if DEBUG: + debug_log("开始获取加密配置") + + if self.radio_rsa.isChecked(): + self.selected_algorithm = 'rsa' + else: + self.selected_algorithm = 'x25519' + if DEBUG: + debug_log(f"算法: {self.selected_algorithm}") + + if self.radio_global.isChecked(): + self.selected_key_mode = 'global' + else: + self.selected_key_mode = 'single' + if DEBUG: + debug_log(f"密钥模式: {self.selected_key_mode}") + + self.strict_password = self.radio_strict.isChecked() + if DEBUG: + debug_log(f"密码强度模式: {'高强度' if self.strict_password else '低强度'}") + + if self.use_password: + pwd = self.password_input.text() + confirm = self.confirm_input.text() + + if not pwd or not confirm: + QMessageBox.warning(self, "提示", "请输入密码") + return + if pwd != confirm: + QMessageBox.warning(self, "错误", "两次输入的密码不一致") + return + + if self.strict_password and not is_password_strong(pwd, strict=True): + QMessageBox.warning(self, "密码强度不足", + "密码必须至少16位,包含大小写字母、数字和特殊字符\n\n" + "或切换到「低强度」模式(不推荐)") + return + + self.custom_password = pwd + if DEBUG: + debug_log("密码验证通过") + + if self.selected_key_mode == 'single': + if DEBUG: + debug_log("生成单独密钥对") + if self.selected_algorithm == 'rsa': + self.single_private, self.single_public = generate_rsa_key(4096) + else: + self.single_private, self.single_public = generate_x25519_keys() + + if DEBUG: + debug_log("加密配置获取完成") + self.accept() + + def get_config(self): + """获取配置""" + return { + 'algorithm': self.selected_algorithm, + 'key_mode': self.selected_key_mode, + 'use_password': self.use_password, + 'strict_password': self.strict_password, + 'custom_password': self.custom_password, + 'single_private': self.single_private, + 'single_public': self.single_public + } + +# ==================== 保存加密文件对话框 ==================== +class SaveEncryptedFileDialog(QDialog): + """保存加密文件对话框""" + + def __init__(self, content, algorithm, key_mode, use_password, strict_password, + custom_password, single_private, single_public, parent=None): + super().__init__(parent) + if DEBUG: + debug_log("初始化保存加密文件对话框") + self.content = content + self.algorithm = algorithm + self.key_mode = key_mode + self.use_password = use_password + self.strict_password = strict_password + self.custom_password = custom_password + self.single_private = single_private + self.single_public = single_public + self.setWindowTitle("保存加密笔记") + self.setFixedSize(650, 400) + self.selected_file_path = None + self.setup_ui() + + def setup_ui(self): + """设置UI""" + layout = QVBoxLayout(self) + layout.setSpacing(15) + + if self.key_mode == 'single': + info_label = QLabel("⚠️ 您选择了【安全加密】模式,将为当前文件单独生成密钥对。\n密钥文件(.key)将与.bjb文件保存在同一目录,请妥善保管!\n丢失密钥文件后将无法解密!") + info_label.setStyleSheet("color: #e36209; background-color: #fff5eb; padding: 10px; border-radius: 8px;") + info_label.setWordWrap(True) + layout.addWidget(info_label) + + layout.addWidget(QLabel("📁 保存位置和文件名:")) + + file_select_layout = QHBoxLayout() + self.file_path_input = QLineEdit() + self.file_path_input.setPlaceholderText("请选择保存位置...") + self.file_path_input.setMinimumHeight(35) + self.file_path_input.textChanged.connect(self.on_path_changed) + file_select_layout.addWidget(self.file_path_input) + + self.browse_btn = QPushButton("浏览...") + self.browse_btn.clicked.connect(self.select_save_file) + file_select_layout.addWidget(self.browse_btn) + layout.addLayout(file_select_layout) + + info_group = QGroupBox("加密信息摘要") + info_layout = QVBoxLayout() + + algo_text = "RSA-4096" if self.algorithm == 'rsa' else "X25519" + key_text = "全局密钥" if self.key_mode == 'global' else "单独密钥(安全模式)" + pwd_text = "已启用" if self.use_password else "未启用" + strength_text = "高强度" if self.strict_password else "低强度" + + info_layout.addWidget(QLabel(f"🔐 加密算法: {algo_text} + AES-256-GCM")) + info_layout.addWidget(QLabel(f"🔑 密钥方式: {key_text}")) + info_layout.addWidget(QLabel(f"🔒 密码保护: {pwd_text}")) + if self.use_password: + info_layout.addWidget(QLabel(f"🔐 密码强度: {strength_text}")) + + info_group.setLayout(info_layout) + layout.addWidget(info_group) + + btn_layout = QHBoxLayout() + self.ok_btn = QPushButton("💾 保存") + self.ok_btn.setStyleSheet("background-color: #28a745; color: white;") + self.cancel_btn = QPushButton("取消") + self.ok_btn.clicked.connect(self.do_save) + self.cancel_btn.clicked.connect(self.reject) + btn_layout.addWidget(self.ok_btn) + btn_layout.addWidget(self.cancel_btn) + layout.addLayout(btn_layout) + + def on_path_changed(self, text): + """路径改变""" + if text.strip(): + self.selected_file_path = text.strip() + + def select_save_file(self): + """选择保存文件""" + file_path, _ = QFileDialog.getSaveFileName(self, "保存加密笔记", "", "加密笔记 (*.bjb)") + if file_path: + if not file_path.endswith('.bjb'): + file_path += '.bjb' + self.selected_file_path = file_path + self.file_path_input.setText(file_path) + + def do_save(self): + """执行保存""" + if not self.selected_file_path: + QMessageBox.warning(self, "提示", "请选择保存位置") + return + + if not self.selected_file_path.endswith('.bjb'): + self.selected_file_path += '.bjb' + + try: + encrypted_data, priv, pub = EncryptedNoteHandler.encrypt_content( + self.content, self.algorithm, self.key_mode, + self.custom_password, self.single_private, self.single_public, + strict_password=self.strict_password + ) + except Exception as e: + QMessageBox.critical(self, "加密失败", str(e)) + return + + try: + with open(self.selected_file_path, 'wb') as f: + f.write(encrypted_data) + except Exception as e: + QMessageBox.critical(self, "保存失败", f"无法保存.bjb文件: {str(e)}") + return + + key_path = None + if self.key_mode == 'single' and priv: + key_path = self.selected_file_path + '.key' + try: + with open(key_path, 'wb') as f: + f.write(priv) + except Exception as e: + QMessageBox.critical(self, "保存失败", f"无法保存.key文件: {str(e)}") + return + + msg = f"✅ 文件已保存!\n\n📄 .bjb文件:{self.selected_file_path}" + if key_path: + msg += f"\n🔑 .key文件:{key_path}\n\n⚠️ 请妥善保管密钥文件!" + else: + msg += "\n\n💡 提示:使用全局密钥,无需额外保存密钥文件。" + + QMessageBox.information(self, "保存成功", msg) + self.accept() + +# ==================== 云端文件树窗口 ==================== +class CloudFileTreeDialog(QDialog): + """云端文件管理对话框""" + + def __init__(self, client, parent=None): + super().__init__(parent) + if DEBUG: + debug_log("初始化云端文件树对话框") + self.client = client + self.setWindowTitle("云端文件管理") + self.resize(900, 700) + self.setup_ui() + self.current_folder = '/' + self.load_filetree() + + def setup_ui(self): + """设置UI""" + layout = QVBoxLayout(self) + + toolbar = QToolBar() + self.up_btn = QAction("⬆ 返回上级", self) + self.up_btn.triggered.connect(self.go_up) + toolbar.addAction(self.up_btn) + self.refresh_btn = QAction("🔄 刷新", self) + self.refresh_btn.triggered.connect(self.load_filetree) + toolbar.addAction(self.refresh_btn) + self.new_folder_btn = QAction("📁 新建文件夹", self) + self.new_folder_btn.triggered.connect(self.create_folder) + toolbar.addAction(self.new_folder_btn) + self.upload_btn = QAction("📤 上传文件", self) + self.upload_btn.triggered.connect(self.upload_file) + toolbar.addAction(self.upload_btn) + layout.addWidget(toolbar) + + search_layout = QHBoxLayout() + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("搜索文件...") + self.search_input.returnPressed.connect(self.search_files) + search_btn = QPushButton("搜索") + search_btn.clicked.connect(self.search_files) + clear_btn = QPushButton("清除") + clear_btn.clicked.connect(self.clear_search) + search_layout.addWidget(self.search_input) + search_layout.addWidget(search_btn) + search_layout.addWidget(clear_btn) + layout.addLayout(search_layout) + + self.tree = QTreeWidget() + self.tree.setHeaderLabels(["名称", "大小", "类型", "修改时间"]) + self.tree.setAlternatingRowColors(True) + self.tree.setContextMenuPolicy(Qt.CustomContextMenu) + self.tree.customContextMenuRequested.connect(self.show_context_menu) + self.tree.itemDoubleClicked.connect(self.on_item_double_click) + layout.addWidget(self.tree) + + self.status_label = QLabel("") + layout.addWidget(self.status_label) + + self.load_storage_info() + + def load_storage_info(self): + """加载存储信息""" + info = self.client.get_storage_info() + if info: + used_mb = info['total_size'] / (1024 * 1024) + max_mb = info['max_size'] / (1024 * 1024) + download_used_mb = info['download_used'] / (1024 * 1024) + download_max_mb = info['download_max'] / (1024 * 1024) + self.status_label.setText(f"存储: {used_mb:.1f}MB / {max_mb:.0f}MB | 今日下载: {download_used_mb:.1f}MB / {download_max_mb:.0f}MB") + + def load_filetree(self): + """加载文件树""" + if DEBUG: + debug_log(f"加载文件树: {self.current_folder}") + data = self.client.get_filetree(self.current_folder) + if not data: + QMessageBox.warning(self, "错误", "加载文件树失败") + return + self.tree.clear() + for item in data['items']: + size_str = self.format_size(item['size']) if item['size'] else "" + type_str = "文件夹" if item['is_folder'] else (item['type'] or "文件") + tree_item = QTreeWidgetItem([item['name'], size_str, type_str, item['created_at']]) + tree_item.setData(0, Qt.UserRole, item) + self.tree.addTopLevelItem(tree_item) + self.load_storage_info() + + def format_size(self, size_bytes): + """格式化文件大小""" + if not size_bytes: + return "" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.1f}{unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.1f}TB" + + def go_up(self): + """返回上级目录""" + if self.current_folder != '/': + parts = self.current_folder.rstrip('/').split('/') + if len(parts) > 1: + self.current_folder = '/' + '/'.join(parts[:-1]) + else: + self.current_folder = '/' + self.load_filetree() + + def create_folder(self): + """创建文件夹""" + name, ok = QInputDialog.getText(self, "新建文件夹", "请输入文件夹名称:") + if ok and name: + if '/' in name or '\\' in name: + QMessageBox.warning(self, "错误", "文件夹名称不能包含路径分隔符") + return + res = self.client.create_folder(name, self.current_folder) + if res: + self.load_filetree() + else: + QMessageBox.warning(self, "错误", "创建失败") + + def upload_file(self): + """上传文件""" + file_path, _ = QFileDialog.getOpenFileName(self, "选择要上传的文件", "", "笔记本文件 (*.bjb *.txt)") + if file_path: + ext = os.path.splitext(file_path)[1].lower() + if ext not in ['.txt', '.bjb']: + QMessageBox.warning(self, "错误", "只支持上传 .txt 和 .bjb 文件") + return + + file_size = os.path.getsize(file_path) + info = self.client.get_storage_info() + if info and info['total_size'] + file_size > info['max_size']: + QMessageBox.warning(self, "错误", "云端存储空间不足") + return + res = self.client.upload_file(file_path, self.current_folder) + if res: + QMessageBox.information(self, "成功", "上传成功") + self.load_filetree() + else: + QMessageBox.warning(self, "错误", "上传失败") + + def search_files(self): + """搜索文件""" + keyword = self.search_input.text().strip() + if not keyword: + return + data = self.client.search_files(keyword) + if data: + self.tree.clear() + for item in data['items']: + size_str = self.format_size(item['size']) if item['size'] else "" + type_str = "文件夹" if item['is_folder'] else (item['type'] or "文件") + tree_item = QTreeWidgetItem([item['name'], size_str, type_str, ""]) + tree_item.setData(0, Qt.UserRole, item) + self.tree.addTopLevelItem(tree_item) + self.status_label.setText(f"搜索到 {len(data['items'])} 个结果") + + def clear_search(self): + """清除搜索""" + self.search_input.clear() + self.load_filetree() + + def on_item_double_click(self, item, col): + """双击项目""" + data = item.data(0, Qt.UserRole) + if data['is_folder']: + if self.search_input.text(): + self.clear_search() + self.current_folder = data['parent'] + '/' + data['name'] if data['parent'] != '/' else '/' + data['name'] + self.load_filetree() + else: + self.download_and_open(data) + + def download_and_open(self, file_info): + """ + 下载并打开云端文件 + 修复:支持单独密钥模式,弹出文件选择对话框让用户选择.key文件 + """ + temp_dir = tempfile.gettempdir() + local_path = os.path.join(temp_dir, f"cloud_{file_info['id']}_{file_info['name']}") + + if DEBUG: + debug_log(f"下载文件到: {local_path}") + debug_log(f"文件信息: ID={file_info['id']}, Name={file_info['name']}, Type={file_info.get('type', 'unknown')}") + + if self.client.download_file(file_info['id'], local_path): + if DEBUG: + debug_log("下载成功") + + ext = os.path.splitext(file_info['name'])[1].lower() + + if ext == '.txt': + # 文本文件直接打开 + try: + with open(local_path, 'r', encoding='utf-8') as f: + content = f.read() + main_window = self.parent() + if isinstance(main_window, MainWindow): + tab = EditorTab(main_window.tabs, main_window.tabs.count(), + file_path=local_path, cloud_id=file_info['id'], + file_type='txt', content=content) + main_window.tabs.addTab(tab.text_edit, os.path.basename(file_info['name'])) + main_window.editor_tabs.append(tab) + main_window.tabs.setCurrentIndex(main_window.tabs.count() - 1) + self.accept() + except Exception as e: + debug_error(f"读取文本文件失败: {e}") + QMessageBox.warning(self, "错误", f"读取文件失败: {str(e)}") + else: + # 加密文件:使用修复后的解密函数,传入云端文件标志和文件名 + if DEBUG: + debug_log("调用解密函数(云端文件模式)") + + # 先获取文件元数据,看看是否需要单独密钥 + metadata = EncryptedNoteHandler.get_file_metadata(local_path) + if metadata and metadata.get('single_key', False): + if DEBUG: + debug_log("检测到单独密钥模式,准备弹出密钥文件选择对话框") + + content, success = decrypt_bjb_file( + local_path, self, + is_cloud_file=True, + cloud_filename=file_info['name'] + ) + + if success and content is not None: + if DEBUG: + debug_log("解密成功,创建编辑器选项卡") + main_window = self.parent() + if isinstance(main_window, MainWindow): + tab = EditorTab(main_window.tabs, main_window.tabs.count(), + file_path=local_path, cloud_id=file_info['id'], + file_type='bjb', content=content) + main_window.tabs.addTab(tab.text_edit, os.path.basename(file_info['name'])) + main_window.editor_tabs.append(tab) + main_window.tabs.setCurrentIndex(main_window.tabs.count() - 1) + self.accept() + else: + if DEBUG: + debug_log(f"警告: parent 不是 MainWindow 类型: {type(main_window)}") + else: + if DEBUG: + debug_log("解密失败,不关闭对话框") + # 解密失败时不关闭对话框,让用户重试 + else: + if DEBUG: + debug_log("下载失败") + QMessageBox.warning(self, "错误", "下载失败") + + def show_context_menu(self, pos): + """显示右键菜单""" + item = self.tree.itemAt(pos) + if not item: + return + data = item.data(0, Qt.UserRole) + menu = QMenu() + rename_action = menu.addAction("重命名") + delete_action = menu.addAction("删除") + move_action = menu.addAction("移动") + download_action = menu.addAction("下载") + action = menu.exec_(self.tree.viewport().mapToGlobal(pos)) + if action == rename_action: + new_name, ok = QInputDialog.getText(self, "重命名", "新名称:", text=data['name']) + if ok and new_name: + if self.client.rename_item(data['id'], new_name): + self.load_filetree() + else: + QMessageBox.warning(self, "错误", "重命名失败") + elif action == delete_action: + if QMessageBox.question(self, "确认", f"确定删除 {data['name']} 吗?") == QMessageBox.Yes: + if self.client.delete_item(data['id']): + self.load_filetree() + else: + QMessageBox.warning(self, "错误", "删除失败") + elif action == move_action: + target, ok = QInputDialog.getText(self, "移动", "目标文件夹路径 (例如 /folder):") + if ok and target: + if self.client.move_item(data['id'], target): + self.load_filetree() + else: + QMessageBox.warning(self, "错误", "移动失败") + elif action == download_action: + save_path, _ = QFileDialog.getSaveFileName(self, "保存文件", data['name']) + if save_path: + if self.client.download_file(data['id'], save_path): + QMessageBox.information(self, "成功", "下载完成") + else: + QMessageBox.warning(self, "错误", "下载失败") + +# ==================== 编辑器选项卡 ==================== +class EditorTab: + """编辑器选项卡类""" + + def __init__(self, tab_widget, index, file_path=None, cloud_id=None, is_new=False, + content="", file_type="txt", encrypt_config=None): + if DEBUG: + debug_log(f"创建编辑器选项卡 - 类型: {file_type}, 新文件: {is_new}, 路径: {file_path}") + self.tab_widget = tab_widget + self.index = index + self.file_path = file_path + self.cloud_id = cloud_id + self.is_new = is_new + self.file_type = file_type + self.encrypt_config = encrypt_config + self.modified = False + self.text_edit = QTextEdit() + self.text_edit.setPlainText(content) + self.text_edit.textChanged.connect(self.on_text_changed) + + font = QFont("Consolas", 12) + self.text_edit.setFont(font) + + self.update_tab_title() + + def update_tab_title(self): + """更新选项卡标题""" + if self.file_path: + name = os.path.basename(self.file_path) + elif self.cloud_id is not None: + name = f"云端文件_{self.cloud_id}" + else: + name = "未命名" + if self.file_type == 'bjb': + name = f"🔒 {name}" + if self.modified: + name = "* " + name + self.tab_widget.setTabText(self.index, name) + + def on_text_changed(self): + """文本改变时的回调""" + if not self.modified: + self.modified = True + self.update_tab_title() + + def get_content(self): + """获取内容""" + return self.text_edit.toPlainText() + + def set_content(self, content): + """设置内容""" + self.text_edit.setPlainText(content) + self.modified = False + self.update_tab_title() + + def mark_saved(self): + """标记已保存""" + self.modified = False + self.update_tab_title() + +# ==================== 加密当前文件对话框 ==================== +class EncryptCurrentFileDialog(QDialog): + """加密当前文件对话框""" + + def __init__(self, parent=None): + super().__init__(parent) + if DEBUG: + debug_log("初始化加密当前文件对话框") + self.setWindowTitle("加密当前文件") + self.setFixedSize(600, 600) + self.selected_algorithm = 'rsa' + self.selected_key_mode = 'global' + self.use_password = False + self.strict_password = True + self.custom_password = None + self.single_private = None + self.single_public = None + self.setup_ui() + + def setup_ui(self): + """设置UI""" + layout = QVBoxLayout(self) + layout.setSpacing(15) + + title = QLabel("🔐 加密当前文件配置") + title.setStyleSheet("font-size: 18px; font-weight: bold;") + layout.addWidget(title) + + info_label = QLabel("⚠️ 注意:加密后将生成新的.bjb文件,原文件不会被删除") + info_label.setStyleSheet("color: #e36209; background-color: #fff5eb; padding: 8px; border-radius: 8px;") + layout.addWidget(info_label) + + group1 = QGroupBox("1. 选择加密算法") + group1_layout = QVBoxLayout() + self.radio_rsa = QRadioButton("RSA-4096 + AES-256-GCM") + self.radio_x25519 = QRadioButton("X25519 + AES-256-GCM") + self.radio_rsa.setChecked(True) + group1_layout.addWidget(self.radio_rsa) + group1_layout.addWidget(self.radio_x25519) + group1.setLayout(group1_layout) + layout.addWidget(group1) + + group2 = QGroupBox("2. 选择密钥方式") + group2_layout = QVBoxLayout() + self.radio_global = QRadioButton("默认加密(使用全局密钥对)") + self.radio_single = QRadioButton("安全加密(为当前文件单独生成密钥对)") + self.radio_global.setChecked(True) + group2_layout.addWidget(self.radio_global) + group2_layout.addWidget(self.radio_single) + group2.setLayout(group2_layout) + layout.addWidget(group2) + + group3 = QGroupBox("3. 额外密码保护") + group3_layout = QVBoxLayout() + + strength_layout = QHBoxLayout() + strength_layout.addWidget(QLabel("密码强度要求:")) + self.radio_strict = QRadioButton("高强度 (推荐)") + self.radio_weak = QRadioButton("低强度 (不推荐)") + self.radio_strict.setChecked(True) + strength_layout.addWidget(self.radio_strict) + strength_layout.addWidget(self.radio_weak) + group3_layout.addLayout(strength_layout) + + self.checkbox_password = QCheckBox("启用双层加密") + self.checkbox_password.toggled.connect(self.on_password_toggled) + group3_layout.addWidget(self.checkbox_password) + + self.password_input = QLineEdit() + self.password_input.setPlaceholderText("请输入密码(至少16位,包含大小写字母、数字和特殊字符)") + self.password_input.setEchoMode(QLineEdit.Password) + self.password_input.setEnabled(False) + group3_layout.addWidget(self.password_input) + + self.confirm_input = QLineEdit() + self.confirm_input.setPlaceholderText("确认密码") + self.confirm_input.setEchoMode(QLineEdit.Password) + self.confirm_input.setEnabled(False) + group3_layout.addWidget(self.confirm_input) + + self.password_strength_label = QLabel("") + self.password_strength_label.setStyleSheet("font-size: 12px;") + group3_layout.addWidget(self.password_strength_label) + + self.password_input.textChanged.connect(self.check_password_strength) + self.radio_strict.toggled.connect(self.on_strength_mode_changed) + + group3.setLayout(group3_layout) + layout.addWidget(group3) + + btn_layout = QHBoxLayout() + self.ok_btn = QPushButton("加密并保存") + self.ok_btn.setStyleSheet("background-color: #28a745; color: white;") + self.cancel_btn = QPushButton("取消") + self.ok_btn.clicked.connect(self.accept_config) + self.cancel_btn.clicked.connect(self.reject) + btn_layout.addWidget(self.ok_btn) + btn_layout.addWidget(self.cancel_btn) + layout.addLayout(btn_layout) + + def on_strength_mode_changed(self): + """密码强度模式改变""" + self.strict_password = self.radio_strict.isChecked() + if self.radio_strict.isChecked(): + self.password_input.setPlaceholderText("请输入密码(至少16位,包含大小写字母、数字和特殊字符)") + else: + self.password_input.setPlaceholderText("请输入密码(无强度限制,但强烈建议使用强密码)") + self.check_password_strength() + + def on_password_toggled(self, checked): + """密码开关切换""" + self.password_input.setEnabled(checked) + self.confirm_input.setEnabled(checked) + self.use_password = checked + if checked: + self.check_password_strength() + else: + self.password_strength_label.setText("") + + def check_password_strength(self): + """检查密码强度""" + if not self.use_password: + self.password_strength_label.setText("") + return + + pwd = self.password_input.text() + if self.radio_strict.isChecked(): + if len(pwd) >= 16 and any(c.isupper() for c in pwd) and any(c.islower() for c in pwd) and any(c.isdigit() for c in pwd) and any(not c.isalnum() for c in pwd): + self.password_strength_label.setText("✓ 密码强度:强") + self.password_strength_label.setStyleSheet("color: green; font-size: 12px;") + elif len(pwd) >= 12: + self.password_strength_label.setText("⚠ 密码强度:中(建议至少16位)") + self.password_strength_label.setStyleSheet("color: orange; font-size: 12px;") + else: + self.password_strength_label.setText("✗ 密码强度:弱(需要至少16位)") + self.password_strength_label.setStyleSheet("color: red; font-size: 12px;") + else: + if len(pwd) >= 8: + self.password_strength_label.setText("✓ 密码已输入(低强度模式)") + self.password_strength_label.setStyleSheet("color: orange; font-size: 12px;") + elif len(pwd) > 0: + self.password_strength_label.setText("⚠ 密码太短") + self.password_strength_label.setStyleSheet("color: orange; font-size: 12px;") + else: + self.password_strength_label.setText("请输入密码") + self.password_strength_label.setStyleSheet("color: gray; font-size: 12px;") + + def accept_config(self): + """接受配置""" + if DEBUG: + debug_log("开始获取加密配置") + + if self.radio_rsa.isChecked(): + self.selected_algorithm = 'rsa' + else: + self.selected_algorithm = 'x25519' + if DEBUG: + debug_log(f"算法: {self.selected_algorithm}") + + if self.radio_global.isChecked(): + self.selected_key_mode = 'global' + else: + self.selected_key_mode = 'single' + if DEBUG: + debug_log(f"密钥模式: {self.selected_key_mode}") + + self.strict_password = self.radio_strict.isChecked() + if DEBUG: + debug_log(f"密码强度模式: {'高强度' if self.strict_password else '低强度'}") + + if self.use_password: + pwd = self.password_input.text() + confirm = self.confirm_input.text() + + if not pwd or not confirm: + QMessageBox.warning(self, "提示", "请输入密码") + return + if pwd != confirm: + QMessageBox.warning(self, "错误", "两次输入的密码不一致") + return + + if self.strict_password and not is_password_strong(pwd, strict=True): + QMessageBox.warning(self, "密码强度不足", + "密码必须至少16位,包含大小写字母、数字和特殊字符\n\n" + "或切换到「低强度」模式(不推荐)") + return + + self.custom_password = pwd + if DEBUG: + debug_log("密码验证通过") + + if self.selected_key_mode == 'single': + if DEBUG: + debug_log("生成单独密钥对") + if self.selected_algorithm == 'rsa': + self.single_private, self.single_public = generate_rsa_key(4096) + else: + self.single_private, self.single_public = generate_x25519_keys() + + if DEBUG: + debug_log("加密配置获取完成") + self.accept() + + def get_config(self): + """获取配置""" + return { + 'algorithm': self.selected_algorithm, + 'key_mode': self.selected_key_mode, + 'use_password': self.use_password, + 'strict_password': self.strict_password, + 'custom_password': self.custom_password, + 'single_private': self.single_private, + 'single_public': self.single_public + } + +# ==================== 主窗口 ==================== +class MainWindow(QMainWindow): + """主窗口类""" + + def __init__(self): + super().__init__() + if DEBUG: + debug_log("初始化主窗口") + self.client = CloudClient() + self.tabs = QTabWidget() + self.tabs.setTabsClosable(True) + self.tabs.tabCloseRequested.connect(self.close_tab) + self.tabs.currentChanged.connect(self.on_tab_changed) + self.setCentralWidget(self.tabs) + self.setWindowTitle(f"云笔记 - 安全加密笔记") + self.resize(1200, 800) + self.setup_menu() + self.setup_statusbar() + self.editor_tabs = [] + self.pending_encrypt_config = None + self.current_editor = None + + ensure_global_keys() + self.check_login() + + def setup_menu(self): + """设置菜单栏""" + menubar = self.menuBar() + + file_menu = menubar.addMenu("文件") + + new_txt_action = QAction("新建文本文件(.txt)", self) + new_txt_action.setShortcut(QKeySequence("Ctrl+N")) + new_txt_action.triggered.connect(lambda: self.new_file('txt')) + file_menu.addAction(new_txt_action) + + new_bjb_action = QAction("新建加密笔记(.bjb)...", self) + new_bjb_action.setShortcut(QKeySequence("Ctrl+Shift+N")) + new_bjb_action.triggered.connect(lambda: self.new_file('bjb')) + file_menu.addAction(new_bjb_action) + + file_menu.addSeparator() + + open_action = QAction("打开文件...", self) + open_action.setShortcut(QKeySequence.Open) + open_action.triggered.connect(self.open_local_file_dialog) + file_menu.addAction(open_action) + + open_cloud_action = QAction("从云端打开...", self) + open_cloud_action.setShortcut(QKeySequence("Ctrl+Shift+O")) + open_cloud_action.triggered.connect(self.open_cloud_file_dialog) + file_menu.addAction(open_cloud_action) + + file_menu.addSeparator() + + save_action = QAction("保存", self) + save_action.setShortcut(QKeySequence.Save) + save_action.triggered.connect(self.save_current_tab) + file_menu.addAction(save_action) + + save_as_action = QAction("另存为...", self) + save_as_action.setShortcut(QKeySequence.SaveAs) + save_as_action.triggered.connect(self.save_as_current_tab) + file_menu.addAction(save_as_action) + + file_menu.addSeparator() + + exit_action = QAction("退出", self) + exit_action.setShortcut(QKeySequence.Quit) + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + # 加密菜单 + encrypt_menu = menubar.addMenu("加密") + + encrypt_current_action = QAction("🔐 加密当前文件...", self) + encrypt_current_action.setShortcut(QKeySequence("Ctrl+E")) + encrypt_current_action.triggered.connect(self.encrypt_current_file) + encrypt_menu.addAction(encrypt_current_action) + + encrypt_menu.addSeparator() + + txt_to_bjb_action = QAction("📄 另存为加密笔记(.bjb)...", self) + txt_to_bjb_action.setShortcut(QKeySequence("Ctrl+Shift+S")) + txt_to_bjb_action.triggered.connect(self.convert_txt_to_bjb) + encrypt_menu.addAction(txt_to_bjb_action) + + cloud_menu = menubar.addMenu("云端") + + manage_cloud_action = QAction("管理云端文件", self) + manage_cloud_action.triggered.connect(self.show_cloud_manager) + cloud_menu.addAction(manage_cloud_action) + + upload_action = QAction("上传当前文件到云端", self) + upload_action.triggered.connect(self.upload_current_tab) + cloud_menu.addAction(upload_action) + + cloud_menu.addSeparator() + + logout_action = QAction("退出登录", self) + logout_action.triggered.connect(self.logout) + cloud_menu.addAction(logout_action) + + help_menu = menubar.addMenu("帮助") + + about_action = QAction("关于", self) + about_action.triggered.connect(self.about) + help_menu.addAction(about_action) + + def setup_statusbar(self): + """设置状态栏""" + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + self.storage_label = QLabel("") + self.status_bar.addPermanentWidget(self.storage_label) + self.update_storage_info() + + def update_storage_info(self): + """更新存储信息""" + if self.client.token: + info = self.client.get_storage_info() + if info: + used_mb = info['total_size'] / (1024 * 1024) + max_mb = info['max_size'] / (1024 * 1024) + self.storage_label.setText(f"📁 {used_mb:.1f}MB / {max_mb:.0f}MB") + + def check_login(self): + """检查登录状态""" + settings = QSettings("CloudNote", "User") + token = settings.value("token") + user_id = settings.value("user_id") + username = settings.value("username") + + if token and user_id and username: + self.client.set_auth(token, int(user_id), username) + user_info = self.client.get_user_info() + if user_info: + self.status_bar.showMessage(f"欢迎回来,{username}") + self.update_storage_info() + return + else: + settings.clear() + self.client.clear_auth() + + dialog = LoginDialog(self.client, self) + if dialog.exec_() == QDialog.Accepted: + self.status_bar.showMessage(f"欢迎 {self.client.username}") + self.update_storage_info() + else: + sys.exit(0) + + def logout(self): + """退出登录""" + self.client.clear_auth() + settings = QSettings("CloudNote", "User") + settings.clear() + self.status_bar.showMessage("已退出登录") + while self.tabs.count() > 0: + self.close_tab(0) + self.check_login() + + def new_file(self, file_type): + """新建文件""" + if DEBUG: + debug_log(f"新建文件: {file_type}") + + if file_type == 'txt': + tab = EditorTab(self.tabs, self.tabs.count(), file_type='txt', is_new=True, content="") + self.tabs.addTab(tab.text_edit, "未命名") + self.editor_tabs.append(tab) + self.tabs.setCurrentIndex(self.tabs.count() - 1) + else: + # 先创建选项卡,再显示配置对话框 + tab = EditorTab(self.tabs, self.tabs.count(), file_type='bjb', is_new=True, content="") + self.tabs.addTab(tab.text_edit, "未命名加密笔记") + self.editor_tabs.append(tab) + self.tabs.setCurrentIndex(self.tabs.count() - 1) + + dialog = NewEncryptedFileDialog(self) + if dialog.exec_() == QDialog.Accepted: + config = dialog.get_config() + tab.encrypt_config = config + self.save_as_current_tab() + else: + self.close_tab(self.tabs.currentIndex()) + + def open_local_file_dialog(self): + """打开本地文件对话框""" + if DEBUG: + debug_log("打开本地文件对话框") + + file_path, _ = QFileDialog.getOpenFileName( + self, "打开文件", "", "笔记本文件 (*.bjb *.txt)" + ) + if file_path: + if DEBUG: + debug_log(f"用户选择文件: {file_path}") + self.open_local_file(file_path) + + def open_local_file(self, file_path, cloud_id=None): + """ + 打开本地文件 + 修复:使用修复后的解密函数,支持单独密钥模式 + """ + ext = os.path.splitext(file_path)[1].lower() + if DEBUG: + debug_log(f"打开本地文件: {file_path}, 类型: {ext}") + + if ext == '.txt': + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + tab = EditorTab(self.tabs, self.tabs.count(), + file_path=file_path, cloud_id=cloud_id, + file_type='txt', content=content) + self.tabs.addTab(tab.text_edit, os.path.basename(file_path)) + self.editor_tabs.append(tab) + self.tabs.setCurrentIndex(self.tabs.count() - 1) + except Exception as e: + debug_error(f"读取文件失败: {e}") + QMessageBox.critical(self, "错误", f"读取文件失败: {str(e)}") + + elif ext == '.bjb': + # 本地文件,is_cloud_file=False,会优先查找同目录.key文件 + if DEBUG: + debug_log("调用解密函数(本地文件模式)") + content, success = decrypt_bjb_file(file_path, self, is_cloud_file=False) + if success and content is not None: + tab = EditorTab(self.tabs, self.tabs.count(), + file_path=file_path, cloud_id=cloud_id, + file_type='bjb', content=content) + self.tabs.addTab(tab.text_edit, os.path.basename(file_path)) + self.editor_tabs.append(tab) + self.tabs.setCurrentIndex(self.tabs.count() - 1) + else: + if DEBUG: + debug_log("解密失败,不创建选项卡") + + def open_cloud_file_dialog(self): + """打开云端文件对话框""" + if not self.client.token: + QMessageBox.warning(self, "提示", "请先登录") + self.check_login() + return + dialog = CloudFileTreeDialog(self.client, self) + dialog.exec_() + + def save_current_tab(self): + """保存当前选项卡 - 加密文件直接使用原有配置静默保存""" + idx = self.tabs.currentIndex() + if idx < 0 or idx >= len(self.editor_tabs): + if DEBUG: + debug_log(f"保存失败: 无效索引 {idx}") + return + + tab = self.editor_tabs[idx] + content = tab.get_content() + + if DEBUG: + debug_log(f"保存当前选项卡: 索引 {idx}, 类型: {tab.file_type}, 新文件: {tab.is_new}") + debug_log(f"内容长度: {len(content)}字符") + + # 新文件:调用另存为(会弹出配置对话框) + if tab.is_new: + if DEBUG: + debug_log("新文件,调用另存为") + self.save_as_current_tab() + return + + # 已有文件路径 + if tab.file_path and os.path.exists(tab.file_path): + try: + if tab.file_type == 'txt': + # 文本文件:直接写入 + if DEBUG: + debug_log(f"保存文本文件: {tab.file_path}") + with open(tab.file_path, 'w', encoding='utf-8') as f: + f.write(content) + tab.mark_saved() + self.status_bar.showMessage(f"已保存: {os.path.basename(tab.file_path)}", 3000) + if DEBUG: + debug_log("文本文件保存成功") + + else: # bjb 加密文件 + if DEBUG: + debug_log(f"保存加密文件: {tab.file_path}") + + # 检查是否有加密配置 + if hasattr(tab, 'encrypt_config') and tab.encrypt_config: + config = tab.encrypt_config + if DEBUG: + debug_log(f"使用现有加密配置 - 算法: {config.get('algorithm')}, 密钥模式: {config.get('key_mode')}, 密码保护: {config.get('use_password', False)}") + + # 使用原有配置加密(静默保存,不弹窗) + encrypted_data, _, _ = EncryptedNoteHandler.encrypt_content( + content, + config.get('algorithm', 'rsa'), + config.get('key_mode', 'global'), + config.get('custom_password'), + config.get('single_private'), + config.get('single_public'), + strict_password=config.get('strict_password', True) + ) + + with open(tab.file_path, 'wb') as f: + f.write(encrypted_data) + + tab.mark_saved() + self.status_bar.showMessage(f"已保存: {os.path.basename(tab.file_path)}", 3000) + if DEBUG: + debug_log("加密文件保存成功(使用现有配置,静默保存)") + else: + # 没有加密配置(理论上不应该发生,但作为后备) + if DEBUG: + debug_log("加密配置缺失,弹出配置对话框") + dialog = NewEncryptedFileDialog(self) + if dialog.exec_() == QDialog.Accepted: + config = dialog.get_config() + tab.encrypt_config = config + encrypted_data, _, _ = EncryptedNoteHandler.encrypt_content( + content, config['algorithm'], config['key_mode'], + config.get('custom_password'), + config.get('single_private'), + config.get('single_public'), + strict_password=config.get('strict_password', True) + ) + with open(tab.file_path, 'wb') as f: + f.write(encrypted_data) + tab.mark_saved() + self.status_bar.showMessage(f"已保存: {os.path.basename(tab.file_path)}", 3000) + if DEBUG: + debug_log("加密文件保存成功(新配置)") + else: + if DEBUG: + debug_log("用户取消保存") + return + except Exception as e: + debug_error(f"保存失败: {e}") + QMessageBox.critical(self, "保存失败", str(e)) + else: + # 文件路径无效,调用另存为 + if DEBUG: + debug_log("文件路径无效,调用另存为") + self.save_as_current_tab() + + def save_as_current_tab(self): + """另存为当前选项卡 - 首次保存时弹出配置对话框""" + idx = self.tabs.currentIndex() + if idx < 0 or idx >= len(self.editor_tabs): + if DEBUG: + debug_log(f"另存为失败: 无效索引 {idx}") + return + + tab = self.editor_tabs[idx] + content = tab.get_content() + + if DEBUG: + debug_log(f"另存为当前选项卡: 索引 {idx}, 类型: {tab.file_type}") + debug_log(f"内容长度: {len(content)}字符") + + if tab.file_type == 'txt': + # 文本文件另存为 + if DEBUG: + debug_log("另存为文本文件") + file_path, _ = QFileDialog.getSaveFileName(self, "保存文件", "", "文本文件 (*.txt)") + if not file_path: + if DEBUG: + debug_log("用户取消保存") + return + if not file_path.endswith('.txt'): + file_path += '.txt' + try: + with open(file_path, 'w', encoding='utf-8') as f: + f.write(content) + tab.file_path = file_path + tab.is_new = False + tab.mark_saved() + if DEBUG: + debug_log(f"文本文件另存为成功: {file_path}") + self.status_bar.showMessage(f"已保存: {os.path.basename(file_path)}", 3000) + except Exception as e: + debug_error(f"保存失败: {e}") + QMessageBox.critical(self, "保存失败", str(e)) + + else: # bjb 加密文件 + if DEBUG: + debug_log("另存为加密文件") + + # 获取加密配置 + if hasattr(tab, 'encrypt_config') and tab.encrypt_config: + # 已有配置,直接使用(但另存为时应该让用户选择新位置) + config = tab.encrypt_config + if DEBUG: + debug_log("使用选项卡中的现有加密配置") + else: + # 没有配置,弹出配置对话框 + if DEBUG: + debug_log("显示加密配置对话框") + dialog = NewEncryptedFileDialog(self) + if dialog.exec_() != QDialog.Accepted: + if DEBUG: + debug_log("用户取消加密配置") + return + config = dialog.get_config() + tab.encrypt_config = config + if DEBUG: + debug_log("加密配置获取完成") + + # 弹出保存文件对话框 + save_dialog = SaveEncryptedFileDialog( + content, config['algorithm'], config['key_mode'], + config['use_password'], config.get('strict_password', True), + config.get('custom_password'), + config.get('single_private'), config.get('single_public'), + self + ) + if save_dialog.exec_() == QDialog.Accepted: + tab.file_type = 'bjb' + tab.encrypt_config = config + tab.is_new = False + if save_dialog.selected_file_path: + tab.file_path = save_dialog.selected_file_path + tab.mark_saved() + if DEBUG: + debug_log(f"加密文件另存为成功: {tab.file_path}") + + def encrypt_current_file(self): + """加密当前文件""" + if DEBUG: + debug_log("加密当前文件功能被调用") + idx = self.tabs.currentIndex() + + if idx < 0 or idx >= len(self.editor_tabs): + QMessageBox.warning(self, "提示", "没有打开的文件") + return + + tab = self.editor_tabs[idx] + content = tab.get_content() + + if not content.strip(): + ret = QMessageBox.question(self, "确认", "文件内容为空,是否继续加密?", + QMessageBox.Yes | QMessageBox.No) + if ret != QMessageBox.Yes: + return + + dialog = EncryptCurrentFileDialog(self) + if dialog.exec_() != QDialog.Accepted: + return + + config = dialog.get_config() + + default_name = "encrypted_note.bjb" + if tab.file_path: + base_name = os.path.splitext(os.path.basename(tab.file_path))[0] + default_name = f"{base_name}_encrypted.bjb" + + file_path, _ = QFileDialog.getSaveFileName(self, "保存加密文件", default_name, "加密笔记 (*.bjb)") + if not file_path: + return + + if not file_path.endswith('.bjb'): + file_path += '.bjb' + + try: + encrypted_data, priv, pub = EncryptedNoteHandler.encrypt_content( + content, config['algorithm'], config['key_mode'], + config['custom_password'], + config.get('single_private'), + config.get('single_public'), + strict_password=config.get('strict_password', True) + ) + + with open(file_path, 'wb') as f: + f.write(encrypted_data) + + if config['key_mode'] == 'single' and priv: + key_path = file_path + '.key' + with open(key_path, 'wb') as f: + f.write(priv) + QMessageBox.information(self, "加密成功", + f"文件已加密保存为:\n{file_path}\n\n" + f"密钥文件已保存为:\n{key_path}\n\n" + f"请妥善保管密钥文件!") + else: + QMessageBox.information(self, "加密成功", + f"文件已加密保存为:\n{file_path}\n\n" + f"使用全局密钥加密,无需额外保存密钥文件。") + + ret = QMessageBox.question(self, "打开文件", + "是否在新选项卡中打开加密后的文件?", + QMessageBox.Yes | QMessageBox.No) + if ret == QMessageBox.Yes: + self.open_local_file(file_path) + + except Exception as e: + debug_error(f"加密失败: {e}") + QMessageBox.critical(self, "加密失败", str(e)) + + def convert_txt_to_bjb(self): + """将当前文本文件转换为加密笔记""" + if DEBUG: + debug_log("TXT转BJB功能被调用") + idx = self.tabs.currentIndex() + + if idx < 0 or idx >= len(self.editor_tabs): + QMessageBox.warning(self, "提示", "没有打开的文件") + return + + tab = self.editor_tabs[idx] + + if tab.file_type == 'bjb': + QMessageBox.information(self, "提示", "当前文件已经是加密笔记格式") + return + + content = tab.get_content() + + dialog = EncryptCurrentFileDialog(self) + if dialog.exec_() != QDialog.Accepted: + return + + config = dialog.get_config() + + default_name = os.path.splitext(os.path.basename(tab.file_path))[0] + ".bjb" if tab.file_path else "converted.bjb" + file_path, _ = QFileDialog.getSaveFileName(self, "保存为加密笔记", default_name, "加密笔记 (*.bjb)") + if not file_path: + return + + if not file_path.endswith('.bjb'): + file_path += '.bjb' + + try: + encrypted_data, priv, pub = EncryptedNoteHandler.encrypt_content( + content, config['algorithm'], config['key_mode'], + config['custom_password'], + config.get('single_private'), + config.get('single_public'), + strict_password=config.get('strict_password', True) + ) + + with open(file_path, 'wb') as f: + f.write(encrypted_data) + + if config['key_mode'] == 'single' and priv: + key_path = file_path + '.key' + with open(key_path, 'wb') as f: + f.write(priv) + QMessageBox.information(self, "转换成功", + f"文件已转换为加密笔记:\n{file_path}\n\n" + f"密钥文件已保存为:\n{key_path}\n\n" + f"请妥善保管密钥文件!") + else: + QMessageBox.information(self, "转换成功", + f"文件已转换为加密笔记:\n{file_path}\n\n" + f"使用全局密钥加密,无需额外保存密钥文件。") + + ret = QMessageBox.question(self, "打开文件", + "是否在新选项卡中打开加密后的文件?", + QMessageBox.Yes | QMessageBox.No) + if ret == QMessageBox.Yes: + self.open_local_file(file_path) + + except Exception as e: + debug_error(f"转换失败: {e}") + QMessageBox.critical(self, "转换失败", str(e)) + + def upload_current_tab(self): + """上传当前文件到云端""" + if not self.client.token: + QMessageBox.warning(self, "提示", "请先登录") + self.check_login() + return + + idx = self.tabs.currentIndex() + if idx < 0 or idx >= len(self.editor_tabs): + return + tab = self.editor_tabs[idx] + + if tab.is_new or not tab.file_path: + QMessageBox.warning(self, "提示", "请先保存文件到本地") + return + + if tab.modified: + ret = QMessageBox.question(self, "提示", "文件未保存,是否先保存?", QMessageBox.Yes | QMessageBox.No) + if ret == QMessageBox.Yes: + self.save_current_tab() + + folder, ok = QInputDialog.getText(self, "上传到云端", "请输入云端目录路径 (默认为 /):", text="/") + if not ok: + return + if not folder: + folder = "/" + + res = self.client.upload_file(tab.file_path, folder) + if res: + QMessageBox.information(self, "成功", "文件已上传到云端") + else: + QMessageBox.warning(self, "错误", "上传失败") + + def show_cloud_manager(self): + """显示云端管理器""" + if not self.client.token: + QMessageBox.warning(self, "提示", "请先登录") + self.check_login() + return + dialog = CloudFileTreeDialog(self.client, self) + dialog.exec_() + + def on_tab_changed(self, index): + """当前选项卡改变时的回调""" + if index >= 0 and index < len(self.editor_tabs): + self.current_editor = self.editor_tabs[index] + else: + self.current_editor = None + + def close_tab(self, index): + """关闭指定索引的选项卡""" + if index < 0 or index >= len(self.editor_tabs): + return + + tab = self.editor_tabs[index] + if tab.modified: + ret = QMessageBox.question(self, "提示", "文件已修改,是否保存?", + QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel) + if ret == QMessageBox.Yes: + self.save_current_tab() + elif ret == QMessageBox.Cancel: + return + + self.tabs.removeTab(index) + self.editor_tabs.pop(index) + + for i, t in enumerate(self.editor_tabs): + t.index = i + + current_idx = self.tabs.currentIndex() + if current_idx >= 0 and current_idx < len(self.editor_tabs): + self.current_editor = self.editor_tabs[current_idx] + else: + self.current_editor = None + + def about(self): + """关于对话框""" + QMessageBox.about(self, "关于云笔记", + "云笔记 v3.0\n\n" + "功能特性:\n" + "• 支持 .txt 文本文件\n" + "• 支持 .bjb 加密笔记\n" + "• 云端同步功能\n" + "• 多选项卡编辑\n" + "• 加密当前文件\n" + "• TXT转BJB加密\n" + "• 支持高强度/低强度密码模式\n" + "• 端到端加密:服务器不存储私钥\n\n" + "加密模式:\n" + "1. RSA-4096 + AES-256-GCM\n" + "2. X25519 + AES-256-GCM\n\n" + "密钥方式:\n" + "• 默认加密:全局密钥对\n" + "• 安全加密:单独生成密钥对\n\n" + "快捷键:\n" + "• Ctrl+N: 新建文本\n" + "• Ctrl+Shift+N: 新建加密笔记\n" + "• Ctrl+E: 加密当前文件\n" + "• Ctrl+Shift+S: 另存为加密笔记\n" + "• Ctrl+S: 保存\n" + "• Ctrl+O: 打开文件\n\n" + f"密钥存储位置:\n" + f"• 用户目录: {USER_KEY_DIR}\n" + f"• 程序目录: {APP_KEY_DIR}") + + +def main(): + """主函数""" + if DEBUG: + debug_log("=" * 50) + debug_log("云笔记应用程序启动") + debug_log("=" * 50) + + app = QApplication(sys.argv) + app.setStyle('Fusion') + + ensure_global_keys() + + window = MainWindow() + window.show() + + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() \ No newline at end of file