Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 46 additions & 24 deletions docs/APP_BUILDER_JSON.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,21 @@ roles:

// ── AppStore listing (used by `czdev publish` + web portal) ──────
"store": {
"summary": "One-line summary", // shown in lists
"description": "Longer detail-page text",// optional; falls back to summary
"categories": ["Games"], // optional; up to a few tags
"screenshots": ["screenshots/main.png"], // >=1 required; 320×170 PNG(s)
"icon": "packaging/icon.png", // optional; square PNG
"license": "MIT", // optional
"source_repo": "https://github.com/you/my_app", // optional
"author": { "github": "you" }, // optional; defaults to uploader
"permissions": [], // optional; declared permissions
"locales": {} // optional; localized title/summary
"summary": "One-line summary", // required; ≤80 chars
"description": "Longer detail-page text", // recommended
"categories": ["Games"], // required; 1–2 from the fixed enum
"screenshots": ["screenshots/main.png"], // required; 1–6 × 320×170 PNG
"icon": "packaging/icon.png", // required; square PNG 128–512 (256 recommended)
"license": "MIT", // required; SPDX id
"source_repo": "https://github.com/you/my_app", // recommended
"author": { "github": "you", "display_name": "Your Name" }, // display_name required
"share_code": "MYAP", // required; 4 alphanumerics, unique store-wide
"permissions": { // required; exactly these 7 booleans
"camera": false, "microphone": false, "imu": false, "network": false,
"additional_hardware": false, "background_service": false,
"external_display": false
},
"locales": {} // recommended; localized title/summary
},

// ── Legacy desktop-emulator fields (optional, no longer consumed) ─
Expand All @@ -62,23 +67,40 @@ roles:

## Store listing — `store`

Read by `czdev publish` and the web portal to build the AppStore page. At
least one 320×170 screenshot is required to publish.
Read by `czdev publish` and the web portal to build the AppStore page. The
store enforces this policy on every submission (czdev pre-checks it locally
with the same script CI runs, so a bad manifest fails before anything is
uploaded):

