feat: 增加认证更新配置与自动检查 (#94)
This commit is contained in:
@@ -16,6 +16,7 @@ from PyQt5.QtWidgets import (
|
|||||||
QFormLayout,
|
QFormLayout,
|
||||||
QHeaderView,
|
QHeaderView,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
|
QLineEdit,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
@@ -210,6 +211,7 @@ class SettingsPage(QWidget):
|
|||||||
admin_gateway=None,
|
admin_gateway=None,
|
||||||
android_device_service=None,
|
android_device_service=None,
|
||||||
update_service=None,
|
update_service=None,
|
||||||
|
update_credential_store=None,
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("settingsPage")
|
self.setObjectName("settingsPage")
|
||||||
@@ -224,6 +226,7 @@ class SettingsPage(QWidget):
|
|||||||
admin_gateway=admin_gateway,
|
admin_gateway=admin_gateway,
|
||||||
android_device_service=android_device_service,
|
android_device_service=android_device_service,
|
||||||
update_service=update_service,
|
update_service=update_service,
|
||||||
|
update_credential_store=update_credential_store,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _build_ui(self) -> None:
|
def _build_ui(self) -> None:
|
||||||
@@ -301,10 +304,20 @@ class SettingsPage(QWidget):
|
|||||||
)
|
)
|
||||||
self.updateManifestUrlInput.setClearButtonEnabled(True)
|
self.updateManifestUrlInput.setClearButtonEnabled(True)
|
||||||
self.updateManifestUrlInput.setAccessibleName("在线更新清单地址")
|
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 = PushButton(FIF.UPDATE, "检查更新", self)
|
||||||
self.updateCheckButton.setAccessibleName("检查并下载软件更新")
|
self.updateCheckButton.setAccessibleName("检查并下载软件更新")
|
||||||
self.updateStatusLabel = CaptionLabel(
|
self.updateStatusLabel = CaptionLabel(
|
||||||
"尚未检查;请填写 HTTPS 更新清单地址", self
|
"尚未保存更新账号和密码", self
|
||||||
)
|
)
|
||||||
self.updateStatusLabel.setAccessibleName("软件更新状态")
|
self.updateStatusLabel.setAccessibleName("软件更新状态")
|
||||||
self.updateStatusLabel.setWordWrap(True)
|
self.updateStatusLabel.setWordWrap(True)
|
||||||
@@ -424,13 +437,20 @@ class SettingsPage(QWidget):
|
|||||||
form.setVerticalSpacing(12)
|
form.setVerticalSpacing(12)
|
||||||
versionLabel = CaptionLabel("当前版本", card)
|
versionLabel = CaptionLabel("当前版本", card)
|
||||||
manifestLabel = CaptionLabel("清单地址", card)
|
manifestLabel = CaptionLabel("清单地址", card)
|
||||||
|
usernameLabel = CaptionLabel("账号", card)
|
||||||
|
passwordLabel = CaptionLabel("密码", card)
|
||||||
manifestLabel.setBuddy(self.updateManifestUrlInput)
|
manifestLabel.setBuddy(self.updateManifestUrlInput)
|
||||||
|
usernameLabel.setBuddy(self.updateUsernameInput)
|
||||||
|
passwordLabel.setBuddy(self.updatePasswordInput)
|
||||||
form.addRow(versionLabel, self.currentVersionLabel)
|
form.addRow(versionLabel, self.currentVersionLabel)
|
||||||
form.addRow(manifestLabel, self.updateManifestUrlInput)
|
form.addRow(manifestLabel, self.updateManifestUrlInput)
|
||||||
|
form.addRow(usernameLabel, self.updateUsernameInput)
|
||||||
|
form.addRow(passwordLabel, self.updatePasswordInput)
|
||||||
layout.addLayout(form)
|
layout.addLayout(form)
|
||||||
|
|
||||||
commandLayout = QHBoxLayout()
|
commandLayout = QHBoxLayout()
|
||||||
commandLayout.addStretch(1)
|
commandLayout.addStretch(1)
|
||||||
|
commandLayout.addWidget(self.updateSaveButton)
|
||||||
commandLayout.addWidget(self.updateCheckButton)
|
commandLayout.addWidget(self.updateCheckButton)
|
||||||
layout.addLayout(commandLayout)
|
layout.addLayout(commandLayout)
|
||||||
return card
|
return card
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ class SettingsPageEventBinder(QObject):
|
|||||||
admin_gateway: Optional[ClientRegistrationGateway] = None,
|
admin_gateway: Optional[ClientRegistrationGateway] = None,
|
||||||
android_device_service: Optional[AndroidDeviceService] = None,
|
android_device_service: Optional[AndroidDeviceService] = None,
|
||||||
update_service=None,
|
update_service=None,
|
||||||
|
update_credential_store=None,
|
||||||
):
|
):
|
||||||
super().__init__(page)
|
super().__init__(page)
|
||||||
self._page = page
|
self._page = page
|
||||||
@@ -349,6 +350,7 @@ class SettingsPageEventBinder(QObject):
|
|||||||
page,
|
page,
|
||||||
repository,
|
repository,
|
||||||
service=update_service,
|
service=update_service,
|
||||||
|
credential_store=update_credential_store,
|
||||||
parent=self,
|
parent=self,
|
||||||
)
|
)
|
||||||
self._client_service = CurrentClientService(repository)
|
self._client_service = CurrentClientService(repository)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -16,7 +17,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import zipfile
|
import zipfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
@@ -28,6 +29,10 @@ MAX_MANIFEST_BYTES = 1024 * 1024
|
|||||||
MAX_UPDATE_BYTES = 500 * 1024 * 1024
|
MAX_UPDATE_BYTES = 500 * 1024 * 1024
|
||||||
MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024
|
MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024
|
||||||
UPDATE_MANIFEST_SETTING = "update.manifest_url"
|
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*))?$")
|
_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}$")
|
_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)
|
@dataclass(frozen=True)
|
||||||
class UpdateInfo:
|
class UpdateInfo:
|
||||||
"""清单中一份可下载更新的信息。"""
|
"""清单中一份可下载更新的信息。"""
|
||||||
@@ -93,12 +106,25 @@ def parse_version(version: str) -> tuple[int, int, int, int]:
|
|||||||
|
|
||||||
|
|
||||||
def validate_manifest_url(url: str) -> str:
|
def validate_manifest_url(url: str) -> str:
|
||||||
"""验证并返回只允许 HTTPS、且不含凭据的清单地址。"""
|
"""验证更新地址;HTTP 只对白名单发布主机开放。"""
|
||||||
|
|
||||||
normalized = url.strip()
|
normalized = url.strip()
|
||||||
parsed = urllib.parse.urlsplit(normalized)
|
parsed = urllib.parse.urlsplit(normalized)
|
||||||
if parsed.scheme.lower() != "https" or not parsed.hostname:
|
scheme = parsed.scheme.lower()
|
||||||
raise UpdateConfigurationError("更新清单地址必须是有效的 HTTPS 地址")
|
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:
|
if parsed.username is not None or parsed.password is not None:
|
||||||
raise UpdateConfigurationError("更新清单地址不能包含账号或密码")
|
raise UpdateConfigurationError("更新清单地址不能包含账号或密码")
|
||||||
if parsed.query:
|
if parsed.query:
|
||||||
@@ -110,24 +136,54 @@ def validate_manifest_url(url: str) -> str:
|
|||||||
|
|
||||||
def _origin(url: str) -> tuple[str, str, int]:
|
def _origin(url: str) -> tuple[str, str, int]:
|
||||||
parsed = urllib.parse.urlsplit(url)
|
parsed = urllib.parse.urlsplit(url)
|
||||||
|
scheme = parsed.scheme.lower()
|
||||||
return (
|
return (
|
||||||
parsed.scheme.lower(),
|
scheme,
|
||||||
(parsed.hostname or "").lower(),
|
(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:
|
class UpdateService:
|
||||||
"""检查并把更新安全暂存到 ``data/update``。"""
|
"""检查并把更新安全暂存到 ``data/update``。"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
update_directory: Optional[Path] = None,
|
update_directory: Optional[Path] = None,
|
||||||
urlopen: Callable = urllib.request.urlopen,
|
urlopen: Optional[Callable] = None,
|
||||||
timeout_seconds: float = 10.0,
|
timeout_seconds: float = 10.0,
|
||||||
):
|
):
|
||||||
self._update_directory = update_directory or (data_dir() / "update")
|
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
|
self._timeout_seconds = timeout_seconds
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -139,6 +195,7 @@ class UpdateService:
|
|||||||
manifest_url: str,
|
manifest_url: str,
|
||||||
current_version: str = __version__,
|
current_version: str = __version__,
|
||||||
is_cancelled: Optional[Callable[[], bool]] = None,
|
is_cancelled: Optional[Callable[[], bool]] = None,
|
||||||
|
credentials: Optional[UpdateCredentials] = None,
|
||||||
) -> UpdateCheckResult:
|
) -> UpdateCheckResult:
|
||||||
"""下载并解析清单,返回是否存在新版本。"""
|
"""下载并解析清单,返回是否存在新版本。"""
|
||||||
|
|
||||||
@@ -148,6 +205,7 @@ class UpdateService:
|
|||||||
configured_url,
|
configured_url,
|
||||||
MAX_MANIFEST_BYTES,
|
MAX_MANIFEST_BYTES,
|
||||||
is_cancelled,
|
is_cancelled,
|
||||||
|
credentials,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
manifest = json.loads(content.decode("utf-8-sig"))
|
manifest = json.loads(content.decode("utf-8-sig"))
|
||||||
@@ -168,6 +226,7 @@ class UpdateService:
|
|||||||
info: UpdateInfo,
|
info: UpdateInfo,
|
||||||
is_cancelled: Optional[Callable[[], bool]] = None,
|
is_cancelled: Optional[Callable[[], bool]] = None,
|
||||||
on_progress: Optional[Callable[[int], None]] = None,
|
on_progress: Optional[Callable[[int], None]] = None,
|
||||||
|
credentials: Optional[UpdateCredentials] = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""下载、校验并安全解压更新,返回暂存的 ``app.new``。"""
|
"""下载、校验并安全解压更新,返回暂存的 ``app.new``。"""
|
||||||
|
|
||||||
@@ -184,10 +243,7 @@ class UpdateService:
|
|||||||
bytes_written = 0
|
bytes_written = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request = urllib.request.Request(
|
request = self._make_request(info.update_url, credentials)
|
||||||
info.update_url,
|
|
||||||
headers={"User-Agent": f"CMAutoBuy/{__version__}"},
|
|
||||||
)
|
|
||||||
with self._open(request) as response:
|
with self._open(request) as response:
|
||||||
final_url = validate_manifest_url(response.geturl())
|
final_url = validate_manifest_url(response.geturl())
|
||||||
if _origin(final_url) != _origin(info.manifest_url):
|
if _origin(final_url) != _origin(info.manifest_url):
|
||||||
@@ -294,11 +350,9 @@ class UpdateService:
|
|||||||
url: str,
|
url: str,
|
||||||
maximum_bytes: int,
|
maximum_bytes: int,
|
||||||
is_cancelled: Optional[Callable[[], bool]],
|
is_cancelled: Optional[Callable[[], bool]],
|
||||||
|
credentials: Optional[UpdateCredentials],
|
||||||
) -> tuple[bytes, str]:
|
) -> tuple[bytes, str]:
|
||||||
request = urllib.request.Request(
|
request = self._make_request(url, credentials)
|
||||||
url,
|
|
||||||
headers={"User-Agent": f"CMAutoBuy/{__version__}"},
|
|
||||||
)
|
|
||||||
with self._open(request) as response:
|
with self._open(request) as response:
|
||||||
final_url = validate_manifest_url(response.geturl())
|
final_url = validate_manifest_url(response.geturl())
|
||||||
declared_size = self._content_length(response)
|
declared_size = self._content_length(response)
|
||||||
@@ -317,6 +371,22 @@ class UpdateService:
|
|||||||
chunks.append(block)
|
chunks.append(block)
|
||||||
return b"".join(chunks), final_url
|
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):
|
def _open(self, request):
|
||||||
try:
|
try:
|
||||||
return self._urlopen(request, timeout=self._timeout_seconds)
|
return self._urlopen(request, timeout=self._timeout_seconds)
|
||||||
|
|||||||
+185
-45
@@ -1,25 +1,34 @@
|
|||||||
"""设置页在线更新事件和 Qt 后台 Worker。"""
|
"""设置页在线更新配置、事件和 Qt 后台 Worker。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
from typing import Optional
|
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 qfluentwidgets import MessageBox
|
||||||
|
|
||||||
from .settings_repository import SettingsRepository
|
from .settings_repository import SettingsRepository
|
||||||
from .update_service import (
|
from .update_service import (
|
||||||
|
DEFAULT_UPDATE_MANIFEST_URL,
|
||||||
|
DEFAULT_UPDATE_USERNAME,
|
||||||
UPDATE_MANIFEST_SETTING,
|
UPDATE_MANIFEST_SETTING,
|
||||||
|
UPDATE_USERNAME_SETTING,
|
||||||
UpdateCancelled,
|
UpdateCancelled,
|
||||||
UpdateCheckResult,
|
UpdateCheckResult,
|
||||||
|
UpdateCredentials,
|
||||||
UpdateInfo,
|
UpdateInfo,
|
||||||
UpdateService,
|
UpdateService,
|
||||||
validate_manifest_url,
|
validate_manifest_url,
|
||||||
)
|
)
|
||||||
|
from .windows_credential_store import (
|
||||||
|
CredentialStoreError,
|
||||||
|
WindowsCredentialStore,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UpdateCheckWorker(QObject):
|
class UpdateCheckWorker(QObject):
|
||||||
"""在线程中保存清单地址并检查新版本。"""
|
"""在线程中使用内存凭据检查新版本。"""
|
||||||
|
|
||||||
succeeded = pyqtSignal(object)
|
succeeded = pyqtSignal(object)
|
||||||
failed = pyqtSignal(str)
|
failed = pyqtSignal(str)
|
||||||
@@ -28,13 +37,13 @@ class UpdateCheckWorker(QObject):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
service: UpdateService,
|
service: UpdateService,
|
||||||
repository: SettingsRepository,
|
|
||||||
manifest_url: str,
|
manifest_url: str,
|
||||||
|
credentials: UpdateCredentials,
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._service = service
|
self._service = service
|
||||||
self._repository = repository
|
|
||||||
self._manifest_url = manifest_url
|
self._manifest_url = manifest_url
|
||||||
|
self._credentials = credentials
|
||||||
self._cancelled = False
|
self._cancelled = False
|
||||||
|
|
||||||
def cancel(self) -> None:
|
def cancel(self) -> None:
|
||||||
@@ -43,13 +52,10 @@ class UpdateCheckWorker(QObject):
|
|||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
try:
|
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(
|
result = self._service.check(
|
||||||
normalized_url,
|
self._manifest_url,
|
||||||
is_cancelled=lambda: self._cancelled,
|
is_cancelled=lambda: self._cancelled,
|
||||||
|
credentials=self._credentials,
|
||||||
)
|
)
|
||||||
if not self._cancelled:
|
if not self._cancelled:
|
||||||
self.succeeded.emit(result)
|
self.succeeded.emit(result)
|
||||||
@@ -59,21 +65,28 @@ class UpdateCheckWorker(QObject):
|
|||||||
if not self._cancelled:
|
if not self._cancelled:
|
||||||
self.failed.emit(str(exc) or "检查更新失败")
|
self.failed.emit(str(exc) or "检查更新失败")
|
||||||
finally:
|
finally:
|
||||||
|
self._credentials = None
|
||||||
self.completed.emit()
|
self.completed.emit()
|
||||||
|
|
||||||
|
|
||||||
class UpdateDownloadWorker(QObject):
|
class UpdateDownloadWorker(QObject):
|
||||||
"""在线程中下载、校验并安全暂存更新。"""
|
"""在线程中认证下载、校验并安全暂存更新。"""
|
||||||
|
|
||||||
progressChanged = pyqtSignal(int)
|
progressChanged = pyqtSignal(int)
|
||||||
succeeded = pyqtSignal(str)
|
succeeded = pyqtSignal(str)
|
||||||
failed = pyqtSignal(str)
|
failed = pyqtSignal(str)
|
||||||
completed = pyqtSignal()
|
completed = pyqtSignal()
|
||||||
|
|
||||||
def __init__(self, service: UpdateService, update: UpdateInfo):
|
def __init__(
|
||||||
|
self,
|
||||||
|
service: UpdateService,
|
||||||
|
update: UpdateInfo,
|
||||||
|
credentials: UpdateCredentials,
|
||||||
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._service = service
|
self._service = service
|
||||||
self._update = update
|
self._update = update
|
||||||
|
self._credentials = credentials
|
||||||
self._cancelled = False
|
self._cancelled = False
|
||||||
|
|
||||||
def cancel(self) -> None:
|
def cancel(self) -> None:
|
||||||
@@ -86,6 +99,7 @@ class UpdateDownloadWorker(QObject):
|
|||||||
self._update,
|
self._update,
|
||||||
is_cancelled=lambda: self._cancelled,
|
is_cancelled=lambda: self._cancelled,
|
||||||
on_progress=self.progressChanged.emit,
|
on_progress=self.progressChanged.emit,
|
||||||
|
credentials=self._credentials,
|
||||||
)
|
)
|
||||||
if not self._cancelled:
|
if not self._cancelled:
|
||||||
self.succeeded.emit(self._update.version)
|
self.succeeded.emit(self._update.version)
|
||||||
@@ -95,56 +109,159 @@ class UpdateDownloadWorker(QObject):
|
|||||||
if not self._cancelled:
|
if not self._cancelled:
|
||||||
self.failed.emit(str(exc) or "下载更新失败")
|
self.failed.emit(str(exc) or "下载更新失败")
|
||||||
finally:
|
finally:
|
||||||
|
self._credentials = None
|
||||||
self.completed.emit()
|
self.completed.emit()
|
||||||
|
|
||||||
|
|
||||||
class UpdateUiEventBinder(QObject):
|
class UpdateUiEventBinder(QObject):
|
||||||
"""管理设置页更新按钮、反馈和两个后台线程。"""
|
"""管理更新配置、保存反馈和两个后台线程。"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
page,
|
page,
|
||||||
repository: SettingsRepository,
|
repository: SettingsRepository,
|
||||||
service: Optional[UpdateService] = None,
|
service: Optional[UpdateService] = None,
|
||||||
|
credential_store=None,
|
||||||
parent=None,
|
parent=None,
|
||||||
):
|
):
|
||||||
super().__init__(parent or page)
|
super().__init__(parent or page)
|
||||||
self._page = page
|
self._page = page
|
||||||
self._repository = repository
|
self._repository = repository
|
||||||
self._service = service or UpdateService()
|
self._service = service or UpdateService()
|
||||||
|
self._credential_store_override = credential_store
|
||||||
self._closing = False
|
self._closing = False
|
||||||
self._check_thread: Optional[QThread] = None
|
self._check_thread: Optional[QThread] = None
|
||||||
self._check_worker: Optional[UpdateCheckWorker] = None
|
self._check_worker: Optional[UpdateCheckWorker] = None
|
||||||
self._download_thread: Optional[QThread] = None
|
self._download_thread: Optional[QThread] = None
|
||||||
self._download_worker: Optional[UpdateDownloadWorker] = None
|
self._download_worker: Optional[UpdateDownloadWorker] = None
|
||||||
|
|
||||||
saved_url = repository.get(UPDATE_MANIFEST_SETTING, "")
|
saved_url_record = repository.get_record(UPDATE_MANIFEST_SETTING)
|
||||||
page.updateManifestUrlInput.setText(
|
saved_username_record = repository.get_record(UPDATE_USERNAME_SETTING)
|
||||||
saved_url if isinstance(saved_url, str) else ""
|
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)
|
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()
|
@pyqtSlot()
|
||||||
def request_check(self) -> None:
|
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
|
return
|
||||||
manifest_url = self._page.updateManifestUrlInput.text().strip()
|
manifest_url = self._page.updateManifestUrlInput.text().strip()
|
||||||
self._page.updateManifestUrlInput.setText(manifest_url)
|
username = self._page.updateUsernameInput.text().strip()
|
||||||
try:
|
if not self._saved_url or not self._saved_username:
|
||||||
validate_manifest_url(manifest_url)
|
self._page.set_update_status("尚未保存更新账号和密码,请填写后保存")
|
||||||
except Exception as exc:
|
self._page.updatePasswordInput.setFocus()
|
||||||
self._page.set_update_status(f"无法检查:{exc}")
|
return
|
||||||
self._page.updateManifestUrlInput.setFocus()
|
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
|
return
|
||||||
|
|
||||||
self._page.set_update_status("正在检查新版本…")
|
self._page.set_update_status("正在检查新版本…")
|
||||||
thread = QThread(self)
|
thread = QThread(self)
|
||||||
worker = UpdateCheckWorker(
|
worker = UpdateCheckWorker(self._service, manifest_url, credentials)
|
||||||
self._service,
|
|
||||||
self._repository,
|
|
||||||
manifest_url,
|
|
||||||
)
|
|
||||||
worker.moveToThread(thread)
|
worker.moveToThread(thread)
|
||||||
thread.started.connect(worker.run)
|
thread.started.connect(worker.run)
|
||||||
worker.succeeded.connect(self._on_check_succeeded)
|
worker.succeeded.connect(self._on_check_succeeded)
|
||||||
@@ -155,9 +272,26 @@ class UpdateUiEventBinder(QObject):
|
|||||||
thread.finished.connect(thread.deleteLater)
|
thread.finished.connect(thread.deleteLater)
|
||||||
self._check_thread = thread
|
self._check_thread = thread
|
||||||
self._check_worker = worker
|
self._check_worker = worker
|
||||||
self._sync_button()
|
self._sync_controls()
|
||||||
thread.start()
|
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)
|
@pyqtSlot(object)
|
||||||
def _on_check_succeeded(self, result: UpdateCheckResult) -> None:
|
def _on_check_succeeded(self, result: UpdateCheckResult) -> None:
|
||||||
if self._closing:
|
if self._closing:
|
||||||
@@ -181,9 +315,7 @@ class UpdateUiEventBinder(QObject):
|
|||||||
dialog.cancelButton.setText("暂不下载")
|
dialog.cancelButton.setText("暂不下载")
|
||||||
dialog.cancelButton.setFocus()
|
dialog.cancelButton.setFocus()
|
||||||
if not dialog.exec():
|
if not dialog.exec():
|
||||||
self._page.set_update_status(
|
self._page.set_update_status(f"发现新版本 {update.version},尚未下载")
|
||||||
f"发现新版本 {update.version},尚未下载"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
self._start_download(update)
|
self._start_download(update)
|
||||||
|
|
||||||
@@ -191,7 +323,7 @@ class UpdateUiEventBinder(QObject):
|
|||||||
def _on_check_failed(self, message: str) -> None:
|
def _on_check_failed(self, message: str) -> None:
|
||||||
if not self._closing:
|
if not self._closing:
|
||||||
self._page.set_update_status(
|
self._page.set_update_status(
|
||||||
f"检查失败:{message};地址已保留,可以重试"
|
f"检查失败:{message};设置已保留,可以重试"
|
||||||
)
|
)
|
||||||
|
|
||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
@@ -199,14 +331,17 @@ class UpdateUiEventBinder(QObject):
|
|||||||
self._check_worker = None
|
self._check_worker = None
|
||||||
self._check_thread = None
|
self._check_thread = None
|
||||||
if not self._closing:
|
if not self._closing:
|
||||||
self._sync_button()
|
self._sync_controls()
|
||||||
|
|
||||||
def _start_download(self, update: UpdateInfo) -> None:
|
def _start_download(self, update: UpdateInfo) -> None:
|
||||||
if self._closing or self._download_thread is not None:
|
if self._closing or self._download_thread is not None:
|
||||||
return
|
return
|
||||||
|
credentials = self._read_credentials(self._saved_username)
|
||||||
|
if credentials is None:
|
||||||
|
return
|
||||||
self._page.set_update_status(f"正在下载版本 {update.version}(0%)…")
|
self._page.set_update_status(f"正在下载版本 {update.version}(0%)…")
|
||||||
thread = QThread(self)
|
thread = QThread(self)
|
||||||
worker = UpdateDownloadWorker(self._service, update)
|
worker = UpdateDownloadWorker(self._service, update, credentials)
|
||||||
worker.moveToThread(thread)
|
worker.moveToThread(thread)
|
||||||
thread.started.connect(worker.run)
|
thread.started.connect(worker.run)
|
||||||
worker.progressChanged.connect(self._on_download_progress)
|
worker.progressChanged.connect(self._on_download_progress)
|
||||||
@@ -218,7 +353,7 @@ class UpdateUiEventBinder(QObject):
|
|||||||
thread.finished.connect(thread.deleteLater)
|
thread.finished.connect(thread.deleteLater)
|
||||||
self._download_thread = thread
|
self._download_thread = thread
|
||||||
self._download_worker = worker
|
self._download_worker = worker
|
||||||
self._sync_button()
|
self._sync_controls()
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
@pyqtSlot(int)
|
@pyqtSlot(int)
|
||||||
@@ -245,16 +380,22 @@ class UpdateUiEventBinder(QObject):
|
|||||||
self._download_worker = None
|
self._download_worker = None
|
||||||
self._download_thread = None
|
self._download_thread = None
|
||||||
if not self._closing:
|
if not self._closing:
|
||||||
self._sync_button()
|
self._sync_controls()
|
||||||
|
|
||||||
def _sync_button(self) -> None:
|
def _is_busy(self) -> bool:
|
||||||
busy = self._check_thread is not None or self._download_thread is not None
|
return 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 _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()
|
@pyqtSlot()
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
"""取消后续处理,断开业务结果并短暂等待线程退出。"""
|
"""取消后续处理,断开业务结果并等待线程安全退出。"""
|
||||||
|
|
||||||
if self._closing:
|
if self._closing:
|
||||||
return
|
return
|
||||||
@@ -290,6 +431,5 @@ class UpdateUiEventBinder(QObject):
|
|||||||
pass
|
pass
|
||||||
if thread is not None and thread.isRunning():
|
if thread is not None and thread.isRunning():
|
||||||
thread.quit()
|
thread.quit()
|
||||||
# 网络读超时是 10 秒;多等 1 秒,避免关闭窗口时销毁仍在运行的 QThread。
|
|
||||||
thread.wait(11000)
|
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})")
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
"""在线更新清单、下载校验和安全解压测试。"""
|
"""在线更新清单、下载校验和安全解压测试。"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
import urllib.request
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.update_service import (
|
from src.update_service import (
|
||||||
|
DEFAULT_UPDATE_MANIFEST_URL,
|
||||||
UnsafeUpdateArchiveError,
|
UnsafeUpdateArchiveError,
|
||||||
UpdateConfigurationError,
|
UpdateConfigurationError,
|
||||||
UpdateIntegrityError,
|
UpdateIntegrityError,
|
||||||
|
UpdateCredentials,
|
||||||
UpdateService,
|
UpdateService,
|
||||||
|
_SameOriginRedirectHandler,
|
||||||
mark_current_version_healthy,
|
mark_current_version_healthy,
|
||||||
parse_version,
|
parse_version,
|
||||||
validate_manifest_url,
|
validate_manifest_url,
|
||||||
@@ -41,9 +46,11 @@ class FakeUrlOpen:
|
|||||||
def __init__(self, responses):
|
def __init__(self, responses):
|
||||||
self.responses = responses
|
self.responses = responses
|
||||||
self.requested_urls = []
|
self.requested_urls = []
|
||||||
|
self.requested_headers = []
|
||||||
|
|
||||||
def __call__(self, request, timeout):
|
def __call__(self, request, timeout):
|
||||||
self.requested_urls.append((request.full_url, timeout))
|
self.requested_urls.append((request.full_url, timeout))
|
||||||
|
self.requested_headers.append(dict(request.header_items()))
|
||||||
content, final_url = self.responses[request.full_url]
|
content, final_url = self.responses[request.full_url]
|
||||||
return FakeResponse(content, final_url, len(content))
|
return FakeResponse(content, final_url, len(content))
|
||||||
|
|
||||||
@@ -107,6 +114,10 @@ class UpdateServiceTest(unittest.TestCase):
|
|||||||
validate_manifest_url(self.manifest_url),
|
validate_manifest_url(self.manifest_url),
|
||||||
self.manifest_url,
|
self.manifest_url,
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
validate_manifest_url(DEFAULT_UPDATE_MANIFEST_URL),
|
||||||
|
DEFAULT_UPDATE_MANIFEST_URL,
|
||||||
|
)
|
||||||
for invalid in (
|
for invalid in (
|
||||||
"http://updates.example.test/manifest.json",
|
"http://updates.example.test/manifest.json",
|
||||||
"https://user:pass@updates.example.test/manifest.json",
|
"https://user:pass@updates.example.test/manifest.json",
|
||||||
@@ -118,6 +129,56 @@ class UpdateServiceTest(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
validate_manifest_url(invalid)
|
validate_manifest_url(invalid)
|
||||||
|
|
||||||
|
def test_basic_auth_is_used_for_manifest_and_update_without_repr_leak(self):
|
||||||
|
update_content = make_update_zip()
|
||||||
|
service, opener = self.service_with(update_content)
|
||||||
|
credentials = UpdateCredentials("release-reader", "unit-test-password")
|
||||||
|
|
||||||
|
result = service.check(
|
||||||
|
self.manifest_url,
|
||||||
|
"0.1.0",
|
||||||
|
credentials=credentials,
|
||||||
|
)
|
||||||
|
service.download_and_stage(result.update, credentials=credentials)
|
||||||
|
|
||||||
|
expected = "Basic " + base64.b64encode(
|
||||||
|
b"release-reader:unit-test-password"
|
||||||
|
).decode("ascii")
|
||||||
|
self.assertEqual(len(opener.requested_headers), 2)
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
headers.get("Authorization") == expected
|
||||||
|
for headers in opener.requested_headers
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertNotIn("unit-test-password", repr(credentials))
|
||||||
|
|
||||||
|
def test_default_unicode_manifest_path_is_encoded_for_http_request(self):
|
||||||
|
request = UpdateService._make_request(
|
||||||
|
DEFAULT_UPDATE_MANIFEST_URL,
|
||||||
|
UpdateCredentials("release-reader", "unit-test-password"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotIn("——", request.full_url)
|
||||||
|
self.assertIn("%E2%80%94%E2%80%94", request.full_url)
|
||||||
|
|
||||||
|
def test_authenticated_redirect_cannot_change_origin(self):
|
||||||
|
handler = _SameOriginRedirectHandler()
|
||||||
|
request = urllib.request.Request(
|
||||||
|
"https://updates.example.test/manifest.json",
|
||||||
|
headers={"Authorization": "Basic test"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(UpdateConfigurationError):
|
||||||
|
handler.redirect_request(
|
||||||
|
request,
|
||||||
|
None,
|
||||||
|
302,
|
||||||
|
"Found",
|
||||||
|
{},
|
||||||
|
"https://other.example.test/manifest.json",
|
||||||
|
)
|
||||||
|
|
||||||
def test_check_reports_newer_and_current_versions(self):
|
def test_check_reports_newer_and_current_versions(self):
|
||||||
update_content = make_update_zip()
|
update_content = make_update_zip()
|
||||||
service, _opener = self.service_with(update_content)
|
service, _opener = self.service_with(update_content)
|
||||||
@@ -196,7 +257,7 @@ class UpdateServiceTest(unittest.TestCase):
|
|||||||
health = json.loads(
|
health = json.loads(
|
||||||
(self.update_directory / "healthy.json").read_text(encoding="utf-8")
|
(self.update_directory / "healthy.json").read_text(encoding="utf-8")
|
||||||
)
|
)
|
||||||
self.assertEqual(health["version"], "0.2.0")
|
self.assertEqual(health["version"], "0.2.1")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""设置页在线更新 UI 和后台线程测试。"""
|
"""设置页认证更新配置、自动检查和后台线程测试。"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -11,38 +11,72 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|||||||
|
|
||||||
from PyQt5.QtCore import QTimer
|
from PyQt5.QtCore import QTimer
|
||||||
from PyQt5.QtTest import QTest
|
from PyQt5.QtTest import QTest
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PyQt5.QtWidgets import QApplication, QLineEdit
|
||||||
|
|
||||||
from src.mock_admin_gateway import MockAdminGateway
|
from src.mock_admin_gateway import MockAdminGateway
|
||||||
from src.settings_repository import SettingsRepository
|
from src.settings_repository import SettingsRepository
|
||||||
from src.settings_ui import SettingsPage
|
from src.settings_ui import SettingsPage
|
||||||
from src.update_service import (
|
from src.update_service import (
|
||||||
|
DEFAULT_UPDATE_MANIFEST_URL,
|
||||||
|
DEFAULT_UPDATE_USERNAME,
|
||||||
UPDATE_MANIFEST_SETTING,
|
UPDATE_MANIFEST_SETTING,
|
||||||
|
UPDATE_USERNAME_SETTING,
|
||||||
UpdateCheckResult,
|
UpdateCheckResult,
|
||||||
UpdateInfo,
|
UpdateInfo,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
MANIFEST_URL = "https://updates.example.test/autobuy%E2%80%94%E2%80%94manifest.json"
|
MANIFEST_URL = "https://updates.example.test/autobuy%E2%80%94%E2%80%94manifest.json"
|
||||||
|
TEST_USERNAME = "release-reader"
|
||||||
|
TEST_PASSWORD = "unit-test-password"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCredentialStore:
|
||||||
|
def __init__(self, saved=None):
|
||||||
|
self.saved = saved
|
||||||
|
self.save_count = 0
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self.saved
|
||||||
|
|
||||||
|
def save(self, username, password):
|
||||||
|
self.saved = (username, password)
|
||||||
|
self.save_count += 1
|
||||||
|
|
||||||
|
|
||||||
class FakeUpdateService:
|
class FakeUpdateService:
|
||||||
def __init__(self, result=None, delay=0.0, download_error=None):
|
def __init__(self, result=None, delay=0.0, download_error=None):
|
||||||
self.result = result or UpdateCheckResult("0.2.0", "0.2.0", False)
|
self.result = result or UpdateCheckResult("0.2.1", "0.2.1", False)
|
||||||
self.delay = delay
|
self.delay = delay
|
||||||
self.download_error = download_error
|
self.download_error = download_error
|
||||||
self.check_count = 0
|
self.check_count = 0
|
||||||
self.download_count = 0
|
self.download_count = 0
|
||||||
|
self.check_credentials = None
|
||||||
|
self.download_credentials = None
|
||||||
|
|
||||||
def check(self, manifest_url, current_version="0.1.0", is_cancelled=None):
|
def check(
|
||||||
|
self,
|
||||||
|
manifest_url,
|
||||||
|
current_version="0.2.1",
|
||||||
|
is_cancelled=None,
|
||||||
|
credentials=None,
|
||||||
|
):
|
||||||
self.check_count += 1
|
self.check_count += 1
|
||||||
self.manifest_url = manifest_url
|
self.manifest_url = manifest_url
|
||||||
|
self.check_credentials = credentials
|
||||||
if self.delay:
|
if self.delay:
|
||||||
time.sleep(self.delay)
|
time.sleep(self.delay)
|
||||||
return self.result
|
return self.result
|
||||||
|
|
||||||
def download_and_stage(self, update, is_cancelled=None, on_progress=None):
|
def download_and_stage(
|
||||||
|
self,
|
||||||
|
update,
|
||||||
|
is_cancelled=None,
|
||||||
|
on_progress=None,
|
||||||
|
credentials=None,
|
||||||
|
):
|
||||||
self.download_count += 1
|
self.download_count += 1
|
||||||
|
self.download_credentials = credentials
|
||||||
if on_progress is not None:
|
if on_progress is not None:
|
||||||
on_progress(50)
|
on_progress(50)
|
||||||
if self.download_error is not None:
|
if self.download_error is not None:
|
||||||
@@ -76,18 +110,19 @@ class UpdateUiEventTest(unittest.TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||||
self.repository = SettingsRepository(
|
self.db_path = Path(self.temporary_directory.name) / "client.db"
|
||||||
Path(self.temporary_directory.name) / "client.db"
|
self.repository = SettingsRepository(self.db_path)
|
||||||
)
|
self.credential_store = FakeCredentialStore()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.temporary_directory.cleanup()
|
self.temporary_directory.cleanup()
|
||||||
|
|
||||||
def _page(self, service):
|
def _page(self, service, credential_store=None):
|
||||||
return SettingsPage(
|
return SettingsPage(
|
||||||
settings_repository=self.repository,
|
settings_repository=self.repository,
|
||||||
admin_gateway=MockAdminGateway(),
|
admin_gateway=MockAdminGateway(),
|
||||||
update_service=service,
|
update_service=service,
|
||||||
|
update_credential_store=credential_store or self.credential_store,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _wait_until(self, predicate, timeout_ms=2000):
|
def _wait_until(self, predicate, timeout_ms=2000):
|
||||||
@@ -97,24 +132,86 @@ class UpdateUiEventTest(unittest.TestCase):
|
|||||||
elapsed += 10
|
elapsed += 10
|
||||||
self.assertTrue(predicate(), "等待在线更新线程超时")
|
self.assertTrue(predicate(), "等待在线更新线程超时")
|
||||||
|
|
||||||
def test_update_card_loads_saved_url_and_current_version(self):
|
def _save_configuration(self, page):
|
||||||
self.repository.set(UPDATE_MANIFEST_SETTING, MANIFEST_URL)
|
page.updateManifestUrlInput.setText(MANIFEST_URL)
|
||||||
|
page.updateUsernameInput.setText(TEST_USERNAME)
|
||||||
|
page.updatePasswordInput.setText(TEST_PASSWORD)
|
||||||
|
page.updateSaveButton.click()
|
||||||
|
|
||||||
|
def test_update_card_uses_safe_defaults_and_password_input(self):
|
||||||
page = self._page(FakeUpdateService())
|
page = self._page(FakeUpdateService())
|
||||||
|
|
||||||
self.assertEqual(page.currentVersionLabel.text(), "0.2.0")
|
self.assertEqual(page.currentVersionLabel.text(), "0.2.1")
|
||||||
self.assertEqual(page.updateManifestUrlInput.text(), MANIFEST_URL)
|
self.assertEqual(
|
||||||
self.assertTrue(page.updateCheckButton.isEnabled())
|
page.updateManifestUrlInput.text(), DEFAULT_UPDATE_MANIFEST_URL
|
||||||
|
)
|
||||||
|
self.assertEqual(page.updateUsernameInput.text(), DEFAULT_UPDATE_USERNAME)
|
||||||
|
self.assertEqual(page.updatePasswordInput.text(), "")
|
||||||
|
self.assertEqual(page.updatePasswordInput.echoMode(), QLineEdit.Password)
|
||||||
|
self.assertTrue(page.updateSaveButton.isEnabled())
|
||||||
page.eventBinder.shutdown()
|
page.eventBinder.shutdown()
|
||||||
page.deleteLater()
|
page.deleteLater()
|
||||||
|
|
||||||
def test_http_url_is_rejected_before_starting_worker(self):
|
def test_save_uses_sqlite_for_non_secret_and_credential_store_for_password(self):
|
||||||
|
page = self._page(FakeUpdateService())
|
||||||
|
|
||||||
|
self._save_configuration(page)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.repository.get(UPDATE_MANIFEST_SETTING), MANIFEST_URL
|
||||||
|
)
|
||||||
|
self.assertEqual(self.repository.get(UPDATE_USERNAME_SETTING), TEST_USERNAME)
|
||||||
|
self.assertEqual(
|
||||||
|
self.credential_store.saved,
|
||||||
|
(TEST_USERNAME, TEST_PASSWORD),
|
||||||
|
)
|
||||||
|
self.assertNotIn(TEST_PASSWORD.encode(), self.db_path.read_bytes())
|
||||||
|
self.assertEqual(page.updatePasswordInput.text(), "")
|
||||||
|
self.assertIn("已安全保存", page.updatePasswordInput.placeholderText())
|
||||||
|
page.eventBinder.shutdown()
|
||||||
|
page.deleteLater()
|
||||||
|
|
||||||
|
def test_blank_password_preserves_existing_credential(self):
|
||||||
|
self.repository.set_many(
|
||||||
|
{
|
||||||
|
UPDATE_MANIFEST_SETTING: MANIFEST_URL,
|
||||||
|
UPDATE_USERNAME_SETTING: TEST_USERNAME,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
store = FakeCredentialStore((TEST_USERNAME, TEST_PASSWORD))
|
||||||
|
page = self._page(FakeUpdateService(), store)
|
||||||
|
|
||||||
|
page.updateSaveButton.click()
|
||||||
|
|
||||||
|
self.assertEqual(store.save_count, 0)
|
||||||
|
self.assertEqual(store.saved, (TEST_USERNAME, TEST_PASSWORD))
|
||||||
|
self.assertIn("安全保存", page.updateStatusLabel.text())
|
||||||
|
page.eventBinder.shutdown()
|
||||||
|
page.deleteLater()
|
||||||
|
|
||||||
|
def test_missing_password_stops_save_and_check(self):
|
||||||
service = FakeUpdateService()
|
service = FakeUpdateService()
|
||||||
page = self._page(service)
|
page = self._page(service)
|
||||||
page.updateManifestUrlInput.setText("http://updates.example.test/manifest.json")
|
|
||||||
|
|
||||||
|
page.updateSaveButton.click()
|
||||||
page.updateCheckButton.click()
|
page.updateCheckButton.click()
|
||||||
|
|
||||||
self.assertIn("必须是有效的 HTTPS", page.updateStatusLabel.text())
|
self.assertIn("密码", page.updateStatusLabel.text())
|
||||||
|
self.assertEqual(service.check_count, 0)
|
||||||
|
page.eventBinder.shutdown()
|
||||||
|
page.deleteLater()
|
||||||
|
|
||||||
|
def test_unapproved_http_url_is_rejected_when_saving(self):
|
||||||
|
service = FakeUpdateService()
|
||||||
|
page = self._page(service)
|
||||||
|
page.updateManifestUrlInput.setText(
|
||||||
|
"http://updates.example.test/manifest.json"
|
||||||
|
)
|
||||||
|
page.updatePasswordInput.setText(TEST_PASSWORD)
|
||||||
|
|
||||||
|
page.updateSaveButton.click()
|
||||||
|
|
||||||
|
self.assertIn("HTTP 只允许固定发布服务器", page.updateStatusLabel.text())
|
||||||
self.assertEqual(service.check_count, 0)
|
self.assertEqual(service.check_count, 0)
|
||||||
page.eventBinder.shutdown()
|
page.eventBinder.shutdown()
|
||||||
page.deleteLater()
|
page.deleteLater()
|
||||||
@@ -122,7 +219,7 @@ class UpdateUiEventTest(unittest.TestCase):
|
|||||||
def test_slow_check_does_not_block_or_start_twice(self):
|
def test_slow_check_does_not_block_or_start_twice(self):
|
||||||
service = FakeUpdateService(delay=0.08)
|
service = FakeUpdateService(delay=0.08)
|
||||||
page = self._page(service)
|
page = self._page(service)
|
||||||
page.updateManifestUrlInput.setText(MANIFEST_URL)
|
self._save_configuration(page)
|
||||||
timer_fired = []
|
timer_fired = []
|
||||||
QTimer.singleShot(10, lambda: timer_fired.append(True))
|
QTimer.singleShot(10, lambda: timer_fired.append(True))
|
||||||
|
|
||||||
@@ -134,27 +231,46 @@ class UpdateUiEventTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(service.check_count, 1)
|
self.assertEqual(service.check_count, 1)
|
||||||
self.assertEqual(
|
self.assertEqual(service.check_credentials.username, TEST_USERNAME)
|
||||||
self.repository.get(UPDATE_MANIFEST_SETTING), MANIFEST_URL
|
self.assertEqual(service.check_credentials.password, TEST_PASSWORD)
|
||||||
)
|
|
||||||
self.assertIn("当前已是最新版本", page.updateStatusLabel.text())
|
self.assertIn("当前已是最新版本", page.updateStatusLabel.text())
|
||||||
page.eventBinder.shutdown()
|
page.eventBinder.shutdown()
|
||||||
page.deleteLater()
|
page.deleteLater()
|
||||||
|
|
||||||
def test_confirmed_new_version_downloads_and_stages(self):
|
def test_saved_configuration_checks_automatically_after_startup(self):
|
||||||
|
self.repository.set_many(
|
||||||
|
{
|
||||||
|
UPDATE_MANIFEST_SETTING: MANIFEST_URL,
|
||||||
|
UPDATE_USERNAME_SETTING: TEST_USERNAME,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
store = FakeCredentialStore((TEST_USERNAME, TEST_PASSWORD))
|
||||||
|
service = FakeUpdateService()
|
||||||
|
|
||||||
|
page = self._page(service, store)
|
||||||
|
self._wait_until(lambda: service.check_count == 1)
|
||||||
|
self._wait_until(
|
||||||
|
lambda: page.eventBinder.updateEventBinder._check_thread is None
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("当前已是最新版本", page.updateStatusLabel.text())
|
||||||
|
page.eventBinder.shutdown()
|
||||||
|
page.deleteLater()
|
||||||
|
|
||||||
|
def test_confirmed_new_version_downloads_with_saved_credentials(self):
|
||||||
update = UpdateInfo(
|
update = UpdateInfo(
|
||||||
version="0.2.0",
|
version="0.3.0",
|
||||||
manifest_url=MANIFEST_URL,
|
manifest_url=MANIFEST_URL,
|
||||||
update_url="https://updates.example.test/CMAutoBuy-0.2.0-update.zip",
|
update_url="https://updates.example.test/CMAutoBuy-0.3.0-update.zip",
|
||||||
file_name="CMAutoBuy-0.2.0-update.zip",
|
file_name="CMAutoBuy-0.3.0-update.zip",
|
||||||
size=1024,
|
size=1024,
|
||||||
sha256="0" * 64,
|
sha256="0" * 64,
|
||||||
)
|
)
|
||||||
service = FakeUpdateService(
|
service = FakeUpdateService(
|
||||||
UpdateCheckResult("0.1.0", "0.2.0", True, update)
|
UpdateCheckResult("0.2.1", "0.3.0", True, update)
|
||||||
)
|
)
|
||||||
page = self._page(service)
|
page = self._page(service)
|
||||||
page.updateManifestUrlInput.setText(MANIFEST_URL)
|
self._save_configuration(page)
|
||||||
|
|
||||||
with patch("src.update_ui_event.MessageBox", AcceptDownloadMessageBox):
|
with patch("src.update_ui_event.MessageBox", AcceptDownloadMessageBox):
|
||||||
page.updateCheckButton.click()
|
page.updateCheckButton.click()
|
||||||
@@ -164,8 +280,9 @@ class UpdateUiEventTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(service.download_count, 1)
|
self.assertEqual(service.download_count, 1)
|
||||||
|
self.assertEqual(service.download_credentials.username, TEST_USERNAME)
|
||||||
|
self.assertEqual(service.download_credentials.password, TEST_PASSWORD)
|
||||||
self.assertIn("已准备好", page.updateStatusLabel.text())
|
self.assertIn("已准备好", page.updateStatusLabel.text())
|
||||||
self.assertIn("重新启动", page.updateStatusLabel.text())
|
|
||||||
page.eventBinder.shutdown()
|
page.eventBinder.shutdown()
|
||||||
page.deleteLater()
|
page.deleteLater()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Windows 更新凭据按服务器隔离的测试。"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from src.windows_credential_store import WindowsCredentialStore
|
||||||
|
|
||||||
|
|
||||||
|
class WindowsCredentialStoreTest(unittest.TestCase):
|
||||||
|
def test_different_origins_use_different_credential_targets(self):
|
||||||
|
http_store = WindowsCredentialStore.for_url(
|
||||||
|
"http://cm.xiapi.com/autobuy——manifest.json"
|
||||||
|
)
|
||||||
|
https_store = WindowsCredentialStore.for_url(
|
||||||
|
"https://cm.xiapi.com/autobuy——manifest.json"
|
||||||
|
)
|
||||||
|
other_store = WindowsCredentialStore.for_url(
|
||||||
|
"https://updates.example.test/manifest.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotEqual(http_store.target, https_store.target)
|
||||||
|
self.assertNotEqual(https_store.target, other_store.target)
|
||||||
|
self.assertNotIn("@", http_store.target)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -84,6 +84,7 @@ Admin 想知道某台 Client 是不是卡死了,用"领取后超时重派"就
|
|||||||
| 模态对话框 | 弹出来必须先处理它、否则动不了主窗口的窗口。**很打断人,只在必须做决定时用** | 高危确认 |
|
| 模态对话框 | 弹出来必须先处理它、否则动不了主窗口的窗口。**很打断人,只在必须做决定时用** | 高危确认 |
|
||||||
| DPI / 显示缩放 | Windows 里"文字和应用的大小"那个百分比设置 | 需验证 100%~200% |
|
| DPI / 显示缩放 | Windows 里"文字和应用的大小"那个百分比设置 | 需验证 100%~200% |
|
||||||
| 高对比度 | Windows 的一种无障碍主题,颜色极简 | 需验证界面不能只靠颜色区分状态 |
|
| 高对比度 | Windows 的一种无障碍主题,颜色极简 | 需验证界面不能只靠颜色区分状态 |
|
||||||
|
| Windows 凭据管理器 | Windows 专门保存密码等秘密的系统存储,普通 SQLite 和日志不应代替它 | 保存在线更新密码,界面不回填明文 |
|
||||||
|
|
||||||
## 4. 自动化和安全
|
## 4. 自动化和安全
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ Client 应执行:
|
|||||||
2. **Android 设备:**ADB 地址、拼多多包名、设备测试连接。
|
2. **Android 设备:**ADB 地址、拼多多包名、设备测试连接。
|
||||||
3. **自动化:**轮询周期、任务超时、最大重试次数和演练模式。
|
3. **自动化:**轮询周期、任务超时、最大重试次数和演练模式。
|
||||||
4. **安全与诊断:**价格允许偏差、最大购买数量、日志目录、截图/XML 保留周期。
|
4. **安全与诊断:**价格允许偏差、最大购买数量、日志目录、截图/XML 保留周期。
|
||||||
5. **软件更新:**当前版本、HTTPS 更新清单地址、检查按钮和稳定状态文字。
|
5. **软件更新:**当前版本、清单地址、账号、密码、保存/检查按钮和稳定状态文字。
|
||||||
|
|
||||||
密码和访问令牌不得以明文写入普通 SQLite 设置或日志。
|
密码和访问令牌不得以明文写入普通 SQLite 设置或日志。
|
||||||
|
|
||||||
@@ -214,13 +214,15 @@ CMAutoBuy/ 整个文件夹拷到任何机器都能用
|
|||||||
`autobuy——manifest.json`。清单至少记录版本、文件名、字节大小和 SHA256;SHA256
|
`autobuy——manifest.json`。清单至少记录版本、文件名、字节大小和 SHA256;SHA256
|
||||||
用于发现下载损坏,不等同于发布者身份认证。
|
用于发现下载损坏,不等同于发布者身份认证。
|
||||||
|
|
||||||
**在线更新:**操作人员在设置页填写完整 HTTPS 清单地址并主动检查。发现新版本后
|
**在线更新:**设置页默认显示已确认的发布清单地址和账号,密码由操作人员首次
|
||||||
必须由用户确认下载;下载、校验和安全解压在后台线程完成,只写入
|
填写并保存到 Windows 凭据管理器;URL 和账号作为非敏感设置保存在 SQLite,密码
|
||||||
|
不回填明文。保存完整配置后,Client 启动时在后台检查一次,也可手动检查。发现
|
||||||
|
新版本后必须由用户确认下载;下载、校验和安全解压在后台线程完成,只写入
|
||||||
`data/update/`。程序不会强制退出,用户下次通过 `Launcher.exe` 启动时才替换
|
`data/update/`。程序不会强制退出,用户下次通过 `Launcher.exe` 启动时才替换
|
||||||
`app/`。Launcher 保留一份 `app.old/`,目录移动失败或新版本没有写入健康标记时
|
`app/`。Launcher 保留一份 `app.old/`,目录移动失败或新版本没有写入健康标记时
|
||||||
恢复旧版本。更新包必须与清单同源;重定向后仍必须是 HTTPS;不允许忽略 TLS
|
恢复旧版本。更新包必须与清单同源且重定向不得跨源。一般地址只允许 HTTPS;当前
|
||||||
证书错误,也不在更新地址中保存账号密码。
|
固定发布主机按已确认方案临时允许默认端口 HTTP 和 Basic Authentication。URL
|
||||||
更新地址还不得包含查询参数或片段,避免 token 一类凭据被持久化。
|
不得包含账号密码、查询参数或片段,认证密码不得写入源码、Git、SQLite 或日志。
|
||||||
|
|
||||||
`0.1.0` 的 Launcher 没有应用在线更新的能力,因此第一次升级到带在线更新的
|
`0.1.0` 的 Launcher 没有应用在线更新的能力,因此第一次升级到带在线更新的
|
||||||
`0.2.0` 仍需人工替换一次完整程序;从 `0.2.0` 开始才可使用上述流程。当前没有
|
`0.2.0` 仍需人工替换一次完整程序;从 `0.2.0` 开始才可使用上述流程。当前没有
|
||||||
|
|||||||
@@ -145,8 +145,9 @@ client/
|
|||||||
- Repository 封装 SQLite,界面和自动化代码不得直接拼接业务 SQL。
|
- Repository 封装 SQLite,界面和自动化代码不得直接拼接业务 SQL。
|
||||||
- PDD Adapter 封装设备连接、页面识别、采集和采购。
|
- PDD Adapter 封装设备连接、页面识别、采集和采购。
|
||||||
- ArtifactStore 保存失败截图、无障碍 XML 和结构化诊断文件。
|
- ArtifactStore 保存失败截图、无障碍 XML 和结构化诊断文件。
|
||||||
- `UpdateService` 只负责 HTTPS 清单、下载校验和安全暂存;正在运行的主程序不替换
|
- `UpdateService` 负责认证清单、下载校验和安全暂存;URL/账号由 Repository 保存,
|
||||||
自身,目录替换与回退只由下一次启动的 `Launcher.exe` 执行。
|
密码只从 Windows 凭据管理器短暂读入内存。正在运行的主程序不替换自身,目录
|
||||||
|
替换与回退只由下一次启动的 `Launcher.exe` 执行。
|
||||||
|
|
||||||
## 5. 线程模型
|
## 5. 线程模型
|
||||||
|
|
||||||
@@ -165,7 +166,7 @@ Qt 主线程
|
|||||||
└── Outbox 重试,不重复执行 PDD 操作
|
└── Outbox 重试,不重复执行 PDD 操作
|
||||||
|
|
||||||
更新工作线程
|
更新工作线程
|
||||||
├── 检查 HTTPS 清单
|
├── 读取系统凭据并检查认证清单
|
||||||
└── 下载、SHA256 校验和安全解压到 data/update/
|
└── 下载、SHA256 校验和安全解压到 data/update/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -293,14 +294,15 @@ def _on_finished(self, remote_task_id: str, result) -> None:
|
|||||||
### 5.3 在线更新启动顺序
|
### 5.3 在线更新启动顺序
|
||||||
|
|
||||||
```text
|
```text
|
||||||
主程序:HTTPS 清单 → 用户确认 → 下载并校验 → data/update/app.new + pending.json
|
主程序:系统凭据 → 认证清单 → 用户确认 → 下载并校验 → app.new + pending.json
|
||||||
Launcher:确认主程序未运行 → app 改名 app.old → app.new 改名 app → 启动主程序
|
Launcher:确认主程序未运行 → app 改名 app.old → app.new 改名 app → 启动主程序
|
||||||
主程序:窗口成功创建 → 写 healthy.json
|
主程序:窗口成功创建 → 写 healthy.json
|
||||||
Launcher:健康标记正确则完成;提前退出则恢复 app.old
|
Launcher:健康标记正确则完成;提前退出则恢复 app.old
|
||||||
```
|
```
|
||||||
|
|
||||||
更新 ZIP 只允许 `app/` 内容,拒绝绝对路径、`..`、反斜杠路径和符号链接。Launcher
|
更新 ZIP 只允许 `app/` 内容,拒绝绝对路径、`..`、反斜杠路径和符号链接。Launcher
|
||||||
不联网、不处理凭据,也不自更新。
|
不联网、不处理凭据,也不自更新。启动自动检查只在 URL、账号和系统密码都已保存时
|
||||||
|
执行,网络失败只更新设置页状态,不阻止主窗口使用。
|
||||||
|
|
||||||
## 6. 任务引擎状态
|
## 6. 任务引擎状态
|
||||||
|
|
||||||
|
|||||||
@@ -299,8 +299,12 @@ class TaskTableModel(QAbstractTableModel):
|
|||||||
### 软件更新
|
### 软件更新
|
||||||
|
|
||||||
- 显示只读的当前版本;
|
- 显示只读的当前版本;
|
||||||
- 清单地址使用带可见标签的单行输入框,只允许完整 HTTPS URL;
|
- 清单地址、账号和密码分别使用带可见标签的单行输入框,密码框默认隐藏且不回填;
|
||||||
- “检查更新”同时保存已验证的非敏感清单地址;检查和下载期间按钮防重复;
|
- “保存”把 URL/账号写入 SQLite,把密码写入 Windows 凭据管理器;已有密码时留空
|
||||||
|
表示不修改,修改账号时必须重新输入密码;
|
||||||
|
- “检查更新”只使用已经保存的配置;存在未保存修改时先提示保存;检查和下载期间
|
||||||
|
输入框及按钮禁用,防止并行任务和配置竞态;
|
||||||
|
- 已保存完整配置时启动后自动检查一次;没有新版或网络失败不弹模态框;
|
||||||
- 状态文字稳定显示未配置、检查中、已是最新、发现新版、下载进度、已准备、失败和
|
- 状态文字稳定显示未配置、检查中、已是最新、发现新版、下载进度、已准备、失败和
|
||||||
恢复建议;
|
恢复建议;
|
||||||
- 发现新版本时使用有明确“下载更新 / 暂不下载”的确认框;下载完成不强制重启,
|
- 发现新版本时使用有明确“下载更新 / 暂不下载”的确认框;下载完成不强制重启,
|
||||||
@@ -322,6 +326,7 @@ class TaskTableModel(QAbstractTableModel):
|
|||||||
| 多个订单候选 | 状态改为“需要人工处理”,打开详情决策 |
|
| 多个订单候选 | 状态改为“需要人工处理”,打开详情决策 |
|
||||||
| 普通任务成功 | 更新行和状态区,不弹“成功”对话框 |
|
| 普通任务成功 | 更新行和状态区,不弹“成功”对话框 |
|
||||||
| 更新检查或下载失败 | 软件更新卡片保留地址并显示原因和重试方式,当前程序不变 |
|
| 更新检查或下载失败 | 软件更新卡片保留地址并显示原因和重试方式,当前程序不变 |
|
||||||
|
| 更新设置保存成功 | 状态区提示已安全保存,密码输入框清空并显示已保存占位文字 |
|
||||||
| 更新下载完成 | 软件更新卡片持续提示“下次启动生效”,不强制关闭程序 |
|
| 更新下载完成 | 软件更新卡片持续提示“下次启动生效”,不强制关闭程序 |
|
||||||
|
|
||||||
### 9.1 错误提示模板(照抄即可)
|
### 9.1 错误提示模板(照抄即可)
|
||||||
|
|||||||
@@ -187,8 +187,14 @@ Artifact 写入前应脱敏,数据库只保存引用。保留周期由设置
|
|||||||
|
|
||||||
### 8.1 在线更新安全
|
### 8.1 在线更新安全
|
||||||
|
|
||||||
- 清单和更新包只允许 HTTPS,不提供忽略证书错误的开关;URL 不得包含账号密码、
|
- 一般清单和更新包只允许 HTTPS;按已确认部署方案,只有固定发布主机的默认端口
|
||||||
查询参数或片段,避免凭据随地址写入 SQLite 或错误提示。
|
临时允许 HTTP Basic Authentication,任何其他 HTTP 地址仍拒绝。
|
||||||
|
- URL 不得包含账号密码、查询参数或片段。URL 和账号可写 SQLite;密码只能写入
|
||||||
|
Windows 凭据管理器,不得进入源码、Git、SQLite、日志、异常文本、工单或文档。
|
||||||
|
- 不同协议、主机或端口使用不同的系统凭据目标;修改服务器地址后必须为新服务器
|
||||||
|
重新输入密码,不能把旧服务器密码自动发送到新地址。
|
||||||
|
- HTTP Basic Authentication 不能防止同网段监听或中间人攻击;主机白名单只限制
|
||||||
|
Client 误连范围,不能替代 TLS,发布服务器具备条件后应迁移到 HTTPS。
|
||||||
- 更新包必须与最终清单 URL 同源,并同时校验清单声明的字节大小和 SHA256。
|
- 更新包必须与最终清单 URL 同源,并同时校验清单声明的字节大小和 SHA256。
|
||||||
- 清单、压缩包和解压后总大小均有限制;ZIP 只能包含安全的 `app/` 内容,拒绝路径
|
- 清单、压缩包和解压后总大小均有限制;ZIP 只能包含安全的 `app/` 内容,拒绝路径
|
||||||
穿越、绝对路径、反斜杠路径和符号链接。
|
穿越、绝对路径、反斜杠路径和符号链接。
|
||||||
|
|||||||
Reference in New Issue
Block a user