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

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,468 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import uuid
SOURCE_ROOT = Path(__file__).resolve().parents[1]
if str(SOURCE_ROOT) not in sys.path:
sys.path.insert(0, str(SOURCE_ROOT))
from app.ota.package import build_package, extract_payload
from app.ota.versioning import SoftwareVersion, read_software_version
from app.ota.policy import export_bundle, package_format, release_metadata, read_policy
from scripts.build_sd_image import build_image, sha256_file
from scripts.fat16_image import Fat16Image
from scripts.image_config import create_config_file, read_config_file
def next_patch(version: SoftwareVersion) -> SoftwareVersion:
return SoftwareVersion(version.major, version.minor, version.patch + 1)
def _image_readme(version: SoftwareVersion, manifest: dict, notes: str, config: dict) -> str:
return (
f"# 奇妙小屏幕控制器 IMAGE {version}\n\n"
f"- 软件版本:`{version}`\n"
f"- 导出时间:`{manifest['created_at']}`\n"
f"- 说明:{notes.strip()}\n"
f"- 产物:`{manifest['artifact']}`\n"
f"- SHA-256:`{manifest['image_sha256']}`\n\n"
"## 未修改镜像的默认设置\n\n"
f"- Linux 用户名:`{config['account']['username']}`\n"
f"- Linux 密码:`{config['account']['password']}`\n"
f"- Wi-Fi SSID:`{config['wifi']['ssid']}`\n"
f"- Wi-Fi 密码:`{config['wifi']['password']}`\n"
"- IPv4:`DHCP`\n\n"
"> 这些是公开的默认凭据。建议刷写前使用镜像编辑器修改;直接使用未修改镜像时,"
"请按上述 SSID 和密码开启热点,并在首次登录后立即更换账户及 Wi-Fi 设置。\n\n"
"首次启动会离线安装全部软件并自动重启一次。请等待设备重新连接热点后再通过 SSH 或网页访问。\n\n"
"本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。\n"
)
def _ota_readme(version: SoftwareVersion, manifest: dict, notes: str) -> str:
metadata = release_metadata(version)
return (
f"# 奇妙小屏幕控制器 OTA {version}\n\n"
f"- 软件版本:`{version}`\n"
f"- 导出时间:`{manifest['created_at']}`\n"
f"- 说明:{notes.strip()}\n"
f"- 产物:`{manifest['artifact']}`\n\n"
f"- 包类型:{'软件安装包(必经升级版本)' if metadata['is_checkpoint'] else '应用更新包'}\n"
f"- 前置系列基线:`{metadata['required_checkpoint']}`\n\n"
"在系统设置上传 OTA;每个 minor 的 .0 必须按顺序安装,同系列补丁允许跳过。"
"1.1.0 含 frpc 0.71.0 离线组件;已有完整组件会跳过,否则修复并保留配置和启停状态。"
"初次安装默认关闭。禁止跳过失败测试;失败自动恢复。\n\n"
"本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。\n"
)
def _copy_source(source: Path, destination: Path, version: SoftwareVersion) -> None:
ignored = shutil.ignore_patterns(".venv", "__pycache__", ".pytest_cache", "node_modules", "data", "output", ".playwright-cli", "system-dependencies")
shutil.copytree(source, destination, ignore=ignored)
(destination / "VERSION").write_text(f"{version}\n", encoding="utf-8", newline="\n")
def _history(path: Path) -> dict:
if path.is_file():
document = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(document, dict) or document.get("schema_version") != 1 or not isinstance(document.get("releases"), list):
raise ValueError("发布记录.json is invalid")
return document
return {
"schema_version": 1,
"releases": [
{
"version": "1.0.1",
"artifact_type": "ota",
"created_at": "2026-08-13T15:31:23+08:00",
"notes": "新增浏览器全量 OTA。",
"artifact_path": "发布更新相关/OTA数据包/1.0.1/matrix-screen-controller-1.0.1.ota",
"artifact_sha256": sha256_file(path.parent / "发布更新相关" / "OTA数据包" / "1.0.1" / "matrix-screen-controller-1.0.1.ota"),
}
],
}
def _atomic_json(path: Path, document: dict) -> None:
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
temporary.write_text(json.dumps(document, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
os.replace(temporary, path)
def _atomic_bytes(path: Path, body: bytes) -> None:
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
temporary.write_bytes(body)
os.replace(temporary, path)
def _require_image_host() -> None:
if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0:
raise ValueError("image export must run as root on Linux")
missing = [name for name in ("bash", "losetup", "mount", "umount", "systemctl", "sha256sum") if shutil.which(name) is None]
if missing:
raise ValueError(f"image export host is missing required commands: {', '.join(missing)}")
def _validated_repair_input(
final_directory: Path,
history: dict,
version: SoftwareVersion,
) -> tuple[bytes, int]:
manifest_path = final_directory / "manifest.json"
if not manifest_path.is_file():
raise ValueError("current image release manifest is missing")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
artifact_name = f"matrix-screen-controller-{version}.img"
artifact = final_directory / artifact_name
sidecar = final_directory / f"{artifact_name}.sha256"
if (
manifest.get("artifact_type") != "image"
or manifest.get("software_version") != str(version)
or manifest.get("artifact") != artifact_name
or not artifact.is_file()
or not sidecar.is_file()
):
raise ValueError("current image release files do not match the repair target")
digest = sha256_file(artifact)
expected_sidecar = f"{digest} {artifact_name}\n".encode("ascii")
if sidecar.read_bytes() != expected_sidecar or manifest.get("image_sha256") != digest:
raise ValueError("current image release digest is inconsistent")
matches = [
(index, item)
for index, item in enumerate(history["releases"])
if item.get("version") == str(version)
]
if len(matches) != 1:
raise ValueError("current version must have exactly one release record before repair")
index, record = matches[0]
expected_path = f"发布更新相关/导出包/{version}/{artifact_name}"
if (
record.get("artifact_type") != "image"
or record.get("artifact_path") != expected_path
or record.get("artifact_sha256") != digest
):
raise ValueError("current image release record is inconsistent")
with Fat16Image(artifact) as image:
config_bytes = image.read_file("MSCCFG.BIN")
config, _, _ = read_config_file(config_bytes)
if config["software_version"] != str(version):
raise ValueError("current image configuration version does not match VERSION")
return config_bytes, index
def _commit_repair(
final_directory: Path,
temporary_directory: Path,
history_path: Path,
history: dict,
) -> Path | None:
backup = final_directory.parent / f".{final_directory.name}.{uuid.uuid4().hex}.invalid-backup"
os.replace(final_directory, backup)
try:
os.replace(temporary_directory, final_directory)
_atomic_json(history_path, history)
except BaseException:
if final_directory.exists():
shutil.rmtree(final_directory, ignore_errors=True)
os.replace(backup, final_directory)
raise
shutil.rmtree(backup, ignore_errors=True)
return backup if backup.exists() else None
def main() -> int:
parser = argparse.ArgumentParser(description="Export one versioned OTA or SD image release")
parser.add_argument("kind", choices=("ota", "image"))
parser.add_argument("--notes", required=True)
parser.add_argument("--version", type=SoftwareVersion.parse, help="explicit release version, e.g. 1.1.0")
parser.add_argument("--config", type=Path, help="required JSON provisioning config for image export")
parser.add_argument("--candidate-output", type=Path, help="build a verified image candidate directory without publishing")
parser.add_argument("--validated-candidate", type=Path, help="exact real-device validated OTA file or image candidate directory")
parser.add_argument("--validation-report", type=Path, help="matching real-device validation JSON")
parser.add_argument(
"--repair-current",
action="store_true",
help="rebuild and atomically replace the registered current-version image",
)
args = parser.parse_args()
if args.repair_current and args.version is not None:
parser.error("--repair-current cannot be combined with --version")
if args.kind == "ota" and args.repair_current:
if args.validated_candidate is None or args.validation_report is None:
parser.error("OTA repair requires --validated-candidate and --validation-report")
from scripts.repair_ota_release import promote
print(promote(SOURCE_ROOT, args.validated_candidate, args.validation_report, args.notes))
return 0
image_promotion = args.kind == "image" and args.validated_candidate is not None
if image_promotion:
if args.validation_report is None:
parser.error("image candidate promotion requires --validation-report")
if args.config is not None or args.version is not None or args.candidate_output is not None or args.repair_current:
parser.error("image candidate promotion cannot use --config, --version, --candidate-output, or --repair-current")
try:
_require_image_host()
except ValueError as exc:
parser.error(str(exc))
from scripts.promote_image_release import promote
print(promote(SOURCE_ROOT, args.validated_candidate, args.validation_report, args.notes))
return 0
if args.validated_candidate is not None or args.validation_report is not None:
parser.error("candidate validation options require OTA --repair-current or image promotion")
if args.kind == "image" and args.config is None and not args.repair_current:
parser.error("--config is required for image candidate export")
if args.kind == "image" and not args.repair_current and args.candidate_output is None:
parser.error("new image releases must use --candidate-output and pass real-card validation before publishing")
if args.kind == "ota" and args.candidate_output is not None:
parser.error("--candidate-output is only supported for image export")
if args.repair_current and args.config is not None:
parser.error("--repair-current reuses the registered image configuration; do not pass --config")
if args.kind == "image":
try:
_require_image_host()
except ValueError as exc:
parser.error(str(exc))
source = SOURCE_ROOT
project = source.parent
lock = project / ".release-export.lock"
try:
descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
parser.error("another release export is already active")
os.close(descriptor)
try:
current = read_software_version(source)
target = current if args.repair_current else (args.version or next_patch(current))
dependency_bundle = None
if not args.repair_current and args.kind == "ota":
dependency_bundle = export_bundle(source, project / "发布更新相关" / "其他依赖", current, target)
if args.kind == "image" and not args.repair_current and target <= current:
parser.error("image candidate version must be newer than the current version")
output_root = project / "发布更新相关" / "OTA数据包" if args.kind == "ota" else project / "发布更新相关" / "导出包"
official_directory = output_root / str(target)
final_directory = args.candidate_output.resolve() if args.candidate_output is not None else official_directory
if args.candidate_output is not None:
try:
final_directory.relative_to(output_root.resolve())
except ValueError:
pass
else:
parser.error("candidate output must be outside the formal image release directory")
if final_directory.exists() and not args.repair_current:
parser.error(f"release directory already exists: {final_directory}")
if official_directory.exists() and args.candidate_output is not None:
parser.error(f"formal image release directory already exists: {official_directory}")
if args.repair_current and not final_directory.is_dir():
parser.error(f"current image release directory is missing: {final_directory}")
output_root.mkdir(parents=True, exist_ok=True)
history_path = project / "发布记录.json"
history = _history(history_path)
policy = read_policy(source)
for previous in history["releases"]:
recorded = previous.get("component_checkpoints", [])
if any(entry not in policy["checkpoints"] for entry in recorded):
parser.error("已发布的依赖节点不可改写;请追加新的 minor .0 节点")
history_original = history_path.read_bytes() if history_path.is_file() else None
version_original = (source / "VERSION").read_bytes()
repair_record_index: int | None = None
repair_config: bytes | None = None
if args.repair_current:
try:
repair_config, repair_record_index = _validated_repair_input(
final_directory, history, target
)
except (OSError, ValueError, json.JSONDecodeError) as exc:
parser.error(str(exc))
build_parent = final_directory.parent if args.candidate_output is not None else output_root
build_parent.mkdir(parents=True, exist_ok=True)
temporary_directory = build_parent / f".{final_directory.name}.{uuid.uuid4().hex}.building"
temporary_directory.mkdir()
published = False
replaced_directory: Path | None = None
try:
with tempfile.TemporaryDirectory(prefix="matrix-release-source-") as staging_text:
staged_source = Path(staging_text) / "核桃派软件源代码"
_copy_source(source, staged_source, target)
created = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
if args.kind == "ota":
artifact = temporary_directory / f"matrix-screen-controller-{target}.ota"
info = build_package(
staged_source,
project / "发布更新相关" / "其他依赖" / "aarch64-py311",
artifact,
version=target,
release_notes=args.notes,
system_dependencies=dependency_bundle,
)
verified = Path(staging_text) / "verified-ota"
extract_payload(info, verified)
if (verified / "software/VERSION").read_text(encoding="utf-8").strip() != str(target):
raise ValueError("exported payload version mismatch")
if (verified / "software/FEATURE_UPDATED_AT").read_bytes() != (source / "FEATURE_UPDATED_AT").read_bytes():
raise ValueError("exported feature timestamp mismatch")
if read_policy(verified / "software") != policy:
raise ValueError("exported dependency policy mismatch")
manifest = {
"format_version": package_format(target),
"artifact_type": "ota",
"software_version": str(target),
"created_at": info.created_at,
"notes": args.notes.strip(),
"artifact": artifact.name,
"artifact_bytes": artifact.stat().st_size,
"artifact_sha256": sha256_file(artifact),
**release_metadata(target),
}
else:
if repair_config is not None:
config: dict | bytes = repair_config
else:
config = json.loads(args.config.read_text(encoding="utf-8"))
config["software_version"] = str(target)
artifact = temporary_directory / f"matrix-screen-controller-{target}.img"
bootstrap_bundle = Path(staging_text) / "MSCBOOT.TGZ"
kernel_dependency = (
project
/ "发布更新相关"
/ "其他依赖"
/ "aarch64-kernel"
/ "6.1.31-matrix-axp313a1"
)
kernel_source = (
project
/ "发布更新相关"
/ "其他依赖"
/ "kernel-source"
/ "walnutpi-linux-6.1.31-30ff3fd5"
)
manifest = build_image(
project / "发布更新相关" / "核桃派镜像" / "2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.img",
staged_source,
project / "发布更新相关" / "其他依赖" / "aarch64-py311",
project / "发布更新相关" / "其他依赖" / "debian12-aarch64",
kernel_dependency,
kernel_source,
config,
artifact,
bootstrap_bundle,
project / "发布更新相关" / "其他依赖" / "frp" / "0.71.0" / "linux-arm64",
)
subprocess.run(
[
"bash",
str(staged_source / "scripts" / "install_image_bootstrap.sh"),
str(artifact),
str(staged_source / "systemd" / "matrix-image-firstboot.service"),
str(staged_source / "systemd" / "10-walnutpi-screen-hardening.conf"),
str(kernel_dependency),
str(bootstrap_bundle),
],
check=True,
)
subprocess.run(
[
"bash",
str(staged_source / "scripts" / "verify_image_bootstrap.sh"),
str(artifact),
str(staged_source / "systemd" / "matrix-image-firstboot.service"),
str(staged_source / "systemd" / "10-walnutpi-screen-hardening.conf"),
str(kernel_dependency),
str(bootstrap_bundle),
],
check=True,
)
manifest["created_at"] = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
manifest["image_bytes"] = artifact.stat().st_size
manifest["image_sha256"] = sha256_file(artifact)
manifest.update({"artifact_type": "image", "notes": args.notes.strip(), "artifact": artifact.name})
config_document, _generation, _slot = read_config_file(
repair_config if repair_config is not None else create_config_file(config)
)
manifest["config_sha256"] = hashlib.sha256(
repair_config if repair_config is not None else create_config_file(config_document)
).hexdigest()
(temporary_directory / f"{artifact.name}.sha256").write_text(
f"{manifest['image_sha256'] if args.kind == 'image' else manifest['artifact_sha256']} {artifact.name}\n",
encoding="ascii",
newline="\n",
)
(temporary_directory / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
newline="\n",
)
(temporary_directory / "README.md").write_text(
_image_readme(target, manifest, args.notes, config_document)
if args.kind == "image"
else _ota_readme(target, manifest, args.notes),
encoding="utf-8",
newline="\n",
)
if args.candidate_output is not None:
os.replace(temporary_directory, final_directory)
published = True
print(final_directory)
return 0
release_record = {
"version": str(target),
"artifact_type": args.kind,
"created_at": manifest["created_at"],
"notes": args.notes.strip(),
"artifact_path": f"{'发布更新相关/OTA数据包' if args.kind == 'ota' else '发布更新相关/导出包'}/{target}/{artifact.name}",
"artifact_sha256": manifest["image_sha256"] if args.kind == "image" else manifest["artifact_sha256"],
**(release_metadata(target) if args.kind == "ota" else {}),
**({"component_checkpoints": policy["checkpoints"]} if args.kind == "ota" else {}),
}
if args.repair_current:
assert repair_record_index is not None
history["releases"][repair_record_index] = release_record
replaced_directory = _commit_repair(
final_directory,
temporary_directory,
history_path,
history,
)
published = True
if replaced_directory is not None and replaced_directory.exists():
print(f"warning: obsolete image backup still requires cleanup: {replaced_directory}", file=sys.stderr)
replaced_directory = None
else:
os.replace(temporary_directory, final_directory)
published = True
history["releases"].append(release_record)
_atomic_json(history_path, history)
version_temp = source / ".VERSION.release.tmp"
version_temp.write_text(f"{target}\n", encoding="utf-8", newline="\n")
os.replace(version_temp, source / "VERSION")
print(final_directory)
except BaseException:
shutil.rmtree(temporary_directory, ignore_errors=True)
if published and not args.repair_current and args.candidate_output is None:
shutil.rmtree(final_directory, ignore_errors=True)
if history_original is None:
history_path.unlink(missing_ok=True)
else:
_atomic_bytes(history_path, history_original)
_atomic_bytes(source / "VERSION", version_original)
(source / ".VERSION.release.tmp").unlink(missing_ok=True)
if replaced_directory is not None and replaced_directory.exists() and not final_directory.exists():
os.replace(replaced_directory, final_directory)
raise
finally:
lock.unlink(missing_ok=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())