| Field | Required | Type | Notes |
|---|---|---|---|
| `store.summary` | recommended | string | one-line summary shown in lists |
| `store.description` | no | string | detail-page text; falls back to `summary` |
| `store.categories` | no | string[] | category tags |
| `store.screenshots` | **yes** | string[] | ≥1 path, relative to the app dir; **320×170** PNG(s) |
| `store.icon` | no | string | square PNG path (else the deb's icon is used) |
| `store.license` | no | string | SPDX id, e.g. `MIT` |
| `store.source_repo` | no | string | public source URL |
| `store.author` | no | object | e.g. `{ "github": "you" }`; defaults to the uploader |
| `store.permissions` | no | string[] | declared permissions (metadata) |
| `store.locales` | no | object | localized `title` / `summary` per locale |

The store title comes from the top-level `app_name`.
| `store.summary` | **yes** | string | one-line summary shown in lists; ≤80 chars |
| `store.description` | recommended | string | detail-page text |
| `store.categories` | **yes** | string[] | 1–2 from the fixed enum below |
| `store.screenshots` | **yes** | string[] | 1–6 paths, relative to the app dir; **320×170** PNG(s) |
| `store.icon` | **yes** | string | square PNG path, 128–512 px (256 recommended) |
| `store.license` | **yes** | string | SPDX id, e.g. `MIT` |
| `store.source_repo` | recommended | string | public source URL (http/https) |
| `store.author` | **yes** | object | `{ "github": "you", "display_name": "Your Name" }` — `display_name` is what the store shows |
| `store.share_code` | **yes** | string | 4 letters/digits, unique across the store; users type it to jump to your app |
| `store.permissions` | **yes** | object | exactly 7 booleans: `camera`, `microphone`, `imu`, `network`, `additional_hardware`, `background_service`, `external_display` |
| `store.locales` | recommended | object | localized `title` / `summary` per locale (`zh-CN`, `ja`, …) |

Categories enum: `System Tools`, `Development`, `Hardware & IoT`, `Security`,
`Radio & Comms`, `AI`, `Creative & Office`, `Media`, `Games`, `Emulators`,
`Education`, `Lifestyle`, `Other`.

The store title comes from the top-level `app_name` and must be unique across
the store (case- and whitespace-insensitive).

When updating a published app, fields you leave out keep their published
values (`czdev publish` merges into the existing `meta.json` — the pinned
`uuid` and an already-assigned `share_code` survive automatically).

**First release of a new app** is not auto-merged: after the PR is created,
post a short video of the app running on a real device in the PR comments;
a maintainer merges it after watching. Version updates merge automatically
once validation passes.

## Legacy desktop-emulator fields

Expand Down
144 changes: 115 additions & 29 deletions scripts/czdev/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ def run(deb: Optional[str] = None):
# 5. Check version is newer than existing
check_version_newer(meta)

# 6. Build the meta.json that will be committed and pre-check it against
# the store's own policy script BEFORE uploading anything, so a
# submission the CI would reject fails right here with the same wording.
meta_out = build_merged_meta(store_meta, meta, user)
precheck_store_meta(meta_out, app_dir, store_meta, meta["package"])

# Determine target: direct push or fork
perm = gh.check_permission(TARGET_OWNER, TARGET_REPO)
if perm >= Permission.WRITE:
Expand Down Expand Up @@ -179,23 +185,8 @@ def run(deb: Optional[str] = None):
shutil.copy2(icon_src, dest_dir / icon_src.name)

# meta.json + release manifest (the binary stays in the buffer release).
# Asset paths are rewritten to the pool layout the files were just
# copied into (icon at <pkg-dir>/<basename>, screenshots under
# <pkg-dir>/screenshots/<basename>) — the app-builder.json values are
# source-repo relative and would 404 when the store resolves them
# against pool/main/<pkg>/.
meta_out = dict(store_meta)
if meta_out.get("icon"):
meta_out["icon"] = Path(meta_out["icon"]).name
if meta_out.get("screenshots"):
meta_out["screenshots"] = [f"screenshots/{Path(s).name}" for s in meta_out["screenshots"]]
if not isinstance(meta_out.get("author"), dict) or not meta_out.get("author"):
display_name = meta["maintainer"].split("<")[0].strip()
meta_out["author"] = {
"github": user.login,
"display_name": display_name or user.login,
}
(dest_dir / "meta.json").write_text(json.dumps(meta_out, indent=2, ensure_ascii=False))
# meta_out was built and pre-checked before the upload.
(dest_dir / "meta.json").write_text(json.dumps(meta_out, indent=2, ensure_ascii=False) + "\n")
(dest_dir / manifest_name).write_text(json.dumps(manifest, indent=2) + "\n")

print(" → Creating commit... ", end="", flush=True)
Expand Down Expand Up @@ -284,18 +275,10 @@ def load_store_meta(deb_path: str) -> tuple:
"categories": store.get("categories", []),
"screenshots": store.get("screenshots", []),
}
if store.get("description"):
meta["description"] = store["description"]
if store.get("locales"):
meta["locales"] = store["locales"]
if store.get("license"):
meta["license"] = store["license"]
if store.get("source_repo"):
meta["source_repo"] = store["source_repo"]
if store.get("icon"):
meta["icon"] = store["icon"]
if store.get("permissions"):
meta["permissions"] = store["permissions"]
for key in ("description", "locales", "license", "source_repo",
"icon", "permissions", "share_code"):
if store.get(key):
meta[key] = store[key]
if isinstance(store.get("author"), dict):
meta["author"] = store["author"]

Expand All @@ -311,6 +294,105 @@ def load_store_meta(deb_path: str) -> tuple:
sys.exit(1)


RAW_BASE = f"https://raw.githubusercontent.com/{TARGET_OWNER}/{TARGET_REPO}/main"


def fetch_url(url: str, timeout: int = 15) -> Optional[bytes]:
try:
return urllib.request.urlopen(urllib.request.Request(url), timeout=timeout).read()
except Exception:
return None


def build_merged_meta(store_meta: dict, deb_meta: dict, user) -> dict:
"""The meta.json to publish: the already-published metadata as the base,
the app-builder.json store section on top.

Merging matters: fields the store section does not carry — most notably
the uuid the registry pinned, or a share_code assigned in an earlier
backfill — must survive an update instead of being wiped by a rewrite.
Asset paths are rewritten to the pool layout the files are copied into
(icon at <pkg-dir>/<basename>, screenshots under <pkg-dir>/screenshots/) —
the app-builder.json values are source-repo relative and would 404 when
the store resolves them against pool/main/<pkg>/.
"""
merged = {}
published = fetch_url(f"{RAW_BASE}/pool/main/{deb_meta['package']}/meta.json")
if published:
try:
merged = json.loads(published.decode("utf-8"))
except Exception:
merged = {}

