同步移动端工程、设备控制改进与发布资料

This commit is contained in:
2026-09-26 19:21:12 +08:00
parent 912ba432cf
commit 98eadc5c8b
137 changed files with 9274 additions and 85 deletions
@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import ipaddress
import json
import os
import re
import subprocess
@@ -15,7 +16,14 @@ PRIVATE_CREDENTIAL = PurePosixPath("测试相关资料/核桃派的用户名和
EXAMPLE_CREDENTIAL = PurePosixPath(
"测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.example.txt"
)
PRIVATE_MOBILE_REGISTRATION = PurePosixPath(
"移动端相关内容/安卓app/如何安卓测试/测试设备登记/测试设备.local.json"
)
EXAMPLE_MOBILE_REGISTRATION = PRIVATE_MOBILE_REGISTRATION.with_name("测试设备.example.json")
BINARY_EXTENSIONS = {
".apk",
".aab",
".jar",
".deb",
".dll",
".docx",
@@ -120,7 +128,7 @@ def _host_text_patterns() -> list[re.Pattern[str]]:
def _private_value_markers() -> tuple[str, ...]:
path = WORKSPACE_ROOT.joinpath(*PRIVATE_CREDENTIAL.parts)
if not path.is_file():
return ()
return _mobile_value_markers()
fields: dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
@@ -132,13 +140,36 @@ def _private_value_markers() -> tuple[str, ...]:
key, value = (part.strip() for part in line.split(separator, 1))
fields[key] = value
public_values = {fields.get(key, "") for key in PUBLIC_IMAGE_KEYS}
return tuple(
return _mobile_value_markers() + tuple(
value
for key in CURRENT_DEVICE_KEYS
if len(value := fields.get(key, "")) >= 4 and value not in public_values
)
def _mobile_value_markers() -> tuple[str, ...]:
path = WORKSPACE_ROOT.joinpath(*PRIVATE_MOBILE_REGISTRATION.parts)
if not path.is_file():
return ()
try:
document = json.loads(path.read_text(encoding="utf-8"))
devices = document["devices"]
if not isinstance(devices, list):
raise ValueError
values = []
for device in devices:
if not isinstance(device, dict):
raise ValueError
serial = device.get("serial", "")
if not isinstance(serial, str):
raise ValueError
if serial and "<" not in serial and len(serial) >= 4:
values.append(serial)
return tuple(values)
except (OSError, UnicodeError, ValueError, KeyError, TypeError):
raise HygieneError("移动端真实登记损坏,无法安全提取私有标识;未输出内容。") from None
def _binary_markers(private_values: tuple[str, ...] = ()) -> list[bytes]:
markers: list[bytes] = []
host_values = {
@@ -178,7 +209,7 @@ def _text_issues(
if any(pattern.search(text) for pattern in _host_text_patterns()):
issues.append("包含开发电脑专属路径、用户名或主机名")
if any(value in text for value in private_values):
issues.append("包含当前设备私有凭据值")
issues.append("包含当前设备私有凭据或测试手机标识")
key_header = re.compile("-----BEGIN " + r"(?:OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----")
if key_header.search(text):
@@ -217,9 +248,12 @@ def audit_paths(paths: list[PurePosixPath], *, scan_binary: bool) -> list[tuple[
findings: list[tuple[str, str]] = []
private_values = _private_value_markers()
for relative in paths:
if relative == PRIVATE_CREDENTIAL:
if relative in (PRIVATE_CREDENTIAL, PRIVATE_MOBILE_REGISTRATION):
findings.append((relative.as_posix(), "真实凭据进入 Git 候选集合"))
continue
if relative.suffix.lower() in {".jks", ".keystore"}:
findings.append((relative.as_posix(), "签名密钥文件进入 Git 候选集合"))
continue
path = WORKSPACE_ROOT.joinpath(*relative.parts)
if not path.is_file():
continue
@@ -243,6 +277,15 @@ def _check_repository_contract(paths: list[PurePosixPath], staged: bool) -> list
ignored = _git("check-ignore", "--quiet", "--", PRIVATE_CREDENTIAL.as_posix(), check=False)
if ignored.returncode != 0:
findings.append((PRIVATE_CREDENTIAL.as_posix(), "真实凭据未被 .gitignore 精确排除"))
mobile_ignored = _git("check-ignore", "--quiet", "--", PRIVATE_MOBILE_REGISTRATION.as_posix(), check=False)
if mobile_ignored.returncode != 0:
findings.append((PRIVATE_MOBILE_REGISTRATION.as_posix(), "真实手机登记未被 .gitignore 排除"))
if staged:
mobile_example = _git("cat-file", "-e", f":{EXAMPLE_MOBILE_REGISTRATION.as_posix()}", check=False)
if mobile_example.returncode != 0:
findings.append((EXAMPLE_MOBILE_REGISTRATION.as_posix(), "手机空白登记示例不在 Git 索引"))
elif EXAMPLE_MOBILE_REGISTRATION not in paths:
findings.append((EXAMPLE_MOBILE_REGISTRATION.as_posix(), "手机空白登记示例不在 Git 候选集合"))
if staged:
example_in_index = _git("cat-file", "-e", f":{EXAMPLE_CREDENTIAL.as_posix()}", check=False)
@@ -13,8 +13,6 @@ from typing import Callable, Sequence
EXPECTED_PINCTRL = "allwinner,sun50i-h616-pinctrl"
DISABLED_SERVICES = (
"lightdm.service",
"bluetooth.service",
"aw859-bluetooth.service",
"gpioc-server.service",
"map_device.service",
)
@@ -96,10 +96,9 @@ make -C "$STAGING/app/display/native" clean all test
systemctl set-default multi-user.target
systemctl disable --now \
lightdm.service \
bluetooth.service \
aw859-bluetooth.service \
gpioc-server.service \
map_device.service
DEFER_SERVICE_START="$DEFER_SERVICE_START" /bin/sh "$STAGING/scripts/install_mobile_bluetooth.sh"
if [ "$DEFER_SERVICE_START" = "1" ]; then
"$STAGING/.venv/bin/python" "$STAGING/scripts/dedicated_host.py" check --service
else
@@ -0,0 +1,48 @@
#!/bin/sh
# Enable the existing WalnutPi vendor controller, without changing WiFi rfkill.
set -eu
test "$(id -u)" = 0 || { echo 'root is required' >&2; exit 1; }
test -x /usr/bin/hciattach
test -x /usr/sbin/rfkill
test -f /lib/systemd/system/aw859-bluetooth.service
install -d -m 0755 /etc/systemd/system/aw859-bluetooth.service.d
cat > /etc/systemd/system/aw859-bluetooth.service.d/50-matrix-mobile.conf <<'EOF'
[Service]
ExecStartPre=
ExecStartPre=/usr/sbin/modprobe hci_uart
ExecStartPre=/usr/sbin/rfkill unblock bluetooth
ExecStart=
ExecStart=/usr/bin/hciattach -n -s 1500000 /dev/ttyBT0 sprd
Restart=on-failure
RestartSec=5
EOF
install -d -m 0755 /etc/systemd/system/bluetooth.service.d
cat > /etc/systemd/system/bluetooth.service.d/50-matrix-mobile.conf <<'EOF'
[Unit]
After=aw859-bluetooth.service
Wants=aw859-bluetooth.service
EOF
python3 - <<'PY'
from pathlib import Path
import re
p = Path('/etc/bluetooth/main.conf')
text = p.read_text(encoding='utf-8')
backup = p.with_name('main.conf.matrix-mobile-original')
if not backup.exists():
with backup.open('x', encoding='utf-8') as stream:
stream.write(text)
for key, value in [('ControllerMode', 'le'), ('AlwaysPairable', 'false')]:
pattern = r'(?m)^\s*' + key + r'\s*=.*$'
if re.search(pattern, text):
text = re.sub(pattern, key + '=' + value, text)
else:
text = text.replace('[General]', '[General]\n' + key + '=' + value, 1)
p.write_text(text, encoding='utf-8')
PY
systemctl unmask bluetooth.service aw859-bluetooth.service
systemctl daemon-reload
systemctl enable bluetooth.service aw859-bluetooth.service
if [ "${DEFER_SERVICE_START:-0}" != 1 ]; then
# Bluetooth failure must not prevent deploying or running the display service.
systemctl start aw859-bluetooth.service bluetooth.service || echo 'Bluetooth unavailable; inspect mobile status' >&2
fi
@@ -32,6 +32,8 @@ RUNTIME_GUARD_BODY = b'# Managed by the OTA component transaction\n[Service]\nRu
SERVICE = 'matrix-screen-controller.service'
WORKER = 'matrix-screen-controller-ota.service'
FRP = 'matrix-screen-frpc.service'
MOBILE_SERVICES = ('aw859-bluetooth.service', 'bluetooth.service')
MOBILE_FILES = ('bluetooth-conf', 'bluetooth-original', 'bluetooth-dropin', 'aw859-dropin')
JOURNAL = Path('ota/component-transaction')
FILES = {
'frpc': Path('/usr/local/bin/frpc'),
@@ -39,6 +41,10 @@ FILES = {
'frpc-dropin': Path('/etc/systemd/system/matrix-screen-frpc.service.d/user.conf'),
'app-unit': Path('/etc/systemd/system/matrix-screen-controller.service'),
'ota-unit': Path('/etc/systemd/system/matrix-screen-controller-ota.service'),
'bluetooth-conf': Path('/etc/bluetooth/main.conf'),
'bluetooth-original': Path('/etc/bluetooth/main.conf.matrix-mobile-original'),
'bluetooth-dropin': Path('/etc/systemd/system/bluetooth.service.d/50-matrix-mobile.conf'),
'aw859-dropin': Path('/etc/systemd/system/aw859-bluetooth.service.d/50-matrix-mobile.conf'),
}
@@ -229,15 +235,18 @@ def begin(source: Path, candidate: Path):
if RUNTIME_GUARD.exists():
raise RuntimeError('存在未清理的 OTA 运行目录保护,请先恢复')
bundle = source / 'system-dependencies/frpc'
mobile_installer = source / 'scripts/install_mobile_bluetooth.sh'
if not bundle.is_dir():
check_installed(source, version)
return
if not mobile_installer.is_file():
return
# Verify the pinned binary, not just a self-reported checksum file.
from app.ota.policy import required_components
expected = required_components(source, SoftwareVersion.parse(version))['frpc']
if hashlib.sha256((bundle / 'frpc').read_bytes()).hexdigest() != expected['sha256']:
raise RuntimeError('frpc payload differs from the registered dependency')
user = account()
if bundle.is_dir():
expected = required_components(source, SoftwareVersion.parse(version))['frpc']
if hashlib.sha256((bundle / 'frpc').read_bytes()).hexdigest() != expected['sha256']:
raise RuntimeError('frpc payload differs from the registered dependency')
user = account() if bundle.is_dir() else None
for root in (DATA, candidate):
if (root / JOURNAL).exists():
raise RuntimeError('存在未完成组件事务,请先恢复')
@@ -249,6 +258,13 @@ def begin(source: Path, candidate: Path):
'permissions': permission_snapshot(DATA), 'files': {},
'active': run(['systemctl', 'is-active', '--quiet', FRP], check=False).returncode == 0,
'enabled': run(['systemctl', 'is-enabled', '--quiet', FRP], check=False).returncode == 0}
if mobile_installer.is_file():
record['mobile_services'] = {
unit: {
'enabled_state': run(['systemctl', 'is-enabled', unit], check=False).stdout.decode().strip(),
'active': run(['systemctl', 'is-active', '--quiet', unit], check=False).returncode == 0,
} for unit in MOBILE_SERVICES
}
try:
for name, path in FILES.items():
record['files'][name] = metadata(path) if path.exists() else None
@@ -273,11 +289,14 @@ def begin(source: Path, candidate: Path):
'/usr/bin/python3', str(HELPER), 'watch'])
protect_runtime()
env = os.environ.copy()
env.update(FRPC_BUNDLE=str(bundle), FRPC_RUN_USER=user)
run(['/bin/sh', str(source / 'scripts/install_frpc_system.sh')], env=env)
if bundle.is_dir():
env.update(FRPC_BUNDLE=str(bundle), FRPC_RUN_USER=user)
run(['/bin/sh', str(source / 'scripts/install_frpc_system.sh')], env=env)
if mobile_installer.is_file():
run(['/bin/sh', str(mobile_installer)], env=env)
mirror_permissions(candidate)
check_installed(source, version)
print('FRP component transaction prepared; waiting for OTA health result')
print('System component transaction prepared; waiting for OTA health result')
except BaseException:
# The watcher handles subsequent worker failure. Restore immediately too,
# so a failed migration never leaves new system files while rolling back.
@@ -289,6 +308,10 @@ def begin(source: Path, candidate: Path):
def restore_components(journal: Path, record: dict):
mobile_services = record.get('mobile_services', {})
for unit in reversed(MOBILE_SERVICES):
if unit in mobile_services:
run(['systemctl', 'stop', unit], check=False)
run(['systemctl', 'stop', FRP], check=False)
for name in ('frpc', 'frpc-unit', 'frpc-dropin'):
path = FILES[name]
@@ -299,6 +322,15 @@ def restore_components(journal: Path, record: dict):
atomic(path, (journal / name).read_bytes(), item['mode'])
apply_metadata(path, item)
restore_permissions(DATA, record['permissions'])
if mobile_services:
for name in MOBILE_FILES:
path = FILES[name]
item = record['files'][name]
if item is None:
path.unlink(missing_ok=True)
else:
atomic(path, (journal / name).read_bytes(), item['mode'])
apply_metadata(path, item)
run(['systemctl', 'daemon-reload'])
# A previously absent unit cannot be disabled; avoid treating absence as error.
if record['files']['frpc-unit'] is not None:
@@ -309,6 +341,24 @@ def restore_components(journal: Path, record: dict):
link.unlink(missing_ok=True)
if record['active']:
run(['systemctl', 'start', FRP])
for unit in MOBILE_SERVICES:
previous = mobile_services.get(unit)
if previous is None:
continue
run(['systemctl', 'unmask', unit])
state = previous['enabled_state']
run(['systemctl', 'disable', unit], check=False)
if state == 'enabled':
run(['systemctl', 'enable', unit])
elif state == 'enabled-runtime':
run(['systemctl', 'enable', '--runtime', unit])
elif state in ('masked', 'masked-runtime'):
args = ['systemctl', 'mask']
if state == 'masked-runtime':
args.append('--runtime')
run(args + [unit])
if previous['active']:
run(['systemctl', 'start', unit])
def locate_journal():
@@ -14,11 +14,13 @@ import sys
import time
import traceback
from typing import Any
import urllib.request
from app.ota.diagnostics import DiagnosticLog, clear_failure_log, persist_failure_log, sha256_file
from app.ota.package import extract_payload, inspect_package
from app.ota.state import read_json, utc_now, write_json, write_last_result
from app.ota.versioning import SoftwareVersion
from app.system.kernel_recovery import CANDIDATE_RELEASE, KernelRecovery
TARGET = Path("/opt/matrix-screen-controller")
RELEASES = Path("/opt/matrix-screen-controller.releases")
@@ -34,6 +36,32 @@ class UpdateFailed(RuntimeError):
pass
def verify_kernel_capability_retained(
before: dict[str, Any],
after: dict[str, Any],
performance_before: dict[str, Any],
performance_after: dict[str, Any],
) -> None:
previously_healthy = (
before.get("release") == CANDIDATE_RELEASE
and before.get("available") is True
and before.get("marker") is True
)
if not previously_healthy:
return
if (after["release"] != CANDIDATE_RELEASE or not after["available"] or not after["marker"]
or set(after["policies"]) != set(before["policies"])):
raise UpdateFailed("OTA lost the validated candidate kernel or cpufreq policy")
requested = performance_before.get("requested")
if (performance_after.get("available") is not True or performance_after.get("requested") is not requested
or (requested is True and performance_after.get("effective") is not True)):
raise UpdateFailed("OTA changed performance mode availability or effective state")
if requested is False and performance_after.get("current_governors") != {
name: value["current"] for name, value in before["policies"].items()
}:
raise UpdateFailed("OTA did not restore the original CPU governors")
def snapshot(root: Path, *, ignored: set[str] | None = None) -> dict[str, tuple[int, str]]:
records: dict[str, tuple[int, str]] = {}
ignored = ignored or set()
@@ -81,6 +109,9 @@ class Transaction:
f"job_id={self.job_id} current_version={self.current_version} "
f"target_version={self.target_version} packaged_at={request.get('packaged_at') or ''}"
)
self.kernel_recovery = KernelRecovery(DATA_ROOT)
self.kernel_before: dict[str, Any] | None = None
self.performance_before: dict[str, Any] | None = None
self.job = {
"id": self.job_id,
"target_version": str(self.target_version),
@@ -118,6 +149,13 @@ class Transaction:
)
def prepare(self) -> None:
self.kernel_before = self.kernel_recovery.capability()
with urllib.request.urlopen("http://127.0.0.1:8080/api/status", timeout=8) as response:
self.performance_before = json.loads(response.read().decode("utf-8"))["system"]["performance_mode"]
self.diagnostics.write(
f"kernel_before={self.kernel_before['release']} "
f"cpufreq_available={self.kernel_before['available']} marker={self.kernel_before['marker']}"
)
if any(path.exists() for path in (self.work_root, self.release, self.data_candidate, self.data_backup)):
raise UpdateFailed("OTA transaction paths already exist")
self.work_root.mkdir(parents=True, mode=0o700)
@@ -273,6 +311,15 @@ class Transaction:
raise UpdateFailed("new service reports the wrong hardware mapping")
if driver.get("oe_timing_backend") != "h618-pwm4" or driver.get("oe_pulse_faults") != 0:
raise UpdateFailed("new service did not pass the PWM4 OE health check")
before = self.kernel_before or {}
if (before.get("available") and before.get("marker")
and before.get("release") == CANDIDATE_RELEASE):
verify_kernel_capability_retained(
before, self.kernel_recovery.capability(), self.performance_before or {},
(status.get("system") or {}).get("performance_mode") or {},
)
else:
self.diagnostics.write("kernel_preexisting_degraded=true; application update may proceed")
self.diagnostics.write(
"health_summary="
f"version:{status.get('service', {}).get('software_version')} "
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Exercise the independent recovery task after a real systemd service stop."""
from __future__ import annotations
import os
from pathlib import Path
import shutil
import subprocess
import time
from uuid import uuid4
def run(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(args, check=True, capture_output=True, text=True, timeout=30)
def main() -> int:
if os.geteuid() != 0 or not Path("/run/systemd/system").is_dir():
raise RuntimeError("real systemd lifecycle test requires root on a systemd host")
token = uuid4().hex[:12]
directory_name = f"matrix-kernel-recovery-test-{token}"
main_unit = f"{directory_name}-main"
task_unit = f"{directory_name}-task"
runtime = Path("/run") / directory_name
if runtime.exists():
raise RuntimeError("isolated runtime directory already exists")
try:
run("systemd-run", "--quiet", f"--unit={main_unit}",
f"--property=RuntimeDirectory={directory_name}",
"--property=RuntimeDirectoryPreserve=yes", "/bin/sleep", "120")
for _ in range(30):
if runtime.is_dir():
break
time.sleep(0.1)
else:
raise RuntimeError("temporary service did not create its runtime directory")
if run("systemctl", "show", main_unit, "--property=RuntimeDirectoryPreserve", "--value").stdout.strip() != "yes":
raise RuntimeError("temporary service did not enable runtime preservation")
run("systemctl", "stop", main_unit)
if not runtime.is_dir():
raise RuntimeError("systemd removed the runtime directory after service stop")
proof = runtime / "independent-task.txt"
run("systemd-run", "--quiet", "--wait", "--collect", f"--unit={task_unit}",
"/usr/bin/python3", "-c",
"from pathlib import Path; import sys; Path(sys.argv[1]).write_text('ok', encoding='ascii')",
str(proof))
if proof.read_text(encoding="ascii") != "ok":
raise RuntimeError("independent task did not survive the main service stop")
print("real systemd recovery lifecycle passed")
return 0
finally:
subprocess.run(["systemctl", "stop", main_unit], check=False, capture_output=True, timeout=15)
if runtime.is_dir() and runtime.parent == Path("/run") and runtime.name == directory_name:
shutil.rmtree(runtime)
if __name__ == "__main__":
raise SystemExit(main())