Skip to content

Commit dcdd78f

Browse files
committed
Add Pages.json
1 parent b8ea069 commit dcdd78f

3 files changed

Lines changed: 222 additions & 0 deletions

File tree

Pages.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[
2+
{
3+
"url": "https://github.com/Core447/sc-pages",
4+
"pages": [
5+
{
6+
"path": "numpad/numpad.scpage",
7+
"commit": "4310c20691f0500a0b78478fda877626ddddf6bc"
8+
}
9+
]
10+
}
11+
]

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,57 @@
11
# StreamController-Plugins
22
This repo contains links to all assets of [StreamController](https://github.com/Core447/StreamController).
3+
4+
## Submitting a page
5+
6+
`Pages.json` does not point at whole repositories like the other files, but at single
7+
`.scpage` files inside them. That way a plugin repository can ship pages for its own
8+
actions without having to be a page repository.
9+
10+
1. Export the page from StreamController: page manager → ⋮ → *Export page*. The resulting
11+
`.scpage` already contains every image the page uses and the list of plugins and icon
12+
packs it needs, so nothing else has to be shipped with it.
13+
2. Commit the `.scpage`, a thumbnail and a manifest to a public repository. The manifest
14+
has to sit next to the page and carry the same name, so `now-playing.scpage` needs a
15+
`now-playing.manifest.json`:
16+
17+
```json
18+
{
19+
"id": "com_core447_MediaPlugin_NowPlaying",
20+
"name": "Now Playing",
21+
"version": "1.0.0",
22+
"thumbnail": "thumbnails/now-playing.png",
23+
"descriptions": { "en_US": "Shows the current track with play/pause and skip." },
24+
"short-descriptions": { "en_US": "Media controls" },
25+
"minimum-app-version": "1.5.0",
26+
"app-version": "1.5.0-beta.16",
27+
"tags": ["media"],
28+
"deck": { "rows": 3, "columns": 5, "dials": 0, "touchscreen": false }
29+
}
30+
```
31+
32+
`id` has to be unique across the whole store - it is what links an installed page back
33+
to its entry here. `thumbnail` is relative to the folder the manifest is in. `deck` is
34+
optional and only shown to the user, pages are never hidden because of it.
35+
3. Add the entry to `Pages.json` and open a pull request:
36+
37+
```json
38+
[
39+
{
40+
"url": "https://github.com/StreamController/MediaPlugin",
41+
"pages": [
42+
{
43+
"path": "store-pages/now-playing.scpage",
44+
"commit": "f4be6cb3db50657b7d4a2f6ed9eecf415c382035"
45+
}
46+
]
47+
}
48+
]
49+
```
50+
51+
Every page is pinned to its own commit, so adding a page does not move the others.
52+
53+
Before opening the pull request you can check your entry with:
54+
55+
```sh
56+
python3 scripts/validate_pages.py
57+
```

scripts/validate_pages.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""
2+
Validates Pages.json.
3+
4+
Unlike the other store files, Pages.json does not point at whole repositories but
5+
at single .scpage files inside them, each pinned to its own commit. Next to every
6+
.scpage there has to be a manifest with the same name, so the store can show the
7+
page without downloading it.
8+
9+
Run from the repository root:
10+
11+
python3 scripts/validate_pages.py
12+
13+
Exits non-zero if anything is wrong.
14+
"""
15+
import json
16+
import re
17+
import sys
18+
import urllib.error
19+
import urllib.request
20+
21+
PAGES_FILE = "Pages.json"
22+
PAGE_EXTENSION = ".scpage"
23+
MANIFEST_SUFFIX = ".manifest.json"
24+
25+
REPO_PATTERN = re.compile(r"^https://github\.com/[^/\s]+/[^/\s]+$")
26+
COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$")
27+
28+
TIMEOUT = 15
29+
USER_AGENT = "StreamController-Store-Validator"
30+
31+
32+
def build_raw_url(repo_url: str, path: str, commit: str) -> str:
33+
repo_url = repo_url.replace("https://github.com/", "https://raw.githubusercontent.com/")
34+
return f"{repo_url}/{commit}/{path}"
35+
36+
37+
def fetch(url: str) -> bytes | None:
38+
"""Returns the content of the url, None if it does not exist."""
39+
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
40+
try:
41+
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
42+
return response.read()
43+
except urllib.error.HTTPError as e:
44+
if e.code == 404:
45+
return None
46+
raise
47+
except urllib.error.URLError as e:
48+
raise RuntimeError(f"Could not reach {url}: {e.reason}") from e
49+
50+
51+
def manifest_path_for(page_path: str) -> str:
52+
return page_path[:-len(PAGE_EXTENSION)] + MANIFEST_SUFFIX
53+
54+
55+
def validate_page(repo_url: str, page: dict, errors: list, seen_ids: dict, seen_paths: dict) -> None:
56+
path = page.get("path")
57+
commit = page.get("commit")
58+
where = f"{repo_url} -> {path}"
59+
60+
if not isinstance(path, str) or not path.endswith(PAGE_EXTENSION):
61+
errors.append(f"{repo_url}: 'path' has to be a {PAGE_EXTENSION} file, got {path!r}")
62+
return
63+
if path.startswith("/") or ".." in path.split("/"):
64+
errors.append(f"{where}: 'path' has to be relative to the repository root")
65+
return
66+
if not isinstance(commit, str) or not COMMIT_PATTERN.match(commit):
67+
errors.append(f"{where}: 'commit' has to be a full 40 character commit hash, got {commit!r}")
68+
return
69+
70+
if (repo_url, path) in seen_paths:
71+
errors.append(f"{where}: listed more than once")
72+
return
73+
seen_paths[(repo_url, path)] = True
74+
75+
if fetch(build_raw_url(repo_url, path, commit)) is None:
76+
errors.append(f"{where}: does not exist at commit {commit}")
77+
78+
manifest_url = build_raw_url(repo_url, manifest_path_for(path), commit)
79+
manifest_content = fetch(manifest_url)
80+
if manifest_content is None:
81+
errors.append(f"{where}: is missing its {manifest_path_for(path)} at commit {commit}")
82+
return
83+
84+
try:
85+
manifest = json.loads(manifest_content)
86+
except json.JSONDecodeError as e:
87+
errors.append(f"{where}: manifest is not valid JSON ({e})")
88+
return
89+
90+
if not isinstance(manifest, dict):
91+
errors.append(f"{where}: manifest has to be an object")
92+
return
93+
94+
for key in ("id", "name"):
95+
if not manifest.get(key):
96+
errors.append(f"{where}: manifest is missing '{key}'")
97+
98+
page_id = manifest.get("id")
99+
if isinstance(page_id, str) and page_id:
100+
if page_id in seen_ids:
101+
errors.append(f"{where}: id '{page_id}' is already used by {seen_ids[page_id]}")
102+
else:
103+
seen_ids[page_id] = where
104+
105+
106+
def validate(json_path: str = PAGES_FILE) -> list:
107+
with open(json_path) as f:
108+
data = json.load(f)
109+
110+
errors = []
111+
if not isinstance(data, list):
112+
return [f"{json_path} has to contain a list"]
113+
114+
seen_ids: dict = {}
115+
seen_paths: dict = {}
116+
117+
for entry in data:
118+
if not isinstance(entry, dict):
119+
errors.append(f"{entry!r} is not an object")
120+
continue
121+
122+
repo_url = entry.get("url")
123+
if not isinstance(repo_url, str) or not REPO_PATTERN.match(repo_url):
124+
errors.append(f"{repo_url!r} is not a https://github.com/<owner>/<repo> url")
125+
continue
126+
127+
pages = entry.get("pages")
128+
if not isinstance(pages, list) or not pages:
129+
errors.append(f"{repo_url}: 'pages' has to be a non empty list")
130+
continue
131+
132+
for page in pages:
133+
if not isinstance(page, dict):
134+
errors.append(f"{repo_url}: {page!r} is not an object")
135+
continue
136+
validate_page(repo_url, page, errors, seen_ids, seen_paths)
137+
138+
return errors
139+
140+
141+
if __name__ == "__main__":
142+
path = sys.argv[1] if len(sys.argv) > 1 else PAGES_FILE
143+
144+
try:
145+
found = validate(path)
146+
except RuntimeError as e:
147+
print(e)
148+
sys.exit(2)
149+
150+
if found:
151+
print(f"{path} is not valid:")
152+
for error in found:
153+
print(f" - {error}")
154+
sys.exit(1)
155+
156+
print(f"{path} is valid")

0 commit comments

Comments
 (0)