feat: 增加认证更新配置与自动检查 (#94)
This commit is contained in:
@@ -16,6 +16,7 @@ from PyQt5.QtWidgets import (
|
||||
QFormLayout,
|
||||
QHeaderView,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -210,6 +211,7 @@ class SettingsPage(QWidget):
|
||||
admin_gateway=None,
|
||||
android_device_service=None,
|
||||
update_service=None,
|
||||
update_credential_store=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("settingsPage")
|
||||
@@ -224,6 +226,7 @@ class SettingsPage(QWidget):
|
||||
admin_gateway=admin_gateway,
|
||||
android_device_service=android_device_service,
|
||||
update_service=update_service,
|
||||
update_credential_store=update_credential_store,
|
||||
)
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
@@ -301,10 +304,20 @@ class SettingsPage(QWidget):
|
||||
)
|
||||
self.updateManifestUrlInput.setClearButtonEnabled(True)
|
||||
self.updateManifestUrlInput.setAccessibleName("在线更新清单地址")
|
||||
self.updateUsernameInput = LineEdit(self)
|
||||
self.updateUsernameInput.setClearButtonEnabled(True)
|
||||
self.updateUsernameInput.setAccessibleName("在线更新账号")
|
||||
self.updatePasswordInput = LineEdit(self)
|
||||
self.updatePasswordInput.setEchoMode(QLineEdit.Password)
|
||||
self.updatePasswordInput.setClearButtonEnabled(True)
|
||||
self.updatePasswordInput.setAccessibleName("在线更新密码")
|
||||
self.updatePasswordInput.setPlaceholderText("请输入密码")
|
||||
self.updateSaveButton = PushButton(FIF.SAVE, "保存", self)
|
||||
self.updateSaveButton.setAccessibleName("安全保存在线更新设置")
|
||||
self.updateCheckButton = PushButton(FIF.UPDATE, "检查更新", self)
|
||||
self.updateCheckButton.setAccessibleName("检查并下载软件更新")
|
||||
self.updateStatusLabel = CaptionLabel(
|
||||
"尚未检查;请填写 HTTPS 更新清单地址", self
|
||||
"尚未保存更新账号和密码", self
|
||||
)
|
||||
self.updateStatusLabel.setAccessibleName("软件更新状态")
|
||||
self.updateStatusLabel.setWordWrap(True)
|
||||
@@ -424,13 +437,20 @@ class SettingsPage(QWidget):
|
||||
form.setVerticalSpacing(12)
|
||||
versionLabel = CaptionLabel("当前版本", card)
|
||||
manifestLabel = CaptionLabel("清单地址", card)
|
||||
usernameLabel = CaptionLabel("账号", card)
|
||||
passwordLabel = CaptionLabel("密码", card)
|
||||
manifestLabel.setBuddy(self.updateManifestUrlInput)
|
||||
usernameLabel.setBuddy(self.updateUsernameInput)
|
||||
passwordLabel.setBuddy(self.updatePasswordInput)
|
||||
form.addRow(versionLabel, self.currentVersionLabel)
|
||||
form.addRow(manifestLabel, self.updateManifestUrlInput)
|
||||
form.addRow(usernameLabel, self.updateUsernameInput)
|
||||
form.addRow(passwordLabel, self.updatePasswordInput)
|
||||
layout.addLayout(form)
|
||||
|
||||
commandLayout = QHBoxLayout()
|
||||
commandLayout.addStretch(1)
|
||||
commandLayout.addWidget(self.updateSaveButton)
|
||||
commandLayout.addWidget(self.updateCheckButton)
|
||||
layout.addLayout(commandLayout)
|
||||
return card
|
||||
|
||||
@@ -310,6 +310,7 @@ class SettingsPageEventBinder(QObject):
|
||||
admin_gateway: Optional[ClientRegistrationGateway] = None,
|
||||
android_device_service: Optional[AndroidDeviceService] = None,
|
||||
update_service=None,
|
||||
update_credential_store=None,
|
||||
):
|
||||
super().__init__(page)
|
||||
self._page = page
|
||||
@@ -349,6 +350,7 @@ class SettingsPageEventBinder(QObject):
|
||||
page,
|
||||
repository,
|
||||
service=update_service,
|
||||
credential_store=update_credential_store,
|
||||
parent=self,
|
||||
)
|
||||
self._client_service = CurrentClientService(repository)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -16,7 +17,7 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Callable, Optional
|
||||
|
||||
@@ -28,6 +29,10 @@ MAX_MANIFEST_BYTES = 1024 * 1024
|
||||
MAX_UPDATE_BYTES = 500 * 1024 * 1024
|
||||
MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024
|
||||
UPDATE_MANIFEST_SETTING = "update.manifest_url"
|
||||
UPDATE_USERNAME_SETTING = "update.username"
|
||||
DEFAULT_UPDATE_MANIFEST_URL = "http://cm.xiapi.com/autobuy——manifest.json"
|
||||
DEFAULT_UPDATE_USERNAME = "admin"
|
||||
_ALLOWED_HTTP_UPDATE_HOST = "cm.xiapi.com"
|
||||
_VERSION_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$")
|
||||
_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
@@ -56,6 +61,14 @@ class UpdateCancelled(UpdateError):
|
||||
"""用户关闭页面后取消继续处理更新。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpdateCredentials:
|
||||
"""仅在内存中使用的更新服务器 Basic Authentication 凭据。"""
|
||||
|
||||
username: str
|
||||
password: str = field(repr=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpdateInfo:
|
||||
"""清单中一份可下载更新的信息。"""
|
||||
@@ -93,12 +106,25 @@ def parse_version(version: str) -> tuple[int, int, int, int]:
|
||||
|
||||
|
||||
def validate_manifest_url(url: str) -> str:
|
||||
"""验证并返回只允许 HTTPS、且不含凭据的清单地址。"""
|
||||
"""验证更新地址;HTTP 只对白名单发布主机开放。"""
|
||||
|
||||
normalized = url.strip()
|
||||
parsed = urllib.parse.urlsplit(normalized)
|
||||
if parsed.scheme.lower() != "https" or not parsed.hostname:
|
||||
raise UpdateConfigurationError("更新清单地址必须是有效的 HTTPS 地址")
|
||||
scheme = parsed.scheme.lower()
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise UpdateConfigurationError("更新清单地址端口无效") from exc
|
||||
is_https = scheme == "https" and bool(parsed.hostname)
|
||||
is_allowed_http = (
|
||||
scheme == "http"
|
||||
and (parsed.hostname or "").lower() == _ALLOWED_HTTP_UPDATE_HOST
|
||||
and port in {None, 80}
|
||||
)
|
||||
if not is_https and not is_allowed_http:
|
||||
raise UpdateConfigurationError(
|
||||
"更新清单地址必须使用 HTTPS;HTTP 只允许固定发布服务器"
|
||||
)
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise UpdateConfigurationError("更新清单地址不能包含账号或密码")
|
||||
if parsed.query:
|
||||
@@ -110,24 +136,54 @@ def validate_manifest_url(url: str) -> str:
|
||||
|
||||
def _origin(url: str) -> tuple[str, str, int]:
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
return (
|
||||
parsed.scheme.lower(),
|
||||
scheme,
|
||||
(parsed.hostname or "").lower(),
|
||||
parsed.port or 443,
|
||||
parsed.port or (443 if scheme == "https" else 80),
|
||||
)
|
||||
|
||||
|
||||
def _ascii_request_url(url: str) -> str:
|
||||
"""只编码 URL 路径中的非 ASCII 字符,避免重复编码已有百分号。"""
|
||||
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
encoded_path = urllib.parse.quote(parsed.path, safe="/%")
|
||||
return urllib.parse.urlunsplit(
|
||||
(parsed.scheme, parsed.netloc, encoded_path, parsed.query, parsed.fragment)
|
||||
)
|
||||
|
||||
|
||||
class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""在发送认证头之前拒绝跨源重定向。"""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
validated_url = validate_manifest_url(newurl)
|
||||
if _origin(req.full_url) != _origin(validated_url):
|
||||
raise UpdateConfigurationError("更新请求不允许跨服务器重定向")
|
||||
return super().redirect_request(
|
||||
req,
|
||||
fp,
|
||||
code,
|
||||
msg,
|
||||
headers,
|
||||
_ascii_request_url(validated_url),
|
||||
)
|
||||
|
||||
|
||||
class UpdateService:
|
||||
"""检查并把更新安全暂存到 ``data/update``。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
update_directory: Optional[Path] = None,
|
||||
urlopen: Callable = urllib.request.urlopen,
|
||||
urlopen: Optional[Callable] = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
):
|
||||
self._update_directory = update_directory or (data_dir() / "update")
|
||||
self._urlopen = urlopen
|
||||
self._urlopen = urlopen or urllib.request.build_opener(
|
||||
_SameOriginRedirectHandler()
|
||||
).open
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
@property
|
||||
@@ -139,6 +195,7 @@ class UpdateService:
|
||||
manifest_url: str,
|
||||
current_version: str = __version__,
|
||||
is_cancelled: Optional[Callable[[], bool]] = None,
|
||||
credentials: Optional[UpdateCredentials] = None,
|
||||
) -> UpdateCheckResult:
|
||||
"""下载并解析清单,返回是否存在新版本。"""
|
||||
|
||||
@@ -148,6 +205,7 @@ class UpdateService:
|
||||
configured_url,
|
||||
MAX_MANIFEST_BYTES,
|
||||
is_cancelled,
|
||||
credentials,
|
||||
)
|
||||
try:
|
||||
manifest = json.loads(content.decode("utf-8-sig"))
|
||||
@@ -168,6 +226,7 @@ class UpdateService:
|
||||
info: UpdateInfo,
|
||||
is_cancelled: Optional[Callable[[], bool]] = None,
|
||||
on_progress: Optional[Callable[[int], None]] = None,
|
||||
credentials: Optional[UpdateCredentials] = None,
|
||||
) -> Path:
|
||||
"""下载、校验并安全解压更新,返回暂存的 ``app.new``。"""
|
||||
|
||||
@@ -184,10 +243,7 @@ class UpdateService:
|
||||
bytes_written = 0
|
||||
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
info.update_url,
|
||||
headers={"User-Agent": f"CMAutoBuy/{__version__}"},
|
||||
)
|
||||
request = self._make_request(info.update_url, credentials)
|
||||
with self._open(request) as response:
|
||||
final_url = validate_manifest_url(response.geturl())
|
||||
if _origin(final_url) != _origin(info.manifest_url):
|
||||
@@ -294,11 +350,9 @@ class UpdateService:
|
||||
url: str,
|
||||
maximum_bytes: int,
|
||||
is_cancelled: Optional[Callable[[], bool]],
|
||||
credentials: Optional[UpdateCredentials],
|
||||
) -> tuple[bytes, str]:
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={"User-Agent": f"CMAutoBuy/{__version__}"},
|
||||
)
|
||||
request = self._make_request(url, credentials)
|
||||
with self._open(request) as response:
|
||||
final_url = validate_manifest_url(response.geturl())
|
||||
declared_size = self._content_length(response)
|
||||
@@ -317,6 +371,22 @@ class UpdateService:
|
||||
chunks.append(block)
|
||||
return b"".join(chunks), final_url
|
||||
|
||||
@staticmethod
|
||||
def _make_request(
|
||||
url: str,
|
||||
credentials: Optional[UpdateCredentials],
|
||||
) -> urllib.request.Request:
|
||||
headers = {"User-Agent": f"CMAutoBuy/{__version__}"}
|
||||
if credentials is not None:
|
||||
username = credentials.username.strip()
|
||||
if not username or not credentials.password:
|
||||
raise UpdateConfigurationError("更新账号和密码不能为空")
|
||||
raw = f"{username}:{credentials.password}".encode("utf-8")
|
||||
headers["Authorization"] = "Basic " + base64.b64encode(raw).decode(
|
||||
"ascii"
|
||||
)
|
||||
return urllib.request.Request(_ascii_request_url(url), headers=headers)
|
||||
|
||||
def _open(self, request):
|
||||
try:
|
||||
return self._urlopen(request, timeout=self._timeout_seconds)
|
||||
|
||||
+185
-45
@@ -1,25 +1,34 @@
|
||||
"""设置页在线更新事件和 Qt 后台 Worker。"""
|
||||
"""设置页在线更新配置、事件和 Qt 后台 Worker。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5.QtCore import QObject, QThread, pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtCore import QObject, QThread, QTimer, pyqtSignal, pyqtSlot
|
||||
from qfluentwidgets import MessageBox
|
||||
|
||||
from .settings_repository import SettingsRepository
|
||||
from .update_service import (
|
||||
DEFAULT_UPDATE_MANIFEST_URL,
|
||||
DEFAULT_UPDATE_USERNAME,
|
||||
UPDATE_MANIFEST_SETTING,
|
||||
UPDATE_USERNAME_SETTING,
|
||||
UpdateCancelled,
|
||||
UpdateCheckResult,
|
||||
UpdateCredentials,
|
||||
UpdateInfo,
|
||||
UpdateService,
|
||||
validate_manifest_url,
|
||||
)
|
||||
from .windows_credential_store import (
|
||||
CredentialStoreError,
|
||||
WindowsCredentialStore,
|
||||
)
|
||||
|
||||
|
||||
class UpdateCheckWorker(QObject):
|
||||
"""在线程中保存清单地址并检查新版本。"""
|
||||
"""在线程中使用内存凭据检查新版本。"""
|
||||
|
||||
succeeded = pyqtSignal(object)
|
||||
failed = pyqtSignal(str)
|
||||
@@ -28,13 +37,13 @@ class UpdateCheckWorker(QObject):
|
||||
def __init__(
|
||||
self,
|
||||
service: UpdateService,
|
||||
repository: SettingsRepository,
|
||||
manifest_url: str,
|
||||
credentials: UpdateCredentials,
|
||||
):
|
||||
super().__init__()
|
||||
self._service = service
|
||||
self._repository = repository
|
||||
self._manifest_url = manifest_url
|
||||
self._credentials = credentials
|
||||
self._cancelled = False
|
||||
|
||||
def cancel(self) -> None:
|
||||
@@ -43,13 +52,10 @@ class UpdateCheckWorker(QObject):
|
||||
@pyqtSlot()
|
||||
def run(self) -> None:
|
||||
try:
|
||||
normalized_url = validate_manifest_url(self._manifest_url)
|
||||
if self._cancelled:
|
||||
return
|
||||
self._repository.set(UPDATE_MANIFEST_SETTING, normalized_url)
|
||||
result = self._service.check(
|
||||
normalized_url,
|
||||
self._manifest_url,
|
||||
is_cancelled=lambda: self._cancelled,
|
||||
credentials=self._credentials,
|
||||
)
|
||||
if not self._cancelled:
|
||||
self.succeeded.emit(result)
|
||||
@@ -59,21 +65,28 @@ class UpdateCheckWorker(QObject):
|
||||
if not self._cancelled:
|
||||
self.failed.emit(str(exc) or "检查更新失败")
|
||||
finally:
|
||||
self._credentials = None
|
||||
self.completed.emit()
|
||||
|
||||
|
||||
class UpdateDownloadWorker(QObject):
|
||||
"""在线程中下载、校验并安全暂存更新。"""
|
||||
"""在线程中认证下载、校验并安全暂存更新。"""
|
||||
|
||||
progressChanged = pyqtSignal(int)
|
||||
succeeded = pyqtSignal(str)
|
||||
failed = pyqtSignal(str)
|
||||
completed = pyqtSignal()
|
||||
|
||||
def __init__(self, service: UpdateService, update: UpdateInfo):
|
||||
def __init__(
|
||||
self,
|
||||
service: UpdateService,
|
||||
update: UpdateInfo,
|
||||
credentials: UpdateCredentials,
|
||||
):
|
||||
super().__init__()
|
||||
self._service = service
|
||||
self._update = update
|
||||
self._credentials = credentials
|
||||
self._cancelled = False
|
||||
|
||||
def cancel(self) -> None:
|
||||
@@ -86,6 +99,7 @@ class UpdateDownloadWorker(QObject):
|
||||
self._update,
|
||||
is_cancelled=lambda: self._cancelled,
|
||||
on_progress=self.progressChanged.emit,
|
||||
credentials=self._credentials,
|
||||
)
|
||||
if not self._cancelled:
|
||||
self.succeeded.emit(self._update.version)
|
||||
@@ -95,56 +109,159 @@ class UpdateDownloadWorker(QObject):
|
||||
if not self._cancelled:
|
||||
self.failed.emit(str(exc) or "下载更新失败")
|
||||
finally:
|
||||
self._credentials = None
|
||||
self.completed.emit()
|
||||
|
||||
|
||||
class UpdateUiEventBinder(QObject):
|
||||
"""管理设置页更新按钮、反馈和两个后台线程。"""
|
||||
"""管理更新配置、保存反馈和两个后台线程。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
page,
|
||||
repository: SettingsRepository,
|
||||
service: Optional[UpdateService] = None,
|
||||
credential_store=None,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent or page)
|
||||
self._page = page
|
||||
self._repository = repository
|
||||
self._service = service or UpdateService()
|
||||
self._credential_store_override = credential_store
|
||||
self._closing = False
|
||||
self._check_thread: Optional[QThread] = None
|
||||
self._check_worker: Optional[UpdateCheckWorker] = None
|
||||
self._download_thread: Optional[QThread] = None
|
||||
self._download_worker: Optional[UpdateDownloadWorker] = None
|
||||
|
||||
saved_url = repository.get(UPDATE_MANIFEST_SETTING, "")
|
||||
page.updateManifestUrlInput.setText(
|
||||
saved_url if isinstance(saved_url, str) else ""
|
||||
saved_url_record = repository.get_record(UPDATE_MANIFEST_SETTING)
|
||||
saved_username_record = repository.get_record(UPDATE_USERNAME_SETTING)
|
||||
saved_url = (
|
||||
saved_url_record.value
|
||||
if saved_url_record is not None and isinstance(saved_url_record.value, str)
|
||||
else DEFAULT_UPDATE_MANIFEST_URL
|
||||
)
|
||||
saved_username = (
|
||||
saved_username_record.value
|
||||
if saved_username_record is not None
|
||||
and isinstance(saved_username_record.value, str)
|
||||
else DEFAULT_UPDATE_USERNAME
|
||||
)
|
||||
page.updateManifestUrlInput.setText(saved_url)
|
||||
page.updateUsernameInput.setText(saved_username)
|
||||
page.updatePasswordInput.clear()
|
||||
self._saved_url = saved_url if saved_url_record is not None else ""
|
||||
self._saved_username = (
|
||||
saved_username if saved_username_record is not None else ""
|
||||
)
|
||||
|
||||
credential_ready = self._set_password_placeholder(saved_url)
|
||||
page.updateSaveButton.clicked.connect(self.request_save)
|
||||
page.updateCheckButton.clicked.connect(self.request_check)
|
||||
self._sync_button()
|
||||
self._sync_controls()
|
||||
|
||||
if self._saved_url and self._saved_username and credential_ready:
|
||||
QTimer.singleShot(0, self.request_check)
|
||||
|
||||
def _credential_store_for(self, manifest_url: str):
|
||||
if self._credential_store_override is not None:
|
||||
return self._credential_store_override
|
||||
return WindowsCredentialStore.for_url(manifest_url)
|
||||
|
||||
def _set_password_placeholder(self, manifest_url: str) -> bool:
|
||||
try:
|
||||
saved = self._credential_store_for(manifest_url).read()
|
||||
except CredentialStoreError as exc:
|
||||
self._page.updatePasswordInput.setPlaceholderText("凭据管理器不可用")
|
||||
self._page.set_update_status(str(exc))
|
||||
return False
|
||||
if saved is None:
|
||||
self._page.updatePasswordInput.setPlaceholderText("请输入密码")
|
||||
return False
|
||||
self._page.updatePasswordInput.setPlaceholderText(
|
||||
"密码已安全保存;留空表示不修改"
|
||||
)
|
||||
return True
|
||||
|
||||
@pyqtSlot()
|
||||
def request_save(self) -> None:
|
||||
"""显式保存非敏感设置和 Windows 系统凭据。"""
|
||||
|
||||
if self._closing or self._is_busy():
|
||||
return
|
||||
manifest_url = self._page.updateManifestUrlInput.text().strip()
|
||||
username = self._page.updateUsernameInput.text().strip()
|
||||
password = self._page.updatePasswordInput.text()
|
||||
self._page.updateManifestUrlInput.setText(manifest_url)
|
||||
self._page.updateUsernameInput.setText(username)
|
||||
|
||||
try:
|
||||
normalized_url = validate_manifest_url(manifest_url)
|
||||
except Exception as exc:
|
||||
self._page.set_update_status(f"保存失败:{exc}")
|
||||
self._page.updateManifestUrlInput.setFocus()
|
||||
return
|
||||
if not username:
|
||||
self._page.set_update_status("保存失败:更新账号不能为空")
|
||||
self._page.updateUsernameInput.setFocus()
|
||||
return
|
||||
|
||||
try:
|
||||
credential_store = self._credential_store_for(normalized_url)
|
||||
existing = credential_store.read()
|
||||
if password:
|
||||
credential_store.save(username, password)
|
||||
elif existing is None:
|
||||
self._page.set_update_status("保存失败:请填写更新密码")
|
||||
self._page.updatePasswordInput.setFocus()
|
||||
return
|
||||
elif existing[0] != username:
|
||||
self._page.set_update_status("账号已修改,请重新填写密码后保存")
|
||||
self._page.updatePasswordInput.setFocus()
|
||||
return
|
||||
self._repository.set_many(
|
||||
{
|
||||
UPDATE_MANIFEST_SETTING: normalized_url,
|
||||
UPDATE_USERNAME_SETTING: username,
|
||||
}
|
||||
)
|
||||
except (CredentialStoreError, OSError, sqlite3.Error, ValueError) as exc:
|
||||
self._page.set_update_status(f"保存失败:{exc}")
|
||||
return
|
||||
|
||||
self._saved_url = normalized_url
|
||||
self._saved_username = username
|
||||
self._page.updatePasswordInput.clear()
|
||||
self._set_password_placeholder(normalized_url)
|
||||
self._page.set_update_status("更新设置已安全保存")
|
||||
|
||||
@pyqtSlot()
|
||||
def request_check(self) -> None:
|
||||
if self._closing or self._check_thread is not None or self._download_thread is not None:
|
||||
if self._closing or self._is_busy():
|
||||
return
|
||||
manifest_url = self._page.updateManifestUrlInput.text().strip()
|
||||
self._page.updateManifestUrlInput.setText(manifest_url)
|
||||
try:
|
||||
validate_manifest_url(manifest_url)
|
||||
except Exception as exc:
|
||||
self._page.set_update_status(f"无法检查:{exc}")
|
||||
self._page.updateManifestUrlInput.setFocus()
|
||||
username = self._page.updateUsernameInput.text().strip()
|
||||
if not self._saved_url or not self._saved_username:
|
||||
self._page.set_update_status("尚未保存更新账号和密码,请填写后保存")
|
||||
self._page.updatePasswordInput.setFocus()
|
||||
return
|
||||
if (
|
||||
manifest_url != self._saved_url
|
||||
or username != self._saved_username
|
||||
or self._page.updatePasswordInput.text()
|
||||
):
|
||||
self._page.set_update_status("更新设置有修改,请先保存后再检查")
|
||||
self._page.updateSaveButton.setFocus()
|
||||
return
|
||||
|
||||
credentials = self._read_credentials(username)
|
||||
if credentials is None:
|
||||
return
|
||||
|
||||
self._page.set_update_status("正在检查新版本…")
|
||||
thread = QThread(self)
|
||||
worker = UpdateCheckWorker(
|
||||
self._service,
|
||||
self._repository,
|
||||
manifest_url,
|
||||
)
|
||||
worker = UpdateCheckWorker(self._service, manifest_url, credentials)
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.run)
|
||||
worker.succeeded.connect(self._on_check_succeeded)
|
||||
@@ -155,9 +272,26 @@ class UpdateUiEventBinder(QObject):
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
self._check_thread = thread
|
||||
self._check_worker = worker
|
||||
self._sync_button()
|
||||
self._sync_controls()
|
||||
thread.start()
|
||||
|
||||
def _read_credentials(self, expected_username: str) -> Optional[UpdateCredentials]:
|
||||
try:
|
||||
saved = self._credential_store_for(self._saved_url).read()
|
||||
except CredentialStoreError as exc:
|
||||
self._page.set_update_status(f"无法读取更新密码:{exc}")
|
||||
return None
|
||||
if saved is None:
|
||||
self._page.set_update_status("尚未保存更新密码,请填写后保存")
|
||||
self._page.updatePasswordInput.setFocus()
|
||||
return None
|
||||
username, password = saved
|
||||
if username != expected_username:
|
||||
self._page.set_update_status("保存的账号不一致,请重新填写密码并保存")
|
||||
self._page.updatePasswordInput.setFocus()
|
||||
return None
|
||||
return UpdateCredentials(username, password)
|
||||
|
||||
@pyqtSlot(object)
|
||||
def _on_check_succeeded(self, result: UpdateCheckResult) -> None:
|
||||
if self._closing:
|
||||
@@ -181,9 +315,7 @@ class UpdateUiEventBinder(QObject):
|
||||
dialog.cancelButton.setText("暂不下载")
|
||||
dialog.cancelButton.setFocus()
|
||||
if not dialog.exec():
|
||||
self._page.set_update_status(
|
||||
f"发现新版本 {update.version},尚未下载"
|
||||
)
|
||||
self._page.set_update_status(f"发现新版本 {update.version},尚未下载")
|
||||
return
|
||||
self._start_download(update)
|
||||
|
||||
@@ -191,7 +323,7 @@ class UpdateUiEventBinder(QObject):
|
||||
def _on_check_failed(self, message: str) -> None:
|
||||
if not self._closing:
|
||||
self._page.set_update_status(
|
||||
f"检查失败:{message};地址已保留,可以重试"
|
||||
f"检查失败:{message};设置已保留,可以重试"
|
||||
)
|
||||
|
||||
@pyqtSlot()
|
||||
@@ -199,14 +331,17 @@ class UpdateUiEventBinder(QObject):
|
||||
self._check_worker = None
|
||||
self._check_thread = None
|
||||
if not self._closing:
|
||||
self._sync_button()
|
||||
self._sync_controls()
|
||||
|
||||
def _start_download(self, update: UpdateInfo) -> None:
|
||||
if self._closing or self._download_thread is not None:
|
||||
return
|
||||
credentials = self._read_credentials(self._saved_username)
|
||||
if credentials is None:
|
||||
return
|
||||
self._page.set_update_status(f"正在下载版本 {update.version}(0%)…")
|
||||
thread = QThread(self)
|
||||
worker = UpdateDownloadWorker(self._service, update)
|
||||
worker = UpdateDownloadWorker(self._service, update, credentials)
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.run)
|
||||
worker.progressChanged.connect(self._on_download_progress)
|
||||
@@ -218,7 +353,7 @@ class UpdateUiEventBinder(QObject):
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
self._download_thread = thread
|
||||
self._download_worker = worker
|
||||
self._sync_button()
|
||||
self._sync_controls()
|
||||
thread.start()
|
||||
|
||||
@pyqtSlot(int)
|
||||
@@ -245,16 +380,22 @@ class UpdateUiEventBinder(QObject):
|
||||
self._download_worker = None
|
||||
self._download_thread = None
|
||||
if not self._closing:
|
||||
self._sync_button()
|
||||
self._sync_controls()
|
||||
|
||||
def _sync_button(self) -> None:
|
||||
busy = self._check_thread is not None or self._download_thread is not None
|
||||
self._page.updateCheckButton.setEnabled(not self._closing and not busy)
|
||||
self._page.updateManifestUrlInput.setEnabled(not self._closing and not busy)
|
||||
def _is_busy(self) -> bool:
|
||||
return self._check_thread is not None or self._download_thread is not None
|
||||
|
||||
def _sync_controls(self) -> None:
|
||||
enabled = not self._closing and not self._is_busy()
|
||||
self._page.updateSaveButton.setEnabled(enabled)
|
||||
self._page.updateCheckButton.setEnabled(enabled)
|
||||
self._page.updateManifestUrlInput.setEnabled(enabled)
|
||||
self._page.updateUsernameInput.setEnabled(enabled)
|
||||
self._page.updatePasswordInput.setEnabled(enabled)
|
||||
|
||||
@pyqtSlot()
|
||||
def shutdown(self) -> None:
|
||||
"""取消后续处理,断开业务结果并短暂等待线程退出。"""
|
||||
"""取消后续处理,断开业务结果并等待线程安全退出。"""
|
||||
|
||||
if self._closing:
|
||||
return
|
||||
@@ -290,6 +431,5 @@ class UpdateUiEventBinder(QObject):
|
||||
pass
|
||||
if thread is not None and thread.isRunning():
|
||||
thread.quit()
|
||||
# 网络读超时是 10 秒;多等 1 秒,避免关闭窗口时销毁仍在运行的 QThread。
|
||||
thread.wait(11000)
|
||||
self._sync_button()
|
||||
self._sync_controls()
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
发布脚本和程序界面需要版本号时都从这里读取,避免多个文件各写一份。
|
||||
"""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.2.1"
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""使用 Windows 凭据管理器保存在线更新密码。
|
||||
|
||||
密码只在调用期间存在于内存,不进入 SQLite、日志或普通错误信息。本模块只使用
|
||||
Windows 自带 Credential API,不增加第三方依赖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import sys
|
||||
from ctypes import wintypes
|
||||
from typing import Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
UPDATE_CREDENTIAL_TARGET_PREFIX = "CMAutoBuy/update"
|
||||
_CRED_TYPE_GENERIC = 1
|
||||
_CRED_PERSIST_LOCAL_MACHINE = 2
|
||||
_ERROR_NOT_FOUND = 1168
|
||||
|
||||
|
||||
class CredentialStoreError(RuntimeError):
|
||||
"""Windows 凭据管理器操作失败。"""
|
||||
|
||||
|
||||
class _CredentialW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Flags", wintypes.DWORD),
|
||||
("Type", wintypes.DWORD),
|
||||
("TargetName", wintypes.LPWSTR),
|
||||
("Comment", wintypes.LPWSTR),
|
||||
("LastWritten", wintypes.FILETIME),
|
||||
("CredentialBlobSize", wintypes.DWORD),
|
||||
("CredentialBlob", ctypes.POINTER(ctypes.c_ubyte)),
|
||||
("Persist", wintypes.DWORD),
|
||||
("AttributeCount", wintypes.DWORD),
|
||||
("Attributes", wintypes.LPVOID),
|
||||
("TargetAlias", wintypes.LPWSTR),
|
||||
("UserName", wintypes.LPWSTR),
|
||||
]
|
||||
|
||||
|
||||
class WindowsCredentialStore:
|
||||
"""读写当前 Windows 用户的通用凭据。"""
|
||||
|
||||
def __init__(self, target: str):
|
||||
self._target = target
|
||||
if sys.platform != "win32":
|
||||
self._api = None
|
||||
return
|
||||
api = ctypes.WinDLL("Advapi32.dll", use_last_error=True)
|
||||
api.CredWriteW.argtypes = [ctypes.POINTER(_CredentialW), wintypes.DWORD]
|
||||
api.CredWriteW.restype = wintypes.BOOL
|
||||
api.CredReadW.argtypes = [
|
||||
wintypes.LPCWSTR,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(ctypes.POINTER(_CredentialW)),
|
||||
]
|
||||
api.CredReadW.restype = wintypes.BOOL
|
||||
api.CredDeleteW.argtypes = [
|
||||
wintypes.LPCWSTR,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
]
|
||||
api.CredDeleteW.restype = wintypes.BOOL
|
||||
api.CredFree.argtypes = [wintypes.LPVOID]
|
||||
api.CredFree.restype = None
|
||||
self._api = api
|
||||
|
||||
@property
|
||||
def target(self) -> str:
|
||||
"""返回不含密码的系统凭据目标名。"""
|
||||
|
||||
return self._target
|
||||
|
||||
@classmethod
|
||||
def for_url(cls, url: str) -> "WindowsCredentialStore":
|
||||
"""按 URL 源创建独立凭据,避免把一个服务器密码发送给另一个服务器。"""
|
||||
|
||||
parsed = urlsplit(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
if not scheme or not host:
|
||||
raise CredentialStoreError("无法为无效更新地址保存密码")
|
||||
port = parsed.port or (443 if scheme == "https" else 80)
|
||||
return cls(f"{UPDATE_CREDENTIAL_TARGET_PREFIX}/{scheme}/{host}/{port}")
|
||||
|
||||
def save(self, username: str, password: str) -> None:
|
||||
"""保存账号和密码;密码为空时拒绝覆盖已有凭据。"""
|
||||
|
||||
api = self._require_windows()
|
||||
normalized_username = username.strip()
|
||||
if not normalized_username:
|
||||
raise CredentialStoreError("更新账号不能为空")
|
||||
if not password:
|
||||
raise CredentialStoreError("更新密码不能为空")
|
||||
|
||||
password_bytes = password.encode("utf-16-le")
|
||||
if len(password_bytes) > 2560:
|
||||
raise CredentialStoreError("更新密码过长")
|
||||
blob = ctypes.create_string_buffer(password_bytes)
|
||||
credential = _CredentialW()
|
||||
credential.Type = _CRED_TYPE_GENERIC
|
||||
credential.TargetName = self._target
|
||||
credential.CredentialBlobSize = len(password_bytes)
|
||||
credential.CredentialBlob = ctypes.cast(
|
||||
blob,
|
||||
ctypes.POINTER(ctypes.c_ubyte),
|
||||
)
|
||||
credential.Persist = _CRED_PERSIST_LOCAL_MACHINE
|
||||
credential.UserName = normalized_username
|
||||
if not api.CredWriteW(ctypes.byref(credential), 0):
|
||||
raise self._system_error("保存更新凭据失败")
|
||||
|
||||
def read(self) -> Optional[tuple[str, str]]:
|
||||
"""返回保存的 ``(账号, 密码)``;不存在时返回 ``None``。"""
|
||||
|
||||
api = self._require_windows()
|
||||
pointer = ctypes.POINTER(_CredentialW)()
|
||||
if not api.CredReadW(
|
||||
self._target,
|
||||
_CRED_TYPE_GENERIC,
|
||||
0,
|
||||
ctypes.byref(pointer),
|
||||
):
|
||||
error_code = ctypes.get_last_error()
|
||||
if error_code == _ERROR_NOT_FOUND:
|
||||
return None
|
||||
raise self._system_error("读取更新凭据失败", error_code)
|
||||
try:
|
||||
credential = pointer.contents
|
||||
password_bytes = ctypes.string_at(
|
||||
credential.CredentialBlob,
|
||||
credential.CredentialBlobSize,
|
||||
)
|
||||
password = password_bytes.decode("utf-16-le")
|
||||
return credential.UserName or "", password
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise CredentialStoreError("保存的更新凭据无法读取,请重新保存") from exc
|
||||
finally:
|
||||
api.CredFree(pointer)
|
||||
|
||||
def exists(self) -> bool:
|
||||
"""返回是否已经保存更新凭据。"""
|
||||
|
||||
return self.read() is not None
|
||||
|
||||
def delete(self) -> bool:
|
||||
"""删除更新凭据;原本不存在时返回 ``False``。"""
|
||||
|
||||
api = self._require_windows()
|
||||
if api.CredDeleteW(self._target, _CRED_TYPE_GENERIC, 0):
|
||||
return True
|
||||
error_code = ctypes.get_last_error()
|
||||
if error_code == _ERROR_NOT_FOUND:
|
||||
return False
|
||||
raise self._system_error("删除更新凭据失败", error_code)
|
||||
|
||||
def _require_windows(self):
|
||||
if self._api is None:
|
||||
raise CredentialStoreError("更新密码只能保存到 Windows 凭据管理器")
|
||||
return self._api
|
||||
|
||||
@staticmethod
|
||||
def _system_error(message: str, error_code: Optional[int] = None):
|
||||
code = ctypes.get_last_error() if error_code is None else error_code
|
||||
return CredentialStoreError(f"{message}(Windows 错误 {code})")
|
||||
Reference in New Issue
Block a user