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

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,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