初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -0,0 +1,4 @@
"""核桃派镜像配置编辑器。"""
__version__ = "2.0.0"
@@ -0,0 +1,18 @@
from __future__ import annotations
import sys
from .cli import run_command
def main() -> int:
if len(sys.argv) > 1:
return run_command(sys.argv[1:])
from .gui import run_gui
return run_gui()
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,42 @@
from __future__ import annotations
import json
from pathlib import Path
import sys
from .config import deserialize_config
from .operations import copy_write_and_verify, read_config
USAGE = "用法:镜像配置编辑器.exe [--validate 镜像 | --apply 原镜像 配置.json 新镜像]"
def run_command(arguments: list[str]) -> int:
try:
if len(arguments) == 2 and arguments[0] == "--validate":
config = read_config(arguments[1])
public = {
"valid": True,
"SoftwareVersion": config["software_version"],
"Username": config["account"]["username"],
"Ssid": config["wifi"]["ssid"],
"Mode": config["ipv4"]["mode"],
"Address": config["ipv4"]["address"],
}
# ASCII escapes match the 1.0.1 System.Text.Json output and remain stable in every Windows console code page.
print(json.dumps(public, ensure_ascii=True, separators=(",", ":")))
return 0
if len(arguments) == 4 and arguments[0] == "--apply":
try:
text = Path(arguments[2]).read_text(encoding="utf-8-sig")
except (OSError, UnicodeError) as exception:
raise ValueError(f"无法读取配置 JSON:{exception}") from exception
config = deserialize_config(text)
destination, _verified = copy_write_and_verify(arguments[1], arguments[3], config)
print(destination)
return 0
print(USAGE, file=sys.stderr)
return 2
except Exception as exception:
print(str(exception), file=sys.stderr)
return 1
@@ -0,0 +1,200 @@
from __future__ import annotations
import hashlib
import ipaddress
import json
import re
import struct
from typing import Any
PRODUCT_ID = "matrix-screen-controller-walnutpi"
CONFIG_FILE_BYTES = 64 * 1024
HEADER_BYTES = 4096
SLOT_BYTES = (CONFIG_FILE_BYTES - HEADER_BYTES) // 2
FILE_MAGIC = b"MSCCFG2\0"
SLOT_MAGIC = b"MSCSLOT\0"
FORMAT_VERSION = 1
_HEADER = struct.Struct("<8sII")
_SLOT_HEADER = struct.Struct("<8sQI32s")
_USERNAME = re.compile(r"[a-z_][a-z0-9_-]{0,31}", re.ASCII)
_VERSION = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)", re.ASCII)
class ImageConfigError(ValueError):
pass
def _exact_object(value: Any, fields: set[str], label: str) -> dict[str, Any]:
if not isinstance(value, dict) or set(value) != fields:
raise ImageConfigError(f"{label}字段无效")
return value
def _utf16_units(value: str) -> int:
# .NET String.Length counts UTF-16 code units; retain 1.0.1 validation semantics.
return len(value.encode("utf-16-le")) // 2
def _validate_secret(value: Any, label: str, minimum: int, maximum: int) -> str:
if not isinstance(value, str) or not minimum <= _utf16_units(value) <= maximum:
raise ImageConfigError(f"{label}长度或字符无效")
if any(character in value for character in ("\0", "\r", "\n")):
raise ImageConfigError(f"{label}长度或字符无效")
return value
def _validate_text(value: Any, label: str, minimum: int, maximum_bytes: int) -> str:
if not isinstance(value, str) or _utf16_units(value) < minimum or len(value.encode("utf-8")) > maximum_bytes:
raise ImageConfigError(f"{label}长度或字符无效")
if any(character in value for character in ("\0", "\r", "\n")):
raise ImageConfigError(f"{label}长度或字符无效")
return value
def validate_config(value: Any) -> dict[str, Any]:
source = _exact_object(
value,
{"schema_version", "product", "software_version", "account", "wifi", "ipv4"},
"镜像配置",
)
schema = source["schema_version"]
if type(schema) is not int or schema != FORMAT_VERSION or source["product"] != PRODUCT_ID:
raise ImageConfigError("镜像产品或配置版本不受支持")
software_version = source["software_version"]
if not isinstance(software_version, str) or _VERSION.fullmatch(software_version) is None:
raise ImageConfigError("软件版本格式无效")
account = _exact_object(source["account"], {"username", "password"}, "账户配置")
username = account["username"]
if not isinstance(username, str) or _USERNAME.fullmatch(username) is None:
raise ImageConfigError("用户名只能使用小写字母、数字、下划线和连字符,且最长 32 个字符")
account_password = _validate_secret(account["password"], "账户密码", 8, 128)
wifi = _exact_object(source["wifi"], {"ssid", "password"}, "Wi-Fi 配置")
ssid = _validate_text(wifi["ssid"], "Wi-Fi 名称", 1, 32)
wifi_password = _validate_secret(wifi["password"], "Wi-Fi 密码", 8, 63)
ipv4 = _exact_object(source["ipv4"], {"mode", "address", "prefix", "gateway", "dns"}, "IPv4 配置")
mode = ipv4["mode"]
if mode == "dhcp":
normalized_ipv4: dict[str, Any] = {"mode": "dhcp", "address": "", "prefix": 0, "gateway": "", "dns": []}
elif mode == "static":
try:
address = ipaddress.IPv4Address(ipv4["address"])
gateway = ipaddress.IPv4Address(ipv4["gateway"])
except (ipaddress.AddressValueError, TypeError) as exception:
raise ImageConfigError("静态 IPv4 地址或网关无效") from exception
prefix = ipv4["prefix"]
if type(prefix) is not int or not 1 <= prefix <= 32:
raise ImageConfigError("IPv4 前缀必须是 1 到 32")
if gateway not in ipaddress.IPv4Network(f"{address}/{prefix}", strict=False):
raise ImageConfigError("静态 IPv4 网关必须与地址处于同一子网")
dns_source = ipv4["dns"]
if not isinstance(dns_source, list) or not 1 <= len(dns_source) <= 4:
raise ImageConfigError("DNS 必须包含 1 到 4 个 IPv4 地址")
try:
dns = [str(ipaddress.IPv4Address(item)) for item in dns_source]
except (ipaddress.AddressValueError, TypeError) as exception:
raise ImageConfigError("DNS 必须包含 1 到 4 个 IPv4 地址") from exception
normalized_ipv4 = {
"mode": "static",
"address": str(ipv4["address"]),
"prefix": prefix,
"gateway": str(ipv4["gateway"]),
"dns": dns,
}
else:
raise ImageConfigError("IPv4 模式无效")
return {
"schema_version": FORMAT_VERSION,
"product": PRODUCT_ID,
"software_version": software_version,
"account": {"username": username, "password": account_password},
"wifi": {"ssid": ssid, "password": wifi_password},
"ipv4": normalized_ipv4,
}
def serialize_config(value: Any) -> str:
return json.dumps(validate_config(value), ensure_ascii=False, separators=(",", ":"))
def deserialize_config(text: str) -> dict[str, Any]:
try:
value = json.loads(text)
except (UnicodeError, json.JSONDecodeError) as exception:
raise ImageConfigError("配置 JSON 无效") from exception
return validate_config(value)
def _slot_offset(index: int) -> int:
return HEADER_BYTES + index * SLOT_BYTES
def _encode_slot(config: Any, generation: int) -> bytes:
payload = (serialize_config(config) + "\n").encode("utf-8")
if len(payload) > SLOT_BYTES - _SLOT_HEADER.size:
raise ImageConfigError("配置内容过大")
header = _SLOT_HEADER.pack(SLOT_MAGIC, generation, len(payload), hashlib.sha256(payload).digest())
return header + payload + bytes(SLOT_BYTES - len(header) - len(payload))
def create_config_file(config: Any) -> bytes:
result = bytearray(CONFIG_FILE_BYTES)
_HEADER.pack_into(result, 0, FILE_MAGIC, FORMAT_VERSION, CONFIG_FILE_BYTES)
result[_slot_offset(0) : _slot_offset(0) + SLOT_BYTES] = _encode_slot(config, 1)
return bytes(result)
def read_config_file(data: bytes) -> tuple[dict[str, Any], int, int]:
if len(data) != CONFIG_FILE_BYTES:
raise ImageConfigError("镜像配置文件大小无效")
try:
magic, version, declared_size = _HEADER.unpack_from(data, 0)
except struct.error as exception:
raise ImageConfigError("镜像配置区头部无效") from exception
if magic != FILE_MAGIC or version != FORMAT_VERSION or declared_size != CONFIG_FILE_BYTES:
raise ImageConfigError("镜像配置区头部无效")
valid: list[tuple[int, int, dict[str, Any]]] = []
for index in range(2):
offset = _slot_offset(index)
slot_magic, generation, payload_bytes, digest = _SLOT_HEADER.unpack_from(data, offset)
if slot_magic != SLOT_MAGIC or payload_bytes <= 0 or payload_bytes > SLOT_BYTES - _SLOT_HEADER.size:
continue
payload_start = offset + _SLOT_HEADER.size
payload = data[payload_start : payload_start + payload_bytes]
if hashlib.sha256(payload).digest() != digest:
continue
try:
document = deserialize_config(payload.decode("utf-8"))
except (UnicodeError, ImageConfigError):
continue
valid.append((generation, index, document))
if not valid:
raise ImageConfigError("镜像配置区没有可恢复的有效副本")
generation, index, document = max(valid, key=lambda item: (item[0], item[1]))
return document, generation, index
def encode_update(data: bytes, config: Any) -> tuple[bytes, int]:
_current, generation, active = read_config_file(data)
target = 1 - active
return _encode_slot(config, generation + 1), target
def update_config_file(data: bytes, config: Any) -> bytes:
slot_data, target = encode_update(data, config)
result = bytearray(data)
offset = _slot_offset(target)
result[offset : offset + SLOT_BYTES] = slot_data
return bytes(result)
def slot_offset(index: int) -> int:
if index not in (0, 1):
raise ValueError("配置槽索引无效")
return _slot_offset(index)
@@ -0,0 +1,176 @@
from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
import struct
from typing import BinaryIO, Iterator
class Fat16Error(ValueError):
pass
@dataclass(frozen=True)
class FatEntry:
first_cluster: int
size: int
class Fat16Disk:
_SUPPORTED_PARTITION_TYPES = {0x04, 0x06, 0x0B, 0x0C, 0x0E}
def __init__(self, path: str | os.PathLike[str], *, writable: bool) -> None:
self.path = Path(path)
self._stream: BinaryIO = self.path.open("r+b" if writable else "rb", buffering=1024 * 1024)
try:
self._initialize()
except Exception:
self._stream.close()
raise
def _initialize(self) -> None:
self._stream.seek(0, os.SEEK_END)
self._image_bytes = self._stream.tell()
mbr = self._read_at(0, 512)
if mbr[510:512] != b"\x55\xaa":
raise Fat16Error("文件没有有效的 MBR 分区表")
partition_type = mbr[450]
partition_lba, partition_sectors = struct.unpack_from("<II", mbr, 454)
if partition_type not in self._SUPPORTED_PARTITION_TYPES or partition_lba == 0 or partition_sectors == 0:
raise Fat16Error("镜像第一分区不是受支持的 FAT 启动分区")
self._partition_offset = partition_lba * 512
self._partition_bytes = partition_sectors * 512
self._partition_end = self._partition_offset + self._partition_bytes
if self._partition_end > self._image_bytes:
raise Fat16Error("镜像分区表超出文件边界")
bpb = self._read_at(self._partition_offset, 512)
bytes_per_sector = struct.unpack_from("<H", bpb, 11)[0]
sectors_per_cluster = bpb[13]
reserved = struct.unpack_from("<H", bpb, 14)[0]
fat_count = bpb[16]
self._root_entries = struct.unpack_from("<H", bpb, 17)[0]
fat_sectors = struct.unpack_from("<H", bpb, 22)[0]
if (
bytes_per_sector != 512
or sectors_per_cluster == 0
or reserved == 0
or fat_count == 0
or fat_sectors == 0
or self._root_entries == 0
):
raise Fat16Error("镜像第一分区不是受支持的 FAT16")
self._cluster_bytes = bytes_per_sector * sectors_per_cluster
self._fat_bytes = fat_sectors * bytes_per_sector
self._fat_offset = self._partition_offset + reserved * bytes_per_sector
self._root_offset = self._fat_offset + fat_count * self._fat_bytes
root_bytes = self._root_entries * 32
root_sectors = (root_bytes + bytes_per_sector - 1) // bytes_per_sector
self._data_offset = self._root_offset + root_sectors * bytes_per_sector
if self._data_offset > self._partition_end or self._fat_offset + self._fat_bytes > self._partition_end:
raise Fat16Error("FAT16 布局超出分区边界")
def __enter__(self) -> "Fat16Disk":
return self
def __exit__(self, *_args: object) -> None:
self.close()
def close(self) -> None:
self._stream.close()
def _read_at(self, offset: int, count: int) -> bytes:
if offset < 0 or count < 0 or offset + count > self._image_bytes:
raise Fat16Error("镜像数据超出文件边界")
self._stream.seek(offset)
value = self._stream.read(count)
if len(value) != count:
raise Fat16Error("镜像数据被截断")
return value
@staticmethod
def _name83(name: str) -> bytes:
parts = name.upper().split(".", 1)
if not 1 <= len(parts[0]) <= 8 or (len(parts) == 2 and len(parts[1]) > 3):
raise ValueError("文件名不是 8.3 格式")
try:
return (parts[0].ljust(8) + (parts[1] if len(parts) == 2 else "").ljust(3)).encode("ascii")
except UnicodeEncodeError as exception:
raise ValueError("文件名不是 ASCII 8.3 格式") from exception
def _find(self, name: str) -> FatEntry:
expected = self._name83(name)
root = self._read_at(self._root_offset, self._root_entries * 32)
for index in range(self._root_entries):
entry = root[index * 32 : (index + 1) * 32]
if entry[0] == 0x00:
break
if entry[0] == 0xE5 or entry[11] == 0x0F:
continue
if entry[:11] == expected:
first_cluster = struct.unpack_from("<H", entry, 26)[0]
size = struct.unpack_from("<I", entry, 28)[0]
return FatEntry(first_cluster, size)
raise Fat16Error(f"镜像缺少 {name}")
def _chain(self, first_cluster: int, fat: bytes) -> Iterator[int]:
seen: set[int] = set()
cluster = first_cluster
while 2 <= cluster < 0xFFF8:
if cluster in seen:
raise Fat16Error("FAT16 簇链循环")
if cluster * 2 + 2 > len(fat):
raise Fat16Error("FAT16 簇号超出 FAT 边界")
offset = self._data_offset + (cluster - 2) * self._cluster_bytes
if offset + self._cluster_bytes > self._partition_end:
raise Fat16Error("FAT16 簇超出分区边界")
seen.add(cluster)
yield cluster
cluster = struct.unpack_from("<H", fat, cluster * 2)[0]
def _cluster_offset(self, cluster: int) -> int:
return self._data_offset + (cluster - 2) * self._cluster_bytes
def read_file(self, name: str) -> bytes:
entry = self._find(name)
if entry.size == 0:
return b""
fat = self._read_at(self._fat_offset, self._fat_bytes)
result = bytearray(entry.size)
written = 0
for cluster in self._chain(entry.first_cluster, fat):
count = min(self._cluster_bytes, entry.size - written)
result[written : written + count] = self._read_at(self._cluster_offset(cluster), count)
written += count
if written == entry.size:
break
if written != entry.size:
raise Fat16Error("镜像配置文件被截断")
return bytes(result)
def write_file_range(self, name: str, file_offset: int, data: bytes) -> None:
entry = self._find(name)
if file_offset < 0 or file_offset + len(data) > entry.size:
raise ValueError("写入范围超出配置文件")
fat = self._read_at(self._fat_offset, self._fat_bytes)
chain = list(self._chain(entry.first_cluster, fat))
remaining = len(data)
source_offset = 0
position = file_offset
while remaining:
chain_index, within = divmod(position, self._cluster_bytes)
if chain_index >= len(chain):
raise Fat16Error("镜像配置文件簇链不足")
count = min(remaining, self._cluster_bytes - within)
absolute = self._cluster_offset(chain[chain_index]) + within
if absolute + count > self._partition_end:
raise Fat16Error("写入范围超出 FAT16 分区")
self._stream.seek(absolute)
self._stream.write(data[source_offset : source_offset + count])
position += count
source_offset += count
remaining -= count
self._stream.flush()
os.fsync(self._stream.fileno())
@@ -0,0 +1,425 @@
from __future__ import annotations
import os
from pathlib import Path
import sys
from typing import Any, Callable
from PySide6.QtCore import QObject, QRunnable, QThreadPool, Qt, Signal
from PySide6.QtGui import QCloseEvent, QDragEnterEvent, QDropEvent, QFont
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QFrame,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QPlainTextEdit,
QProgressBar,
QPushButton,
QScrollArea,
QSpinBox,
QStyle,
QVBoxLayout,
QWidget,
)
from . import __version__
from .config import validate_config
from .operations import copy_write_and_verify, read_config, write_and_verify
from .paths import validate_new_destination
class AppDialog(QDialog):
def __init__(self, parent: QWidget | None, message: str, title: str, icon: QStyle.StandardPixmap, confirm: bool) -> None:
super().__init__(parent)
self.setWindowTitle(title)
self.setModal(True)
self.setMinimumSize(520, 260)
self.resize(640, 320)
icon_label = QLabel()
icon_label.setPixmap(self.style().standardIcon(icon).pixmap(48, 48))
icon_label.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter)
body = QPlainTextEdit(message)
body.setReadOnly(True)
body.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Yes | QDialogButtonBox.StandardButton.No
if confirm
else QDialogButtonBox.StandardButton.Ok
)
if confirm:
buttons.button(QDialogButtonBox.StandardButton.Yes).setText("是")
buttons.button(QDialogButtonBox.StandardButton.No).setText("否")
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
else:
buttons.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
buttons.accepted.connect(self.accept)
top = QHBoxLayout()
top.addWidget(icon_label)
top.addWidget(body, 1)
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(20)
layout.addLayout(top, 1)
layout.addWidget(buttons)
@classmethod
def information(cls, parent: QWidget, message: str, title: str) -> None:
cls(parent, message, title, QStyle.StandardPixmap.SP_MessageBoxInformation, False).exec()
@classmethod
def error(cls, parent: QWidget | None, message: str, title: str) -> None:
cls(parent, message, title, QStyle.StandardPixmap.SP_MessageBoxCritical, False).exec()
@classmethod
def confirm(cls, parent: QWidget, message: str, title: str) -> bool:
return cls(parent, message, title, QStyle.StandardPixmap.SP_MessageBoxWarning, True).exec() == QDialog.DialogCode.Accepted
class WorkerSignals(QObject):
progress = Signal(int)
succeeded = Signal(object)
failed = Signal(str)
class Worker(QRunnable):
def __init__(self, operation: Callable[[Callable[[int], None]], Any]) -> None:
super().__init__()
self.operation = operation
self.signals = WorkerSignals()
def run(self) -> None:
try:
result = self.operation(self.signals.progress.emit)
except Exception as exception:
self.signals.failed.emit(str(exception))
else:
self.signals.succeeded.emit(result)
class MainWindow(QMainWindow):
PREFERRED_SIZE = (960, 760)
MINIMUM_SIZE = (760, 600)
def __init__(self) -> None:
super().__init__()
self.setWindowTitle(f"核桃派镜像配置编辑器 {__version__}")
self.setAcceptDrops(True)
self._image_path: Path | None = None
self._loaded: dict[str, Any] | None = None
self._busy = False
self._workers: set[Worker] = set()
self._thread_pool = QThreadPool.globalInstance()
self._screen_signal_connected = False
self.path = QLineEdit()
self.path.setReadOnly(True)
self.version = QLabel("尚未打开镜像")
self.username = QLineEdit()
self.account_password = QLineEdit()
self.ssid = QLineEdit()
self.wifi_password = QLineEdit()
self.mode = QComboBox()
self.mode.addItems(["自动获取(DHCP)", "静态 IPv4"])
self.address = QLineEdit()
self.prefix = QSpinBox()
self.prefix.setRange(1, 32)
self.prefix.setValue(24)
self.gateway = QLineEdit()
self.dns = QLineEdit()
self.open_button = QPushButton("打开镜像…")
self.save_button = QPushButton("修改原镜像")
self.save_as_button = QPushButton("另存为…")
self.progress = QProgressBar()
self.progress.setRange(0, 100)
self.progress.hide()
self.save_button.setEnabled(False)
self.save_as_button.setEnabled(False)
self.account_password.setEchoMode(QLineEdit.EchoMode.Password)
self.wifi_password.setEchoMode(QLineEdit.EchoMode.Password)
self.mode.currentIndexChanged.connect(self._update_network_fields)
self.open_button.clicked.connect(self._open_dialog)
self.save_button.clicked.connect(self._save_original)
self.save_as_button.clicked.connect(self._save_as)
self.setCentralWidget(self._build_ui())
self._update_network_fields()
def _build_ui(self) -> QWidget:
content = QWidget()
outer = QVBoxLayout(content)
outer.setContentsMargins(24, 24, 24, 24)
outer.setSpacing(12)
header = QHBoxLayout()
header.addWidget(self.path, 1)
self.open_button.setMinimumSize(130, 38)
header.addWidget(self.open_button)
outer.addLayout(header)
outer.addWidget(self.version)
outer.addWidget(self._group("登录账户", [("用户名", self.username), ("密码", self._password_row(self.account_password))]))
outer.addWidget(self._group("Wi-Fi", [("SSID", self.ssid), ("密码", self._password_row(self.wifi_password))]))
outer.addWidget(
self._group(
"IPv4",
[
("方式", self.mode),
("地址", self.address),
("前缀", self.prefix),
("网关", self.gateway),
("DNS(逗号分隔)", self.dns),
],
)
)
footer = QHBoxLayout()
footer.addWidget(self.progress, 1)
footer.addStretch(1)
self.save_button.setMinimumSize(150, 40)
self.save_as_button.setMinimumSize(130, 40)
footer.addWidget(self.save_button)
footer.addWidget(self.save_as_button)
outer.addLayout(footer)
scroll = QScrollArea()
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setWidgetResizable(True)
scroll.setWidget(content)
return scroll
@staticmethod
def _group(title: str, rows: list[tuple[str, QWidget]]) -> QGroupBox:
group = QGroupBox(title)
form = QFormLayout(group)
form.setContentsMargins(16, 20, 16, 16)
form.setHorizontalSpacing(18)
form.setVerticalSpacing(10)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
for label, widget in rows:
widget.setMinimumWidth(320)
form.addRow(label, widget)
return group
@staticmethod
def _password_row(field: QLineEdit) -> QWidget:
wrapper = QWidget()
layout = QHBoxLayout(wrapper)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(12)
show = QCheckBox("显示")
show.toggled.connect(lambda checked: field.setEchoMode(QLineEdit.EchoMode.Normal if checked else QLineEdit.EchoMode.Password))
layout.addWidget(field, 1)
layout.addWidget(show)
return wrapper
def showEvent(self, event: Any) -> None:
super().showEvent(event)
self._fit_to_screen()
handle = self.windowHandle()
if handle is not None and not self._screen_signal_connected:
handle.screenChanged.connect(self._fit_to_screen)
self._screen_signal_connected = True
def _fit_to_screen(self, *_args: object) -> None:
screen = self.screen() or QApplication.primaryScreen()
if screen is None:
return
area = screen.availableGeometry()
maximum_width = max(320, int(area.width() * 0.9))
maximum_height = max(240, int(area.height() * 0.9))
minimum_width = min(self.MINIMUM_SIZE[0], maximum_width)
minimum_height = min(self.MINIMUM_SIZE[1], maximum_height)
self.setMinimumSize(minimum_width, minimum_height)
width = max(minimum_width, min(self.PREFERRED_SIZE[0], maximum_width))
height = max(minimum_height, min(self.PREFERRED_SIZE[1], maximum_height))
self.resize(width, height)
self.move(area.left() + max(0, (area.width() - width) // 2), area.top() + max(0, (area.height() - height) // 2))
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
urls = event.mimeData().urls() if event.mimeData().hasUrls() else []
if not self._busy and len(urls) == 1 and urls[0].isLocalFile():
event.acceptProposedAction()
else:
event.ignore()
def dropEvent(self, event: QDropEvent) -> None:
urls = event.mimeData().urls() if event.mimeData().hasUrls() else []
if not self._busy and len(urls) == 1 and urls[0].isLocalFile():
event.acceptProposedAction()
self._open_image(urls[0].toLocalFile())
else:
event.ignore()
def closeEvent(self, event: QCloseEvent) -> None:
if self._busy:
event.ignore()
return
super().closeEvent(event)
def _open_dialog(self) -> None:
filename, _filter = QFileDialog.getOpenFileName(self, "打开核桃派镜像", "", "核桃派镜像 (*.img)")
if filename:
self._open_image(filename)
def _open_image(self, filename: str) -> None:
try:
config = read_config(filename)
self._apply_loaded(Path(filename).resolve(), config)
except Exception as exception:
AppDialog.error(self, str(exception), "无法打开镜像")
def _apply_loaded(self, filename: Path, config: dict[str, Any]) -> None:
self._image_path = filename
self._loaded = config
self.path.setText(str(filename))
self.version.setText(f"软件版本:{config['software_version']} 配置校验:正常")
self.username.setText(config["account"]["username"])
self.account_password.setText(config["account"]["password"])
self.ssid.setText(config["wifi"]["ssid"])
self.wifi_password.setText(config["wifi"]["password"])
ipv4 = config["ipv4"]
self.mode.setCurrentIndex(1 if ipv4["mode"] == "static" else 0)
self.address.setText(ipv4["address"])
self.prefix.setValue(ipv4["prefix"] or 24)
self.gateway.setText(ipv4["gateway"])
self.dns.setText(",".join(ipv4["dns"]))
self.save_button.setEnabled(True)
self.save_as_button.setEnabled(True)
self._update_network_fields()
def _collect(self) -> dict[str, Any]:
if self._loaded is None:
raise ValueError("尚未打开镜像")
ipv4: dict[str, Any]
if self.mode.currentIndex() == 0:
ipv4 = {"mode": "dhcp", "address": "", "prefix": 0, "gateway": "", "dns": []}
else:
ipv4 = {
"mode": "static",
"address": self.address.text().strip(),
"prefix": self.prefix.value(),
"gateway": self.gateway.text().strip(),
"dns": [item.strip() for item in self.dns.text().split(",") if item.strip()],
}
return validate_config(
{
"schema_version": self._loaded["schema_version"],
"product": self._loaded["product"],
"software_version": self._loaded["software_version"],
"account": {"username": self.username.text().strip(), "password": self.account_password.text()},
"wifi": {"ssid": self.ssid.text(), "password": self.wifi_password.text()},
"ipv4": ipv4,
}
)
def _save_original(self) -> None:
if self._image_path is None:
return
try:
config = self._collect()
except Exception as exception:
AppDialog.error(self, str(exception), "保存失败")
return
if not AppDialog.confirm(
self,
"这会直接修改当前镜像。建议日常使用“另存为”保留原始包。\n\n确定继续吗?",
"确认修改原镜像",
):
return
target = self._image_path
self._start_worker(
lambda progress: write_and_verify(target, config, progress),
lambda verified: self._save_succeeded(target, verified, "配置已保存,镜像与 SHA-256 摘要均已完成写后校验。", "保存成功"),
)
def _save_as(self) -> None:
if self._image_path is None:
return
try:
config = self._collect()
except Exception as exception:
AppDialog.error(self, str(exception), "另存为失败")
return
dialog = QFileDialog(self, "请重命名,以便区分设备或使用地点", str(self._image_path.parent), "核桃派镜像 (*.img)")
dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave)
dialog.setFileMode(QFileDialog.FileMode.AnyFile)
dialog.setOption(QFileDialog.Option.DontConfirmOverwrite, True)
dialog.selectFile(f"{self._image_path.stem}-自定义.img")
if dialog.exec() != QDialog.DialogCode.Accepted or not dialog.selectedFiles():
return
selected = dialog.selectedFiles()[0]
if Path(selected).suffix == "":
selected += ".img"
try:
destination = validate_new_destination(self._image_path, selected)
except Exception as exception:
AppDialog.error(self, str(exception), "另存为失败")
return
source = self._image_path
self._start_worker(
lambda progress: copy_write_and_verify(source, destination, config, progress),
lambda result: self._save_succeeded(
result[0], result[1], "自定义镜像已保存并重新打开,镜像与 SHA-256 摘要均已完成写后校验。", "另存为成功"
),
)
def _start_worker(self, operation: Callable[[Callable[[int], None]], Any], on_success: Callable[[Any], None]) -> None:
self._set_busy(True)
worker = Worker(operation)
self._workers.add(worker)
worker.signals.progress.connect(self.progress.setValue)
def succeeded(result: Any) -> None:
self._workers.discard(worker)
self._set_busy(False)
on_success(result)
def failed(message: str) -> None:
self._workers.discard(worker)
self._set_busy(False)
AppDialog.error(self, message, "保存失败")
worker.signals.succeeded.connect(succeeded)
worker.signals.failed.connect(failed)
self._thread_pool.start(worker)
def _save_succeeded(self, target: Path, verified: dict[str, Any], message: str, title: str) -> None:
self._apply_loaded(target, verified)
AppDialog.information(self, message, title)
def _set_busy(self, busy: bool) -> None:
self._busy = busy
self.open_button.setEnabled(not busy)
self.save_button.setEnabled(not busy and self._loaded is not None)
self.save_as_button.setEnabled(not busy and self._loaded is not None)
self.progress.setVisible(busy)
self.progress.setValue(0)
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) if busy else QApplication.restoreOverrideCursor()
def _update_network_fields(self) -> None:
enabled = self.mode.currentIndex() == 1
for widget in (self.address, self.prefix, self.gateway, self.dns):
widget.setEnabled(enabled)
def run_gui() -> int:
app = QApplication(sys.argv)
app.setApplicationName("核桃派镜像配置编辑器")
app.setApplicationVersion(__version__)
if os.name == "nt":
app.setFont(QFont("Microsoft YaHei UI", 10))
window = MainWindow()
window.show()
return app.exec()
@@ -0,0 +1,171 @@
from __future__ import annotations
import hashlib
import os
from pathlib import Path
import shutil
from typing import Any, Callable
import uuid
from .config import encode_update, read_config_file, serialize_config, slot_offset, validate_config
from .fat16 import Fat16Disk
from .paths import (
cleanup_new_destination,
ensure_sidecar_writable,
sidecar_path,
validate_existing_image,
validate_new_destination,
)
Progress = Callable[[int], None]
_CHUNK_BYTES = 4 * 1024 * 1024
class ImageOperationError(ValueError):
pass
def _hash_file(path: Path, progress: Progress | None = None, *, start: int = 0, span: int = 100) -> str:
digest = hashlib.sha256()
size = path.stat().st_size
completed = 0
with path.open("rb", buffering=_CHUNK_BYTES) as stream:
for chunk in iter(lambda: stream.read(_CHUNK_BYTES), b""):
digest.update(chunk)
completed += len(chunk)
if progress is not None:
fraction = completed / size if size else 1.0
progress(min(100, start + int(fraction * span)))
if progress is not None:
progress(min(100, start + span))
return digest.hexdigest()
def _read_sidecar_digest(path: Path, expected_filename: str) -> str:
try:
if path.stat().st_size > 128 * 1024:
raise ImageOperationError("镜像 SHA-256 摘要文件过大")
raw = path.read_bytes()
except OSError as exception:
raise ImageOperationError(f"无法读取镜像 SHA-256 摘要:{exception}") from exception
if raw.startswith(b"\xef\xbb\xbf"):
raise ImageOperationError("镜像 SHA-256 摘要必须使用无 BOM UTF-8 格式")
try:
content = raw.decode("utf-8", errors="strict")
except UnicodeDecodeError as exception:
raise ImageOperationError("镜像 SHA-256 摘要不是有效的 UTF-8 文本") from exception
if len(content) < 68 or not content.endswith("\n") or "\r" in content or "\n" in content[:-1]:
raise ImageOperationError("镜像 SHA-256 摘要文件必须是以 LF 结尾的单行规范格式")
line = content[:-1]
digest = line[:64]
if len(line) < 67 or any(character not in "0123456789abcdef" for character in digest) or line[64:66] != " ":
raise ImageOperationError("镜像 SHA-256 摘要文件格式无效")
if line[66:] != expected_filename:
raise ImageOperationError("镜像 SHA-256 摘要中的文件名与当前镜像不一致")
return digest
def verify_sidecar_if_present(image_path: Path, progress: Progress | None = None) -> None:
sidecar = sidecar_path(image_path)
if not sidecar.exists():
return
expected = _read_sidecar_digest(sidecar, image_path.name)
actual = _hash_file(image_path, progress)
if actual != expected:
raise ImageOperationError("镜像内容与 SHA-256 摘要不符,禁止编辑")
def read_config(image: str | os.PathLike[str], progress: Progress | None = None) -> dict[str, Any]:
image_path = validate_existing_image(image)
verify_sidecar_if_present(image_path, progress)
with Fat16Disk(image_path, writable=False) as disk:
config, _generation, _slot = read_config_file(disk.read_file("MSCCFG.BIN"))
return config
def write_config(image: str | os.PathLike[str], config: Any) -> None:
image_path = validate_existing_image(image)
ensure_sidecar_writable(image_path)
verify_sidecar_if_present(image_path)
normalized = validate_config(config)
with Fat16Disk(image_path, writable=True) as disk:
current = disk.read_file("MSCCFG.BIN")
active, _generation, _slot = read_config_file(current)
if active["software_version"] != normalized["software_version"]:
raise ImageOperationError("禁止通过编辑器改变软件版本")
slot_data, target = encode_update(current, normalized)
disk.write_file_range("MSCCFG.BIN", slot_offset(target), slot_data)
verified, _generation, _slot = read_config_file(disk.read_file("MSCCFG.BIN"))
if serialize_config(verified) != serialize_config(normalized):
raise ImageOperationError("配置写后校验失败")
def write_sha256(image: str | os.PathLike[str], progress: Progress | None = None) -> None:
image_path = validate_existing_image(image)
ensure_sidecar_writable(image_path)
digest = _hash_file(image_path, progress)
sidecar = sidecar_path(image_path)
temporary = Path(str(sidecar) + f".{uuid.uuid4().hex}.tmp")
try:
payload = f"{digest} {image_path.name}\n".encode("utf-8")
with temporary.open("xb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, sidecar)
stored = _read_sidecar_digest(sidecar, image_path.name)
if stored != digest:
raise ImageOperationError("镜像 SHA-256 摘要写后校验失败")
finally:
try:
temporary.unlink(missing_ok=True)
except OSError:
pass
def write_and_verify(image: str | os.PathLike[str], config: Any, progress: Progress | None = None) -> dict[str, Any]:
write_config(image, config)
write_sha256(image, progress)
return read_config(image)
def _copy_file(source: Path, destination: Path, progress: Progress | None = None) -> None:
total = source.stat().st_size
completed = 0
with source.open("rb", buffering=_CHUNK_BYTES) as incoming, destination.open("xb", buffering=_CHUNK_BYTES) as outgoing:
for chunk in iter(lambda: incoming.read(_CHUNK_BYTES), b""):
outgoing.write(chunk)
completed += len(chunk)
if progress is not None:
progress(int((completed / total if total else 1.0) * 35))
outgoing.flush()
os.fsync(outgoing.fileno())
try:
shutil.copystat(source, destination)
except OSError:
pass
def copy_write_and_verify(
source: str | os.PathLike[str],
destination: str | os.PathLike[str],
config: Any,
progress: Progress | None = None,
) -> tuple[Path, dict[str, Any]]:
source_path = validate_existing_image(source)
target = validate_new_destination(source_path, destination)
normalized = validate_config(config)
read_config(source_path)
try:
_copy_file(source_path, target, progress)
write_config(target, normalized)
write_sha256(target, None if progress is None else lambda value: progress(35 + value * 65 // 100))
verified = read_config(target)
return target, verified
except Exception as exception:
cleanup_error = cleanup_new_destination(target)
if cleanup_error is not None:
raise ImageOperationError(f"{exception}\n清理失败:{cleanup_error}") from exception
raise
@@ -0,0 +1,109 @@
from __future__ import annotations
import os
from pathlib import Path
import re
import uuid
class ImagePathError(ValueError):
pass
_WINDOWS_RESERVED = re.compile(r"^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$", re.IGNORECASE | re.ASCII)
_WINDOWS_INVALID = set('<>:"/\\|?*')
def _absolute(path: str | os.PathLike[str]) -> Path:
if not str(path).strip():
raise ImagePathError("镜像路径不能为空")
try:
return Path(path).expanduser().resolve(strict=False)
except (OSError, RuntimeError, ValueError) as exception:
raise ImagePathError("镜像路径格式无效") from exception
def _validate_name_and_directory(path: str | os.PathLike[str]) -> Path:
full = _absolute(path)
name = full.name
if not name.strip():
raise ImagePathError("镜像文件名不能为空")
if full.suffix.lower() != ".img":
raise ImagePathError("镜像文件名必须以 .img 结尾")
if os.name == "nt":
if name != name.rstrip(" .") or any(character in _WINDOWS_INVALID or ord(character) < 32 for character in name):
raise ImagePathError("镜像文件名包含 Windows 不允许的字符,或以空格、句点结尾")
if _WINDOWS_RESERVED.fullmatch(full.stem):
raise ImagePathError("镜像文件名使用了 Windows 保留名称,请更换名称")
if not full.parent.is_dir():
raise ImagePathError("镜像目标文件夹不存在")
return full
def sidecar_path(image_path: Path) -> Path:
return Path(str(image_path) + ".sha256")
def validate_existing_image(path: str | os.PathLike[str]) -> Path:
full = _validate_name_and_directory(path)
if not full.is_file():
raise ImagePathError(f"镜像文件不存在:{full}")
sidecar = sidecar_path(full)
if sidecar.is_dir():
raise ImagePathError("同名 SHA-256 摘要路径被文件夹占用")
return full
def validate_new_destination(source: str | os.PathLike[str], destination: str | os.PathLike[str]) -> Path:
source_path = validate_existing_image(source)
target = _validate_name_and_directory(destination)
if os.path.normcase(str(target)) == os.path.normcase(str(source_path)):
raise ImagePathError("另存为必须使用与原镜像不同的文件名")
if target.exists():
raise ImagePathError("目标镜像已经存在,请使用新的文件名")
sidecar = sidecar_path(target)
if sidecar.exists():
raise ImagePathError("目标镜像的同名 SHA-256 摘要已经存在,请使用新的文件名")
_probe_directory(target.parent)
return target
def ensure_sidecar_writable(image_path: Path) -> None:
sidecar = sidecar_path(image_path)
if sidecar.exists():
if not sidecar.is_file():
raise ImagePathError("同名 SHA-256 摘要不是普通文件")
try:
with sidecar.open("ab"):
pass
except OSError as exception:
raise ImagePathError("同名 SHA-256 摘要不可写") from exception
_probe_directory(image_path.parent)
def _probe_directory(directory: Path) -> None:
probe = directory / f".matrix-image-editor-{uuid.uuid4().hex}.tmp"
try:
with probe.open("xb") as stream:
stream.flush()
os.fsync(stream.fileno())
except OSError as exception:
raise ImagePathError("镜像目标文件夹不可写") from exception
finally:
try:
probe.unlink(missing_ok=True)
except OSError:
pass
def cleanup_new_destination(destination: str | os.PathLike[str]) -> str | None:
target = Path(destination)
failures: list[str] = []
for item in (sidecar_path(target), target):
try:
if item.is_file() or item.is_symlink():
item.unlink()
except OSError as exception:
failures.append(f"{item.name}:{exception}")
return ";".join(failures) if failures else None