Add: windows mvp - transparent bugs not fixed

This commit is contained in:
DaZuo0122
2026-02-12 22:58:33 +08:00
commit 61825f647d
147 changed files with 28498 additions and 0 deletions

197
scripts/package_windows.py Normal file
View File

@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""Build and package a portable Windows ZIP for sprimo-app."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist"
BIN_REL = ROOT / "target" / "release" / "sprimo-app.exe"
ASSETS_REL = ROOT / "assets"
class PackagingError(RuntimeError):
"""Raised when packaging preconditions are not met."""
@dataclass(frozen=True)
class PackageLayout:
version: str
zip_path: Path
checksum_path: Path
def run(cmd: list[str], cwd: Path = ROOT) -> None:
result = subprocess.run(cmd, cwd=cwd, check=False)
if result.returncode != 0:
raise PackagingError(f"command failed ({result.returncode}): {' '.join(cmd)}")
def read_version() -> str:
cmd = ["cargo", "metadata", "--format-version", "1", "--no-deps"]
proc = subprocess.run(
cmd,
cwd=ROOT,
check=False,
capture_output=True,
text=True,
)
if proc.returncode != 0:
raise PackagingError(f"failed to read cargo metadata: {proc.stderr.strip()}")
data = json.loads(proc.stdout)
root_pkg = data.get("workspace_root")
if not root_pkg:
raise PackagingError("workspace metadata missing workspace_root")
# Prefer workspace package version.
manifest_path = ROOT / "Cargo.toml"
manifest = manifest_path.read_text(encoding="utf-8")
marker = "version = "
for line in manifest.splitlines():
stripped = line.strip()
if stripped.startswith(marker):
return stripped.split("=", 1)[1].strip().strip('"')
# Fallback to first package version from metadata.
packages = data.get("packages", [])
if packages:
return packages[0]["version"]
raise PackagingError("could not determine version")
def ensure_release_binary() -> Path:
if not BIN_REL.exists():
run(["cargo", "build", "--release", "-p", "sprimo-app"])
if not BIN_REL.exists():
raise PackagingError(f"release binary missing: {BIN_REL}")
return BIN_REL
def ensure_assets() -> None:
required = [
ASSETS_REL / "sprite-packs" / "default" / "manifest.json",
ASSETS_REL / "sprite-packs" / "default" / "sprite.png",
]
missing = [path for path in required if not path.exists()]
if missing:
joined = ", ".join(str(path) for path in missing)
raise PackagingError(f"required assets missing: {joined}")
def iter_stage_files(stage_root: Path) -> Iterable[Path]:
for path in stage_root.rglob("*"):
if path.is_file():
yield path
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
while True:
chunk = handle.read(1024 * 1024)
if not chunk:
break
digest.update(chunk)
return digest.hexdigest()
def package() -> PackageLayout:
version = read_version()
ensure_assets()
binary = ensure_release_binary()
DIST.mkdir(parents=True, exist_ok=True)
artifact_name = f"sprimo-windows-x64-v{version}"
zip_path = DIST / f"{artifact_name}.zip"
checksum_path = DIST / f"{artifact_name}.zip.sha256"
with tempfile.TemporaryDirectory(prefix="sprimo-package-") as temp_dir:
stage = Path(temp_dir) / artifact_name
stage.mkdir(parents=True, exist_ok=True)
shutil.copy2(binary, stage / "sprimo-app.exe")
shutil.copytree(ASSETS_REL, stage / "assets", dirs_exist_ok=True)
readme = stage / "README.txt"
readme.write_text(
"Sprimo portable package\n"
"Run: sprimo-app.exe\n"
"Assets are expected at ./assets relative to the executable.\n",
encoding="utf-8",
)
archive_base = DIST / artifact_name
if zip_path.exists():
zip_path.unlink()
if checksum_path.exists():
checksum_path.unlink()
shutil.make_archive(str(archive_base), "zip", root_dir=stage.parent, base_dir=stage.name)
checksum = sha256_file(zip_path)
checksum_path.write_text(f"{checksum} {zip_path.name}\n", encoding="utf-8")
return PackageLayout(version=version, zip_path=zip_path, checksum_path=checksum_path)
def smoke() -> None:
layout = package()
print(f"package created: {layout.zip_path}")
print(f"checksum file: {layout.checksum_path}")
with tempfile.TemporaryDirectory(prefix="sprimo-smoke-") as temp_dir:
unzip_dir = Path(temp_dir) / "unpacked"
shutil.unpack_archive(layout.zip_path, unzip_dir)
root_candidates = [p for p in unzip_dir.iterdir() if p.is_dir()]
if not root_candidates:
raise PackagingError("smoke failed: package root directory missing")
pkg_root = root_candidates[0]
required = [
pkg_root / "sprimo-app.exe",
pkg_root / "assets" / "sprite-packs" / "default" / "manifest.json",
pkg_root / "assets" / "sprite-packs" / "default" / "sprite.png",
]
missing = [path for path in required if not path.exists()]
if missing:
joined = ", ".join(str(path) for path in missing)
raise PackagingError(f"smoke failed: expected files missing: {joined}")
print("smoke check passed: package structure is valid")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Sprimo Windows packaging helper")
parser.add_argument("command", choices=["package", "smoke"], help="action to execute")
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
if args.command == "package":
layout = package()
print(f"created: {layout.zip_path}")
print(f"sha256: {layout.checksum_path}")
else:
smoke()
return 0
except PackagingError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

