新增功能:

- 登录成功后凭据加密落盘(auth.json,DPAPI 同 nodes.json 保护级别)
- 每次启动后台用已保存凭据拉取最新节点配置(内容比对,变化才覆盖):服务端修改 nodes.json 后,客户端下次启动自动同步
- 凭据失效(401)或网络失败时保留本地配置不破坏;登录框在保存过凭据时自动预填,重新登录一键完成
This commit is contained in:
RoyirinRosmontis
2026-08-23 13:45:05 +08:00
parent 1e60db3b11
commit bbf78e655d
+58
View File
@@ -663,6 +663,55 @@ def _nodes_decrypt(blob: bytes) -> str:
except Exception: except Exception:
return blob.decode("utf-8") return blob.decode("utf-8")
def _auth_save(username, password):
"""登录凭据加密保存到用户数据目录(DPAPI,本机本用户可解)。"""
import json
text = json.dumps({"username": username, "password": password}, ensure_ascii=False)
_USER_DIR.mkdir(parents=True, exist_ok=True)
(_USER_DIR / "auth.json").write_bytes(_nodes_encrypt(text))
def _auth_load():
"""读取已保存的登录凭据;不存在或损坏返回 None。"""
try:
import json
f = _USER_DIR / "auth.json"
if not f.is_file():
return None
data = json.loads(_nodes_decrypt(f.read_bytes()))
if not (isinstance(data, dict) and data.get("username") and data.get("password")):
return None
return data
except Exception:
return None
def _nodes_sync_remote():
"""启动时用已保存凭据拉取最新节点配置(内容比对,变化才覆盖)。"""
cred = _auth_load()
if not cred:
return # 从未登录过,等登录框处理
url = str(_config_val("nodes_url", "") or "").strip()
if not url:
return
try:
import json
data = _fetch_nodes_auth(url, cred["username"], cred["password"])
if not (isinstance(data, list) and data
and all(isinstance(n, dict) for n in data)
and str(data[0].get("domain") or "").strip()):
raise ValueError("返回的节点数据无效")
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
local = _USER_DIR / "nodes.json"
if local.is_file() and _nodes_decrypt(local.read_bytes()) == text:
log("节点配置已是最新")
return
_USER_DIR.mkdir(parents=True, exist_ok=True)
local.write_bytes(_nodes_encrypt(text))
global _NODES
_NODES = None # 使后续读取(节点菜单/开启代理)拿到新配置
log(f"节点配置已更新: {len(data)} 个节点 (nodes.json)")
except Exception as e:
log(f"节点配置在线更新失败,使用本地配置: {e}")
def _fetch_nodes_auth(url, user, password, timeout=10): def _fetch_nodes_auth(url, user, password, timeout=10):
"""带 Basic 认证拉取 nodes.json(参与 nginx 认证;直连,不经过系统代理)。""" """带 Basic 认证拉取 nodes.json(参与 nginx 认证;直连,不经过系统代理)。"""
import json import json
@@ -1182,6 +1231,12 @@ class LoginDialog(QDialog):
self.ed_pass.returnPressed.connect(self._submit) self.ed_pass.returnPressed.connect(self._submit)
form.addWidget(self.ed_pass) form.addWidget(self.ed_pass)
# 保存过凭据时预填,重新登录只需点一下
cred = _auth_load()
if cred:
self.ed_user.setText(cred["username"])
self.ed_pass.setText(cred["password"])
self.lb_err = QLabel("") self.lb_err = QLabel("")
self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;") self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
self.lb_err.setFixedHeight(14) self.lb_err.setFixedHeight(14)
@@ -1237,6 +1292,7 @@ class LoginDialog(QDialog):
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n" text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
_USER_DIR.mkdir(parents=True, exist_ok=True) _USER_DIR.mkdir(parents=True, exist_ok=True)
(_USER_DIR / "nodes.json").write_bytes(_nodes_encrypt(text)) (_USER_DIR / "nodes.json").write_bytes(_nodes_encrypt(text))
_auth_save(user, password) # 记住凭据,之后每次启动自动同步节点
self._done.emit(True, str(len(data))) self._done.emit(True, str(len(data)))
except Exception as e: except Exception as e:
msg = ("认证失败,请检查用户名/密码" if getattr(e, "code", None) == 401 msg = ("认证失败,请检查用户名/密码" if getattr(e, "code", None) == 401
@@ -1325,6 +1381,8 @@ class MainWindow(QWidget):
threading.Thread(target=self._check_update, daemon=True).start() threading.Thread(target=self._check_update, daemon=True).start()
# 后台检查 PAC 数据更新(首次启动自动拉取到用户数据目录) # 后台检查 PAC 数据更新(首次启动自动拉取到用户数据目录)
threading.Thread(target=_user_init_remote, daemon=True).start() threading.Thread(target=_user_init_remote, daemon=True).start()
# 后台用已保存凭据同步最新节点配置(服务端改动下次启动生效)
threading.Thread(target=_nodes_sync_remote, daemon=True).start()
# 节点未配置时延迟弹出登录框(等窗口显示后) # 节点未配置时延迟弹出登录框(等窗口显示后)
QTimer.singleShot(600, self._maybe_show_login) QTimer.singleShot(600, self._maybe_show_login)