250 lines
10 KiB
Python
250 lines
10 KiB
Python
"""只做本地校验和显式保存的配置页。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Protocol
|
|
|
|
from PySide6.QtCore import Qt, Signal, Slot
|
|
from PySide6.QtGui import QAction, QKeySequence
|
|
from PySide6.QtWidgets import (
|
|
QComboBox,
|
|
QFormLayout,
|
|
QLabel,
|
|
QLineEdit,
|
|
QPushButton,
|
|
QScrollArea,
|
|
QSpinBox,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from cmbuyer_client.core.models import SecretToken
|
|
from cmbuyer_client.core.errors import ValidationError
|
|
from cmbuyer_client.core.validation import require_uuid4
|
|
from cmbuyer_client.localstate.models import LOOPBACK_SERVICE_URL, ProfileSettings
|
|
|
|
|
|
class ProfileStore(Protocol):
|
|
def save_profile(self, settings: ProfileSettings, token: SecretToken | None) -> None: ...
|
|
|
|
|
|
class SettingsPage(QScrollArea):
|
|
settings_saved = Signal(object, bool)
|
|
VALIDATION_HINT = "服务身份将在首次真实领取时验证;设备与 App 状态由后续已取证执行能力验证。"
|
|
|
|
def __init__(
|
|
self,
|
|
store: ProfileStore,
|
|
*,
|
|
profile_id: str = "default",
|
|
settings: ProfileSettings | None = None,
|
|
has_stored_device_token: bool = False,
|
|
identity_frozen: bool = False,
|
|
parent: QWidget | None = None,
|
|
) -> None:
|
|
super().__init__(parent)
|
|
self.setObjectName("settingsPage")
|
|
self.setWidgetResizable(True)
|
|
self._store = store
|
|
self._profile_id = profile_id
|
|
self._has_stored_device_token = has_stored_device_token
|
|
self._loaded_settings = settings
|
|
self._identity_frozen = identity_frozen
|
|
|
|
content = QWidget()
|
|
outer = QVBoxLayout(content)
|
|
title = QLabel("配置")
|
|
title.setObjectName("settingsTitle")
|
|
title.setStyleSheet("font-size: 20px; font-weight: 600;")
|
|
outer.addWidget(title)
|
|
|
|
form = QFormLayout()
|
|
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
|
|
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
outer.addLayout(form)
|
|
|
|
self.service_url = QLineEdit(LOOPBACK_SERVICE_URL)
|
|
self.service_url.setReadOnly(True)
|
|
self.service_url.setObjectName("serviceUrl")
|
|
form.addRow("采购服务 URL", self.service_url)
|
|
|
|
self.device_id = QLineEdit()
|
|
self.device_id.setObjectName("deviceId")
|
|
self.device_id.setPlaceholderText("小写 UUIDv4")
|
|
form.addRow("设备 UUID", self.device_id)
|
|
|
|
self.device_token = QLineEdit()
|
|
self.device_token.setObjectName("deviceToken")
|
|
self.device_token.setEchoMode(QLineEdit.EchoMode.Password)
|
|
# 控件只负责给输入设置合理上限;长度与字符集必须在 save() 中显式
|
|
# 验证。若这里限制为 64,粘贴 65 位 token 会被 Qt 静默截成合法
|
|
# 64 位并覆盖原凭据。
|
|
self.device_token.setMaxLength(256)
|
|
self.device_token.setPlaceholderText("首次必填;已有凭据时留空表示保留")
|
|
form.addRow("设备 token", self.device_token)
|
|
|
|
self.token_status = QLabel()
|
|
self.token_status.setObjectName("tokenStatus")
|
|
form.addRow("凭据状态", self.token_status)
|
|
|
|
self.adb_path = QLineEdit()
|
|
self.adb_path.setObjectName("adbPath")
|
|
form.addRow("ADB 路径", self.adb_path)
|
|
|
|
self.adb_serial = QLineEdit()
|
|
self.adb_serial.setObjectName("adbSerial")
|
|
form.addRow("设备 serial", self.adb_serial)
|
|
|
|
self.transport = QComboBox()
|
|
self.transport.setObjectName("transport")
|
|
self.transport.addItem("USB", "usb")
|
|
self.transport.addItem("WiFi", "wifi")
|
|
form.addRow("连接方式", self.transport)
|
|
|
|
self.poll_interval = self._spin(5, 300, 15, "pollInterval", " 秒")
|
|
form.addRow("轮询间隔", self.poll_interval)
|
|
self.failure_threshold = self._spin(1, 10, 3, "failureThreshold", " 次")
|
|
form.addRow("连续失败停止阈值", self.failure_threshold)
|
|
self.http_timeout = self._spin(1, 120, 10, "httpTimeout", " 秒")
|
|
form.addRow("HTTP 超时", self.http_timeout)
|
|
self.step_timeout = self._spin(5, 300, 45, "stepTimeout", " 秒")
|
|
form.addRow("真机步骤超时", self.step_timeout)
|
|
|
|
self.validation_hint = QLabel(self.VALIDATION_HINT)
|
|
self.validation_hint.setObjectName("deferredValidationHint")
|
|
self.validation_hint.setWordWrap(True)
|
|
outer.addWidget(self.validation_hint)
|
|
|
|
self.feedback = QLabel()
|
|
self.feedback.setObjectName("settingsFeedback")
|
|
self.feedback.setWordWrap(True)
|
|
self.feedback.setAccessibleName("配置保存状态")
|
|
outer.addWidget(self.feedback)
|
|
|
|
self.save_action = QAction("保存配置", self)
|
|
self.save_action.setShortcut(QKeySequence.StandardKey.Save)
|
|
self.save_action.triggered.connect(self.save)
|
|
self.addAction(self.save_action)
|
|
self.save_button = QPushButton("保存配置")
|
|
self.save_button.setObjectName("saveSettings")
|
|
self.save_button.clicked.connect(self.save_action.trigger)
|
|
outer.addWidget(self.save_button, 0, Qt.AlignmentFlag.AlignRight)
|
|
outer.addStretch(1)
|
|
self.setWidget(content)
|
|
|
|
if settings is not None:
|
|
self._load(settings)
|
|
self._update_token_status()
|
|
self.set_identity_frozen(identity_frozen)
|
|
|
|
@staticmethod
|
|
def _spin(minimum: int, maximum: int, value: int, name: str, suffix: str) -> QSpinBox:
|
|
field = QSpinBox()
|
|
field.setObjectName(name)
|
|
field.setRange(minimum, maximum)
|
|
field.setValue(value)
|
|
field.setSuffix(suffix)
|
|
return field
|
|
|
|
def _load(self, settings: ProfileSettings) -> None:
|
|
self.device_id.setText(settings.device_id)
|
|
self.adb_path.setText(settings.adb_path)
|
|
self.adb_serial.setText(settings.adb_serial)
|
|
self.transport.setCurrentIndex(max(0, self.transport.findData(settings.transport)))
|
|
self.poll_interval.setValue(settings.poll_interval_seconds)
|
|
self.failure_threshold.setValue(settings.failure_threshold)
|
|
self.http_timeout.setValue(settings.http_timeout_seconds)
|
|
self.step_timeout.setValue(settings.step_timeout_seconds)
|
|
|
|
def set_identity_frozen(self, frozen: bool) -> None:
|
|
self._identity_frozen = frozen
|
|
for field in (
|
|
self.device_id,
|
|
self.adb_path,
|
|
self.adb_serial,
|
|
self.transport,
|
|
self.poll_interval,
|
|
self.failure_threshold,
|
|
self.http_timeout,
|
|
self.step_timeout,
|
|
):
|
|
field.setEnabled(not frozen)
|
|
if frozen:
|
|
self.feedback.setText("存在待恢复或执行中的领取;服务与设备身份参数已冻结。")
|
|
elif self.feedback.text().startswith("存在待恢复或执行中的领取"):
|
|
self.feedback.clear()
|
|
|
|
@Slot()
|
|
def save(self) -> None:
|
|
self.feedback.clear()
|
|
token_text = self.device_token.text().strip()
|
|
device_id = self.device_id.text().strip()
|
|
try:
|
|
require_uuid4(device_id, "invalid_device_id")
|
|
except (TypeError, ValueError, ValidationError):
|
|
self._validation_error(self.device_id, "设备 UUID 必须是小写 UUIDv4。")
|
|
return
|
|
if not self._has_stored_device_token and not token_text:
|
|
self._validation_error(self.device_token, "首次保存必须填写设备 token。")
|
|
return
|
|
if token_text and (len(token_text) != 64 or any(character not in "0123456789abcdef" for character in token_text)):
|
|
self._validation_error(self.device_token, "设备 token 必须为 64 位小写十六进制。")
|
|
return
|
|
try:
|
|
if self._identity_frozen:
|
|
if self._loaded_settings is None:
|
|
self._validation_error(self.device_id, "冻结配置缺少原始设置,不能保存。")
|
|
return
|
|
settings = self._loaded_settings
|
|
else:
|
|
adb_text = self.adb_path.text().strip()
|
|
if not adb_text:
|
|
self._validation_error(self.adb_path, "请填写 ADB 路径。")
|
|
return
|
|
adb_file = Path(adb_text).expanduser()
|
|
if not adb_file.is_file():
|
|
self._validation_error(self.adb_path, "ADB 路径必须指向本机已存在的文件。")
|
|
return
|
|
adb_file = adb_file.resolve(strict=True)
|
|
serial = self.adb_serial.text().strip()
|
|
if not serial:
|
|
self._validation_error(self.adb_serial, "请填写设备 serial。")
|
|
return
|
|
settings = ProfileSettings(
|
|
profile_id=self._profile_id,
|
|
service_url=LOOPBACK_SERVICE_URL,
|
|
device_id=device_id,
|
|
adb_path=str(adb_file),
|
|
adb_serial=serial,
|
|
transport=str(self.transport.currentData()),
|
|
poll_interval_seconds=self.poll_interval.value(),
|
|
failure_threshold=self.failure_threshold.value(),
|
|
http_timeout_seconds=self.http_timeout.value(),
|
|
step_timeout_seconds=self.step_timeout.value(),
|
|
)
|
|
token = SecretToken(token_text) if token_text else None
|
|
except (TypeError, ValueError):
|
|
self._validation_error(self.device_id, "配置格式不正确,请检查设备 UUID 与各项参数。")
|
|
return
|
|
try:
|
|
# None 明确表示保留 T-303 中已有的 DPAPI 密文,绝不是清除凭据。
|
|
self._store.save_profile(settings, token)
|
|
except Exception:
|
|
self.feedback.setText("配置保存失败,本地安全存储未更新。")
|
|
(self.device_token if token_text else self.device_id).setFocus()
|
|
return
|
|
self._has_stored_device_token = True
|
|
self._loaded_settings = settings
|
|
self.device_token.clear()
|
|
self._update_token_status()
|
|
self.feedback.setText("配置已保存。本地校验不代表服务、设备或 App 已就绪。")
|
|
self.settings_saved.emit(settings, True)
|
|
|
|
def _validation_error(self, field: QWidget, message: str) -> None:
|
|
self.feedback.setText(message)
|
|
field.setFocus()
|
|
|
|
def _update_token_status(self) -> None:
|
|
self.token_status.setText("已保存" if self._has_stored_device_token else "未保存")
|