127
scripts/qa_validate.py Normal file
View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Validate issue QA workflow artifacts."""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ISSUES_DIR = ROOT / "issues"
REQUIRED_SECTIONS = (
"Title",
"Severity",
"Environment",
"Summary",
"Reproduction Steps",
"Expected Result",
"Actual Result",
"Root Cause Analysis",
"Fix Plan",
"Implementation Notes",
"Verification",
"Status History",
"Closure",
)
CLOSED_STATES = {"Verification Passed", "Closed"}
def issue_files() -> list[Path]:
return sorted(ISSUES_DIR.glob("issue[0-9]*.md"))
def has_section(content: str, name: str) -> bool:
pattern = rf"(?m)^##\s+{re.escape(name)}\s*$"
return bool(re.search(pattern, content))
def current_status(content: str) -> str | None:
match = re.search(r"(?m)^- Current Status:\s*`?([^`\n]+)`?\s*$", content)
return match.group(1).strip() if match else None
def screenshot_refs(content: str) -> list[str]:
pattern = r"issues/screenshots/[A-Za-z0-9._-]+\.png"
return sorted(set(re.findall(pattern, content)))
def has_before_ref(refs: list[str], issue_num: str) -> bool:
legacy = f"issues/screenshots/issue{issue_num}.png"
return any("-before-" in ref for ref in refs) or legacy in refs
def has_after_ref(refs: list[str]) -> bool:
return any("-after-" in ref for ref in refs)
def command_check_present(content: str) -> bool:
required = (
"`cargo check --workspace`",
"`cargo test --workspace`",
"`just qa-validate`",
)
return all(token in content for token in required)
def verify_issue(path: Path) -> list[str]:
errors: list[str] = []
content = path.read_text(encoding="utf-8")
issue_num_match = re.search(r"issue([0-9]+)\.md$", path.name)
issue_num = issue_num_match.group(1) if issue_num_match else "?"
for section in REQUIRED_SECTIONS:
if not has_section(content, section):
errors.append(f"{path}: missing section '## {section}'")
refs = screenshot_refs(content)
for ref in refs:
if not (ROOT / ref).exists():
errors.append(f"{path}: missing screenshot file: {ref}")
if not has_before_ref(refs, issue_num):
errors.append(
f"{path}: missing before screenshot reference "
"(use issueN-before-...png or legacy issueN.png)"
)
status = current_status(content)
if status in CLOSED_STATES and not has_after_ref(refs):
errors.append(
f"{path}: status '{status}' requires at least one after screenshot reference"
)
if status in CLOSED_STATES and not command_check_present(content):
errors.append(
f"{path}: status '{status}' requires command checklist entries for "
"cargo check, cargo test, and just qa-validate"
)
return errors
def main() -> int:
files = issue_files()
if not files:
print("error: no issue files found under issues/", file=sys.stderr)
return 1
failures: list[str] = []
for path in files:
failures.extend(verify_issue(path))
if failures:
for failure in failures:
print(f"error: {failure}", file=sys.stderr)
return 1
print(f"qa validation passed ({len(files)} issue file(s) checked)")
return 0
if __name__ == "__main__":
raise SystemExit(main())