初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# 核桃派镜像配置编辑器
|
||||
|
||||
`2.0.0` 起使用 Python 3 + PySide6 重写。`编辑器程序/` 保存 Windows 10/11 x64 可直接运行的绿色单 EXE;`编辑器源代码/` 是不依赖本项目其他目录的跨平台源码,可在 macOS 上本地重新打包。
|
||||
|
||||
打开版本化 IMG 后可查看软件版本、账户名、Wi-Fi 和 IPv4 配置,密码默认遮挡。支持二次确认后修改原镜像,以及使用宿主系统合法的新名称另存为定制副本。中文、空格和长路径受支持;保存完成会生成无 BOM UTF-8 的同名 `.sha256` 并重新验证。损坏、截断、产品错误、未知 schema、配置摘要错误或现有镜像摘要不符时禁止保存。
|
||||
|
||||
编辑器只修改 IMG 内固定的双槽配置区,不负责向 TF 卡写入镜像。写卡仍使用 Rufus 等原始磁盘写入工具。
|
||||
|
||||
1.0.1 C#/.NET WinForms 版已完整归档到 `各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/`。
|
||||
@@ -0,0 +1,7 @@
|
||||
/.build-venv/
|
||||
/.build/
|
||||
/dist/
|
||||
/build/
|
||||
/*.spec
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
@@ -0,0 +1,39 @@
|
||||
# 构建镜像配置编辑器 2.0.0
|
||||
|
||||
源码使用 Python 3 和 PySide6,不依赖本项目其他目录。支持 Python 3.10–3.14,构建依赖已在 `requirements-build.txt` 锁定。
|
||||
|
||||
以下命令均在本文件所在的 `发布更新相关/镜像编辑器/编辑器源代码/` 目录执行。
|
||||
|
||||
## Windows 10/11 x64
|
||||
|
||||
```powershell
|
||||
.\build_windows.ps1
|
||||
```
|
||||
|
||||
脚本创建隔离构建环境、运行单元测试,再使用 PyInstaller 生成单 EXE,并更新 `../编辑器程序/` 的版本和 SHA-256。发布后的电脑不需要安装 Python、.NET 或 Qt。
|
||||
已有锁定依赖的 venv 时可传入 `-BuildEnvironment "<venv路径>"`,脚本会拒绝错误的 PySide6/PyInstaller 版本。
|
||||
|
||||
## macOS
|
||||
|
||||
必须在 macOS 上本地构建,PyInstaller 不是跨平台编译器:
|
||||
|
||||
```sh
|
||||
chmod +x ./build_macos.sh
|
||||
./build_macos.sh
|
||||
```
|
||||
|
||||
本项目未交付或验收 macOS 产物;对外分发 `.app` 时还应由分发者完成 Apple 签名和公证。命令行功能位于同一源码入口;若需在终端直接查看标准输出,将 macOS 构建参数的 `--windowed` 改为 `--console`。
|
||||
|
||||
## 源码运行与命令行
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH = ".\src"
|
||||
python .\editor_main.py
|
||||
python .\editor_main.py --validate "<镜像.img>"
|
||||
python .\editor_main.py --apply "<原镜像.img>" "<配置.json>" "<新镜像.img>"
|
||||
python -m unittest discover -s .\tests -v
|
||||
```
|
||||
|
||||
编辑器只修改 IMG 内 FAT16 启动分区的固定双槽配置区,不负责向 TF 卡写入镜像。源码、构建物和日志不得内置发布密码或读取项目凭据文件。
|
||||
|
||||
第三方软件声明见 `THIRD_PARTY_NOTICES.md`。
|
||||
@@ -0,0 +1,10 @@
|
||||
# 第三方软件声明
|
||||
|
||||
本编辑器的发布包包含 Python、Qt for Python/PySide6、Qt 及 PyInstaller 的必要组件。
|
||||
|
||||
- Python 使用 Python Software Foundation License:<https://docs.python.org/3/license.html>
|
||||
- Qt for Python/PySide6 社区版使用 LGPLv3/GPLv3 及其所含第三方许可:<https://doc.qt.io/qtforpython-6/licenses.html>
|
||||
- Qt 的开源许可和模块说明:<https://doc.qt.io/qt-6/licensing.html>
|
||||
- PyInstaller 使用 GPLv2-or-later,其 bootloader 带有允许发布生成程序的例外:<https://pyinstaller.org/en/stable/license.html>
|
||||
|
||||
构建时只引入 Qt Core/Gui/Widgets 及平台插件等本程序实际使用的组件。再分发者应按实际发布地区和用途核对并遵守相应许可条款。
|
||||
@@ -0,0 +1 @@
|
||||
2.0.0
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
python3 -m venv .build-venv
|
||||
. .build-venv/bin/activate
|
||||
python -m pip install --requirement requirements-build.txt
|
||||
PYTHONPATH=src python -m unittest discover -s tests -v
|
||||
python -m PyInstaller --noconfirm --clean --onefile --windowed \
|
||||
--name "核桃派镜像配置编辑器" \
|
||||
--paths src editor_main.py
|
||||
@@ -0,0 +1,69 @@
|
||||
param(
|
||||
[string]$Python = "python",
|
||||
[string]$BuildEnvironment = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$sourceRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$buildRoot = Join-Path $sourceRoot ".build"
|
||||
$programRoot = [System.IO.Path]::GetFullPath((Join-Path $sourceRoot "..\编辑器程序"))
|
||||
|
||||
if ($BuildEnvironment) {
|
||||
$pythonExe = [System.IO.Path]::GetFullPath((Join-Path $BuildEnvironment "Scripts\python.exe"))
|
||||
if (-not (Test-Path -LiteralPath $pythonExe)) {
|
||||
throw "指定的构建环境不存在:$BuildEnvironment"
|
||||
}
|
||||
} else {
|
||||
$venvRoot = Join-Path $sourceRoot ".build-venv"
|
||||
$pythonExe = Join-Path $venvRoot "Scripts\python.exe"
|
||||
if (-not (Test-Path -LiteralPath $pythonExe)) {
|
||||
& $Python -m venv $venvRoot
|
||||
}
|
||||
& $pythonExe -m pip install --disable-pip-version-check --requirement (Join-Path $sourceRoot "requirements-build.txt")
|
||||
}
|
||||
|
||||
& $pythonExe -c "import PyInstaller, PySide6; assert PyInstaller.__version__ == '6.22.2'; assert PySide6.__version__ == '6.11.2'"
|
||||
if ($LASTEXITCODE -ne 0) { throw "构建依赖版本校验失败" }
|
||||
|
||||
$env:PYTHONPATH = Join-Path $sourceRoot "src"
|
||||
& $pythonExe -m unittest discover -s (Join-Path $sourceRoot "tests") -v
|
||||
if ($LASTEXITCODE -ne 0) { throw "镜像编辑器单元测试失败" }
|
||||
|
||||
New-Item -ItemType Directory -Path $buildRoot -Force | Out-Null
|
||||
$distRoot = Join-Path $buildRoot "dist"
|
||||
$workRoot = Join-Path $buildRoot "work"
|
||||
$specRoot = Join-Path $buildRoot "spec"
|
||||
New-Item -ItemType Directory -Path $distRoot,$workRoot,$specRoot -Force | Out-Null
|
||||
|
||||
$originalPath = $env:PATH
|
||||
try {
|
||||
# Avoid collecting unrelated DLLs from the caller's PATH (for example another app's ICU build).
|
||||
$env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
|
||||
& $pythonExe -m PyInstaller `
|
||||
--noconfirm `
|
||||
--clean `
|
||||
--onefile `
|
||||
--console `
|
||||
--hide-console=hide-early `
|
||||
--name "核桃派镜像配置编辑器" `
|
||||
--version-file (Join-Path $sourceRoot "windows_version_info.txt") `
|
||||
--paths (Join-Path $sourceRoot "src") `
|
||||
--distpath $distRoot `
|
||||
--workpath $workRoot `
|
||||
--specpath $specRoot `
|
||||
(Join-Path $sourceRoot "editor_main.py")
|
||||
if ($LASTEXITCODE -ne 0) { throw "PyInstaller 构建失败" }
|
||||
} finally {
|
||||
$env:PATH = $originalPath
|
||||
}
|
||||
|
||||
$artifact = Join-Path $distRoot "核桃派镜像配置编辑器.exe"
|
||||
if (-not (Test-Path -LiteralPath $artifact)) {
|
||||
throw "PyInstaller 未生成目标 EXE"
|
||||
}
|
||||
New-Item -ItemType Directory -Path $programRoot -Force | Out-Null
|
||||
Copy-Item -LiteralPath $artifact -Destination (Join-Path $programRoot "核桃派镜像配置编辑器.exe") -Force
|
||||
[System.IO.File]::WriteAllText((Join-Path $programRoot "VERSION"), "2.0.0`n", [System.Text.UTF8Encoding]::new($false))
|
||||
$hash = (Get-FileHash -LiteralPath (Join-Path $programRoot "核桃派镜像配置编辑器.exe") -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
[System.IO.File]::WriteAllText((Join-Path $programRoot "SHA256SUMS"), "$hash 核桃派镜像配置编辑器.exe`n", [System.Text.UTF8Encoding]::new($false))
|
||||
Write-Output "已生成:$programRoot"
|
||||
@@ -0,0 +1,5 @@
|
||||
from matrix_image_editor.__main__ import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,16 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=77"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "matrix-image-editor"
|
||||
version = "2.0.0"
|
||||
description = "WalnutPi matrix screen image configuration editor"
|
||||
requires-python = ">=3.10,<3.15"
|
||||
dependencies = ["PySide6-Essentials==6.11.2"]
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -0,0 +1,2 @@
|
||||
PyInstaller==6.22.2
|
||||
PySide6-Essentials==6.11.2
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import struct
|
||||
|
||||
|
||||
SECTOR_BYTES = 512
|
||||
PARTITION_LBA = 1
|
||||
PARTITION_SECTORS = 8192
|
||||
RESERVED_SECTORS = 1
|
||||
FAT_COUNT = 2
|
||||
FAT_SECTORS = 32
|
||||
ROOT_ENTRIES = 512
|
||||
ROOT_SECTORS = ROOT_ENTRIES * 32 // SECTOR_BYTES
|
||||
DATA_RELATIVE_SECTOR = RESERVED_SECTORS + FAT_COUNT * FAT_SECTORS + ROOT_SECTORS
|
||||
|
||||
|
||||
def build_fixture(path: Path, config_file: bytes) -> Path:
|
||||
if len(config_file) != 64 * 1024:
|
||||
raise ValueError("配置文件必须为 64 KiB")
|
||||
image = bytearray((PARTITION_LBA + PARTITION_SECTORS) * SECTOR_BYTES)
|
||||
image[510:512] = b"\x55\xaa"
|
||||
partition_entry = 446
|
||||
image[partition_entry + 4] = 0x06
|
||||
struct.pack_into("<II", image, partition_entry + 8, PARTITION_LBA, PARTITION_SECTORS)
|
||||
|
||||
boot = PARTITION_LBA * SECTOR_BYTES
|
||||
image[boot : boot + 3] = b"\xeb\x3c\x90"
|
||||
image[boot + 3 : boot + 11] = b"MSDOS5.0"
|
||||
struct.pack_into("<H", image, boot + 11, SECTOR_BYTES)
|
||||
image[boot + 13] = 1
|
||||
struct.pack_into("<H", image, boot + 14, RESERVED_SECTORS)
|
||||
image[boot + 16] = FAT_COUNT
|
||||
struct.pack_into("<H", image, boot + 17, ROOT_ENTRIES)
|
||||
struct.pack_into("<H", image, boot + 19, PARTITION_SECTORS)
|
||||
image[boot + 21] = 0xF8
|
||||
struct.pack_into("<H", image, boot + 22, FAT_SECTORS)
|
||||
image[boot + 510 : boot + 512] = b"\x55\xaa"
|
||||
|
||||
fat = bytearray(FAT_SECTORS * SECTOR_BYTES)
|
||||
struct.pack_into("<HH", fat, 0, 0xFFF8, 0xFFFF)
|
||||
clusters = len(config_file) // SECTOR_BYTES
|
||||
for index in range(clusters):
|
||||
cluster = 2 + index
|
||||
struct.pack_into("<H", fat, cluster * 2, 0xFFFF if index == clusters - 1 else cluster + 1)
|
||||
first_fat = boot + RESERVED_SECTORS * SECTOR_BYTES
|
||||
image[first_fat : first_fat + len(fat)] = fat
|
||||
image[first_fat + len(fat) : first_fat + 2 * len(fat)] = fat
|
||||
|
||||
root = boot + (RESERVED_SECTORS + FAT_COUNT * FAT_SECTORS) * SECTOR_BYTES
|
||||
image[root : root + 11] = b"MSCCFG BIN"
|
||||
image[root + 11] = 0x20
|
||||
struct.pack_into("<H", image, root + 26, 2)
|
||||
struct.pack_into("<I", image, root + 28, len(config_file))
|
||||
data = boot + DATA_RELATIVE_SECTOR * SECTOR_BYTES
|
||||
image[data : data + len(config_file)] = config_file
|
||||
path.write_bytes(image)
|
||||
return path
|
||||
|
||||
|
||||
def first_fat_offset() -> int:
|
||||
return (PARTITION_LBA + RESERVED_SECTORS) * SECTOR_BYTES
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
SOURCE_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SOURCE_ROOT / "src"))
|
||||
|
||||
from matrix_image_editor.config import ( # noqa: E402
|
||||
HEADER_BYTES,
|
||||
SLOT_BYTES,
|
||||
ImageConfigError,
|
||||
create_config_file,
|
||||
read_config_file,
|
||||
update_config_file,
|
||||
validate_config,
|
||||
)
|
||||
|
||||
|
||||
def sample_config() -> dict:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"product": "matrix-screen-controller-walnutpi",
|
||||
"software_version": "1.0.3",
|
||||
"account": {"username": "matrix", "password": "password123"},
|
||||
"wifi": {"ssid": "测试 WiFi", "password": "wifi-pass-123"},
|
||||
"ipv4": {"mode": "dhcp", "address": "", "prefix": 0, "gateway": "", "dns": []},
|
||||
}
|
||||
|
||||
|
||||
class ConfigTests(unittest.TestCase):
|
||||
def test_round_trip_and_update_inactive_slot(self) -> None:
|
||||
original = create_config_file(sample_config())
|
||||
config, generation, slot = read_config_file(original)
|
||||
self.assertEqual((generation, slot), (1, 0))
|
||||
config["wifi"]["ssid"] = "新网络"
|
||||
updated = update_config_file(original, config)
|
||||
verified, generation, slot = read_config_file(updated)
|
||||
self.assertEqual((generation, slot, verified["wifi"]["ssid"]), (2, 1, "新网络"))
|
||||
self.assertEqual(updated[HEADER_BYTES : HEADER_BYTES + SLOT_BYTES], original[HEADER_BYTES : HEADER_BYTES + SLOT_BYTES])
|
||||
|
||||
def test_corrupt_active_slot_falls_back_and_both_corrupt_fail(self) -> None:
|
||||
original = create_config_file(sample_config())
|
||||
updated = bytearray(update_config_file(original, sample_config()))
|
||||
updated[HEADER_BYTES + SLOT_BYTES + 60] ^= 0x01
|
||||
_config, generation, slot = read_config_file(bytes(updated))
|
||||
self.assertEqual((generation, slot), (1, 0))
|
||||
updated[HEADER_BYTES + 60] ^= 0x01
|
||||
with self.assertRaises(ImageConfigError):
|
||||
read_config_file(bytes(updated))
|
||||
|
||||
def test_rejects_unknown_product_schema_and_fields(self) -> None:
|
||||
for field, value in (("product", "wrong"), ("schema_version", 2)):
|
||||
config = sample_config()
|
||||
config[field] = value
|
||||
with self.assertRaises(ImageConfigError):
|
||||
validate_config(config)
|
||||
config = sample_config()
|
||||
config["unexpected"] = True
|
||||
with self.assertRaises(ImageConfigError):
|
||||
validate_config(config)
|
||||
|
||||
def test_utf8_ssid_and_utf16_secret_lengths_match_contract(self) -> None:
|
||||
config = sample_config()
|
||||
config["wifi"]["ssid"] = "中" * 10
|
||||
validate_config(config)
|
||||
config["wifi"]["ssid"] = "中" * 11
|
||||
with self.assertRaises(ImageConfigError):
|
||||
validate_config(config)
|
||||
config = sample_config()
|
||||
config["account"]["password"] = "😀" * 4
|
||||
validate_config(config)
|
||||
|
||||
def test_static_ipv4_requires_same_subnet_and_valid_dns(self) -> None:
|
||||
config = sample_config()
|
||||
config["ipv4"] = {
|
||||
"mode": "static",
|
||||
"address": "192.168.10.20",
|
||||
"prefix": 24,
|
||||
"gateway": "192.168.10.1",
|
||||
"dns": ["1.1.1.1", "8.8.8.8"],
|
||||
}
|
||||
validate_config(config)
|
||||
config["ipv4"]["gateway"] = "192.168.11.1"
|
||||
with self.assertRaises(ImageConfigError):
|
||||
validate_config(config)
|
||||
|
||||
def test_project_codec_cross_compatibility_when_repository_is_present(self) -> None:
|
||||
project_codec = SOURCE_ROOT.parents[2] / "核桃派软件源代码" / "scripts" / "image_config.py"
|
||||
if not project_codec.is_file():
|
||||
self.skipTest("独立源码副本中没有主工程交叉校验器")
|
||||
spec = importlib.util.spec_from_file_location("project_image_config", project_codec)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
ours = create_config_file(copy.deepcopy(sample_config()))
|
||||
theirs = module.create_config_file(copy.deepcopy(sample_config()))
|
||||
self.assertEqual(module.read_config_file(ours)[0], read_config_file(theirs)[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
SOURCE_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SOURCE_ROOT / "src"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from PySide6.QtWidgets import QApplication, QLineEdit # noqa: E402
|
||||
from matrix_image_editor.gui import MainWindow # noqa: E402
|
||||
from test_config import sample_config # noqa: E402
|
||||
|
||||
|
||||
class GuiTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.window = MainWindow()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.window.close()
|
||||
|
||||
def test_passwords_are_hidden_and_dhcp_disables_static_fields(self) -> None:
|
||||
self.assertEqual(self.window.account_password.echoMode(), QLineEdit.EchoMode.Password)
|
||||
self.assertEqual(self.window.wifi_password.echoMode(), QLineEdit.EchoMode.Password)
|
||||
for widget in (self.window.address, self.window.prefix, self.window.gateway, self.window.dns):
|
||||
self.assertFalse(widget.isEnabled())
|
||||
self.window.mode.setCurrentIndex(1)
|
||||
for widget in (self.window.address, self.window.prefix, self.window.gateway, self.window.dns):
|
||||
self.assertTrue(widget.isEnabled())
|
||||
|
||||
def test_loaded_values_collect_without_changing_image_version(self) -> None:
|
||||
config = sample_config()
|
||||
self.window._apply_loaded(Path("sample.img"), config)
|
||||
self.window.username.setText("new_user")
|
||||
collected = self.window._collect()
|
||||
self.assertEqual(collected["software_version"], config["software_version"])
|
||||
self.assertEqual(collected["account"]["username"], "new_user")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SOURCE_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SOURCE_ROOT / "src"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from fixture_image import build_fixture, first_fat_offset # noqa: E402
|
||||
from test_config import sample_config # noqa: E402
|
||||
from matrix_image_editor.config import create_config_file # noqa: E402
|
||||
from matrix_image_editor.cli import run_command # noqa: E402
|
||||
from matrix_image_editor.fat16 import Fat16Disk, Fat16Error # noqa: E402
|
||||
from matrix_image_editor.operations import ( # noqa: E402
|
||||
ImageOperationError,
|
||||
copy_write_and_verify,
|
||||
read_config,
|
||||
write_and_verify,
|
||||
)
|
||||
from matrix_image_editor.paths import ImagePathError, cleanup_new_destination, validate_new_destination # noqa: E402
|
||||
|
||||
|
||||
class ImageOperationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="镜像编辑器-")
|
||||
self.root = Path(self.temporary.name)
|
||||
self.image = build_fixture(self.root / "原 镜像.img", create_config_file(sample_config()))
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary.cleanup()
|
||||
|
||||
def test_read_write_sidecar_and_reopen(self) -> None:
|
||||
config = read_config(self.image)
|
||||
config["wifi"]["ssid"] = "中文 新 WiFi"
|
||||
progress: list[int] = []
|
||||
verified = write_and_verify(self.image, config, progress.append)
|
||||
self.assertEqual(verified["wifi"]["ssid"], "中文 新 WiFi")
|
||||
sidecar = Path(str(self.image) + ".sha256")
|
||||
raw = sidecar.read_bytes()
|
||||
self.assertFalse(raw.startswith(b"\xef\xbb\xbf"))
|
||||
self.assertEqual(raw, f"{hashlib.sha256(self.image.read_bytes()).hexdigest()} {self.image.name}\n".encode("utf-8"))
|
||||
self.assertEqual(progress[-1], 100)
|
||||
|
||||
def test_rejects_bad_sidecar_forms_and_content(self) -> None:
|
||||
sidecar = Path(str(self.image) + ".sha256")
|
||||
digest = hashlib.sha256(self.image.read_bytes()).hexdigest()
|
||||
invalid = [
|
||||
b"\xef\xbb\xbf" + f"{digest} {self.image.name}\n".encode("utf-8"),
|
||||
f"{digest.upper()} {self.image.name}\n".encode("utf-8"),
|
||||
f"{digest} {self.image.name}\n".encode("utf-8"),
|
||||
f"{digest} wrong.img\n".encode("utf-8"),
|
||||
f"{'0' * 64} {self.image.name}\n".encode("utf-8"),
|
||||
f"{digest} {self.image.name}\r\n".encode("utf-8"),
|
||||
]
|
||||
for payload in invalid:
|
||||
with self.subTest(payload=payload[:12]):
|
||||
sidecar.write_bytes(payload)
|
||||
with self.assertRaises(ImageOperationError):
|
||||
read_config(self.image)
|
||||
|
||||
def test_copy_apply_preserves_source_and_cleans_failure(self) -> None:
|
||||
original = self.image.read_bytes()
|
||||
config = sample_config()
|
||||
config["account"]["username"] = "new_user"
|
||||
target, verified = copy_write_and_verify(self.image, self.root / "新 镜像.img", config)
|
||||
self.assertEqual(verified["account"]["username"], "new_user")
|
||||
self.assertEqual(self.image.read_bytes(), original)
|
||||
self.assertTrue(Path(str(target) + ".sha256").is_file())
|
||||
|
||||
failed = self.root / "失败.img"
|
||||
wrong_version = sample_config()
|
||||
wrong_version["software_version"] = "9.9.9"
|
||||
with self.assertRaises(ImageOperationError):
|
||||
copy_write_and_verify(self.image, failed, wrong_version)
|
||||
self.assertFalse(failed.exists())
|
||||
self.assertFalse(Path(str(failed) + ".sha256").exists())
|
||||
|
||||
def test_path_rejections(self) -> None:
|
||||
with self.assertRaises(ImagePathError):
|
||||
validate_new_destination(self.image, self.image)
|
||||
existing = self.root / "existing.img"
|
||||
existing.write_bytes(b"")
|
||||
with self.assertRaises(ImagePathError):
|
||||
validate_new_destination(self.image, existing)
|
||||
orphan = self.root / "orphan.img"
|
||||
Path(str(orphan) + ".sha256").write_text("orphan", encoding="utf-8")
|
||||
with self.assertRaises(ImagePathError):
|
||||
validate_new_destination(self.image, orphan)
|
||||
with self.assertRaises(ImagePathError):
|
||||
validate_new_destination(self.image, self.root / "wrong.txt")
|
||||
if os.name == "nt":
|
||||
with self.assertRaises(ImagePathError):
|
||||
validate_new_destination(self.image, self.root / "CON.img")
|
||||
|
||||
def test_fat_cycle_and_partition_bounds_are_rejected(self) -> None:
|
||||
cyclic = self.root / "cycle.img"
|
||||
cyclic.write_bytes(self.image.read_bytes())
|
||||
with cyclic.open("r+b") as stream:
|
||||
stream.seek(first_fat_offset() + 2 * 2)
|
||||
stream.write(struct.pack("<H", 2))
|
||||
with Fat16Disk(cyclic, writable=False) as disk:
|
||||
with self.assertRaises(Fat16Error):
|
||||
disk.read_file("MSCCFG.BIN")
|
||||
|
||||
truncated = self.root / "truncated.img"
|
||||
truncated.write_bytes(self.image.read_bytes()[:-512])
|
||||
with self.assertRaises(Fat16Error):
|
||||
Fat16Disk(truncated, writable=False)
|
||||
|
||||
def test_cli_contract_and_apply(self) -> None:
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
self.assertEqual(run_command(["--validate", str(self.image)]), 0)
|
||||
document = json.loads(output.getvalue())
|
||||
self.assertEqual(
|
||||
set(document),
|
||||
{"valid", "SoftwareVersion", "Username", "Ssid", "Mode", "Address"},
|
||||
)
|
||||
config_path = self.root / "config.json"
|
||||
config_path.write_text(json.dumps(sample_config(), ensure_ascii=False), encoding="utf-8-sig")
|
||||
target = self.root / "cli-new.img"
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(run_command(["--apply", str(self.image), str(config_path), str(target)]), 0)
|
||||
self.assertTrue(target.is_file())
|
||||
self.assertTrue(Path(str(target) + ".sha256").is_file())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,26 @@
|
||||
VSVersionInfo(
|
||||
ffi=FixedFileInfo(
|
||||
filevers=(2, 0, 0, 0),
|
||||
prodvers=(2, 0, 0, 0),
|
||||
mask=0x3f,
|
||||
flags=0x0,
|
||||
OS=0x40004,
|
||||
fileType=0x1,
|
||||
subtype=0x0,
|
||||
date=(0, 0)
|
||||
),
|
||||
kids=[
|
||||
StringFileInfo([
|
||||
StringTable('080404b0', [
|
||||
StringStruct('CompanyName', '奇妙小屏幕控制器'),
|
||||
StringStruct('FileDescription', '核桃派镜像配置编辑器'),
|
||||
StringStruct('FileVersion', '2.0.0.0'),
|
||||
StringStruct('InternalName', 'matrix_image_editor'),
|
||||
StringStruct('OriginalFilename', '核桃派镜像配置编辑器.exe'),
|
||||
StringStruct('ProductName', '核桃派镜像配置编辑器'),
|
||||
StringStruct('ProductVersion', '2.0.0')
|
||||
])
|
||||
]),
|
||||
VarFileInfo([VarStruct('Translation', [2052, 1200])])
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
# 镜像配置编辑器 2.0.0
|
||||
|
||||
双击 `核桃派镜像配置编辑器.exe` 即可运行,不需要安装 Python、.NET、Qt,也不需要复制任何 DLL。支持 Windows 10/11 x64,不写注册表或安装系统服务。单 EXE 首次启动会将内置运行库解压到系统临时目录,退出后自动清理。
|
||||
|
||||
打开 IMG 后可查看账户、Wi-Fi 和 IPv4 设置,密码默认隐藏。“修改原镜像”会先二次确认;“另存为”会保留原文件,并要求新的合法 `.img` 文件名。中文、空格和长路径受支持;保存后会生成无 BOM UTF-8 的 `.img.sha256` 并重新校验镜像。
|
||||
|
||||
本程序不负责写 TF 卡;完成配置后请使用 Rufus 写入 IMG。日常优先使用“另存为”,不要直接修改正式发布镜像。
|
||||
|
||||
命令行接口:
|
||||
|
||||
```text
|
||||
核桃派镜像配置编辑器.exe --validate <镜像.img>
|
||||
核桃派镜像配置编辑器.exe --apply <原镜像.img> <配置.json> <新镜像.img>
|
||||
```
|
||||
|
||||
源码、macOS 构建说明和第三方许可声明位于 `../编辑器源代码/`。
|
||||
@@ -0,0 +1 @@
|
||||
44d6662770af14284df9926b788c651bfd732571b3f8e7665a0112e1622731df 核桃派镜像配置编辑器.exe
|
||||
@@ -0,0 +1 @@
|
||||
2.0.0
|
||||
Binary file not shown.
Reference in New Issue
Block a user