update = dict(store_meta)
if update.get("icon"):
update["icon"] = Path(update["icon"]).name
if update.get("screenshots"):
update["screenshots"] = [f"screenshots/{Path(s).name}" for s in update["screenshots"]]
author = update.pop("author", None)
for k, v in update.items():
if v not in (None, "", [], {}):
merged[k] = v
merged_author = dict(merged.get("author") or {})
if isinstance(author, dict):
merged_author.update({k: v for k, v in author.items() if v})
merged_author["github"] = user.login
if not merged_author.get("display_name"):
display_name = deb_meta["maintainer"].split("<")[0].strip()
merged_author["display_name"] = display_name or user.login
merged["author"] = merged_author
return merged


def precheck_store_meta(meta_out: dict, app_dir: str, store_meta: dict, pkg: str):
"""Run the store's own metadata policy locally before uploading anything.

Uses the exact script CI runs (fetched from the packages repo), staged
against a temporary pool/main/<pkg>/ layout, so the verdict and the wording
match what the PR would get. Uniqueness needs the whole pool and is left
to CI. If the script cannot be fetched (offline), the pre-check is skipped
and CI remains the authority.
"""
script = fetch_url(f"{RAW_BASE}/.github/scripts/store_meta_policy.py")
if not script:
print(" ⚠ could not fetch store_meta_policy.py — skipping local pre-check (CI will validate)")
return

stage = Path(tempfile.mkdtemp(prefix="czdev-precheck-"))
try:
pkg_dir = stage / pkg
(pkg_dir / "screenshots").mkdir(parents=True)
(pkg_dir / "meta.json").write_text(json.dumps(meta_out, ensure_ascii=False))
for shot in store_meta.get("screenshots") or []:
src = Path(app_dir) / shot
if src.is_file():
shutil.copy2(src, pkg_dir / "screenshots" / src.name)
if store_meta.get("icon"):
icon_src = Path(app_dir) / store_meta["icon"]
if icon_src.is_file():
shutil.copy2(icon_src, pkg_dir / icon_src.name)

script_path = stage / "store_meta_policy.py"
script_path.write_bytes(script)
errors_path = stage / "errors.txt"
warnings_path = stage / "warnings.txt"
subprocess.run([sys.executable, str(script_path), pkg, str(pkg_dir), "-",
str(errors_path), str(warnings_path)], check=False)
if warnings_path.is_file():
for line in warnings_path.read_text(encoding="utf-8").splitlines():
print(f" ⚠ {line}")
if errors_path.is_file():
print("ERROR: store metadata does not meet the store policy:", file=sys.stderr)
for line in errors_path.read_text(encoding="utf-8").splitlines():
print(f" ✗ {line}", file=sys.stderr)
print("\n Fix the \"store\" section of app-builder.json (and the referenced", file=sys.stderr)
print(" images), then run `czdev publish` again. Nothing was uploaded.", file=sys.stderr)
sys.exit(1)
print(" ✓ store metadata passes the store policy")
finally:
shutil.rmtree(stage, ignore_errors=True)


# Directories where a *system* systemd unit runs as root by default (user
# units under .../systemd/user/ run as the logged-in user and are not a concern).
_SYSTEMD_SYSTEM_DIRS = ("lib/systemd/system/", "usr/lib/systemd/system/", "etc/systemd/system/")
Expand Down Expand Up @@ -460,6 +542,10 @@ def check_version_newer(meta: dict):
print(f" ✓ {meta['version']} is newer than existing {existing_version}")
else:
print(" ✓ New package (no existing version found)")
print(" NOTE: first releases are reviewed by a human. After the PR is")
print(" created, post a short video of the app running on a real device")
print(" in the PR comments; a maintainer will merge it after watching.")
print(" Later version updates merge automatically.")


def compare_versions(a: str, b: str) -> int:
Expand Down
Loading